diff --git a/docs/research/mixed-team-per-member-runtime-lanes-plan.md b/docs/research/mixed-team-per-member-runtime-lanes-plan.md new file mode 100644 index 00000000..46a6ba01 --- /dev/null +++ b/docs/research/mixed-team-per-member-runtime-lanes-plan.md @@ -0,0 +1,1858 @@ +# Mixed Team Per-Member Runtime Lanes Plan + +**Status**: proposed implementation plan after code-level confidence pass +**Chosen path**: preserve the existing primary team bootstrap lane, add secondary per-member runtime lanes only where the primary lane cannot truthfully own a member, starting with `OpenCode` +**Score**: `🎯 9 πŸ›‘οΈ 9 🧠 8` +**Expected size**: roughly `1700-2900` lines in `claude_team`, plus only small additive follow-up work in `agent_teams_orchestrator` if a provider-specific side-lane seam needs extra metadata + +## Executive Decision + +The first version of mixed teams should **not** try to turn every provider/model difference into its own lane. + +That would be too abstract for the current code and would create avoidable risk. + +Instead, V1 should do this: + +- keep the current **primary team bootstrap lane** as the owner for the lead and for teammates that the existing primary team bootstrap can already launch safely +- introduce **secondary member lanes** only for runtime families that cannot truthfully live on the primary lane +- start with **`OpenCode` as the first secondary-lane provider** +- ship the first mixed rollout for **`Anthropic` / `Codex` / `Gemini` leads with one `OpenCode` teammate** +- explicitly defer **`OpenCode`-led mixed teams** until canonical team ownership through the adapter path is hardened +- keep **secondary lanes single-member in V1** +- allow **at most one `OpenCode` secondary lane/member per team in V1** until provider-local OpenCode runtime stores become lane-scoped +- keep **team-level backend/fast UI** in V1 +- make member-level backend/fast an **additive internal contract** first, not a renderer editing surface +- explicitly **exclude the current one-shot scheduler** from this phase because it is not a team-runtime lifecycle system + +This is more reliable than the earlier generic framing and matches the real code much better. + +### What this feature is really about + +This is **not** a generic rewrite of all mixed-provider behavior. + +The current system already has a working primary-lane notion of mixed teammate `provider/model/effort` for non-`OpenCode` teammates. +The real missing capability is: + +- providers that require a different runtime owner than the primary team bootstrap owner + +So V1 should be implemented as a **side-lane extension**, not as a universal per-provider decomposition rewrite. + +## Why The Earlier Draft Needed Tightening + +The earlier draft had three low-confidence assumptions that were too loose: + +1. It treated all mixed-provider differences as if they needed lane separation. +2. It implied schedule parity through the same coordinator, but the current scheduler does not launch persistent teams. +3. It talked about replacing `team -> run` maps wholesale, while a safer path is to keep the primary run maps and add side-lane tracking additively. + +After checking the code, those assumptions should be corrected. + +## What The Current Code Actually Proves + +### 1. The primary team bootstrap path already supports member-level provider/model/effort + +`TeamProvisioningService` already materializes member-level runtime intent through: + +- `materializeEffectiveTeamMemberSpecs(...)` +- `resolveAndValidateLaunchIdentity(...)` +- deterministic team bootstrap inputs built from `effectiveMemberSpecs` + +That means the primary lane is already more capable than a purely lead-only launch contract. + +This is important because it means we should preserve and wrap that path, not bypass it. + +### 2. The runtime adapter registry is currently provider-specific and effectively `OpenCode`-specific + +The runtime adapter layer in: + +- `src/main/services/team/runtime/TeamRuntimeAdapter.ts` +- `src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts` + +is not a generic multi-provider lane system today. + +In practice, this means: + +- `Anthropic`, `Codex`, and `Gemini` still live on the primary team bootstrap path +- `OpenCode` is the provider that currently forces an alternate launch owner + +That is the real reason `vector-room-7` broke. + +### 3. Current live tracking is fundamentally primary-team-scoped + +`TeamProvisioningService` still maintains: + +- `provisioningRunByTeam` +- `aliveRunByTeam` +- `runtimeAdapterRunByTeam` + +These encode a strong `team -> run` assumption. + +Replacing them all in one shot would be high risk. +The safer path is: + +- keep them as the **primary lane truth** +- add **secondary lane tracking** next to them + +### 4. `team.meta.json` is already lead-level launch truth + +`TeamMetaStore` already persists: + +- `providerId` +- `providerBackendId` +- `model` +- `effort` +- `fastMode` +- `launchIdentity` + +This makes `team.meta.json` the natural **lead-level desired and resolved runtime source**. + +### 5. `members.meta.json` is still missing the runtime fields needed for mixed-lane truth + +`TeamMembersMetaStore` currently persists `TeamMember[]`, which already carries: + +- `providerId` +- `model` +- `effort` + +but not: + +- `providerBackendId` +- `fastMode` + +So the current member metadata is not rich enough to reconstruct mixed-lane runtime identity deterministically. + +### 6. The current scheduler is not a team-runtime lifecycle engine + +`ScheduledTaskExecutor` is a one-shot CLI runner: + +- it spawns `claude -p` +- it uses `ScheduleLaunchConfig` +- it validates fast mode and provider env +- it does **not** create or reconcile a persistent team with member lanes + +Therefore the current scheduler must be treated as **out of scope** for mixed-team lane rollout. +The earlier draft was too optimistic there. + +### 7. Backup already includes the right metadata files + +`TeamBackupService` already backs up: + +- `config.json` +- `team.meta.json` +- `members.meta.json` + +This is good news: additive mixed-lane persistence does not require a separate backup architecture. + +What it does require is: + +- additive field compatibility +- restore/reconcile tests proving those new fields survive backup/restore intact + +### 8. The current `OpenCode` adapter flattens failures across its own `expectedMembers` + +`OpenCodeTeamRuntimeAdapter` maps blocked launch and readiness failures across every member in its `input.expectedMembers`. + +This means the coordinator must treat adapter input subsets as a hard invariant: + +- if the coordinator passes unrelated members into a side-lane adapter input, the adapter will correctly flatten that failure across all of them +- therefore adapter-local `expectedMembers` must contain only lane-owned members + +This is not just an optimization. It is required for truthful failure attribution. + +### 9. The primary lane already supports member-level `provider/model/effort` + +This is an important positive fact from the code: + +- teammate spawn prompts include provider/model/effort overrides +- `buildAgentToolArgsSuffix(...)` passes `provider`, `model`, and `effort` into persistent teammate spawn requests +- relaunch reconstruction already restores member `providerId/model/effort` from `members.meta.json` + +So `Anthropic` / `Codex` / `Gemini` variation inside the primary lane is not hypothetical. The current primary bootstrap already has a real member-level provider/model/effort seam. + +That is why V1 should preserve those members on the primary lane instead of over-normalizing them into new side lanes. + +### 10. The primary lane does not yet honor member-level `providerBackendId/fastMode` + +The current teammate spawn seam for the primary lane does **not** pass member-level: + +- `providerBackendId` +- `fastMode` + +through the Agent-team spawn contract. + +That means the plan must be explicit: + +- primary-lane teammates in V1 continue to inherit backend/fast behavior from the lead-side launch/runtime owner +- member-level backend/fast additions are additive persistence contracts first +- side-lane owners may consume those fields earlier than the primary lane + +Without this clarification, the plan would over-promise primary-lane capability that the code does not currently have. + +### 11. `restoreTeam(...)` is not backup restoration + +There is another place where terminology can easily mislead implementation: + +- `restoreTeam(...)` in `TeamDataService` simply undeletes `config.deletedAt` +- startup backup restoration is handled separately by `TeamBackupService.restoreIfNeeded()` + +So any mixed-lane "restore" design must be explicit about which path it means: + +- undelete +- startup recovery from persisted files +- backup restoration after source disappearance + +### 12. `members.meta.json` already contains a root `providerBackendId`, but it is not member truth + +`TeamMembersMetaStore` already persists a file-level: + +- `providerBackendId` + +at the root of `members.meta.json`. + +That is useful as compatibility metadata, but it is **not** member-scoped runtime truth. + +So V1 must be explicit: + +- keep the existing root field for backward compatibility +- do not reinterpret that root field as if it were per-member backend truth +- add member-level backend/fast fields separately + +### 13. `config.json` is intentionally normalized to lead-only before launch + +`normalizeTeamConfigForLaunch(...)` removes teammates from `config.json` before the primary CLI launch, and `assertConfigLeadOnlyForLaunch(...)` enforces that invariant. + +This is a critical architectural fact: + +- `config.json` is not a reliable full-roster artifact during launch +- mixed-lane design must not rely on `config.json` as canonical teammate truth +- full desired roster must live in `members.meta.json`, not in the launch-time `config.json` + +### 14. Live `addMember` and `restartMember` currently go through the lead-side Agent tool seam + +`buildAddMemberSpawnMessage(...)` and `buildRestartMemberSpawnMessage(...)` tell the lead to spawn teammates through the Agent tool with: + +- `provider` +- `model` +- `effort` + +and no member-level: + +- `providerBackendId` +- `fastMode` +- lane ownership metadata + +That means the current add/restart seam is only safe for primary-lane teammates. +It cannot be reused unchanged for `OpenCode` or any future secondary-lane member. + +### 15. Launch roster fallbacks are degraded for mixed-lane truth + +`resolveLaunchExpectedMembers(...)` falls back in this order: + +1. `members.meta.json` +2. inboxes merged with `config.json` provider/model/effort overrides +3. `config.json` fallback + +Only the first path can become authoritative mixed-lane truth. +The inbox/config fallbacks recover at most: + +- name +- role +- workflow +- isolation +- provider/model/effort + +They do **not** recover: + +- member-level backend +- member-level fast mode +- lane attribution +- resolved member launch identity + +So mixed-lane relaunch/startup recovery must treat those fallbacks as degraded and non-authoritative. + +### 16. Primary launch env and provider args are resolved once at the root request + +The current create/launch flow calls `buildProvisioningEnv(request.providerId, request.providerBackendId)` once for the primary request and then carries: + +- `shellEnv` +- `providerArgs` +- auth/runtime warnings + +into the primary spawn. + +That means secondary lanes must not inherit primary launch env blindly. +Each secondary lane needs its own provider-aware env/auth/provider-args resolution. + +### 17. `TeamMemberResolver` and `TeamMemberSnapshot` are not mixed-lane-aware today + +Current team-view membership and runtime labels are still resolved mainly from: + +- `config.members` +- `members.meta.json` +- inbox names + +and `TeamMemberSnapshot` currently carries: + +- `providerId` +- `model` +- `effort` + +but not: + +- `providerBackendId` +- `selectedFastMode` +- `resolvedFastMode` +- `laneId` +- `laneKind` + +So renderer truth will diverge from runtime truth unless these contracts are upgraded explicitly. + +### 18. `launch-state.json` normalization will drop new mixed-lane fields unless upgraded + +`normalizePersistedLaunchSnapshot(...)` and `normalizePersistedMemberState(...)` rebuild the persisted launch-state shape field-by-field. + +That means additive mixed-lane fields are **not** automatically safe just because they were written once. + +Any mixed-lane schema rollout must therefore include: + +- shared type updates +- launch-state write-path updates +- launch-state normalize/read-path updates + +Otherwise read-normalize-write cycles will silently erase the new fields. + +### 19. `expectedMembers` currently means the full expected teammate roster across many callers + +Today the codebase uses `expectedMembers` as a durable "all expected teammates" field in: + +- launch-state summaries +- bootstrap recovery +- provisioning progress/presentation +- Team list/detail launch badges + +This is **not** a private primary-lane-only field. + +So mixed-lane rollout must not silently repurpose existing `expectedMembers` to mean "primary bootstrap subset". + +If V1 needs both concepts, it must add them explicitly, for example: + +- `expectedMembers` as full mixed-team desired roster +- `primaryExpectedMembers` or `bootstrapExpectedMembers` as the primary-lane subset + +instead of redefining the old field in place. + +### 20. `choosePreferredLaunchSnapshot(...)` currently prefers by timestamp only + +`TeamConfigReader` and other readers currently pick between bootstrap and persisted launch snapshots using only `updatedAt`. + +That is safe enough today because the snapshots are structurally similar. +It becomes unsafe for mixed teams if: + +- bootstrap snapshot is structurally poorer and only knows about the primary lane +- persisted launch-state is structurally richer and includes lane-aware mixed truth + +In that world, a newer bootstrap snapshot could silently clobber richer mixed truth. + +So the mixed plan must include an explicit precedence rule: + +- richer mixed-lane persisted launch-state must not be overridden by a structurally poorer bootstrap snapshot just because it is newer + +### 21. Team summary readers currently have a much smaller launch-state size budget than the main store + +Today: + +- `TeamLaunchStateStore` reads up to `256 KB` +- `TeamConfigReader` summary path reads only up to `32 KB` +- `team-fs-worker` summary path also reads only up to `32 KB` + +So a richer mixed-lane `launch-state.json` can become perfectly valid for the main runtime path while silently disappearing from Team list / dashboard summary readers. + +The plan therefore needs an explicit size-budget rule for mixed-lane persistence. + +### 22. Team summary logic is duplicated between main-thread and fs-worker paths + +Mixed launch-state summary behavior is not centralized today: + +- `TeamConfigReader` has its own launch-state summary path and bootstrap snapshot precedence logic +- `team-fs-worker` has a separate launch-state summary implementation + +They are already not perfectly symmetric, and mixed-lane schema/precedence changes would widen that drift unless the plan explicitly treats them as a parity surface. + +### 23. Post-launch `config.json` runtime projection is currently lossy + +`applyEffectiveLaunchStateToConfig(...)` projects runtime state back into `config.members`, but only for: + +- `providerId` +- `model` +- `effort` + +It does **not** project: + +- member backend +- fast mode +- lane ownership +- resolved launch identity + +So post-launch `config.json` is not just non-canonical in principle - it is concretely a lossy projection today. + +Mixed rollout must treat it only as a compatibility-facing artifact, never as authoritative runtime reconstruction input. + +### 24. Live roster mutation on running teams is still lead-message-driven and not mixed-safe + +Today, when a team is alive: + +- `addMember` persists metadata, then tells the lead to spawn the teammate +- `replaceMembers` diffs the roster, sends lead spawn messages for added members, then sends a summary message +- `removeMember` marks metadata and sends a generic lead message + +That means live roster mutation is fundamentally coupled to: + +- lead-owned teammate spawning +- lead-side interpretation of roster changes +- no side-lane ownership contract + +So V1 should not treat only "lane migration" as unsafe. +The safer rule is broader: live roster mutation of a running mixed team is out of scope. + +### 25. `OpenCode` bridge launch is still single-model and team-scoped + +`OpenCodeLaunchTeamCommandBody` currently carries: + +- one `selectedModel` +- one `teamName` +- one `runId` + +for the whole `OpenCode` launch command. + +That is a hard seam, not just an implementation detail. + +So even before considering runtime stores, the current state-changing bridge contract is not yet a natural fit for: + +- multiple independent `OpenCode` side lanes inside one mixed team +- different `OpenCode` side-lane members choosing different raw models concurrently + +### 26. `OpenCode` provider-local runtime stores are team-scoped, not lane-scoped + +The current provider-local runtime store namespace lives under: + +- `/.opencode-runtime/manifest.json` +- `/.opencode-runtime/launch-state.json` +- `/.opencode-runtime/opencode-launch-transaction.json` +- `/.opencode-runtime/opencode-permissions.json` + +And those stores currently encode: + +- one `activeRunId` per team manifest +- one `activeCapabilitySnapshotId` per team manifest +- one active `OpenCode` launch transaction per team + +This means the provider-local `OpenCode` state layer is still **team-scoped single-run state**, not a lane namespace that can safely host multiple concurrent `OpenCode` side lanes for the same logical team. + +So V1 must be explicit: + +- keep global mixed-team `launch-state.json` separate from provider-local `.opencode-runtime/launch-state.json` +- support **at most one `OpenCode` secondary lane/member per team** +- defer multi-`OpenCode` mixed teams until the provider-local runtime store namespace is lane-scoped + +## The Real Problem We Need To Solve + +`vector-room-7` failed because runtime ownership answered the wrong question. + +Today the system effectively asks: + +- "does this team contain OpenCode anywhere?" + +But it should ask: + +- "which runtime owner is allowed to launch each member?" + +That distinction matters because: + +- the lead and same-family teammates may still be valid on the primary lane +- a specific member such as an `OpenCode` teammate may require a side lane +- a side-lane failure must stay attributed to that member or lane only + +## Goals + +- Support mixed teams inside one logical team without splitting into fake subteams. +- Keep one lead and one canonical team identity. +- Make runtime ownership member-scoped where needed. +- Preserve the current primary team bootstrap path for the cases it already handles well. +- Add secondary lanes only where truth requires it. +- Make relaunch and restore deterministic. +- Keep renderer truth honest for partial success and partial failure. +- Roll out additively and safely. + +## Non-Goals + +- No hidden split-team illusion under the hood. +- No big-bang rewrite of every provider into lane adapters. +- No immediate per-member backend/fast editing UI. +- No claim that the current one-shot schedule system becomes mixed-team aware in this phase. +- No optimistic multi-member secondary lane grouping in V1. + +## V1 Scope Decision + +### βœ… In scope + +- primary lane + secondary side-lane coordinator +- `OpenCode` as the first secondary-lane provider +- mixed teams where the lead remains on the existing primary team bootstrap path +- exactly one `OpenCode` secondary teammate per mixed team +- member-level lane attribution and persistence +- mixed create/launch/relaunch/restore/stop/reconcile for persistent teams +- truthful renderer diagnostics + +### ❌ Out of scope for V1 + +- schedule parity through the current `ScheduledTaskExecutor` +- per-member backend/fast editing controls in the dialogs +- multi-member secondary lane grouping +- more than one `OpenCode` teammate inside the same mixed team +- converting `Codex` or `Anthropic` into side-lane providers unless a future runtime seam truly requires it +- `OpenCode`-led mixed teams where the canonical team owner would need to move onto the adapter path + +## Chosen Runtime Ownership Model + +## Two Kinds Of Lanes + +### 1. Primary lane + +The primary lane is the existing deterministic team bootstrap lane owned by the main team process. + +It remains responsible for: + +- the lead +- canonical team prompt/session semantics +- teammates that the current primary bootstrap owner can still launch truthfully + +This is not a new idea. It is the current working system, formalized as a lane. + +### 2. Secondary lanes + +A secondary lane is a side runtime owner for a member that cannot be truthfully launched by the primary lane. + +In V1: + +- secondary lanes are **single-member only** +- the first supported owner is `OpenCode` +- a secondary lane never becomes the canonical team owner + +This keeps the model simple and reliable. + +## Compatibility Rule + +The compatibility question in V1 is **not**: + +- "same provider?" +- "same model?" +- "same effort?" + +The real question is: + +- "is this member allowed to remain owned by the current primary team bootstrap lane?" + +### V1 compatibility policy + +A member stays on the primary lane when: + +- their provider/runtime family is supported by the primary team bootstrap owner +- no runtime family declares that the member must be isolated into a secondary lane + +A member moves to a secondary lane when: + +- their runtime family explicitly requires a separate runtime owner + +### Initial concrete policy + +In V1: + +- `anthropic` -> primary-lane eligible +- `codex` -> primary-lane eligible +- `gemini` -> primary-lane eligible +- `opencode` -> secondary-lane required + +This is intentionally specific because that is what the code currently supports. + +### Lead policy in V1 + +For the first shipping phase: + +- mixed teams are supported when the lead is `anthropic`, `codex`, or `gemini` +- pure `opencode` teams continue to use the existing runtime adapter path +- `opencode`-led mixed teams are deferred + +This keeps canonical team ownership on the already-proven primary bootstrap path for V1 mixed support. + +## Why We Are Not Using β€œAny Difference = New Lane” + +That approach looks elegant on paper but is the wrong tradeoff here. + +It would force us to: + +- duplicate or replace working bootstrap behavior for `Codex` / `Anthropic` / `Gemini` +- redesign the renderer editor prematurely +- massively widen relaunch and restore risk + +The better architecture is: + +- generic coordinator +- specific V1 ownership policy +- a separate later phase for adapter-owned mixed primary lanes if we need them + +That gives us scalability later without pretending the current code is more symmetric than it really is. + +## Source Of Truth Matrix + +The most important plan improvement is to separate **desired** truth from **resolved** truth. + +| Artifact | Responsibility in V1 | Source kind | +| --- | --- | --- | +| `team.meta.json` | Lead-level desired runtime request and resolved lead launch identity | desired + resolved | +| `members.meta.json` | Member desired runtime contract overrides | desired | +| `launch-state.json` | Per-member resolved launch outcome, lane assignment, resolved launch identity | resolved | +| in-memory run maps | Live lane ownership and stop/reconcile handles | live runtime only | + +### Rules + +- `team.meta.json` is the source of truth for the lead request +- `members.meta.json` is the source of truth for member overrides +- `launch-state.json` is the source of truth for the most recent resolved lane/member execution +- live maps must never become the only source of member lane ownership +- `config.json` is not canonical teammate-roster truth during launch because it is intentionally normalized to lead-only +- post-launch `config.json` runtime projection is lossy and compatibility-facing only +- provider-local `OpenCode` runtime stores under `.opencode-runtime/*` are a separate provider-owned namespace, not the canonical mixed-team snapshot +- if a compact launch summary projection is introduced for Team list / dashboard, it must be derived from the same canonical snapshot precedence rules as the main runtime path +- the root `members.meta.json.providerBackendId` remains compatibility metadata, not member-scoped runtime truth +- team-view/runtime labels must eventually be derived from lane-aware snapshot truth, not only from `config + meta + inboxes` +- existing `launch-state.expectedMembers` semantics must remain explicit and backward-safe; do not silently redefine it to mean primary-only subset + +## Contracts To Add Or Tighten + +## 1. `TeamProvisioningMemberInput` + +Extend `src/shared/types/team.ts`: + +```ts +export interface TeamProvisioningMemberInput { + name: string + role?: string + workflow?: string + isolation?: 'worktree' + providerId?: TeamProviderId + providerBackendId?: TeamProviderBackendId + model?: string + effort?: EffortLevel + fastMode?: TeamFastMode +} +``` + +Reason: + +- member desired runtime contract must be able to express full future lane identity +- even if the renderer does not expose backend/fast editors in V1, the contract must exist additively + +### V1 semantic rule + +In the shipping V1 scope: + +- member `providerId/model/effort` are meaningful for both primary-lane and secondary-lane members +- member `providerBackendId/fastMode` are meaningful for secondary-lane planning and future compatibility +- primary-lane teammates still inherit effective backend/fast behavior from the lead/runtime owner + +## 2. `TeamMember` + +Extend `TeamMember` additively with: + +- `providerBackendId?: TeamProviderBackendId` +- `fastMode?: TeamFastMode` + +### Guardrail + +`config.json` writers must **not** start depending on those fields. +They may remain omitted in canonical CLI-facing config writes until the CLI actually needs them. + +This lets `members.meta.json` evolve without silently changing `config.json` semantics. + +## 3. `TeamMemberSnapshot` + +Extend renderer-facing member truth with: + +- `providerBackendId?: TeamProviderBackendId` +- `selectedFastMode?: TeamFastMode` +- `resolvedFastMode?: boolean` +- `laneId?: string` +- `laneKind?: 'primary' | 'secondary'` + +This is needed so Team Detail can show truthful member runtime identity without flattening everything into team-level labels. + +### Guardrail + +This is not optional polish. + +The mixed feature must stay dark until `TeamMemberSnapshot` and the resolver path can consume lane-aware truth. Otherwise runtime behavior will be correct while Team Detail still lies. + +## 4. `PersistedTeamLaunchMemberState` + +Additive fields required: + +- `providerId?: TeamProviderId` +- `providerBackendId?: TeamProviderBackendId` +- `launchIdentity?: ProviderModelLaunchIdentity` +- `laneId?: string` +- `laneKind?: 'primary' | 'secondary'` +- `laneOwnerProviderId?: TeamProviderId` + +This is the minimum needed for deterministic relaunch and accurate diagnostics. + +### Guardrail + +Because `normalizePersistedLaunchSnapshot(...)` rebuilds the persisted structure explicitly, this schema work is incomplete until the normalizer also roundtrips these additive fields safely. + +## 5. `TeamAgentRuntimeEntry` + +Extend live runtime snapshot entries with: + +- `providerId?: TeamProviderId` +- `providerBackendId?: TeamProviderBackendId` +- `laneId?: string` +- `laneKind?: 'primary' | 'secondary'` + +This is a live presentation contract only. +It does not replace persisted truth. + +## 6. `MemberDraft` + +Do **not** expose member-level backend/fast in the UI in V1. + +But for future-proofing, the internal draft type may be extended additively later if needed. + +In V1: + +- renderer editing remains `providerId/model/effort` +- backend/fast remain inherited internal fields + +That is the safer scope boundary. + +## Domain Model + +## Desired member contract + +```ts +type DesiredTeamMemberRuntimeContract = { + memberName: string + providerId: TeamProviderId + providerBackendId: TeamProviderBackendId | null + model: string | null + effort: EffortLevel | null + fastMode: TeamFastMode | null + cwd: string +} +``` + +This is reconstructed from: + +- `team.meta.json` lead defaults +- `members.meta.json` member overrides + +## Resolved member runtime identity + +```ts +type ResolvedTeamMemberRuntimeIdentity = { + memberName: string + role: 'lead' | 'member' + desired: DesiredTeamMemberRuntimeContract + launchIdentity: ProviderModelLaunchIdentity + laneRequirement: 'primary-eligible' | 'secondary-required' +} +``` + +This is computed after provider-aware resolution. + +## Lane plan + +```ts +type TeamRuntimeLanePlan = { + laneId: string + laneKind: 'primary' | 'secondary' + ownerProviderId: TeamProviderId + memberNames: string[] + launchMode: 'existing-primary-bootstrap' | 'runtime-adapter' +} +``` + +### Important V1 simplification + +Secondary lanes are single-member only, so: + +- `laneKind === 'secondary'` always implies `memberNames.length === 1` +- in the shipping V1 scope, only one secondary `OpenCode` lane may exist for a team +- if a mixed draft includes more than one `OpenCode` teammate, the planner must reject it explicitly instead of silently grouping or serializing them + +This is deliberate and should be written into the code as a policy, not left as an implicit side effect. + +## Runtime Ownership Architecture + +## New coordinator + +Introduce a feature-owned coordinator: + +`src/features/team-runtime-lanes/main/composition/createTeamRuntimeLaneCoordinator.ts` + +Suggested facade: + +`TeamRuntimeLaneCoordinator` + +### Responsibilities + +- read desired lead/member runtime contracts +- resolve member runtime identity +- partition members into primary vs secondary lanes +- launch the primary lane through the existing `TeamProvisioningService` path +- launch each secondary lane through an adapter owner +- merge lane results back into one aggregate team snapshot +- stop and reconcile all lanes + +### Hard rules + +- adapters never get unrelated members +- a secondary-lane failure cannot overwrite primary-lane member states +- in the shipping V1 mixed scope, the lead remains owned by the existing primary bootstrap lane + +## Why the coordinator must preserve the primary path + +The current primary path already owns: + +- deterministic bootstrap +- lead inbox/session continuity +- much of the team lifecycle + +So the coordinator should wrap it, not replace it. + +That means the architecture is: + +- existing primary provisioning path remains the canonical team owner +- secondary lanes are additive sidecars + +This is safer than trying to make every provider look like a fully independent team owner. + +## In-Memory Tracking Model + +The earlier draft said to replace `team -> run` maps. +That is too broad for V1. + +## Safer V1 tracking decision + +Keep: + +- `provisioningRunByTeam` +- `aliveRunByTeam` + +as the primary-lane run markers. + +Add: + +```ts +type SecondaryLaneRunRecord = { + laneId: string + providerId: TeamProviderId + memberName: string + runId: string + cwd?: string +} +``` + +and: + +```ts +private readonly secondaryLaneRunsByTeam = new Map>() +``` + +Optionally also add: + +```ts +private readonly secondaryLaneProgressByRunId = new Map() +``` + +### Why this is safer + +- most of the codebase already assumes one primary team run +- mixed lanes only need additive side-lane tracking +- stop/reconcile/restore can aggregate over the new side map without destabilizing every existing caller + +## Create And Launch Flow + +## Critical bootstrap invariant + +The current primary bootstrap flow explicitly embeds the teammate roster into: + +- deterministic bootstrap spec `members` +- primary run `expectedMembers` +- primary-lane member spawn tracking + +So a mixed-lane implementation must not treat those structures as the full mixed-team roster. + +🚨 V1 rule: + +- secondary-lane members must be excluded from the primary bootstrap spec +- secondary-lane members must be excluded from the primary in-memory `expectedMembers` +- aggregate mixed-team progress must track the full desired roster separately + +Otherwise the primary lane will wait forever for members that are intentionally being launched elsewhere. + +## Step 1 - persist desired contracts + +Before runtime launch: + +- write `team.meta.json` with lead request and lead launch identity seed +- write `members.meta.json` with full roster and member desired overrides + +### Rule + +Canonical team artifacts must always describe the full roster. +No provider-specific adapter is allowed to "own" who belongs in the roster. + +### Additional rule + +That full roster must be persisted in: + +- `members.meta.json` +- mixed-lane-aware `launch-state.json` + +and **not** inferred from launch-time `config.json`, because the current launch flow intentionally strips teammates out of `config.json` before spawning the lead CLI. + +## Step 2 - resolve member runtime identity + +Resolve: + +- lead desired contract +- each member desired contract +- resolved runtime identity for each member + +This uses the same provider-aware resolution services already used for team launch, but now produces member-scoped output. + +## Step 3 - partition lanes + +Partition members into: + +- primary-lane members +- secondary-lane members + +### V1 partition policy + +- lead is always primary +- primary-eligible members stay primary +- `OpenCode` members become one-member secondary lanes + +### Bootstrap subset rule + +After partitioning: + +- `primaryBootstrapMembers = lead + primary-eligible teammates` +- `secondaryLaneMembers = secondary-required teammates` + +Only `primaryBootstrapMembers` may feed: + +- `buildDeterministicCreateBootstrapSpec(...)` +- `buildDeterministicLaunchBootstrapSpec(...)` +- primary-lane `expectedMembers` +- primary-lane `memberSpawnStatuses` + +## Step 4 - launch primary lane first + +Use the existing primary team bootstrap flow for: + +- lead +- all primary-lane teammates + +This preserves current session and deterministic bootstrap assumptions. + +## Step 5 - launch secondary lanes after primary readiness + +Once the primary lane reaches the point where it can truthfully own the team context: + +- launch secondary lanes +- one side lane per member in V1 + +### Concurrency + +Allow bounded parallelism only if provider-specific side-lane launches are proven safe. +Default to conservative small concurrency. + +### Join semantics + +Secondary lanes must behave like post-bootstrap teammate attachments, not hidden members inside the original primary bootstrap transaction. + +That means: + +- the primary lane becomes ready against its own subset +- secondary lanes attach afterward +- mixed-team aggregate progress remains launching until both: + - the primary lane is ready enough + - every planned secondary lane has either joined or failed + +## Explicit V1 mixed-team shipping matrix + +### Supported in V1 + +- `Anthropic lead + one OpenCode teammate` +- `Codex lead + one OpenCode teammate` +- `Gemini lead + one OpenCode teammate` +- all existing pure non-OpenCode teams +- all existing pure OpenCode teams + +### Deferred after V1 + +- more than one `OpenCode` teammate inside the same mixed team +- `OpenCode lead + Codex teammate` +- `OpenCode lead + Anthropic teammate` +- `OpenCode lead + Gemini teammate` + +Those cases require canonical team ownership on the adapter path and should be treated as a separate risk class. + +## Step 6 - merge lane outcomes + +Merge: + +- primary lane member evidence +- secondary lane member evidence + +into one `launch-state.json` snapshot. + +### Guardrail + +Aggregate team state must be derived from member states, never the other way around. + +### Aggregate roster truth + +The mixed-team aggregate snapshot should distinguish: + +- full desired roster for the whole launch +- primary bootstrap expected members only + +Without that distinction, UI and restore logic will confuse: + +- "primary bootstrap is still waiting" +with +- "a side lane has not attached yet" + +### Backward-safe field rule + +Because current readers and UI already treat `expectedMembers` as the full expected teammate roster, the mixed schema should add a new explicit field for the primary subset rather than silently changing the meaning of `expectedMembers`. + +## Relaunch And Restore + +This remains one of the highest-risk parts. + +## Relaunch source rules + +When relaunching: + +1. desired contract comes from `members.meta.json` + lead-level `team.meta.json` +2. previous resolved member/lane truth comes from `launch-state.json` +3. current global provider settings are fallback only when stored team/member truth is absent + +### Why this matters + +The current relaunch flow: + +- stops the team +- replaces members +- launches again + +So if we do not persist member lane truth explicitly, relaunch will drift back toward team-level assumptions. + +## Restore rules + +On restore or startup reconciliation: + +- restore primary lane from primary persisted state +- restore or reconcile secondary lanes from per-member lane attribution in `launch-state.json` +- if a secondary lane cannot be restored, only that member is degraded or failed + +### Snapshot precedence rule + +If both bootstrap-derived state and persisted launch-state exist: + +- do not choose only by `updatedAt` +- prefer the snapshot that preserves mixed-lane structural truth +- a newer but structurally poorer bootstrap snapshot must not overwrite richer lane-aware launch-state + +### Explicit non-goal + +Do not reinterpret a previously mixed team as a pure team just because side-lane state is missing. + +## Stop And Cleanup + +## Ordering decision + +For stop/relaunch: + +1. stop secondary lanes first +2. stop primary lane second + +### Why this order + +- prevents side-lane members from being left attached to a dead team owner +- reduces orphaned-runtime risk + +## Cleanup obligations + +`stopTeam(...)` must: + +- stop all secondary lanes +- stop the primary lane +- clear additive side-lane tracking maps +- persist a reconciled launch snapshot + +No stop path may claim success while a side-lane member is still live. + +## Live Edit And Add-Member Boundary + +This is another high-risk area that the earlier draft was too soft about. + +Today: + +- `Edit Team` uses `replaceMembers(...)` +- runtime-affecting edits on a live team rely on `restartMember(...)` +- `Add member` is a post-bootstrap attachment flow + +Those semantics are not equivalent for mixed lanes. + +### V1 policy + +#### Allowed + +- fresh mixed create +- mixed relaunch +- mixed stop/start + +#### Explicitly blocked + +- live `addMember` on a running mixed team in V1 +- live `replaceMembers` on a running mixed team in V1 +- live `removeMember` on a running mixed team in V1 +- live editing an existing member from primary-lane ownership to secondary-lane ownership +- live editing an existing member from secondary-lane ownership back to primary-lane ownership +- treating a lane migration as a simple restart +- using the current lead Agent tool restart flow as if it were a safe secondary-lane restart/migration path + +### Why this is the right boundary + +`addMember(...)` is already conceptually a post-bootstrap attachment flow. +`restartMember(...)` is not. It assumes the same runtime ownership model still applies. + +And for secondary lanes there is one more hard constraint: + +- the current add/restart seam is lead-owned Agent tool spawning with only provider/model/effort overrides +- that seam is insufficient for side-lane runtime owners like `OpenCode` + +So the safer V1 boundary is: + +- ship fresh launch / relaunch / stop / startup recovery for mixed teams +- defer live roster mutation of running mixed teams +- defer live side-lane add-member and live lane migration to a later phase + +## Renderer Truth + +## V1 renderer scope + +Do not redesign the team editor UI. + +Do: + +- show lead and members with truthful runtime identity +- show that some members are on secondary lanes when useful +- keep partial-failure attribution member-scoped + +### Shipping rule + +Mixed-team execution must not be enabled for real users until `TeamMemberSnapshot` and `TeamMemberResolver` can surface lane-aware truth. +Without that, runtime behavior and Team Detail diverge immediately. + +## Required visible truth + +- lead row must always exist +- member rows must not inherit another member's failure +- provider/runtime labels must be per member, not flattened from one team-wide owner + +## Current lead bug tie-in + +The `vector-room-7` missing lead issue proves one more rule: + +- lead visibility must not depend on provider-specific config writing quirks + +The canonical roster must remain team-owned, not adapter-owned. + +## Scheduler Boundary + +This part needed the strongest correction. + +## Current scheduler reality + +`ScheduledTaskExecutor` is a one-shot CLI job runner. +It is not a persistent team-runtime lifecycle owner. + +Therefore this feature should **not** claim: + +- mixed-team lane execution through the current schedule system +- schedule parity with the persistent mixed-team coordinator + +## V1 schedule policy + +- existing `ScheduleLaunchConfig` remains lead-level only +- current schedules remain independent one-shot prompt runs +- mixed-team lane coordination is explicitly out of scope for this feature + +### Terminology clarification + +This repository currently has multiple different "restore" concepts: + +- `restoreTeam(...)` in `TeamDataService` is undelete of `config.deletedAt` +- `TeamBackupService.restoreIfNeeded()` is startup backup restoration +- startup reconciliation also reads persisted launch state + +This feature plan uses: + +- **relaunch** for stop + launch again with persisted team metadata +- **startup recovery** for recovering mixed-lane runtime truth from persisted files after app/runtime interruption + +Keeping those concepts separate will reduce implementation mistakes. + +## Future extension + +If later the product adds a true "scheduled team relaunch / scheduled team run" feature, that feature must call the same `TeamRuntimeLaneCoordinator`. + +But that is a different system than the current scheduler. + +## Diagnostics And Observability + +The coordinator must emit: + +- member desired contract summary +- lane assignment summary +- per-lane prepare result +- per-member launch attribution +- aggregate launch snapshot revision + +### Example diagnostics + +- `member alice stays on primary lane (codex primary bootstrap)` +- `member tom routed to secondary lane opencode:tom` +- `member bob routed to secondary lane opencode:bob` + +This is essential. Mixed teams will be too hard to debug without explicit lane attribution. + +## Migration And Compatibility + +## Read compatibility + +Old teams remain valid: + +- missing member `providerBackendId` -> inherit lead/default provider backend +- missing member `fastMode` -> `inherit` +- missing member lane attribution in `launch-state.json` -> assume primary-lane if the member is primary-eligible + +### Important limit + +That last fallback must be used only for clearly primary-eligible providers. +Do not silently infer `OpenCode` members back onto the primary lane. + +## Mixed-Lane Degraded Fallback Policy + +This needed a stricter statement after the code pass. + +### Pure primary-lane teams + +For existing non-mixed teams, the current degraded launch-roster fallback behavior can stay: + +- `members.meta.json` +- inboxes + `config.json` +- `config.json` + +because the primary lane already understands member `provider/model/effort` in that model. + +### Persisted mixed-lane teams + +For teams that already have mixed-lane evidence, the coordinator must treat these as authoritative inputs: + +- `members.meta.json` +- `launch-state.json` + +If those files are missing or inconsistent, the product must **not** silently rebuild a mixed-lane plan from inboxes/config fallback only. + +### Required degraded behavior + +If mixed-lane evidence is missing: + +- recover only the canonical team summary and visible roster if possible +- mark runtime/lane reconstruction as degraded +- block or require a fresh relaunch/bootstrap for mixed-lane execution + +This is safer than pretending inbox/config fallback can recover member backend/fast/lane truth when the current code proves it cannot. + +## Write policy + +All new mixed launches must persist: + +- member desired overrides in `members.meta.json` +- member resolved lane attribution in `launch-state.json` + +Do not remove old team-level fields during rollout. + +### Existing relaunch compatibility we can reuse + +The current relaunch path already reconstructs member `providerId/model/effort` from `members.meta.json` when relaunching a team. + +So mixed-lane persistence work should focus mainly on adding: + +- lane attribution +- member-level backend/fast metadata +- resolved launch identity where needed + +and should not re-solve the already-working member `provider/model/effort` relaunch behavior from scratch. + +## Backup And Restore Compatibility + +Because backup already captures `team.meta.json` and `members.meta.json`, this feature should follow a simple compatibility rule: + +- only additive field changes in those files during V1 +- no rename or shape break of the existing root files + +### Required tests + +- backup a team with mixed-lane additive member metadata +- restore it +- verify `team.meta.json`, `members.meta.json`, and `launch-state.json` still reconstruct the same desired + resolved member truth + +## Dangerous And Thin Places + +### 1. Treating lane compatibility as model/effort math instead of runtime ownership policy + +This would produce an elegant but wrong planner. + +Guardrail: + +- first ask which runtime owner is allowed +- only then talk about model/effort/backend specifics + +### 2. Letting side-lane adapters write canonical roster artifacts + +That caused the missing-lead bug class already. + +Guardrail: + +- canonical roster is always written by team-owned code + +### 3. Replacing the primary run maps too early + +That would destabilize a lot of lifecycle code unrelated to mixed teams. + +Guardrail: + +- keep primary maps +- add side-lane maps + +### 4. Claiming schedule parity too early + +This would create a misleading plan and likely bugs. + +Guardrail: + +- keep current scheduler explicitly out of scope + +### 5. Allowing multi-member secondary lanes in V1 + +That creates grouping bugs before ownership is stable. + +Guardrail: + +- V1 side lanes are single-member only + +### 6. Mixing primary bootstrap expectations with aggregate mixed-team progress + +This would create stuck launches and misleading `"Members joining"` states. + +Guardrail: + +- primary in-memory `expectedMembers` is subset-only +- persisted launch-state `expectedMembers` stays the full expected roster unless a new explicit field is introduced for the bootstrap subset +- aggregate desired roster is tracked separately + +### 7. Treating live lane migration as a simple restart + +This would corrupt ownership and attribution. + +Guardrail: + +- block lane-migration edits in V1 +- implement migration later as a separate coordinator-backed increment + +### 8. Assuming primary-lane teammates already support member-level backend/fast + +This would create hidden runtime drift between persisted desired state and actual spawn behavior. + +Guardrail: + +- document that primary-lane teammates inherit backend/fast from the lead/runtime owner in V1 +- only secondary-lane planning may consume member-level backend/fast earlier + +### 9. Passing non-owned members into a side-lane adapter input + +This would cause provider-local flattening of failures back onto unrelated members. + +Guardrail: + +- adapter inputs must be lane-local subsets only +- add tests where one side-lane member fails and no other member inherits that failure + +### 10. Trusting `config.json` as canonical mixed-team roster truth + +The launch path intentionally normalizes `config.json` to lead-only. + +Guardrail: + +- `config.json` may remain CLI-facing and lead-centric +- mixed full-roster truth must come from `members.meta.json` and mixed-lane-aware `launch-state.json` + +### 11. Reusing the current lead Agent tool add-member/restart seam for secondary lanes + +That seam only carries provider/model/effort and assumes the lead owns the teammate spawn. + +Guardrail: + +- coordinator-managed secondary-lane attachment must bypass the generic lead Agent tool spawn-message path +- V1 must not claim secondary-lane restart/migration support through the current restart helper + +### 12. Treating inbox/config fallback as authoritative mixed-lane recovery + +That would silently erase member backend/fast/lane truth on relaunch/startup recovery. + +Guardrail: + +- inbox/config fallback may recover names and basic provider/model/effort only +- persisted mixed-lane teams require `members.meta.json` + `launch-state.json` for authoritative recovery +- if those are missing, degrade explicitly instead of inventing a mixed plan + +### 13. Reusing primary-lane env/provider args for side lanes + +Primary create/launch resolves env/auth/provider args once for the root request. + +Guardrail: + +- every secondary lane resolves its own env/auth/provider args +- do not inherit root request provider args into side-lane launches + +### 14. Treating renderer parity as optional polish + +Today `TeamMemberResolver` is built from `config + meta + inboxes`, not lane-aware launch-state truth. + +Guardrail: + +- lane-aware `TeamMemberSnapshot` and resolver parity must land before mixed support is enabled +- do not treat this as a post-shipping cosmetic cleanup + +### 15. Adding mixed-lane fields without upgrading launch-state normalization + +`normalizePersistedLaunchSnapshot(...)` reconstructs known fields and would otherwise drop new member-level lane/runtime data. + +Guardrail: + +- any mixed-lane schema change must ship with normalizer support +- schema upgrade is not complete until read-normalize-write roundtrips preserve the new fields + +### 16. Quietly redefining `expectedMembers` + +That field is already used widely for launch badges, summaries, provisioning presentation, and recovery. + +Guardrail: + +- do not change `expectedMembers` from "full expected roster" to "primary subset" +- add a new explicit field for bootstrap/primary expected members instead + +### 17. Choosing bootstrap vs persisted launch snapshot by timestamp only + +Mixed teams introduce a structurally richer launch-state than bootstrap-only recovery can express. + +Guardrail: + +- timestamp-only precedence is not enough once mixed-lane fields exist +- snapshot selection must prefer structurally richer mixed-lane truth over a newer but poorer bootstrap snapshot + +### 18. Letting mixed-lane launch-state outgrow Team list / dashboard summary readers + +Main runtime storage and summary readers currently have different size budgets. + +Guardrail: + +- mixed-lane rollout must define an explicit launch-state size budget for summary-safe operation +- preferred V1 answer is a compact summary projection written from canonical snapshot truth, not a blind increase of all reader limits +- if that compact projection does not exist, rollout must stay blocked until reader limits, performance, and summary parity are explicitly re-validated + +### 19. Updating summary logic only in one of the two reader paths + +Team summaries are read both in-process and through `team-fs-worker`. + +The current worker path does **not** use `choosePreferredLaunchSnapshot(...)` and does **not** read bootstrap launch snapshots. It only reads raw persisted `launch-state.json`. + +Guardrail: + +- schema, precedence, and summary derivation changes must land in both paths together +- preferred V1 answer is a shared summary derivation contract or compact summary artifact consumed by both paths +- if parity cannot be guaranteed, mixed launch-state summary should stay dark rather than diverge between worker and fallback modes + +### 20. Treating post-launch `config.json` runtime projection as authoritative mixed truth + +The existing config projection writes only provider/model/effort and is intentionally lossy. + +Guardrail: + +- never reconstruct mixed-lane runtime truth from projected `config.json` +- keep config projection compatibility-focused and minimal unless the CLI itself requires more + +### 21. Allowing live roster mutation on running mixed teams in V1 + +Current live add/remove/replace flows all route through lead-side messaging and primary-lane assumptions. + +Guardrail: + +- V1 mixed support should block live roster mutation on running mixed teams +- require stop/edit/relaunch until a coordinator-owned live mutation model exists + +### 22. Treating provider-local `OpenCode` runtime stores as if they were already lane-scoped + +The current `.opencode-runtime` namespace is team-scoped and single-active-run-scoped. + +Guardrail: + +- keep provider-local `OpenCode` launch-state/manifest/transaction files separate from global mixed-team `launch-state.json` +- do not multiplex multiple `OpenCode` side lanes into the same provider-local store namespace in V1 + +### 23. Assuming multiple `OpenCode` side lanes are possible without changing the bridge contract + +The current `OpenCode` launch command takes one `selectedModel` for the whole provider-owned run. + +Guardrail: + +- V1 mixed support must reject drafts with more than one `OpenCode` teammate +- future multi-`OpenCode` support requires both lane-scoped runtime-store namespacing and a bridge/API seam that can represent lane-local model ownership truthfully + +### 24. Reusing the legacy "expected teammates vs confirmed artifacts" summary fallback for mixed teams + +Current Team list / dashboard fallback summary logic infers partial launch failure by comparing: + +- expected teammate names from config/meta +- confirmed artifacts such as config members and inbox files + +That heuristic is not lane-aware. + +For mixed teams, a side-lane member can be legitimately absent from primary-lane artifacts during bootstrap or attachment without meaning "launch failed". + +Guardrail: + +- once a team is mixed-aware, summary fallback must not reuse the legacy artifact-count heuristic as if it were canonical launch truth +- mixed teams should prefer canonical lane-aware snapshot/projection truth +- if that truth is missing, summary should degrade to unknown/pending rather than inventing a partial failure + +### 25. Reusing the current primary-run `persistLaunchStateSnapshot(...)` as the canonical mixed snapshot writer + +Today `persistLaunchStateSnapshot(run)` writes `launch-state.json` from one `ProvisioningRun` and uses: + +- `run.expectedMembers` +- `run.memberSpawnStatuses` +- `run.provisioningComplete` + +That is safe for a single primary run, but unsafe for mixed teams because: + +- primary `run.expectedMembers` is only the bootstrap subset +- a clean-success primary run currently clears persisted launch-state entirely +- aggregate mixed truth may still include pending or failed side lanes + +Guardrail: + +- mixed teams need an aggregate snapshot writer owned by the coordinator, not direct reuse of the current primary-run persistence helper as canonical truth +- primary-lane snapshot persistence may remain lane-local input, but it must not overwrite or clear the global mixed-team launch snapshot on its own + +### 26. Letting `createPersistedLaunchSnapshot(...)` auto-fail not-yet-attached side-lane members + +Current `createPersistedLaunchSnapshot(...)` upgrades members from `starting` to `failed_to_start` whenever: + +- `launchPhase !== 'active'` +- the member never reached spawned/alive/confirmed signals + +That is a good repair for single-lane crashed launches, but dangerous for mixed teams if: + +- the primary lane flips out of `active` +- a planned side lane has not attached yet +- the aggregate launch is not actually terminal + +Guardrail: + +- aggregate mixed `launchPhase` must stay `active` until every planned side lane is terminal or explicitly removed from the plan +- primary-lane completion must not by itself trigger terminalization of still-planned side-lane members +- if V1 cannot enforce that invariant, mixed aggregate snapshot creation must use a separate terminalization policy instead of reusing the default helper unchanged + +### 27. Reusing bootstrap-specific pending copy for mixed side-lane attachment + +Current Team list / Team detail copy for `partial_pending` talks in bootstrap terms such as: + +- teammate still joining +- runtime pending bootstrap + +For mixed teams, `partial_pending` may instead mean: + +- primary lane is already ready +- one side lane has not attached yet +- one side lane is reconciling provider-local runtime truth + +Guardrail: + +- mixed renderer states must not over-interpret aggregate `partial_pending` as bootstrap-only +- once a team is mixed-aware, pending copy should become lane-aware or neutral, for example "launch still reconciling" rather than "pending bootstrap" + +## Rollout Plan + +## Phase 0 - Additive contracts and persistence parity + +**Commit boundary**: `feat(team-runtime): add additive mixed-lane contracts` + +`🎯 10 πŸ›‘οΈ 10 🧠 4` +Roughly `420-750` lines + +### Scope + +- extend shared member runtime contracts with `providerBackendId` and `fastMode` +- extend `TeamMemberSnapshot` and persisted launch member contracts with lane-aware additive fields +- extend `members.meta.json` read/write shape +- extend launch-state member shape additively +- upgrade launch-state normalizer/read path so the new fields survive roundtrip +- add explicit mixed-safe roster fields instead of redefining `expectedMembers` +- define a mixed-safe launch-state size budget for summary readers +- choose a summary-safe read strategy for Team list / dashboard, preferably a compact summary projection derived from canonical snapshot precedence +- define aggregate mixed-team snapshot ownership so primary-lane persistence helpers cannot overwrite or clear canonical mixed truth +- no behavior change yet + +### Tests + +- backward-compatible meta read/write +- backward-compatible launch-state normalization +- launch-state roundtrip preserves new additive mixed-lane fields +- old readers still see `expectedMembers` as full expected roster +- summary readers continue to read mixed launch-state within the agreed size budget +- summary strategy tests prove worker and in-process readers agree even when bootstrap snapshot and persisted launch-state disagree on recency +- aggregate snapshot tests prove primary-lane clean-success does not clear pending mixed side-lane truth +- member contract serialization tests +- backup/restore compatibility for additive member runtime metadata +- explicit semantic tests proving primary-lane teammates still inherit backend/fast in V1 +- `TeamMemberSnapshot` compatibility tests for sparse old teams + +## Phase 1 - Coordinator shell plus resolver parity + +**Commit boundary**: `feat(team-runtime): introduce mixed-lane coordinator shell` + +`🎯 9 πŸ›‘οΈ 10 🧠 6` +Roughly `520-900` lines + +### Scope + +- add `TeamRuntimeLaneCoordinator` +- keep current primary launch path untouched behaviorally +- add side-lane tracking maps without enabling mixed behavior yet +- make `TeamMemberResolver` and related team-view snapshot assembly lane-aware for additive fields +- centralize Team summary derivation so `TeamConfigReader` and `team-fs-worker` consume the same mixed-summary contract instead of reimplementing snapshot choice independently +- introduce coordinator-owned aggregate launch-state assembly so lane-local progress feeds one canonical mixed snapshot +- primary-only teams remain identical + +### Tests + +- single-provider create/launch parity +- no regression in pure `OpenCode` gated path +- no regression in stop/reconcile for primary-only teams +- no regression in existing mixed primary-lane member `provider/model/effort` behavior +- team view remains stable for old teams with no mixed-lane fields +- lead synthesis still works when lane-aware fields are missing +- worker and in-process team summary paths derive the same launch summary from the same mixed snapshot +- worker and in-process paths agree when bootstrap snapshot is newer than persisted launch-state and when persisted launch-state is structurally richer than bootstrap snapshot +- mixed-aware teams do not fall back to the legacy artifact-count partial-failure heuristic when canonical lane-aware snapshot truth is missing +- lane-local primary snapshot updates do not clear or terminalize the aggregate mixed snapshot prematurely + +## Phase 2 - Enable `OpenCode` single-member secondary lanes + +**Commit boundary**: `feat(team-runtime): enable opencode secondary member lanes` + +`🎯 9 πŸ›‘οΈ 9 🧠 8` +Roughly `650-1100` lines + +### Scope + +- remove team-wide `OpenCode` capture logic +- allow one `OpenCode` member to become a single-member secondary lane +- reject mixed drafts with more than one `OpenCode` teammate with a clear unsupported-in-v1 message +- exclude secondary-lane members from primary bootstrap spec and primary `expectedMembers` +- launch primary lane plus side lanes +- merge lane results into member-scoped launch-state +- keep aggregate mixed `launchPhase` active until all planned lanes are terminal, even if the primary lane finishes earlier + +### Tests + +- `Codex` lead + `OpenCode` teammate +- `Anthropic` lead + `OpenCode` teammate +- `Gemini` lead + `OpenCode` teammate +- multiple `OpenCode` teammates in one mixed team are rejected explicitly in V1 +- primary lane does not wedge waiting for side-lane members +- one failing `OpenCode` side lane does not flatten failure onto other members +- mixed launch keeps `config.json` lead-only while `members.meta.json` retains the full desired roster +- every `OpenCode` side lane resolves its own env/auth/provider args instead of reusing the lead launch env +- global mixed-team `launch-state.json` and provider-local `.opencode-runtime/launch-state.json` remain separate and do not overwrite each other +- primary-lane clean-success does not clear aggregate mixed launch-state while a side lane is still pending + +## Phase 3 - Lifecycle parity for mixed persistent teams + +**Commit boundary**: `feat(team-runtime): add mixed-lane lifecycle parity` + +`🎯 8 πŸ›‘οΈ 9 🧠 7` +Roughly `450-850` lines + +### Scope + +- mixed relaunch +- mixed restore/startup recovery +- mixed stop +- mixed reconcile +- explicit blocking for unsupported live lane-migration edits +- explicit degraded-state handling when persisted mixed-lane truth is missing and only inbox/config fallback is available +- explicit blocking for live side-lane add-member in V1 +- mixed-safe snapshot precedence when bootstrap and persisted launch-state disagree +- mixed-safe summary precedence in both Team summary reader paths +- explicit blocking for live roster mutation on running mixed teams + +### Tests + +- create -> launch -> relaunch +- create -> launch -> stop +- startup recovery with stale secondary lane +- partial side-lane failure stays member-scoped +- live lane-migration edit is rejected with a clear message +- relaunch preserves primary-lane member `provider/model/effort` and does not invent member-level backend/fast behavior +- persisted mixed-lane recovery degrades explicitly when `members.meta.json` or lane-aware `launch-state.json` is missing +- live side-lane add-member attempt is rejected with a clear unsupported-in-v1 message +- richer persisted mixed launch-state wins over a newer bootstrap-only snapshot when both exist +- Team list / dashboard summary stays stable in both worker and in-process modes for the same mixed team +- live `addMember` / `replaceMembers` / `removeMember` on a running mixed team are rejected with clear stop-edit-relaunch guidance + +## Phase 4 - Renderer truth and diagnostics polish + +**Commit boundary**: `feat(team-runtime): surface mixed-lane member truth in ui` + +`🎯 8 πŸ›‘οΈ 8 🧠 5` +Roughly `180-320` lines + +### Scope + +- member lane labels +- member-scoped failure copy +- mixed-aware pending/reconciling copy that does not pretend every pending state is "bootstrap pending" +- cleaner diagnostics and affordances on top of already-landed resolver parity + +### Tests + +- Team Detail shows lead reliably +- member rows show provider/runtime truth +- side-lane failure does not flatten team-wide +- Team list/detail pending copy stays truthful when the primary lane is ready but a side lane is still attaching or reconciling + +## Future Phase - Live mixed-team roster mutation + +Not part of the first mixed-team rollout. + +Only start this after primary mixed launch/relaunch/recovery is stable. + +### Scope + +- add or remove members on a running mixed team +- replace member roster on a running mixed team +- restart a secondary-lane member through a coordinator-owned path +- explicit migration workflow for primary <-> secondary lane changes + +### Why this is deferred + +The current live add/remove/replace/restart seams are lead-Agent-tool-based or lead-message-based and do not carry enough runtime metadata for side-lane ownership. Shipping them in V1 would be materially riskier than fresh mixed launch support. + +## Future Phase - `OpenCode` lane-scoped runtime namespace and multi-`OpenCode` mixed teams + +Not part of the first mixed-team rollout. + +Only start this after the single-`OpenCode`-teammate V1 is stable. + +### Scope + +- introduce lane-scoped provider-local runtime-store namespace, for example `.opencode-runtime//...` +- replace team-scoped single active-run assumptions in manifest/transaction/permission stores +- extend bridge/API contracts so `OpenCode` side-lane launches can represent lane-local model ownership truthfully +- only after that allow more than one `OpenCode` teammate inside the same mixed team + +## Future Phase - Scheduled team runtime coordinator + +Not part of this feature. + +Only start this when the product adds a real scheduled team-runtime flow distinct from the current one-shot scheduler. + +## Test Plan + +## Main-process tests + +- member desired-contract inheritance from lead defaults +- lane partition policy: primary-eligible vs secondary-required +- launch-state merge preserves per-member attribution +- side-lane run tracking does not break primary run tracking + +## Integration tests + +- mixed create writes full canonical roster +- mixed launch persists member lane attribution +- mixed relaunch uses persisted member truth, not current global defaults +- side-lane stop/reconcile only affects side-lane members +- mixed launch/relaunch never treats lead-only `config.json` as the canonical full roster source +- degraded inbox/config fallback for a mixed team does not silently reconstruct `OpenCode` back onto the primary lane +- team-view snapshots reflect lane-aware persisted truth instead of flattening back to config/meta-only labels +- Team list / dashboard summaries still read mixed launch-state within the supported size budget +- mixed drafts with more than one `OpenCode` teammate are rejected explicitly in V1 +- global mixed-team `launch-state.json` and provider-local `.opencode-runtime/launch-state.json` remain disambiguated +- mixed Team list / dashboard summaries do not infer partial failure from missing side-lane artifacts while the team is still launching + +## Renderer tests + +- lead row remains visible even when old data is sparse +- mixed team rows show member-level runtime truth +- partial failure UI remains member-scoped + +## Live Signoff + +Required live signoff before rollout: + +1. `Codex` lead + `OpenCode` teammate +2. `Anthropic` lead + `OpenCode` teammate +3. `Gemini` lead + `OpenCode` teammate +4. mixed team relaunch +5. mixed team stop and restart +6. startup recovery with an interrupted side lane +7. Team Detail truth matches persisted lane/member runtime identity for a mixed team +8. mixed draft with two `OpenCode` teammates is rejected with a clear unsupported-in-v1 message + +## Final Recommendation + +Implement mixed teams as: + +- one canonical primary team bootstrap lane +- additive single-member secondary lanes where runtime ownership truly requires it +- in V1, at most one `OpenCode` secondary teammate per mixed team +- member-scoped persistence before behavior changes +- side-lane tracking added beside, not instead of, current primary run maps +- fresh mixed launch/relaunch/recovery first, with live side-lane attachment deferred + +This is the most reliable and scalable path because it: + +- matches the real code +- fixes the `vector-room-7` class of bugs directly +- keeps working bootstrap behavior intact +- gives us a clean extension point for future side-lane providers without over-engineering V1 diff --git a/resources/pricing.json b/resources/pricing.json index c8e27349..a85dc8e9 100644 --- a/resources/pricing.json +++ b/resources/pricing.json @@ -454,6 +454,20 @@ "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true }, + "anthropic.claude-mythos-preview": { + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_tool_choice": true + }, "global.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 0.00000625, "cache_read_input_token_cost": 5e-7, diff --git a/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts b/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts new file mode 100644 index 00000000..c429f84d --- /dev/null +++ b/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from 'vitest'; + +import { buildMixedPersistedLaunchSnapshot } from '../buildMixedPersistedLaunchSnapshot'; + +describe('buildMixedPersistedLaunchSnapshot', () => { + it('records bootstrapExpectedMembers when a secondary lane extends the expected roster', () => { + const snapshot = buildMixedPersistedLaunchSnapshot({ + teamName: 'mixed-team', + launchPhase: 'active', + updatedAt: '2026-04-22T10:00:00.000Z', + leadDefaults: { + providerId: 'codex', + providerBackendId: 'codex-native', + selectedFastMode: 'off', + resolvedFastMode: false, + launchIdentity: null, + }, + primaryMembers: [{ name: 'alice', providerId: 'codex', model: 'gpt-5.4', effort: 'high' }], + primaryStatuses: { + alice: { + launchState: 'confirmed_alive', + status: 'online', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + livenessSource: 'heartbeat', + firstSpawnAcceptedAt: '2026-04-22T09:59:00.000Z', + lastHeartbeatAt: '2026-04-22T09:59:30.000Z', + updatedAt: '2026-04-22T10:00:00.000Z', + } as never, + }, + secondaryMembers: [ + { + laneId: 'secondary:opencode:bob', + member: { + name: 'bob', + providerId: 'opencode', + model: 'minimax-m2.5-free', + effort: 'medium', + }, + leadDefaults: { + providerId: 'codex', + providerBackendId: 'codex-native', + selectedFastMode: 'off', + resolvedFastMode: false, + launchIdentity: null, + }, + pendingReason: 'Queued for OpenCode secondary lane launch.', + }, + ], + }); + + expect(snapshot.expectedMembers).toEqual(['alice', 'bob']); + expect(snapshot.bootstrapExpectedMembers).toEqual(['alice']); + expect(snapshot.members.alice).toMatchObject({ + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + }); + expect(snapshot.members.bob).toMatchObject({ + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'starting', + }); + expect(snapshot.summary).toEqual({ + confirmedCount: 1, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 0, + }); + expect(snapshot.teamLaunchState).toBe('partial_pending'); + }); + + it('marks the team clean_success once the secondary lane confirms bootstrap', () => { + const snapshot = buildMixedPersistedLaunchSnapshot({ + teamName: 'mixed-team', + launchPhase: 'finished', + updatedAt: '2026-04-22T10:05:00.000Z', + leadDefaults: { + providerId: 'codex', + providerBackendId: 'codex-native', + selectedFastMode: 'off', + resolvedFastMode: false, + launchIdentity: null, + }, + primaryMembers: [{ name: 'alice', providerId: 'codex', model: 'gpt-5.4', effort: 'high' }], + primaryStatuses: { + alice: { + launchState: 'confirmed_alive', + status: 'online', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + livenessSource: 'heartbeat', + firstSpawnAcceptedAt: '2026-04-22T10:00:00.000Z', + lastHeartbeatAt: '2026-04-22T10:01:00.000Z', + updatedAt: '2026-04-22T10:05:00.000Z', + } as never, + }, + secondaryMembers: [ + { + laneId: 'secondary:opencode:bob', + member: { + name: 'bob', + providerId: 'opencode', + model: 'minimax-m2.5-free', + effort: 'medium', + }, + leadDefaults: { + providerId: 'codex', + providerBackendId: 'codex-native', + selectedFastMode: 'off', + resolvedFastMode: false, + launchIdentity: null, + }, + evidence: { + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + diagnostics: ['spawn accepted', 'late heartbeat received'], + }, + }, + ], + }); + + expect(snapshot.bootstrapExpectedMembers).toEqual(['alice']); + expect(snapshot.members.bob).toMatchObject({ + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + }); + expect(snapshot.summary).toEqual({ + confirmedCount: 2, + pendingCount: 0, + failedCount: 0, + runtimeAlivePendingCount: 0, + }); + expect(snapshot.teamLaunchState).toBe('clean_success'); + }); + + it('keeps a side-lane failure member-scoped instead of flattening it onto primary members', () => { + const snapshot = buildMixedPersistedLaunchSnapshot({ + teamName: 'mixed-team', + launchPhase: 'finished', + updatedAt: '2026-04-22T10:05:00.000Z', + leadDefaults: { + providerId: 'codex', + providerBackendId: 'codex-native', + selectedFastMode: 'off', + resolvedFastMode: false, + launchIdentity: null, + }, + primaryMembers: [{ name: 'alice', providerId: 'codex', model: 'gpt-5.4', effort: 'high' }], + primaryStatuses: { + alice: { + launchState: 'confirmed_alive', + status: 'online', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + livenessSource: 'heartbeat', + firstSpawnAcceptedAt: '2026-04-22T10:00:00.000Z', + lastHeartbeatAt: '2026-04-22T10:01:00.000Z', + updatedAt: '2026-04-22T10:05:00.000Z', + } as never, + }, + secondaryMembers: [ + { + laneId: 'secondary:opencode:bob', + member: { + name: 'bob', + providerId: 'opencode', + model: 'minimax-m2.5-free', + effort: 'medium', + }, + leadDefaults: { + providerId: 'codex', + providerBackendId: 'codex-native', + selectedFastMode: 'off', + resolvedFastMode: false, + launchIdentity: null, + }, + evidence: { + launchState: 'failed_to_start', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'OpenCode side lane failed to attach', + diagnostics: ['secondary runtime attach failed'], + }, + }, + ], + }); + + expect(snapshot.members.alice).toMatchObject({ + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + hardFailure: false, + }); + expect(snapshot.members.bob).toMatchObject({ + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'failed_to_start', + hardFailure: true, + hardFailureReason: 'OpenCode side lane failed to attach', + }); + expect(snapshot.summary).toEqual({ + confirmedCount: 1, + pendingCount: 0, + failedCount: 1, + runtimeAlivePendingCount: 0, + }); + expect(snapshot.teamLaunchState).toBe('partial_failure'); + }); +}); diff --git a/src/features/team-runtime-lanes/core/domain/__tests__/planTeamRuntimeLanes.test.ts b/src/features/team-runtime-lanes/core/domain/__tests__/planTeamRuntimeLanes.test.ts new file mode 100644 index 00000000..5f810746 --- /dev/null +++ b/src/features/team-runtime-lanes/core/domain/__tests__/planTeamRuntimeLanes.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; + +import { planTeamRuntimeLanes } from '../planTeamRuntimeLanes'; + +describe('planTeamRuntimeLanes', () => { + it('keeps non-OpenCode members on the primary lane', () => { + const result = planTeamRuntimeLanes({ + leadProviderId: 'codex', + members: [ + { name: 'alice', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'gemini', model: 'gemini-2.5-pro' }, + ], + }); + + expect(result).toMatchObject({ + ok: true, + plan: { + mode: 'primary_only', + primaryMembers: [ + expect.objectContaining({ name: 'alice', providerId: 'codex' }), + expect.objectContaining({ name: 'bob', providerId: 'gemini' }), + ], + sideLanes: [], + }, + }); + }); + + it('creates one secondary OpenCode lane per OpenCode teammate', () => { + const result = planTeamRuntimeLanes({ + leadProviderId: 'codex', + members: [ + { name: 'alice', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + { name: 'tom', providerId: 'opencode', model: 'nemotron-3-super-free' }, + ], + }); + + expect(result).toMatchObject({ + ok: true, + plan: { + mode: 'mixed_opencode_side_lanes', + primaryMembers: [expect.objectContaining({ name: 'alice', providerId: 'codex' })], + sideLanes: [ + { + laneId: 'secondary:opencode:bob', + providerId: 'opencode', + member: expect.objectContaining({ + name: 'bob', + providerId: 'opencode', + model: 'minimax-m2.5-free', + }), + }, + { + laneId: 'secondary:opencode:tom', + providerId: 'opencode', + member: expect.objectContaining({ + name: 'tom', + providerId: 'opencode', + model: 'nemotron-3-super-free', + }), + }, + ], + }, + }); + }); + + it('creates a secondary OpenCode lane for an Anthropic-led mixed team', () => { + const result = planTeamRuntimeLanes({ + leadProviderId: 'anthropic', + members: [ + { name: 'alice', providerId: 'anthropic', model: 'claude-opus-4-1' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + }); + + expect(result).toMatchObject({ + ok: true, + plan: { + mode: 'mixed_opencode_side_lanes', + primaryMembers: [expect.objectContaining({ name: 'alice', providerId: 'anthropic' })], + sideLanes: [ + { + laneId: 'secondary:opencode:bob', + providerId: 'opencode', + member: expect.objectContaining({ + name: 'bob', + providerId: 'opencode', + model: 'minimax-m2.5-free', + }), + }, + ], + }, + }); + }); + + it('creates a secondary OpenCode lane for a Gemini-led mixed team', () => { + const result = planTeamRuntimeLanes({ + leadProviderId: 'gemini', + members: [ + { name: 'alice', providerId: 'gemini', model: 'gemini-2.5-pro' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + }); + + expect(result).toMatchObject({ + ok: true, + plan: { + mode: 'mixed_opencode_side_lanes', + primaryMembers: [expect.objectContaining({ name: 'alice', providerId: 'gemini' })], + sideLanes: [ + { + laneId: 'secondary:opencode:bob', + providerId: 'opencode', + member: expect.objectContaining({ + name: 'bob', + providerId: 'opencode', + model: 'minimax-m2.5-free', + }), + }, + ], + }, + }); + }); + + it('rejects OpenCode-led mixed teams in this phase', () => { + const result = planTeamRuntimeLanes({ + leadProviderId: 'opencode', + members: [ + { name: 'alice', providerId: 'opencode', model: 'minimax-m2.5-free' }, + { name: 'bob', providerId: 'codex', model: 'gpt-5.4' }, + ], + }); + + expect(result).toEqual({ + ok: false, + reason: 'unsupported_opencode_led_mixed_team', + message: + 'Mixed teams with an OpenCode lead are not supported in this phase. Keep the team lead on Anthropic, Codex, or Gemini when you mix OpenCode with other providers.', + }); + }); +}); diff --git a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts new file mode 100644 index 00000000..7b0afef7 --- /dev/null +++ b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts @@ -0,0 +1,324 @@ +import { isLeadMember } from '@shared/utils/leadDetection'; +import { migrateProviderBackendId } from '@shared/utils/providerBackend'; +import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; + +import type { + MemberLaunchState, + MemberSpawnLivenessSource, + MemberSpawnStatusEntry, + PersistedTeamLaunchMemberSources, + PersistedTeamLaunchMemberState, + PersistedTeamLaunchPhase, + PersistedTeamLaunchSnapshot, + ProviderModelLaunchIdentity, + TeamFastMode, + TeamProviderBackendId, + TeamProviderId, + TeamProvisioningMemberInput, +} from '@shared/types'; + +export interface MixedLaneLeadRuntimeDefaults { + providerId: TeamProviderId; + providerBackendId?: TeamProviderBackendId | null; + selectedFastMode?: TeamFastMode; + resolvedFastMode?: boolean | null; + launchIdentity?: ProviderModelLaunchIdentity | null; +} + +export interface MixedSecondaryLaneMemberStateInput { + laneId: string; + member: TeamProvisioningMemberInput; + leadDefaults: MixedLaneLeadRuntimeDefaults; + evidence?: { + launchState?: MemberLaunchState; + agentToolAccepted?: boolean; + runtimeAlive?: boolean; + bootstrapConfirmed?: boolean; + hardFailure?: boolean; + hardFailureReason?: string; + diagnostics?: string[]; + } | null; + pendingReason?: string; +} + +function deriveMemberLaunchState(params: { + hardFailure?: boolean; + bootstrapConfirmed?: boolean; + runtimeAlive?: boolean; + agentToolAccepted?: boolean; +}): MemberLaunchState { + if (params.hardFailure) { + return 'failed_to_start'; + } + if (params.bootstrapConfirmed) { + return 'confirmed_alive'; + } + if (params.runtimeAlive || params.agentToolAccepted) { + return 'runtime_pending_bootstrap'; + } + return 'starting'; +} + +function buildDiagnostics( + member: Pick< + PersistedTeamLaunchMemberState, + 'agentToolAccepted' | 'runtimeAlive' | 'bootstrapConfirmed' | 'hardFailureReason' | 'sources' + > +): string[] { + const diagnostics: string[] = []; + if (member.agentToolAccepted) diagnostics.push('spawn accepted'); + if (member.runtimeAlive) diagnostics.push('runtime alive'); + if (member.bootstrapConfirmed) diagnostics.push('late heartbeat received'); + if (member.runtimeAlive && !member.bootstrapConfirmed) { + diagnostics.push('waiting for teammate check-in'); + } + if (member.hardFailureReason) + diagnostics.push(`hard failure reason: ${member.hardFailureReason}`); + if (member.sources?.duplicateRespawnBlocked) diagnostics.push('respawn blocked as duplicate'); + if (member.sources?.configDrift) diagnostics.push('config drift detected'); + return diagnostics; +} + +function createSourcesFromStatus( + status: Pick +): PersistedTeamLaunchMemberSources | undefined { + const sources: PersistedTeamLaunchMemberSources = {}; + if (status.livenessSource === 'heartbeat') { + sources.nativeHeartbeat = true; + sources.inboxHeartbeat = true; + } + if (status.livenessSource === 'process' || status.runtimeAlive) { + sources.processAlive = true; + } + return Object.values(sources).some(Boolean) ? sources : undefined; +} + +function normalizeFastMode(value: TeamFastMode | undefined): TeamFastMode | undefined { + return value === 'inherit' || value === 'on' || value === 'off' ? value : undefined; +} + +function createPrimaryLaneMemberState(params: { + member: TeamProvisioningMemberInput; + status?: MemberSpawnStatusEntry; + updatedAt: string; + leadDefaults: MixedLaneLeadRuntimeDefaults; +}): PersistedTeamLaunchMemberState { + const providerId = + normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId; + const runtime = params.status; + const sources = runtime ? createSourcesFromStatus(runtime) : undefined; + const base: PersistedTeamLaunchMemberState = { + name: params.member.name.trim(), + providerId, + providerBackendId: + migrateProviderBackendId(providerId, params.member.providerBackendId) ?? + (providerId === params.leadDefaults.providerId + ? (params.leadDefaults.providerBackendId ?? undefined) + : undefined), + model: params.member.model?.trim() || undefined, + effort: params.member.effort, + selectedFastMode: + normalizeFastMode(params.member.fastMode) ?? + (providerId === params.leadDefaults.providerId + ? normalizeFastMode(params.leadDefaults.selectedFastMode) + : undefined), + resolvedFastMode: + providerId === params.leadDefaults.providerId + ? (params.leadDefaults.resolvedFastMode ?? undefined) + : undefined, + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: params.leadDefaults.providerId, + launchIdentity: + providerId === params.leadDefaults.providerId + ? (params.leadDefaults.launchIdentity ?? undefined) + : undefined, + launchState: + runtime?.launchState ?? + deriveMemberLaunchState({ + hardFailure: runtime?.hardFailure, + bootstrapConfirmed: runtime?.bootstrapConfirmed, + runtimeAlive: runtime?.runtimeAlive, + agentToolAccepted: runtime?.agentToolAccepted, + }), + agentToolAccepted: runtime?.agentToolAccepted === true, + runtimeAlive: runtime?.runtimeAlive === true, + bootstrapConfirmed: runtime?.bootstrapConfirmed === true, + hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start', + hardFailureReason: runtime?.hardFailureReason ?? runtime?.error, + firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt, + lastHeartbeatAt: runtime?.lastHeartbeatAt, + lastRuntimeAliveAt: runtime?.runtimeAlive ? params.updatedAt : undefined, + lastEvaluatedAt: runtime?.updatedAt ?? params.updatedAt, + sources, + diagnostics: undefined, + }; + base.diagnostics = buildDiagnostics(base); + return base; +} + +function createSecondaryLaneMemberState( + params: MixedSecondaryLaneMemberStateInput & { updatedAt: string } +): PersistedTeamLaunchMemberState { + const providerId = + normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId; + const evidence = params.evidence; + const hardFailureReason = + evidence?.hardFailureReason ?? + (!evidence && params.pendingReason ? params.pendingReason : undefined); + const launchState = + evidence?.launchState ?? + deriveMemberLaunchState({ + hardFailure: evidence?.hardFailure, + bootstrapConfirmed: evidence?.bootstrapConfirmed, + runtimeAlive: evidence?.runtimeAlive, + agentToolAccepted: evidence?.agentToolAccepted, + }); + const base: PersistedTeamLaunchMemberState = { + name: params.member.name.trim(), + providerId, + providerBackendId: + migrateProviderBackendId(providerId, params.member.providerBackendId) ?? + (providerId === params.leadDefaults.providerId + ? (params.leadDefaults.providerBackendId ?? undefined) + : undefined), + model: params.member.model?.trim() || undefined, + effort: params.member.effort, + selectedFastMode: + normalizeFastMode(params.member.fastMode) ?? + (providerId === params.leadDefaults.providerId + ? normalizeFastMode(params.leadDefaults.selectedFastMode) + : undefined), + resolvedFastMode: + providerId === params.leadDefaults.providerId + ? (params.leadDefaults.resolvedFastMode ?? undefined) + : undefined, + laneId: params.laneId, + laneKind: 'secondary', + laneOwnerProviderId: providerId, + launchState, + agentToolAccepted: evidence?.agentToolAccepted === true, + runtimeAlive: evidence?.runtimeAlive === true, + bootstrapConfirmed: evidence?.bootstrapConfirmed === true, + hardFailure: evidence?.hardFailure === true || launchState === 'failed_to_start', + hardFailureReason, + firstSpawnAcceptedAt: evidence?.agentToolAccepted ? params.updatedAt : undefined, + lastHeartbeatAt: evidence?.bootstrapConfirmed ? params.updatedAt : undefined, + lastRuntimeAliveAt: evidence?.runtimeAlive ? params.updatedAt : undefined, + lastEvaluatedAt: params.updatedAt, + sources: evidence?.runtimeAlive + ? { + processAlive: true, + nativeHeartbeat: evidence.bootstrapConfirmed === true || undefined, + inboxHeartbeat: evidence.bootstrapConfirmed === true || undefined, + } + : undefined, + diagnostics: evidence?.diagnostics?.length ? [...evidence.diagnostics] : undefined, + }; + base.diagnostics = base.diagnostics?.length ? base.diagnostics : buildDiagnostics(base); + return base; +} + +function summarizeMembers( + expectedMembers: readonly string[], + members: Record +): PersistedTeamLaunchSnapshot['summary'] { + let confirmedCount = 0; + let pendingCount = 0; + let failedCount = 0; + let runtimeAlivePendingCount = 0; + + for (const memberName of expectedMembers) { + const entry = members[memberName]; + if (!entry) { + pendingCount += 1; + continue; + } + if (entry.launchState === 'confirmed_alive') { + confirmedCount += 1; + continue; + } + if (entry.launchState === 'failed_to_start') { + failedCount += 1; + continue; + } + pendingCount += 1; + if (entry.runtimeAlive) { + runtimeAlivePendingCount += 1; + } + } + + return { + confirmedCount, + pendingCount, + failedCount, + runtimeAlivePendingCount, + }; +} + +function deriveTeamLaunchState( + summary: PersistedTeamLaunchSnapshot['summary'] +): PersistedTeamLaunchSnapshot['teamLaunchState'] { + if (summary.failedCount > 0) { + return 'partial_failure'; + } + if (summary.pendingCount > 0) { + return 'partial_pending'; + } + return 'clean_success'; +} + +export function buildMixedPersistedLaunchSnapshot(params: { + teamName: string; + leadSessionId?: string; + launchPhase: PersistedTeamLaunchPhase; + leadDefaults: MixedLaneLeadRuntimeDefaults; + primaryMembers: readonly TeamProvisioningMemberInput[]; + primaryStatuses: Record; + secondaryMembers?: readonly MixedSecondaryLaneMemberStateInput[]; + updatedAt?: string; +}): PersistedTeamLaunchSnapshot { + const updatedAt = params.updatedAt ?? new Date().toISOString(); + const primaryExpectedMembers = params.primaryMembers + .map((member) => member.name.trim()) + .filter((name) => name.length > 0 && name !== 'user' && !isLeadMember({ name })); + const members: Record = {}; + + for (const member of params.primaryMembers) { + const trimmedName = member.name.trim(); + if (!trimmedName || trimmedName === 'user' || isLeadMember({ name: trimmedName })) continue; + members[trimmedName] = createPrimaryLaneMemberState({ + member, + status: params.primaryStatuses[trimmedName], + updatedAt, + leadDefaults: params.leadDefaults, + }); + } + + for (const laneMember of params.secondaryMembers ?? []) { + const trimmedName = laneMember.member.name.trim(); + if (!trimmedName || trimmedName === 'user' || isLeadMember({ name: trimmedName })) continue; + members[trimmedName] = createSecondaryLaneMemberState({ + ...laneMember, + updatedAt, + }); + } + + const expectedMembers = Array.from(new Set([...primaryExpectedMembers, ...Object.keys(members)])); + const summary = summarizeMembers(expectedMembers, members); + + return { + version: 2, + teamName: params.teamName, + updatedAt, + ...(params.leadSessionId ? { leadSessionId: params.leadSessionId } : {}), + launchPhase: params.launchPhase, + expectedMembers, + ...(primaryExpectedMembers.join('\u0000') !== expectedMembers.join('\u0000') + ? { bootstrapExpectedMembers: primaryExpectedMembers } + : {}), + members, + summary, + teamLaunchState: deriveTeamLaunchState(summary), + }; +} diff --git a/src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts b/src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts new file mode 100644 index 00000000..88ca234e --- /dev/null +++ b/src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts @@ -0,0 +1,195 @@ +import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; + +import type { + EffortLevel, + TeamFastMode, + TeamProviderBackendId, + TeamProviderId, + TeamProvisioningMemberInput, +} from '@shared/types'; + +export interface RuntimeLanePlannerMemberInput { + name: string; + providerId?: TeamProviderId; + providerBackendId?: TeamProviderBackendId; + model?: string; + effort?: EffortLevel; + fastMode?: TeamFastMode; +} + +export interface PlannedRuntimeMember extends RuntimeLanePlannerMemberInput { + providerId: TeamProviderId; +} + +export interface PlannedTeamMemberLaneIdentity { + laneId: string; + laneKind: 'primary' | 'secondary'; + laneOwnerProviderId: TeamProviderId; +} + +export type TeamRuntimeLanePlan = + | { + mode: 'primary_only'; + primaryMembers: PlannedRuntimeMember[]; + allMembers: PlannedRuntimeMember[]; + sideLanes: []; + } + | { + mode: 'pure_opencode'; + primaryMembers: PlannedRuntimeMember[]; + allMembers: PlannedRuntimeMember[]; + sideLanes: []; + } + | { + mode: 'mixed_opencode_side_lanes'; + primaryMembers: PlannedRuntimeMember[]; + allMembers: PlannedRuntimeMember[]; + sideLanes: Array<{ + laneId: string; + providerId: 'opencode'; + member: PlannedRuntimeMember; + }>; + }; + +export type TeamRuntimeLanePlanErrorReason = 'unsupported_opencode_led_mixed_team'; + +export interface TeamRuntimeLanePlanError { + ok: false; + reason: TeamRuntimeLanePlanErrorReason; + message: string; +} + +export interface TeamRuntimeLanePlanSuccess { + ok: true; + plan: TeamRuntimeLanePlan; +} + +export type TeamRuntimeLanePlanResult = TeamRuntimeLanePlanSuccess | TeamRuntimeLanePlanError; + +function normalizeLeadProviderId(providerId: TeamProviderId | undefined): TeamProviderId { + return normalizeOptionalTeamProviderId(providerId) ?? 'anthropic'; +} + +function normalizePlannedMembers( + members: readonly RuntimeLanePlannerMemberInput[], + leadProviderId: TeamProviderId +): PlannedRuntimeMember[] { + return members + .map((member) => ({ + ...member, + name: member.name.trim(), + providerId: normalizeOptionalTeamProviderId(member.providerId) ?? leadProviderId, + })) + .filter((member) => member.name.length > 0); +} + +export function buildPlannedMemberLaneIdentity(params: { + leadProviderId?: TeamProviderId; + member: Pick; +}): PlannedTeamMemberLaneIdentity { + const leadProviderId = normalizeLeadProviderId(params.leadProviderId); + const memberProviderId = + normalizeOptionalTeamProviderId(params.member.providerId) ?? leadProviderId; + const trimmedName = params.member.name.trim(); + + if (leadProviderId !== 'opencode' && memberProviderId === 'opencode') { + return { + laneId: `secondary:opencode:${trimmedName}`, + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + }; + } + + return { + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: leadProviderId, + }; +} + +export function planTeamRuntimeLanes(params: { + leadProviderId?: TeamProviderId; + members: readonly RuntimeLanePlannerMemberInput[]; +}): TeamRuntimeLanePlanResult { + const leadProviderId = normalizeLeadProviderId(params.leadProviderId); + const allMembers = normalizePlannedMembers(params.members, leadProviderId); + const openCodeMembers = allMembers.filter((member) => member.providerId === 'opencode'); + + if (leadProviderId === 'opencode') { + const nonOpenCodeMembers = allMembers.filter((member) => member.providerId !== 'opencode'); + if (nonOpenCodeMembers.length > 0) { + return { + ok: false, + reason: 'unsupported_opencode_led_mixed_team', + message: + 'Mixed teams with an OpenCode lead are not supported in this phase. Keep the team lead on Anthropic, Codex, or Gemini when you mix OpenCode with other providers.', + }; + } + return { + ok: true, + plan: { + mode: 'pure_opencode', + primaryMembers: allMembers, + allMembers, + sideLanes: [], + }, + }; + } + + if (openCodeMembers.length === 0) { + return { + ok: true, + plan: { + mode: 'primary_only', + primaryMembers: allMembers, + allMembers, + sideLanes: [], + }, + }; + } + return { + ok: true, + plan: { + mode: 'mixed_opencode_side_lanes', + primaryMembers: allMembers.filter((member) => member.providerId !== 'opencode'), + allMembers, + sideLanes: openCodeMembers.map((member) => ({ + laneId: buildPlannedMemberLaneIdentity({ + leadProviderId, + member, + }).laneId, + providerId: 'opencode', + member, + })), + }, + }; +} + +export function isMixedOpenCodeSideLanePlan( + plan: TeamRuntimeLanePlan +): plan is Extract { + return plan.mode === 'mixed_opencode_side_lanes'; +} + +export function isPureOpenCodeLanePlan( + plan: TeamRuntimeLanePlan +): plan is Extract { + return plan.mode === 'pure_opencode'; +} + +export function fromProvisioningMembers( + leadProviderId: TeamProviderId | undefined, + members: readonly TeamProvisioningMemberInput[] +): TeamRuntimeLanePlanResult { + return planTeamRuntimeLanes({ + leadProviderId, + members: members.map((member) => ({ + name: member.name, + providerId: normalizeOptionalTeamProviderId(member.providerId), + providerBackendId: member.providerBackendId, + model: member.model, + effort: member.effort, + fastMode: member.fastMode, + })), + }); +} diff --git a/src/features/team-runtime-lanes/main/composition/__tests__/createTeamRuntimeLaneCoordinator.test.ts b/src/features/team-runtime-lanes/main/composition/__tests__/createTeamRuntimeLaneCoordinator.test.ts new file mode 100644 index 00000000..7ddbc914 --- /dev/null +++ b/src/features/team-runtime-lanes/main/composition/__tests__/createTeamRuntimeLaneCoordinator.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { createTeamRuntimeLaneCoordinator } from '../createTeamRuntimeLaneCoordinator'; + +describe('createTeamRuntimeLaneCoordinator', () => { + it('plans a mixed OpenCode side lane when the adapter is available', () => { + const coordinator = createTeamRuntimeLaneCoordinator(); + + const plan = coordinator.planProvisioningMembers({ + leadProviderId: 'codex', + hasOpenCodeRuntimeAdapter: true, + members: [ + { name: 'alice', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'tom', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + }); + + expect(coordinator.isMixedSideLanePlan(plan)).toBe(true); + expect(plan).toMatchObject({ + mode: 'mixed_opencode_side_lanes', + primaryMembers: [{ name: 'alice', providerId: 'codex', model: 'gpt-5.4' }], + sideLanes: [ + { + laneId: 'secondary:opencode:tom', + providerId: 'opencode', + member: { + name: 'tom', + providerId: 'opencode', + model: 'minimax-m2.5-free', + }, + }, + ], + }); + }); + + it('rejects a mixed OpenCode side lane when the runtime adapter is unavailable', () => { + const coordinator = createTeamRuntimeLaneCoordinator(); + + expect(() => + coordinator.planProvisioningMembers({ + leadProviderId: 'codex', + hasOpenCodeRuntimeAdapter: false, + members: [ + { name: 'alice', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'tom', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + }) + ).toThrow('Mixed teams with OpenCode side lanes require the OpenCode runtime adapter'); + }); +}); diff --git a/src/features/team-runtime-lanes/main/composition/createTeamRuntimeLaneCoordinator.ts b/src/features/team-runtime-lanes/main/composition/createTeamRuntimeLaneCoordinator.ts new file mode 100644 index 00000000..3b05e370 --- /dev/null +++ b/src/features/team-runtime-lanes/main/composition/createTeamRuntimeLaneCoordinator.ts @@ -0,0 +1,43 @@ +import { buildMixedPersistedLaunchSnapshot } from '@features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot'; +import { + fromProvisioningMembers, + isMixedOpenCodeSideLanePlan, + type TeamRuntimeLanePlan, +} from '@features/team-runtime-lanes/core/domain/planTeamRuntimeLanes'; + +import type { PersistedTeamLaunchSnapshot, TeamCreateRequest, TeamProviderId } from '@shared/types'; + +export interface TeamRuntimeLaneCoordinator { + planProvisioningMembers(params: { + leadProviderId?: TeamProviderId; + members: TeamCreateRequest['members']; + hasOpenCodeRuntimeAdapter: boolean; + }): TeamRuntimeLanePlan; + buildAggregateLaunchSnapshot( + params: Parameters[0] + ): PersistedTeamLaunchSnapshot; + isMixedSideLanePlan(plan: TeamRuntimeLanePlan): boolean; +} + +export function createTeamRuntimeLaneCoordinator(): TeamRuntimeLaneCoordinator { + return { + planProvisioningMembers(params) { + const lanePlan = fromProvisioningMembers(params.leadProviderId, params.members); + if (!lanePlan.ok) { + throw new Error(lanePlan.message); + } + if (isMixedOpenCodeSideLanePlan(lanePlan.plan) && !params.hasOpenCodeRuntimeAdapter) { + throw new Error( + 'Mixed teams with OpenCode side lanes require the OpenCode runtime adapter to be registered.' + ); + } + return lanePlan.plan; + }, + buildAggregateLaunchSnapshot(params) { + return buildMixedPersistedLaunchSnapshot(params); + }, + isMixedSideLanePlan(plan) { + return isMixedOpenCodeSideLanePlan(plan); + }, + }; +} diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index a086ae25..f19b2899 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -95,9 +95,10 @@ import { formatEffortLevelListForProvider, isTeamEffortLevelForProvider, } from '@shared/utils/effortLevels'; +import { isLeadMember } from '@shared/utils/leadDetection'; import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; -import { isTeamProviderId } from '@shared/utils/teamProvider'; +import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { buildStandaloneSlashCommandMeta, parseStandaloneSlashCommand, @@ -195,6 +196,7 @@ import type { TeamFastMode, TeamProviderBackendId, TeamProviderId, + TeamProvisioningModelVerificationMode, TeamProvisioningPrepareResult, TeamProvisioningProgress, TeamSummary, @@ -207,6 +209,7 @@ import type { UpdateKanbanPatch, } from '@shared/types'; import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser'; +import type { TeamMembersMetaFile } from '../services/team/TeamMembersMetaStore'; const logger = createLogger('IPC:teams'); @@ -1214,6 +1217,234 @@ function parseOptionalTeamFastMode( }; } +type RuntimeRosterMutationMember = { + name: string; + role?: string; + workflow?: string; + isolation?: 'worktree'; + providerId?: TeamProviderId; + providerBackendId?: TeamProviderBackendId; + model?: string; + effort?: EffortLevel; + fastMode?: TeamFastMode; + removedAt?: number | string | null; +}; + +const OPENCODE_LEAD_LIVE_ROSTER_MUTATION_BLOCK_MESSAGE = + 'Live roster mutation for a running OpenCode-led team is not supported in this phase. Stop the team, edit the roster, then relaunch.'; +const OPENCODE_OWNERSHIP_MIGRATION_BLOCK_MESSAGE = + 'Live member migration between OpenCode and the primary runtime owner is not supported in this phase. Stop the team, edit the roster, then relaunch.'; + +function isOpenCodeRosterMutationMember(member: RuntimeRosterMutationMember | undefined): boolean { + return normalizeOptionalTeamProviderId(member?.providerId) === 'opencode'; +} + +function isLeadRosterMutationMember(member: RuntimeRosterMutationMember | undefined): boolean { + if (!member) { + return false; + } + if (isLeadMember(member)) { + return true; + } + const normalizedName = member.name.trim().toLowerCase(); + if (normalizedName === 'lead') { + return true; + } + return member.role?.toLowerCase().includes('lead') === true; +} + +function isOpenCodeLedRoster(members: RuntimeRosterMutationMember[]): boolean { + const leadMember = members.find( + (member) => !member.removedAt && isLeadRosterMutationMember(member) + ); + return normalizeOptionalTeamProviderId(leadMember?.providerId) === 'opencode'; +} + +function didOpenCodeRosterMemberChange( + previous: RuntimeRosterMutationMember | undefined, + next: RuntimeRosterMutationMember | undefined +): boolean { + if (!previous || !next) { + return false; + } + + return ( + (previous.role?.trim() || undefined) !== (next.role?.trim() || undefined) || + (previous.workflow?.trim() || undefined) !== (next.workflow?.trim() || undefined) || + (previous.isolation === 'worktree' ? 'worktree' : undefined) !== + (next.isolation === 'worktree' ? 'worktree' : undefined) || + normalizeOptionalTeamProviderId(previous.providerId) !== + normalizeOptionalTeamProviderId(next.providerId) || + migrateProviderBackendId( + normalizeOptionalTeamProviderId(previous.providerId), + previous.providerBackendId + ) !== + migrateProviderBackendId( + normalizeOptionalTeamProviderId(next.providerId), + next.providerBackendId + ) || + (previous.model?.trim() || undefined) !== (next.model?.trim() || undefined) || + previous.effort !== next.effort || + previous.fastMode !== next.fastMode + ); +} + +function findOpenCodeOwnershipMigrationNames(options: { + previousMembers: RuntimeRosterMutationMember[]; + nextMembers: RuntimeRosterMutationMember[]; +}): string[] { + const previousByName = new Map( + options.previousMembers + .filter((member) => !member.removedAt) + .map((member) => [member.name.trim().toLowerCase(), member]) + ); + const migrationNames: string[] = []; + for (const nextMember of options.nextMembers) { + const previousMember = previousByName.get(nextMember.name.trim().toLowerCase()); + if (!previousMember) { + continue; + } + if ( + isOpenCodeRosterMutationMember(previousMember) !== isOpenCodeRosterMutationMember(nextMember) + ) { + migrationNames.push(nextMember.name.trim()); + } + } + return migrationNames; +} + +function toRollbackReplaceMembersRequest(members: RuntimeRosterMutationMember[]): { + members: { + name: string; + role?: string; + workflow?: string; + isolation?: 'worktree'; + providerId?: TeamProviderId; + providerBackendId?: TeamProviderBackendId; + model?: string; + effort?: EffortLevel; + fastMode?: TeamFastMode; + }[]; +} { + return { + members: members + .filter((member) => !member.removedAt && !isLeadRosterMutationMember(member)) + .map((member) => ({ + name: member.name.trim(), + role: member.role?.trim() || undefined, + workflow: member.workflow?.trim() || undefined, + isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined, + providerId: normalizeOptionalTeamProviderId(member.providerId), + providerBackendId: migrateProviderBackendId(member.providerId, member.providerBackendId), + model: member.model?.trim() || undefined, + effort: member.effort, + fastMode: member.fastMode, + })), + }; +} + +async function restorePreviousMembersMetaSnapshot(options: { + teamName: string; + teamDataService: TeamDataService; + previousMembers: RuntimeRosterMutationMember[]; + previousMembersMeta: TeamMembersMetaFile | null; +}): Promise { + const { teamName, teamDataService, previousMembers, previousMembersMeta } = options; + + if (previousMembersMeta) { + try { + await new TeamMembersMetaStore().writeMembers(teamName, previousMembersMeta.members, { + providerBackendId: previousMembersMeta.providerBackendId, + }); + return true; + } catch (error) { + logger.error( + `Failed to restore exact live OpenCode roster metadata for ${teamName}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + try { + await teamDataService.replaceMembers( + teamName, + toRollbackReplaceMembersRequest(previousMembers) + ); + return true; + } catch (error) { + logger.error( + `Failed to roll back fallback live OpenCode roster metadata for ${teamName}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return false; + } +} + +async function rollbackOpenCodeLiveRosterMutation(options: { + teamName: string; + teamDataService: TeamDataService; + provisioning: TeamProvisioningService; + previousMembers: RuntimeRosterMutationMember[]; + previousMembersMeta: TeamMembersMetaFile | null; + restoreOpenCodeMemberNames?: string[]; + detachOpenCodeMemberNames?: string[]; +}): Promise { + const { + teamName, + teamDataService, + provisioning, + previousMembers, + previousMembersMeta, + restoreOpenCodeMemberNames = [], + detachOpenCodeMemberNames = [], + } = options; + + const metadataRestored = await restorePreviousMembersMetaSnapshot({ + teamName, + teamDataService, + previousMembers, + previousMembersMeta, + }); + + const detachNames = Array.from( + new Set(detachOpenCodeMemberNames.map((memberName) => memberName.trim()).filter(Boolean)) + ); + for (const memberName of detachNames) { + try { + await provisioning.detachOpenCodeOwnedMemberLane(teamName, memberName); + } catch (error) { + logger.warn( + `Failed to clean up OpenCode lane for ${teamName}/${memberName} during rollback: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + if (!metadataRestored) { + return; + } + + const restoreNames = Array.from( + new Set(restoreOpenCodeMemberNames.map((memberName) => memberName.trim()).filter(Boolean)) + ); + for (const memberName of restoreNames) { + try { + await provisioning.reattachOpenCodeOwnedMemberLane(teamName, memberName, { + reason: 'member_updated', + }); + } catch (error) { + logger.warn( + `Failed to restore OpenCode lane for ${teamName}/${memberName} during rollback: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } +} + async function validateProvisioningRequest( request: unknown ): Promise<{ valid: true; value: TeamCreateRequest } | { valid: false; error: string }> { @@ -1702,13 +1933,15 @@ async function handlePrepareProvisioning( providerId: unknown, providerIds: unknown, selectedModels: unknown, - limitContext: unknown + limitContext: unknown, + modelVerificationMode: unknown ): Promise> { let validatedCwd: string | undefined; let validatedProviderId: TeamLaunchRequest['providerId']; let validatedProviderIds: TeamProviderId[] | undefined; let validatedSelectedModels: string[] | undefined; let validatedLimitContext: boolean | undefined; + let validatedModelVerificationMode: TeamProvisioningModelVerificationMode | undefined; if (cwd !== undefined) { if (typeof cwd !== 'string' || cwd.trim().length === 0) { return { success: false, error: 'cwd must be a non-empty string' }; @@ -1762,12 +1995,22 @@ async function handlePrepareProvisioning( } validatedLimitContext = limitContext; } + if (modelVerificationMode !== undefined) { + if (modelVerificationMode !== 'compatibility' && modelVerificationMode !== 'deep') { + return { + success: false, + error: 'modelVerificationMode must be compatibility or deep when provided', + }; + } + validatedModelVerificationMode = modelVerificationMode; + } return wrapTeamHandler('prepareProvisioning', () => getTeamProvisioningService().prepareForProvisioning(validatedCwd, { providerId: validatedProviderId, providerIds: validatedProviderIds, modelIds: validatedSelectedModels, limitContext: validatedLimitContext, + modelVerificationMode: validatedModelVerificationMode, }) ); } @@ -3238,7 +3481,17 @@ async function handleAddMember( return wrapTeamHandler('addMember', async () => { const tn = vTeam.value!; const memberName = vName.value!; - await getTeamDataService().addMember(tn, { + const teamDataService = getTeamDataService(); + const previousMembersMeta = await new TeamMembersMetaStore().getMeta(tn).catch(() => null); + const previousMembers = (await teamDataService.getTeamData(tn)) + .members as RuntimeRosterMutationMember[]; + const provisioning = getTeamProvisioningService(); + const isTeamAlive = provisioning.isTeamAlive(tn); + if (isTeamAlive && isOpenCodeLedRoster(previousMembers)) { + throw new Error(OPENCODE_LEAD_LIVE_ROSTER_MUTATION_BLOCK_MESSAGE); + } + + await teamDataService.addMember(tn, { name: memberName, role: role, workflow: typeof workflow === 'string' ? workflow.trim() || undefined : undefined, @@ -3249,9 +3502,26 @@ async function handleAddMember( }); // If team is alive, notify the lead to spawn the new teammate - const provisioning = getTeamProvisioningService(); - if (provisioning.isTeamAlive(tn)) { - const teamDataService = getTeamDataService(); + if (isTeamAlive) { + if (providerValidation.value === 'opencode') { + try { + await provisioning.reattachOpenCodeOwnedMemberLane(tn, memberName, { + reason: 'member_added', + }); + } catch (error) { + await rollbackOpenCodeLiveRosterMutation({ + teamName: tn, + teamDataService, + provisioning, + previousMembers, + previousMembersMeta, + detachOpenCodeMemberNames: [memberName], + }); + throw error; + } + return; + } + let leadName = 'team-lead'; let displayName = tn; try { @@ -3304,8 +3574,10 @@ async function handleReplaceMembers( workflow?: string; isolation?: 'worktree'; providerId?: TeamProviderId; + providerBackendId?: TeamProviderBackendId; model?: string; effort?: EffortLevel; + fastMode?: TeamFastMode; }[] = []; for (const item of payload.members) { if (!item || typeof item !== 'object') { @@ -3317,8 +3589,10 @@ async function handleReplaceMembers( workflow?: unknown; isolation?: unknown; providerId?: unknown; + providerBackendId?: unknown; model?: unknown; effort?: unknown; + fastMode?: unknown; }; const vName = validateTeammateName(m.name); if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' }; @@ -3340,6 +3614,13 @@ async function handleReplaceMembers( if (!providerValidation.valid) { return { success: false, error: providerValidation.error }; } + const providerBackendValidation = parseOptionalProviderBackendId( + (m as { providerBackendId?: unknown }).providerBackendId, + providerValidation.value + ); + if (!providerBackendValidation.valid) { + return { success: false, error: providerBackendValidation.error }; + } if (m.model !== undefined && typeof m.model !== 'string') { return { success: false, error: 'member model must be string' }; } @@ -3350,27 +3631,96 @@ async function handleReplaceMembers( if (!effortValidation.valid) { return { success: false, error: effortValidation.error }; } + const fastModeValidation = parseOptionalTeamFastMode((m as { fastMode?: unknown }).fastMode); + if (!fastModeValidation.valid) { + return { success: false, error: fastModeValidation.error }; + } members.push({ name, role: typeof m.role === 'string' ? m.role.trim() : undefined, workflow: typeof m.workflow === 'string' ? m.workflow.trim() : undefined, isolation: m.isolation === 'worktree' ? ('worktree' as const) : undefined, providerId: providerValidation.value, + providerBackendId: providerBackendValidation.value, model: typeof m.model === 'string' ? m.model.trim() || undefined : undefined, effort: effortValidation.value, + fastMode: fastModeValidation.value, }); } return wrapTeamHandler('replaceMembers', async () => { const tn = vTeam.value!; const teamDataService = getTeamDataService(); - const previousMembers = (await teamDataService.getTeamData(tn)).members; - const diff = buildReplaceMembersDiff(previousMembers, members); + const previousMembersMeta = await new TeamMembersMetaStore().getMeta(tn).catch(() => null); + const previousMembers = (await teamDataService.getTeamData(tn)) + .members as RuntimeRosterMutationMember[]; + const provisioning = getTeamProvisioningService(); + const isTeamAlive = provisioning.isTeamAlive(tn); + const useSecondaryOpenCodeLaneRouting = isTeamAlive && !isOpenCodeLedRoster(previousMembers); + if (isTeamAlive && !useSecondaryOpenCodeLaneRouting) { + throw new Error(OPENCODE_LEAD_LIVE_ROSTER_MUTATION_BLOCK_MESSAGE); + } + if (useSecondaryOpenCodeLaneRouting) { + const ownershipMigrationNames = findOpenCodeOwnershipMigrationNames({ + previousMembers, + nextMembers: members, + }); + if (ownershipMigrationNames.length > 0) { + throw new Error( + `${OPENCODE_OWNERSHIP_MIGRATION_BLOCK_MESSAGE} Affected member(s): ${ownershipMigrationNames.join(', ')}` + ); + } + } + const primaryDiff = buildReplaceMembersDiff( + previousMembers.filter((member) => + useSecondaryOpenCodeLaneRouting ? !isOpenCodeRosterMutationMember(member) : true + ), + members.filter((member) => + useSecondaryOpenCodeLaneRouting ? !isOpenCodeRosterMutationMember(member) : true + ) + ); + const previousByName = new Map( + previousMembers + .filter((member) => !member.removedAt) + .map((member) => [member.name.trim().toLowerCase(), member as RuntimeRosterMutationMember]) + ); + const nextByName = new Map( + members.map((member) => [ + member.name.trim().toLowerCase(), + member as RuntimeRosterMutationMember, + ]) + ); + const removedOpenCodeMembers = useSecondaryOpenCodeLaneRouting + ? previousMembers.filter((member) => { + const normalizedName = member.name.trim().toLowerCase(); + return ( + !member.removedAt && + isOpenCodeRosterMutationMember(member) && + !nextByName.has(normalizedName) + ); + }) + : []; + const addedOpenCodeMembers = useSecondaryOpenCodeLaneRouting + ? members.filter((member) => { + const normalizedName = member.name.trim().toLowerCase(); + return isOpenCodeRosterMutationMember(member) && !previousByName.has(normalizedName); + }) + : []; + const updatedOpenCodeMembers = useSecondaryOpenCodeLaneRouting + ? members.filter((member) => { + const normalizedName = member.name.trim().toLowerCase(); + const previousMember = previousByName.get(normalizedName); + return ( + isOpenCodeRosterMutationMember(member) && + isOpenCodeRosterMutationMember(previousMember) && + didOpenCodeRosterMemberChange(previousMember, member) + ); + }) + : []; await teamDataService.replaceMembers(tn, { members }); - const provisioning = getTeamProvisioningService(); - if (!provisioning.isTeamAlive(tn)) { + if (!isTeamAlive) { return; } @@ -3387,7 +3737,39 @@ async function handleReplaceMembers( // Best-effort: fall back to default lead and team names } - for (const addedMember of diff.added) { + try { + for (const removedMember of removedOpenCodeMembers) { + await provisioning.detachOpenCodeOwnedMemberLane(tn, removedMember.name); + } + + for (const addedMember of addedOpenCodeMembers) { + await provisioning.reattachOpenCodeOwnedMemberLane(tn, addedMember.name, { + reason: 'member_added', + }); + } + + for (const updatedMember of updatedOpenCodeMembers) { + await provisioning.reattachOpenCodeOwnedMemberLane(tn, updatedMember.name, { + reason: 'member_updated', + }); + } + } catch (error) { + await rollbackOpenCodeLiveRosterMutation({ + teamName: tn, + teamDataService, + provisioning, + previousMembers, + previousMembersMeta, + restoreOpenCodeMemberNames: [ + ...removedOpenCodeMembers.map((member) => member.name), + ...updatedOpenCodeMembers.map((member) => member.name), + ], + detachOpenCodeMemberNames: addedOpenCodeMembers.map((member) => member.name), + }); + throw error; + } + + for (const addedMember of primaryDiff.added) { const spawnMessage = buildAddMemberSpawnMessage(tn, displayName, leadName, addedMember); try { await provisioning.sendMessageToTeam(tn, spawnMessage); @@ -3396,7 +3778,7 @@ async function handleReplaceMembers( } } - const summaryMessage = buildReplaceMembersSummaryMessage(diff); + const summaryMessage = buildReplaceMembersSummaryMessage(primaryDiff); if (!summaryMessage) { return; } @@ -3421,11 +3803,39 @@ async function handleRemoveMember( return wrapTeamHandler('removeMember', async () => { const tn = vTeam.value!; const name = vMember.value!; - await getTeamDataService().removeMember(tn, name); + const teamDataService = getTeamDataService(); + const previousMembersMeta = await new TeamMembersMetaStore().getMeta(tn).catch(() => null); + const previousMembers = (await teamDataService.getTeamData(tn)) + .members as RuntimeRosterMutationMember[]; + const provisioning = getTeamProvisioningService(); + const isTeamAlive = provisioning.isTeamAlive(tn); + if (isTeamAlive && isOpenCodeLedRoster(previousMembers)) { + throw new Error(OPENCODE_LEAD_LIVE_ROSTER_MUTATION_BLOCK_MESSAGE); + } + const removedMember = previousMembers.find( + (member) => member.name.trim().toLowerCase() === name.trim().toLowerCase() + ); + await teamDataService.removeMember(tn, name); // Notify the lead about removed member - const provisioning = getTeamProvisioningService(); - if (provisioning.isTeamAlive(tn)) { + if (isTeamAlive) { + if (isOpenCodeRosterMutationMember(removedMember)) { + try { + await provisioning.detachOpenCodeOwnedMemberLane(tn, name); + } catch (error) { + await rollbackOpenCodeLiveRosterMutation({ + teamName: tn, + teamDataService, + provisioning, + previousMembers, + previousMembersMeta, + restoreOpenCodeMemberNames: [name], + }); + throw error; + } + return; + } + const message = `Teammate "${name}" has been removed from the team. ` + `They will no longer participate in team activities. Please reassign their tasks if needed.`; diff --git a/src/main/services/team/TeamBackupService.ts b/src/main/services/team/TeamBackupService.ts index 932a3a09..7d12808e 100644 --- a/src/main/services/team/TeamBackupService.ts +++ b/src/main/services/team/TeamBackupService.ts @@ -59,6 +59,8 @@ const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024; const TEAM_ROOT_FILES = [ 'config.json', 'team.meta.json', + 'launch-state.json', + 'launch-summary.json', 'kanban-state.json', 'sentMessages.json', 'sent-cross-team.json', @@ -68,6 +70,7 @@ const TEAM_ROOT_FILES = [ // Subdirs under ~/.claude/teams/{teamName}/ const TEAM_SUBDIRS = ['inboxes', 'review-decisions']; +const TEAM_RECURSIVE_SUBDIRS = ['.opencode-runtime']; // Subdirs under getAppDataPath() (our own storage, not in ~/.claude/) const APP_DATA_SUBDIRS = ['attachments']; const APP_DATA_DEEP_SUBDIRS = ['task-attachments']; @@ -102,6 +105,57 @@ function isValidConfig(content: string): boolean { } } +async function collectRecursiveFiles( + rootDir: string, + relPrefix: string +): Promise { + const files: BackupFileDescriptor[] = []; + const walk = async (dirPath: string, relDir: string): Promise => { + const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const sourcePath = path.join(dirPath, entry.name); + const relPath = relDir ? `${relDir}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + await walk(sourcePath, relPath); + continue; + } + if (entry.isFile()) { + files.push({ + sourcePath, + relPath: relPrefix ? `${relPrefix}/${relPath}` : relPath, + }); + } + } + }; + + await walk(rootDir, ''); + return files; +} + +function collectRecursiveFilesSync(rootDir: string, relPrefix: string): BackupFileDescriptor[] { + const files: BackupFileDescriptor[] = []; + const walk = (dirPath: string, relDir: string): void => { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const sourcePath = path.join(dirPath, entry.name); + const relPath = relDir ? `${relDir}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + walk(sourcePath, relPath); + continue; + } + if (entry.isFile()) { + files.push({ + sourcePath, + relPath: relPrefix ? `${relPrefix}/${relPath}` : relPath, + }); + } + } + }; + + walk(rootDir, ''); + return files; +} + // --------------------------------------------------------------------------- // TeamBackupService // --------------------------------------------------------------------------- @@ -734,6 +788,15 @@ export class TeamBackupService { } } + for (const subdir of TEAM_RECURSIVE_SUBDIRS) { + const dirPath = path.join(teamDir, subdir); + try { + files.push(...(await collectRecursiveFiles(dirPath, subdir))); + } catch (err: unknown) { + if (!isEnoent(err)) hasErrors = true; + } + } + // Flat subdirs under app data dir (attachments/) const appDataDir = getAppDataPath(); for (const subdir of APP_DATA_SUBDIRS) { @@ -830,6 +893,14 @@ export class TeamBackupService { } } + for (const subdir of TEAM_RECURSIVE_SUBDIRS) { + try { + files.push(...collectRecursiveFilesSync(path.join(teamDir, subdir), subdir)); + } catch { + // skip + } + } + // Flat subdirs under app data dir (attachments/) const appDataDir = getAppDataPath(); for (const subdir of APP_DATA_SUBDIRS) { diff --git a/src/main/services/team/TeamBootstrapStateReader.ts b/src/main/services/team/TeamBootstrapStateReader.ts index dc808c5d..25835fa5 100644 --- a/src/main/services/team/TeamBootstrapStateReader.ts +++ b/src/main/services/team/TeamBootstrapStateReader.ts @@ -19,6 +19,7 @@ const MAX_BOOTSTRAP_STATE_BYTES = 256 * 1024; const MAX_BOOTSTRAP_JOURNAL_BYTES = 256 * 1024; const MAX_BOOTSTRAP_LOCK_METADATA_BYTES = 64 * 1024; const ACTIVE_BOOTSTRAP_STUCK_CLASSIFICATION_MS = 3 * 60 * 1000; +const TERMINAL_BOOTSTRAP_ONLY_PENDING_GRACE_MS = 5 * 60 * 1000; interface RawBootstrapMemberState { name?: unknown; @@ -810,13 +811,84 @@ export async function clearBootstrapState(teamName: string): Promise { } } +function isLaunchSnapshotLike(value: unknown): value is PersistedTeamLaunchSnapshot { + return ( + Boolean(value) && + typeof value === 'object' && + Array.isArray((value as PersistedTeamLaunchSnapshot).expectedMembers) && + typeof (value as PersistedTeamLaunchSnapshot).members === 'object' && + (value as PersistedTeamLaunchSnapshot).members !== null + ); +} + +function getLaunchSnapshotRichness(snapshot: PersistedTeamLaunchSnapshot): number { + let metadataScore = 0; + for (const member of Object.values(snapshot.members)) { + if (!member || typeof member !== 'object') continue; + if (member.providerId) metadataScore += 3; + if (member.providerBackendId) metadataScore += 3; + if (member.selectedFastMode) metadataScore += 2; + if (typeof member.resolvedFastMode === 'boolean') metadataScore += 2; + if (member.laneId) metadataScore += 4; + if (member.laneKind) metadataScore += 4; + if (member.laneOwnerProviderId) metadataScore += 3; + if (member.launchIdentity) metadataScore += 6; + } + return ( + snapshot.expectedMembers.length * 10 + + Object.keys(snapshot.members).length * 5 + + metadataScore + + (snapshot.bootstrapExpectedMembers?.length ? 20 : 0) + ); +} + +export function shouldIgnoreTerminalBootstrapOnlyPendingSnapshot( + snapshot: Pick, + nowMs: number = Date.now() +): boolean { + if (snapshot.launchPhase !== 'finished' || snapshot.teamLaunchState !== 'partial_pending') { + return false; + } + + const updatedAtMs = Date.parse(snapshot.updatedAt); + if (!Number.isFinite(updatedAtMs)) { + return false; + } + + return nowMs - updatedAtMs >= TERMINAL_BOOTSTRAP_ONLY_PENDING_GRACE_MS; +} + export function choosePreferredLaunchSnapshot( bootstrapSnapshot: T | null, launchSnapshot: T | null ): T | null { if (!bootstrapSnapshot) return launchSnapshot; + if ( + !launchSnapshot && + isLaunchSnapshotLike(bootstrapSnapshot) && + shouldIgnoreTerminalBootstrapOnlyPendingSnapshot(bootstrapSnapshot) + ) { + return null; + } if (!launchSnapshot) return bootstrapSnapshot; + if (isLaunchSnapshotLike(bootstrapSnapshot) && isLaunchSnapshotLike(launchSnapshot)) { + const bootstrapRichness = getLaunchSnapshotRichness(bootstrapSnapshot); + const launchRichness = getLaunchSnapshotRichness(launchSnapshot); + if ( + launchRichness > bootstrapRichness && + launchSnapshot.expectedMembers.length >= bootstrapSnapshot.expectedMembers.length + ) { + return launchSnapshot as T; + } + if ( + bootstrapRichness > launchRichness && + bootstrapSnapshot.expectedMembers.length >= launchSnapshot.expectedMembers.length + ) { + return bootstrapSnapshot as T; + } + } + const bootstrapMs = Date.parse(bootstrapSnapshot.updatedAt ?? ''); const launchMs = Date.parse(launchSnapshot.updatedAt ?? ''); if (Number.isFinite(bootstrapMs) && Number.isFinite(launchMs)) { diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index f8c4fd49..4132b5cb 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -9,16 +9,26 @@ import { import * as fs from 'fs'; import * as path from 'path'; -import { - choosePreferredLaunchSnapshot, - readBootstrapLaunchSnapshot, -} from './TeamBootstrapStateReader'; +import { readBootstrapLaunchSnapshot } from './TeamBootstrapStateReader'; import { getTeamFsWorkerClient } from './TeamFsWorkerClient'; import { normalizePersistedLaunchSnapshot } from './TeamLaunchStateEvaluator'; +import { + type LaunchStateSummary, + choosePreferredLaunchStateSummary, + normalizePersistedLaunchSummaryProjection, + shouldSuppressLegacyLaunchArtifactHeuristic, + TEAM_LAUNCH_SUMMARY_FILE, +} from './TeamLaunchSummaryProjection'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMetaStore } from './TeamMetaStore'; -import type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@shared/types'; +import type { + TeamConfig, + TeamMember, + TeamProviderId, + TeamSummary, + TeamSummaryMember, +} from '@shared/types'; const logger = createLogger('Service:TeamConfigReader'); @@ -77,67 +87,43 @@ function resolveProjectPathFromConfig( return undefined; } -interface LaunchStateSummary { - partialLaunchFailure?: true; - expectedMemberCount?: number; - confirmedMemberCount?: number; - missingMembers?: string[]; - teamLaunchState?: TeamSummary['teamLaunchState']; - launchUpdatedAt?: string; - confirmedCount?: number; - pendingCount?: number; - failedCount?: number; - runtimeAlivePendingCount?: number; -} - async function readLaunchStateSummary(teamDir: string): Promise { const bootstrapSnapshot = await readBootstrapLaunchSnapshot(path.basename(teamDir)); const launchStatePath = path.join(teamDir, TEAM_LAUNCH_STATE_FILE); - const launchSnapshot = await (async () => { - try { - const stat = await fs.promises.stat(launchStatePath); - if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) { + const launchSummaryPath = path.join(teamDir, TEAM_LAUNCH_SUMMARY_FILE); + const [launchSnapshot, launchSummaryProjection] = await Promise.all([ + (async () => { + try { + const stat = await fs.promises.stat(launchStatePath); + if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) { + return null; + } + + const raw = await readFileUtf8WithTimeout(launchStatePath, PER_TEAM_READ_TIMEOUT_MS); + return normalizePersistedLaunchSnapshot(path.basename(teamDir), JSON.parse(raw)); + } catch { return null; } + })(), + (async () => { + try { + const stat = await fs.promises.stat(launchSummaryPath); + if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) { + return null; + } + const raw = await readFileUtf8WithTimeout(launchSummaryPath, PER_TEAM_READ_TIMEOUT_MS); + return normalizePersistedLaunchSummaryProjection(path.basename(teamDir), JSON.parse(raw)); + } catch { + return null; + } + })(), + ]); - const raw = await readFileUtf8WithTimeout(launchStatePath, PER_TEAM_READ_TIMEOUT_MS); - return normalizePersistedLaunchSnapshot(path.basename(teamDir), JSON.parse(raw)); - } catch { - return null; - } - })(); - - const snapshot = choosePreferredLaunchSnapshot(bootstrapSnapshot, launchSnapshot); - if (!snapshot) { - return null; - } - - try { - const missingMembers = snapshot.expectedMembers.filter((name) => { - const member = snapshot.members[name]; - return member?.launchState === 'failed_to_start'; - }); - return { - ...(snapshot.teamLaunchState === 'partial_failure' - ? { partialLaunchFailure: true as const } - : {}), - ...(snapshot.expectedMembers.length > 0 - ? { expectedMemberCount: snapshot.expectedMembers.length } - : {}), - ...(snapshot.summary.confirmedCount > 0 - ? { confirmedMemberCount: snapshot.summary.confirmedCount } - : {}), - ...(missingMembers.length > 0 ? { missingMembers } : {}), - teamLaunchState: snapshot.teamLaunchState, - launchUpdatedAt: snapshot.updatedAt, - confirmedCount: snapshot.summary.confirmedCount, - pendingCount: snapshot.summary.pendingCount, - failedCount: snapshot.summary.failedCount, - runtimeAlivePendingCount: snapshot.summary.runtimeAlivePendingCount, - }; - } catch { - return null; - } + return choosePreferredLaunchStateSummary({ + bootstrapSnapshot, + launchSnapshot, + launchSummaryProjection, + }); } async function mapLimit( @@ -171,7 +157,8 @@ function withReadTimeout(promise: Promise, ms: number): Promise { export class TeamConfigReader { constructor( - private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore() + private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(), + private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore() ) {} async listTeams(): Promise { @@ -251,6 +238,7 @@ export class TeamConfigReader { try { let config: TeamConfig | null = null; + let leadProviderId: TeamProviderId | undefined; let displayName: string | null = null; let description = ''; let color: string | undefined; @@ -319,6 +307,7 @@ export class TeamConfigReader { const removedKeys = new Set(); const expectedTeammateNames = new Set(); const confirmedArtifactNames = new Set(); + let metaMembers: TeamMember[] = []; const mergeMember = (m: TeamMember): void => { const name = m.name?.trim(); @@ -339,7 +328,7 @@ export class TeamConfigReader { // Also read members.meta.json β€” UI-created teams store members there, // and CLI-created teams may have additional members added via the UI. try { - const metaMembers = await this.membersMetaStore.getMembers(teamName); + metaMembers = await this.membersMetaStore.getMembers(teamName); for (const member of metaMembers) { const name = member.name?.trim(); if (!name) continue; @@ -357,6 +346,12 @@ export class TeamConfigReader { // best-effort β€” don't fail listing if meta file is broken } + try { + leadProviderId = (await this.teamMetaStore.getMeta(teamName))?.providerId; + } catch { + leadProviderId = undefined; + } + // Merge config members AFTER meta so removedAt can suppress stale config entries. if (config && Array.isArray(config.members)) { for (const member of config.members) { @@ -383,11 +378,15 @@ export class TeamConfigReader { // best-effort } - // Defense: drop CLI auto-suffixed duplicates (alice-2) when base name exists. - const allNames = Array.from(memberMap.values()).map((m) => m.name); - const keepName = createCliAutoSuffixNameGuard(allNames); + // Defense: drop CLI auto-suffixed duplicates (alice-2) only when the + // base name is still active. Removed base members must not hide active + // suffixed teammates in summary/list paths. + const activeNamesForAutoSuffix = Array.from(memberMap.values()) + .map((member) => member.name) + .filter((name) => !removedKeys.has(name.trim().toLowerCase())); + const keepName = createCliAutoSuffixNameGuard(activeNamesForAutoSuffix); // Defense: drop CLI provisioner artifacts (alice-provisioner) when base name exists. - const keepProvisioner = createCliProvisionerNameGuard(allNames); + const keepProvisioner = createCliProvisionerNameGuard(activeNamesForAutoSuffix); for (const [key, member] of Array.from(memberMap.entries())) { if (!keepName(member.name) || !keepProvisioner(member.name)) { memberMap.delete(key); @@ -395,9 +394,16 @@ export class TeamConfigReader { } const members = Array.from(memberMap.values()); + const suppressLegacyLaunchArtifactHeuristic = shouldSuppressLegacyLaunchArtifactHeuristic({ + leadProviderId, + members: metaMembers, + }); const launchStateSummary = (await readLaunchStateSummary(teamDir)) ?? (() => { + if (suppressLegacyLaunchArtifactHeuristic) { + return null; + } if ( !leadSessionId || expectedTeammateNames.size === 0 || @@ -470,7 +476,13 @@ export class TeamConfigReader { try { const metaStore = new TeamMembersMetaStore(); const members = await metaStore.getMembers(teamName); - memberCount = members.length; + memberCount = members.filter((member) => { + const name = member.name?.trim() ?? ''; + if (!name || name === 'user' || isLeadMember(member)) { + return false; + } + return !member.removedAt; + }).length; } catch { // best-effort } diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 962052fd..81a807f1 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -1,6 +1,10 @@ import { yieldToEventLoop } from '@main/utils/asyncYield'; import { getClaudeBasePath, getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder'; import { killProcessByPid } from '@main/utils/processKill'; +import { + fromProvisioningMembers, + isMixedOpenCodeSideLanePlan, +} from '@features/team-runtime-lanes/core/domain/planTeamRuntimeLanes'; import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN, @@ -12,6 +16,7 @@ import { isTeamEffortLevel } from '@shared/utils/effortLevels'; import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics'; import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; +import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState'; import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands'; @@ -41,9 +46,15 @@ import { } from './mergeLiveLeadProcessMessages'; import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils'; import { TeamConfigReader } from './TeamConfigReader'; +import { + choosePreferredLaunchSnapshot, + readBootstrapLaunchSnapshot, +} from './TeamBootstrapStateReader'; import { TeamInboxReader } from './TeamInboxReader'; import { TeamInboxWriter } from './TeamInboxWriter'; import { TeamKanbanManager } from './TeamKanbanManager'; +import { hasMixedPersistedLaunchMetadata } from './TeamLaunchStateEvaluator'; +import { TeamLaunchStateStore } from './TeamLaunchStateStore'; import { TeamMemberResolver } from './TeamMemberResolver'; import { TeamMemberRuntimeAdvisoryService } from './TeamMemberRuntimeAdvisoryService'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; @@ -67,6 +78,7 @@ import type { KanbanColumnId, KanbanState, MessagesPage, + ReplaceMembersRequest, SendMessageRequest, SendMessageResult, TaskAttachmentMeta, @@ -76,6 +88,7 @@ import type { TeamConfig, TeamCreateConfigRequest, TeamMember, + TeamMemberSnapshot, TeamMemberActivityMeta, TeamProcess, TeamProviderId, @@ -88,6 +101,7 @@ import type { UpdateKanbanPatch, } from '@shared/types'; import type { AgentTeamsController } from 'agent-teams-controller'; +import type { TeamMetaFile } from './TeamMetaStore'; const { createController } = agentTeamsControllerModule; @@ -100,6 +114,92 @@ const PROCESS_HEALTH_INTERVAL_MS = 2_000; const TASK_MAP_YIELD_EVERY = 250; const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification'; const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000; +const MIXED_TEAM_LIVE_MUTATION_BLOCK_MESSAGE = + 'Live roster mutation on a running mixed team is not supported in V1. Stop the team, edit the roster, then relaunch.'; + +function resolveEffectiveMemberProviderId( + leadProviderId: TeamProviderId | undefined, + member: ReturnType[number] | undefined +): TeamProviderId { + return normalizeOptionalTeamProviderId(member?.providerId) ?? leadProviderId ?? 'anthropic'; +} + +function isSupportedRunningMixedRosterMutation(params: { + leadProviderId: TeamProviderId | undefined; + previousMembers: ReturnType; + nextMembers: ReturnType; +}): boolean { + if (params.leadProviderId === 'opencode') { + return false; + } + + const previousByName = new Map( + params.previousMembers.map((member) => [member.name.trim().toLowerCase(), member]) + ); + const nextByName = new Map( + params.nextMembers.map((member) => [member.name.trim().toLowerCase(), member]) + ); + const candidateNames = new Set([...previousByName.keys(), ...nextByName.keys()]); + + for (const candidateName of candidateNames) { + const previous = previousByName.get(candidateName); + const next = nextByName.get(candidateName); + const previousProviderId = resolveEffectiveMemberProviderId(params.leadProviderId, previous); + const nextProviderId = resolveEffectiveMemberProviderId(params.leadProviderId, next); + + if (!previous && next) { + if (nextProviderId !== 'opencode') { + return false; + } + continue; + } + + if (previous && !next) { + if (previousProviderId !== 'opencode') { + return false; + } + continue; + } + + if (!previous || !next) { + continue; + } + + if (previousProviderId !== nextProviderId) { + return false; + } + + if (previousProviderId !== 'opencode') { + const stablePrimaryShape = JSON.stringify({ + name: previous.name, + role: previous.role, + workflow: previous.workflow, + isolation: previous.isolation, + providerId: previous.providerId, + providerBackendId: previous.providerBackendId, + model: previous.model, + effort: previous.effort, + fastMode: previous.fastMode, + }); + const nextPrimaryShape = JSON.stringify({ + name: next.name, + role: next.role, + workflow: next.workflow, + isolation: next.isolation, + providerId: next.providerId, + providerBackendId: next.providerBackendId, + model: next.model, + effort: next.effort, + fastMode: next.fastMode, + }); + if (stablePrimaryShape !== nextPrimaryShape) { + return false; + } + } + } + + return true; +} function requireCanonicalMessageId(message: InboxMessage): string { const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; @@ -168,6 +268,81 @@ function extractPassiveUserPeerSummaryBody(text: string): string | null { return body.length > 0 ? body : null; } +function hasVisibleLeadMember(members: readonly TeamMemberSnapshot[]): boolean { + return members.some((member) => { + if (isLeadMember(member)) { + return true; + } + const normalizedName = member.name.trim().toLowerCase(); + if (normalizedName === 'lead') { + return true; + } + return member.role?.toLowerCase().includes('lead') === true; + }); +} + +function hasExplicitLeadInConfig(config: TeamConfig): boolean { + return (config.members ?? []).some((member) => { + if (isLeadMember(member)) { + return true; + } + const normalizedName = member.name?.trim().toLowerCase() ?? ''; + if (normalizedName === 'lead') { + return true; + } + return member.role?.toLowerCase().includes('lead') === true; + }); +} + +function toProvisioningMemberShape( + members: readonly Pick< + TeamMember, + | 'name' + | 'role' + | 'workflow' + | 'isolation' + | 'providerId' + | 'providerBackendId' + | 'model' + | 'effort' + | 'fastMode' + | 'removedAt' + >[] +): Array<{ + name: string; + role?: string; + workflow?: string; + isolation?: 'worktree'; + providerId?: TeamProviderId; + providerBackendId?: TeamMember['providerBackendId']; + model?: string; + effort?: TeamMember['effort']; + fastMode?: TeamMember['fastMode']; +}> { + return members + .filter((member) => !member.removedAt) + .filter((member) => { + const normalizedName = member.name.trim(); + return ( + normalizedName.length > 0 && !isLeadMember({ name: normalizedName, agentType: undefined }) + ); + }) + .map((member) => ({ + name: member.name.trim(), + role: member.role, + workflow: member.workflow, + isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined, + providerId: normalizeOptionalTeamProviderId(member.providerId), + providerBackendId: member.providerBackendId, + model: member.model, + effort: isTeamEffortLevel(member.effort) ? member.effort : undefined, + fastMode: + member.fastMode === 'inherit' || member.fastMode === 'on' || member.fastMode === 'off' + ? member.fastMode + : undefined, + })); +} + interface FileWatchReconcileTrigger { source: 'inbox' | 'task'; detail?: string; @@ -208,7 +383,8 @@ export class TeamDataService { private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache(), private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver( configReader - ) + ), + private readonly launchStateStore: TeamLaunchStateStore = new TeamLaunchStateStore() ) { this.messageFeedService = new TeamMessageFeedService({ getConfig: (teamName) => this.configReader.getConfig(teamName), @@ -223,10 +399,135 @@ export class TeamDataService { return this.controllerFactory(teamName); } + private async readTeamLaneMutationContext(teamName: string): Promise<{ + leadProviderId: TeamProviderId | undefined; + activeMembers: ReturnType; + currentMixed: boolean; + }> { + const [teamMeta, activeMembersRaw, bootstrapSnapshot, persistedLaunchSnapshot] = + await Promise.all([ + this.teamMetaStore.getMeta(teamName).catch(() => null), + this.membersMetaStore.getMembers(teamName).catch(() => []), + readBootstrapLaunchSnapshot(teamName).catch(() => null), + this.launchStateStore.read(teamName).catch(() => null), + ]); + + const preferredLaunchSnapshot = choosePreferredLaunchSnapshot( + bootstrapSnapshot, + persistedLaunchSnapshot + ); + const leadProviderId = + teamMeta?.launchIdentity?.providerId ?? normalizeOptionalTeamProviderId(teamMeta?.providerId); + const activeMembers = toProvisioningMemberShape(activeMembersRaw); + const currentPlan = fromProvisioningMembers(leadProviderId, activeMembers); + const currentMixed = + hasMixedPersistedLaunchMetadata(preferredLaunchSnapshot) || + (currentPlan.ok && isMixedOpenCodeSideLanePlan(currentPlan.plan)); + + return { + leadProviderId, + activeMembers, + currentMixed, + }; + } + + private async assertRosterMutationAllowed( + teamName: string, + nextMembers: ReturnType + ): Promise { + const context = await this.readTeamLaneMutationContext(teamName); + const nextPlan = fromProvisioningMembers(context.leadProviderId, nextMembers); + if (!nextPlan.ok) { + throw new Error(nextPlan.message); + } + const nextMixed = isMixedOpenCodeSideLanePlan(nextPlan.plan); + if (!(context.currentMixed || nextMixed)) { + return; + } + const isRunning = (await this.readProcesses(teamName).catch(() => [] as TeamProcess[])).some( + (process) => !process.stoppedAt + ); + if (isRunning) { + if ( + !isSupportedRunningMixedRosterMutation({ + leadProviderId: context.leadProviderId, + previousMembers: context.activeMembers, + nextMembers, + }) + ) { + throw new Error(MIXED_TEAM_LIVE_MUTATION_BLOCK_MESSAGE); + } + } + } + setMemberRuntimeAdvisoryService(service: TeamMemberRuntimeAdvisoryService): void { this.memberRuntimeAdvisoryService = service; } + private async synthesizeLeadMemberIfMissing( + teamName: string, + config: TeamConfig, + members: TeamMemberSnapshot[], + tasks: TeamTaskWithKanban[], + teamMeta?: TeamMetaFile | null + ): Promise { + if (hasVisibleLeadMember(members) || hasExplicitLeadInConfig(config)) { + return; + } + + if (typeof teamMeta === 'undefined') { + try { + teamMeta = await this.teamMetaStore.getMeta(teamName); + } catch { + teamMeta = null; + } + } + + const launchIdentity = teamMeta?.launchIdentity; + const leadName = 'team-lead'; + const ownedTasks = tasks.filter((task) => task.owner === leadName); + const currentTask = + ownedTasks.find( + (task) => + task.status === 'in_progress' && + task.reviewState !== 'approved' && + task.kanbanColumn !== 'approved' + ) ?? null; + + members.unshift({ + name: leadName, + agentId: undefined, + currentTaskId: currentTask?.id ?? null, + taskCount: ownedTasks.length, + color: getMemberColorByName(leadName), + agentType: 'team-lead', + role: 'Team Lead', + workflow: undefined, + isolation: undefined, + providerId: launchIdentity?.providerId ?? teamMeta?.providerId, + providerBackendId: + launchIdentity?.providerBackendId ?? + migrateProviderBackendId(teamMeta?.providerId, teamMeta?.providerBackendId) ?? + undefined, + model: + launchIdentity?.resolvedLaunchModel ?? launchIdentity?.selectedModel ?? teamMeta?.model, + effort: + launchIdentity?.resolvedEffort ?? + launchIdentity?.selectedEffort ?? + (isTeamEffortLevel(teamMeta?.effort) ? teamMeta?.effort : undefined), + selectedFastMode: launchIdentity?.selectedFastMode ?? teamMeta?.fastMode ?? undefined, + resolvedFastMode: + typeof launchIdentity?.resolvedFastMode === 'boolean' + ? launchIdentity.resolvedFastMode + : undefined, + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: launchIdentity?.providerId ?? teamMeta?.providerId ?? 'anthropic', + cwd: config.projectPath ?? teamMeta?.cwd, + removedAt: undefined, + }); + } + private getTaskLabel(task: Pick): string { return formatTaskDisplayLabel(task); } @@ -809,6 +1110,24 @@ export class TeamDataService { warningText: 'Member metadata failed to load', load: () => this.membersMetaStore.getMembers(teamName), }); + const teamMetaStep = startReadStep({ + label: 'teamMeta', + createFallback: () => null, + warningText: 'Team runtime metadata failed to load', + load: () => this.teamMetaStore.getMeta(teamName), + }); + const launchStateStep = startReadStep({ + label: 'launchState', + createFallback: () => null, + warningText: 'Launch state failed to load', + load: async () => { + const [bootstrapSnapshot, launchSnapshot] = await Promise.all([ + readBootstrapLaunchSnapshot(teamName), + this.launchStateStore.read(teamName), + ]); + return choosePreferredLaunchSnapshot(bootstrapSnapshot, launchSnapshot); + }, + }); const kanbanStateStep = startReadStep({ label: 'kanbanState', createFallback: (): KanbanState => ({ @@ -827,8 +1146,21 @@ export class TeamDataService { load: () => this.taskReader.getTasks(teamName), }) ); - const [tasksStepResult, inboxNamesStepResult, metaMembersStepResult, kanbanStateStepResult] = - await Promise.all([tasksStep, inboxNamesStep, metaMembersStep, kanbanStateStep]); + const [ + tasksStepResult, + inboxNamesStepResult, + metaMembersStepResult, + teamMetaStepResult, + launchStateStepResult, + kanbanStateStepResult, + ] = await Promise.all([ + tasksStep, + inboxNamesStep, + metaMembersStep, + teamMetaStep, + launchStateStep, + kanbanStateStep, + ]); // After parallelizing the top read phase, these marks no longer represent // serial stage boundaries. They now capture the actual completion time for @@ -837,11 +1169,15 @@ export class TeamDataService { marks.tasks = tasksStepResult.completedAt; marks.inboxNames = inboxNamesStepResult.completedAt; marks.metaMembers = metaMembersStepResult.completedAt; + marks.teamMeta = teamMetaStepResult.completedAt; + marks.launchState = launchStateStepResult.completedAt; marks.kanbanState = kanbanStateStepResult.completedAt; if (tasksStepResult.warning) warnings.push(tasksStepResult.warning); if (inboxNamesStepResult.warning) warnings.push(inboxNamesStepResult.warning); if (metaMembersStepResult.warning) warnings.push(metaMembersStepResult.warning); + if (teamMetaStepResult.warning) warnings.push(teamMetaStepResult.warning); + if (launchStateStepResult.warning) warnings.push(launchStateStepResult.warning); if (kanbanStateStepResult.warning) warnings.push(kanbanStateStepResult.warning); const tasks: TeamTask[] = tasksStepResult.value; @@ -849,6 +1185,8 @@ export class TeamDataService { mark('postStart'); const metaMembers: TeamConfig['members'] = metaMembersStepResult.value; + const teamMeta: TeamMetaFile | null = teamMetaStepResult.value; + const launchSnapshot = launchStateStepResult.value; const kanbanState: KanbanState = kanbanStateStepResult.value; mark('kanbanGc'); @@ -879,8 +1217,22 @@ export class TeamDataService { config, metaMembers, inboxNames, - tasksWithKanban + tasksWithKanban, + { + launchSnapshot, + leadProviderId: teamMeta?.launchIdentity?.providerId ?? teamMeta?.providerId, + leadProviderBackendId: + teamMeta?.launchIdentity?.providerBackendId ?? + migrateProviderBackendId(teamMeta?.providerId, teamMeta?.providerBackendId) ?? + undefined, + leadFastMode: teamMeta?.launchIdentity?.selectedFastMode ?? teamMeta?.fastMode ?? undefined, + leadResolvedFastMode: + typeof teamMeta?.launchIdentity?.resolvedFastMode === 'boolean' + ? teamMeta.launchIdentity.resolvedFastMode + : undefined, + } ); + await this.synthesizeLeadMemberIfMissing(teamName, config, members, tasksWithKanban, teamMeta); mark('resolveMembers'); try { @@ -1279,16 +1631,17 @@ export class TeamDataService { role: request.role?.trim() || undefined, workflow: request.workflow?.trim() || undefined, isolation: request.isolation === 'worktree' ? ('worktree' as const) : undefined, - providerId: - request.providerId === 'codex' || request.providerId === 'gemini' - ? request.providerId - : undefined, + providerId: normalizeOptionalTeamProviderId(request.providerId), model: request.model?.trim() || undefined, effort: isTeamEffortLevel(request.effort) ? request.effort : undefined, agentType: 'general-purpose', joinedAt: Date.now(), }; + await this.assertRosterMutationAllowed( + teamName, + toProvisioningMemberShape([...members, newMember]) + ); const nextMembers = applyDistinctRosterColors([...members, newMember]); await this.membersMetaStore.writeMembers(teamName, nextMembers); } @@ -1311,20 +1664,7 @@ export class TeamDataService { return { oldRole, changed: true }; } - async replaceMembers( - teamName: string, - request: { - members: { - name: string; - role?: string; - workflow?: string; - isolation?: 'worktree'; - providerId?: TeamProviderId; - model?: string; - effort?: TeamMember['effort']; - }[]; - } - ): Promise { + async replaceMembers(teamName: string, request: ReplaceMembersRequest): Promise { const existing = await this.membersMetaStore.getMembers(teamName); const existingLead = existing.find(isLeadMember) ?? null; const existingByName = new Map(existing.map((m) => [m.name.toLowerCase(), m])); @@ -1363,8 +1703,13 @@ export class TeamDataService { workflow: member.workflow?.trim() || undefined, isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined, providerId: normalizeOptionalTeamProviderId(member.providerId), + providerBackendId: migrateProviderBackendId(member.providerId, member.providerBackendId), model: member.model?.trim() || undefined, effort: isTeamEffortLevel(member.effort) ? member.effort : undefined, + fastMode: + member.fastMode === 'inherit' || member.fastMode === 'on' || member.fastMode === 'off' + ? member.fastMode + : undefined, agentType: prev?.agentType ?? 'general-purpose', agentId: isSameActiveMember ? prev?.agentId : undefined, color: prev?.color, @@ -1373,6 +1718,7 @@ export class TeamDataService { }; }) ); + await this.assertRosterMutationAllowed(teamName, toProvisioningMemberShape(nextActive)); // Preserve/mark removed members so stale inbox files don't resurrect them in the UI. const nextRemoved: TeamMember[] = []; @@ -1408,6 +1754,14 @@ export class TeamDataService { throw new Error('Cannot remove team lead'); } + await this.assertRosterMutationAllowed( + teamName, + toProvisioningMemberShape( + members.filter( + (candidate) => candidate.name.trim().toLowerCase() !== memberName.trim().toLowerCase() + ) + ) + ); member.removedAt = Date.now(); await this.membersMetaStore.writeMembers(teamName, members); } diff --git a/src/main/services/team/TeamLaunchStateEvaluator.ts b/src/main/services/team/TeamLaunchStateEvaluator.ts index 331d7f79..63fee42d 100644 --- a/src/main/services/team/TeamLaunchStateEvaluator.ts +++ b/src/main/services/team/TeamLaunchStateEvaluator.ts @@ -1,9 +1,12 @@ import { isLeadMember } from '@shared/utils/leadDetection'; +import { migrateProviderBackendId } from '@shared/utils/providerBackend'; +import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import type { MemberLaunchState, MemberSpawnLivenessSource, MemberSpawnStatusEntry, + ProviderModelLaunchIdentity, PersistedTeamLaunchMemberSources, PersistedTeamLaunchMemberState, PersistedTeamLaunchPhase, @@ -106,6 +109,27 @@ export function summarizePersistedLaunchMembers( return { confirmedCount, pendingCount, failedCount, runtimeAlivePendingCount }; } +export function hasMixedPersistedLaunchMetadata( + snapshot: PersistedTeamLaunchSnapshot | null | undefined +): boolean { + if (!snapshot) { + return false; + } + if ( + Array.isArray(snapshot.bootstrapExpectedMembers) && + snapshot.bootstrapExpectedMembers.join('\u0000') !== snapshot.expectedMembers.join('\u0000') + ) { + return true; + } + return Object.values(snapshot.members).some( + (member) => + Boolean(member?.laneId) || + Boolean(member?.laneKind) || + Boolean(member?.laneOwnerProviderId) || + Boolean(member?.launchIdentity) + ); +} + function deriveMemberLaunchState( member: Pick< PersistedTeamLaunchMemberState, @@ -128,6 +152,83 @@ function toBoolean(value: unknown): boolean { return value === true; } +function normalizeFastMode(value: unknown): PersistedTeamLaunchMemberState['selectedFastMode'] { + return value === 'inherit' || value === 'on' || value === 'off' ? value : undefined; +} + +function normalizeLaunchIdentity( + value: unknown, + fallbackProviderId?: PersistedTeamLaunchMemberState['providerId'] +): ProviderModelLaunchIdentity | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + const raw = value as Record; + const providerId = + normalizeOptionalTeamProviderId(raw.providerId) ?? + normalizeOptionalTeamProviderId(fallbackProviderId); + if (!providerId) { + return undefined; + } + const selectedModelKind = + raw.selectedModelKind === 'explicit' || raw.selectedModelKind === 'default' + ? raw.selectedModelKind + : 'default'; + const catalogSource = + raw.catalogSource === 'anthropic-models-api' || + raw.catalogSource === 'app-server' || + raw.catalogSource === 'static-fallback' || + raw.catalogSource === 'runtime' || + raw.catalogSource === 'unavailable' + ? raw.catalogSource + : 'unavailable'; + return { + providerId, + providerBackendId: + migrateProviderBackendId( + providerId, + typeof raw.providerBackendId === 'string' ? raw.providerBackendId : undefined + ) ?? null, + selectedModel: typeof raw.selectedModel === 'string' ? raw.selectedModel.trim() || null : null, + selectedModelKind, + resolvedLaunchModel: + typeof raw.resolvedLaunchModel === 'string' ? raw.resolvedLaunchModel.trim() || null : null, + catalogId: typeof raw.catalogId === 'string' ? raw.catalogId.trim() || null : null, + catalogSource, + catalogFetchedAt: + typeof raw.catalogFetchedAt === 'string' ? raw.catalogFetchedAt.trim() || null : null, + selectedEffort: + raw.selectedEffort === 'none' || + raw.selectedEffort === 'minimal' || + raw.selectedEffort === 'low' || + raw.selectedEffort === 'medium' || + raw.selectedEffort === 'high' || + raw.selectedEffort === 'xhigh' || + raw.selectedEffort === 'max' + ? raw.selectedEffort + : null, + resolvedEffort: + raw.resolvedEffort === 'none' || + raw.resolvedEffort === 'minimal' || + raw.resolvedEffort === 'low' || + raw.resolvedEffort === 'medium' || + raw.resolvedEffort === 'high' || + raw.resolvedEffort === 'xhigh' || + raw.resolvedEffort === 'max' + ? raw.resolvedEffort + : null, + selectedFastMode: + raw.selectedFastMode === 'inherit' || + raw.selectedFastMode === 'on' || + raw.selectedFastMode === 'off' + ? raw.selectedFastMode + : null, + resolvedFastMode: typeof raw.resolvedFastMode === 'boolean' ? raw.resolvedFastMode : null, + fastResolutionReason: + typeof raw.fastResolutionReason === 'string' ? raw.fastResolutionReason.trim() || null : null, + }; +} + function normalizeSources(value: unknown): PersistedTeamLaunchMemberSources | undefined { if (!value || typeof value !== 'object') { return undefined; @@ -158,8 +259,35 @@ function normalizePersistedMemberState( if (!normalizedName || normalizedName === 'user' || isLeadMember({ name: normalizedName })) { return null; } + const providerId = normalizeOptionalTeamProviderId(parsed.providerId); const next: PersistedTeamLaunchMemberState = { name: normalizedName, + providerId, + providerBackendId: migrateProviderBackendId( + providerId, + typeof parsed.providerBackendId === 'string' ? parsed.providerBackendId : undefined + ), + model: typeof parsed.model === 'string' ? parsed.model.trim() || undefined : undefined, + effort: + parsed.effort === 'none' || + parsed.effort === 'minimal' || + parsed.effort === 'low' || + parsed.effort === 'medium' || + parsed.effort === 'high' || + parsed.effort === 'xhigh' || + parsed.effort === 'max' + ? parsed.effort + : undefined, + selectedFastMode: normalizeFastMode(parsed.selectedFastMode), + resolvedFastMode: + typeof parsed.resolvedFastMode === 'boolean' ? parsed.resolvedFastMode : undefined, + laneId: typeof parsed.laneId === 'string' ? parsed.laneId.trim() || undefined : undefined, + laneKind: + parsed.laneKind === 'primary' || parsed.laneKind === 'secondary' + ? parsed.laneKind + : undefined, + laneOwnerProviderId: normalizeOptionalTeamProviderId(parsed.laneOwnerProviderId), + launchIdentity: normalizeLaunchIdentity(parsed.launchIdentity, providerId), launchState: 'starting', agentToolAccepted: toBoolean(parsed.agentToolAccepted), runtimeAlive: toBoolean(parsed.runtimeAlive), @@ -199,6 +327,7 @@ function normalizePersistedMemberState( export function createPersistedLaunchSnapshot(params: { teamName: string; expectedMembers: readonly string[]; + bootstrapExpectedMembers?: readonly string[]; leadSessionId?: string; launchPhase?: PersistedTeamLaunchPhase; members?: Record; @@ -212,6 +341,13 @@ export function createPersistedLaunchSnapshot(params: { .filter((name) => name.length > 0 && name !== 'user' && !isLeadMember({ name })) ) ); + const bootstrapExpectedMembers = Array.from( + new Set( + (params.bootstrapExpectedMembers ?? expectedMembers) + .map(normalizeMemberName) + .filter((name) => name.length > 0 && name !== 'user' && !isLeadMember({ name })) + ) + ); const members = params.members ?? {}; const launchPhase = params.launchPhase ?? 'active'; @@ -246,6 +382,10 @@ export function createPersistedLaunchSnapshot(params: { ...(params.leadSessionId ? { leadSessionId: params.leadSessionId } : {}), launchPhase, expectedMembers, + ...(bootstrapExpectedMembers.length > 0 && + bootstrapExpectedMembers.join('\u0000') !== expectedMembers.join('\u0000') + ? { bootstrapExpectedMembers } + : {}), members, summary, teamLaunchState: deriveTeamLaunchAggregateState(summary), @@ -416,6 +556,11 @@ export function normalizePersistedLaunchSnapshot( (name): name is string => typeof name === 'string' && normalizeMemberName(name).length > 0 ) : []; + const bootstrapExpectedMembers = Array.isArray(record.bootstrapExpectedMembers) + ? record.bootstrapExpectedMembers.filter( + (name): name is string => typeof name === 'string' && normalizeMemberName(name).length > 0 + ) + : undefined; const updatedAt = typeof record.updatedAt === 'string' && record.updatedAt.trim().length > 0 ? record.updatedAt @@ -446,6 +591,7 @@ export function normalizePersistedLaunchSnapshot( record.launchPhase === 'reconciled' ? record.launchPhase : 'finished', + bootstrapExpectedMembers, members: normalizedMembers, updatedAt, }); diff --git a/src/main/services/team/TeamLaunchStateStore.ts b/src/main/services/team/TeamLaunchStateStore.ts index 556f3b0f..2f0dd0cf 100644 --- a/src/main/services/team/TeamLaunchStateStore.ts +++ b/src/main/services/team/TeamLaunchStateStore.ts @@ -5,6 +5,10 @@ import * as path from 'path'; import { atomicWriteAsync } from './atomicWrite'; import { normalizePersistedLaunchSnapshot } from './TeamLaunchStateEvaluator'; +import { + createPersistedLaunchSummaryProjection, + TEAM_LAUNCH_SUMMARY_FILE, +} from './TeamLaunchSummaryProjection'; import type { PersistedTeamLaunchSnapshot } from '@shared/types'; @@ -16,6 +20,10 @@ export function getTeamLaunchStatePath(teamName: string): string { return path.join(getTeamsBasePath(), teamName, TEAM_LAUNCH_STATE_FILE); } +export function getTeamLaunchSummaryPath(teamName: string): string { + return path.join(getTeamsBasePath(), teamName, TEAM_LAUNCH_SUMMARY_FILE); +} + export class TeamLaunchStateStore { async read(teamName: string): Promise { const targetPath = getTeamLaunchStatePath(teamName); @@ -37,6 +45,10 @@ export class TeamLaunchStateStore { getTeamLaunchStatePath(teamName), `${JSON.stringify(snapshot, null, 2)}\n` ); + await atomicWriteAsync( + getTeamLaunchSummaryPath(teamName), + `${JSON.stringify(createPersistedLaunchSummaryProjection(snapshot), null, 2)}\n` + ); } catch (error) { logger.warn( `[${teamName}] Failed to persist launch-state: ${ @@ -49,6 +61,7 @@ export class TeamLaunchStateStore { async clear(teamName: string): Promise { try { await fs.promises.rm(getTeamLaunchStatePath(teamName), { force: true }); + await fs.promises.rm(getTeamLaunchSummaryPath(teamName), { force: true }); } catch { // best-effort } diff --git a/src/main/services/team/TeamLaunchSummaryProjection.ts b/src/main/services/team/TeamLaunchSummaryProjection.ts new file mode 100644 index 00000000..200e75be --- /dev/null +++ b/src/main/services/team/TeamLaunchSummaryProjection.ts @@ -0,0 +1,224 @@ +import { + isMixedOpenCodeSideLanePlan, + planTeamRuntimeLanes, +} from '@features/team-runtime-lanes/core/domain/planTeamRuntimeLanes'; +import { shouldIgnoreTerminalBootstrapOnlyPendingSnapshot } from './TeamBootstrapStateReader'; +import { hasMixedPersistedLaunchMetadata } from './TeamLaunchStateEvaluator'; +import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; + +import type { PersistedTeamLaunchSnapshot, TeamProviderId, TeamSummary } from '@shared/types'; + +export const TEAM_LAUNCH_SUMMARY_FILE = 'launch-summary.json'; + +export interface LaunchStateSummary { + partialLaunchFailure?: true; + expectedMemberCount?: number; + confirmedMemberCount?: number; + missingMembers?: string[]; + teamLaunchState?: TeamSummary['teamLaunchState']; + launchUpdatedAt?: string; + confirmedCount?: number; + pendingCount?: number; + failedCount?: number; + runtimeAlivePendingCount?: number; +} + +export interface PersistedTeamLaunchSummaryProjection extends LaunchStateSummary { + version: 1; + teamName: string; + updatedAt: string; + mixedAware?: true; +} + +function normalizeIsoDate(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function toMillis(value: string | undefined | null): number { + if (!value) { + return Number.NaN; + } + return Date.parse(value); +} + +export function createLaunchStateSummary( + snapshot: PersistedTeamLaunchSnapshot +): LaunchStateSummary { + const missingMembers = snapshot.expectedMembers.filter((name) => { + const member = snapshot.members[name]; + return member?.launchState === 'failed_to_start'; + }); + + return { + ...(snapshot.teamLaunchState === 'partial_failure' + ? { partialLaunchFailure: true as const } + : {}), + ...(snapshot.expectedMembers.length > 0 + ? { expectedMemberCount: snapshot.expectedMembers.length } + : {}), + ...(snapshot.summary.confirmedCount > 0 + ? { confirmedMemberCount: snapshot.summary.confirmedCount } + : {}), + ...(missingMembers.length > 0 ? { missingMembers } : {}), + teamLaunchState: snapshot.teamLaunchState, + launchUpdatedAt: snapshot.updatedAt, + confirmedCount: snapshot.summary.confirmedCount, + pendingCount: snapshot.summary.pendingCount, + failedCount: snapshot.summary.failedCount, + runtimeAlivePendingCount: snapshot.summary.runtimeAlivePendingCount, + }; +} + +export function createPersistedLaunchSummaryProjection( + snapshot: PersistedTeamLaunchSnapshot +): PersistedTeamLaunchSummaryProjection { + return { + version: 1, + teamName: snapshot.teamName, + updatedAt: snapshot.updatedAt, + ...(hasMixedPersistedLaunchMetadata(snapshot) ? { mixedAware: true as const } : {}), + ...createLaunchStateSummary(snapshot), + }; +} + +export function normalizePersistedLaunchSummaryProjection( + teamName: string, + value: unknown +): PersistedTeamLaunchSummaryProjection | null { + if (!value || typeof value !== 'object') { + return null; + } + const record = value as Record; + if (record.version !== 1) { + return null; + } + const updatedAt = normalizeIsoDate(record.updatedAt); + if (!updatedAt) { + return null; + } + + const normalized: PersistedTeamLaunchSummaryProjection = { + version: 1, + teamName, + updatedAt, + ...(record.mixedAware === true ? { mixedAware: true as const } : {}), + }; + + if (record.partialLaunchFailure === true) { + normalized.partialLaunchFailure = true; + } + if (typeof record.expectedMemberCount === 'number' && record.expectedMemberCount >= 0) { + normalized.expectedMemberCount = record.expectedMemberCount; + } + if (typeof record.confirmedMemberCount === 'number' && record.confirmedMemberCount >= 0) { + normalized.confirmedMemberCount = record.confirmedMemberCount; + } + if (Array.isArray(record.missingMembers)) { + const missingMembers = record.missingMembers.filter( + (member): member is string => typeof member === 'string' && member.trim().length > 0 + ); + if (missingMembers.length > 0) { + normalized.missingMembers = missingMembers; + } + } + if ( + record.teamLaunchState === 'partial_failure' || + record.teamLaunchState === 'partial_pending' || + record.teamLaunchState === 'clean_success' + ) { + normalized.teamLaunchState = record.teamLaunchState; + } + if (typeof record.confirmedCount === 'number' && record.confirmedCount >= 0) { + normalized.confirmedCount = record.confirmedCount; + } + if (typeof record.pendingCount === 'number' && record.pendingCount >= 0) { + normalized.pendingCount = record.pendingCount; + } + if (typeof record.failedCount === 'number' && record.failedCount >= 0) { + normalized.failedCount = record.failedCount; + } + if (typeof record.runtimeAlivePendingCount === 'number' && record.runtimeAlivePendingCount >= 0) { + normalized.runtimeAlivePendingCount = record.runtimeAlivePendingCount; + } + normalized.launchUpdatedAt = updatedAt; + return normalized; +} + +export function choosePreferredLaunchStateSummary(params: { + bootstrapSnapshot?: PersistedTeamLaunchSnapshot | null; + launchSnapshot?: PersistedTeamLaunchSnapshot | null; + launchSummaryProjection?: PersistedTeamLaunchSummaryProjection | null; +}): LaunchStateSummary | null { + if (params.launchSnapshot) { + return createLaunchStateSummary(params.launchSnapshot); + } + + const bootstrapSnapshot = params.bootstrapSnapshot ?? null; + const projection = params.launchSummaryProjection ?? null; + if (!bootstrapSnapshot) { + return projection; + } + if (!projection && shouldIgnoreTerminalBootstrapOnlyPendingSnapshot(bootstrapSnapshot)) { + return null; + } + if (!projection) { + return createLaunchStateSummary(bootstrapSnapshot); + } + + const bootstrapMixedAware = hasMixedPersistedLaunchMetadata(bootstrapSnapshot); + const projectionMixedAware = projection.mixedAware === true; + if (projectionMixedAware !== bootstrapMixedAware) { + return projectionMixedAware ? projection : createLaunchStateSummary(bootstrapSnapshot); + } + + const projectionUpdatedAtMs = toMillis(projection.updatedAt); + const bootstrapUpdatedAtMs = toMillis(bootstrapSnapshot.updatedAt); + if (!Number.isFinite(bootstrapUpdatedAtMs)) { + return projection; + } + if (!Number.isFinite(projectionUpdatedAtMs)) { + return createLaunchStateSummary(bootstrapSnapshot); + } + return projectionUpdatedAtMs >= bootstrapUpdatedAtMs + ? projection + : createLaunchStateSummary(bootstrapSnapshot); +} + +export function shouldSuppressLegacyLaunchArtifactHeuristic(params: { + leadProviderId?: TeamProviderId; + members: readonly { name: string; providerId?: TeamProviderId; removedAt?: unknown }[]; +}): boolean { + const liveMembers = params.members + .filter((member) => !member.removedAt) + .map((member) => ({ + name: member.name.trim(), + providerId: normalizeOptionalTeamProviderId(member.providerId), + })) + .filter((member) => member.name.length > 0); + + if (liveMembers.length === 0) { + return false; + } + + const normalizedLeadProviderId = normalizeOptionalTeamProviderId(params.leadProviderId); + const hasOpenCodeProvider = + normalizedLeadProviderId === 'opencode' || + liveMembers.some((member) => member.providerId === 'opencode'); + const hasNonOpenCodeProvider = + (normalizedLeadProviderId != null && normalizedLeadProviderId !== 'opencode') || + liveMembers.some((member) => member.providerId != null && member.providerId !== 'opencode'); + if (hasOpenCodeProvider && hasNonOpenCodeProvider) { + return true; + } + + const plan = planTeamRuntimeLanes({ + leadProviderId: normalizedLeadProviderId, + members: liveMembers, + }); + + return plan.ok && isMixedOpenCodeSideLanePlan(plan.plan); +} diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index 45ee41ba..5905cc3b 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -1,16 +1,20 @@ import { getMemberColorByName } from '@shared/constants/memberColors'; +import { buildPlannedMemberLaneIdentity } from '@features/team-runtime-lanes/core/domain/planTeamRuntimeLanes'; import { createCliAutoSuffixNameGuard, createCliProvisionerNameGuard, } from '@shared/utils/teamMemberName'; import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; +import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import type { TeamConfig, + PersistedTeamLaunchSnapshot, TeamMember, TeamMemberSnapshot, + TeamProviderBackendId, TeamProviderId, TeamTaskWithKanban, } from '@shared/types'; @@ -65,7 +69,14 @@ export class TeamMemberResolver { config: TeamConfig, metaMembers: TeamConfig['members'], inboxNames: string[], - tasks: TeamTaskWithKanban[] + tasks: TeamTaskWithKanban[], + options?: { + launchSnapshot?: PersistedTeamLaunchSnapshot | null; + leadProviderId?: TeamProviderId; + leadProviderBackendId?: TeamProviderBackendId | null; + leadFastMode?: TeamMember['fastMode']; + leadResolvedFastMode?: boolean | null; + } ): TeamMemberSnapshot[] { const names = new Set(); const explicitNames = new Set(); @@ -99,6 +110,22 @@ export class TeamMemberResolver { } } + const launchSnapshot = options?.launchSnapshot; + if (launchSnapshot) { + for (const name of launchSnapshot.expectedMembers) { + const trimmed = name.trim(); + if (!trimmed) continue; + addName(trimmed); + explicitNames.add(trimmed.toLowerCase()); + } + for (const name of Object.keys(launchSnapshot.members)) { + const trimmed = name.trim(); + if (!trimmed) continue; + addName(trimmed); + explicitNames.add(trimmed.toLowerCase()); + } + } + for (const inboxName of inboxNames) { if (typeof inboxName === 'string' && inboxName.trim() !== '') { const trimmed = inboxName.trim(); @@ -130,8 +157,10 @@ export class TeamMemberResolver { workflow?: string; isolation?: 'worktree'; providerId?: TeamProviderId; + providerBackendId?: TeamProviderBackendId; model?: string; effort?: TeamMember['effort']; + fastMode?: TeamMember['fastMode']; color?: string; cwd?: string; } @@ -150,8 +179,15 @@ export class TeamMemberResolver { workflow: configMember.workflow, isolation: configMember.isolation === 'worktree' ? ('worktree' as const) : undefined, providerId, + providerBackendId: migrateProviderBackendId(providerId, configMember.providerBackendId), model: configMember.model, effort: configMember.effort, + fastMode: + configMember.fastMode === 'inherit' || + configMember.fastMode === 'on' || + configMember.fastMode === 'off' + ? configMember.fastMode + : undefined, color: configMember.color, cwd: configMember.cwd, }); @@ -168,9 +204,12 @@ export class TeamMemberResolver { workflow?: string; isolation?: 'worktree'; providerId?: TeamProviderId; + providerBackendId?: TeamProviderBackendId; model?: string; effort?: TeamMember['effort']; + fastMode?: TeamMember['fastMode']; color?: string; + cwd?: string; removedAt?: number; } >(); @@ -184,15 +223,36 @@ export class TeamMemberResolver { workflow: member.workflow, isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined, providerId: member.providerId, + providerBackendId: migrateProviderBackendId( + member.providerId, + member.providerBackendId + ), model: member.model, effort: member.effort, + fastMode: + member.fastMode === 'inherit' || member.fastMode === 'on' || member.fastMode === 'off' + ? member.fastMode + : undefined, color: member.color, + cwd: member.cwd, removedAt: member.removedAt, }); } } } + const launchMemberMap = new Map< + string, + NonNullable['members'][string]> + >(); + if (launchSnapshot) { + for (const [memberName, member] of Object.entries(launchSnapshot.members)) { + if (typeof memberName === 'string' && memberName.trim().length > 0 && member) { + launchMemberMap.set(memberName.trim(), member); + } + } + } + // "user" is a built-in pseudo-member in Claude Code's team framework // (recipient of SendMessage to "user"). It's not a real AI teammate. names.delete('user'); @@ -204,8 +264,13 @@ export class TeamMemberResolver { names.delete('lead'); } - // Defense: hide CLI auto-suffixed duplicates (alice-2) when base name (alice) exists. - const keepName = createCliAutoSuffixNameGuard(names); + // Defense: hide CLI auto-suffixed duplicates (alice-2) only when the base + // name still exists as an active member. Removed base members must not hide + // active suffixed teammates after live mutation / rollback flows. + const activeNamesForAutoSuffix = Array.from(names).filter((name) => { + return !metaMemberMap.get(name)?.removedAt; + }); + const keepName = createCliAutoSuffixNameGuard(activeNamesForAutoSuffix); // Defense: hide CLI provisioner artifacts (alice-provisioner) when base name (alice) exists. const keepProvisioner = createCliProvisionerNameGuard(names); for (const name of Array.from(names)) { @@ -226,6 +291,26 @@ export class TeamMemberResolver { ) ?? null; const configMember = configMemberMap.get(name); const metaMember = metaMemberMap.get(name); + const launchMember = launchMemberMap.get(name); + const effectiveProviderId = + launchMember?.providerId ?? + configMember?.providerId ?? + metaMember?.providerId ?? + options?.leadProviderId; + const plannedLane = buildPlannedMemberLaneIdentity({ + leadProviderId: options?.leadProviderId, + member: { + name, + providerId: effectiveProviderId, + }, + }); + const providerBackendId = + launchMember?.providerBackendId ?? + configMember?.providerBackendId ?? + metaMember?.providerBackendId ?? + (effectiveProviderId === options?.leadProviderId + ? (options?.leadProviderBackendId ?? undefined) + : undefined); const agentId = configMember?.agentId ?? metaMember?.agentId; members.push({ name, @@ -237,10 +322,27 @@ export class TeamMemberResolver { role: configMember?.role ?? metaMember?.role, workflow: configMember?.workflow ?? metaMember?.workflow, isolation: configMember?.isolation ?? metaMember?.isolation, - providerId: configMember?.providerId ?? metaMember?.providerId, - model: configMember?.model ?? metaMember?.model, - effort: configMember?.effort ?? metaMember?.effort, - cwd: configMember?.cwd, + providerId: effectiveProviderId, + providerBackendId, + model: launchMember?.model ?? configMember?.model ?? metaMember?.model, + effort: launchMember?.effort ?? configMember?.effort ?? metaMember?.effort, + selectedFastMode: + launchMember?.selectedFastMode ?? + configMember?.fastMode ?? + metaMember?.fastMode ?? + (effectiveProviderId === options?.leadProviderId + ? (options?.leadFastMode ?? undefined) + : undefined), + resolvedFastMode: + typeof launchMember?.resolvedFastMode === 'boolean' + ? launchMember.resolvedFastMode + : effectiveProviderId === options?.leadProviderId + ? (options?.leadResolvedFastMode ?? undefined) + : undefined, + laneId: launchMember?.laneId ?? plannedLane.laneId, + laneKind: launchMember?.laneKind ?? plannedLane.laneKind, + laneOwnerProviderId: launchMember?.laneOwnerProviderId ?? plannedLane.laneOwnerProviderId, + cwd: configMember?.cwd ?? metaMember?.cwd, removedAt: metaMember?.removedAt, }); } diff --git a/src/main/services/team/TeamMembersMetaStore.ts b/src/main/services/team/TeamMembersMetaStore.ts index 2a5fbe58..63d201c5 100644 --- a/src/main/services/team/TeamMembersMetaStore.ts +++ b/src/main/services/team/TeamMembersMetaStore.ts @@ -1,6 +1,7 @@ import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { getTeamsBasePath } from '@main/utils/pathDecoder'; import { isTeamEffortLevel } from '@shared/utils/effortLevels'; +import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import * as fs from 'fs'; @@ -26,28 +27,46 @@ function normalizeOptionalBackendId(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } +function normalizeFastMode(value: unknown): TeamMember['fastMode'] { + return value === 'inherit' || value === 'on' || value === 'off' ? value : undefined; +} + function normalizeMember(member: TeamMember): TeamMember | null { const trimmedName = member.name?.trim(); if (!trimmedName) { return null; } + const providerId = normalizeOptionalTeamProviderId(member.providerId); return { name: trimmedName, role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined, workflow: typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined, isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined, - providerId: normalizeOptionalTeamProviderId(member.providerId), + providerId, + providerBackendId: migrateProviderBackendId( + providerId, + normalizeOptionalBackendId(member.providerBackendId) + ), model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined, effort: isTeamEffortLevel(member.effort) ? member.effort : undefined, + fastMode: normalizeFastMode(member.fastMode), agentType: typeof member.agentType === 'string' ? member.agentType.trim() || undefined : undefined, color: typeof member.color === 'string' ? member.color.trim() || undefined : undefined, joinedAt: typeof member.joinedAt === 'number' ? member.joinedAt : undefined, agentId: typeof member.agentId === 'string' ? member.agentId : undefined, + cwd: typeof member.cwd === 'string' ? member.cwd.trim() || undefined : undefined, removedAt: typeof member.removedAt === 'number' ? member.removedAt : undefined, }; } +function buildActiveNameGuard(membersByName: Map): (name: string) => boolean { + const activeNames = Array.from(membersByName.values()) + .filter((member) => !member.removedAt) + .map((member) => member.name); + return createCliAutoSuffixNameGuard(activeNames); +} + export class TeamMembersMetaStore { private getMetaPath(teamName: string): string { return path.join(getTeamsBasePath(), teamName, 'members.meta.json'); @@ -106,9 +125,11 @@ export class TeamMembersMetaStore { deduped.set(normalized.name, normalized); } - // Defense: drop CLI auto-suffixed duplicates (alice-2) when base name exists. + // Defense: drop CLI auto-suffixed duplicates (alice-2) only when the base + // name is still active. Removed base members must not hide active suffixed + // teammates after live mutation / rollback flows. const allNames = Array.from(deduped.keys()); - const keepName = createCliAutoSuffixNameGuard(allNames); + const keepName = buildActiveNameGuard(deduped); for (const name of allNames) { if (!keepName(name)) { deduped.delete(name); @@ -140,9 +161,11 @@ export class TeamMembersMetaStore { deduped.set(normalized.name, normalized); } - // Defense: drop CLI auto-suffixed duplicates (alice-2) when base name exists. + // Defense: drop CLI auto-suffixed duplicates (alice-2) only when the base + // name is still active. Removed base members must not hide active suffixed + // teammates after live mutation / rollback flows. const allNames = Array.from(deduped.keys()); - const keepName = createCliAutoSuffixNameGuard(allNames); + const keepName = buildActiveNameGuard(deduped); for (const name of allNames) { if (!keepName(name)) { deduped.delete(name); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 4b72a2b6..c65a5a48 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -11,6 +11,13 @@ import { resolveCodexFastMode, resolveCodexRuntimeSelection, } from '@features/codex-runtime-profile/main'; +import { + buildPlannedMemberLaneIdentity, + fromProvisioningMembers, + isMixedOpenCodeSideLanePlan, + type TeamRuntimeLanePlan, +} from '@features/team-runtime-lanes/core/domain/planTeamRuntimeLanes'; +import { createTeamRuntimeLaneCoordinator } from '@features/team-runtime-lanes/main/composition/createTeamRuntimeLaneCoordinator'; import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; import { NotificationManager } from '@main/services/infrastructure/NotificationManager'; import { getAppIconPath } from '@main/utils/appIcon'; @@ -127,6 +134,7 @@ import { TeamInboxReader } from './TeamInboxReader'; import { TeamInboxWriter } from './TeamInboxWriter'; import { createPersistedLaunchSnapshot, + hasMixedPersistedLaunchMetadata, snapshotFromRuntimeMemberStatuses, snapshotToMemberSpawnStatuses, } from './TeamLaunchStateEvaluator'; @@ -141,6 +149,8 @@ import { type TeamRuntimeLaunchInput, type TeamRuntimeLaunchResult, type TeamRuntimeMemberLaunchEvidence, + type TeamRuntimePrepareResult, + type TeamRuntimeStopInput, } from './runtime'; import { RuntimeDeliveryDestinationRegistry, @@ -149,7 +159,16 @@ import { type RuntimeDeliveryDestinationPort, } from './opencode/delivery/RuntimeDeliveryService'; import { createRuntimeDeliveryJournalStore } from './opencode/delivery/RuntimeDeliveryJournal'; -import { getOpenCodeTeamRuntimeDirectory } from './opencode/store/OpenCodeRuntimeManifestEvidenceReader'; +import { + clearOpenCodeRuntimeLaneStorage, + getOpenCodeLaneScopedRuntimeFilePath, + getOpenCodeRuntimeRunTombstonesPath, + getOpenCodeTeamRuntimeDirectory, + migrateLegacyOpenCodeRuntimeState, + readOpenCodeRuntimeLaneIndex, + removeOpenCodeRuntimeLaneIndexEntry, + upsertOpenCodeRuntimeLaneIndexEntry, +} from './opencode/store/OpenCodeRuntimeManifestEvidenceReader'; import { createRuntimeRunTombstoneStore, type RuntimeEvidenceKind, @@ -242,7 +261,10 @@ import type { TeamLaunchAggregateState, TeamLaunchRequest, TeamLaunchResponse, + TeamMember, + TeamProviderBackendId, TeamProviderId, + TeamProvisioningModelVerificationMode, TeamProvisioningPrepareResult, TeamProvisioningProgress, TeamProvisioningState, @@ -257,6 +279,23 @@ import type { } from '@shared/types'; const logger = createLogger('Service:TeamProvisioning'); +const PREFLIGHT_DEBUG_LOG_PATH = path.join(os.tmpdir(), 'claude-team-preflight-debug.log'); + +function appendPreflightDebugLog(event: string, data: Record): void { + try { + fs.appendFileSync( + PREFLIGHT_DEBUG_LOG_PATH, + `${JSON.stringify({ + at: new Date().toISOString(), + event, + ...data, + })}\n`, + 'utf8' + ); + } catch { + // Best-effort debug logging only. + } +} const { AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES, AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES, @@ -342,6 +381,8 @@ const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000; 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 PROVIDER_MODEL_PROBE_CONCURRENCY = 2; function applyDistinctProvisioningMemberColors< T extends { name: string; color?: string; removedAt?: number }, @@ -707,6 +748,42 @@ function isOpenCodeLegacyProvisioningRequest(request: { ); } +function isPureOpenCodeProvisioningRequest(request: { + providerId?: unknown; + members?: readonly { providerId?: unknown; provider?: unknown }[]; +}): boolean { + if (!isOpenCodeLegacyProvisioningRequest(request)) { + return false; + } + + const rootProviderId = normalizeOptionalTeamProviderId(request.providerId); + if (rootProviderId && rootProviderId !== 'opencode') { + return false; + } + + return (request.members ?? []).every((member) => { + const memberProviderId = + normalizeOptionalTeamProviderId(member.providerId) ?? + normalizeOptionalTeamProviderId(member.provider); + return !memberProviderId || memberProviderId === 'opencode'; + }); +} + +export function getOpenCodeMixedProviderProvisioningError(): string { + return ( + 'This OpenCode mixed-team request is outside the current support scope. ' + + 'Supported mixed teams keep the lead on Anthropic, Codex, or Gemini. OpenCode-led mixed teams still remain blocked in this phase.' + ); +} + +export function getMixedLaunchFallbackRecoveryError(): string { + return ( + 'Persisted mixed-team launch recovery requires members.meta.json lane-aware roster truth. ' + + 'Inbox/config fallback cannot safely reconstruct an OpenCode secondary lane in V1. ' + + 'Run a fresh team bootstrap or restore the missing mixed-team metadata first.' + ); +} + function assertOpenCodeNotLaunchedThroughLegacyProvisioning(request: { providerId?: unknown; members?: readonly { providerId?: unknown; provider?: unknown }[]; @@ -714,6 +791,21 @@ function assertOpenCodeNotLaunchedThroughLegacyProvisioning(request: { if (!isOpenCodeLegacyProvisioningRequest(request)) { return; } + const lanePlan = fromProvisioningMembers( + normalizeOptionalTeamProviderId(request.providerId), + (request.members ?? []).map((member, index) => ({ + name: `member-${index + 1}`, + providerId: + normalizeOptionalTeamProviderId(member.providerId) ?? + normalizeOptionalTeamProviderId(member.provider), + })) + ); + if (!lanePlan.ok) { + throw new Error(lanePlan.message || getOpenCodeMixedProviderProvisioningError()); + } + if (!isPureOpenCodeProvisioningRequest(request)) { + return; + } throw new Error( 'OpenCode team launch is not enabled in the legacy Claude stream-json provisioning path. ' + 'Use the gated OpenCode runtime adapter once production launch is enabled.' @@ -928,7 +1020,10 @@ interface ProvisioningRun { onProgress: (progress: TeamProvisioningProgress) => void; expectedMembers: string[]; request: TeamCreateRequest; + allEffectiveMembers: TeamCreateRequest['members']; effectiveMembers: TeamCreateRequest['members']; + launchIdentity: ProviderModelLaunchIdentity | null; + mixedSecondaryLanes: MixedSecondaryRuntimeLaneState[]; lastLogProgressAt: number; /** Monotonic ms timestamp of last stdout/stderr data. For stall detection. */ lastDataReceivedAt: number; @@ -1084,6 +1179,17 @@ interface ProvisioningRun { lastMemberSpawnAuditMissingWarningAt: Map; } +interface MixedSecondaryRuntimeLaneState { + laneId: string; + providerId: 'opencode'; + member: TeamCreateRequest['members'][number]; + runId: string | null; + state: 'queued' | 'launching' | 'finished'; + result: TeamRuntimeLaunchResult | null; + warnings: string[]; + diagnostics: string[]; +} + type LeadActivityState = 'active' | 'idle' | 'offline'; type ProvisioningAuthSource = @@ -1265,6 +1371,12 @@ function matchesTeamMemberIdentity(leftName: string, rightName: string): boolean ); } +function matchesExactTeamMemberName(candidateName: string, memberName: string): boolean { + const left = candidateName.trim().toLowerCase(); + const right = memberName.trim().toLowerCase(); + return left.length > 0 && left === right; +} + interface MemberSpawnInboxCursor { timestamp: string; messageId: string; @@ -3114,6 +3226,8 @@ function normalizeSameTeamText(text: string): string { } export class TeamProvisioningService { + private readonly runtimeLaneCoordinator = createTeamRuntimeLaneCoordinator(); + private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000; private static readonly BOOTSTRAP_FAILURE_TAIL_BYTES = 128 * 1024; private static readonly RECENT_CROSS_TEAM_DELIVERY_TTL_MS = 10 * 60 * 1000; @@ -3133,6 +3247,13 @@ export class TeamProvisioningService { string, { runId: string; providerId: TeamProviderId; cwd?: string } >(); + private readonly secondaryRuntimeRunByTeam = new Map< + string, + Map< + string, + { runId: string; providerId: 'opencode'; laneId: string; memberName: string; cwd?: string } + > + >(); private readonly retainedClaudeLogsByTeam = new Map(); private readonly persistedTranscriptClaudeLogsCache = new Map< string, @@ -3650,8 +3771,251 @@ export class TeamProvisioningService { providerId?: TeamProviderId; members?: readonly { providerId?: TeamProviderId; provider?: TeamProviderId }[]; }): boolean { - return ( - isOpenCodeLegacyProvisioningRequest(request) && this.getOpenCodeRuntimeAdapter() !== null + return isPureOpenCodeProvisioningRequest(request) && this.getOpenCodeRuntimeAdapter() !== null; + } + + private planRuntimeLanesOrThrow( + leadProviderId: TeamProviderId | undefined, + members: TeamCreateRequest['members'] + ): TeamRuntimeLanePlan { + return this.runtimeLaneCoordinator.planProvisioningMembers({ + leadProviderId, + members, + hasOpenCodeRuntimeAdapter: this.getOpenCodeRuntimeAdapter() !== null, + }); + } + + private createMixedSecondaryLaneStates( + plan: TeamRuntimeLanePlan + ): MixedSecondaryRuntimeLaneState[] { + if (!isMixedOpenCodeSideLanePlan(plan)) { + return []; + } + return plan.sideLanes.map((sideLane) => ({ + laneId: sideLane.laneId, + providerId: 'opencode', + member: { + ...sideLane.member, + }, + runId: null, + state: 'queued', + result: null, + warnings: [], + diagnostics: [], + })); + } + + private createMixedSecondaryLaneStateForMember( + run: Pick, + member: TeamCreateRequest['members'][number] + ): MixedSecondaryRuntimeLaneState { + const laneIdentity = buildPlannedMemberLaneIdentity({ + leadProviderId: resolveTeamProviderId(run.request.providerId), + member: { + name: member.name, + providerId: normalizeOptionalTeamProviderId(member.providerId), + }, + }); + + if (laneIdentity.laneKind !== 'secondary' || laneIdentity.laneOwnerProviderId !== 'opencode') { + throw new Error( + `Member "${member.name}" is not eligible for an OpenCode secondary runtime lane` + ); + } + + return { + laneId: laneIdentity.laneId, + providerId: 'opencode', + member: { + ...member, + }, + runId: null, + state: 'queued', + result: null, + warnings: [], + diagnostics: [], + }; + } + + private getMixedSecondaryLaunchPhase(run: ProvisioningRun): PersistedTeamLaunchPhase { + return (run.mixedSecondaryLanes ?? []).some( + (lane) => lane.state !== 'finished' || lane.result?.teamLaunchState === 'partial_pending' + ) + ? 'active' + : 'finished'; + } + + private upsertRunAllEffectiveMember( + run: ProvisioningRun, + member: TeamCreateRequest['members'][number] + ): void { + const normalizedName = member.name.trim().toLowerCase(); + const nextMembers = run.allEffectiveMembers.filter( + (candidate) => candidate.name.trim().toLowerCase() !== normalizedName + ); + nextMembers.push(member); + run.allEffectiveMembers = nextMembers; + run.request.members = nextMembers; + } + + private removeRunAllEffectiveMember(run: ProvisioningRun, memberName: string): void { + const normalizedName = memberName.trim().toLowerCase(); + const nextMembers = run.allEffectiveMembers.filter( + (candidate) => candidate.name.trim().toLowerCase() !== normalizedName + ); + run.allEffectiveMembers = nextMembers; + run.request.members = nextMembers; + } + + private hasSecondaryRuntimeRuns(teamName: string): boolean { + const runs = this.secondaryRuntimeRunByTeam.get(teamName); + return Boolean(runs && runs.size > 0); + } + + private getSecondaryRuntimeRuns(teamName: string): Array<{ + runId: string; + providerId: 'opencode'; + laneId: string; + memberName: string; + cwd?: string; + }> { + return Array.from(this.secondaryRuntimeRunByTeam.get(teamName)?.values() ?? []); + } + + private setSecondaryRuntimeRun(input: { + teamName: string; + runId: string; + providerId: 'opencode'; + laneId: string; + memberName: string; + cwd?: string; + }): void { + const runs = this.secondaryRuntimeRunByTeam.get(input.teamName) ?? new Map(); + runs.set(input.laneId, { + runId: input.runId, + providerId: input.providerId, + laneId: input.laneId, + memberName: input.memberName, + cwd: input.cwd, + }); + this.secondaryRuntimeRunByTeam.set(input.teamName, runs); + } + + private deleteSecondaryRuntimeRun(teamName: string, laneId: string): void { + const runs = this.secondaryRuntimeRunByTeam.get(teamName); + if (!runs) { + return; + } + runs.delete(laneId); + if (runs.size === 0) { + this.secondaryRuntimeRunByTeam.delete(teamName); + } + } + + private clearSecondaryRuntimeRuns(teamName: string): void { + this.secondaryRuntimeRunByTeam.delete(teamName); + } + + private getCurrentOpenCodeRuntimeRunId(teamName: string, laneId: string): string | null { + if (laneId === 'primary') { + const trackedRunId = this.getTrackedRunId(teamName); + const trackedRun = trackedRunId ? this.runs.get(trackedRunId) : null; + if (trackedRun && this.shouldRouteOpenCodeToRuntimeAdapter(trackedRun.request)) { + return trackedRunId; + } + const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName); + if (runtimeRun?.providerId === 'opencode') { + return runtimeRun.runId; + } + return null; + } + + const secondaryLaneRun = this.secondaryRuntimeRunByTeam.get(teamName)?.get(laneId); + return secondaryLaneRun?.runId ?? null; + } + + private async resolveOpenCodeRuntimeLaneId(params: { + teamName: string; + runId: string; + memberName?: string; + }): Promise { + const runtimeRun = this.runtimeAdapterRunByTeam.get(params.teamName); + if (runtimeRun?.providerId === 'opencode' && runtimeRun.runId === params.runId) { + return 'primary'; + } + + for (const lane of this.getSecondaryRuntimeRuns(params.teamName)) { + if (lane.runId === params.runId) { + return lane.laneId; + } + } + + if (params.memberName) { + const trackedRunId = this.getTrackedRunId(params.teamName); + const trackedRun = trackedRunId ? this.runs.get(trackedRunId) : null; + const plannedLane = trackedRun?.mixedSecondaryLanes.find( + (lane) => lane.member.name.trim() === params.memberName + ); + if (plannedLane) { + return plannedLane.laneId; + } + + const persisted = await this.launchStateStore.read(params.teamName).catch(() => null); + const persistedMember = persisted?.members?.[params.memberName]; + if ( + persistedMember?.laneOwnerProviderId === 'opencode' && + typeof persistedMember.laneId === 'string' && + persistedMember.laneId.trim().length > 0 + ) { + return persistedMember.laneId.trim(); + } + } + + return 'primary'; + } + + private buildConfiguredProvisioningMember( + configuredMember: NonNullable< + ReturnType + > + ): TeamCreateRequest['members'][number] { + return { + name: configuredMember.name, + ...(configuredMember.role ? { role: configuredMember.role } : {}), + ...(configuredMember.workflow ? { workflow: configuredMember.workflow } : {}), + ...(configuredMember.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}), + ...(configuredMember.providerId ? { providerId: configuredMember.providerId } : {}), + ...(configuredMember.providerBackendId + ? { providerBackendId: configuredMember.providerBackendId } + : {}), + ...(configuredMember.model ? { model: configuredMember.model } : {}), + ...(configuredMember.effort ? { effort: configuredMember.effort } : {}), + ...(configuredMember.fastMode ? { fastMode: configuredMember.fastMode } : {}), + }; + } + + private buildMembersMetaWritePayload(members: TeamCreateRequest['members']): TeamMember[] { + return applyDistinctProvisioningMemberColors( + members.map((member) => ({ + name: member.name.trim(), + role: member.role?.trim() || undefined, + workflow: member.workflow?.trim() || undefined, + isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined, + providerId: normalizeOptionalTeamProviderId(member.providerId), + providerBackendId: migrateProviderBackendId(member.providerId, member.providerBackendId), + model: member.model?.trim() || undefined, + effort: isTeamEffortLevel(member.effort) ? member.effort : undefined, + fastMode: + member.fastMode === 'inherit' || member.fastMode === 'on' || member.fastMode === 'off' + ? member.fastMode + : undefined, + agentType: 'general-purpose' as const, + color: getMemberColorByName(member.name.trim()), + joinedAt: + typeof (member as { joinedAt?: unknown }).joinedAt === 'number' + ? ((member as { joinedAt?: number }).joinedAt as number) + : Date.now(), + })) ); } @@ -4618,10 +4982,12 @@ export class TeamProvisioningService { const memberName = requireRuntimeString(payload.memberName, 'memberName'); const runtimeSessionId = requireRuntimeString(payload.runtimeSessionId, 'runtimeSessionId'); const observedAt = normalizeRuntimeIso(payload.observedAt); + const laneId = await this.resolveOpenCodeRuntimeLaneId({ teamName, runId, memberName }); await this.assertOpenCodeRuntimeEvidenceAccepted({ teamName, runId, + laneId, evidenceKind: 'bootstrap_checkin', }); await this.updateOpenCodeRuntimeMemberLiveness({ @@ -4651,13 +5017,20 @@ export class TeamProvisioningService { const payload = asRuntimeRecord(raw); const teamName = requireRuntimeString(payload.teamName, 'teamName'); const runId = requireRuntimeString(payload.runId, 'runId'); + const fromMemberName = requireRuntimeString(payload.fromMemberName, 'fromMemberName'); + const laneId = await this.resolveOpenCodeRuntimeLaneId({ + teamName, + runId, + memberName: fromMemberName, + }); await this.assertOpenCodeRuntimeEvidenceAccepted({ teamName, runId, + laneId, evidenceKind: 'delivery_call', }); - const delivery = this.createOpenCodeRuntimeDeliveryService(teamName); + const delivery = this.createOpenCodeRuntimeDeliveryService(teamName, laneId); const ack = await delivery.deliver({ ...payload, teamName, @@ -4693,10 +5066,12 @@ export class TeamProvisioningService { const idempotencyKey = requireRuntimeString(payload.idempotencyKey, 'idempotencyKey'); const runtimeSessionId = optionalRuntimeString(payload.runtimeSessionId); const observedAt = normalizeRuntimeIso(payload.createdAt); + const laneId = await this.resolveOpenCodeRuntimeLaneId({ teamName, runId, memberName }); await this.assertOpenCodeRuntimeEvidenceAccepted({ teamName, runId, + laneId, evidenceKind: 'delivery_call', }); @@ -4737,10 +5112,12 @@ export class TeamProvisioningService { const memberName = requireRuntimeString(payload.memberName, 'memberName'); const runtimeSessionId = requireRuntimeString(payload.runtimeSessionId, 'runtimeSessionId'); const observedAt = normalizeRuntimeIso(payload.observedAt); + const laneId = await this.resolveOpenCodeRuntimeLaneId({ teamName, runId, memberName }); await this.assertOpenCodeRuntimeEvidenceAccepted({ teamName, runId, + laneId, evidenceKind: 'heartbeat', }); await this.updateOpenCodeRuntimeMemberLiveness({ @@ -4769,18 +5146,20 @@ export class TeamProvisioningService { private async assertOpenCodeRuntimeEvidenceAccepted(input: { teamName: string; runId: string; + laneId: string; evidenceKind: RuntimeEvidenceKind; }): Promise { const store = createRuntimeRunTombstoneStore({ - filePath: path.join( - getOpenCodeTeamRuntimeDirectory(getTeamsBasePath(), input.teamName), - 'opencode-run-tombstones.json' + filePath: getOpenCodeRuntimeRunTombstonesPath( + getTeamsBasePath(), + input.teamName, + input.laneId ), }); await store.assertEvidenceAccepted({ teamName: input.teamName, runId: input.runId, - currentRunId: this.getTrackedRunId(input.teamName), + currentRunId: this.getCurrentOpenCodeRuntimeRunId(input.teamName, input.laneId), evidenceKind: input.evidenceKind, }); } @@ -4801,7 +5180,14 @@ export class TeamProvisioningService { .map((member) => (typeof member.name === 'string' ? member.name.trim() : '')) .filter((name) => name.length > 0 && name !== 'user' && !isLeadMember({ name })); const previousMember = previous?.members[input.memberName]; + const persistedIdentity = this.resolvePersistedRuntimeMemberIdentity({ + teamName: input.teamName, + memberName: input.memberName, + previousMember, + }); const nextMember: PersistedTeamLaunchMemberState = { + ...persistedIdentity, + ...(previousMember ?? {}), name: input.memberName, launchState: 'confirmed_alive', agentToolAccepted: true, @@ -4843,14 +5229,91 @@ export class TeamProvisioningService { }); } - private createOpenCodeRuntimeDeliveryService(teamName: string): RuntimeDeliveryService { - const runtimeDir = getOpenCodeTeamRuntimeDirectory(getTeamsBasePath(), teamName); + private resolvePersistedRuntimeMemberIdentity(params: { + teamName: string; + memberName: string; + previousMember?: PersistedTeamLaunchMemberState; + }): Partial { + if (params.previousMember) { + return { + providerId: params.previousMember.providerId, + providerBackendId: params.previousMember.providerBackendId, + model: params.previousMember.model, + effort: params.previousMember.effort, + selectedFastMode: params.previousMember.selectedFastMode, + resolvedFastMode: params.previousMember.resolvedFastMode, + laneId: params.previousMember.laneId, + laneKind: params.previousMember.laneKind, + laneOwnerProviderId: params.previousMember.laneOwnerProviderId, + launchIdentity: params.previousMember.launchIdentity, + }; + } + + const trackedRunId = this.getTrackedRunId(params.teamName); + const trackedRun = trackedRunId ? this.runs.get(trackedRunId) : null; + const secondaryLane = trackedRun?.mixedSecondaryLanes?.find( + (lane) => lane.member.name.trim() === params.memberName + ); + if (secondaryLane) { + return { + providerId: 'opencode', + model: secondaryLane.member.model, + effort: secondaryLane.member.effort, + laneId: secondaryLane.laneId, + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + }; + } + + const primaryMember = trackedRun?.effectiveMembers?.find( + (member) => member.name.trim() === params.memberName + ); + if (!primaryMember) { + return {}; + } + + const laneIdentity = buildPlannedMemberLaneIdentity({ + leadProviderId: resolveTeamProviderId(trackedRun?.request.providerId), + member: { + name: primaryMember.name, + providerId: normalizeOptionalTeamProviderId(primaryMember.providerId), + }, + }); + const providerId = + normalizeOptionalTeamProviderId(primaryMember.providerId) ?? + resolveTeamProviderId(trackedRun?.request.providerId); + + return { + providerId, + providerBackendId: migrateProviderBackendId( + providerId, + primaryMember.providerBackendId ?? trackedRun?.request.providerBackendId + ), + model: primaryMember.model, + effort: primaryMember.effort, + selectedFastMode: primaryMember.fastMode ?? trackedRun?.request.fastMode, + laneId: laneIdentity.laneId, + laneKind: laneIdentity.laneKind, + laneOwnerProviderId: laneIdentity.laneOwnerProviderId, + }; + } + + private createOpenCodeRuntimeDeliveryService( + teamName: string, + laneId: string + ): RuntimeDeliveryService { const journal = createRuntimeDeliveryJournalStore({ - filePath: path.join(runtimeDir, 'opencode-delivery-journal.json'), + filePath: getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId, + fileName: 'opencode-delivery-journal.json', + }), }); return new RuntimeDeliveryService( { - getCurrentRunId: async (candidateTeamName) => this.getTrackedRunId(candidateTeamName), + getCurrentRunId: async (candidateTeamName) => + this.getCurrentOpenCodeRuntimeRunId(candidateTeamName, laneId), }, journal, new RuntimeDeliveryDestinationRegistry(this.createOpenCodeRuntimeDeliveryPorts()), @@ -5034,23 +5497,61 @@ export class TeamProvisioningService { } async recoverOpenCodeRuntimeDeliveryJournal(teamName: string): Promise<{ recovered: true }> { - const runtimeDir = getOpenCodeTeamRuntimeDirectory(getTeamsBasePath(), teamName); - const journal = createRuntimeDeliveryJournalStore({ - filePath: path.join(runtimeDir, 'opencode-delivery-journal.json'), - }); - const reconciler = new RuntimeDeliveryReconciler( - journal, - new RuntimeDeliveryDestinationRegistry(this.createOpenCodeRuntimeDeliveryPorts()), - { - append: async (event) => { - logger.warn(`[${event.teamName}] ${event.message}`); - }, - } + const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch( + () => ({ + version: 1 as const, + updatedAt: nowIso(), + lanes: {}, + }) ); - await reconciler.reconcileTeam(teamName); + const recoveryLaneIds = await this.getOpenCodeRuntimeRecoveryLaneIds(teamName, laneIndex.lanes); + for (const laneId of recoveryLaneIds) { + const journal = createRuntimeDeliveryJournalStore({ + filePath: getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId, + fileName: 'opencode-delivery-journal.json', + }), + }); + const reconciler = new RuntimeDeliveryReconciler( + journal, + new RuntimeDeliveryDestinationRegistry(this.createOpenCodeRuntimeDeliveryPorts()), + { + append: async (event) => { + logger.warn(`[${event.teamName}] ${event.message}`); + }, + } + ); + await reconciler.reconcileTeam(teamName); + } return { recovered: true }; } + private async getOpenCodeRuntimeRecoveryLaneIds( + teamName: string, + laneIndexEntries?: Record + ): Promise { + const laneIds = Object.keys(laneIndexEntries ?? {}); + if (laneIds.length > 0) { + return laneIds; + } + + const snapshot = await this.launchStateStore.read(teamName).catch(() => null); + const snapshotLaneIds = Array.from( + new Set( + Object.values(snapshot?.members ?? {}) + .map((member) => + member?.laneOwnerProviderId === 'opencode' && typeof member.laneId === 'string' + ? member.laneId.trim() + : '' + ) + .filter((laneId) => laneId.length > 0) + ) + ); + return snapshotLaneIds.length > 0 ? snapshotLaneIds : ['primary']; + } + getLeadActivityState(teamName: string): { state: 'active' | 'idle' | 'offline'; runId: string | null; @@ -5655,6 +6156,11 @@ export class TeamProvisioningService { } catch { configuredMembers = []; } + const metaMembers = await this.membersMetaStore.getMembers(teamName).catch(() => []); + const launchSnapshot = choosePreferredLaunchSnapshot( + await readBootstrapLaunchSnapshot(teamName), + await this.launchStateStore.read(teamName) + ); const liveRuntimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName); const runtimePids = new Set(); @@ -5694,7 +6200,34 @@ export class TeamProvisioningService { return fallback; }; + const candidateMembers = new Map(); for (const member of configuredMembers) { + const memberName = typeof member?.name === 'string' ? member.name.trim() : ''; + if (!memberName || this.isMemberRemovedInMeta(metaMembers, memberName)) continue; + candidateMembers.set(memberName, member); + } + for (const member of metaMembers) { + const memberName = typeof member?.name === 'string' ? member.name.trim() : ''; + if (!memberName || member.removedAt || candidateMembers.has(memberName)) continue; + candidateMembers.set(memberName, member); + } + for (const memberName of launchSnapshot?.expectedMembers ?? []) { + if (candidateMembers.has(memberName) || this.isMemberRemovedInMeta(metaMembers, memberName)) { + continue; + } + const launchMember = launchSnapshot?.members[memberName]; + candidateMembers.set(memberName, { + name: memberName, + agentType: 'general-purpose', + providerId: launchMember?.providerId, + providerBackendId: launchMember?.providerBackendId, + model: launchMember?.model, + effort: launchMember?.effort, + fastMode: launchMember?.selectedFastMode, + }); + } + + for (const member of candidateMembers.values()) { const memberName = typeof member?.name === 'string' ? member.name.trim() : ''; if (!memberName) continue; @@ -5724,18 +6257,29 @@ export class TeamProvisioningService { const persistedRuntimeMember = getPersistedRuntimeMember(memberName); const liveRuntimeMember = getLiveRuntimeMember(memberName); + const launchMember = launchSnapshot?.members[memberName]; const backendType = normalizeTeamAgentRuntimeBackendType( persistedRuntimeMember?.backendType, false ); const restartable = backendType !== 'in-process'; - const runtimeModel = liveRuntimeMember?.model ?? member.model?.trim() ?? undefined; + const runtimeModel = + liveRuntimeMember?.model ?? + launchMember?.model?.trim() ?? + member.model?.trim() ?? + undefined; snapshotMembers[memberName] = { memberName, - alive: liveRuntimeMember?.alive ?? false, + alive: liveRuntimeMember?.alive ?? launchMember?.runtimeAlive ?? false, restartable, ...(backendType ? { backendType } : {}), + ...(launchMember?.providerId ? { providerId: launchMember.providerId } : {}), + ...(launchMember?.providerBackendId + ? { providerBackendId: launchMember.providerBackendId } + : {}), + ...(launchMember?.laneId ? { laneId: launchMember.laneId } : {}), + ...(launchMember?.laneKind ? { laneKind: launchMember.laneKind } : {}), ...(liveRuntimeMember?.pid ? { pid: liveRuntimeMember.pid } : {}), ...(runtimeModel ? { runtimeModel } : {}), ...(liveRuntimeMember?.pid && rssBytesByPid.has(liveRuntimeMember.pid) @@ -5815,6 +6359,20 @@ export class TeamProvisioningService { if (isLeadMember({ name: memberName, agentType: configuredMember.agentType })) { throw new Error('Lead restart is not supported from member controls'); } + const desiredProviderId = normalizeOptionalTeamProviderId(configuredMember.providerId); + const mixedSecondaryLanes = run.mixedSecondaryLanes ?? []; + const liveSecondaryLaneMemberName = + mixedSecondaryLanes + .find((lane) => lane.member.name.trim() === memberName) + ?.member.name?.trim() ?? null; + const leadProviderId = resolveTeamProviderId(run.request.providerId); + const desiredSecondaryLane = desiredProviderId === 'opencode' && leadProviderId !== 'opencode'; + if (liveSecondaryLaneMemberName === memberName || desiredSecondaryLane) { + await this.reattachOpenCodeOwnedMemberLane(teamName, memberName, { + reason: 'manual_restart', + }); + return; + } if (run.pendingMemberRestarts.has(memberName)) { throw new Error(`Restart for teammate "${memberName}" is already in progress`); } @@ -6009,6 +6567,125 @@ export class TeamProvisioningService { } } + private getMutableAliveRunOrThrow(teamName: string): ProvisioningRun { + const runId = this.getAliveRunId(teamName); + if (!runId) { + throw new Error(`Team "${teamName}" is not currently running`); + } + const run = this.runs.get(runId); + if (!run || run.processKilled || run.cancelRequested) { + throw new Error(`Team "${teamName}" is not currently running`); + } + return run; + } + + async reattachOpenCodeOwnedMemberLane( + teamName: string, + memberName: string, + options?: { reason?: 'member_added' | 'member_updated' | 'manual_restart' } + ): Promise { + const run = this.getMutableAliveRunOrThrow(teamName); + const leadProviderId = resolveTeamProviderId(run.request.providerId); + if (leadProviderId === 'opencode') { + throw new Error( + 'OpenCode-led mixed teams are not supported in this phase. Stop the team and relaunch with a non-OpenCode lead.' + ); + } + if (!this.getOpenCodeRuntimeAdapter()) { + throw new Error('OpenCode runtime adapter is not available for controlled lane reattach.'); + } + + 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, + memberName + ); + if (!configuredMember) { + throw new Error(`Member "${memberName}" is not configured in team "${teamName}"`); + } + if (configuredMember.removedAt) { + throw new Error(`Member "${memberName}" has been removed`); + } + if (isLeadMember({ name: configuredMember.name, agentType: configuredMember.agentType })) { + throw new Error('Lead lane reattach is not supported'); + } + const desiredProviderId = normalizeOptionalTeamProviderId(configuredMember.providerId); + if (desiredProviderId !== 'opencode') { + throw new Error( + `Controlled reattach is only supported for OpenCode-owned members. "${memberName}" remains on the primary runtime owner.` + ); + } + + const memberSpec = this.buildConfiguredProvisioningMember(configuredMember); + const nextLane = this.createMixedSecondaryLaneStateForMember(run, memberSpec); + const existingLaneIndex = run.mixedSecondaryLanes.findIndex( + (lane) => lane.laneId === nextLane.laneId || lane.member.name.trim() === memberName + ); + const existingLane = existingLaneIndex >= 0 ? run.mixedSecondaryLanes[existingLaneIndex] : null; + + if (existingLane) { + await this.stopSingleMixedSecondaryRuntimeLane(run, existingLane, 'relaunch'); + } + + const laneState = existingLane ?? nextLane; + laneState.laneId = nextLane.laneId; + laneState.member = memberSpec; + laneState.runId = null; + laneState.state = 'queued'; + laneState.result = null; + laneState.warnings = []; + laneState.diagnostics = options?.reason ? [`controlled_reattach:${options.reason}`] : []; + + if (existingLaneIndex >= 0) { + run.mixedSecondaryLanes[existingLaneIndex] = laneState; + } else { + run.mixedSecondaryLanes.push(laneState); + } + + this.upsertRunAllEffectiveMember(run, memberSpec); + this.agentRuntimeSnapshotCache.delete(teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + this.resetRuntimeToolActivity(run, memberName); + this.clearMemberSpawnToolTracking(run, memberName); + run.pendingMemberRestarts.delete(memberName); + + await this.launchSingleMixedSecondaryLane(run, laneState); + } + + async detachOpenCodeOwnedMemberLane(teamName: string, memberName: string): Promise { + const run = this.getMutableAliveRunOrThrow(teamName); + const laneIndex = run.mixedSecondaryLanes.findIndex((lane) => + matchesTeamMemberIdentity(lane.member.name, memberName) + ); + if (laneIndex < 0) { + this.removeRunAllEffectiveMember(run, memberName); + this.agentRuntimeSnapshotCache.delete(teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + await this.persistLaunchStateSnapshot(run, this.getMixedSecondaryLaunchPhase(run)); + return; + } + + const [lane] = run.mixedSecondaryLanes.splice(laneIndex, 1); + await this.stopSingleMixedSecondaryRuntimeLane(run, lane, 'cleanup'); + this.removeRunAllEffectiveMember(run, memberName); + this.agentRuntimeSnapshotCache.delete(teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + this.resetRuntimeToolActivity(run, memberName); + this.clearMemberSpawnToolTracking(run, memberName); + run.pendingMemberRestarts.delete(memberName); + await this.persistLaunchStateSnapshot(run, this.getMixedSecondaryLaunchPhase(run)); + } + private getMemberLaunchGraceKey(run: ProvisioningRun, memberName: string): string { return `member-launch-grace:${run.runId}:${memberName}`; } @@ -6195,6 +6872,7 @@ export class TeamProvisioningService { providerIds?: TeamProviderId[]; modelIds?: string[]; limitContext?: boolean; + modelVerificationMode?: TeamProvisioningModelVerificationMode; } ): Promise { const targetCwdForValidation = cwd?.trim() || process.cwd(); @@ -6239,22 +6917,35 @@ export class TeamProvisioningService { continue; } - const prepare = await adapter.prepare({ - runId: `prepare-${randomUUID()}`, - teamName: '__prepare_opencode__', - cwd: targetCwd, - providerId: 'opencode', - model: selectedModelIds[0], - runtimeOnly: selectedModelIds.length === 0, - skipPermissions: true, - expectedMembers: [], - previousLaunchState: null, - }); - details.push(...prepare.diagnostics); - warnings.push(...prepare.warnings); - if (!prepare.ok) { - blockingMessages.push(`OpenCode: ${prepare.reason}`); + if (selectedModelIds.length === 0) { + const prepare = await adapter.prepare({ + runId: `prepare-${randomUUID()}`, + teamName: '__prepare_opencode__', + cwd: targetCwd, + providerId: 'opencode', + model: undefined, + runtimeOnly: true, + skipPermissions: true, + expectedMembers: [], + previousLaunchState: null, + }); + details.push(...prepare.diagnostics); + warnings.push(...prepare.warnings); + if (!prepare.ok) { + blockingMessages.push(`OpenCode: ${prepare.reason}`); + } + continue; } + + const openCodeModelPrepare = await this.prepareSelectedOpenCodeModels({ + adapter, + cwd: targetCwd, + modelIds: selectedModelIds, + verificationMode: opts?.modelVerificationMode ?? 'deep', + }); + details.push(...openCodeModelPrepare.details); + warnings.push(...openCodeModelPrepare.warnings); + blockingMessages.push(...openCodeModelPrepare.blockingMessages); continue; } @@ -6313,6 +7004,7 @@ export class TeamProvisioningService { } if (blockingMessages.length > 0) { + const failureWarnings = Array.from(new Set([...warnings, ...blockingMessages])); return { ready: false, details: details.length > 0 ? details : undefined, @@ -6320,7 +7012,7 @@ export class TeamProvisioningService { blockingMessages.length === 1 ? blockingMessages[0] : 'Some provider runtimes are not ready', - warnings: blockingMessages.length > 1 ? blockingMessages : undefined, + warnings: failureWarnings.length > 0 ? failureWarnings : undefined, }; } @@ -6339,6 +7031,336 @@ export class TeamProvisioningService { }; } + private async prepareSelectedOpenCodeModels({ + adapter, + cwd, + modelIds, + verificationMode, + }: { + adapter: TeamLaunchRuntimeAdapter; + cwd: string; + modelIds: string[]; + verificationMode: TeamProvisioningModelVerificationMode; + }): Promise<{ + details: string[]; + warnings: string[]; + blockingMessages: string[]; + }> { + const details: string[] = []; + const warnings: string[] = []; + const blockingMessages: string[] = []; + const startedAt = Date.now(); + + if (modelIds.length === 0) { + return { details, warnings, blockingMessages }; + } + + if (verificationMode === 'compatibility') { + const sharedCompatibilityPrepare = await this.prepareSelectedOpenCodeModelsCompatibilityBatch( + { + adapter, + cwd, + modelIds, + } + ); + if (sharedCompatibilityPrepare) { + return sharedCompatibilityPrepare; + } + } + + const results = new Array<{ modelId: string; prepare: TeamRuntimePrepareResult }>( + modelIds.length + ); + const workerCount = Math.min(OPENCODE_PREFLIGHT_MODEL_PROBE_CONCURRENCY, modelIds.length); + let nextIndex = 0; + + const prepareModel = async (modelId: string): Promise => { + const startedAt = Date.now(); + try { + const prepare = await adapter.prepare({ + runId: `prepare-${randomUUID()}`, + teamName: '__prepare_opencode__', + cwd, + providerId: 'opencode', + model: modelId, + runtimeOnly: verificationMode === 'compatibility', + skipPermissions: true, + expectedMembers: [], + previousLaunchState: null, + }); + appendPreflightDebugLog('opencode_model_prepare_result', { + cwd, + modelId, + verificationMode, + durationMs: Date.now() - startedAt, + ok: prepare.ok, + reason: prepare.ok ? null : prepare.reason, + diagnostics: prepare.diagnostics, + warnings: prepare.warnings, + }); + return prepare; + } catch (error) { + const message = getErrorMessage(error).trim() || 'OpenCode model verification failed'; + appendPreflightDebugLog('opencode_model_prepare_result', { + cwd, + modelId, + verificationMode, + durationMs: Date.now() - startedAt, + ok: false, + reason: 'unknown_error', + diagnostics: [message], + warnings: [], + }); + return { + ok: false, + providerId: 'opencode', + reason: 'unknown_error', + retryable: false, + diagnostics: [message], + warnings: [], + }; + } + }; + + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (true) { + const currentIndex = nextIndex; + nextIndex += 1; + if (currentIndex >= modelIds.length) { + return; + } + + const modelId = modelIds[currentIndex]; + results[currentIndex] = { + modelId, + prepare: await prepareModel(modelId), + }; + } + }) + ); + + for (const result of results) { + if (!result) { + blockingMessages.push( + 'OpenCode preflight could not collect model verification results for all selected models.' + ); + continue; + } + + const { modelId, prepare } = result; + warnings.push(...prepare.warnings); + if (prepare.ok) { + details.push( + verificationMode === 'compatibility' + ? `Selected model ${modelId} is compatible. Deep verification pending.` + : `Selected model ${modelId} verified for launch.` + ); + continue; + } + + const primaryReason = + prepare.diagnostics.find((entry) => entry.trim().length > 0) ?? prepare.reason; + const unavailableLine = `Selected model ${modelId} is unavailable. ${primaryReason}`; + const verificationWarningLine = `Selected model ${modelId} could not be verified. ${primaryReason}`; + if (prepare.retryable) { + warnings.push(verificationWarningLine); + if (verificationMode === 'compatibility') { + blockingMessages.push(verificationWarningLine); + } + } else { + if (verificationMode === 'compatibility') { + details.push(unavailableLine); + } + blockingMessages.push(unavailableLine); + } + } + + appendPreflightDebugLog('opencode_model_prepare_batch_complete', { + cwd, + modelIds, + verificationMode, + durationMs: Date.now() - startedAt, + details, + warnings, + blockingMessages, + }); + + return { details, warnings, blockingMessages }; + } + + private async prepareSelectedOpenCodeModelsCompatibilityBatch({ + adapter, + cwd, + modelIds, + }: { + adapter: TeamLaunchRuntimeAdapter; + cwd: string; + modelIds: string[]; + }): Promise<{ + details: string[]; + warnings: string[]; + blockingMessages: string[]; + } | null> { + const details: string[] = []; + const warnings: string[] = []; + const blockingMessages: string[] = []; + const startedAt = Date.now(); + + appendPreflightDebugLog('opencode_compatibility_batch_start', { + cwd, + modelIds, + }); + + let sharedPrepare: TeamRuntimePrepareResult; + try { + sharedPrepare = await adapter.prepare({ + runId: `prepare-${randomUUID()}`, + teamName: '__prepare_opencode__', + cwd, + providerId: 'opencode', + model: undefined, + runtimeOnly: true, + skipPermissions: true, + expectedMembers: [], + previousLaunchState: null, + }); + } catch (error) { + const message = getErrorMessage(error).trim() || 'OpenCode model verification failed'; + sharedPrepare = { + ok: false, + providerId: 'opencode', + reason: 'unknown_error', + retryable: false, + diagnostics: [message], + warnings: [], + }; + } + + warnings.push(...sharedPrepare.warnings); + appendPreflightDebugLog('opencode_compatibility_batch_shared_prepare', { + cwd, + modelIds, + durationMs: Date.now() - startedAt, + ok: sharedPrepare.ok, + reason: sharedPrepare.ok ? null : sharedPrepare.reason, + diagnostics: sharedPrepare.diagnostics, + }); + + if (!sharedPrepare.ok) { + const primaryReason = + sharedPrepare.diagnostics.find((entry) => entry.trim().length > 0) ?? sharedPrepare.reason; + for (const modelId of modelIds) { + const unavailableLine = `Selected model ${modelId} is unavailable. ${primaryReason}`; + const verificationWarningLine = `Selected model ${modelId} could not be verified. ${primaryReason}`; + if (sharedPrepare.retryable) { + warnings.push(verificationWarningLine); + blockingMessages.push(verificationWarningLine); + } else { + details.push(unavailableLine); + blockingMessages.push(unavailableLine); + } + } + return { details, warnings, blockingMessages }; + } + + const latestReadiness = + 'getLastOpenCodeTeamLaunchReadiness' in adapter && + typeof adapter.getLastOpenCodeTeamLaunchReadiness === 'function' + ? adapter.getLastOpenCodeTeamLaunchReadiness(cwd) + : null; + const availableModels = Array.from( + new Set( + (latestReadiness?.availableModels ?? []) + .map((modelId: string) => modelId.trim()) + .filter(Boolean) + ) + ) as string[]; + appendPreflightDebugLog('opencode_compatibility_batch_catalog', { + cwd, + modelIds, + availableModelCount: availableModels.length, + availableModelsSample: availableModels.slice(0, 20), + fellBackToPerModelPrepare: availableModels.length === 0, + }); + + if (availableModels.length === 0) { + return null; + } + + for (const modelId of modelIds) { + const resolvedModel = this.resolveOpenCodeCompatibilityModel(modelId, availableModels); + if (resolvedModel.ok) { + details.push(`Selected model ${modelId} is compatible. Deep verification pending.`); + continue; + } + + const unavailableLine = `Selected model ${modelId} is unavailable. ${resolvedModel.reason}`; + details.push(unavailableLine); + blockingMessages.push(unavailableLine); + } + + appendPreflightDebugLog('opencode_compatibility_batch_complete', { + cwd, + modelIds, + durationMs: Date.now() - startedAt, + blockingMessages, + details, + }); + + return { details, warnings, blockingMessages }; + } + + private resolveOpenCodeCompatibilityModel( + requestedModelId: string, + availableModels: readonly string[] + ): { ok: true; resolvedModelId: string } | { ok: false; reason: string } { + const trimmedModelId = requestedModelId.trim(); + if (!trimmedModelId) { + return { + ok: false, + reason: 'Selected model id is empty.', + }; + } + + if (availableModels.includes(trimmedModelId)) { + return { + ok: true, + resolvedModelId: trimmedModelId, + }; + } + + if (trimmedModelId.includes('/')) { + return { + ok: false, + reason: `Selected model ${trimmedModelId} was not found in the live provider catalog.`, + }; + } + + const matchingProviderScopedModels = availableModels.filter( + (candidate) => candidate.split('/').at(-1) === trimmedModelId + ); + if (matchingProviderScopedModels.length === 1) { + return { + ok: true, + resolvedModelId: matchingProviderScopedModels[0], + }; + } + if (matchingProviderScopedModels.length > 1) { + return { + ok: false, + reason: + `Selected model ${trimmedModelId} matched multiple live provider models: ` + + matchingProviderScopedModels.join(', '), + }; + } + + return { + ok: false, + reason: `Selected model ${trimmedModelId} was not found in the live provider catalog.`, + }; + } + private async verifySelectedProviderModels({ claudePath, cwd, @@ -6359,6 +7381,7 @@ export class TeamProvisioningService { const details: string[] = []; const warnings: string[] = []; const blockingMessages: string[] = []; + const startedAt = Date.now(); if (modelIds.length === 0) { return { details, warnings, blockingMessages }; @@ -6377,6 +7400,13 @@ export class TeamProvisioningService { { kind: 'ready' | 'warning' | 'unavailable'; reason?: string } >(); let resolvedDefaultModelId: string | null | undefined; + const plannedModels: Array< + | { requestedModelId: string; targetModelId: string } + | { + requestedModelId: string; + immediateOutcome: { kind: 'ready' | 'warning' | 'unavailable'; reason?: string }; + } + > = []; const recordOutcome = ( requestedModelId: string, @@ -6397,6 +7427,12 @@ export class TeamProvisioningService { ); }; + appendPreflightDebugLog('provider_model_probe_batch_start', { + providerId, + cwd, + modelIds, + }); + for (const modelId of modelIds) { const label = modelId.trim(); if (!label) { @@ -6422,9 +7458,12 @@ export class TeamProvisioningService { } } if (!resolvedDefaultModelId) { - recordOutcome(label, { - kind: 'warning', - reason: 'Could not resolve the runtime default model', + plannedModels.push({ + requestedModelId: label, + immediateOutcome: { + kind: 'warning', + reason: 'Could not resolve the runtime default model', + }, }); continue; } @@ -6441,12 +7480,27 @@ export class TeamProvisioningService { } } + plannedModels.push({ + requestedModelId: label, + targetModelId, + }); + } + + const uniqueTargetModelIds = Array.from( + new Set( + plannedModels.flatMap((entry) => ('targetModelId' in entry ? [entry.targetModelId] : [])) + ) + ); + + const runProbeForModel = async ( + targetModelId: string + ): Promise<{ kind: 'ready' | 'warning' | 'unavailable'; reason?: string }> => { const cachedOutcome = probeOutcomeByResolvedModelId.get(targetModelId); if (cachedOutcome) { - recordOutcome(label, cachedOutcome); - continue; + return cachedOutcome; } + const probeStartedAt = Date.now(); try { const result = await this.spawnProbe( claudePath, @@ -6460,42 +7514,91 @@ export class TeamProvisioningService { } ); const combinedOutput = buildCombinedLogs(result.stdout, result.stderr).trim(); - if (result.exitCode === 0 && isProviderModelProbeSuccessOutput(combinedOutput)) { - const outcome = { kind: 'ready' as const }; - probeOutcomeByResolvedModelId.set(targetModelId, outcome); - recordOutcome(label, outcome); - continue; - } - - const reason = combinedOutput || `Probe exited with code ${result.exitCode ?? 'unknown'}.`; - const normalizedReason = normalizeProviderModelProbeFailureReason(reason); - if (classifyProviderModelProbeFailure(reason) === 'unavailable') { - const outcome = { kind: 'unavailable' as const, reason: normalizedReason }; - probeOutcomeByResolvedModelId.set(targetModelId, outcome); - recordOutcome(label, outcome); - } else { - const outcome = { kind: 'warning' as const, reason: normalizedReason }; - probeOutcomeByResolvedModelId.set(targetModelId, outcome); - recordOutcome(label, outcome); - } + const outcome = + result.exitCode === 0 && isProviderModelProbeSuccessOutput(combinedOutput) + ? ({ kind: 'ready' } as const) + : classifyProviderModelProbeFailure( + combinedOutput || `Probe exited with code ${result.exitCode ?? 'unknown'}.` + ) === 'unavailable' + ? ({ + kind: 'unavailable', + reason: normalizeProviderModelProbeFailureReason( + combinedOutput || `Probe exited with code ${result.exitCode ?? 'unknown'}.` + ), + } as const) + : ({ + kind: 'warning', + reason: normalizeProviderModelProbeFailureReason( + combinedOutput || `Probe exited with code ${result.exitCode ?? 'unknown'}.` + ), + } as const); + probeOutcomeByResolvedModelId.set(targetModelId, outcome); + appendPreflightDebugLog('provider_model_probe_result', { + providerId, + cwd, + targetModelId, + durationMs: Date.now() - probeStartedAt, + outcome, + }); + return outcome; } catch (error) { const message = error instanceof Error ? error.message.trim() : String(error).trim(); const normalizedMessage = normalizeProviderModelProbeFailureReason(message); - if ( + const outcome = classifyProviderModelProbeFailure(message) === 'unavailable' && !isTransientModelProbeMessage(message) - ) { - const outcome = { kind: 'unavailable' as const, reason: normalizedMessage }; - probeOutcomeByResolvedModelId.set(targetModelId, outcome); - recordOutcome(label, outcome); - } else { - const outcome = { kind: 'warning' as const, reason: normalizedMessage }; - probeOutcomeByResolvedModelId.set(targetModelId, outcome); - recordOutcome(label, outcome); - } + ? ({ kind: 'unavailable', reason: normalizedMessage } as const) + : ({ kind: 'warning', reason: normalizedMessage } as const); + probeOutcomeByResolvedModelId.set(targetModelId, outcome); + appendPreflightDebugLog('provider_model_probe_result', { + providerId, + cwd, + targetModelId, + durationMs: Date.now() - probeStartedAt, + outcome, + }); + return outcome; } + }; + + const workerCount = Math.min(PROVIDER_MODEL_PROBE_CONCURRENCY, uniqueTargetModelIds.length); + let nextProbeIndex = 0; + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (true) { + const currentIndex = nextProbeIndex; + nextProbeIndex += 1; + if (currentIndex >= uniqueTargetModelIds.length) { + return; + } + await runProbeForModel(uniqueTargetModelIds[currentIndex]); + } + }) + ); + + for (const entry of plannedModels) { + if ('immediateOutcome' in entry) { + recordOutcome(entry.requestedModelId, entry.immediateOutcome); + continue; + } + + const outcome = probeOutcomeByResolvedModelId.get(entry.targetModelId) ?? { + kind: 'warning' as const, + reason: 'Model verification failed', + }; + recordOutcome(entry.requestedModelId, outcome); } + appendPreflightDebugLog('provider_model_probe_batch_complete', { + providerId, + cwd, + modelIds, + durationMs: Date.now() - startedAt, + details, + warnings, + blockingMessages, + }); + return { details, warnings, blockingMessages }; } @@ -7488,7 +8591,7 @@ export class TeamProvisioningService { if (envWarning) { throw new Error(envWarning); } - const effectiveMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({ + const allEffectiveMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({ claudePath, cwd: request.cwd, members: request.members, @@ -7501,6 +8604,11 @@ export class TeamProvisioningService { primaryEnv: provisioningEnv, limitContext: request.limitContext, }); + const lanePlan = this.planRuntimeLanesOrThrow(request.providerId, allEffectiveMemberSpecs); + const primaryMemberNames = new Set(lanePlan.primaryMembers.map((member) => member.name)); + const effectiveMemberSpecs = allEffectiveMemberSpecs.filter((member) => + primaryMemberNames.has(member.name) + ); const launchIdentity = await this.resolveAndValidateLaunchIdentity({ claudePath, cwd: request.cwd, @@ -7532,9 +8640,12 @@ export class TeamProvisioningService { timeoutHandle: null, fsMonitorHandle: null, onProgress, - expectedMembers: request.members.map((member) => member.name), + expectedMembers: effectiveMemberSpecs.map((member) => member.name), request, + allEffectiveMembers: allEffectiveMemberSpecs, effectiveMembers: effectiveMemberSpecs, + launchIdentity, + mixedSecondaryLanes: this.createMixedSecondaryLaneStates(lanePlan), lastLogProgressAt: 0, lastDataReceivedAt: 0, // intentionally 0 β€” real reset happens after spawn (see startStallWatchdog call sites) lastStdoutReceivedAt: 0, @@ -7580,7 +8691,7 @@ export class TeamProvisioningService { geminiPostLaunchHydrationSent: false, suppressGeminiPostLaunchHydrationOutput: false, memberSpawnStatuses: new Map( - request.members.map((m) => [m.name, createInitialMemberSpawnStatusEntry()]) + effectiveMemberSpecs.map((member) => [member.name, createInitialMemberSpawnStatusEntry()]) ), memberSpawnToolUseIds: new Map(), pendingMemberRestarts: new Map(), @@ -7716,19 +8827,7 @@ export class TeamProvisioningService { launchIdentity, createdAt: Date.now(), }); - const membersToWrite = applyDistinctProvisioningMemberColors( - effectiveMemberSpecs.map((m) => ({ - name: m.name.trim(), - role: m.role?.trim() || undefined, - workflow: m.workflow?.trim() || undefined, - isolation: m.isolation === 'worktree' ? ('worktree' as const) : undefined, - providerId: normalizeOptionalTeamProviderId(m.providerId), - model: m.model?.trim() || undefined, - effort: isTeamEffortLevel(m.effort) ? m.effort : undefined, - agentType: 'general-purpose' as const, - joinedAt: Date.now(), - })) - ); + const membersToWrite = this.buildMembersMetaWritePayload(allEffectiveMemberSpecs); await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, { providerBackendId: request.providerBackendId, }); @@ -7878,22 +8977,7 @@ export class TeamProvisioningService { limitContext: request.limitContext, createdAt: Date.now(), }); - const membersToWrite = applyDistinctProvisioningMemberColors( - effectiveMembers.map((member) => ({ - name: member.name.trim(), - role: member.role?.trim() || undefined, - workflow: member.workflow?.trim() || undefined, - isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined, - providerId: normalizeOptionalTeamProviderId(member.providerId), - model: member.model?.trim() || undefined, - effort: - member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' - ? member.effort - : undefined, - agentType: 'general-purpose' as const, - joinedAt: Date.now(), - })) - ); + const membersToWrite = this.buildMembersMetaWritePayload(effectiveMembers); await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, { providerBackendId: request.providerBackendId, }); @@ -7923,7 +9007,8 @@ export class TeamProvisioningService { await ensureCwdExists(request.cwd); const { members, warning } = await this.resolveLaunchExpectedMembers( request.teamName, - configRaw + configRaw, + request.providerId ); const effectiveMembers = buildEffectiveTeamMemberSpecs(members, { providerId: request.providerId, @@ -7984,8 +9069,20 @@ export class TeamProvisioningService { this.resetTeamScopedTransientStateForNewRun(input.request.teamName); const previousLaunchState = await this.launchStateStore.read(input.request.teamName); await this.clearPersistedLaunchState(input.request.teamName); + await migrateLegacyOpenCodeRuntimeState({ + teamsBasePath: getTeamsBasePath(), + teamName: input.request.teamName, + laneId: 'primary', + }); + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: getTeamsBasePath(), + teamName: input.request.teamName, + laneId: 'primary', + state: 'active', + }); const launchInput: TeamRuntimeLaunchInput = { runId, + laneId: 'primary', teamName: input.request.teamName, cwd: input.request.cwd, prompt: input.prompt, @@ -8021,6 +9118,7 @@ export class TeamProvisioningService { await this.persistOpenCodeRuntimeAdapterLaunchResult(result, launchInput); const success = result.teamLaunchState === 'clean_success'; const pending = result.teamLaunchState === 'partial_pending'; + const failed = result.teamLaunchState === 'partial_failure'; const finalProgress = this.setRuntimeAdapterProgress( { ...launching, @@ -8046,12 +9144,22 @@ export class TeamProvisioningService { }, input.onProgress ); - this.runtimeAdapterRunByTeam.set(input.request.teamName, { - runId, - providerId: 'opencode', - cwd: input.request.cwd, - }); - this.aliveRunByTeam.set(input.request.teamName, runId); + if (failed) { + await clearOpenCodeRuntimeLaneStorage({ + teamsBasePath: getTeamsBasePath(), + teamName: input.request.teamName, + laneId: 'primary', + }).catch(() => undefined); + this.runtimeAdapterRunByTeam.delete(input.request.teamName); + this.aliveRunByTeam.delete(input.request.teamName); + } else { + this.runtimeAdapterRunByTeam.set(input.request.teamName, { + runId, + providerId: 'opencode', + cwd: input.request.cwd, + }); + this.aliveRunByTeam.set(input.request.teamName, runId); + } if (this.provisioningRunByTeam.get(input.request.teamName) === runId) { this.provisioningRunByTeam.delete(input.request.teamName); } @@ -8063,6 +9171,11 @@ export class TeamProvisioningService { }); return { runId }; } catch (error) { + await clearOpenCodeRuntimeLaneStorage({ + teamsBasePath: getTeamsBasePath(), + teamName: input.request.teamName, + laneId: 'primary', + }).catch(() => undefined); const message = error instanceof Error ? error.message : String(error); this.setRuntimeAdapterProgress( { @@ -8093,15 +9206,26 @@ export class TeamProvisioningService { description: request.description, color: request.color, projectPath: request.cwd, - members: members.map((member) => ({ - name: member.name, - role: member.role, - workflow: member.workflow, - isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined, - providerId: normalizeOptionalTeamProviderId(member.providerId), - model: member.model, - effort: member.effort, - })), + members: [ + { + name: 'team-lead', + role: 'Team Lead', + agentType: 'team-lead', + providerId: normalizeOptionalTeamProviderId(request.providerId), + model: request.model, + effort: request.effort, + cwd: request.cwd, + }, + ...members.map((member) => ({ + name: member.name, + role: member.role, + workflow: member.workflow, + isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined, + providerId: normalizeOptionalTeamProviderId(member.providerId), + model: member.model, + effort: member.effort, + })), + ], }; await atomicWriteAsync(configPath, `${JSON.stringify(config, null, 2)}\n`); } @@ -8113,11 +9237,12 @@ export class TeamProvisioningService { const members: Record = {}; for (const member of input.expectedMembers) { const evidence = result.members[member.name]; - members[member.name] = this.toOpenCodePersistedLaunchMember(member.name, evidence); + members[member.name] = this.toOpenCodePersistedLaunchMember(member, evidence); } const snapshot = createPersistedLaunchSnapshot({ teamName: input.teamName, expectedMembers: input.expectedMembers.map((member) => member.name), + bootstrapExpectedMembers: input.expectedMembers.map((member) => member.name), leadSessionId: result.leadSessionId, launchPhase: result.launchPhase, members, @@ -8127,13 +9252,20 @@ export class TeamProvisioningService { } private toOpenCodePersistedLaunchMember( - memberName: string, + member: TeamRuntimeLaunchInput['expectedMembers'][number], evidence: TeamRuntimeMemberLaunchEvidence | undefined ): PersistedTeamLaunchMemberState { const now = nowIso(); const launchState = evidence?.launchState ?? 'failed_to_start'; return { - name: memberName, + name: member.name, + providerId: 'opencode', + providerBackendId: undefined, + model: member.model?.trim() || undefined, + effort: member.effort, + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'opencode', launchState, agentToolAccepted: evidence?.agentToolAccepted === true, runtimeAlive: evidence?.runtimeAlive === true, @@ -8229,13 +9361,11 @@ export class TeamProvisioningService { members: expectedMemberSpecs, source, warning, - } = await this.resolveLaunchExpectedMembers(request.teamName, configRaw); + } = await this.resolveLaunchExpectedMembers(request.teamName, configRaw, request.providerId); assertOpenCodeNotLaunchedThroughLegacyProvisioning({ providerId: request.providerId, members: expectedMemberSpecs, }); - const expectedMembers = expectedMemberSpecs.map((m) => m.name); - // Extract leadSessionId for session resume on reconnect. // If a valid JSONL file exists for the previous session, we can resume it // so the lead retains full context of prior work. @@ -8402,7 +9532,7 @@ export class TeamProvisioningService { throw new Error(envWarning); } - const effectiveMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({ + const allEffectiveMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({ claudePath, cwd: request.cwd, members: expectedMemberSpecs, @@ -8415,6 +9545,12 @@ export class TeamProvisioningService { primaryEnv: provisioningEnv, limitContext: request.limitContext, }); + const lanePlan = this.planRuntimeLanesOrThrow(request.providerId, allEffectiveMemberSpecs); + const primaryMemberNames = new Set(lanePlan.primaryMembers.map((member) => member.name)); + const effectiveMemberSpecs = allEffectiveMemberSpecs.filter((member) => + primaryMemberNames.has(member.name) + ); + const expectedMembers = effectiveMemberSpecs.map((member) => member.name); const launchIdentity = await this.resolveAndValidateLaunchIdentity({ claudePath, cwd: request.cwd, @@ -8426,7 +9562,7 @@ export class TeamProvisioningService { // Build a synthetic TeamCreateRequest for reuse by shared infrastructure const syntheticRequest: TeamCreateRequest = { teamName: request.teamName, - members: effectiveMemberSpecs, + members: allEffectiveMemberSpecs, cwd: request.cwd, providerId: request.providerId, providerBackendId: request.providerBackendId, @@ -8473,7 +9609,10 @@ export class TeamProvisioningService { onProgress, expectedMembers, request: syntheticRequest, + allEffectiveMembers: allEffectiveMemberSpecs, effectiveMembers: effectiveMemberSpecs, + launchIdentity, + mixedSecondaryLanes: this.createMixedSecondaryLaneStates(lanePlan), lastLogProgressAt: 0, lastDataReceivedAt: 0, // intentionally 0 β€” real reset happens after spawn (see startStallWatchdog call sites) lastStdoutReceivedAt: 0, @@ -8684,18 +9823,7 @@ export class TeamProvisioningService { }); await this.membersMetaStore.writeMembers( request.teamName, - effectiveMemberSpecs.map((member) => ({ - name: member.name.trim(), - role: member.role?.trim() || undefined, - workflow: member.workflow?.trim() || undefined, - isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined, - providerId: normalizeOptionalTeamProviderId(member.providerId), - model: member.model?.trim() || undefined, - effort: isTeamEffortLevel(member.effort) ? member.effort : undefined, - agentType: 'general-purpose', - color: getMemberColorByName(member.name.trim()), - joinedAt: Date.now(), - })), + this.buildMembersMetaWritePayload(allEffectiveMemberSpecs), { providerBackendId: request.providerBackendId, } @@ -10086,7 +11214,7 @@ export class TeamProvisioningService { ): string | undefined { for (const member of configuredMembers ?? []) { const candidateName = typeof member?.name === 'string' ? member.name.trim() : ''; - if (!candidateName || !matchesTeamMemberIdentity(candidateName, memberName)) { + if (!candidateName || !matchesExactTeamMemberName(candidateName, memberName)) { continue; } const model = member.model?.trim(); @@ -10103,7 +11231,7 @@ export class TeamProvisioningService { ): string | undefined { for (const member of metaMembers) { const candidateName = member.name?.trim() ?? ''; - if (!candidateName || !matchesTeamMemberIdentity(candidateName, memberName)) { + if (!candidateName || !matchesExactTeamMemberName(candidateName, memberName)) { continue; } const model = member.model?.trim(); @@ -10124,18 +11252,20 @@ export class TeamProvisioningService { workflow?: string; isolation?: 'worktree'; providerId?: TeamProviderId; + providerBackendId?: TeamProviderBackendId; model?: string; effort?: EffortLevel; + fastMode?: TeamFastMode; agentType?: string; removedAt?: number | string; } | null { const configuredMember = (configuredMembers ?? []).find((member) => { const candidateName = typeof member?.name === 'string' ? member.name.trim() : ''; - return candidateName.length > 0 && matchesTeamMemberIdentity(candidateName, memberName); + return candidateName.length > 0 && matchesExactTeamMemberName(candidateName, memberName); }); const metaMember = metaMembers.find((member) => { const candidateName = member.name?.trim() ?? ''; - return candidateName.length > 0 && matchesTeamMemberIdentity(candidateName, memberName); + return candidateName.length > 0 && matchesExactTeamMemberName(candidateName, memberName); }); if (!configuredMember && !metaMember) { @@ -10154,12 +11284,25 @@ export class TeamProvisioningService { const providerId = normalizeTeamMemberProviderId(metaMember?.providerId) ?? normalizeTeamMemberProviderId(configuredMember?.providerId); + const providerBackendId = + migrateProviderBackendId(metaMember?.providerId, metaMember?.providerBackendId) ?? + migrateProviderBackendId(configuredMember?.providerId, configuredMember?.providerBackendId); const model = metaMember?.model?.trim() || configuredMember?.model?.trim() || undefined; const effort = isTeamEffortLevel(metaMember?.effort) ? metaMember.effort : isTeamEffortLevel(configuredMember?.effort) ? configuredMember.effort : undefined; + const fastMode = + metaMember?.fastMode === 'inherit' || + metaMember?.fastMode === 'on' || + metaMember?.fastMode === 'off' + ? metaMember.fastMode + : configuredMember?.fastMode === 'inherit' || + configuredMember?.fastMode === 'on' || + configuredMember?.fastMode === 'off' + ? configuredMember.fastMode + : undefined; const agentType = metaMember?.agentType?.trim() || configuredMember?.agentType?.trim() || undefined; const removedAt = metaMember?.removedAt ?? configuredMember?.removedAt; @@ -10170,8 +11313,10 @@ export class TeamProvisioningService { ...(workflow ? { workflow } : {}), ...(isolation ? { isolation } : {}), ...(providerId ? { providerId } : {}), + ...(providerBackendId ? { providerBackendId } : {}), ...(model ? { model } : {}), ...(effort ? { effort } : {}), + ...(fastMode ? { fastMode } : {}), ...(agentType ? { agentType } : {}), ...(removedAt != null ? { removedAt } : {}), }; @@ -10196,6 +11341,24 @@ export class TeamProvisioningService { return 'team-lead'; } + private isMemberRemovedInMeta( + metaMembers: Awaited>, + memberName: string + ): boolean { + const normalizedMemberName = memberName.trim().toLowerCase(); + if (!normalizedMemberName) { + return false; + } + return metaMembers.some((member) => { + const candidateName = member.name?.trim().toLowerCase() ?? ''; + return ( + candidateName.length > 0 && + candidateName === normalizedMemberName && + Boolean(member.removedAt) + ); + }); + } + private findEffectiveRunMemberModel( run: ProvisioningRun | null, memberName: string @@ -10280,7 +11443,11 @@ export class TeamProvisioningService { for (const member of persistedRuntimeMembers) { const memberName = typeof member.name === 'string' ? member.name.trim() : ''; - if (!memberName || isLeadMember({ name: memberName })) { + if ( + !memberName || + this.isMemberRemovedInMeta(metaMembers, memberName) || + isLeadMember({ name: memberName }) + ) { continue; } const runtimeModel = @@ -10299,7 +11466,11 @@ export class TeamProvisioningService { for (const member of configuredMembers) { const memberName = typeof member?.name === 'string' ? member.name.trim() : ''; - if (!memberName || isLeadMember({ name: memberName, agentType: member.agentType })) { + if ( + !memberName || + this.isMemberRemovedInMeta(metaMembers, memberName) || + isLeadMember({ name: memberName, agentType: member.agentType }) + ) { continue; } const runtimeModel = @@ -10313,7 +11484,11 @@ export class TeamProvisioningService { for (const member of metaMembers) { const memberName = typeof member?.name === 'string' ? member.name.trim() : ''; - if (!memberName || isLeadMember({ name: memberName, agentType: member.agentType })) { + if ( + !memberName || + member.removedAt || + isLeadMember({ name: memberName, agentType: member.agentType }) + ) { continue; } const runtimeModel = @@ -10590,6 +11765,43 @@ export class TeamProvisioningService { return `${prefix} β€” ${launchSummary.confirmedCount}/${run.expectedMembers.length} teammates made contact${launchSummary.runtimeAlivePendingCount > 0 ? `, ${launchSummary.runtimeAlivePendingCount} teammate${launchSummary.runtimeAlivePendingCount === 1 ? '' : 's'} online` : ''}${stillStartingCount > 0 ? `${launchSummary.runtimeAlivePendingCount > 0 ? ', ' : ', '}${stillStartingCount} still joining` : ''}`; } + private buildAggregatePendingLaunchMessage( + prefix: string, + run: ProvisioningRun, + launchSummary: { + confirmedCount: number; + pendingCount: number; + failedCount: number; + runtimeAlivePendingCount: number; + }, + snapshot?: PersistedTeamLaunchSnapshot | null + ): string { + const mixedSecondaryLanes = run.mixedSecondaryLanes ?? []; + if (!snapshot || mixedSecondaryLanes.length === 0) { + return this.buildPendingBootstrapStatusMessage(prefix, run, launchSummary); + } + + const primaryExpectedMembers = new Set( + snapshot.bootstrapExpectedMembers ?? run.expectedMembers + ); + const secondaryPendingMembers = snapshot.expectedMembers.filter((memberName) => { + if (primaryExpectedMembers.has(memberName)) { + return false; + } + const member = snapshot.members[memberName]; + return ( + member && + member.launchState !== 'confirmed_alive' && + member.launchState !== 'failed_to_start' + ); + }); + if (secondaryPendingMembers.length === 0) { + return this.buildPendingBootstrapStatusMessage(prefix, run, launchSummary); + } + + return `${prefix} - waiting for secondary runtime lane: ${secondaryPendingMembers.join(', ')}`; + } + private buildRuntimeSpawnStatusRecord( run: ProvisioningRun ): Record { @@ -10601,17 +11813,92 @@ export class TeamProvisioningService { return statuses; } + private buildMixedPersistedLaunchSnapshotForRun( + run: ProvisioningRun, + launchPhase: PersistedTeamLaunchPhase + ): PersistedTeamLaunchSnapshot | null { + const mixedSecondaryLanes = run.mixedSecondaryLanes ?? []; + if (!run.isLaunch || mixedSecondaryLanes.length === 0) { + return null; + } + + return this.runtimeLaneCoordinator.buildAggregateLaunchSnapshot({ + teamName: run.teamName, + leadSessionId: run.detectedSessionId ?? undefined, + launchPhase, + leadDefaults: { + providerId: resolveTeamProviderId(run.request.providerId), + providerBackendId: + migrateProviderBackendId(run.request.providerId, run.request.providerBackendId) ?? null, + selectedFastMode: run.request.fastMode, + resolvedFastMode: + typeof run.launchIdentity?.resolvedFastMode === 'boolean' + ? run.launchIdentity.resolvedFastMode + : null, + launchIdentity: run.launchIdentity ?? null, + }, + primaryMembers: run.effectiveMembers, + primaryStatuses: this.buildRuntimeSpawnStatusRecord(run), + secondaryMembers: mixedSecondaryLanes.map((secondaryLane) => { + const evidenceEntry = secondaryLane.result?.members[secondaryLane.member.name]; + return { + laneId: secondaryLane.laneId, + member: secondaryLane.member, + leadDefaults: { + providerId: resolveTeamProviderId(run.request.providerId), + providerBackendId: + migrateProviderBackendId(run.request.providerId, run.request.providerBackendId) ?? + null, + selectedFastMode: run.request.fastMode, + resolvedFastMode: + typeof run.launchIdentity?.resolvedFastMode === 'boolean' + ? run.launchIdentity.resolvedFastMode + : null, + launchIdentity: run.launchIdentity ?? null, + }, + evidence: evidenceEntry + ? { + launchState: evidenceEntry.launchState, + agentToolAccepted: evidenceEntry.agentToolAccepted, + runtimeAlive: evidenceEntry.runtimeAlive, + bootstrapConfirmed: evidenceEntry.bootstrapConfirmed, + hardFailure: evidenceEntry.hardFailure, + hardFailureReason: evidenceEntry.hardFailureReason, + diagnostics: evidenceEntry.diagnostics, + } + : null, + pendingReason: + secondaryLane.result || secondaryLane.state === 'finished' + ? undefined + : secondaryLane.state === 'launching' + ? 'Launching through OpenCode secondary lane.' + : 'Queued for OpenCode secondary lane launch.', + }; + }), + }); + } + + private hasMixedLaunchMetadata(snapshot: PersistedTeamLaunchSnapshot | null): boolean { + return hasMixedPersistedLaunchMetadata(snapshot); + } + private async persistLaunchStateSnapshot( run: ProvisioningRun, launchPhase: 'active' | 'finished' | 'reconciled' = run.provisioningComplete ? 'finished' : 'active' - ): Promise { + ): Promise { + const mixedSnapshot = this.buildMixedPersistedLaunchSnapshotForRun(run, launchPhase); + if (mixedSnapshot) { + await this.launchStateStore.write(run.teamName, mixedSnapshot); + return mixedSnapshot; + } + if (!run.isLaunch || !run.expectedMembers || run.expectedMembers.length === 0) { if (run.isLaunch) { await this.clearPersistedLaunchState(run.teamName); } - return; + return null; } const snapshot = snapshotFromRuntimeMemberStatuses({ @@ -10624,10 +11911,233 @@ export class TeamProvisioningService { if (snapshot.teamLaunchState === 'clean_success' && launchPhase !== 'active') { await this.clearPersistedLaunchState(run.teamName); - return; + return null; } await this.launchStateStore.write(run.teamName, snapshot); + return snapshot; + } + + private async launchSingleMixedSecondaryLane( + run: ProvisioningRun, + lane: MixedSecondaryRuntimeLaneState + ): Promise { + const adapter = this.getOpenCodeRuntimeAdapter(); + if (!adapter) { + const message = 'OpenCode runtime adapter is not registered for mixed team launch.'; + lane.state = 'finished'; + lane.result = { + runId: lane.runId ?? randomUUID(), + teamName: run.teamName, + launchPhase: 'finished', + teamLaunchState: 'partial_failure', + members: { + [lane.member.name]: { + memberName: lane.member.name, + providerId: 'opencode', + launchState: 'failed_to_start', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'opencode_runtime_adapter_missing', + diagnostics: [message], + }, + }, + warnings: [], + diagnostics: [message], + }; + lane.warnings = []; + lane.diagnostics = [message]; + return; + } + + const migration = await migrateLegacyOpenCodeRuntimeState({ + teamsBasePath: getTeamsBasePath(), + teamName: run.teamName, + laneId: lane.laneId, + }); + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: getTeamsBasePath(), + teamName: run.teamName, + laneId: lane.laneId, + state: migration.degraded ? 'degraded' : 'active', + diagnostics: migration.diagnostics, + }); + + lane.state = 'launching'; + lane.runId = lane.runId ?? randomUUID(); + lane.warnings = []; + lane.diagnostics = [...migration.diagnostics]; + this.setSecondaryRuntimeRun({ + teamName: run.teamName, + runId: lane.runId, + providerId: 'opencode', + laneId: lane.laneId, + memberName: lane.member.name, + cwd: run.request.cwd, + }); + + await this.persistLaunchStateSnapshot(run, this.getMixedSecondaryLaunchPhase(run)); + const previousLaunchState = await this.launchStateStore.read(run.teamName); + + try { + const result = await adapter.launch({ + runId: lane.runId, + laneId: lane.laneId, + teamName: run.teamName, + cwd: run.request.cwd, + prompt: run.request.prompt?.trim() ?? undefined, + providerId: 'opencode', + model: lane.member.model, + effort: lane.member.effort, + skipPermissions: run.request.skipPermissions !== false, + expectedMembers: [ + { + name: lane.member.name, + role: lane.member.role, + workflow: lane.member.workflow, + isolation: lane.member.isolation === 'worktree' ? ('worktree' as const) : undefined, + providerId: 'opencode', + model: lane.member.model, + effort: lane.member.effort, + cwd: run.request.cwd, + }, + ], + previousLaunchState, + }); + lane.state = 'finished'; + lane.result = result; + lane.warnings = [...result.warnings]; + lane.diagnostics = [...migration.diagnostics, ...result.diagnostics]; + + if (result.teamLaunchState === 'partial_failure') { + this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + lane.state = 'finished'; + lane.result = { + runId: lane.runId, + teamName: run.teamName, + launchPhase: 'finished', + teamLaunchState: 'partial_failure', + members: { + [lane.member.name]: { + memberName: lane.member.name, + providerId: 'opencode', + launchState: 'failed_to_start', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: message, + diagnostics: [message], + }, + }, + warnings: [], + diagnostics: [message], + }; + lane.warnings = []; + lane.diagnostics = [...migration.diagnostics, message]; + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: getTeamsBasePath(), + teamName: run.teamName, + laneId: lane.laneId, + state: 'degraded', + diagnostics: [message], + }).catch(() => undefined); + this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId); + } + + await this.persistLaunchStateSnapshot(run, this.getMixedSecondaryLaunchPhase(run)); + } + + private async stopSingleMixedSecondaryRuntimeLane( + run: ProvisioningRun, + lane: MixedSecondaryRuntimeLaneState, + reason: TeamRuntimeStopInput['reason'] + ): Promise { + const adapter = this.getOpenCodeRuntimeAdapter(); + const previousLaunchState = await this.launchStateStore.read(run.teamName); + + try { + if (adapter && lane.runId) { + await adapter.stop({ + runId: lane.runId, + laneId: lane.laneId, + teamName: run.teamName, + cwd: run.request.cwd, + providerId: 'opencode', + reason, + previousLaunchState, + force: true, + }); + } + } catch (error) { + logger.warn( + `[${run.teamName}] Failed to stop mixed OpenCode lane ${lane.laneId}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } finally { + await clearOpenCodeRuntimeLaneStorage({ + teamsBasePath: getTeamsBasePath(), + teamName: run.teamName, + laneId: lane.laneId, + }).catch(() => undefined); + this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId); + lane.runId = null; + lane.state = 'finished'; + lane.result = null; + lane.warnings = []; + lane.diagnostics = []; + } + } + + private async launchMixedSecondaryLaneIfNeeded( + run: ProvisioningRun + ): Promise { + const mixedSecondaryLanes = run.mixedSecondaryLanes ?? []; + if (mixedSecondaryLanes.length === 0) { + return this.persistLaunchStateSnapshot(run, 'finished'); + } + + const adapter = this.getOpenCodeRuntimeAdapter(); + if (!adapter) { + for (const lane of mixedSecondaryLanes) { + lane.state = 'finished'; + lane.result = { + runId: lane.runId ?? randomUUID(), + teamName: run.teamName, + launchPhase: 'finished', + teamLaunchState: 'partial_failure', + members: { + [lane.member.name]: { + memberName: lane.member.name, + providerId: 'opencode', + launchState: 'failed_to_start', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'opencode_runtime_adapter_missing', + diagnostics: ['OpenCode runtime adapter is not registered for mixed team launch.'], + }, + }, + warnings: [], + diagnostics: ['OpenCode runtime adapter is not registered for mixed team launch.'], + }; + lane.diagnostics = lane.result.diagnostics; + } + return this.persistLaunchStateSnapshot(run, 'finished'); + } + + for (const lane of mixedSecondaryLanes) { + await this.launchSingleMixedSecondaryLane(run, lane); + } + + return this.persistLaunchStateSnapshot(run, this.getMixedSecondaryLaunchPhase(run)); } private async reconcilePersistedLaunchState(teamName: string): Promise<{ @@ -10819,7 +12329,10 @@ export class TeamProvisioningService { updatedAt: now, }); - if (reconciled.teamLaunchState === 'clean_success') { + if ( + reconciled.teamLaunchState === 'clean_success' && + !this.hasMixedLaunchMetadata(reconciled) + ) { await this.clearPersistedLaunchState(teamName); return { snapshot: null, statuses: {} }; } @@ -11343,6 +12856,9 @@ export class TeamProvisioningService { const runId = this.getTrackedRunId(teamName); if (!runId) { + if (this.hasSecondaryRuntimeRuns(teamName)) { + void this.stopMixedSecondaryRuntimeLanes(teamName); + } return; } const run = this.runs.get(runId); @@ -11352,6 +12868,9 @@ export class TeamProvisioningService { void this.stopOpenCodeRuntimeAdapterTeam(teamName, runId); return; } + if (this.hasSecondaryRuntimeRuns(teamName)) { + void this.stopMixedSecondaryRuntimeLanes(teamName); + } this.provisioningRunByTeam.delete(teamName); this.aliveRunByTeam.delete(teamName); return; @@ -11362,16 +12881,77 @@ export class TeamProvisioningService { run.processKilled = true; run.cancelRequested = true; killTeamProcess(run.child); + if (this.hasSecondaryRuntimeRuns(teamName)) { + void this.stopMixedSecondaryRuntimeLanes(teamName); + } const progress = updateProgress(run, 'disconnected', 'Team stopped by user'); run.onProgress(progress); this.cleanupRun(run); logger.info(`[${teamName}] Process stopped (SIGKILL)`); } + private async stopMixedSecondaryRuntimeLanes(teamName: string): Promise { + const secondaryRuns = this.getSecondaryRuntimeRuns(teamName); + if (secondaryRuns.length === 0) { + return; + } + const adapter = this.getOpenCodeRuntimeAdapter(); + const previousLaunchState = await this.launchStateStore.read(teamName); + if (!adapter) { + await Promise.all( + secondaryRuns.map((secondaryRun) => + clearOpenCodeRuntimeLaneStorage({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: secondaryRun.laneId, + }).catch(() => undefined) + ) + ); + this.clearSecondaryRuntimeRuns(teamName); + return; + } + try { + for (const secondaryRun of secondaryRuns) { + try { + await adapter.stop({ + runId: secondaryRun.runId, + laneId: secondaryRun.laneId, + teamName, + cwd: secondaryRun.cwd ?? this.readPersistedTeamProjectPath(teamName) ?? undefined, + providerId: 'opencode', + reason: 'user_requested', + previousLaunchState, + force: true, + }); + } catch (error) { + logger.warn( + `[${teamName}] Failed to stop mixed OpenCode secondary lane ${secondaryRun.laneId}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } finally { + await clearOpenCodeRuntimeLaneStorage({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: secondaryRun.laneId, + }).catch(() => undefined); + this.deleteSecondaryRuntimeRun(teamName, secondaryRun.laneId); + } + } + } finally { + this.clearSecondaryRuntimeRuns(teamName); + } + } + private async stopOpenCodeRuntimeAdapterTeam(teamName: string, runId: string): Promise { const adapter = this.getOpenCodeRuntimeAdapter(); const previousLaunchState = await this.launchStateStore.read(teamName); if (!adapter) { + await clearOpenCodeRuntimeLaneStorage({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: 'primary', + }).catch(() => undefined); this.runtimeAdapterRunByTeam.delete(teamName); this.aliveRunByTeam.delete(teamName); this.provisioningRunByTeam.delete(teamName); @@ -11391,6 +12971,7 @@ export class TeamProvisioningService { const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName); const result = await adapter.stop({ runId, + laneId: 'primary', teamName, cwd: runtimeRun?.cwd ?? this.readPersistedTeamProjectPath(teamName) ?? undefined, providerId: 'opencode', @@ -11433,6 +13014,11 @@ export class TeamProvisioningService { cliLogsTail: message, }); } finally { + await clearOpenCodeRuntimeLaneStorage({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: 'primary', + }).catch(() => undefined); this.runtimeAdapterRunByTeam.delete(teamName); this.aliveRunByTeam.delete(teamName); this.provisioningRunByTeam.delete(teamName); @@ -13481,22 +15067,40 @@ export class TeamProvisioningService { await this.refreshMemberSpawnStatusesFromLeadInbox(run); await this.maybeAuditMemberSpawnStatuses(run, { force: true }); await this.finalizeMissingRegisteredMembersAsFailed(run); - await this.persistLaunchStateSnapshot(run, 'finished'); - const failedSpawnMembers = this.getFailedSpawnMembers(run); - const launchSummary = this.getMemberLaunchSummary(run); + const persistedLaunchSnapshot = await this.launchMixedSecondaryLaneIfNeeded(run); + const failedSpawnMembers = persistedLaunchSnapshot + ? persistedLaunchSnapshot.expectedMembers + .filter( + (memberName) => + persistedLaunchSnapshot.members[memberName]?.launchState === 'failed_to_start' + ) + .map((memberName) => ({ + name: memberName, + error: persistedLaunchSnapshot.members[memberName]?.hardFailureReason, + updatedAt: persistedLaunchSnapshot.members[memberName]?.lastEvaluatedAt ?? nowIso(), + })) + : this.getFailedSpawnMembers(run); + const launchSummary = persistedLaunchSnapshot?.summary ?? this.getMemberLaunchSummary(run); const hasSpawnFailures = failedSpawnMembers.length > 0; const stillStartingCount = Math.max( 0, launchSummary.pendingCount - launchSummary.runtimeAlivePendingCount ); const hasPendingBootstrap = - !hasSpawnFailures && stillStartingCount > 0 && (run.expectedMembers?.length ?? 0) > 0; + !hasSpawnFailures && + stillStartingCount > 0 && + (persistedLaunchSnapshot?.expectedMembers.length ?? run.expectedMembers?.length ?? 0) > 0; const readyMessage = hasSpawnFailures ? `Launch completed with teammate errors β€” ${failedSpawnMembers .map((member) => member.name) .join(', ')} failed to start` : hasPendingBootstrap - ? this.buildPendingBootstrapStatusMessage('Launch completed', run, launchSummary) + ? this.buildAggregatePendingLaunchMessage( + 'Launch completed', + run, + launchSummary, + persistedLaunchSnapshot + ) : 'Team launched β€” process alive and ready'; const progress = updateProgress(run, 'ready', readyMessage, { cliLogsTail: extractCliLogsFromRun(run), @@ -13642,16 +15246,29 @@ export class TeamProvisioningService { await this.refreshMemberSpawnStatusesFromLeadInbox(run); await this.maybeAuditMemberSpawnStatuses(run, { force: true }); await this.finalizeMissingRegisteredMembersAsFailed(run); - await this.persistLaunchStateSnapshot(run, 'finished'); - const failedSpawnMembers = this.getFailedSpawnMembers(run); - const launchSummary = this.getMemberLaunchSummary(run); + const persistedLaunchSnapshot = await this.launchMixedSecondaryLaneIfNeeded(run); + const failedSpawnMembers = persistedLaunchSnapshot + ? persistedLaunchSnapshot.expectedMembers + .filter( + (memberName) => + persistedLaunchSnapshot.members[memberName]?.launchState === 'failed_to_start' + ) + .map((memberName) => ({ + name: memberName, + error: persistedLaunchSnapshot.members[memberName]?.hardFailureReason, + updatedAt: persistedLaunchSnapshot.members[memberName]?.lastEvaluatedAt ?? nowIso(), + })) + : this.getFailedSpawnMembers(run); + const launchSummary = persistedLaunchSnapshot?.summary ?? this.getMemberLaunchSummary(run); const hasSpawnFailures = failedSpawnMembers.length > 0; const stillStartingCount = Math.max( 0, launchSummary.pendingCount - launchSummary.runtimeAlivePendingCount ); const hasPendingBootstrap = - !hasSpawnFailures && stillStartingCount > 0 && run.expectedMembers.length > 0; + !hasSpawnFailures && + stillStartingCount > 0 && + (persistedLaunchSnapshot?.expectedMembers.length ?? run.expectedMembers.length) > 0; const progress = updateProgress( run, 'ready', @@ -13660,7 +15277,12 @@ export class TeamProvisioningService { .map((member) => member.name) .join(', ')} failed to start` : hasPendingBootstrap - ? this.buildPendingBootstrapStatusMessage('Team provisioned', run, launchSummary) + ? this.buildAggregatePendingLaunchMessage( + 'Team provisioned', + run, + launchSummary, + persistedLaunchSnapshot + ) : 'Team provisioned β€” process alive and ready', { cliLogsTail: extractCliLogsFromRun(run), @@ -14053,6 +15675,9 @@ export class TeamProvisioningService { if (this.aliveRunByTeam.get(run.teamName) === run.runId) { this.aliveRunByTeam.delete(run.teamName); } + if (!hasNewerTrackedRun) { + this.clearSecondaryRuntimeRuns(run.teamName); + } if (!hasNewerTrackedRun) { this.agentRuntimeSnapshotCache.delete(run.teamName); this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); @@ -15444,16 +17069,9 @@ export class TeamProvisioningService { const joinedAt = Date.now(); try { - const membersToWrite = applyDistinctProvisioningMemberColors( + const membersToWrite = this.buildMembersMetaWritePayload( teammateMembers.map((member) => ({ - name: member.name.trim(), - role: member.role?.trim() || undefined, - workflow: member.workflow?.trim() || undefined, - isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined, - providerId: normalizeOptionalTeamProviderId(member.providerId), - model: member.model?.trim() || undefined, - effort: isTeamEffortLevel(member.effort) ? member.effort : undefined, - agentType: 'general-purpose' as const, + ...member, joinedAt, })) ); @@ -15471,7 +17089,8 @@ export class TeamProvisioningService { private async resolveLaunchExpectedMembers( teamName: string, - configRaw: string + configRaw: string, + leadProviderId?: TeamProviderId ): Promise<{ members: TeamCreateRequest['members']; source: 'members-meta' | 'inboxes' | 'config-fallback'; @@ -15575,6 +17194,12 @@ export class TeamProvisioningService { const memberOverridesUsed = members.some( (member) => member.providerId || member.model || member.effort || member.isolation ); + this.assertMixedLaunchFallbackSafe({ + teamName, + leadProviderId, + source: 'inboxes', + members, + }); return { members, source: 'inboxes', @@ -15588,6 +17213,9 @@ export class TeamProvisioningService { }; } } catch (error) { + if (error instanceof Error && error.message.includes(getMixedLaunchFallbackRecoveryError())) { + throw error; + } logger.warn( `[${teamName}] Failed to read inbox member names: ${ error instanceof Error ? error.message : String(error) @@ -15597,6 +17225,12 @@ export class TeamProvisioningService { const configMembers = this.extractTeammateSpecsFromConfig(teamName, configRaw); if (configMembers.length > 0) { + this.assertMixedLaunchFallbackSafe({ + teamName, + leadProviderId, + source: 'config-fallback', + members: configMembers, + }); return { members: configMembers, source: 'config-fallback', @@ -15626,6 +17260,24 @@ export class TeamProvisioningService { }; } + private assertMixedLaunchFallbackSafe(params: { + teamName: string; + leadProviderId?: TeamProviderId; + source: 'inboxes' | 'config-fallback'; + members: TeamCreateRequest['members']; + }): void { + const lanePlan = this.runtimeLaneCoordinator.planProvisioningMembers({ + leadProviderId: params.leadProviderId, + members: params.members, + hasOpenCodeRuntimeAdapter: true, + }); + if (this.runtimeLaneCoordinator.isMixedSideLanePlan(lanePlan)) { + throw new Error( + `[${params.teamName}] ${getMixedLaunchFallbackRecoveryError()} Fallback source: ${params.source}.` + ); + } + } + private extractTeammateSpecsFromConfig( teamName: string, configRaw: string diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index c263abe9..a72f3b97 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -49,6 +49,7 @@ export interface OpenCodeTeamLaunchMemberCommandSpec { export interface OpenCodeLaunchTeamCommandBody { mode: OpenCodeTeamLaunchMode; runId: string; + laneId: string; teamId: string; teamName: string; projectPath: string; @@ -80,6 +81,7 @@ export interface OpenCodeLaunchTeamCommandData { export interface OpenCodeReconcileTeamCommandBody { runId: string; + laneId: string; teamId: string; teamName: string; projectPath?: string; @@ -92,6 +94,7 @@ export interface OpenCodeReconcileTeamCommandBody { export interface OpenCodeStopTeamCommandBody { runId: string; + laneId: string; teamId: string; teamName: string; projectPath?: string; @@ -223,6 +226,7 @@ export interface OpenCodeBridgeHandshake { export interface OpenCodeBridgeCommandPreconditions { handshakeIdentityHash: string; + laneId: string | null; expectedRunId: string | null; expectedCapabilitySnapshotId: string | null; expectedBehaviorFingerprint: string | null; @@ -486,6 +490,7 @@ export function assertBridgeEvidenceCanCommitToRuntimeStores(input: { export function createOpenCodeBridgeIdempotencyKey(input: { command: OpenCodeBridgeCommandName; teamName: string; + laneId?: string | null; runId: string | null; body: unknown; }): string { @@ -493,6 +498,7 @@ export function createOpenCodeBridgeIdempotencyKey(input: { 'opencode', sanitizeKeyPart(input.command), sanitizeKeyPart(input.teamName), + sanitizeKeyPart(input.laneId ?? 'no-lane'), sanitizeKeyPart(input.runId ?? 'no-run'), ].join(':'); return `${scope}:${stableHash(input).slice(0, 32)}`; diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore.ts index b49fd7a2..d2cc80db 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore.ts @@ -20,6 +20,7 @@ export interface OpenCodeBridgeCommandLedgerEntry { requestId: string; command: OpenCodeBridgeCommandName; teamName: string; + laneId: string | null; runId: string | null; requestHash: string; responseHash: string | null; @@ -33,6 +34,7 @@ export interface OpenCodeBridgeCommandLedgerEntry { export interface OpenCodeBridgeCommandLease { leaseId: string; teamName: string; + laneId: string | null; runId: string | null; command: OpenCodeBridgeCommandName; holderPeer: 'claude_team'; @@ -68,6 +70,7 @@ export class OpenCodeBridgeCommandLedger { requestId: string; command: OpenCodeBridgeCommandName; teamName: string; + laneId?: string | null; runId: string | null; requestHash: string; }): Promise { @@ -110,6 +113,7 @@ export class OpenCodeBridgeCommandLedger { requestId: input.requestId, command: input.command, teamName: input.teamName, + laneId: input.laneId ?? null, runId: input.runId, requestHash: input.requestHash, responseHash: null, @@ -216,6 +220,7 @@ export class OpenCodeBridgeCommandLeaseStore { async acquire(input: { teamName: string; + laneId?: string | null; runId: string | null; command: OpenCodeBridgeCommandName; ttlMs: number; @@ -233,6 +238,7 @@ export class OpenCodeBridgeCommandLeaseStore { const active = normalized.find( (lease) => lease.teamName === input.teamName && + lease.laneId === (input.laneId ?? null) && lease.state === 'active' && Date.parse(lease.expiresAt) > nowMs ); @@ -246,6 +252,7 @@ export class OpenCodeBridgeCommandLeaseStore { created = { leaseId: this.idFactory(), teamName: input.teamName, + laneId: input.laneId ?? null, runId: input.runId, command: input.command, holderPeer: 'claude_team', diff --git a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts index f560ef74..14bad398 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts @@ -52,7 +52,13 @@ export interface OpenCodeReadinessBridgeOptions { } export interface OpenCodeProductionE2EEvidenceReadPort { - read(input?: { selectedModel?: string | null; projectPathFingerprint?: string | null }): Promise<{ + 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; @@ -134,6 +140,9 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { ? await this.options.productionE2eEvidence.read({ selectedModel: expectedModel, projectPathFingerprint, + opencodeVersion: input.runtime.version, + binaryFingerprint: input.runtime.binaryFingerprint, + capabilitySnapshotId: input.runtime.capabilitySnapshotId, }) : { ok: false, @@ -204,6 +213,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { OpenCodeLaunchTeamCommandData >('opencode.launchTeam', input, { teamName: input.teamName, + laneId: input.laneId, runId: input.runId, capabilitySnapshotId: input.expectedCapabilitySnapshotId, cwd: input.projectPath, @@ -221,6 +231,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { OpenCodeLaunchTeamCommandData >('opencode.reconcileTeam', input, { teamName: input.teamName, + laneId: input.laneId, runId: input.runId, capabilitySnapshotId: input.expectedCapabilitySnapshotId ?? null, cwd, @@ -236,6 +247,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { OpenCodeStopTeamCommandData >('opencode.stopTeam', input, { teamName: input.teamName, + laneId: input.laneId, runId: input.runId, capabilitySnapshotId: input.expectedCapabilitySnapshotId ?? null, cwd, @@ -269,6 +281,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { body: TBody, input: { teamName: string; + laneId: string; runId: string; capabilitySnapshotId: string | null; cwd: string; @@ -280,6 +293,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { return await this.options.stateChangingCommands.execute({ command, teamName: input.teamName, + laneId: input.laneId, runId: input.runId, capabilitySnapshotId: input.capabilitySnapshotId, behaviorFingerprint: null, @@ -341,6 +355,7 @@ function blockedReadiness(input: { state: input.state, launchAllowed: false, modelId: input.modelId, + availableModels: [], opencodeVersion: null, installMethod: null, binaryPath: null, diff --git a/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts b/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts index 0dc1fd30..6501e07b 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts @@ -44,7 +44,7 @@ export interface OpenCodeBridgeHandshakePort { } export interface RuntimeStoreManifestReader { - read(teamName: string): Promise; + read(teamName: string, laneId?: string | null): Promise; } export interface OpenCodeStateChangingBridgeDiagnosticsSink { @@ -93,6 +93,7 @@ export class OpenCodeStateChangingBridgeCommandService { async execute(input: { command: OpenCodeBridgeCommandName; teamName: string; + laneId?: string | null; runId: string | null; capabilitySnapshotId: string | null; behaviorFingerprint: string | null; @@ -100,7 +101,8 @@ export class OpenCodeStateChangingBridgeCommandService { cwd: string; timeoutMs: number; }): Promise> { - const manifest = await this.manifestReader.read(input.teamName); + const normalizedLaneId = input.laneId ?? null; + const manifest = await this.manifestReader.read(input.teamName, normalizedLaneId); const handshake = await this.handshakePort.handshake({ requiredCommand: input.command, expectedRunId: input.runId, @@ -124,12 +126,14 @@ export class OpenCodeStateChangingBridgeCommandService { const idempotencyKey = createOpenCodeBridgeIdempotencyKey({ command: input.command, teamName: input.teamName, + laneId: normalizedLaneId, runId: input.runId, body: input.body, }); const commandRequestId = this.requestIdFactory(); const lease = await this.leaseStore.acquire({ teamName: input.teamName, + laneId: normalizedLaneId, runId: input.runId, command: input.command, ttlMs: input.timeoutMs + 5_000, @@ -138,6 +142,7 @@ export class OpenCodeStateChangingBridgeCommandService { try { const bodyWithPreconditions = attachBridgePreconditions(input.body, { handshakeIdentityHash: handshake.identityHash, + laneId: normalizedLaneId, expectedRunId: input.runId, expectedCapabilitySnapshotId: input.capabilitySnapshotId, expectedBehaviorFingerprint: input.behaviorFingerprint, @@ -151,10 +156,12 @@ export class OpenCodeStateChangingBridgeCommandService { requestId: commandRequestId, command: input.command, teamName: input.teamName, + laneId: input.laneId, runId: input.runId, requestHash: stableHash({ command: input.command, teamName: input.teamName, + laneId: normalizedLaneId, runId: input.runId, capabilitySnapshotId: input.capabilitySnapshotId, behaviorFingerprint: input.behaviorFingerprint, @@ -186,6 +193,7 @@ export class OpenCodeStateChangingBridgeCommandService { await this.appendUnknownOutcomeDiagnostic({ result, teamName: input.teamName, + laneId: normalizedLaneId, runId: input.runId, command: input.command, idempotencyKey, @@ -233,6 +241,7 @@ export class OpenCodeStateChangingBridgeCommandService { private async appendUnknownOutcomeDiagnostic(input: { result: OpenCodeBridgeResult; teamName: string; + laneId: string | null; runId: string | null; command: OpenCodeBridgeCommandName; idempotencyKey: string; @@ -244,14 +253,25 @@ export class OpenCodeStateChangingBridgeCommandService { type: 'opencode_bridge_unknown_outcome', providerId: 'opencode', teamName: input.teamName, + ...(input.laneId + ? { + data: { + laneId: input.laneId, + command: input.command, + idempotencyKey: input.idempotencyKey, + leaseId: input.leaseId, + }, + } + : { + data: { + command: input.command, + idempotencyKey: input.idempotencyKey, + leaseId: input.leaseId, + }, + }), runId: input.runId ?? extractRunId(input.result) ?? undefined, severity: 'warning', message: 'OpenCode bridge command timed out; outcome must be reconciled before retry', - data: { - command: input.command, - idempotencyKey: input.idempotencyKey, - leaseId: input.leaseId, - }, createdAt: completedAt, }); } diff --git a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts index cbcf83ed..93a61e70 100644 --- a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts +++ b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts @@ -94,6 +94,10 @@ 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[]; @@ -376,14 +380,6 @@ function collectExpectedRuntimeDiagnostics( ); } - if (!expected.selectedModel) { - diagnostics.push('OpenCode production gate cannot verify selected raw model id'); - } else if (evidence.selectedModel !== expected.selectedModel) { - diagnostics.push( - `OpenCode production E2E evidence model ${evidence.selectedModel} does not match selected model ${expected.selectedModel}. Production launch is intentionally scoped to the exact raw model id; regenerate evidence with OPENCODE_E2E_MODEL=${expected.selectedModel}.` - ); - } - if ( expected.projectPathFingerprint && evidence.projectPathFingerprint !== expected.projectPathFingerprint diff --git a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts index 9811b2c0..d844f5af 100644 --- a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts +++ b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts @@ -25,8 +25,16 @@ export interface OpenCodeProductionE2EEvidenceStoreOptions { } 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 { @@ -106,11 +114,48 @@ function selectEvidence( const modelId = options.selectedModel?.trim() ?? ''; const projectPathFingerprint = options.projectPathFingerprint?.trim() ?? ''; - if (modelId) { - const entries = Object.values(data.entriesByModel).filter( - (entry) => entry.selectedModel === modelId + 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 (entries.length === 0) { + 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: [ @@ -119,32 +164,12 @@ function selectEvidence( }; } - if (projectPathFingerprint) { - const exactMatch = pickNewestEvidence( - entries.filter((entry) => entry.projectPathFingerprint === projectPathFingerprint) - ); - if (exactMatch) { - return { - evidence: exactMatch, - diagnostics: [], - }; - } - - return { - evidence: null, - diagnostics: [ - `OpenCode production E2E evidence artifact has no entry for selected model ${modelId} and the current working directory`, - ], - }; - } - return { - evidence: pickNewestEvidence(entries), + evidence: pickNewestEvidence(exactModelEntries), diagnostics: [], }; } - const entries = Object.values(data.entriesByModel); if (entries.length === 1) { return { evidence: entries[0] ?? null, diagnostics: [] }; } @@ -182,6 +207,31 @@ 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 { diff --git a/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts b/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts index 19da9a8c..8428c813 100644 --- a/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts +++ b/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts @@ -45,6 +45,7 @@ export interface OpenCodeTeamLaunchReadiness { state: OpenCodeTeamLaunchReadinessState; launchAllowed: boolean; modelId: string | null; + availableModels: string[]; opencodeVersion: string | null; installMethod: OpenCodeInstallMethod | null; binaryPath: string | null; @@ -326,6 +327,7 @@ function readiness(input: { state: input.state, launchAllowed: input.launchAllowed === true, modelId: input.modelId, + availableModels: input.inventory?.models ?? [], opencodeVersion: input.inventory?.version ?? null, installMethod: input.inventory?.installMethod ?? null, binaryPath: input.inventory?.binaryPath ?? null, diff --git a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts index ce11bb46..a2efbc5e 100644 --- a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts +++ b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts @@ -1,3 +1,4 @@ +import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'; import * as path from 'path'; import type { RuntimeStoreManifestEvidence } from '../bridge/OpenCodeBridgeCommandContract'; @@ -10,7 +11,23 @@ export interface OpenCodeRuntimeManifestEvidenceReaderOptions { } const OPENCODE_TEAM_RUNTIME_DIR = '.opencode-runtime'; +const OPENCODE_TEAM_RUNTIME_LANES_DIR = 'lanes'; +const OPENCODE_TEAM_RUNTIME_LANES_INDEX_FILE = 'lanes.json'; const OPENCODE_RUNTIME_MANIFEST_FILE = 'manifest.json'; +const OPENCODE_RUNTIME_RUN_TOMBSTONES_FILE = 'opencode-run-tombstones.json'; + +export interface OpenCodeRuntimeLaneIndexEntry { + laneId: string; + state: 'active' | 'stopped' | 'degraded'; + updatedAt: string; + diagnostics?: string[]; +} + +export interface OpenCodeRuntimeLaneIndex { + version: 1; + updatedAt: string; + lanes: Record; +} export class OpenCodeRuntimeManifestEvidenceReader implements RuntimeStoreManifestReader { private readonly teamsBasePath: string; @@ -21,9 +38,13 @@ export class OpenCodeRuntimeManifestEvidenceReader implements RuntimeStoreManife this.clock = options.clock ?? (() => new Date()); } - async read(teamName: string): Promise { + async read(teamName: string, laneId?: string | null): Promise { + const normalizedLaneId = laneId?.trim() || null; + const manifestPath = normalizedLaneId + ? await resolveOpenCodeRuntimeManifestReadPath(this.teamsBasePath, teamName, normalizedLaneId) + : getOpenCodeRuntimeManifestPath(this.teamsBasePath, teamName); const manifest = await createRuntimeStoreManifestStore({ - filePath: getOpenCodeRuntimeManifestPath(this.teamsBasePath, teamName), + filePath: manifestPath, teamName, clock: this.clock, }).read(); @@ -36,13 +57,347 @@ export class OpenCodeRuntimeManifestEvidenceReader implements RuntimeStoreManife } } +async function fileExists(filePath: string): Promise { + try { + await stat(filePath); + return true; + } catch { + return false; + } +} + +async function resolveOpenCodeRuntimeManifestReadPath( + teamsBasePath: string, + teamName: string, + laneId: string +): Promise { + const laneManifestPath = getOpenCodeRuntimeManifestPath(teamsBasePath, teamName, laneId); + if (await fileExists(laneManifestPath)) { + return laneManifestPath; + } + + const legacyManifestPath = getOpenCodeRuntimeManifestPath(teamsBasePath, teamName); + if (!(await fileExists(legacyManifestPath))) { + return laneManifestPath; + } + + if (!(await canFallbackToLegacyManifest(teamsBasePath, teamName, laneId))) { + return laneManifestPath; + } + + return legacyManifestPath; +} + +async function canFallbackToLegacyManifest( + teamsBasePath: string, + teamName: string, + laneId: string +): Promise { + const laneDirsPath = path.join( + getOpenCodeTeamRuntimeDirectory(teamsBasePath, teamName), + OPENCODE_TEAM_RUNTIME_LANES_DIR + ); + const existingLaneDirs = await readdir(laneDirsPath).catch(() => [] as string[]); + if (existingLaneDirs.length > 0) { + return false; + } + + const laneIndex = await readOpenCodeRuntimeLaneIndex(teamsBasePath, teamName).catch(() => ({ + version: 1 as const, + updatedAt: new Date().toISOString(), + lanes: {}, + })); + const siblingLaneIds = Object.keys(laneIndex.lanes).filter( + (candidateLaneId) => candidateLaneId !== laneId + ); + return siblingLaneIds.length === 0; +} + export function getOpenCodeTeamRuntimeDirectory(teamsBasePath: string, teamName: string): string { return path.join(teamsBasePath, teamName, OPENCODE_TEAM_RUNTIME_DIR); } -export function getOpenCodeRuntimeManifestPath(teamsBasePath: string, teamName: string): string { +export function getOpenCodeRuntimeLaneIndexPath(teamsBasePath: string, teamName: string): string { + return path.join( + getOpenCodeTeamRuntimeDirectory(teamsBasePath, teamName), + OPENCODE_TEAM_RUNTIME_LANES_INDEX_FILE + ); +} + +export function getOpenCodeTeamRuntimeLaneDirectory( + teamsBasePath: string, + teamName: string, + laneId: string +): string { + return path.join( + getOpenCodeTeamRuntimeDirectory(teamsBasePath, teamName), + OPENCODE_TEAM_RUNTIME_LANES_DIR, + encodeURIComponent(laneId) + ); +} + +export function getOpenCodeRuntimeManifestPath( + teamsBasePath: string, + teamName: string, + laneId?: string | null +): string { + if (laneId && laneId.trim().length > 0) { + return path.join( + getOpenCodeTeamRuntimeLaneDirectory(teamsBasePath, teamName, laneId.trim()), + OPENCODE_RUNTIME_MANIFEST_FILE + ); + } return path.join( getOpenCodeTeamRuntimeDirectory(teamsBasePath, teamName), OPENCODE_RUNTIME_MANIFEST_FILE ); } + +export function getOpenCodeLaneScopedRuntimeFilePath(params: { + teamsBasePath: string; + teamName: string; + laneId: string; + fileName: string; +}): string { + return path.join( + getOpenCodeTeamRuntimeLaneDirectory(params.teamsBasePath, params.teamName, params.laneId), + params.fileName + ); +} + +export async function readOpenCodeRuntimeLaneIndex( + teamsBasePath: string, + teamName: string +): Promise { + const filePath = getOpenCodeRuntimeLaneIndexPath(teamsBasePath, teamName); + if (!(await fileExists(filePath))) { + return { + version: 1, + updatedAt: new Date().toISOString(), + lanes: {}, + }; + } + const raw = await readFile(filePath, 'utf8'); + const parsed = JSON.parse(raw) as Partial; + if ( + parsed.version !== 1 || + typeof parsed.updatedAt !== 'string' || + !parsed.lanes || + typeof parsed.lanes !== 'object' + ) { + return { + version: 1, + updatedAt: new Date().toISOString(), + lanes: {}, + }; + } + return { + version: 1, + updatedAt: parsed.updatedAt, + lanes: Object.fromEntries( + Object.entries(parsed.lanes).flatMap(([key, value]) => { + if ( + !value || + typeof value !== 'object' || + typeof (value as OpenCodeRuntimeLaneIndexEntry).laneId !== 'string' || + typeof (value as OpenCodeRuntimeLaneIndexEntry).updatedAt !== 'string' + ) { + return []; + } + const entry = value as OpenCodeRuntimeLaneIndexEntry; + return [ + [ + key, + { + laneId: entry.laneId, + state: + entry.state === 'active' || entry.state === 'stopped' || entry.state === 'degraded' + ? entry.state + : 'degraded', + updatedAt: entry.updatedAt, + diagnostics: Array.isArray(entry.diagnostics) + ? entry.diagnostics.filter((item): item is string => typeof item === 'string') + : undefined, + } satisfies OpenCodeRuntimeLaneIndexEntry, + ], + ]; + }) + ), + }; +} + +export async function writeOpenCodeRuntimeLaneIndex( + teamsBasePath: string, + teamName: string, + index: OpenCodeRuntimeLaneIndex +): Promise { + const runtimeDir = getOpenCodeTeamRuntimeDirectory(teamsBasePath, teamName); + await mkdir(runtimeDir, { recursive: true }); + await writeFile( + getOpenCodeRuntimeLaneIndexPath(teamsBasePath, teamName), + `${JSON.stringify(index, null, 2)}\n`, + 'utf8' + ); +} + +export async function upsertOpenCodeRuntimeLaneIndexEntry(params: { + teamsBasePath: string; + teamName: string; + laneId: string; + state: OpenCodeRuntimeLaneIndexEntry['state']; + diagnostics?: string[]; +}): Promise { + const index = await readOpenCodeRuntimeLaneIndex(params.teamsBasePath, params.teamName); + index.updatedAt = new Date().toISOString(); + index.lanes[params.laneId] = { + laneId: params.laneId, + state: params.state, + updatedAt: index.updatedAt, + diagnostics: params.diagnostics?.length ? [...params.diagnostics] : undefined, + }; + await writeOpenCodeRuntimeLaneIndex(params.teamsBasePath, params.teamName, index); +} + +export async function removeOpenCodeRuntimeLaneIndexEntry(params: { + teamsBasePath: string; + teamName: string; + laneId: string; +}): Promise { + const index = await readOpenCodeRuntimeLaneIndex(params.teamsBasePath, params.teamName); + if (!index.lanes[params.laneId]) { + return; + } + delete index.lanes[params.laneId]; + index.updatedAt = new Date().toISOString(); + await writeOpenCodeRuntimeLaneIndex(params.teamsBasePath, params.teamName, index); +} + +export async function clearOpenCodeRuntimeLaneStorage(params: { + teamsBasePath: string; + teamName: string; + laneId: string; +}): Promise { + await rm( + getOpenCodeTeamRuntimeLaneDirectory(params.teamsBasePath, params.teamName, params.laneId), + { recursive: true, force: true } + ); + await removeOpenCodeRuntimeLaneIndexEntry(params); +} + +export async function migrateLegacyOpenCodeRuntimeState(params: { + teamsBasePath: string; + teamName: string; + laneId: string; + clock?: () => Date; +}): Promise<{ migrated: boolean; degraded: boolean; diagnostics: string[] }> { + const clock = params.clock ?? (() => new Date()); + const runtimeDir = getOpenCodeTeamRuntimeDirectory(params.teamsBasePath, params.teamName); + const laneDir = getOpenCodeTeamRuntimeLaneDirectory( + params.teamsBasePath, + params.teamName, + params.laneId + ); + const diagnostics: string[] = []; + + if (!(await fileExists(runtimeDir))) { + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: params.teamsBasePath, + teamName: params.teamName, + laneId: params.laneId, + state: 'active', + }); + return { migrated: false, degraded: false, diagnostics }; + } + + const laneDirsPath = path.join(runtimeDir, OPENCODE_TEAM_RUNTIME_LANES_DIR); + const existingLaneDirs = await readdir(laneDirsPath).catch(() => [] as string[]); + if (existingLaneDirs.length > 0) { + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: params.teamsBasePath, + teamName: params.teamName, + laneId: params.laneId, + state: 'active', + }); + return { migrated: false, degraded: false, diagnostics }; + } + + const knownLegacyFiles = [ + OPENCODE_RUNTIME_MANIFEST_FILE, + 'launch-state.json', + 'opencode-sessions.json', + 'opencode-launch-transaction.json', + 'opencode-delivery-journal.json', + 'opencode-permissions.json', + 'opencode-host-leases.json', + 'opencode-compatibility.json', + 'opencode-runtime-revision.json', + 'opencode-diagnostics.json', + OPENCODE_RUNTIME_RUN_TOMBSTONES_FILE, + ]; + const legacyFiles = ( + await Promise.all( + knownLegacyFiles.map(async (fileName) => + (await fileExists(path.join(runtimeDir, fileName))) ? fileName : null + ) + ) + ).filter((fileName): fileName is string => Boolean(fileName)); + + if (legacyFiles.length === 0) { + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: params.teamsBasePath, + teamName: params.teamName, + laneId: params.laneId, + state: 'active', + }); + return { migrated: false, degraded: false, diagnostics }; + } + + const index = await readOpenCodeRuntimeLaneIndex(params.teamsBasePath, params.teamName); + const otherLaneIds = Object.keys(index.lanes).filter((laneId) => laneId !== params.laneId); + if (otherLaneIds.length > 0) { + diagnostics.push( + `Legacy OpenCode runtime state is ambiguous for ${params.teamName}; existing lanes: ${otherLaneIds.join(', ')}` + ); + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: params.teamsBasePath, + teamName: params.teamName, + laneId: params.laneId, + state: 'degraded', + diagnostics, + }); + return { migrated: false, degraded: true, diagnostics }; + } + + await mkdir(laneDir, { recursive: true }); + for (const fileName of legacyFiles) { + await rename(path.join(runtimeDir, fileName), path.join(laneDir, fileName)); + } + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: params.teamsBasePath, + teamName: params.teamName, + laneId: params.laneId, + state: 'active', + diagnostics: [`migrated legacy team-scoped OpenCode runtime state at ${clock().toISOString()}`], + }); + diagnostics.push(`migrated ${legacyFiles.length} legacy OpenCode runtime files`); + return { migrated: true, degraded: false, diagnostics }; +} + +export function getOpenCodeRuntimeRunTombstonesPath( + teamsBasePath: string, + teamName: string, + laneId?: string | null +): string { + if (laneId && laneId.trim().length > 0) { + return getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath, + teamName, + laneId: laneId.trim(), + fileName: OPENCODE_RUNTIME_RUN_TOMBSTONES_FILE, + }); + } + return path.join( + getOpenCodeTeamRuntimeDirectory(teamsBasePath, teamName), + OPENCODE_RUNTIME_RUN_TOMBSTONES_FILE + ); +} diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index a0f687f3..6686c89f 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -59,6 +59,7 @@ const REQUIRED_READY_CHECKPOINTS = new Set([ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { readonly providerId = 'opencode' as const; private readonly lastProjectPathByTeamName = new Map(); + private readonly lastReadinessByProjectPath = new Map(); constructor( private readonly bridge: OpenCodeTeamRuntimeBridgePort, @@ -87,6 +88,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { requireExecutionProbe: !runtimeOnly, launchMode: runtimeOnly ? undefined : configuredLaunchMode, }); + this.lastReadinessByProjectPath.set(input.cwd, readiness); if (!readiness.launchAllowed) { return { @@ -132,6 +134,10 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { }; } + getLastOpenCodeTeamLaunchReadiness(projectPath: string): OpenCodeTeamLaunchReadiness | null { + return this.lastReadinessByProjectPath.get(projectPath) ?? null; + } + async launch(input: TeamRuntimeLaunchInput): Promise { const configuredLaunchMode = resolveOpenCodeTeamLaunchMode(this.options); const prepared = await this.prepare(input); @@ -157,6 +163,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { const data = await this.bridge.launchOpenCodeTeam({ mode: configuredLaunchMode, runId: input.runId, + laneId: input.laneId?.trim() || 'primary', teamId: input.teamName, teamName: input.teamName, projectPath: input.cwd, @@ -183,6 +190,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { : null; const data = await this.bridge.reconcileOpenCodeTeam({ runId: input.runId, + laneId: input.laneId?.trim() || 'primary', teamId: input.teamName, teamName: input.teamName, projectPath, @@ -263,6 +271,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { : null; const data = await this.bridge.stopOpenCodeTeam({ runId: input.runId, + laneId: input.laneId?.trim() || 'primary', teamId: input.teamName, teamName: input.teamName, projectPath, diff --git a/src/main/services/team/runtime/TeamRuntimeAdapter.ts b/src/main/services/team/runtime/TeamRuntimeAdapter.ts index f4252b16..ad8a77d7 100644 --- a/src/main/services/team/runtime/TeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/TeamRuntimeAdapter.ts @@ -25,6 +25,7 @@ export interface TeamRuntimeMemberSpec { export interface TeamRuntimeLaunchInput { runId: string; teamName: string; + laneId?: string; cwd: string; prompt?: string; providerId: TeamRuntimeProviderId; @@ -95,6 +96,7 @@ export type TeamRuntimeReconcileReason = export interface TeamRuntimeReconcileInput { runId: string; teamName: string; + laneId?: string; providerId: TeamRuntimeProviderId; expectedMembers: TeamRuntimeMemberSpec[]; previousLaunchState: PersistedTeamLaunchSnapshot | null; @@ -117,6 +119,7 @@ export type TeamRuntimeStopReason = 'user_requested' | 'relaunch' | 'cleanup' | export interface TeamRuntimeStopInput { runId: string; teamName: string; + laneId?: string; cwd?: string; providerId: TeamRuntimeProviderId; reason: TeamRuntimeStopReason; diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index 6ad828ef..79f0e01f 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -2,7 +2,14 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { parentPort } from 'node:worker_threads'; +import { readBootstrapLaunchSnapshot } from '@main/services/team/TeamBootstrapStateReader'; import { normalizePersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator'; +import { + choosePreferredLaunchStateSummary, + normalizePersistedLaunchSummaryProjection, + shouldSuppressLegacyLaunchArtifactHeuristic, + TEAM_LAUNCH_SUMMARY_FILE, +} from '@main/services/team/TeamLaunchSummaryProjection'; import { isLeadMember } from '@shared/utils/leadDetection'; import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; @@ -112,6 +119,8 @@ interface RawMember { agentType?: unknown; role?: unknown; color?: unknown; + providerId?: unknown; + provider?: unknown; removedAt?: unknown; } @@ -324,50 +333,42 @@ function dropCliProvisionerMembers( async function readLaunchState( teamsDir: string, teamName: string -): Promise<{ - partialLaunchFailure?: true; - expectedMemberCount?: number; - confirmedMemberCount?: number; - missingMembers?: string[]; - teamLaunchState?: string; - launchUpdatedAt?: string; - confirmedCount?: number; - pendingCount?: number; - failedCount?: number; - runtimeAlivePendingCount?: number; -} | null> { +): Promise> { + const bootstrapSnapshot = await readBootstrapLaunchSnapshot(teamName); const launchStatePath = path.join(teamsDir, teamName, TEAM_LAUNCH_STATE_FILE); - try { - const stat = await fs.promises.stat(launchStatePath); - if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) return null; - const raw = await fs.promises.readFile(launchStatePath, 'utf8'); - const snapshot = normalizePersistedLaunchSnapshot(teamName, JSON.parse(raw)); - if (!snapshot) return null; - const missingMembers = snapshot.expectedMembers.filter((name) => { - const member = snapshot.members[name]; - return member?.launchState === 'failed_to_start'; - }); - return { - ...(snapshot.teamLaunchState === 'partial_failure' - ? { partialLaunchFailure: true as const } - : {}), - ...(snapshot.expectedMembers.length > 0 - ? { expectedMemberCount: snapshot.expectedMembers.length } - : {}), - ...(snapshot.summary.confirmedCount > 0 - ? { confirmedMemberCount: snapshot.summary.confirmedCount } - : {}), - ...(missingMembers.length > 0 ? { missingMembers } : {}), - teamLaunchState: snapshot.teamLaunchState, - launchUpdatedAt: snapshot.updatedAt, - confirmedCount: snapshot.summary.confirmedCount, - pendingCount: snapshot.summary.pendingCount, - failedCount: snapshot.summary.failedCount, - runtimeAlivePendingCount: snapshot.summary.runtimeAlivePendingCount, - }; - } catch { - return null; - } + const launchSummaryPath = path.join(teamsDir, teamName, TEAM_LAUNCH_SUMMARY_FILE); + const [launchSnapshot, launchSummaryProjection] = await Promise.all([ + (async () => { + try { + const stat = await fs.promises.stat(launchStatePath); + if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) { + return null; + } + const raw = await fs.promises.readFile(launchStatePath, 'utf8'); + return normalizePersistedLaunchSnapshot(teamName, JSON.parse(raw)); + } catch { + return null; + } + })(), + (async () => { + try { + const stat = await fs.promises.stat(launchSummaryPath); + if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) { + return null; + } + const raw = await fs.promises.readFile(launchSummaryPath, 'utf8'); + return normalizePersistedLaunchSummaryProjection(teamName, JSON.parse(raw)); + } catch { + return null; + } + })(), + ]); + + return choosePreferredLaunchStateSummary({ + bootstrapSnapshot, + launchSnapshot, + launchSummaryProjection, + }); } /** @@ -398,7 +399,12 @@ async function readDraftTeamMeta( const membersRaw = await fs.promises.readFile(membersPath, 'utf8'); const membersData = JSON.parse(membersRaw) as { members?: unknown[] }; if (Array.isArray(membersData?.members)) { - memberCount = membersData.members.length; + memberCount = membersData.members.filter((member) => { + if (!isRawMember(member)) return false; + const name = typeof member.name === 'string' ? member.name.trim() : ''; + if (!name || name === 'user' || isLeadMember(member)) return false; + return !member.removedAt; + }).length; } } catch { // best-effort @@ -538,6 +544,30 @@ async function listTeams( const removedKeys = new Set(); const expectedTeammateNames = new Set(); const confirmedArtifactNames = new Set(); + const metaRuntimeMembers: Array<{ + name: string; + providerId?: 'anthropic' | 'codex' | 'gemini' | 'opencode'; + removedAt?: unknown; + }> = []; + let leadProviderId: 'anthropic' | 'codex' | 'gemini' | 'opencode' | undefined; + + try { + const teamMetaPath = path.join(payload.teamsDir, teamName, 'team.meta.json'); + const teamMetaStat = await fs.promises.stat(teamMetaPath); + if (teamMetaStat.isFile() && teamMetaStat.size <= 256 * 1024) { + const raw = await readFileUtf8WithTimeout(teamMetaPath, payload.maxConfigReadMs); + const parsed = JSON.parse(raw) as { providerId?: unknown }; + leadProviderId = + parsed?.providerId === 'anthropic' || + parsed?.providerId === 'codex' || + parsed?.providerId === 'gemini' || + parsed?.providerId === 'opencode' + ? parsed.providerId + : undefined; + } + } catch { + leadProviderId = undefined; + } try { const metaPath = path.join(payload.teamsDir, teamName, 'members.meta.json'); @@ -548,15 +578,32 @@ async function listTeams( const members: unknown[] = Array.isArray(parsed?.members) ? parsed.members : []; for (const member of members) { if (!isRawMember(member)) continue; + const rawProviderId = member.providerId ?? member.provider; + const providerId = + rawProviderId === 'anthropic' || + rawProviderId === 'codex' || + rawProviderId === 'gemini' || + rawProviderId === 'opencode' + ? rawProviderId + : undefined; const name = typeof member.name === 'string' ? member.name.trim() : ''; if (!name) continue; if (isLeadMember(member)) continue; const key = name.toLowerCase(); if (member.removedAt) { removedKeys.add(key); + metaRuntimeMembers.push({ + name, + providerId, + removedAt: member.removedAt, + }); continue; } expectedTeammateNames.add(name); + metaRuntimeMembers.push({ + name, + providerId, + }); mergeMember(member, memberMap, removedKeys); } } @@ -599,9 +646,16 @@ async function listTeams( ...member, color: memberColors.get(member.name) ?? member.color, })); + const suppressLegacyLaunchArtifactHeuristic = shouldSuppressLegacyLaunchArtifactHeuristic({ + leadProviderId, + members: metaRuntimeMembers, + }); const launchStateSummary = (await readLaunchState(payload.teamsDir, teamName)) ?? (() => { + if (suppressLegacyLaunchArtifactHeuristic) { + return null; + } if ( !leadSessionId || expectedTeammateNames.size === 0 || diff --git a/src/preload/index.ts b/src/preload/index.ts index fb50e9cf..c7bbd64f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -305,6 +305,7 @@ import type { TeamLaunchResponse, TeamMemberActivityMeta, TeamMessageNotificationData, + TeamProvisioningModelVerificationMode, TeamProvisioningPrepareResult, TeamProvisioningProgress, TeamSummary, @@ -875,7 +876,8 @@ const electronAPI: ElectronAPI = { providerId?: TeamLaunchRequest['providerId'], providerIds?: TeamLaunchRequest['providerId'][], selectedModels?: string[], - limitContext?: boolean + limitContext?: boolean, + modelVerificationMode?: TeamProvisioningModelVerificationMode ) => { return invokeIpcWithResult( TEAM_PREPARE_PROVISIONING, @@ -883,7 +885,8 @@ const electronAPI: ElectronAPI = { providerId, providerIds, selectedModels, - limitContext + limitContext, + modelVerificationMode ); }, createTeam: async (request: TeamCreateRequest) => { diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 84b62d4c..746b7aad 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -63,6 +63,7 @@ import type { TeamLaunchRequest, TeamLaunchResponse, TeamMemberActivityMeta, + TeamProvisioningModelVerificationMode, TeamProvisioningPrepareResult, TeamProvisioningProgress, TeamsAPI, @@ -748,7 +749,8 @@ export class HttpAPIClient implements ElectronAPI { _providerId?: TeamLaunchRequest['providerId'], _providerIds?: TeamLaunchRequest['providerId'][], _selectedModels?: string[], - _limitContext?: boolean + _limitContext?: boolean, + _modelVerificationMode?: TeamProvisioningModelVerificationMode ): Promise => { throw new Error('Team provisioning is not available in browser mode'); }, diff --git a/src/renderer/components/common/GlobalProviderStatusHeader.tsx b/src/renderer/components/common/GlobalProviderStatusHeader.tsx new file mode 100644 index 00000000..a8e764a2 --- /dev/null +++ b/src/renderer/components/common/GlobalProviderStatusHeader.tsx @@ -0,0 +1,311 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { + mergeCodexCliStatusWithSnapshot, + useCodexAccountSnapshot, +} from '@features/codex-account/renderer'; +import { isElectronMode } from '@renderer/api'; +import { formatProviderStatusText } from '@renderer/components/runtime/providerConnectionUi'; +import { useStore } from '@renderer/store'; +import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; +import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze'; +import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; + +import { ProviderBrandLogo } from './ProviderBrandLogo'; + +import type { CliProviderId, CliProviderStatus } from '@shared/types'; + +interface ProviderActivityState { + provider: CliProviderStatus; + loading: boolean; + error: boolean; +} + +function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boolean): boolean { + return ( + providerLoading || + (!provider.authenticated && + provider.statusMessage === 'Checking...' && + provider.models.length === 0 && + provider.backend == null) + ); +} + +function shouldMaskCodexNegativeBootstrapState( + sourceProvider: CliProviderStatus | null, + mergedProvider: CliProviderStatus +): boolean { + return ( + sourceProvider?.providerId === 'codex' && + sourceProvider.statusMessage === 'Checking...' && + mergedProvider.providerId === 'codex' && + mergedProvider.connection?.codex?.launchReadinessState === 'missing_auth' && + mergedProvider.connection.codex.login.status === 'idle' + ); +} + +function getActivityToneStyles(tone: 'loading' | 'checked' | 'error'): { + borderColor: string; + backgroundColor: string; + textColor: string; + statusColor: string; +} { + switch (tone) { + case 'checked': + return { + borderColor: 'rgba(34, 197, 94, 0.22)', + backgroundColor: 'rgba(34, 197, 94, 0.08)', + textColor: '#dcfce7', + statusColor: '#86efac', + }; + case 'error': + return { + borderColor: 'rgba(239, 68, 68, 0.28)', + backgroundColor: 'rgba(239, 68, 68, 0.08)', + textColor: '#fee2e2', + statusColor: '#fca5a5', + }; + case 'loading': + default: + return { + borderColor: 'var(--color-border-emphasis)', + backgroundColor: 'rgba(255, 255, 255, 0.03)', + textColor: 'var(--color-text-secondary)', + statusColor: 'var(--color-text-muted)', + }; + } +} + +function areProviderIdListsEqual(nextIds: CliProviderId[], prevIds: CliProviderId[]): boolean { + return nextIds.length === prevIds.length && nextIds.every((id, index) => prevIds[index] === id); +} + +export const GlobalProviderStatusHeader = (): React.JSX.Element | null => { + const isElectron = useMemo(() => isElectronMode(), []); + const { + cliStatus, + cliStatusLoading, + cliProviderStatusLoading, + multimodelEnabled, + isDashboardFocused, + } = useStore( + useShallow((state) => { + const focusedPane = state.paneLayout.panes.find( + (pane) => pane.id === state.paneLayout.focusedPaneId + ); + const activeTab = focusedPane?.tabs.find((tab) => tab.id === focusedPane.activeTabId) ?? null; + + return { + cliStatus: state.cliStatus, + cliStatusLoading: state.cliStatusLoading, + cliProviderStatusLoading: state.cliProviderStatusLoading, + multimodelEnabled: state.appConfig?.general?.multimodelEnabled ?? true, + isDashboardFocused: + !focusedPane || focusedPane.tabs.length === 0 || activeTab?.type === 'dashboard', + }; + }) + ); + const [cycleProviderIds, setCycleProviderIds] = useState([]); + + const loadingCliStatus = useMemo( + () => + !cliStatus && cliStatusLoading && multimodelEnabled + ? createLoadingMultimodelCliStatus() + : cliStatus, + [cliStatus, cliStatusLoading, multimodelEnabled] + ); + + const codexAccount = useCodexAccountSnapshot({ + enabled: + isElectron && + multimodelEnabled && + loadingCliStatus?.flavor === 'agent_teams_orchestrator' && + Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')), + includeRateLimits: false, + }); + + const effectiveCliStatus = useMemo( + () => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot), + [codexAccount.snapshot, loadingCliStatus] + ); + const codexSnapshotPending = + codexAccount.loading && + Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')) && + !codexAccount.snapshot; + + const sourceProviderMap = useMemo( + () => + new Map( + (loadingCliStatus?.providers ?? []).map((provider) => [provider.providerId, provider]) + ), + [loadingCliStatus?.providers] + ); + + const providerStates = useMemo(() => { + const visibleProviders = filterMainScreenCliProviders(effectiveCliStatus?.providers ?? []); + + return visibleProviders.map((provider) => { + const sourceProvider = sourceProviderMap.get(provider.providerId) ?? null; + const loading = + isProviderCardLoading(provider, cliProviderStatusLoading[provider.providerId] === true) || + (provider.providerId === 'codex' && codexSnapshotPending) || + shouldMaskCodexNegativeBootstrapState(sourceProvider, provider); + + return { + provider, + loading, + error: !loading && provider.verificationState === 'error', + }; + }); + }, [ + cliProviderStatusLoading, + codexSnapshotPending, + effectiveCliStatus?.providers, + sourceProviderMap, + ]); + + const visibleProviderIds = useMemo( + () => providerStates.map((state) => state.provider.providerId), + [providerStates] + ); + const loadingProviderIds = useMemo( + () => providerStates.filter((state) => state.loading).map((state) => state.provider.providerId), + [providerStates] + ); + const errorProviderIds = useMemo( + () => providerStates.filter((state) => state.error).map((state) => state.provider.providerId), + [providerStates] + ); + const providerStateMap = useMemo( + () => new Map(providerStates.map((state) => [state.provider.providerId, state])), + [providerStates] + ); + + useEffect(() => { + setCycleProviderIds((previousIds) => { + const visiblePreviousIds = previousIds.filter((providerId) => + visibleProviderIds.includes(providerId) + ); + + if (loadingProviderIds.length > 0) { + const nextIds = [...visiblePreviousIds]; + for (const providerId of loadingProviderIds) { + if (!nextIds.includes(providerId)) { + nextIds.push(providerId); + } + } + + return areProviderIdListsEqual(nextIds, previousIds) ? previousIds : nextIds; + } + + if (errorProviderIds.length > 0) { + return areProviderIdListsEqual(errorProviderIds, previousIds) + ? previousIds + : errorProviderIds; + } + + return previousIds.length === 0 ? previousIds : []; + }); + }, [errorProviderIds, loadingProviderIds, visibleProviderIds]); + + const displayProviderIds = useMemo(() => { + if (loadingProviderIds.length > 0) { + const activeCycleIds = ( + cycleProviderIds.length > 0 ? cycleProviderIds : loadingProviderIds + ).filter((providerId) => providerStateMap.has(providerId)); + return Array.from(new Set([...activeCycleIds, ...errorProviderIds])); + } + + if (errorProviderIds.length > 0) { + return errorProviderIds; + } + + return []; + }, [cycleProviderIds, errorProviderIds, loadingProviderIds, providerStateMap]); + + if ( + !isElectron || + isDashboardFocused || + !multimodelEnabled || + !effectiveCliStatus || + effectiveCliStatus.flavor !== 'agent_teams_orchestrator' || + !effectiveCliStatus.installed || + displayProviderIds.length === 0 + ) { + return null; + } + + return ( +
+ + Provider Activity + +
+ {displayProviderIds.map((providerId) => { + const providerState = providerStateMap.get(providerId); + if (!providerState) { + return null; + } + + const tone = providerState.loading + ? 'loading' + : providerState.error + ? 'error' + : 'checked'; + const styles = getActivityToneStyles(tone); + const statusText = + tone === 'loading' + ? 'Checking...' + : tone === 'error' + ? formatProviderStatusText(providerState.provider) + : 'Checked'; + + return ( +
+ {tone === 'loading' ? ( + + ) : tone === 'error' ? ( + + ) : ( + + )} + + + {providerState.provider.displayName} + + + {statusText} + +
+ ); + })} +
+
+ ); +}; diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index da249f22..406ea0a1 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -36,6 +36,10 @@ import { SettingsToggle } from '@renderer/components/settings/components'; import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel'; import { TerminalModal } from '@renderer/components/terminal/TerminalModal'; import { useCliInstaller } from '@renderer/hooks/useCliInstaller'; +import { + loadDashboardCliStatusBannerCollapsed, + saveDashboardCliStatusBannerCollapsed, +} from '@renderer/services/dashboardCliStatusBannerPreference'; import { useStore } from '@renderer/store'; import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; import { formatBytes } from '@renderer/utils/formatters'; @@ -47,6 +51,7 @@ import { AlertTriangle, CheckCircle, ChevronDown, + ChevronRight, ChevronUp, Download, HelpCircle, @@ -258,16 +263,20 @@ interface InstalledBannerProps { cliProviderStatusLoading: Partial>; codexSnapshotPending: boolean; cliStatusError: string | null; + providersCollapsed: boolean; isBusy: boolean; multimodelEnabled: boolean; multimodelBusy: boolean; onInstall: () => void; onRefresh: () => void; onMultimodelToggle: (enabled: boolean) => void; + onToggleProvidersCollapsed: () => void; onProviderLogin: (providerId: CliProviderId) => void; onProviderLogout: (providerId: CliProviderId) => void; onProviderManage: (providerId: CliProviderId) => void; onProviderRefresh: (providerId: CliProviderId) => void; + onCodexReconnect: () => void; + codexReconnectBusy: boolean; variant: BannerVariant; } @@ -547,16 +556,20 @@ const InstalledBanner = ({ cliProviderStatusLoading, codexSnapshotPending, cliStatusError, + providersCollapsed, isBusy, multimodelEnabled, multimodelBusy, onInstall, onRefresh, onMultimodelToggle, + onToggleProvidersCollapsed, onProviderLogin, onProviderLogout, onProviderManage, onProviderRefresh, + onCodexReconnect, + codexReconnectBusy, variant, }: InstalledBannerProps): React.JSX.Element => { const openExtensionsTab = useStore((s) => s.openExtensionsTab); @@ -568,14 +581,37 @@ const InstalledBanner = ({ const canOpenExtensions = cliStatus.installed; const runtimeLabel = formatRuntimeLabel(cliStatus); const runtimeAuthSummary = formatRuntimeAuthSummary(cliStatus, visibleProviders); + const showCollapseControl = visibleProviders.length > 0; + const showExpandedContent = !providersCollapsed; return (
+ {showCollapseControl && ( + + )}
@@ -663,12 +699,12 @@ const InstalledBanner = ({ )}
- {cliStatusError && !cliStatusLoading && ( + {showExpandedContent && cliStatusError && !cliStatusLoading && (

Failed to check for updates. Check your network connection and try again.

)} - {visibleProviders.length > 0 && ( + {showExpandedContent && visibleProviders.length > 0 && (
- {codexDashboardHint} +
+ {codexDashboardHint} + {codexNeedsReconnect ? ( + + ) : null} +
) : null}
@@ -960,6 +1019,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => { const [isVerifyingAuth, setIsVerifyingAuth] = useState(false); const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false); const [showTroubleshoot, setShowTroubleshoot] = useState(false); + const [providersCollapsed, setProvidersCollapsed] = useState(() => + loadDashboardCliStatusBannerCollapsed() + ); const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true; const loadingCliStatus = useMemo( () => @@ -1048,6 +1110,27 @@ export const CliStatusBanner = (): React.JSX.Element | null => { }); }, [bootstrapCliStatus, fetchCliStatus, multimodelEnabled]); + const handleToggleProvidersCollapsed = useCallback(() => { + setProvidersCollapsed((current) => { + const next = !current; + saveDashboardCliStatusBannerCollapsed(next); + return next; + }); + }, []); + + const handleCodexDashboardLogin = useCallback(() => { + void (async () => { + const success = await codexAccount.startChatgptLogin(); + if (success) { + await refreshCliStatusForCurrentMode({ + multimodelEnabled, + bootstrapCliStatus, + fetchCliStatus, + }); + } + })(); + }, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]); + const handleMultimodelToggle = useCallback( async (enabled: boolean) => { setIsSwitchingFlavor(true); @@ -1321,16 +1404,20 @@ export const CliStatusBanner = (): React.JSX.Element | null => { cliProviderStatusLoading={cliProviderStatusLoading} codexSnapshotPending={codexSnapshotPending} cliStatusError={cliStatusError ?? null} + providersCollapsed={providersCollapsed} isBusy={isBusy} multimodelEnabled={multimodelEnabled} multimodelBusy={isSwitchingFlavor} onInstall={handleInstall} onRefresh={handleRefresh} onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)} + onToggleProvidersCollapsed={handleToggleProvidersCollapsed} onProviderLogin={handleProviderLogin} onProviderLogout={handleProviderLogout} onProviderManage={handleProviderManage} onProviderRefresh={handleProviderRefresh} + onCodexReconnect={handleCodexDashboardLogin} + codexReconnectBusy={codexAccount.loading} variant="info" /> ); @@ -1545,16 +1632,20 @@ export const CliStatusBanner = (): React.JSX.Element | null => { cliProviderStatusLoading={cliProviderStatusLoading} codexSnapshotPending={codexSnapshotPending} cliStatusError={cliStatusError ?? null} + providersCollapsed={providersCollapsed} isBusy={isBusy} multimodelEnabled={multimodelEnabled} multimodelBusy={isSwitchingFlavor} onInstall={handleInstall} onRefresh={handleRefresh} onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)} + onToggleProvidersCollapsed={handleToggleProvidersCollapsed} onProviderLogin={handleProviderLogin} onProviderLogout={handleProviderLogout} onProviderManage={handleProviderManage} onProviderRefresh={handleProviderRefresh} + onCodexReconnect={handleCodexDashboardLogin} + codexReconnectBusy={codexAccount.loading} variant={variant} /> {installedAuxiliaryUi} @@ -1603,16 +1694,20 @@ export const CliStatusBanner = (): React.JSX.Element | null => { cliProviderStatusLoading={cliProviderStatusLoading} codexSnapshotPending={codexSnapshotPending} cliStatusError={cliStatusError ?? null} + providersCollapsed={providersCollapsed} isBusy={isBusy} multimodelEnabled={multimodelEnabled} multimodelBusy={isSwitchingFlavor} onInstall={handleInstall} onRefresh={handleRefresh} onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)} + onToggleProvidersCollapsed={handleToggleProvidersCollapsed} onProviderLogin={handleProviderLogin} onProviderLogout={handleProviderLogout} onProviderManage={handleProviderManage} onProviderRefresh={handleProviderRefresh} + onCodexReconnect={handleCodexDashboardLogin} + codexReconnectBusy={codexAccount.loading} variant={variant} />
{ cliProviderStatusLoading={cliProviderStatusLoading} codexSnapshotPending={codexSnapshotPending} cliStatusError={cliStatusError ?? null} + providersCollapsed={providersCollapsed} isBusy={isBusy} multimodelEnabled={multimodelEnabled} multimodelBusy={isSwitchingFlavor} onInstall={handleInstall} onRefresh={handleRefresh} onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)} + onToggleProvidersCollapsed={handleToggleProvidersCollapsed} onProviderLogin={handleProviderLogin} onProviderLogout={handleProviderLogout} onProviderManage={handleProviderManage} onProviderRefresh={handleProviderRefresh} + onCodexReconnect={handleCodexDashboardLogin} + codexReconnectBusy={codexAccount.loading} variant={variant} /> {installedAuxiliaryUi} diff --git a/src/renderer/components/layout/TabbedLayout.tsx b/src/renderer/components/layout/TabbedLayout.tsx index baa0c6dc..cbaba50a 100644 --- a/src/renderer/components/layout/TabbedLayout.tsx +++ b/src/renderer/components/layout/TabbedLayout.tsx @@ -29,6 +29,7 @@ import { useStore } from '@renderer/store'; import { useShallow } from 'zustand/react/shallow'; import { CliInstallWarningBanner } from '../common/CliInstallWarningBanner'; +import { GlobalProviderStatusHeader } from '../common/GlobalProviderStatusHeader'; import { UpdateBanner } from '../common/UpdateBanner'; import { UpdateDialog } from '../common/UpdateDialog'; import { WorkspaceIndicator } from '../common/WorkspaceIndicator'; @@ -163,6 +164,7 @@ export const TabbedLayout = (): React.JSX.Element => { > +
{/* Command Palette (Cmd+K) */} diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index ae4b612c..e41a2f16 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -276,7 +276,10 @@ function getCodexAccountPanelHint( return null; } - if (codex.managedAccount?.type === 'chatgpt') { + const hasActiveChatgptSession = + codex.effectiveAuthMode === 'chatgpt' && codex.launchAllowed === true; + + if (hasActiveChatgptSession) { if (!codex.rateLimits) { return 'Usage limits appear here after Codex reports them for the connected ChatGPT account.'; } @@ -689,6 +692,10 @@ export const ProviderRuntimeSettingsDialog = ({ : null; const codexConnection = selectedProvider?.providerId === 'codex' ? (selectedProvider.connection?.codex ?? null) : null; + const codexHasActiveChatgptSession = + codexConnection?.effectiveAuthMode === 'chatgpt' && codexConnection.launchAllowed === true; + const codexNeedsReconnect = + Boolean(codexConnection?.localActiveChatgptAccountPresent) && !codexHasActiveChatgptSession; const codexLoginPending = codexConnection?.login.status === 'starting' || codexConnection?.login.status === 'pending'; const configurableAuthModes = selectedProvider?.connection?.configurableAuthModes ?? []; @@ -1358,7 +1365,7 @@ export const ProviderRuntimeSettingsDialog = ({ > Cancel login - ) : codexConnection?.managedAccount?.type === 'chatgpt' ? ( + ) : codexHasActiveChatgptSession ? ( )}
@@ -1385,21 +1392,25 @@ export const ProviderRuntimeSettingsDialog = ({ - {codexConnection?.managedAccount?.type === 'chatgpt' + {codexHasActiveChatgptSession ? 'Connected' - : codexLoginPending - ? 'Login in progress' - : 'Not connected'} + : codexNeedsReconnect + ? 'Reconnect required' + : codexLoginPending + ? 'Login in progress' + : 'Not connected'} {codexConnection ? ( - isTeamProviderId(member.providerId) ? [member.providerId] : [] + !member.removedAt && isTeamProviderId(member.providerId) ? [member.providerId] : [] ), ]) ); @@ -590,6 +600,7 @@ export const CreateTeamDialog = ({ const prepareModelResultsCacheRef = useRef( new Map>() ); + const lastPrepareRequestSignatureRef = useRef(null); useEffect(() => { runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider; @@ -612,10 +623,44 @@ export const CreateTeamDialog = ({ useEffect(() => { if (!open) { - prepareModelResultsCacheRef.current.clear(); + lastPrepareRequestSignatureRef.current = null; } }, [open]); + const prepareRuntimeStatusSignature = useMemo( + () => + buildProviderPrepareRuntimeStatusSignature( + selectedMemberProviders, + runtimeProviderStatusById + ), + [runtimeProviderStatusById, selectedMemberProviders] + ); + const prepareMembersSignature = useMemo( + () => buildProviderPrepareMembersSignature(effectiveMemberDrafts), + [effectiveMemberDrafts] + ); + const prepareRequestSignature = useMemo( + () => + buildProviderPrepareRequestSignature({ + cwd: effectiveCwd, + selectedProviderId, + selectedModel, + selectedMemberProviders, + limitContext, + runtimeStatusSignature: prepareRuntimeStatusSignature, + membersSignature: prepareMembersSignature, + }), + [ + effectiveCwd, + limitContext, + prepareMembersSignature, + prepareRuntimeStatusSignature, + selectedMemberProviders, + selectedModel, + selectedProviderId, + ] + ); + useEffect(() => { if (multimodelEnabled) { return; @@ -644,10 +689,14 @@ export const CreateTeamDialog = ({ useEffect(() => { if (!open || !canCreate || !launchTeam) { + prepareRequestSeqRef.current += 1; + lastPrepareRequestSignatureRef.current = null; return; } if (typeof api.teams.prepareProvisioning !== 'function') { + prepareRequestSeqRef.current += 1; + lastPrepareRequestSignatureRef.current = null; setPrepareState('failed'); setPrepareWarnings([]); setPrepareChecks([]); @@ -658,6 +707,8 @@ export const CreateTeamDialog = ({ } if (!effectiveCwd) { + prepareRequestSeqRef.current += 1; + lastPrepareRequestSignatureRef.current = null; setPrepareState('idle'); setPrepareWarnings([]); setPrepareChecks([]); @@ -665,7 +716,11 @@ export const CreateTeamDialog = ({ return; } - let cancelled = false; + if (lastPrepareRequestSignatureRef.current === prepareRequestSignature) { + return; + } + lastPrepareRequestSignatureRef.current = prepareRequestSignature; + const requestSeq = ++prepareRequestSeqRef.current; const initialChecks = alignProvisioningChecks( prepareChecksRef.current, @@ -676,170 +731,176 @@ export const CreateTeamDialog = ({ setPrepareWarnings([]); setPrepareChecks(initialChecks); - // Defer so file list fetch (triggered by project select) can run first - const timer = setTimeout(() => { - void (async () => { - let checks = initialChecks; - const providerPlans = selectedMemberProviders.map((providerId) => { - const selectedModelChecks = (() => { - const next = new Set(); - let hasDefaultSelection = false; - const supportsProviderDefaultCheck = - providerId === 'codex' || - providerId === 'gemini' || - (providerId === 'anthropic' && selectedProviderId === 'anthropic'); - const leadModel = computeEffectiveTeamModel( - selectedModel, - limitContext, - selectedProviderId - ); - if (selectedProviderId === providerId && selectedModel.trim()) { - if (leadModel?.trim()) { - next.add(leadModel.trim()); - } - } else if (selectedProviderId === providerId && supportsProviderDefaultCheck) { + void (async () => { + await Promise.resolve(); + let checks = initialChecks; + const providerPlans = selectedMemberProviders.map((providerId) => { + const selectedModelChecks = (() => { + const next = new Set(); + let hasDefaultSelection = false; + const supportsProviderDefaultCheck = + providerId === 'codex' || + providerId === 'gemini' || + (providerId === 'anthropic' && selectedProviderId === 'anthropic'); + const leadModel = computeEffectiveTeamModel( + selectedModel, + limitContext, + selectedProviderId + ); + if (selectedProviderId === providerId && selectedModel.trim()) { + if (leadModel?.trim()) { + next.add(leadModel.trim()); + } + } else if (selectedProviderId === providerId && supportsProviderDefaultCheck) { + hasDefaultSelection = true; + } + for (const member of effectiveMemberDrafts) { + if (member.removedAt) { + continue; + } + const scopedModel = resolveProviderScopedMemberModel({ + memberProviderId: member.providerId, + memberModel: member.model, + selectedProviderId, + runtimeProviderStatusById, + }); + if (scopedModel.providerId !== providerId) { + continue; + } + if (scopedModel.model) { + next.add(scopedModel.model); + } else if (supportsProviderDefaultCheck) { hasDefaultSelection = true; } - for (const member of effectiveMemberDrafts) { - if (member.removedAt) { - continue; - } - const scopedModel = resolveProviderScopedMemberModel({ - memberProviderId: member.providerId, - memberModel: member.model, - selectedProviderId, - runtimeProviderStatusById, - }); - if (scopedModel.providerId !== providerId) { - continue; - } - if (scopedModel.model) { - next.add(scopedModel.model); - } else if (supportsProviderDefaultCheck) { - hasDefaultSelection = true; - } - } - if (supportsProviderDefaultCheck && hasDefaultSelection) { - next.add(DEFAULT_PROVIDER_MODEL_SELECTION); - } - return Array.from(next); - })(); - const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null; - const cacheKey = buildProviderPrepareModelCacheKey({ - cwd: effectiveCwd, - providerId, - backendSummary, - limitContext, - }); - const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {}; - const cachedSnapshot = getProviderPrepareCachedSnapshot({ - providerId, - selectedModelIds: selectedModelChecks, - cachedModelResultsById, - }); - return { - providerId, - selectedModelChecks, - backendSummary, - cacheKey, - cachedModelResultsById, - cachedSnapshot, - }; + } + if (supportsProviderDefaultCheck && hasDefaultSelection) { + next.add(DEFAULT_PROVIDER_MODEL_SELECTION); + } + return Array.from(next); + })(); + const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null; + const cacheKey = buildProviderPrepareModelCacheKey({ + cwd: effectiveCwd, + providerId, + backendSummary, + limitContext, + runtimeStatusSignature: prepareRuntimeStatusSignature, }); + const cachedModelResultsById = { + ...getShortLivedProviderPrepareModelResults({ + providerId, + cacheKey, + }), + ...(prepareModelResultsCacheRef.current.get(cacheKey) ?? {}), + }; + const cachedSnapshot = getProviderPrepareCachedSnapshot({ + providerId, + selectedModelIds: selectedModelChecks, + cachedModelResultsById, + }); + return { + providerId, + selectedModelChecks, + backendSummary, + cacheKey, + cachedModelResultsById, + cachedSnapshot, + }; + }); - try { - for (const plan of providerPlans) { - checks = updateProviderCheck(checks, plan.providerId, { - status: plan.selectedModelChecks.length > 0 ? plan.cachedSnapshot.status : 'checking', - backendSummary: plan.backendSummary, - details: plan.cachedSnapshot.details, - }); - } - if (!cancelled && prepareRequestSeqRef.current === requestSeq) { - setPrepareChecks(checks); - } - const providerResults = await Promise.all( - providerPlans.map(async (plan) => { - const prepResult = await runProviderPrepareDiagnostics({ - cwd: effectiveCwd, - providerId: plan.providerId, - selectedModelIds: plan.selectedModelChecks, - prepareProvisioning: api.teams.prepareProvisioning, - limitContext, - cachedModelResultsById: plan.cachedModelResultsById, - onModelProgress: ({ details }) => { - checks = updateProviderCheck(checks, plan.providerId, { - status: 'checking', - backendSummary: plan.backendSummary, - details, - }); - if (!cancelled && prepareRequestSeqRef.current === requestSeq) { - setPrepareChecks(checks); - } - }, - }); - return { ...plan, prepResult }; - }) - ); - let anyFailure = false; - let anyNotes = false; - const collectedWarnings: string[] = []; - for (const plan of providerResults) { - if (plan.prepResult.warnings.length > 0) { - anyNotes = true; - collectedWarnings.push( - ...plan.prepResult.warnings.map( - (warning) => `${getProviderLabel(plan.providerId)}: ${warning}` - ) - ); - } - if (plan.prepResult.status === 'failed') { - anyFailure = true; - } else if (plan.prepResult.status === 'notes') { - anyNotes = true; - } - prepareModelResultsCacheRef.current.set( - plan.cacheKey, - buildReusableProviderPrepareModelResults(plan.prepResult.modelResultsById) - ); - checks = updateProviderCheck(checks, plan.providerId, { - status: plan.prepResult.status, - backendSummary: plan.backendSummary, - details: plan.prepResult.details, - }); - } - if (!cancelled && prepareRequestSeqRef.current === requestSeq) { - setPrepareChecks(checks); - } - if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; - const failureMessage = - getPrimaryProvisioningFailureDetail(checks) ?? - 'Some selected providers need attention.'; - setPrepareState(anyFailure ? 'failed' : 'ready'); - setPrepareMessage( - anyFailure - ? failureMessage - : anyNotes - ? 'Selected providers are ready with notes.' - : 'Selected providers are ready.' - ); - setPrepareWarnings(collectedWarnings); - } catch (error) { - if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; - const failureMessage = - error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment'; - setPrepareState('failed'); - setPrepareWarnings([]); - setPrepareChecks(failIncompleteProviderChecks(checks, failureMessage)); - setPrepareMessage(failureMessage); + try { + for (const plan of providerPlans) { + checks = updateProviderCheck(checks, plan.providerId, { + status: plan.selectedModelChecks.length > 0 ? plan.cachedSnapshot.status : 'checking', + backendSummary: plan.backendSummary, + details: plan.cachedSnapshot.details, + }); } - })(); - }, 250); - - return () => { - cancelled = true; - clearTimeout(timer); - }; + if (prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); + } + const providerResults = await Promise.all( + providerPlans.map(async (plan) => { + const prepResult = await runProviderPrepareDiagnostics({ + cwd: effectiveCwd, + providerId: plan.providerId, + selectedModelIds: plan.selectedModelChecks, + prepareProvisioning: api.teams.prepareProvisioning, + limitContext, + cachedModelResultsById: plan.cachedModelResultsById, + onModelProgress: ({ status, details }) => { + checks = updateProviderCheck(checks, plan.providerId, { + status, + backendSummary: plan.backendSummary, + details, + }); + if (prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); + } + }, + }); + return { ...plan, prepResult }; + }) + ); + let anyFailure = false; + let anyNotes = false; + const collectedWarnings: string[] = []; + for (const plan of providerResults) { + if (plan.prepResult.warnings.length > 0) { + anyNotes = true; + collectedWarnings.push( + ...plan.prepResult.warnings.map( + (warning) => `${getProviderLabel(plan.providerId)}: ${warning}` + ) + ); + } + if (plan.prepResult.status === 'failed') { + anyFailure = true; + } else if (plan.prepResult.status === 'notes') { + anyNotes = true; + } + if (prepareRequestSeqRef.current === requestSeq) { + const reusableModelResults = buildReusableProviderPrepareModelResults( + plan.prepResult.modelResultsById + ); + prepareModelResultsCacheRef.current.set(plan.cacheKey, reusableModelResults); + storeShortLivedProviderPrepareModelResults({ + providerId: plan.providerId, + cacheKey: plan.cacheKey, + modelResultsById: plan.prepResult.modelResultsById, + }); + } + checks = updateProviderCheck(checks, plan.providerId, { + status: plan.prepResult.status, + backendSummary: plan.backendSummary, + details: plan.prepResult.details, + }); + } + if (prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); + } + if (prepareRequestSeqRef.current !== requestSeq) return; + const failureMessage = + getPrimaryProvisioningFailureDetail(checks) ?? 'Some selected providers need attention.'; + setPrepareState(anyFailure ? 'failed' : 'ready'); + setPrepareMessage( + anyFailure + ? failureMessage + : anyNotes + ? 'Selected providers are ready with notes.' + : 'Selected providers are ready.' + ); + setPrepareWarnings(collectedWarnings); + } catch (error) { + if (prepareRequestSeqRef.current !== requestSeq) return; + const failureMessage = + error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment'; + setPrepareState('failed'); + setPrepareWarnings([]); + setPrepareChecks(failIncompleteProviderChecks(checks, failureMessage)); + setPrepareMessage(failureMessage); + } + })(); }, [ open, canCreate, @@ -847,6 +908,7 @@ export const CreateTeamDialog = ({ effectiveCwd, effectiveMemberDrafts, limitContext, + prepareRequestSignature, runtimeProviderStatusById, selectedModel, selectedProviderId, @@ -1383,6 +1445,16 @@ export const CreateTeamDialog = ({ const activeError = localError ?? modelValidationError ?? provisioningErrorsByTeam[request.teamName] ?? null; + const effectivePrepare = useMemo( + () => + deriveEffectiveProvisioningPrepareState({ + state: prepareState, + message: prepareMessage, + warnings: prepareWarnings, + checks: prepareChecks, + }), + [prepareChecks, prepareMessage, prepareState, prepareWarnings] + ); const canOpenExistingTeam = activeError?.includes('Team already exists') === true && request.teamName.length > 0; @@ -1833,14 +1905,16 @@ export const CreateTeamDialog = ({
- {canCreate && launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? ( + {canCreate && + launchTeam && + (effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading') ? ( <>
- {prepareMessage ?? - (prepareState === 'idle' + {effectivePrepare.message ?? + (effectivePrepare.state === 'idle' ? 'Warming up CLI environment...' : 'Preparing environment...')} @@ -1853,7 +1927,7 @@ export const CreateTeamDialog = ({ ) : null} - {canCreate && launchTeam && prepareState === 'ready' ? ( + {canCreate && launchTeam && effectivePrepare.state === 'ready' ? (
@@ -1864,9 +1938,9 @@ export const CreateTeamDialog = ({ : 'CLI environment ready'}
- {prepareMessage ? ( + {effectivePrepare.message ? (

- {prepareMessage} + {effectivePrepare.message}

) : null} @@ -1882,7 +1956,7 @@ export const CreateTeamDialog = ({
) : null} - {canCreate && launchTeam && prepareState === 'failed' ? ( + {canCreate && launchTeam && effectivePrepare.state === 'failed' ? (
@@ -1891,7 +1965,7 @@ export const CreateTeamDialog = ({ CLI environment is not available - launch is blocked

- {prepareMessage ?? 'Failed to prepare environment'} + {effectivePrepare.message ?? 'Failed to prepare environment'}

Pre-flight check to catch errors before launch @@ -1919,7 +1993,7 @@ export const CreateTeamDialog = ({

) : null}

- {getProvisioningFailureHint(prepareMessage, prepareChecks)} + {getProvisioningFailureHint(effectivePrepare.message, prepareChecks)}

) : null} @@ -1951,7 +2025,8 @@ export const CreateTeamDialog = ({ Creating... - ) : launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? ( + ) : launchTeam && + (effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading') ? ( 'Skip preflight and create' ) : ( 'Create' diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index b9bb6bd9..7230d233 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -104,8 +104,18 @@ import { type ProviderPrepareDiagnosticsModelResult, runProviderPrepareDiagnostics, } from './providerPrepareDiagnostics'; +import { + getShortLivedProviderPrepareModelResults, + storeShortLivedProviderPrepareModelResults, +} from './providerPrepareShortLivedCache'; +import { + buildProviderPrepareModelChecksSignature, + buildProviderPrepareRequestSignature, + buildProviderPrepareRuntimeStatusSignature, +} from './providerPrepareRequestSignature'; import { getProvisioningModelIssue } from './provisioningModelIssues'; import { + deriveEffectiveProvisioningPrepareState, failIncompleteProviderChecks, getPrimaryProvisioningFailureDetail, getProvisioningFailureHint, @@ -447,7 +457,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen new Set([ selectedProviderId, ...effectiveMemberDrafts.flatMap((member) => - isTeamProviderId(member.providerId) ? [member.providerId] : [] + !member.removedAt && isTeamProviderId(member.providerId) ? [member.providerId] : [] ), ]) ), @@ -471,6 +481,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const prepareModelResultsCacheRef = useRef( new Map>() ); + const lastPrepareRequestSignatureRef = useRef(null); useEffect(() => { runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider; @@ -480,7 +491,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen }, [prepareChecks]); useEffect(() => { if (!open) { - prepareModelResultsCacheRef.current.clear(); + lastPrepareRequestSignatureRef.current = null; } }, [open]); const runtimeProviderStatusById = useMemo( @@ -1211,6 +1222,39 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen // --------------------------------------------------------------------------- const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim(); + const prepareRuntimeStatusSignature = useMemo( + () => + buildProviderPrepareRuntimeStatusSignature( + selectedMemberProviders, + runtimeProviderStatusById + ), + [runtimeProviderStatusById, selectedMemberProviders] + ); + const selectedModelChecksByProviderSignature = useMemo( + () => buildProviderPrepareModelChecksSignature(selectedModelChecksByProvider), + [selectedModelChecksByProvider] + ); + const prepareRequestSignature = useMemo( + () => + buildProviderPrepareRequestSignature({ + cwd: effectiveCwd, + selectedProviderId, + selectedModel, + selectedMemberProviders, + limitContext, + runtimeStatusSignature: prepareRuntimeStatusSignature, + modelChecksSignature: selectedModelChecksByProviderSignature, + }), + [ + effectiveCwd, + limitContext, + prepareRuntimeStatusSignature, + selectedMemberProviders, + selectedModel, + selectedModelChecksByProviderSignature, + selectedProviderId, + ] + ); // Clear stale provisioning error when dialog opens useEffect(() => { @@ -1221,9 +1265,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen // Warm up CLI for the currently selected working directory (launch mode only). useEffect(() => { - if (!open || !isLaunchMode) return; + if (!open || !isLaunchMode) { + prepareRequestSeqRef.current += 1; + lastPrepareRequestSignatureRef.current = null; + return; + } if (typeof api.teams.prepareProvisioning !== 'function') { + prepareRequestSeqRef.current += 1; + lastPrepareRequestSignatureRef.current = null; setPrepareState('failed'); setPrepareWarnings([]); setPrepareChecks([]); @@ -1234,6 +1284,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen } if (!effectiveCwd) { + prepareRequestSeqRef.current += 1; + lastPrepareRequestSignatureRef.current = null; setPrepareState('idle'); setPrepareWarnings([]); setPrepareChecks([]); @@ -1241,7 +1293,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen return; } - let cancelled = false; + if (lastPrepareRequestSignatureRef.current === prepareRequestSignature) { + return; + } + lastPrepareRequestSignatureRef.current = prepareRequestSignature; + const requestSeq = ++prepareRequestSeqRef.current; const initialChecks = alignProvisioningChecks( prepareChecksRef.current, @@ -1262,8 +1318,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen providerId, backendSummary, limitContext, + runtimeStatusSignature: prepareRuntimeStatusSignature, }); - const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {}; + const cachedModelResultsById = { + ...getShortLivedProviderPrepareModelResults({ + providerId, + cacheKey, + }), + ...(prepareModelResultsCacheRef.current.get(cacheKey) ?? {}), + }; const cachedSnapshot = getProviderPrepareCachedSnapshot({ providerId, selectedModelIds: selectedModelChecks, @@ -1287,7 +1350,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen details: plan.cachedSnapshot.details, }); } - if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + if (prepareRequestSeqRef.current === requestSeq) { setPrepareChecks(checks); } const providerResults = await Promise.all( @@ -1299,13 +1362,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen prepareProvisioning: api.teams.prepareProvisioning, limitContext, cachedModelResultsById: plan.cachedModelResultsById, - onModelProgress: ({ details }) => { + onModelProgress: ({ status, details }) => { checks = updateProviderCheck(checks, plan.providerId, { - status: 'checking', + status, backendSummary: plan.backendSummary, details, }); - if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + if (prepareRequestSeqRef.current === requestSeq) { setPrepareChecks(checks); } }, @@ -1330,20 +1393,27 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen } else if (plan.prepResult.status === 'notes') { anyNotes = true; } - prepareModelResultsCacheRef.current.set( - plan.cacheKey, - buildReusableProviderPrepareModelResults(plan.prepResult.modelResultsById) - ); + if (prepareRequestSeqRef.current === requestSeq) { + const reusableModelResults = buildReusableProviderPrepareModelResults( + plan.prepResult.modelResultsById + ); + prepareModelResultsCacheRef.current.set(plan.cacheKey, reusableModelResults); + storeShortLivedProviderPrepareModelResults({ + providerId: plan.providerId, + cacheKey: plan.cacheKey, + modelResultsById: plan.prepResult.modelResultsById, + }); + } checks = updateProviderCheck(checks, plan.providerId, { status: plan.prepResult.status, backendSummary: plan.backendSummary, details: plan.prepResult.details, }); } - if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + if (prepareRequestSeqRef.current === requestSeq) { setPrepareChecks(checks); } - if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; + if (prepareRequestSeqRef.current !== requestSeq) return; const failureMessage = getPrimaryProvisioningFailureDetail(checks) ?? 'Some selected providers need attention.'; setPrepareState(anyFailure ? 'failed' : 'ready'); @@ -1356,7 +1426,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ); setPrepareWarnings(collectedWarnings); } catch (error) { - if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; + if (prepareRequestSeqRef.current !== requestSeq) return; const failureMessage = error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment'; setPrepareState('failed'); @@ -1365,14 +1435,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen setPrepareMessage(failureMessage); } })(); - - return () => { - cancelled = true; - }; }, [ open, isLaunchMode, effectiveCwd, + prepareRequestSignature, selectedProviderId, selectedMemberProviders, selectedModelChecksByProvider, @@ -1687,6 +1754,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const provisioningError = isLaunchMode ? props.provisioningError : null; const activeError = localError ?? modelValidationError ?? provisioningError; + const effectivePrepare = useMemo( + () => + deriveEffectiveProvisioningPrepareState({ + state: prepareState, + message: prepareMessage, + warnings: prepareWarnings, + checks: prepareChecks, + }), + [prepareChecks, prepareMessage, prepareState, prepareWarnings] + ); const launchInFlight = useStore((s) => isLaunchMode && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false ); @@ -2480,14 +2557,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen {/* Launch-only: CLI warm-up status */} {isLaunchMode ? (
- {prepareState === 'idle' || prepareState === 'loading' ? ( + {effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading' ? ( <>
- {prepareMessage ?? - (prepareState === 'idle' + {effectivePrepare.message ?? + (effectivePrepare.state === 'idle' ? 'Warming up CLI environment...' : 'Preparing environment...')} @@ -2503,7 +2580,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ) : null} - {prepareState === 'ready' ? ( + {effectivePrepare.state === 'ready' ? (
@@ -2514,9 +2591,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen : 'CLI environment ready'}
- {prepareMessage ? ( + {effectivePrepare.message ? (

- {prepareMessage} + {effectivePrepare.message}

) : null} @@ -2532,7 +2609,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
) : null} - {prepareState === 'failed' ? ( + {effectivePrepare.state === 'failed' ? (
@@ -2542,18 +2619,21 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen blocked

- {prepareMessage ?? 'Failed to prepare environment'} + {effectivePrepare.message ?? 'Failed to prepare environment'}

Pre-flight check to catch errors before {isRelaunch ? 'relaunch' : 'launch'}

- {!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? ( + {!shouldHideProvisioningProviderStatusList( + prepareChecks, + effectivePrepare.message + ) ? ( ) : null} {prepareWarnings.length > 0 && prepareChecks.length === 0 ? ( @@ -2571,9 +2651,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ) : null}

- {getProvisioningFailureHint(prepareMessage, prepareChecks)} + {getProvisioningFailureHint(effectivePrepare.message, prepareChecks)}

- {(prepareMessage ?? '').toLowerCase().includes('spawn ') || + {(effectivePrepare.message ?? '').toLowerCase().includes('spawn ') || prepareChecks.some((check) => check.details.some((detail) => detail.toLowerCase().includes('spawn ')) ) ? ( diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx index 7c766e07..710a666e 100644 --- a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx +++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx @@ -8,6 +8,7 @@ import type { TeamProviderId } from '@shared/types'; import type { CliProviderStatus } from '@shared/types'; export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed'; +export type ProvisioningPrepareState = 'idle' | 'loading' | 'ready' | 'failed'; export interface ProvisioningProviderCheck { providerId: TeamProviderId; @@ -139,6 +140,7 @@ type ProvisioningDetailSummary = | 'Authentication required' | 'Runtime provider is not configured' | 'CLI preflight failed' + | 'Selected model compatibility pending' | 'Selected model verified' | 'Selected model unavailable' | 'Selected model verification timed out' @@ -197,6 +199,9 @@ function summarizeDetail( if (lower.includes('claude cli preflight check failed')) { return 'CLI preflight failed'; } + if (lower.includes('compatible, deep verification pending')) { + return 'Selected model compatibility pending'; + } if (lower.includes('selected model') && lower.includes('verified for launch')) { return 'Selected model verified'; } @@ -236,6 +241,7 @@ function summarizeDetail( } function getModelDetailSummary(details: string[]): string | null { + let compatibilityPendingCount = 0; let verifiedCount = 0; let unavailableCount = 0; let timedOutCount = 0; @@ -244,6 +250,10 @@ function getModelDetailSummary(details: string[]): string | null { for (const detail of details) { const lower = detail.toLowerCase(); + if (lower.includes('compatible, deep verification pending')) { + compatibilityPendingCount += 1; + continue; + } if (lower.includes(' - verified')) { verifiedCount += 1; continue; @@ -275,6 +285,9 @@ function getModelDetailSummary(details: string[]): string | null { if (timedOutCount > 0) { parts.push(`${timedOutCount} model${timedOutCount === 1 ? '' : 's'} timed out`); } + if (compatibilityPendingCount > 0) { + parts.push(`${compatibilityPendingCount} compatible, deep verification pending`); + } if (checkingCount > 0) { parts.push(`${checkingCount} checking`); } @@ -285,6 +298,14 @@ function getModelDetailSummary(details: string[]): string | null { return parts.length > 0 ? `Selected model checks - ${parts.join(', ')}` : null; } +function hasCompatibilityPendingDetails(checks: ProvisioningProviderCheck[]): boolean { + return checks.some((check) => + check.details.some((detail) => + detail.toLowerCase().includes('compatible, deep verification pending') + ) + ); +} + function getDisplayStatusText(check: ProvisioningProviderCheck): string { const modelSummary = getModelDetailSummary(check.details); if (modelSummary) { @@ -402,6 +423,64 @@ export function getPrimaryProvisioningFailureDetail( return null; } +export function deriveEffectiveProvisioningPrepareState(params: { + state: ProvisioningPrepareState; + message: string | null; + warnings: string[]; + checks: ProvisioningProviderCheck[]; +}): { state: ProvisioningPrepareState; message: string | null } { + if (params.state !== 'loading') { + return { + state: params.state, + message: params.message, + }; + } + + if (params.checks.length === 0) { + return { + state: params.state, + message: params.message, + }; + } + + const hasPendingChecks = params.checks.some( + (check) => check.status === 'pending' || check.status === 'checking' + ); + if (hasPendingChecks) { + if (hasCompatibilityPendingDetails(params.checks)) { + return { + state: params.state, + message: + 'Deep verification is still running. OpenCode free models may take around 20 seconds.', + }; + } + return { + state: params.state, + message: params.message, + }; + } + + if (params.checks.some((check) => check.status === 'failed')) { + return { + state: 'failed', + message: + getPrimaryProvisioningFailureDetail(params.checks) ?? + params.message ?? + 'Some selected providers need attention.', + }; + } + + const hasNotes = + params.warnings.length > 0 || params.checks.some((check) => check.status === 'notes'); + + return { + state: 'ready', + message: hasNotes + ? 'Selected providers are ready with notes.' + : 'Selected providers are ready.', + }; +} + export function shouldHideProvisioningProviderStatusList( checks: ProvisioningProviderCheck[], message: string | null | undefined diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index f043c1fe..d66d1d95 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -19,6 +19,7 @@ import { } from '@renderer/utils/geminiUiFreeze'; import { getAvailableTeamProviderModelOptions, + isTeamProviderModelVerificationPending, getTeamModelUiDisabledReason, normalizeTeamModelForUi, TEAM_MODEL_UI_DISABLED_BADGE_LABEL, @@ -259,8 +260,8 @@ export const TeamModelSelector: React.FC = ({ }; const shouldAwaitRuntimeModelList = effectiveProviderId !== 'anthropic' && - (effectiveCliStatus == null || effectiveCliStatusLoading) && - runtimeProviderStatus == null; + (runtimeProviderStatus == null || + isTeamProviderModelVerificationPending(effectiveProviderId, runtimeProviderStatus)); const normalizedValue = normalizeTeamModelForUi( effectiveProviderId, value, diff --git a/src/renderer/components/team/dialogs/providerPrepareCacheKey.ts b/src/renderer/components/team/dialogs/providerPrepareCacheKey.ts index e67e1efa..6e1c55ec 100644 --- a/src/renderer/components/team/dialogs/providerPrepareCacheKey.ts +++ b/src/renderer/components/team/dialogs/providerPrepareCacheKey.ts @@ -5,16 +5,19 @@ export function buildProviderPrepareModelCacheKey({ providerId, backendSummary, limitContext, + runtimeStatusSignature, }: { cwd: string; providerId: TeamProviderId; backendSummary: string | null | undefined; limitContext: boolean; + runtimeStatusSignature?: string | null | undefined; }): string { return [ cwd, providerId, backendSummary ?? '', limitContext ? 'limit-context:on' : 'limit-context:off', + runtimeStatusSignature ?? '', ].join('::'); } diff --git a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts index e1c02717..2d8384d7 100644 --- a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts +++ b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts @@ -1,7 +1,11 @@ import { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog'; import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection'; -import type { TeamProviderId, TeamProvisioningPrepareResult } from '@shared/types'; +import type { + TeamProviderId, + TeamProvisioningModelVerificationMode, + TeamProvisioningPrepareResult, +} from '@shared/types'; export type ProviderPrepareCheckStatus = 'ready' | 'notes' | 'failed'; @@ -10,10 +14,12 @@ type PrepareProvisioningFn = ( providerId?: TeamProviderId, providerIds?: TeamProviderId[], selectedModels?: string[], - limitContext?: boolean + limitContext?: boolean, + modelVerificationMode?: TeamProvisioningModelVerificationMode ) => Promise; interface ProviderPrepareDiagnosticsProgress { + status: ProviderPrepareCheckStatus | 'checking'; details: string[]; completedCount: number; totalCount: number; @@ -69,6 +75,10 @@ function buildModelSuccessLine(providerId: TeamProviderId, modelId: string): str return `${getModelLabel(providerId, modelId)} - verified`; } +function buildModelCompatibilityPendingLine(providerId: TeamProviderId, modelId: string): string { + return `${getModelLabel(providerId, modelId)} - compatible, deep verification pending...`; +} + export function getProviderPrepareCachedSnapshot({ providerId, selectedModelIds, @@ -210,6 +220,38 @@ function getModelScopedEntries(modelId: string, result: TeamProvisioningPrepareR .filter((entry) => scopedPattern.test(entry)); } +function isModelScopedEntryForAnyModel(modelIds: readonly string[], entry: string): boolean { + const trimmed = entry.trim(); + if (!trimmed) { + return false; + } + + return modelIds.some((modelId) => + new RegExp(`^Selected model ${escapeRegExp(modelId)}\\b`, 'i').test(trimmed) + ); +} + +function looksLikeSingleModelBatchFailure( + modelId: string, + result: TeamProvisioningPrepareResult +): boolean { + const candidates = [...(result.details ?? []), ...(result.warnings ?? []), result.message] + .map((entry) => entry?.trim() ?? '') + .filter(Boolean); + const modelLower = modelId.toLowerCase(); + + return candidates.some((candidate) => { + const lower = candidate.toLowerCase(); + return ( + lower.includes(modelLower) || + lower.includes('requested model') || + lower.includes('model is not supported') || + lower.includes('model is not available') || + lower.includes('selected model') + ); + }); +} + function getScopedModelReason(modelId: string, entries: string[]): string | null { for (const entry of entries) { const stripped = stripSelectedModelPrefix(modelId, entry); @@ -302,6 +344,27 @@ function suppressSupersededRuntimeWarnings(params: { }; } +function getProgressStatus(params: { + completedCount: number; + totalCount: number; + runtimeWarnings: string[]; + modelResultsById: Map; +}): ProviderPrepareCheckStatus | 'checking' { + if (params.completedCount < params.totalCount) { + return 'checking'; + } + if (Array.from(params.modelResultsById.values()).some((result) => result.status === 'failed')) { + return 'failed'; + } + if ( + params.runtimeWarnings.length > 0 || + Array.from(params.modelResultsById.values()).some((result) => result.status === 'notes') + ) { + return 'notes'; + } + return 'ready'; +} + function resolveModelResultFromBatch( providerId: TeamProviderId, modelId: string, @@ -378,6 +441,84 @@ function resolveModelResultFromBatch( }; } +function resolveModelResultFromCompatibilityBatch( + providerId: TeamProviderId, + modelId: string, + result: TeamProvisioningPrepareResult, + isOnlyModel: boolean +): { kind: 'compatible' } | { kind: 'terminal'; result: ProviderPrepareDiagnosticsModelResult } { + const modelScopedEntries = getModelScopedEntries(modelId, result); + const scopedReason = getScopedModelReason(modelId, modelScopedEntries); + const fallbackBatchReason = isOnlyModel + ? (getResultReason(modelId, result) ?? normalizeModelReason(result.message)) + : null; + + const hasCompatibilityLine = modelScopedEntries.some((entry) => + /selected model .* is compatible\. deep verification pending\./i.test(entry) + ); + if (hasCompatibilityLine || (result.ready && modelScopedEntries.length === 0)) { + return { kind: 'compatible' }; + } + + const hasUnavailableLine = modelScopedEntries.some((entry) => + /selected model .* is unavailable\./i.test(entry) + ); + if (hasUnavailableLine || (!result.ready && isOnlyModel)) { + return { + kind: 'terminal', + result: { + status: 'failed', + line: buildModelFailureLine( + providerId, + modelId, + 'unavailable', + scopedReason ?? fallbackBatchReason + ), + warningLine: null, + }, + }; + } + + const hasVerificationWarningLine = modelScopedEntries.some((entry) => + /selected model .* could not be verified\./i.test(entry) + ); + if (hasVerificationWarningLine) { + const line = buildModelFailureLine( + providerId, + modelId, + 'check failed', + scopedReason ?? fallbackBatchReason + ); + return { + kind: 'terminal', + result: { + status: 'notes', + line, + warningLine: line, + }, + }; + } + + return { + kind: 'terminal', + result: { + status: 'notes', + line: buildModelFailureLine( + providerId, + modelId, + 'check failed', + scopedReason ?? fallbackBatchReason ?? 'Model verification failed' + ), + warningLine: buildModelFailureLine( + providerId, + modelId, + 'check failed', + scopedReason ?? fallbackBatchReason ?? 'Model verification failed' + ), + }, + }; +} + export async function runProviderPrepareDiagnostics({ cwd, providerId, @@ -395,26 +536,26 @@ export async function runProviderPrepareDiagnostics({ onModelProgress?: (progress: ProviderPrepareDiagnosticsProgress) => void; cachedModelResultsById?: Record; }): Promise { - const runtimeResult = await prepareProvisioning( - cwd, - providerId, - [providerId], - undefined, - limitContext - ); - const runtimeDetailLines = createRuntimeDetailLines(runtimeResult); - const runtimeWarnings = [...(runtimeResult.warnings ?? [])]; - - if (!runtimeResult.ready) { - return { - status: 'failed', - details: [...runtimeDetailLines, ...(runtimeResult.message ? [runtimeResult.message] : [])], - warnings: runtimeWarnings, - modelResultsById: {}, - }; - } - if (selectedModelIds.length === 0) { + const runtimeResult = await prepareProvisioning( + cwd, + providerId, + [providerId], + undefined, + limitContext + ); + const runtimeDetailLines = createRuntimeDetailLines(runtimeResult); + const runtimeWarnings = [...(runtimeResult.warnings ?? [])]; + + if (!runtimeResult.ready) { + return { + status: 'failed', + details: [...runtimeDetailLines, ...(runtimeResult.message ? [runtimeResult.message] : [])], + warnings: runtimeWarnings, + modelResultsById: {}, + }; + } + return { status: runtimeWarnings.length > 0 ? 'notes' : 'ready', details: runtimeDetailLines, @@ -429,6 +570,8 @@ export async function runProviderPrepareDiagnostics({ const reusableModelResultsById = cachedModelResultsById ?? {}; const modelResultsById = new Map(); const modelLines = new Map(); + let runtimeDetailLines: string[] = []; + let runtimeWarnings: string[] = []; let completedCount = 0; let hasFailure = false; let hasNotes = false; @@ -454,9 +597,20 @@ export async function runProviderPrepareDiagnostics({ } const emitProgress = (): void => { + const filteredRuntime = suppressSupersededRuntimeWarnings({ + runtimeDetailLines, + runtimeWarnings, + modelResultsById, + }); onModelProgress?.({ + status: getProgressStatus({ + completedCount, + totalCount: orderedModelIds.length, + runtimeWarnings: filteredRuntime.runtimeWarnings, + modelResultsById, + }), details: [ - ...runtimeDetailLines, + ...filteredRuntime.runtimeDetailLines, ...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''), ], completedCount, @@ -467,52 +621,297 @@ export async function runProviderPrepareDiagnostics({ emitProgress(); const uncachedModelIds = orderedModelIds.filter((modelId) => !modelResultsById.has(modelId)); - if (uncachedModelIds.length > 0) { - try { - const batchedModelResult = await prepareProvisioning( - cwd, - providerId, - [providerId], - uncachedModelIds, - limitContext - ); + if (uncachedModelIds.length === 0) { + const runtimeResult = await prepareProvisioning( + cwd, + providerId, + [providerId], + undefined, + limitContext + ); + runtimeDetailLines = createRuntimeDetailLines(runtimeResult); + runtimeWarnings = [...(runtimeResult.warnings ?? [])]; - for (const modelId of uncachedModelIds) { - const resolvedResult = resolveModelResultFromBatch( + if (!runtimeResult.ready) { + return { + status: 'failed', + details: [...runtimeDetailLines, ...(runtimeResult.message ? [runtimeResult.message] : [])], + warnings: runtimeWarnings, + modelResultsById: {}, + }; + } + } else { + const recordTerminalModelResult = ( + modelId: string, + resolvedResult: ProviderPrepareDiagnosticsModelResult + ): void => { + modelLines.set(modelId, resolvedResult.line); + modelResultsById.set(modelId, resolvedResult); + completedCount += 1; + if (resolvedResult.status === 'failed') { + hasFailure = true; + } else if (resolvedResult.status === 'notes') { + hasNotes = true; + } + if (resolvedResult.warningLine) { + modelWarnings.push(resolvedResult.warningLine); + } + }; + + if (providerId === 'opencode') { + const compatibilityPassedModelIds: string[] = []; + try { + const compatibilityResult = await prepareProvisioning( + cwd, providerId, - modelId, - batchedModelResult, - uncachedModelIds.length === 1 + [providerId], + uncachedModelIds, + limitContext, + 'compatibility' ); - modelLines.set(modelId, resolvedResult.line); - modelResultsById.set(modelId, resolvedResult); - if (resolvedResult.status === 'failed') { - hasFailure = true; - } else if (resolvedResult.status === 'notes') { - hasNotes = true; + runtimeDetailLines = createRuntimeDetailLines(compatibilityResult).filter( + (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) + ); + runtimeWarnings = [...(compatibilityResult.warnings ?? [])].filter( + (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) + ); + + const hasModelScopedEntries = uncachedModelIds.some( + (modelId) => getModelScopedEntries(modelId, compatibilityResult).length > 0 + ); + const hasNonModelScopedDiagnostics = + runtimeDetailLines.length > 0 || runtimeWarnings.length > 0; + const hasSingleModelFallbackReason = + uncachedModelIds.length === 1 && + looksLikeSingleModelBatchFailure(uncachedModelIds[0], compatibilityResult); + if ( + !compatibilityResult.ready && + !hasModelScopedEntries && + (uncachedModelIds.length > 1 || + (!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason)) + ) { + return { + status: 'failed', + details: [ + ...runtimeDetailLines, + ...(compatibilityResult.message ? [compatibilityResult.message] : []), + ], + warnings: runtimeWarnings, + modelResultsById: {}, + }; } - if (resolvedResult.warningLine) { - modelWarnings.push(resolvedResult.warningLine); + if (!hasModelScopedEntries && uncachedModelIds.length === 1) { + runtimeDetailLines = []; + runtimeWarnings = []; + } + + for (const modelId of uncachedModelIds) { + const compatibilityResolution = resolveModelResultFromCompatibilityBatch( + providerId, + modelId, + compatibilityResult, + uncachedModelIds.length === 1 + ); + if (compatibilityResolution.kind === 'compatible') { + modelLines.set(modelId, buildModelCompatibilityPendingLine(providerId, modelId)); + compatibilityPassedModelIds.push(modelId); + continue; + } + recordTerminalModelResult(modelId, compatibilityResolution.result); + } + } catch (error) { + hasNotes = true; + const reason = normalizeModelReason( + error instanceof Error ? error.message.trim() : String(error).trim() + ); + for (const modelId of uncachedModelIds) { + const line = buildModelFailureLine(providerId, modelId, 'check failed', reason || null); + recordTerminalModelResult(modelId, { + status: 'notes', + line, + warningLine: line, + }); } } - } catch (error) { - hasNotes = true; - const reason = normalizeModelReason( - error instanceof Error ? error.message.trim() : String(error).trim() - ); - for (const modelId of uncachedModelIds) { - const line = buildModelFailureLine(providerId, modelId, 'check failed', reason || null); - modelLines.set(modelId, line); - modelWarnings.push(line); - modelResultsById.set(modelId, { - status: 'notes', - line, - warningLine: line, - }); - } - } finally { - completedCount += uncachedModelIds.length; + emitProgress(); + + if (compatibilityPassedModelIds.length === 0) { + const filteredRuntime = suppressSupersededRuntimeWarnings({ + runtimeDetailLines, + runtimeWarnings, + modelResultsById, + }); + const dedupedWarnings = Array.from( + new Set([...filteredRuntime.runtimeWarnings, ...modelWarnings]) + ); + const selectedModelResultsById = Object.fromEntries( + orderedModelIds + .map((modelId) => [modelId, modelResultsById.get(modelId)] as const) + .filter((entry): entry is [string, ProviderPrepareDiagnosticsModelResult] => + Boolean(entry[1]) + ) + ); + + return { + status: hasFailure + ? 'failed' + : hasNotes || dedupedWarnings.length > 0 + ? 'notes' + : 'ready', + details: [ + ...filteredRuntime.runtimeDetailLines, + ...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''), + ], + warnings: dedupedWarnings, + modelResultsById: selectedModelResultsById, + }; + } + + try { + const batchedModelResult = await prepareProvisioning( + cwd, + providerId, + [providerId], + compatibilityPassedModelIds, + limitContext, + 'deep' + ); + runtimeDetailLines = createRuntimeDetailLines(batchedModelResult).filter( + (entry) => !isModelScopedEntryForAnyModel(compatibilityPassedModelIds, entry) + ); + runtimeWarnings = [...(batchedModelResult.warnings ?? [])].filter( + (entry) => !isModelScopedEntryForAnyModel(compatibilityPassedModelIds, entry) + ); + + const hasModelScopedEntries = compatibilityPassedModelIds.some( + (modelId) => getModelScopedEntries(modelId, batchedModelResult).length > 0 + ); + const hasNonModelScopedDiagnostics = + runtimeDetailLines.length > 0 || runtimeWarnings.length > 0; + const hasSingleModelFallbackReason = + compatibilityPassedModelIds.length === 1 && + looksLikeSingleModelBatchFailure(compatibilityPassedModelIds[0], batchedModelResult); + if ( + !batchedModelResult.ready && + !hasModelScopedEntries && + (compatibilityPassedModelIds.length > 1 || + (!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason)) + ) { + return { + status: 'failed', + details: [ + ...runtimeDetailLines, + ...(batchedModelResult.message ? [batchedModelResult.message] : []), + ], + warnings: runtimeWarnings, + modelResultsById: {}, + }; + } + if (!hasModelScopedEntries && compatibilityPassedModelIds.length === 1) { + runtimeDetailLines = []; + runtimeWarnings = []; + } + + for (const modelId of compatibilityPassedModelIds) { + recordTerminalModelResult( + modelId, + resolveModelResultFromBatch( + providerId, + modelId, + batchedModelResult, + compatibilityPassedModelIds.length === 1 + ) + ); + } + } catch (error) { + hasNotes = true; + const reason = normalizeModelReason( + error instanceof Error ? error.message.trim() : String(error).trim() + ); + for (const modelId of compatibilityPassedModelIds) { + const line = buildModelFailureLine(providerId, modelId, 'check failed', reason || null); + recordTerminalModelResult(modelId, { + status: 'notes', + line, + warningLine: line, + }); + } + } finally { + emitProgress(); + } + } else { + try { + const batchedModelResult = await prepareProvisioning( + cwd, + providerId, + [providerId], + uncachedModelIds, + limitContext + ); + runtimeDetailLines = createRuntimeDetailLines(batchedModelResult).filter( + (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) + ); + runtimeWarnings = [...(batchedModelResult.warnings ?? [])].filter( + (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) + ); + + const hasModelScopedEntries = uncachedModelIds.some( + (modelId) => getModelScopedEntries(modelId, batchedModelResult).length > 0 + ); + const hasNonModelScopedDiagnostics = + runtimeDetailLines.length > 0 || runtimeWarnings.length > 0; + const hasSingleModelFallbackReason = + uncachedModelIds.length === 1 && + looksLikeSingleModelBatchFailure(uncachedModelIds[0], batchedModelResult); + if ( + !batchedModelResult.ready && + !hasModelScopedEntries && + (uncachedModelIds.length > 1 || + (!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason)) + ) { + return { + status: 'failed', + details: [ + ...runtimeDetailLines, + ...(batchedModelResult.message ? [batchedModelResult.message] : []), + ], + warnings: runtimeWarnings, + modelResultsById: {}, + }; + } + if (!hasModelScopedEntries && uncachedModelIds.length === 1) { + runtimeDetailLines = []; + runtimeWarnings = []; + } + + for (const modelId of uncachedModelIds) { + recordTerminalModelResult( + modelId, + resolveModelResultFromBatch( + providerId, + modelId, + batchedModelResult, + uncachedModelIds.length === 1 + ) + ); + } + } catch (error) { + hasNotes = true; + const reason = normalizeModelReason( + error instanceof Error ? error.message.trim() : String(error).trim() + ); + for (const modelId of uncachedModelIds) { + const line = buildModelFailureLine(providerId, modelId, 'check failed', reason || null); + recordTerminalModelResult(modelId, { + status: 'notes', + line, + warningLine: line, + }); + } + } finally { + emitProgress(); + } } } diff --git a/src/renderer/components/team/dialogs/providerPrepareRequestSignature.ts b/src/renderer/components/team/dialogs/providerPrepareRequestSignature.ts new file mode 100644 index 00000000..0e820eff --- /dev/null +++ b/src/renderer/components/team/dialogs/providerPrepareRequestSignature.ts @@ -0,0 +1,122 @@ +import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes'; +import type { CliProviderStatus, TeamProviderId } from '@shared/types'; + +type RuntimeProviderStatusById = ReadonlyMap; +type SelectedModelChecksByProvider = ReadonlyMap; + +function normalizeModelIds(modelIds: readonly string[] | null | undefined): string[] { + return Array.from( + new Set((modelIds ?? []).map((modelId) => modelId.trim()).filter(Boolean)) + ).sort(); +} + +export function buildProviderPrepareMembersSignature(members: readonly MemberDraft[]): string { + return JSON.stringify( + members.map((member) => ({ + id: member.id, + providerId: member.providerId ?? null, + model: member.model?.trim() || null, + effort: member.effort ?? null, + removed: Boolean(member.removedAt), + })) + ); +} + +export function buildProviderPrepareModelChecksSignature( + modelChecksByProvider: SelectedModelChecksByProvider +): string { + return JSON.stringify( + Array.from(modelChecksByProvider.entries()) + .map(([providerId, modelIds]) => ({ + providerId, + modelIds: normalizeModelIds(modelIds), + })) + .sort((left, right) => left.providerId.localeCompare(right.providerId)) + ); +} + +export function buildProviderPrepareRuntimeStatusSignature( + providerIds: readonly TeamProviderId[], + runtimeProviderStatusById: RuntimeProviderStatusById +): string { + return JSON.stringify( + Array.from(new Set(providerIds)) + .sort() + .map((providerId) => { + const provider = runtimeProviderStatusById.get(providerId) ?? null; + return { + providerId, + supported: provider?.supported ?? null, + authenticated: provider?.authenticated ?? null, + authMethod: provider?.authMethod ?? null, + selectedBackendId: provider?.selectedBackendId ?? null, + resolvedBackendId: provider?.resolvedBackendId ?? null, + models: normalizeModelIds(provider?.models), + modelCatalogSource: provider?.modelCatalog?.source ?? null, + modelCatalogStatus: provider?.modelCatalog?.status ?? null, + modelCatalogModels: normalizeModelIds( + provider?.modelCatalog?.models?.map((model) => model.id) + ), + connection: provider?.connection + ? { + supportsOAuth: provider.connection.supportsOAuth, + supportsApiKey: provider.connection.supportsApiKey, + configuredAuthMode: provider.connection.configuredAuthMode ?? null, + apiKeyConfigured: provider.connection.apiKeyConfigured, + apiKeySource: provider.connection.apiKeySource ?? null, + codex: provider.connection.codex + ? { + preferredAuthMode: provider.connection.codex.preferredAuthMode, + effectiveAuthMode: provider.connection.codex.effectiveAuthMode, + appServerState: provider.connection.codex.appServerState, + managedAccountType: provider.connection.codex.managedAccount?.type ?? null, + managedAccountEmail: provider.connection.codex.managedAccount?.email ?? null, + requiresOpenaiAuth: provider.connection.codex.requiresOpenaiAuth ?? null, + localAccountArtifactsPresent: + provider.connection.codex.localAccountArtifactsPresent ?? null, + localActiveChatgptAccountPresent: + provider.connection.codex.localActiveChatgptAccountPresent ?? null, + loginStatus: provider.connection.codex.login?.status ?? null, + launchAllowed: provider.connection.codex.launchAllowed, + launchIssueMessage: provider.connection.codex.launchIssueMessage ?? null, + launchReadinessState: provider.connection.codex.launchReadinessState, + } + : null, + } + : null, + availableBackends: (provider?.availableBackends ?? []) + .map((backend) => ({ + id: backend.id, + available: backend.available, + selectable: backend.selectable, + state: backend.state ?? null, + recommended: backend.recommended, + audience: backend.audience ?? null, + })) + .sort((left, right) => left.id.localeCompare(right.id)), + }; + }) + ); +} + +export function buildProviderPrepareRequestSignature(input: { + cwd: string; + selectedProviderId: TeamProviderId; + selectedModel: string; + selectedMemberProviders: readonly TeamProviderId[]; + limitContext?: boolean; + runtimeStatusSignature: string; + membersSignature?: string; + modelChecksSignature?: string; +}): string { + return JSON.stringify({ + cwd: input.cwd, + selectedProviderId: input.selectedProviderId, + selectedModel: input.selectedModel.trim(), + selectedMemberProviders: Array.from(new Set(input.selectedMemberProviders)).sort(), + limitContext: Boolean(input.limitContext), + runtimeStatusSignature: input.runtimeStatusSignature, + membersSignature: input.membersSignature ?? null, + modelChecksSignature: input.modelChecksSignature ?? null, + }); +} diff --git a/src/renderer/components/team/dialogs/providerPrepareShortLivedCache.ts b/src/renderer/components/team/dialogs/providerPrepareShortLivedCache.ts new file mode 100644 index 00000000..37ef2032 --- /dev/null +++ b/src/renderer/components/team/dialogs/providerPrepareShortLivedCache.ts @@ -0,0 +1,77 @@ +import type { TeamProviderId } from '@shared/types'; + +import type { ProviderPrepareDiagnosticsModelResult } from './providerPrepareDiagnostics'; + +const OPENCODE_DEEP_VERIFY_SUCCESS_CACHE_TTL_MS = 45_000; + +type ShortLivedProviderPrepareCacheEntry = { + expiresAt: number; + modelResultsById: Record; +}; + +const shortLivedProviderPrepareCache = new Map(); + +function pruneExpiredEntries(now: number): void { + for (const [cacheKey, entry] of shortLivedProviderPrepareCache.entries()) { + if (entry.expiresAt <= now) { + shortLivedProviderPrepareCache.delete(cacheKey); + } + } +} + +export function getShortLivedProviderPrepareModelResults({ + providerId, + cacheKey, +}: { + providerId: TeamProviderId; + cacheKey: string; +}): Record { + if (providerId !== 'opencode') { + return {}; + } + + const now = Date.now(); + pruneExpiredEntries(now); + const entry = shortLivedProviderPrepareCache.get(cacheKey); + if (!entry) { + return {}; + } + + return { ...entry.modelResultsById }; +} + +export function storeShortLivedProviderPrepareModelResults({ + providerId, + cacheKey, + modelResultsById, +}: { + providerId: TeamProviderId; + cacheKey: string; + modelResultsById: Record; +}): void { + if (providerId !== 'opencode') { + return; + } + + const readyResultsById = Object.fromEntries( + Object.entries(modelResultsById).filter(([, result]) => result.status === 'ready') + ); + if (Object.keys(readyResultsById).length === 0) { + return; + } + + const now = Date.now(); + pruneExpiredEntries(now); + const existingEntry = shortLivedProviderPrepareCache.get(cacheKey); + shortLivedProviderPrepareCache.set(cacheKey, { + expiresAt: now + OPENCODE_DEEP_VERIFY_SUCCESS_CACHE_TTL_MS, + modelResultsById: { + ...(existingEntry?.modelResultsById ?? {}), + ...readyResultsById, + }, + }); +} + +export function __resetShortLivedProviderPrepareCacheForTests(): void { + shortLivedProviderPrepareCache.clear(); +} diff --git a/src/renderer/components/team/dialogs/provisioningMemberScope.ts b/src/renderer/components/team/dialogs/provisioningMemberScope.ts new file mode 100644 index 00000000..92d094b4 --- /dev/null +++ b/src/renderer/components/team/dialogs/provisioningMemberScope.ts @@ -0,0 +1,10 @@ +import { isTeamProviderId } from '@shared/utils/teamProvider'; + +import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes'; +import type { TeamProviderId } from '@shared/types'; + +export function collectActiveMemberProviderIds(members: readonly MemberDraft[]): TeamProviderId[] { + return members.flatMap((member) => + !member.removedAt && isTeamProviderId(member.providerId) ? [member.providerId] : [] + ); +} diff --git a/src/renderer/services/dashboardCliStatusBannerPreference.ts b/src/renderer/services/dashboardCliStatusBannerPreference.ts new file mode 100644 index 00000000..0945baf4 --- /dev/null +++ b/src/renderer/services/dashboardCliStatusBannerPreference.ts @@ -0,0 +1,20 @@ +const DASHBOARD_CLI_STATUS_BANNER_COLLAPSED_KEY = 'dashboard:cli-status-banner-collapsed'; + +export function loadDashboardCliStatusBannerCollapsed(): boolean { + try { + return window.localStorage.getItem(DASHBOARD_CLI_STATUS_BANNER_COLLAPSED_KEY) === 'true'; + } catch { + return false; + } +} + +export function saveDashboardCliStatusBannerCollapsed(collapsed: boolean): void { + try { + window.localStorage.setItem( + DASHBOARD_CLI_STATUS_BANNER_COLLAPSED_KEY, + collapsed ? 'true' : 'false' + ); + } catch { + // Ignore storage failures and keep the dashboard responsive. + } +} diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts index 1924562f..77efb4c2 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -172,6 +172,74 @@ export function mergeCliStatusPreservingHydratedProviders( }; } +function isMultimodelCliStatus( + status: CliInstallationStatus | null | undefined +): status is CliInstallationStatus & { flavor: 'agent_teams_orchestrator' } { + return status?.flavor === 'agent_teams_orchestrator'; +} + +function hasActiveProviderStatusLoading( + providerLoading: Partial> +): boolean { + return Object.values(providerLoading).some((loading) => loading === true); +} + +function getAuthenticatedProvider(providers: CliProviderStatus[]): CliProviderStatus | null { + return providers.find((provider) => provider.authenticated) ?? null; +} + +function buildMultimodelCliAuthState(params: { + status: CliInstallationStatus; + providers?: CliProviderStatus[]; + providerLoading?: Partial>; +}): Pick { + const providers = params.providers ?? params.status.providers; + const providerLoading = params.providerLoading ?? {}; + const authenticatedProvider = getAuthenticatedProvider(providers); + + return { + authLoggedIn: providers.some((provider) => provider.authenticated), + authMethod: authenticatedProvider?.authMethod ?? null, + authStatusChecking: params.status.installed && hasActiveProviderStatusLoading(providerLoading), + }; +} + +function getProviderDisplayName(providerId: CliProviderId): string { + switch (providerId) { + case 'anthropic': + return 'Anthropic'; + case 'codex': + return 'Codex'; + case 'gemini': + return 'Gemini'; + case 'opencode': + return 'OpenCode'; + } +} + +function createProviderStatusErrorSnapshot(params: { + providerId: CliProviderId; + message: string; + currentProvider?: CliProviderStatus; +}): CliProviderStatus { + const currentProvider = + params.currentProvider ?? + createLoadingMultimodelCliStatus().providers.find( + (provider) => provider.providerId === params.providerId + )!; + + return { + ...currentProvider, + providerId: params.providerId, + displayName: currentProvider.displayName ?? getProviderDisplayName(params.providerId), + authenticated: false, + authMethod: null, + verificationState: 'error', + statusMessage: params.message, + detailMessage: null, + }; +} + // ============================================================================= // Slice Interface // ============================================================================= @@ -297,12 +365,22 @@ export const createCliInstallerSlice: StateCreator 0, - }, + cliStatus: nextAuthState + ? { + ...nextCliStatus, + launchError: metadata.launchError ?? null, + ...nextAuthState, + } + : nextCliStatus, cliStatusLoading: false, cliProviderStatusLoading: nextProviderLoading, }; @@ -362,10 +440,21 @@ export const createCliInstallerSlice: StateCreator ({ - cliStatus: mergeCliStatusPreservingHydratedProviders(state.cliStatus, status), - cliProviderStatusLoading: {}, - })); + set((state) => { + const nextCliStatus = mergeCliStatusPreservingHydratedProviders(state.cliStatus, status); + return { + cliStatus: isMultimodelCliStatus(nextCliStatus) + ? { + ...nextCliStatus, + ...buildMultimodelCliAuthState({ + status: nextCliStatus, + providerLoading: {}, + }), + } + : nextCliStatus, + cliProviderStatusLoading: {}, + }; + }); if (status.installed) { for (const provider of status.providers) { void get().fetchCliProviderStatus(provider.providerId, { @@ -404,13 +493,27 @@ export const createCliInstallerSlice: StateCreator { if (!silent) { - set((state) => ({ - cliStatusError: null, - cliProviderStatusLoading: { + set((state) => { + const nextLoading = { ...state.cliProviderStatusLoading, [providerId]: true, - }, - })); + }; + + return { + cliStatusError: null, + cliProviderStatusLoading: nextLoading, + cliStatus: + state.cliStatus && isMultimodelCliStatus(state.cliStatus) + ? { + ...state.cliStatus, + ...buildMultimodelCliAuthState({ + status: state.cliStatus, + providerLoading: nextLoading, + }), + } + : state.cliStatus, + }; + }); } try { @@ -418,6 +521,7 @@ export const createCliInstallerSlice: StateCreator { + const currentCliStatus = state.cliStatus; const nextLoading = silent ? state.cliProviderStatusLoading : { @@ -432,28 +536,38 @@ export const createCliInstallerSlice: StateCreator provider.providerId === providerId ); const nextProviders = hasProvider - ? state.cliStatus.providers.map((provider) => + ? settledCliStatus.providers.map((provider) => provider.providerId === providerId ? providerStatus : provider ) - : [...state.cliStatus.providers, providerStatus]; - const authenticatedProvider = - nextProviders.find((provider) => provider.authenticated) ?? null; + : [...settledCliStatus.providers, providerStatus]; + const nextCliStatus = isMultimodelCliStatus(settledCliStatus) + ? { + ...settledCliStatus, + providers: nextProviders, + ...buildMultimodelCliAuthState({ + status: settledCliStatus, + providers: nextProviders, + providerLoading: nextLoading, + }), + } + : { + ...settledCliStatus, + providers: nextProviders, + authLoggedIn: nextProviders.some((provider) => provider.authenticated), + authMethod: getAuthenticatedProvider(nextProviders)?.authMethod ?? null, + }; return { - cliStatus: { - ...state.cliStatus, - providers: nextProviders, - authLoggedIn: nextProviders.some((provider) => provider.authenticated), - authMethod: authenticatedProvider?.authMethod ?? null, - }, + cliStatus: nextCliStatus, cliProviderStatusLoading: nextLoading, }; }); @@ -462,6 +576,7 @@ export const createCliInstallerSlice: StateCreator { + const currentCliStatus = state.cliStatus; const nextLoading = silent ? state.cliProviderStatusLoading : { @@ -476,9 +591,57 @@ export const createCliInstallerSlice: StateCreator provider.providerId === providerId) ?? + undefined; + const nextProviders = settledCliStatus.providers.some( + (provider) => provider.providerId === providerId + ) + ? settledCliStatus.providers.map((provider) => + provider.providerId === providerId + ? createProviderStatusErrorSnapshot({ + providerId, + message, + currentProvider, + }) + : provider + ) + : [ + ...currentCliStatus.providers, + createProviderStatusErrorSnapshot({ + providerId, + message, + currentProvider, + }), + ]; + return { cliStatusError: message, cliProviderStatusLoading: nextLoading, + cliStatus: isMultimodelCliStatus(settledCliStatus) + ? { + ...settledCliStatus, + providers: nextProviders, + ...buildMultimodelCliAuthState({ + status: settledCliStatus, + providers: nextProviders, + providerLoading: nextLoading, + }), + } + : { + ...settledCliStatus, + providers: nextProviders, + authLoggedIn: nextProviders.some((provider) => provider.authenticated), + authMethod: getAuthenticatedProvider(nextProviders)?.authMethod ?? null, + }, }; }); } finally { diff --git a/src/renderer/utils/teamModelAvailability.ts b/src/renderer/utils/teamModelAvailability.ts index d0630bf8..ddd09c16 100644 --- a/src/renderer/utils/teamModelAvailability.ts +++ b/src/renderer/utils/teamModelAvailability.ts @@ -90,14 +90,32 @@ export function isTeamProviderModelVerificationPending( return true; } - if (providerStatus.verificationState !== 'unknown') { - return false; - } - const hasRuntimeModelTruth = providerStatus.models.length > 0 || (providerStatus.modelCatalog?.models.length ?? 0) > 0 || (providerStatus.modelAvailability?.length ?? 0) > 0; + if (!hasRuntimeModelTruth) { + if ( + providerId === 'codex' && + providerStatus.backend?.kind === 'codex-native' && + providerStatus.supported + ) { + return true; + } + + if ( + providerId === 'opencode' && + providerStatus.backend?.kind === 'opencode-cli' && + providerStatus.supported + ) { + return true; + } + } + + if (providerStatus.verificationState !== 'unknown') { + return false; + } + if (hasRuntimeModelTruth) { return false; } @@ -454,7 +472,7 @@ export function getTeamModelSelectionError( } if (!providerStatus) { - return `Model "${trimmed}" is waiting for ${getTeamProviderLabel(providerId) ?? providerId} runtime verification. Wait for the model list to load or use Default.`; + return null; } if (isTeamProviderModelVerificationPending(providerId, providerStatus)) { diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 01bf5cb4..b62c57b8 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -77,6 +77,7 @@ import type { TeamLaunchResponse, TeamMemberActivityMeta, TeamMessageNotificationData, + TeamProvisioningModelVerificationMode, TeamProvisioningPrepareResult, TeamProvisioningProgress, TeamSummary, @@ -445,7 +446,8 @@ export interface TeamsAPI { providerId?: TeamLaunchRequest['providerId'], providerIds?: TeamLaunchRequest['providerId'][], selectedModels?: string[], - limitContext?: boolean + limitContext?: boolean, + modelVerificationMode?: TeamProvisioningModelVerificationMode ) => Promise; createTeam: (request: TeamCreateRequest) => Promise; getProvisioningStatus: (runId: string) => Promise; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 7052926c..0e45df7a 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -10,8 +10,10 @@ export interface TeamMember { /** Opt-in runtime isolation for persistent teammates. Omitted means shared workspace. */ isolation?: 'worktree'; providerId?: TeamProviderId; + providerBackendId?: TeamProviderBackendId; model?: string; effort?: EffortLevel; + fastMode?: TeamFastMode; color?: string; joinedAt?: number; cwd?: string; @@ -767,8 +769,14 @@ export interface TeamMemberSnapshot { workflow?: string; isolation?: 'worktree'; providerId?: TeamProviderId; + providerBackendId?: TeamProviderBackendId; model?: string; effort?: EffortLevel; + selectedFastMode?: TeamFastMode; + resolvedFastMode?: boolean; + laneId?: string; + laneKind?: 'primary' | 'secondary'; + laneOwnerProviderId?: TeamProviderId; cwd?: string; /** Set only when member's git branch differs from the lead's branch. */ gitBranch?: string; @@ -909,6 +917,16 @@ export interface PersistedTeamLaunchMemberSources { export interface PersistedTeamLaunchMemberState { name: string; + providerId?: TeamProviderId; + providerBackendId?: TeamProviderBackendId; + model?: string; + effort?: EffortLevel; + selectedFastMode?: TeamFastMode; + resolvedFastMode?: boolean; + laneId?: string; + laneKind?: 'primary' | 'secondary'; + laneOwnerProviderId?: TeamProviderId; + launchIdentity?: ProviderModelLaunchIdentity; launchState: MemberLaunchState; agentToolAccepted: boolean; runtimeAlive: boolean; @@ -937,6 +955,7 @@ export interface PersistedTeamLaunchSnapshot { leadSessionId?: string; launchPhase: PersistedTeamLaunchPhase; expectedMembers: string[]; + bootstrapExpectedMembers?: string[]; members: Record; summary: PersistedTeamLaunchSummary; teamLaunchState: TeamLaunchAggregateState; @@ -962,6 +981,10 @@ export interface TeamAgentRuntimeEntry { alive: boolean; restartable: boolean; backendType?: TeamAgentRuntimeBackendType; + providerId?: TeamProviderId; + providerBackendId?: TeamProviderBackendId; + laneId?: string; + laneKind?: 'primary' | 'secondary'; pid?: number; runtimeModel?: string; rssBytes?: number; @@ -1072,8 +1095,10 @@ export interface TeamProvisioningMemberInput { /** Opt-in: run this teammate in its own git worktree. */ isolation?: 'worktree'; providerId?: TeamProviderId; + providerBackendId?: TeamProviderBackendId; model?: string; effort?: EffortLevel; + fastMode?: TeamFastMode; } export interface TeamCreateRequest { @@ -1114,6 +1139,8 @@ export interface TeamCreateResponse { runId: string; } +export type TeamProvisioningModelVerificationMode = 'compatibility' | 'deep'; + export interface TeamProvisioningPrepareResult { ready: boolean; message: string; diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index c1472d8e..c2aee555 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -32,6 +32,10 @@ const { mockAddTeamNotification } = vi.hoisted(() => ({ const { mockGetMembersMeta } = vi.hoisted(() => ({ mockGetMembersMeta: vi.fn(), })); +const { mockGetMembersMetaFile, mockWriteMembersMeta } = vi.hoisted(() => ({ + mockGetMembersMetaFile: vi.fn(), + mockWriteMembersMeta: vi.fn(), +})); const { mockTeamDataWorkerClient } = vi.hoisted(() => ({ mockTeamDataWorkerClient: { isAvailable: vi.fn(), @@ -51,6 +55,8 @@ vi.mock('@main/services/infrastructure/NotificationManager', () => ({ vi.mock('@main/services/team/TeamMembersMetaStore', () => ({ TeamMembersMetaStore: vi.fn().mockImplementation(() => ({ getMembers: mockGetMembersMeta, + getMeta: mockGetMembersMetaFile, + writeMembers: mockWriteMembersMeta, })), })); vi.mock('@main/services/team/TeamDataWorkerClient', () => ({ @@ -229,6 +235,8 @@ describe('ipc teams handlers', () => { getAliveTeams: vi.fn(() => ['my-team']), getLeadActivityState: vi.fn(() => 'idle'), stopTeam: vi.fn(() => undefined), + reattachOpenCodeOwnedMemberLane: vi.fn(async () => undefined), + detachOpenCodeOwnedMemberLane: vi.fn(async () => undefined), }; const boardTaskActivityService = { getTaskActivity: vi.fn<() => Promise>(async () => []), @@ -259,6 +267,14 @@ describe('ipc teams handlers', () => { vi.clearAllMocks(); mockGetMembersMeta.mockReset(); mockGetMembersMeta.mockResolvedValue([]); + mockGetMembersMetaFile.mockReset(); + mockGetMembersMetaFile.mockResolvedValue({ + version: 1, + providerBackendId: undefined, + members: [], + }); + mockWriteMembersMeta.mockReset(); + mockWriteMembersMeta.mockResolvedValue(undefined); mockTeamDataWorkerClient.isAvailable.mockReturnValue(false); mockTeamDataWorkerClient.getTeamData.mockReset(); mockTeamDataWorkerClient.getMessagesPage.mockReset(); @@ -1731,6 +1747,135 @@ describe('ipc teams handlers', () => { const result = (await handler({} as never, 'my-team', null)) as { success: boolean }; expect(result.success).toBe(false); }); + + it('blocks live addMember for a running OpenCode-led team before metadata is written', async () => { + const handler = handlers.get(TEAM_ADD_MEMBER)!; + service.getTeamData.mockResolvedValueOnce({ + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [ + { + name: 'team-lead', + providerId: 'opencode', + role: 'Team Lead', + currentTaskId: null, + taskCount: 0, + }, + ], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }); + + const result = (await handler({} as never, 'my-team', { + name: 'alice', + role: 'developer', + providerId: 'opencode', + })) as { success: boolean; error?: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain('running OpenCode-led team'); + expect(service.addMember).not.toHaveBeenCalled(); + expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled(); + vi.mocked(console.error).mockClear(); + }); + + it('rolls back live OpenCode addMember metadata when controlled reattach fails', async () => { + const handler = handlers.get(TEAM_ADD_MEMBER)!; + mockGetMembersMetaFile.mockResolvedValueOnce({ + version: 1, + providerBackendId: 'codex-native', + members: [ + { + name: 'team-lead', + providerId: 'codex', + providerBackendId: 'codex-native', + role: 'Team Lead', + agentType: 'team-lead', + }, + { + name: 'bob', + providerId: 'codex', + providerBackendId: 'codex-native', + role: 'Developer', + agentType: 'general-purpose', + agentId: 'agent-bob', + }, + ], + }); + service.getTeamData.mockResolvedValueOnce({ + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [ + { + name: 'team-lead', + providerId: 'codex', + role: 'Team Lead', + currentTaskId: null, + taskCount: 0, + }, + { + name: 'bob', + providerId: 'codex', + role: 'Developer', + currentTaskId: null, + taskCount: 0, + }, + ], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }); + provisioningService.reattachOpenCodeOwnedMemberLane.mockRejectedValueOnce( + new Error('reattach failed') + ); + + const result = (await handler({} as never, 'my-team', { + name: 'alice', + role: 'developer', + providerId: 'opencode', + model: 'minimax-m2.5-free', + })) as { success: boolean; error?: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain('reattach failed'); + expect(service.addMember).toHaveBeenCalledWith('my-team', { + name: 'alice', + role: 'developer', + workflow: undefined, + isolation: undefined, + providerId: 'opencode', + model: 'minimax-m2.5-free', + effort: undefined, + }); + expect(service.replaceMembers).not.toHaveBeenCalled(); + expect(mockWriteMembersMeta).toHaveBeenCalledWith( + 'my-team', + [ + { + name: 'team-lead', + providerId: 'codex', + providerBackendId: 'codex-native', + role: 'Team Lead', + agentType: 'team-lead', + }, + { + name: 'bob', + providerId: 'codex', + providerBackendId: 'codex-native', + role: 'Developer', + agentType: 'general-purpose', + agentId: 'agent-bob', + }, + ], + { providerBackendId: 'codex-native' } + ); + expect(provisioningService.detachOpenCodeOwnedMemberLane).toHaveBeenCalledWith( + 'my-team', + 'alice' + ); + vi.mocked(console.error).mockClear(); + }); }); describe('updateConfig', () => { @@ -1793,6 +1938,418 @@ describe('ipc teams handlers', () => { const result = (await handler({} as never, 'my-team', '../bad')) as { success: boolean }; expect(result.success).toBe(false); }); + + it('blocks live removeMember for a running OpenCode-led team before metadata is changed', async () => { + const handler = handlers.get(TEAM_REMOVE_MEMBER)!; + service.getTeamData.mockResolvedValueOnce({ + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [ + { + name: 'team-lead', + providerId: 'opencode', + role: 'Team Lead', + currentTaskId: null, + taskCount: 0, + }, + { + name: 'alice', + providerId: 'opencode', + role: 'Developer', + currentTaskId: null, + taskCount: 0, + }, + ], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }); + + const result = (await handler({} as never, 'my-team', 'alice')) as { + success: boolean; + error?: string; + }; + + expect(result.success).toBe(false); + expect(result.error).toContain('running OpenCode-led team'); + expect(service.removeMember).not.toHaveBeenCalled(); + expect(provisioningService.detachOpenCodeOwnedMemberLane).not.toHaveBeenCalled(); + vi.mocked(console.error).mockClear(); + }); + + it('rolls back live OpenCode removeMember metadata when lane detach fails', async () => { + const handler = handlers.get(TEAM_REMOVE_MEMBER)!; + mockGetMembersMetaFile.mockResolvedValueOnce({ + version: 1, + providerBackendId: undefined, + members: [ + { + name: 'team-lead', + providerId: 'codex', + role: 'Team Lead', + agentType: 'team-lead', + }, + { + name: 'alice', + providerId: 'opencode', + model: 'nemotron-3-super-free', + role: 'Developer', + agentType: 'general-purpose', + agentId: 'agent-alice', + }, + ], + }); + service.getTeamData.mockResolvedValueOnce({ + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [ + { + name: 'team-lead', + providerId: 'codex', + role: 'Team Lead', + currentTaskId: null, + taskCount: 0, + }, + { + name: 'alice', + providerId: 'opencode', + model: 'nemotron-3-super-free', + role: 'Developer', + currentTaskId: null, + taskCount: 0, + }, + ], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }); + provisioningService.detachOpenCodeOwnedMemberLane.mockRejectedValueOnce( + new Error('detach failed') + ); + + const result = (await handler({} as never, 'my-team', 'alice')) as { + success: boolean; + error?: string; + }; + + expect(result.success).toBe(false); + expect(result.error).toContain('detach failed'); + expect(service.removeMember).toHaveBeenCalledWith('my-team', 'alice'); + expect(service.replaceMembers).not.toHaveBeenCalled(); + expect(mockWriteMembersMeta).toHaveBeenCalledWith( + 'my-team', + [ + { + name: 'team-lead', + providerId: 'codex', + role: 'Team Lead', + agentType: 'team-lead', + }, + { + name: 'alice', + providerId: 'opencode', + model: 'nemotron-3-super-free', + role: 'Developer', + agentType: 'general-purpose', + agentId: 'agent-alice', + }, + ], + { providerBackendId: undefined } + ); + expect(provisioningService.reattachOpenCodeOwnedMemberLane).toHaveBeenCalledWith( + 'my-team', + 'alice', + { reason: 'member_updated' } + ); + vi.mocked(console.error).mockClear(); + }); + }); + + describe('replaceMembers', () => { + it('blocks live replaceMembers for a running OpenCode-led team before metadata is changed', async () => { + const handler = handlers.get(TEAM_REPLACE_MEMBERS)!; + service.getTeamData.mockResolvedValueOnce({ + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [ + { + name: 'team-lead', + providerId: 'opencode', + role: 'Team Lead', + currentTaskId: null, + taskCount: 0, + }, + { + name: 'alice', + providerId: 'opencode', + role: 'Developer', + currentTaskId: null, + taskCount: 0, + }, + ], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }); + + const result = (await handler({} as never, 'my-team', { + members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }], + })) as { success: boolean; error?: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain('running OpenCode-led team'); + expect(service.replaceMembers).not.toHaveBeenCalled(); + expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled(); + expect(provisioningService.detachOpenCodeOwnedMemberLane).not.toHaveBeenCalled(); + vi.mocked(console.error).mockClear(); + }); + + it('rolls back live OpenCode replaceMembers metadata when lane reattach fails', async () => { + const handler = handlers.get(TEAM_REPLACE_MEMBERS)!; + mockGetMembersMetaFile.mockResolvedValueOnce({ + version: 1, + providerBackendId: 'codex-native', + members: [ + { + name: 'team-lead', + providerId: 'codex', + providerBackendId: 'codex-native', + role: 'Team Lead', + agentType: 'team-lead', + }, + { + name: 'alice', + providerId: 'opencode', + model: 'nemotron-3-super-free', + role: 'Developer', + agentType: 'general-purpose', + agentId: 'agent-alice', + }, + { + name: 'bob', + providerId: 'codex', + providerBackendId: 'codex-native', + role: 'Developer', + agentType: 'general-purpose', + agentId: 'agent-bob', + }, + ], + }); + service.getTeamData.mockResolvedValueOnce({ + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [ + { + name: 'team-lead', + providerId: 'codex', + role: 'Team Lead', + currentTaskId: null, + taskCount: 0, + }, + { + name: 'alice', + providerId: 'opencode', + model: 'nemotron-3-super-free', + role: 'Developer', + currentTaskId: null, + taskCount: 0, + }, + { + name: 'bob', + providerId: 'codex', + role: 'Developer', + currentTaskId: null, + taskCount: 0, + }, + ], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }); + provisioningService.reattachOpenCodeOwnedMemberLane.mockRejectedValueOnce( + new Error('reattach failed') + ); + + const result = (await handler({} as never, 'my-team', { + members: [ + { + name: 'alice', + role: 'Developer', + providerId: 'opencode', + model: 'minimax-m2.5-free', + }, + { + name: 'bob', + role: 'Developer', + providerId: 'codex', + }, + ], + })) as { success: boolean; error?: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain('reattach failed'); + expect(service.replaceMembers).toHaveBeenNthCalledWith(1, 'my-team', { + members: [ + { + name: 'alice', + role: 'Developer', + workflow: undefined, + isolation: undefined, + providerId: 'opencode', + providerBackendId: undefined, + model: 'minimax-m2.5-free', + effort: undefined, + fastMode: undefined, + }, + { + name: 'bob', + role: 'Developer', + workflow: undefined, + isolation: undefined, + providerId: 'codex', + providerBackendId: undefined, + model: undefined, + effort: undefined, + fastMode: undefined, + }, + ], + }); + expect(service.replaceMembers).toHaveBeenCalledTimes(1); + expect(mockWriteMembersMeta).toHaveBeenCalledWith( + 'my-team', + [ + { + name: 'team-lead', + providerId: 'codex', + providerBackendId: 'codex-native', + role: 'Team Lead', + agentType: 'team-lead', + }, + { + name: 'alice', + providerId: 'opencode', + model: 'nemotron-3-super-free', + role: 'Developer', + agentType: 'general-purpose', + agentId: 'agent-alice', + }, + { + name: 'bob', + providerId: 'codex', + providerBackendId: 'codex-native', + role: 'Developer', + agentType: 'general-purpose', + agentId: 'agent-bob', + }, + ], + { providerBackendId: 'codex-native' } + ); + expect(provisioningService.reattachOpenCodeOwnedMemberLane).toHaveBeenNthCalledWith( + 1, + 'my-team', + 'alice', + { reason: 'member_updated' } + ); + expect(provisioningService.reattachOpenCodeOwnedMemberLane).toHaveBeenNthCalledWith( + 2, + 'my-team', + 'alice', + { reason: 'member_updated' } + ); + vi.mocked(console.error).mockClear(); + }); + + it('blocks live replaceMembers when a member migrates from primary runtime ownership to OpenCode', async () => { + const handler = handlers.get(TEAM_REPLACE_MEMBERS)!; + service.getTeamData.mockResolvedValueOnce({ + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [ + { + name: 'team-lead', + providerId: 'codex', + role: 'Team Lead', + currentTaskId: null, + taskCount: 0, + }, + { + name: 'alice', + providerId: 'codex', + role: 'Developer', + currentTaskId: null, + taskCount: 0, + }, + ], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }); + + const result = (await handler({} as never, 'my-team', { + members: [ + { + name: 'alice', + role: 'Developer', + providerId: 'opencode', + model: 'minimax-m2.5-free', + }, + ], + })) as { success: boolean; error?: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain('Live member migration between OpenCode and the primary runtime owner'); + expect(result.error).toContain('alice'); + expect(service.replaceMembers).not.toHaveBeenCalled(); + expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled(); + expect(provisioningService.detachOpenCodeOwnedMemberLane).not.toHaveBeenCalled(); + vi.mocked(console.error).mockClear(); + }); + + it('blocks live replaceMembers when a member migrates from OpenCode to primary runtime ownership', async () => { + const handler = handlers.get(TEAM_REPLACE_MEMBERS)!; + service.getTeamData.mockResolvedValueOnce({ + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [ + { + name: 'team-lead', + providerId: 'codex', + role: 'Team Lead', + currentTaskId: null, + taskCount: 0, + }, + { + name: 'alice', + providerId: 'opencode', + model: 'nemotron-3-super-free', + role: 'Developer', + currentTaskId: null, + taskCount: 0, + }, + ], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }); + + const result = (await handler({} as never, 'my-team', { + members: [ + { + name: 'alice', + role: 'Developer', + providerId: 'codex', + }, + ], + })) as { success: boolean; error?: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain('Live member migration between OpenCode and the primary runtime owner'); + expect(result.error).toContain('alice'); + expect(service.replaceMembers).not.toHaveBeenCalled(); + expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled(); + expect(provisioningService.detachOpenCodeOwnedMemberLane).not.toHaveBeenCalled(); + vi.mocked(console.error).mockClear(); + }); }); describe('updateMemberRole', () => { diff --git a/test/main/services/team/OpenCodeProductionE2EEvidence.test.ts b/test/main/services/team/OpenCodeProductionE2EEvidence.test.ts index 8bbb961c..d8c6159c 100644 --- a/test/main/services/team/OpenCodeProductionE2EEvidence.test.ts +++ b/test/main/services/team/OpenCodeProductionE2EEvidence.test.ts @@ -30,7 +30,7 @@ describe('OpenCodeProductionE2EEvidence', () => { await fs.rm(tempDir, { recursive: true, force: true }); }); - it('accepts strict evidence only when runtime identity, model and required MCP tools match', () => { + it('accepts production evidence when runtime identity, project context and required MCP tools match', () => { const evidence = passingEvidence(); expect(validateOpenCodeProductionE2EEvidence(evidence)).toEqual(evidence); @@ -85,7 +85,6 @@ describe('OpenCodeProductionE2EEvidence', () => { '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', - 'OpenCode production E2E evidence model openrouter/anthropic/claude-sonnet-4.5 does not match selected model openai/gpt-5.4-mini. Production launch is intentionally scoped to the exact raw model id; regenerate evidence with OPENCODE_E2E_MODEL=openai/gpt-5.4-mini.', ]), }); }); @@ -139,7 +138,7 @@ describe('OpenCodeProductionE2EEvidence', () => { }); }); - it('stores production evidence for multiple raw model ids and reads exact model matches', async () => { + 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, @@ -174,6 +173,87 @@ describe('OpenCodeProductionE2EEvidence', () => { }); }); + 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({ @@ -218,9 +298,7 @@ describe('OpenCodeProductionE2EEvidence', () => { ).resolves.toMatchObject({ ok: true, evidence: null, - diagnostics: [ - 'OpenCode production E2E evidence artifact has no entry for selected model opencode/minimax-m2.5-free and the current working directory', - ], + diagnostics: ['OpenCode production E2E evidence artifact has no entry for the current working directory'], }); }); }); diff --git a/test/main/services/team/OpenCodeProductionGate.live.test.ts b/test/main/services/team/OpenCodeProductionGate.live.test.ts index 7fa3b1e2..c614b8d5 100644 --- a/test/main/services/team/OpenCodeProductionGate.live.test.ts +++ b/test/main/services/team/OpenCodeProductionGate.live.test.ts @@ -126,6 +126,7 @@ liveDescribe('OpenCode production gate live e2e', () => { launch = await readinessBridge.launchOpenCodeTeam({ mode: 'dogfood', runId, + laneId: 'primary', teamId: teamName, teamName, projectPath: PROJECT_PATH, @@ -147,6 +148,7 @@ liveDescribe('OpenCode production gate live e2e', () => { reconcile = await readinessBridge.reconcileOpenCodeTeam({ runId, + laneId: 'primary', teamId: teamName, teamName, projectPath: PROJECT_PATH, @@ -158,11 +160,11 @@ liveDescribe('OpenCode production gate live e2e', () => { expect(reconcile.teamLaunchState).toBe('ready'); const transcript = await bridgeClient.execute< - { teamId: string; teamName: string; memberName: string }, + { teamId: string; teamName: string; laneId: string; memberName: string }, { logProjection?: { messages?: unknown[] }; messages?: unknown[] } >( 'opencode.getRuntimeTranscript', - { teamId: teamName, teamName, memberName }, + { teamId: teamName, teamName, laneId: 'primary', memberName }, { cwd: PROJECT_PATH, timeoutMs: 60_000 } ); expect(transcript.ok).toBe(true); @@ -181,6 +183,7 @@ liveDescribe('OpenCode production gate live e2e', () => { stop = await readinessBridge.stopOpenCodeTeam({ runId, + laneId: 'primary', teamId: teamName, teamName, projectPath: PROJECT_PATH, @@ -247,6 +250,7 @@ liveDescribe('OpenCode production gate live e2e', () => { await readinessBridge .stopOpenCodeTeam({ runId, + laneId: 'primary', teamId: teamName, teamName, projectPath: PROJECT_PATH, @@ -326,11 +330,13 @@ async function rejectsStaleCapability(input: { 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, diff --git a/test/main/services/team/OpenCodeReadinessBridge.test.ts b/test/main/services/team/OpenCodeReadinessBridge.test.ts index 2d6bb09f..5aa66bbe 100644 --- a/test/main/services/team/OpenCodeReadinessBridge.test.ts +++ b/test/main/services/team/OpenCodeReadinessBridge.test.ts @@ -144,7 +144,7 @@ describe('OpenCodeReadinessBridge', () => { }); }); - it('keeps production readiness open when evidence matches runtime identity and raw model', async () => { + it('keeps production readiness open when evidence matches runtime identity and project context', async () => { const executor = fakeExecutor( bridgeSuccess(readiness({ state: 'ready', launchAllowed: true })) ); @@ -169,6 +169,35 @@ describe('OpenCodeReadinessBridge', () => { 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: [], }); }); @@ -204,6 +233,7 @@ describe('OpenCodeReadinessBridge', () => { bridge.launchOpenCodeTeam({ mode: 'dogfood', runId: 'run-1', + laneId: 'primary', teamId: 'team-a', teamName: 'team-a', projectPath: '/repo', @@ -223,6 +253,7 @@ describe('OpenCodeReadinessBridge', () => { expect.objectContaining({ command: 'opencode.launchTeam', teamName: 'team-a', + laneId: 'primary', runId: 'run-1', capabilitySnapshotId: 'cap-1', cwd: '/repo', @@ -327,6 +358,7 @@ function readiness( state: 'adapter_disabled', launchAllowed: false, modelId: 'openai/gpt-5.4-mini', + availableModels: ['openai/gpt-5.4-mini'], opencodeVersion: '1.14.19', installMethod: 'brew', binaryPath: '/opt/homebrew/bin/opencode', diff --git a/test/main/services/team/OpenCodeRuntimeManifestEvidenceReader.test.ts b/test/main/services/team/OpenCodeRuntimeManifestEvidenceReader.test.ts new file mode 100644 index 00000000..87b00cc2 --- /dev/null +++ b/test/main/services/team/OpenCodeRuntimeManifestEvidenceReader.test.ts @@ -0,0 +1,241 @@ +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 { + OpenCodeRuntimeManifestEvidenceReader, + getOpenCodeLaneScopedRuntimeFilePath, + getOpenCodeRuntimeLaneIndexPath, + getOpenCodeTeamRuntimeDirectory, + migrateLegacyOpenCodeRuntimeState, + readOpenCodeRuntimeLaneIndex, + upsertOpenCodeRuntimeLaneIndexEntry, +} from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader'; + +describe('OpenCodeRuntimeManifestEvidenceReader migration', () => { + let tempDir: string; + let now: Date; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-runtime-migration-')); + now = new Date('2026-04-22T10:00:00.000Z'); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('migrates legacy team-scoped OpenCode runtime files into the addressed lane', async () => { + const teamName = 'team-alpha'; + const laneId = 'secondary:opencode:alice'; + const runtimeDir = getOpenCodeTeamRuntimeDirectory(tempDir, teamName); + + await fs.mkdir(runtimeDir, { recursive: true }); + await fs.writeFile(path.join(runtimeDir, 'manifest.json'), '{"highWatermark":7}\n', 'utf8'); + await fs.writeFile( + path.join(runtimeDir, 'opencode-launch-transaction.json'), + '{"transactionId":"tx-1"}\n', + 'utf8' + ); + + const result = await migrateLegacyOpenCodeRuntimeState({ + teamsBasePath: tempDir, + teamName, + laneId, + clock: () => now, + }); + + expect(result).toEqual({ + migrated: true, + degraded: false, + diagnostics: ['migrated 2 legacy OpenCode runtime files'], + }); + + await expect(fs.readFile(path.join(runtimeDir, 'manifest.json'), 'utf8')).rejects.toThrow(); + await expect( + fs.readFile(path.join(runtimeDir, 'opencode-launch-transaction.json'), 'utf8') + ).rejects.toThrow(); + + await expect( + fs.readFile( + getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempDir, + teamName, + laneId, + fileName: 'manifest.json', + }), + 'utf8' + ) + ).resolves.toBe('{"highWatermark":7}\n'); + await expect( + fs.readFile( + getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempDir, + teamName, + laneId, + fileName: 'opencode-launch-transaction.json', + }), + 'utf8' + ) + ).resolves.toBe('{"transactionId":"tx-1"}\n'); + + await expect(fs.readFile(getOpenCodeRuntimeLaneIndexPath(tempDir, teamName), 'utf8')).resolves.toContain( + `"${laneId}"` + ); + await expect(readOpenCodeRuntimeLaneIndex(tempDir, teamName)).resolves.toMatchObject({ + lanes: { + [laneId]: { + laneId, + state: 'active', + diagnostics: [ + `migrated legacy team-scoped OpenCode runtime state at ${now.toISOString()}`, + ], + }, + }, + }); + }); + + it('marks ambiguous legacy runtime state as degraded instead of guessing a lane', async () => { + const teamName = 'team-beta'; + const laneId = 'secondary:opencode:alice'; + const otherLaneId = 'secondary:opencode:bob'; + const runtimeDir = getOpenCodeTeamRuntimeDirectory(tempDir, teamName); + + await fs.mkdir(runtimeDir, { recursive: true }); + await fs.writeFile(path.join(runtimeDir, 'manifest.json'), '{"highWatermark":11}\n', 'utf8'); + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: tempDir, + teamName, + laneId: otherLaneId, + state: 'active', + }); + + const result = await migrateLegacyOpenCodeRuntimeState({ + teamsBasePath: tempDir, + teamName, + laneId, + clock: () => now, + }); + + expect(result.migrated).toBe(false); + expect(result.degraded).toBe(true); + expect(result.diagnostics).toEqual([ + `Legacy OpenCode runtime state is ambiguous for ${teamName}; existing lanes: ${otherLaneId}`, + ]); + + await expect(fs.readFile(path.join(runtimeDir, 'manifest.json'), 'utf8')).resolves.toBe( + '{"highWatermark":11}\n' + ); + await expect( + fs.readFile( + getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempDir, + teamName, + laneId, + fileName: 'manifest.json', + }), + 'utf8' + ) + ).rejects.toThrow(); + + await expect(readOpenCodeRuntimeLaneIndex(tempDir, teamName)).resolves.toMatchObject({ + lanes: { + [otherLaneId]: { + laneId: otherLaneId, + state: 'active', + }, + [laneId]: { + laneId, + state: 'degraded', + diagnostics: [ + `Legacy OpenCode runtime state is ambiguous for ${teamName}; existing lanes: ${otherLaneId}`, + ], + }, + }, + }); + }); + + it('does not fall back to team-scoped legacy manifest when sibling lane metadata already exists', async () => { + const teamName = 'team-gamma'; + const laneId = 'secondary:opencode:alice'; + const otherLaneId = 'secondary:opencode:bob'; + const runtimeDir = getOpenCodeTeamRuntimeDirectory(tempDir, teamName); + const reader = new OpenCodeRuntimeManifestEvidenceReader({ teamsBasePath: tempDir }); + + await fs.mkdir(runtimeDir, { recursive: true }); + await fs.writeFile( + path.join( + runtimeDir, + 'manifest.json' + ), + JSON.stringify({ + schemaVersion: 1, + updatedAt: '2026-04-22T10:00:00.000Z', + data: { + schemaVersion: 1, + teamName, + activeRunId: 'legacy-run', + activeCapabilitySnapshotId: 'cap-1', + activeBehaviorFingerprint: null, + highWatermark: 11, + lastCommittedBatchId: null, + lastPreparingBatchId: null, + entries: [], + lastRecoveryPlanId: null, + updatedAt: '2026-04-22T10:00:00.000Z', + }, + }), + 'utf8' + ); + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: tempDir, + teamName, + laneId: otherLaneId, + state: 'active', + }); + + await expect(reader.read(teamName, laneId)).resolves.toEqual({ + highWatermark: 0, + activeRunId: null, + capabilitySnapshotId: null, + }); + }); + + it('still falls back to team-scoped legacy manifest for safe single-lane backward compatibility', async () => { + const teamName = 'team-delta'; + const laneId = 'secondary:opencode:alice'; + const runtimeDir = getOpenCodeTeamRuntimeDirectory(tempDir, teamName); + const reader = new OpenCodeRuntimeManifestEvidenceReader({ teamsBasePath: tempDir }); + + await fs.mkdir(runtimeDir, { recursive: true }); + await fs.writeFile( + path.join(runtimeDir, 'manifest.json'), + JSON.stringify({ + schemaVersion: 1, + updatedAt: '2026-04-22T10:00:00.000Z', + data: { + schemaVersion: 1, + teamName, + activeRunId: 'legacy-run', + activeCapabilitySnapshotId: 'cap-1', + activeBehaviorFingerprint: null, + highWatermark: 11, + lastCommittedBatchId: null, + lastPreparingBatchId: null, + entries: [], + lastRecoveryPlanId: null, + updatedAt: '2026-04-22T10:00:00.000Z', + }, + }), + 'utf8' + ); + + await expect(reader.read(teamName, laneId)).resolves.toEqual({ + highWatermark: 11, + activeRunId: 'legacy-run', + capabilitySnapshotId: 'cap-1', + }); + }); +}); diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index 7bf64b11..15e1befb 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -223,6 +223,7 @@ function readiness( state: 'adapter_disabled', launchAllowed: false, modelId: 'openai/gpt-5.4-mini', + availableModels: ['openai/gpt-5.4-mini'], opencodeVersion: '1.14.19', installMethod: 'brew', binaryPath: '/opt/homebrew/bin/opencode', diff --git a/test/main/services/team/TeamBackupService.test.ts b/test/main/services/team/TeamBackupService.test.ts new file mode 100644 index 00000000..26c8c2d6 --- /dev/null +++ b/test/main/services/team/TeamBackupService.test.ts @@ -0,0 +1,233 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const hoisted = vi.hoisted(() => ({ + teamsBase: '', + backupsBase: '', + appDataPath: '', + tasksBase: '', +})); + +vi.mock('../../../../src/main/utils/pathDecoder', () => ({ + getTeamsBasePath: () => hoisted.teamsBase, + getBackupsBasePath: () => hoisted.backupsBase, + getAppDataPath: () => hoisted.appDataPath, + getTasksBasePath: () => hoisted.tasksBase, +})); + +import { TeamBackupService } from '../../../../src/main/services/team/TeamBackupService'; + +describe('TeamBackupService', () => { + let tempDir = ''; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-backup-service-')); + hoisted.teamsBase = path.join(tempDir, 'teams'); + hoisted.backupsBase = path.join(tempDir, 'backups'); + hoisted.appDataPath = path.join(tempDir, 'app-data'); + hoisted.tasksBase = path.join(tempDir, 'tasks'); + + await fs.mkdir(hoisted.teamsBase, { recursive: true }); + await fs.mkdir(hoisted.backupsBase, { recursive: true }); + await fs.mkdir(hoisted.appDataPath, { recursive: true }); + await fs.mkdir(hoisted.tasksBase, { recursive: true }); + }); + + afterEach(async () => { + hoisted.teamsBase = ''; + hoisted.backupsBase = ''; + hoisted.appDataPath = ''; + hoisted.tasksBase = ''; + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it('backs up and restores additive mixed-lane metadata and launch snapshots', async () => { + const service = new TeamBackupService(); + const teamName = 'mixed-team'; + const teamDir = path.join(hoisted.teamsBase, teamName); + await fs.mkdir(teamDir, { recursive: true }); + + const config = { + name: 'Mixed Team', + projectPath: '/tmp/project', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }; + const teamMeta = { + version: 1, + cwd: '/tmp/project', + providerId: 'codex', + providerBackendId: 'codex-native', + fastMode: 'off', + createdAt: Date.now(), + }; + const membersMeta = { + version: 1, + providerBackendId: 'codex-native', + members: [ + { name: 'alice', providerId: 'codex', role: 'reviewer' }, + { + name: 'tom', + providerId: 'opencode', + providerBackendId: 'opencode-cli', + model: 'minimax-m2.5-free', + fastMode: 'inherit', + role: 'developer', + }, + ], + }; + const launchState = { + version: 2, + teamName, + updatedAt: '2026-04-22T12:00:00.000Z', + launchPhase: 'active', + expectedMembers: ['alice', 'tom'], + bootstrapExpectedMembers: ['alice'], + members: { + alice: { + name: 'alice', + providerId: 'codex', + providerBackendId: 'codex-native', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + lastEvaluatedAt: '2026-04-22T12:00:00.000Z', + }, + tom: { + name: 'tom', + providerId: 'opencode', + providerBackendId: 'opencode-cli', + laneId: 'secondary:opencode:tom', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'starting', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + lastEvaluatedAt: '2026-04-22T12:00:00.000Z', + }, + }, + summary: { + confirmedCount: 1, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 0, + }, + teamLaunchState: 'partial_pending', + }; + const launchSummary = { + version: 1, + teamName, + updatedAt: '2026-04-22T12:00:00.000Z', + mixedAware: true, + expectedMemberCount: 2, + confirmedMemberCount: 1, + pendingCount: 1, + failedCount: 0, + teamLaunchState: 'partial_pending', + launchUpdatedAt: '2026-04-22T12:00:00.000Z', + }; + const runtimeLaneDir = path.join( + teamDir, + '.opencode-runtime', + 'lanes', + encodeURIComponent('secondary:opencode:tom') + ); + const runtimeLaneIndex = { + version: 1, + updatedAt: '2026-04-22T12:00:00.000Z', + lanes: { + 'secondary:opencode:tom': { + laneId: 'secondary:opencode:tom', + state: 'active', + updatedAt: '2026-04-22T12:00:00.000Z', + diagnostics: [], + }, + }, + }; + const runtimeManifest = { + schemaVersion: 1, + highWatermark: 12, + activeRunId: 'lane-run-1', + capabilitySnapshotId: 'cap-1', + }; + + await fs.writeFile(path.join(teamDir, 'config.json'), JSON.stringify(config), 'utf8'); + await fs.writeFile(path.join(teamDir, 'team.meta.json'), JSON.stringify(teamMeta), 'utf8'); + await fs.writeFile( + path.join(teamDir, 'members.meta.json'), + JSON.stringify(membersMeta), + 'utf8' + ); + await fs.writeFile( + path.join(teamDir, 'launch-state.json'), + JSON.stringify(launchState), + 'utf8' + ); + await fs.writeFile( + path.join(teamDir, 'launch-summary.json'), + JSON.stringify(launchSummary), + 'utf8' + ); + await fs.mkdir(runtimeLaneDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, '.opencode-runtime', 'lanes.json'), + JSON.stringify(runtimeLaneIndex), + 'utf8' + ); + await fs.writeFile( + path.join(runtimeLaneDir, 'runtime-store-manifest.json'), + JSON.stringify(runtimeManifest), + 'utf8' + ); + + await service.initialize(); + await service.backupTeam(teamName); + + await fs.rm(teamDir, { recursive: true, force: true }); + + const restored = await service.restoreIfNeeded(); + service.dispose(); + + expect(restored).toContain(teamName); + + const restoredMembersMeta = JSON.parse( + await fs.readFile(path.join(teamDir, 'members.meta.json'), 'utf8') + ); + const restoredLaunchState = JSON.parse( + await fs.readFile(path.join(teamDir, 'launch-state.json'), 'utf8') + ); + const restoredLaunchSummary = JSON.parse( + await fs.readFile(path.join(teamDir, 'launch-summary.json'), 'utf8') + ); + const restoredTeamMeta = JSON.parse( + await fs.readFile(path.join(teamDir, 'team.meta.json'), 'utf8') + ); + const restoredRuntimeLaneIndex = JSON.parse( + await fs.readFile(path.join(teamDir, '.opencode-runtime', 'lanes.json'), 'utf8') + ); + const restoredRuntimeManifest = JSON.parse( + await fs.readFile(path.join(runtimeLaneDir, 'runtime-store-manifest.json'), 'utf8') + ); + + expect(restoredTeamMeta.providerId).toBe('codex'); + expect(restoredMembersMeta.members).toEqual(membersMeta.members); + expect(restoredLaunchState.bootstrapExpectedMembers).toEqual(['alice']); + expect(restoredLaunchState.members.tom.laneKind).toBe('secondary'); + expect(restoredLaunchState.members.tom.laneOwnerProviderId).toBe('opencode'); + expect(restoredLaunchSummary.mixedAware).toBe(true); + expect(restoredLaunchSummary.teamLaunchState).toBe('partial_pending'); + expect(restoredRuntimeLaneIndex.lanes['secondary:opencode:tom'].state).toBe('active'); + expect(restoredRuntimeManifest.activeRunId).toBe('lane-run-1'); + }); +}); diff --git a/test/main/services/team/TeamBootstrapStateReader.test.ts b/test/main/services/team/TeamBootstrapStateReader.test.ts index d83a5167..48c31258 100644 --- a/test/main/services/team/TeamBootstrapStateReader.test.ts +++ b/test/main/services/team/TeamBootstrapStateReader.test.ts @@ -477,4 +477,49 @@ describe('TeamBootstrapStateReader', () => { kind: 'launch', }); }); + + it('ignores stale terminal bootstrap-only pending snapshots when canonical launch state is missing', () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-04-22T15:00:00.000Z')); + + const preferred = choosePreferredLaunchSnapshot( + { + version: 2, + teamName: 'atlas-hq-2', + updatedAt: '2026-04-09T20:35:57.962Z', + launchPhase: 'finished', + expectedMembers: ['alice', 'jack'], + members: { + alice: { + name: 'alice', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + lastEvaluatedAt: '2026-04-09T20:35:57.962Z', + }, + jack: { + name: 'jack', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + lastEvaluatedAt: '2026-04-09T20:35:57.962Z', + }, + }, + summary: { + confirmedCount: 0, + pendingCount: 2, + failedCount: 0, + runtimeAlivePendingCount: 0, + }, + teamLaunchState: 'partial_pending', + }, + null + ); + + expect(preferred).toBeNull(); + nowSpy.mockRestore(); + }); }); diff --git a/test/main/services/team/TeamConfigReader.test.ts b/test/main/services/team/TeamConfigReader.test.ts new file mode 100644 index 00000000..c8ff3138 --- /dev/null +++ b/test/main/services/team/TeamConfigReader.test.ts @@ -0,0 +1,257 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const hoisted = vi.hoisted(() => ({ + teamsBase: '', +})); + +vi.mock('../../../../src/main/utils/pathDecoder', () => ({ + getTeamsBasePath: () => hoisted.teamsBase, +})); + +vi.mock('../../../../src/main/services/team/TeamFsWorkerClient', () => ({ + getTeamFsWorkerClient: () => ({ + isAvailable: () => false, + }), +})); + +import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader'; +import { createPersistedLaunchSummaryProjection } from '../../../../src/main/services/team/TeamLaunchSummaryProjection'; + +describe('TeamConfigReader', () => { + let tempDir = ''; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-config-reader-')); + hoisted.teamsBase = tempDir; + }); + + afterEach(async () => { + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + } + hoisted.teamsBase = ''; + }); + + it('uses compact launch summary projection when launch-state.json is oversized', async () => { + const teamName = 'mixed-team'; + const teamDir = path.join(tempDir, teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: 'Mixed Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + await fs.writeFile(path.join(teamDir, 'launch-state.json'), 'x'.repeat(40 * 1024), 'utf8'); + await fs.writeFile( + path.join(teamDir, 'launch-summary.json'), + JSON.stringify( + createPersistedLaunchSummaryProjection({ + version: 2, + teamName, + updatedAt: '2026-04-22T12:00:00.000Z', + launchPhase: 'finished', + expectedMembers: ['alice', 'bob'], + bootstrapExpectedMembers: ['alice'], + members: { + alice: { + name: 'alice', + providerId: 'codex', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + lastEvaluatedAt: '2026-04-22T12:00:00.000Z', + }, + bob: { + name: 'bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'failed_to_start', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'Side lane failed', + lastEvaluatedAt: '2026-04-22T12:00:00.000Z', + }, + }, + summary: { + confirmedCount: 1, + pendingCount: 0, + failedCount: 1, + runtimeAlivePendingCount: 0, + }, + teamLaunchState: 'partial_failure', + } as never), + null, + 2 + ), + 'utf8' + ); + await fs.writeFile( + path.join(teamDir, 'bootstrap-state.json'), + JSON.stringify({ + version: 1, + teamName, + runId: 'bootstrap-run-1', + ownerPid: process.pid, + startedAt: Date.parse('2026-04-22T12:01:00.000Z'), + updatedAt: Date.parse('2026-04-22T12:01:00.000Z'), + phase: 'spawning_members', + members: [{ name: 'alice', status: 'pending' }], + }), + 'utf8' + ); + + const reader = new TeamConfigReader(); + const teams = await reader.listTeams(); + expect(teams).toHaveLength(1); + expect(teams[0]).toMatchObject({ + teamName, + displayName: 'Mixed Team', + partialLaunchFailure: true, + expectedMemberCount: 2, + confirmedMemberCount: 1, + missingMembers: ['bob'], + teamLaunchState: 'partial_failure', + confirmedCount: 1, + pendingCount: 0, + failedCount: 1, + }); + }); + + it('does not invent a partial-failure summary from artifact counts for mixed-aware teams when canonical launch truth is unavailable', async () => { + const teamName = 'mixed-aware-team'; + const teamDir = path.join(tempDir, teamName); + await fs.mkdir(path.join(teamDir, 'inboxes'), { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: 'Mixed Aware Team', + leadSessionId: 'lead-session-1', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + await fs.writeFile( + path.join(teamDir, 'team.meta.json'), + JSON.stringify({ + version: 1, + cwd: tempDir, + providerId: 'codex', + createdAt: Date.now(), + }), + 'utf8' + ); + await fs.writeFile( + path.join(teamDir, 'members.meta.json'), + JSON.stringify({ + version: 1, + members: [ + { name: 'alice', providerId: 'codex', role: 'reviewer' }, + { name: 'tom', providerId: 'opencode', role: 'developer' }, + ], + }), + 'utf8' + ); + await fs.writeFile(path.join(teamDir, 'inboxes', 'alice.json'), '{}', 'utf8'); + + const reader = new TeamConfigReader(); + const teams = await reader.listTeams(); + expect(teams).toHaveLength(1); + expect(teams[0]).toMatchObject({ + teamName, + displayName: 'Mixed Aware Team', + memberCount: 2, + }); + expect(teams[0]?.partialLaunchFailure).toBeUndefined(); + expect(teams[0]?.teamLaunchState).toBeUndefined(); + expect(teams[0]?.missingMembers).toBeUndefined(); + }); + + it('does not let a removed base member hide an active auto-suffixed teammate in team summaries', async () => { + const teamName = 'suffix-team'; + const teamDir = path.join(tempDir, teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: 'Suffix Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + await fs.writeFile( + path.join(teamDir, 'members.meta.json'), + JSON.stringify({ + version: 1, + members: [ + { name: 'alice', role: 'developer', removedAt: Date.now() - 60_000 }, + { name: 'alice-2', role: 'reviewer' }, + ], + }), + 'utf8' + ); + + const reader = new TeamConfigReader(); + const teams = await reader.listTeams(); + expect(teams).toHaveLength(1); + expect(teams[0]).toMatchObject({ + teamName, + displayName: 'Suffix Team', + memberCount: 1, + members: [{ name: 'alice-2', role: 'reviewer' }], + }); + }); + + it('counts only active non-lead teammates for draft team summaries', async () => { + const teamName = 'draft-summary-team'; + const teamDir = path.join(tempDir, teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'team.meta.json'), + JSON.stringify({ + version: 1, + cwd: tempDir, + displayName: 'Draft Summary Team', + createdAt: Date.parse('2026-04-22T12:00:00.000Z'), + }), + 'utf8' + ); + await fs.writeFile( + path.join(teamDir, 'members.meta.json'), + JSON.stringify({ + version: 1, + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', removedAt: Date.now() - 60_000 }, + { name: 'bob', role: 'developer' }, + ], + }), + 'utf8' + ); + + const reader = new TeamConfigReader(); + const teams = await reader.listTeams(); + expect(teams).toHaveLength(1); + expect(teams[0]).toMatchObject({ + teamName, + displayName: 'Draft Summary Team', + memberCount: 1, + pendingCreate: true, + }); + }); +}); diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 8dfce6ef..e5aeae7b 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -9,6 +9,7 @@ import { encodePath, setClaudeBasePathOverride } from '../../../../src/main/util import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader'; import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils'; import { TeamDataService } from '../../../../src/main/services/team/TeamDataService'; +import type { TeamMetaFile } from '../../../../src/main/services/team/TeamMetaStore'; import type { InboxMessage, @@ -333,6 +334,7 @@ function createGetTeamDataHarness(options: { listInboxNames?: () => Promise; getMessages?: () => Promise; getMembers?: () => Promise; + getTeamMeta?: () => Promise; getState?: () => Promise; readMessages?: () => Promise; resolveMembers?: ( @@ -367,6 +369,11 @@ function createGetTeamDataHarness(options: { (async () => { return [] as TeamConfig['members']; }); + const getTeamMeta = + options.getTeamMeta ?? + (async () => { + return null; + }); const getState = options.getState ?? (async () => { @@ -395,6 +402,9 @@ function createGetTeamDataHarness(options: { const membersMetaStore = { getMembers: vi.fn(getMembers), }; + const teamMetaStore = { + getMeta: vi.fn(getTeamMeta), + }; const sentMessagesStore = { readMessages: vi.fn(readMessages), }; @@ -431,7 +441,7 @@ function createGetTeamDataHarness(options: { }, }) as never) as never, {} as never, - {} as never, + teamMetaStore as never, advisoryService as never ); @@ -441,6 +451,7 @@ function createGetTeamDataHarness(options: { taskReader, inboxReader, membersMetaStore, + teamMetaStore, sentMessagesStore, resolveMembersSpy, kanbanManager, @@ -630,6 +641,262 @@ describe('TeamDataService', () => { expect(writtenMembers.find((member) => member.name === 'bob')?.isolation).toBeUndefined(); }); + it('persists member-level provider backend and fast mode during replaceMembers', async () => { + const writeMembers = vi.fn(async () => {}); + const membersMetaStore = { + getMembers: vi.fn(async () => []), + writeMembers, + } as never; + + const service = new TeamDataService( + { getConfig: vi.fn(), listTeams: vi.fn() } as never, + { getTasks: vi.fn(async () => []) } as never, + { listInboxNames: vi.fn(async () => []), getMessages: vi.fn(async () => []) } as never, + {} as never, + {} as never, + { resolveMembers: vi.fn(() => []) } as never, + { + getState: vi.fn(async () => ({ teamName: 'runtime-team', reviewers: [], tasks: {} })), + } as never, + {} as never, + membersMetaStore, + { readMessages: vi.fn(async () => []) } as never, + (() => ({ processes: { listProcesses: vi.fn(async () => []) } }) as never) as never, + {} as never, + { getMeta: vi.fn(async () => ({ providerId: 'codex' })) } as never + ); + + await service.replaceMembers('runtime-team', { + members: [ + { + name: 'alice', + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + effort: 'high', + fastMode: 'on', + }, + ], + }); + + expect(writeMembers).toHaveBeenCalledWith( + 'runtime-team', + expect.arrayContaining([ + expect.objectContaining({ + name: 'alice', + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + effort: 'high', + fastMode: 'on', + }), + ]) + ); + }); + + it('allows multiple OpenCode teammates in replaceMembers drafts before they are persisted', async () => { + const writeMembers = vi.fn(async () => {}); + const membersMetaStore = { + getMembers: vi.fn(async () => []), + writeMembers, + } as never; + + const service = new TeamDataService( + { getConfig: vi.fn(), listTeams: vi.fn() } as never, + { getTasks: vi.fn(async () => []) } as never, + { listInboxNames: vi.fn(async () => []), getMessages: vi.fn(async () => []) } as never, + {} as never, + {} as never, + { resolveMembers: vi.fn(() => []) } as never, + { + getState: vi.fn(async () => ({ teamName: 'runtime-team', reviewers: [], tasks: {} })), + } as never, + {} as never, + membersMetaStore, + { readMessages: vi.fn(async () => []) } as never, + (() => ({ processes: { listProcesses: vi.fn(async () => []) } }) as never) as never, + {} as never, + { getMeta: vi.fn(async () => ({ providerId: 'codex' })) } as never + ); + + await expect( + service.replaceMembers('runtime-team', { + members: [ + { name: 'alice', providerId: 'opencode', model: 'minimax-m2.5-free' }, + { name: 'bob', providerId: 'opencode', model: 'nemotron-3-super-free' }, + ], + }) + ).resolves.toBeUndefined(); + + expect(writeMembers).toHaveBeenCalledTimes(1); + }); + + it('blocks live addMember on a running mixed team', async () => { + const writeMembers = vi.fn(async () => {}); + const membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'alice', + role: 'Reviewer', + providerId: 'opencode', + model: 'minimax-m2.5-free', + agentType: 'general-purpose', + }, + ]), + writeMembers, + } as never; + + const service = new TeamDataService( + { getConfig: vi.fn(), listTeams: vi.fn() } as never, + { getTasks: vi.fn(async () => []) } as never, + { listInboxNames: vi.fn(async () => []), getMessages: vi.fn(async () => []) } as never, + {} as never, + {} as never, + { resolveMembers: vi.fn(() => []) } as never, + { + getState: vi.fn(async () => ({ teamName: 'mixed-team', reviewers: [], tasks: {} })), + } as never, + {} as never, + membersMetaStore, + { readMessages: vi.fn(async () => []) } as never, + (() => + ({ + processes: { + listProcesses: vi.fn(async () => [ + { + id: 'run-1', + label: 'mixed-team', + pid: 123, + registeredAt: new Date().toISOString(), + }, + ]), + }, + }) as never) as never, + {} as never, + { getMeta: vi.fn(async () => ({ providerId: 'codex' })) } as never + ); + + await expect( + service.addMember('mixed-team', { + name: 'bob', + role: 'Developer', + providerId: 'codex', + model: 'gpt-5.4', + effort: 'medium', + }) + ).rejects.toThrow( + 'Live roster mutation on a running mixed team is not supported in V1. Stop the team, edit the roster, then relaunch.' + ); + + expect(writeMembers).not.toHaveBeenCalled(); + }); + + it('blocks live replaceMembers on a running mixed team', async () => { + const writeMembers = vi.fn(async () => {}); + const membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'alice', + role: 'Reviewer', + providerId: 'opencode', + model: 'minimax-m2.5-free', + agentType: 'general-purpose', + }, + ]), + writeMembers, + } as never; + + const service = new TeamDataService( + { getConfig: vi.fn(), listTeams: vi.fn() } as never, + { getTasks: vi.fn(async () => []) } as never, + { listInboxNames: vi.fn(async () => []), getMessages: vi.fn(async () => []) } as never, + {} as never, + {} as never, + { resolveMembers: vi.fn(() => []) } as never, + { + getState: vi.fn(async () => ({ teamName: 'mixed-team', reviewers: [], tasks: {} })), + } as never, + {} as never, + membersMetaStore, + { readMessages: vi.fn(async () => []) } as never, + (() => + ({ + processes: { + listProcesses: vi.fn(async () => [ + { + id: 'run-1', + label: 'mixed-team', + pid: 123, + registeredAt: new Date().toISOString(), + }, + ]), + }, + }) as never) as never, + {} as never, + { getMeta: vi.fn(async () => ({ providerId: 'codex' })) } as never + ); + + await expect( + service.replaceMembers('mixed-team', { + members: [{ name: 'alice', providerId: 'codex', model: 'gpt-5.4', effort: 'high' }], + }) + ).rejects.toThrow( + 'Live roster mutation on a running mixed team is not supported in V1. Stop the team, edit the roster, then relaunch.' + ); + + expect(writeMembers).not.toHaveBeenCalled(); + }); + + it('allows live removeMember for an OpenCode-owned member on a running mixed team', async () => { + const writeMembers = vi.fn(async () => {}); + const membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'alice', + role: 'Reviewer', + providerId: 'opencode', + model: 'minimax-m2.5-free', + agentType: 'general-purpose', + }, + ]), + writeMembers, + } as never; + + const service = new TeamDataService( + { getConfig: vi.fn(), listTeams: vi.fn() } as never, + { getTasks: vi.fn(async () => []) } as never, + { listInboxNames: vi.fn(async () => []), getMessages: vi.fn(async () => []) } as never, + {} as never, + {} as never, + { resolveMembers: vi.fn(() => []) } as never, + { + getState: vi.fn(async () => ({ teamName: 'mixed-team', reviewers: [], tasks: {} })), + } as never, + {} as never, + membersMetaStore, + { readMessages: vi.fn(async () => []) } as never, + (() => + ({ + processes: { + listProcesses: vi.fn(async () => [ + { + id: 'run-1', + label: 'mixed-team', + pid: 123, + registeredAt: new Date().toISOString(), + }, + ]), + }, + }) as never) as never, + {} as never, + { getMeta: vi.fn(async () => ({ providerId: 'codex' })) } as never + ); + + await expect(service.removeMember('mixed-team', 'alice')).resolves.toBeUndefined(); + + expect(writeMembers).toHaveBeenCalledTimes(1); + }); + it('does not carry over agentId from a previously removed member with the same name', async () => { const writeMembers = vi.fn(async () => {}); const membersMetaStore = { @@ -3968,6 +4235,104 @@ describe('TeamDataService', () => { ]); }); + it('synthesizes a team lead from team meta when config and members meta have no lead entry', async () => { + const harness = createGetTeamDataHarness({ + config: { + name: 'My team', + projectPath: '/repo', + members: [ + { + name: 'alice', + role: 'Reviewer', + providerId: 'codex', + model: 'gpt-5.4', + }, + ], + }, + getTeamMeta: async () => ({ + version: 1, + cwd: '/repo', + providerId: 'codex', + model: 'gpt-5.4', + effort: 'medium', + createdAt: Date.now(), + }), + resolveMembers: () => [buildResolvedMember('alice')], + }); + + const data = await harness.service.getTeamData('my-team'); + + expect(data.members[0]).toMatchObject({ + name: 'team-lead', + agentType: 'team-lead', + role: 'Team Lead', + providerId: 'codex', + model: 'gpt-5.4', + effort: 'medium', + cwd: '/repo', + }); + expect(data.members[1]).toMatchObject({ + name: 'alice', + }); + expect(harness.teamMetaStore.getMeta).toHaveBeenCalledWith('my-team'); + }); + + it('surfaces lane-aware member runtime truth alongside the synthesized lead snapshot', async () => { + const harness = createGetTeamDataHarness({ + config: { + name: 'My team', + projectPath: '/repo', + members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }], + }, + getTeamMeta: async () => ({ + version: 1, + cwd: '/repo', + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + effort: 'medium', + fastMode: 'off', + createdAt: Date.now(), + }), + resolveMembers: () => [ + { + ...buildResolvedMember('alice'), + providerId: 'opencode', + providerBackendId: 'opencode-cli', + model: 'minimax-m2.5-free', + laneId: 'secondary:opencode:alice', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + selectedFastMode: 'inherit', + resolvedFastMode: false, + }, + ], + }); + + const data = await harness.service.getTeamData('my-team'); + + expect(data.members[0]).toMatchObject({ + name: 'team-lead', + agentType: 'team-lead', + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + effort: 'medium', + cwd: '/repo', + }); + expect(data.members[1]).toMatchObject({ + name: 'alice', + providerId: 'opencode', + providerBackendId: 'opencode-cli', + model: 'minimax-m2.5-free', + laneId: 'secondary:opencode:alice', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + selectedFastMode: 'inherit', + resolvedFastMode: false, + }); + }); + it('degrades advisory lookup failure to warning and still completes the snapshot', async () => { const harness = createGetTeamDataHarness({ resolveMembers: () => [buildResolvedMember('alice')], @@ -4145,12 +4510,19 @@ describe('TeamDataService', () => { buildDefaultTeamConfig(), metaMembers, inboxNames, - [ + expect.arrayContaining([ expect.objectContaining({ id: 'task-1', subject: 'Investigate rollout', }), - ] + ]), + expect.objectContaining({ + launchSnapshot: null, + leadProviderId: undefined, + leadProviderBackendId: undefined, + leadFastMode: undefined, + leadResolvedFastMode: undefined, + }) ); }); diff --git a/test/main/services/team/TeamFsWorker.integration.test.ts b/test/main/services/team/TeamFsWorker.integration.test.ts new file mode 100644 index 00000000..8264f123 --- /dev/null +++ b/test/main/services/team/TeamFsWorker.integration.test.ts @@ -0,0 +1,215 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { Worker } from 'worker_threads'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { createPersistedLaunchSummaryProjection } from '../../../../src/main/services/team/TeamLaunchSummaryProjection'; + +interface WorkerResponse { + id: string; + ok: boolean; + result?: unknown; + error?: string; +} + +function getWorkerPath(): string { + return path.join(process.cwd(), 'dist-electron', 'main', 'team-fs-worker.cjs'); +} + +function callListTeams(worker: Worker, teamsDir: string): Promise { + const requestId = `req-${Date.now()}`; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error('team-fs-worker test timed out')); + }, 10_000); + + const cleanup = () => { + clearTimeout(timeout); + worker.off('message', onMessage); + worker.off('error', onError); + }; + + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + const onMessage = (message: WorkerResponse) => { + if (!message || message.id !== requestId) { + return; + } + cleanup(); + if (!message.ok) { + reject(new Error(message.error || 'team-fs-worker returned an unknown error')); + return; + } + resolve(Array.isArray(message.result) ? message.result : []); + }; + + worker.on('message', onMessage); + worker.on('error', onError); + worker.postMessage({ + id: requestId, + op: 'listTeams', + payload: { + teamsDir, + largeConfigBytes: 8 * 1024, + configHeadBytes: 4 * 1024, + maxConfigBytes: 256 * 1024, + maxConfigReadMs: 5_000, + maxMembersMetaBytes: 256 * 1024, + maxSessionHistoryInSummary: 10, + maxProjectPathHistoryInSummary: 10, + concurrency: 2, + }, + }); + }); +} + +describe('team-fs-worker integration', () => { + let tempDir = ''; + + afterEach(async () => { + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + tempDir = ''; + } + }); + + it('uses launch-summary.json when launch-state.json is too large for mixed-team summaries', async () => { + const workerPath = getWorkerPath(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-')); + const teamName = 'mixed-worker-team'; + const teamDir = path.join(tempDir, teamName); + await fs.mkdir(teamDir, { recursive: true }); + + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: 'Mixed Worker Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + await fs.writeFile(path.join(teamDir, 'launch-state.json'), 'x'.repeat(40 * 1024), 'utf8'); + await fs.writeFile( + path.join(teamDir, 'launch-summary.json'), + JSON.stringify( + createPersistedLaunchSummaryProjection({ + version: 2, + teamName, + updatedAt: '2026-04-22T12:00:00.000Z', + launchPhase: 'finished', + expectedMembers: ['alice', 'bob'], + bootstrapExpectedMembers: ['alice'], + members: { + alice: { + name: 'alice', + providerId: 'codex', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + lastEvaluatedAt: '2026-04-22T12:00:00.000Z', + }, + bob: { + name: 'bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'failed_to_start', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'Side lane failed', + lastEvaluatedAt: '2026-04-22T12:00:00.000Z', + }, + }, + summary: { + confirmedCount: 1, + pendingCount: 0, + failedCount: 1, + runtimeAlivePendingCount: 0, + }, + teamLaunchState: 'partial_failure', + } as never), + null, + 2 + ), + 'utf8' + ); + + const worker = new Worker(workerPath); + try { + const teams = (await callListTeams(worker, tempDir)) as Array>; + expect(teams).toHaveLength(1); + expect(teams[0]).toMatchObject({ + teamName, + displayName: 'Mixed Worker Team', + partialLaunchFailure: true, + expectedMemberCount: 2, + confirmedMemberCount: 1, + missingMembers: ['bob'], + teamLaunchState: 'partial_failure', + confirmedCount: 1, + pendingCount: 0, + failedCount: 1, + }); + } finally { + await worker.terminate(); + } + }); + + it('ignores removed and lead members when draft-team worker summary counts members', async () => { + const workerPath = getWorkerPath(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-')); + const teamName = 'draft-worker-team'; + const teamDir = path.join(tempDir, teamName); + await fs.mkdir(teamDir, { recursive: true }); + + await fs.writeFile( + path.join(teamDir, 'team.meta.json'), + JSON.stringify({ + version: 1, + cwd: tempDir, + displayName: 'Draft Worker Team', + createdAt: Date.parse('2026-04-22T12:00:00.000Z'), + }), + 'utf8' + ); + await fs.writeFile( + path.join(teamDir, 'members.meta.json'), + JSON.stringify({ + version: 1, + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', removedAt: Date.parse('2026-04-22T12:01:00.000Z') }, + { name: 'bob', role: 'developer' }, + ], + }), + 'utf8' + ); + + const worker = new Worker(workerPath); + try { + const teams = (await callListTeams(worker, tempDir)) as Array>; + expect(teams).toHaveLength(1); + expect(teams[0]).toMatchObject({ + teamName, + displayName: 'Draft Worker Team', + memberCount: 1, + }); + } finally { + await worker.terminate(); + } + }); +}); diff --git a/test/main/services/team/TeamLaunchSummaryProjection.test.ts b/test/main/services/team/TeamLaunchSummaryProjection.test.ts new file mode 100644 index 00000000..6a69c1d4 --- /dev/null +++ b/test/main/services/team/TeamLaunchSummaryProjection.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest'; + +import { + choosePreferredLaunchStateSummary, + createPersistedLaunchSummaryProjection, + shouldSuppressLegacyLaunchArtifactHeuristic, +} from '../../../../src/main/services/team/TeamLaunchSummaryProjection'; + +describe('TeamLaunchSummaryProjection', () => { + it('ignores stale terminal bootstrap-only pending summaries when canonical launch truth is missing', () => { + const summary = choosePreferredLaunchStateSummary({ + bootstrapSnapshot: { + version: 2, + teamName: 'atlas-hq-2', + updatedAt: '2026-04-09T20:35:57.962Z', + launchPhase: 'finished', + expectedMembers: ['alice', 'jack'], + members: { + alice: { + name: 'alice', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + lastEvaluatedAt: '2026-04-09T20:35:57.962Z', + }, + jack: { + name: 'jack', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + lastEvaluatedAt: '2026-04-09T20:35:57.962Z', + }, + }, + summary: { + confirmedCount: 0, + pendingCount: 2, + failedCount: 0, + runtimeAlivePendingCount: 0, + }, + teamLaunchState: 'partial_pending', + } as never, + launchSummaryProjection: null, + }); + + expect(summary).toBeNull(); + }); + + it('prefers a mixed-aware persisted summary projection over a newer but poorer bootstrap snapshot', () => { + const bootstrapSnapshot = { + version: 2, + teamName: 'mixed-team', + updatedAt: '2026-04-22T12:05:00.000Z', + launchPhase: 'active', + expectedMembers: ['alice'], + members: { + alice: { + name: 'alice', + providerId: 'codex', + launchState: 'starting', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + lastEvaluatedAt: '2026-04-22T12:05:00.000Z', + }, + }, + summary: { + confirmedCount: 0, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 0, + }, + teamLaunchState: 'partial_pending', + } as const; + + const mixedSnapshot = { + version: 2, + teamName: 'mixed-team', + updatedAt: '2026-04-22T12:00:00.000Z', + launchPhase: 'finished', + expectedMembers: ['alice', 'bob'], + bootstrapExpectedMembers: ['alice'], + members: { + alice: { + name: 'alice', + providerId: 'codex', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + lastEvaluatedAt: '2026-04-22T12:00:00.000Z', + }, + bob: { + name: 'bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'failed_to_start', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'Side lane failed', + lastEvaluatedAt: '2026-04-22T12:00:00.000Z', + }, + }, + summary: { + confirmedCount: 1, + pendingCount: 0, + failedCount: 1, + runtimeAlivePendingCount: 0, + }, + teamLaunchState: 'partial_failure', + } as const; + + const summary = choosePreferredLaunchStateSummary({ + bootstrapSnapshot: bootstrapSnapshot as never, + launchSummaryProjection: createPersistedLaunchSummaryProjection(mixedSnapshot as never), + }); + + expect(summary).toMatchObject({ + partialLaunchFailure: true, + expectedMemberCount: 2, + confirmedMemberCount: 1, + missingMembers: ['bob'], + teamLaunchState: 'partial_failure', + confirmedCount: 1, + pendingCount: 0, + failedCount: 1, + }); + }); + + it('suppresses legacy artifact-count launch heuristics for mixed-aware desired rosters', () => { + expect( + shouldSuppressLegacyLaunchArtifactHeuristic({ + leadProviderId: 'codex', + members: [ + { name: 'alice', providerId: 'codex' }, + { name: 'tom', providerId: 'opencode' }, + ], + }) + ).toBe(true); + + expect( + shouldSuppressLegacyLaunchArtifactHeuristic({ + leadProviderId: 'opencode', + members: [{ name: 'alice', providerId: 'codex' }], + }) + ).toBe(true); + + expect( + shouldSuppressLegacyLaunchArtifactHeuristic({ + leadProviderId: 'codex', + members: [ + { name: 'alice', providerId: 'opencode' }, + { name: 'tom', providerId: 'opencode' }, + ], + }) + ).toBe(true); + + expect( + shouldSuppressLegacyLaunchArtifactHeuristic({ + leadProviderId: 'codex', + members: [{ name: 'alice', providerId: 'codex' }], + }) + ).toBe(false); + }); +}); diff --git a/test/main/services/team/TeamMemberResolver.test.ts b/test/main/services/team/TeamMemberResolver.test.ts index 77f44ce7..66066540 100644 --- a/test/main/services/team/TeamMemberResolver.test.ts +++ b/test/main/services/team/TeamMemberResolver.test.ts @@ -202,6 +202,30 @@ describe('TeamMemberResolver', () => { expect(names).not.toContain('ops.bot'); }); + it('does not let a removed base member hide an active suffixed teammate', () => { + const resolver = new TeamMemberResolver(); + const config: TeamConfig = { + name: 'Team', + members: [ + { name: 'team-lead', agentType: 'team-lead', role: 'lead' }, + { name: 'alice-2', agentType: 'general-purpose' }, + ], + }; + const metaMembers: TeamConfig['members'] = [ + { + name: 'alice', + agentType: 'general-purpose', + removedAt: 1715000000000, + }, + ]; + + const members = resolver.resolveMembers(config, metaMembers, [], []); + const names = members.map((member) => member.name); + + expect(names).toContain('alice-2'); + expect(names).toContain('alice'); + }); + it('sets currentTaskId for in_progress task', () => { const resolver = new TeamMemberResolver(); const config: TeamConfig = { diff --git a/test/main/services/team/TeamMembersMetaStore.test.ts b/test/main/services/team/TeamMembersMetaStore.test.ts new file mode 100644 index 00000000..dcf20210 --- /dev/null +++ b/test/main/services/team/TeamMembersMetaStore.test.ts @@ -0,0 +1,87 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const hoisted = vi.hoisted(() => ({ + teamsBase: '', +})); + +vi.mock('@main/utils/pathDecoder', () => ({ + getTeamsBasePath: () => hoisted.teamsBase, +})); + +import { TeamMembersMetaStore } from '../../../../src/main/services/team/TeamMembersMetaStore'; + +describe('TeamMembersMetaStore', () => { + let tempDir = ''; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-members-meta-store-')); + hoisted.teamsBase = path.join(tempDir, 'teams'); + await fs.mkdir(hoisted.teamsBase, { recursive: true }); + }); + + afterEach(async () => { + hoisted.teamsBase = ''; + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it('keeps an active suffixed member when the base member is removed during writeMembers', async () => { + const store = new TeamMembersMetaStore(); + const teamName = 'mixed-team'; + await fs.mkdir(path.join(hoisted.teamsBase, teamName), { recursive: true }); + + await store.writeMembers(teamName, [ + { + name: 'alice', + providerId: 'codex', + removedAt: Date.now(), + }, + { + name: 'alice-2', + providerId: 'opencode', + model: 'minimax-m2.5-free', + }, + ]); + + const members = await store.getMembers(teamName); + expect(members.map((member) => member.name)).toEqual(['alice', 'alice-2']); + }); + + it('keeps an active suffixed member when reading persisted metadata with a removed base member', async () => { + const store = new TeamMembersMetaStore(); + const teamName = 'mixed-team'; + const teamDir = path.join(hoisted.teamsBase, teamName); + await fs.mkdir(teamDir, { recursive: true }); + + await fs.writeFile( + path.join(teamDir, 'members.meta.json'), + JSON.stringify( + { + version: 1, + members: [ + { + name: 'alice', + providerId: 'codex', + removedAt: Date.now(), + }, + { + name: 'alice-2', + providerId: 'opencode', + model: 'minimax-m2.5-free', + }, + ], + }, + null, + 2 + ) + ); + + const members = await store.getMembers(teamName); + expect(members.map((member) => member.name)).toEqual(['alice', 'alice-2']); + }); +}); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 31856854..1e476288 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -1,6 +1,7 @@ import type { ChildProcess } from 'child_process'; import { EventEmitter } from 'events'; import * as fs from 'fs'; +import { promises as fsPromises } from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -127,7 +128,13 @@ import { import { getTeamBootstrapStatePath } from '@main/services/team/TeamBootstrapStateReader'; import { createPersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator'; import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore'; +import { + getOpenCodeLaneScopedRuntimeFilePath, + readOpenCodeRuntimeLaneIndex, + upsertOpenCodeRuntimeLaneIndexEntry, +} from '@main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; +import { TeamRuntimeAdapterRegistry } from '@main/services/team/runtime/TeamRuntimeAdapter'; import { spawnCli } from '@main/utils/childProcess'; import { killProcessByPid } from '@main/utils/processKill'; import { encodePath } from '@main/utils/pathDecoder'; @@ -719,6 +726,105 @@ describe('TeamProvisioningService', () => { rssBytes: 456_000_000, }); }); + + it('excludes removed meta members from runtime snapshot candidate members', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'alice', + providerId: 'opencode', + model: 'minimax-m2.5-free', + removedAt: Date.now(), + }, + ]), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => null), + }; + vi.mocked(pidusage).mockResolvedValueOnce({} as any); + + const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team'); + + expect(snapshot.members.alice).toBeUndefined(); + }); + + it('excludes removed meta members from live runtime metadata resolution', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'alice', + providerId: 'opencode', + model: 'minimax-m2.5-free', + agentId: 'alice@runtime-team', + removedAt: Date.now(), + }, + ]), + }; + (svc as any).readPersistedRuntimeMembers = vi.fn(() => [ + { + name: 'alice', + agentId: 'alice@runtime-team', + backendType: 'tmux', + tmuxPaneId: '%1', + }, + ]); + + const metadata = await (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team'); + + expect(metadata.has('alice')).toBe(false); + }); + + it('does not let removed base member metadata hide an active suffixed member', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice-2', providerId: 'codex', model: 'gpt-5.4-mini' }, + ], + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'alice', + providerId: 'opencode', + model: 'minimax-m2.5-free', + removedAt: Date.now(), + }, + ]), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => null), + }; + vi.mocked(pidusage).mockResolvedValueOnce({} as any); + + const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team'); + + expect(snapshot.members['alice-2']).toMatchObject({ + memberName: 'alice-2', + runtimeModel: 'gpt-5.4-mini', + }); + expect(snapshot.members.alice).toBeUndefined(); + }); }); describe('restartMember', () => { @@ -865,6 +971,204 @@ describe('TeamProvisioningService', () => { expect(restartMessage).toContain('Their workflow: Use the updated checklist'); }); + it('does not let removed base-member metadata override a suffixed teammate during restart', async () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + teamName: 'edited-team', + expectedMembers: ['alice-2'], + memberSpawnStatuses: new Map([ + [ + 'alice-2', + createMemberSpawnStatusEntry({ + status: 'online', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + livenessSource: 'heartbeat', + firstSpawnAcceptedAt: new Date().toISOString(), + lastHeartbeatAt: new Date().toISOString(), + }), + ], + ]), + }); + 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: 'Edited Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'alice', + providerId: 'opencode', + model: 'nemotron-3-super-free', + removedAt: Date.now(), + }, + { + name: 'alice-2', + role: 'Reviewer', + providerId: 'codex', + model: 'gpt-5.4-mini', + effort: 'high', + agentType: 'general-purpose', + }, + ]), + }; + (svc as any).readPersistedRuntimeMembers = vi.fn(() => []); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map()); + (svc as any).aliveRunByTeam.set('edited-team', run.runId); + (svc as any).runs.set(run.runId, run); + + await svc.restartMember('edited-team', 'alice-2'); + + expect(sendMessageToRun).toHaveBeenCalledTimes(1); + const restartCall = sendMessageToRun.mock.calls[0] as unknown as + | [unknown, string] + | undefined; + const restartMessage = restartCall?.[1] ?? ''; + expect(restartMessage).toContain('provider="codex"'); + expect(restartMessage).toContain('model="gpt-5.4-mini"'); + expect(restartMessage).toContain('effort="high"'); + expect(restartMessage).not.toContain('nemotron-3-super-free'); + }); + + it('requires the OpenCode runtime adapter before restarting a secondary-lane teammate', async () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + teamName: 'mixed-team', + expectedMembers: ['alice'], + memberSpawnStatuses: new Map(), + }); + run.child = { pid: 111 }; + run.processKilled = false; + run.cancelRequested = false; + run.mixedSecondaryLanes = [ + { + laneId: 'secondary:opencode:alice', + providerId: 'opencode', + member: { + name: 'alice', + providerId: 'opencode', + model: 'minimax-m2.5-free', + effort: 'medium', + }, + runId: 'opencode-run-1', + state: 'launching', + result: null, + warnings: [], + diagnostics: [], + }, + ]; + + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + name: 'Mixed Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'alice', + role: 'Developer', + providerId: 'opencode', + model: 'minimax-m2.5-free', + effort: 'medium', + agentType: 'general-purpose', + }, + ]), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ providerId: 'codex' })), + }; + (svc as any).readPersistedRuntimeMembers = vi.fn(() => []); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map()); + (svc as any).aliveRunByTeam.set('mixed-team', run.runId); + (svc as any).runs.set(run.runId, run); + + await expect(svc.restartMember('mixed-team', 'alice')).rejects.toThrow( + 'OpenCode runtime adapter is not available for controlled lane reattach.' + ); + }); + + it('still allows restarting a primary-lane teammate when another mixed secondary lane exists', async () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + teamName: 'mixed-team', + expectedMembers: ['alice'], + memberSpawnStatuses: new Map(), + }); + run.child = { pid: 111 }; + run.processKilled = false; + run.cancelRequested = false; + run.mixedSecondaryLanes = [ + { + laneId: 'secondary:opencode:bob', + providerId: 'opencode', + member: { + name: 'bob', + providerId: 'opencode', + model: 'minimax-m2.5-free', + effort: 'medium', + }, + runId: 'opencode-run-1', + state: 'launching', + result: null, + warnings: [], + diagnostics: [], + }, + ]; + + const sendMessageToRun = vi.fn(async () => {}); + (svc as any).sendMessageToRun = sendMessageToRun; + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + name: 'Mixed Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'alice', + role: 'Reviewer', + providerId: 'codex', + model: 'gpt-5.4', + effort: 'high', + agentType: 'general-purpose', + }, + { + name: 'bob', + role: 'Developer', + providerId: 'opencode', + model: 'minimax-m2.5-free', + effort: 'medium', + agentType: 'general-purpose', + }, + ]), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ providerId: 'codex' })), + }; + (svc as any).readPersistedRuntimeMembers = vi.fn(() => []); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map()); + (svc as any).aliveRunByTeam.set('mixed-team', run.runId); + (svc as any).runs.set(run.runId, run); + + await svc.restartMember('mixed-team', 'alice'); + + expect(sendMessageToRun).toHaveBeenCalledTimes(1); + expect(run.pendingMemberRestarts.has('alice')).toBe(true); + }); + it('aborts restart if the teammate is removed before respawn is requested', async () => { const svc = new TeamProvisioningService(); const run = createMemberSpawnRun({ @@ -1299,6 +1603,990 @@ describe('TeamProvisioningService', () => { expect(sendMessageToRun).toHaveBeenCalledTimes(1); }); + it('uses secondary-lane pending copy instead of bootstrap-only pending copy for mixed teams', async () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + teamName: 'mixed-team', + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([ + [ + 'alice', + createMemberSpawnStatusEntry({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + }), + ], + ]), + }); + run.isLaunch = true; + run.mixedSecondaryLanes = [ + { + laneId: 'secondary:opencode:bob', + providerId: 'opencode', + member: { + name: 'bob', + providerId: 'opencode', + model: 'minimax-m2.5-free', + effort: 'medium', + }, + runId: 'opencode-run-1', + state: 'launching', + result: null, + warnings: [], + diagnostics: [], + }, + ]; + + const message = (svc as any).buildAggregatePendingLaunchMessage( + 'Finishing launch', + run, + { + confirmedCount: 1, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 0, + }, + { + version: 2, + teamName: 'mixed-team', + updatedAt: '2026-04-22T12:00:00.000Z', + launchPhase: 'active', + expectedMembers: ['alice', 'bob'], + bootstrapExpectedMembers: ['alice'], + members: { + alice: { + name: 'alice', + providerId: 'codex', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + lastEvaluatedAt: '2026-04-22T12:00:00.000Z', + }, + bob: { + name: 'bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'starting', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + lastEvaluatedAt: '2026-04-22T12:00:00.000Z', + }, + }, + summary: { + confirmedCount: 1, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 0, + }, + teamLaunchState: 'partial_pending', + } + ); + + expect(message).toBe('Finishing launch - waiting for secondary runtime lane: bob'); + }); + + it('launches the OpenCode secondary lane with side-lane provider and member runtime identity', async () => { + const svc = new TeamProvisioningService(); + const adapterLaunch = vi.fn(async (input: Record) => ({ + runId: String(input.runId), + teamName: String(input.teamName), + launchPhase: 'finished', + teamLaunchState: 'clean_success', + members: { + bob: { + memberName: 'bob', + providerId: 'opencode', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + diagnostics: [], + }, + }, + warnings: [], + diagnostics: [], + })); + + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: adapterLaunch, + reconcile: vi.fn(), + stop: vi.fn(), + } as any, + ]); + svc.setRuntimeAdapterRegistry(registry); + + (svc as any).launchStateStore = { + read: vi.fn(async () => null), + write: vi.fn(async () => {}), + }; + + const run = createMemberSpawnRun({ + teamName: 'mixed-team', + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([ + [ + 'alice', + createMemberSpawnStatusEntry({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + }), + ], + ]), + }); + run.isLaunch = true; + run.request = { + teamName: 'mixed-team', + cwd: '/tmp/mixed-team', + providerId: 'codex', + model: 'gpt-5.4', + effort: 'high', + skipPermissions: true, + }; + run.effectiveMembers = [ + { + name: 'alice', + role: 'Reviewer', + providerId: 'codex', + model: 'gpt-5.4', + effort: 'high', + }, + ]; + run.detectedSessionId = 'lead-session-1'; + run.launchIdentity = null; + run.mixedSecondaryLanes = [ + { + laneId: 'secondary:opencode:bob', + providerId: 'opencode', + member: { + name: 'bob', + role: 'Developer', + providerId: 'opencode', + model: 'minimax-m2.5-free', + effort: 'medium', + }, + runId: null, + state: 'queued', + result: null, + warnings: [], + diagnostics: [], + }, + ]; + + await (svc as any).launchMixedSecondaryLaneIfNeeded(run); + + expect(adapterLaunch).toHaveBeenCalledTimes(1); + expect(adapterLaunch).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: 'secondary:opencode:bob', + providerId: 'opencode', + model: 'minimax-m2.5-free', + effort: 'medium', + cwd: '/tmp/mixed-team', + expectedMembers: [ + expect.objectContaining({ + name: 'bob', + providerId: 'opencode', + model: 'minimax-m2.5-free', + effort: 'medium', + cwd: '/tmp/mixed-team', + }), + ], + }) + ); + }); + + it('preserves mixed lane metadata when OpenCode runtime liveness updates a secondary lane member', async () => { + const svc = new TeamProvisioningService(); + const previousSnapshot = { + version: 2 as const, + teamName: 'mixed-team', + updatedAt: '2026-04-22T12:00:00.000Z', + launchPhase: 'active' as const, + expectedMembers: ['alice', 'bob'], + bootstrapExpectedMembers: ['alice'], + members: { + alice: { + name: 'alice', + providerId: 'codex' as const, + laneId: 'primary', + laneKind: 'primary' as const, + laneOwnerProviderId: 'codex' as const, + launchState: 'confirmed_alive' as const, + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + lastEvaluatedAt: '2026-04-22T12:00:00.000Z', + }, + bob: { + name: 'bob', + providerId: 'opencode' as const, + model: 'minimax-m2.5-free', + effort: 'medium' as const, + laneId: 'secondary:opencode:bob', + laneKind: 'secondary' as const, + laneOwnerProviderId: 'opencode' as const, + launchIdentity: { + providerId: 'opencode' as const, + providerBackendId: null, + selectedModel: 'minimax-m2.5-free', + selectedModelKind: 'explicit' as const, + resolvedLaunchModel: 'minimax-m2.5-free', + catalogId: 'minimax-m2.5-free', + catalogSource: 'runtime' as const, + catalogFetchedAt: '2026-04-22T12:00:00.000Z', + selectedEffort: 'medium' as const, + resolvedEffort: 'medium' as const, + selectedFastMode: null, + resolvedFastMode: null, + fastResolutionReason: null, + }, + launchState: 'runtime_pending_bootstrap' as const, + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + lastEvaluatedAt: '2026-04-22T12:00:00.000Z', + }, + }, + summary: { + confirmedCount: 1, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 1, + }, + teamLaunchState: 'partial_pending' as const, + }; + const write = vi.fn(async () => {}); + + (svc as any).launchStateStore = { + read: vi.fn(async () => previousSnapshot), + write, + }; + + await (svc as any).updateOpenCodeRuntimeMemberLiveness({ + teamName: 'mixed-team', + runId: 'run-member-spawn-1', + memberName: 'bob', + runtimeSessionId: 'session-bob', + observedAt: '2026-04-22T12:05:00.000Z', + diagnostics: ['native heartbeat'], + reason: 'OpenCode runtime heartbeat accepted', + }); + + expect(write).toHaveBeenCalledTimes(1); + const writtenSnapshot = (write.mock.calls[0] as unknown as [string, Record] | undefined)?.[1] as + | { members?: Record } + | undefined; + expect(writtenSnapshot?.members?.bob).toMatchObject({ + name: 'bob', + providerId: 'opencode', + model: 'minimax-m2.5-free', + effort: 'medium', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchIdentity: { + providerId: 'opencode', + selectedModel: 'minimax-m2.5-free', + resolvedLaunchModel: 'minimax-m2.5-free', + }, + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + }); + }); + + it('accepts secondary OpenCode lane evidence using the lane run id instead of the lead run id', async () => { + const svc = new TeamProvisioningService(); + + (svc as any).aliveRunByTeam.set('mixed-team', 'lead-run'); + (svc as any).runs.set('lead-run', { + runId: 'lead-run', + teamName: 'mixed-team', + request: { + providerId: 'codex', + }, + }); + (svc as any).setSecondaryRuntimeRun({ + teamName: 'mixed-team', + runId: 'opencode-run-1', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/tmp/mixed-team', + }); + + await expect( + (svc as any).assertOpenCodeRuntimeEvidenceAccepted({ + teamName: 'mixed-team', + runId: 'opencode-run-1', + laneId: 'secondary:opencode:bob', + evidenceKind: 'heartbeat', + }) + ).resolves.toBeUndefined(); + }); + + it('uses the secondary lane run id for OpenCode runtime delivery journal acceptance', async () => { + const svc = new TeamProvisioningService(); + const delivered = new Map(); + + (svc as any).aliveRunByTeam.set('mixed-team', 'lead-run'); + (svc as any).runs.set('lead-run', { + runId: 'lead-run', + teamName: 'mixed-team', + request: { + providerId: 'codex', + }, + }); + (svc as any).setSecondaryRuntimeRun({ + teamName: 'mixed-team', + runId: 'opencode-run-1', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/tmp/mixed-team', + }); + (svc as any).createOpenCodeRuntimeDeliveryPorts = vi.fn(() => [ + { + kind: 'member_inbox', + write: vi.fn(async ({ envelope, destinationMessageId }) => { + const location = { + kind: 'member_inbox' as const, + teamName: envelope.teamName, + memberName: + typeof envelope.to === 'object' && 'memberName' in envelope.to + ? envelope.to.memberName + : 'unknown', + messageId: destinationMessageId, + }; + delivered.set(destinationMessageId, location); + return location; + }), + verify: vi.fn(async ({ destinationMessageId }) => { + const location = delivered.get(destinationMessageId) ?? null; + return { + found: location !== null, + location, + diagnostics: [], + }; + }), + buildChangeEvent: vi.fn(() => null), + }, + ]); + + const delivery = (svc as any).createOpenCodeRuntimeDeliveryService( + 'mixed-team', + 'secondary:opencode:bob' + ); + const ack = await delivery.deliver({ + idempotencyKey: 'delivery-1', + runId: 'opencode-run-1', + teamName: 'mixed-team', + fromMemberName: 'bob', + providerId: 'opencode', + runtimeSessionId: 'session-bob', + to: { memberName: 'alice' }, + text: 'hi', + createdAt: '2026-04-22T12:05:00.000Z', + }); + + expect(ack).toMatchObject({ + ok: true, + delivered: true, + reason: null, + }); + }); + + it('recovers OpenCode delivery journals from canonical launch snapshot when lane index is missing', async () => { + const svc = new TeamProvisioningService(); + + (svc as any).launchStateStore = { + read: vi.fn(async () => ({ + version: 2, + teamName: 'mixed-team', + updatedAt: '2026-04-22T12:00:00.000Z', + launchPhase: 'active', + expectedMembers: ['alice', 'bob', 'tom'], + bootstrapExpectedMembers: ['alice'], + members: { + alice: { + name: 'alice', + providerId: 'codex', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + lastEvaluatedAt: '2026-04-22T12:00:00.000Z', + }, + bob: { + name: 'bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + lastEvaluatedAt: '2026-04-22T12:00:00.000Z', + }, + tom: { + name: 'tom', + providerId: 'opencode', + laneId: 'secondary:opencode:tom', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + lastEvaluatedAt: '2026-04-22T12:00:00.000Z', + }, + }, + summary: { + confirmedCount: 2, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 1, + }, + teamLaunchState: 'partial_pending', + })), + }; + + await expect( + (svc as any).getOpenCodeRuntimeRecoveryLaneIds('mixed-team', {}) + ).resolves.toEqual(['secondary:opencode:bob', 'secondary:opencode:tom']); + }); + + it('routes runtime deliveries to the persisted secondary OpenCode lane after in-memory tracking is lost', async () => { + const svc = new TeamProvisioningService(); + const observedLaneIds: string[] = []; + + (svc as any).launchStateStore = { + read: vi.fn(async () => ({ + version: 2, + teamName: 'mixed-team', + updatedAt: '2026-04-22T12:00:00.000Z', + launchPhase: 'active', + expectedMembers: ['alice', 'bob'], + bootstrapExpectedMembers: ['alice'], + members: { + alice: { + name: 'alice', + providerId: 'codex', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + lastEvaluatedAt: '2026-04-22T12:00:00.000Z', + }, + bob: { + name: 'bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + lastEvaluatedAt: '2026-04-22T12:00:00.000Z', + }, + }, + summary: { + confirmedCount: 2, + pendingCount: 0, + failedCount: 0, + runtimeAlivePendingCount: 0, + }, + teamLaunchState: 'ready', + })), + }; + (svc as any).assertOpenCodeRuntimeEvidenceAccepted = vi.fn(async ({ laneId }) => { + observedLaneIds.push(`evidence:${laneId}`); + }); + (svc as any).createOpenCodeRuntimeDeliveryService = vi.fn((_teamName, laneId) => { + observedLaneIds.push(`delivery:${laneId}`); + return { + deliver: vi.fn(async () => ({ + ok: true, + delivered: true, + idempotencyKey: 'delivery-1', + location: { + kind: 'member_inbox' as const, + teamName: 'mixed-team', + memberName: 'alice', + messageId: 'msg-1', + }, + reason: null, + })), + }; + }); + + const ack = await svc.deliverOpenCodeRuntimeMessage({ + idempotencyKey: 'delivery-1', + teamName: 'mixed-team', + runId: 'opencode-run-1', + fromMemberName: 'bob', + runtimeSessionId: 'session-bob', + to: { memberName: 'alice' }, + text: 'hi', + createdAt: '2026-04-22T12:05:00.000Z', + }); + + expect(ack).toMatchObject({ + ok: true, + state: 'delivered', + teamName: 'mixed-team', + runId: 'opencode-run-1', + }); + expect(observedLaneIds).toEqual([ + 'evidence:secondary:opencode:bob', + 'delivery:secondary:opencode:bob', + ]); + }); + + it('removes lane index entries when mixed secondary lanes are stopped without an OpenCode adapter', async () => { + const svc = new TeamProvisioningService(); + const teamName = 'mixed-team'; + + (svc as any).setSecondaryRuntimeRun({ + teamName, + runId: 'opencode-run-1', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/tmp/mixed-team', + }); + (svc as any).setSecondaryRuntimeRun({ + teamName, + runId: 'opencode-run-2', + providerId: 'opencode', + laneId: 'secondary:opencode:tom', + memberName: 'tom', + cwd: '/tmp/mixed-team', + }); + + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: 'secondary:opencode:bob', + state: 'active', + }); + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: 'secondary:opencode:tom', + state: 'active', + }); + await fsPromises.mkdir( + path.dirname( + getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: 'secondary:opencode:bob', + fileName: 'opencode-delivery-journal.json', + }) + ), + { recursive: true } + ); + await fsPromises.writeFile( + getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: 'secondary:opencode:bob', + fileName: 'opencode-delivery-journal.json', + }), + '{"records":[]}\n', + 'utf8' + ); + + (svc as any).launchStateStore = { + read: vi.fn(async () => null), + }; + + await (svc as any).stopMixedSecondaryRuntimeLanes(teamName); + + await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({ + lanes: {}, + }); + await expect( + fsPromises.stat( + path.dirname( + getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: 'secondary:opencode:bob', + fileName: 'opencode-delivery-journal.json', + }) + ) + ) + ).rejects.toThrow(); + }); + + it('clears provider-local lane storage when a single mixed secondary lane is stopped during controlled reattach', async () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + teamName: 'mixed-team', + expectedMembers: ['alice'], + }); + run.request = { + providerId: 'codex', + cwd: '/tmp/mixed-team', + members: [], + }; + const lane = { + laneId: 'secondary:opencode:bob', + providerId: 'opencode' as const, + member: { + name: 'bob', + providerId: 'opencode', + model: 'minimax-m2.5-free', + }, + runId: 'opencode-run-1', + state: 'active', + result: null, + warnings: [], + diagnostics: [], + }; + + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: tempTeamsBase, + teamName: run.teamName, + laneId: lane.laneId, + state: 'active', + }); + await fsPromises.mkdir( + path.dirname( + getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempTeamsBase, + teamName: run.teamName, + laneId: lane.laneId, + fileName: 'opencode-permissions.json', + }) + ), + { recursive: true } + ); + await fsPromises.writeFile( + getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempTeamsBase, + teamName: run.teamName, + laneId: lane.laneId, + fileName: 'opencode-permissions.json', + }), + '{"requests":[]}\n', + 'utf8' + ); + + await (svc as any).stopSingleMixedSecondaryRuntimeLane(run, lane, 'relaunch'); + + await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, run.teamName)).resolves.toMatchObject({ + lanes: {}, + }); + await expect( + fsPromises.stat( + path.dirname( + getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempTeamsBase, + teamName: run.teamName, + laneId: lane.laneId, + fileName: 'opencode-permissions.json', + }) + ) + ) + ).rejects.toThrow(); + expect(lane.runId).toBeNull(); + expect(lane.state).toBe('finished'); + }); + + it('removes the primary lane index entry when a pure OpenCode team is stopped without an adapter', async () => { + const svc = new TeamProvisioningService(); + const teamName = 'opencode-team'; + + (svc as any).runtimeAdapterRunByTeam.set(teamName, { + runId: 'opencode-run-1', + providerId: 'opencode', + cwd: '/tmp/opencode-team', + }); + (svc as any).aliveRunByTeam.set(teamName, 'opencode-run-1'); + (svc as any).provisioningRunByTeam.set(teamName, 'opencode-run-1'); + (svc as any).launchStateStore = { + read: vi.fn(async () => null), + }; + + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: 'primary', + state: 'active', + }); + await fsPromises.mkdir( + path.dirname( + getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: 'primary', + fileName: 'opencode-delivery-journal.json', + }) + ), + { recursive: true } + ); + await fsPromises.writeFile( + getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: 'primary', + fileName: 'opencode-delivery-journal.json', + }), + '{"records":[]}\n', + 'utf8' + ); + + await (svc as any).stopOpenCodeRuntimeAdapterTeam(teamName, 'opencode-run-1'); + + await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({ + lanes: {}, + }); + await expect( + fsPromises.stat( + path.dirname( + getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: 'primary', + fileName: 'opencode-delivery-journal.json', + }) + ) + ) + ).rejects.toThrow(); + expect((svc as any).runtimeAdapterRunByTeam.has(teamName)).toBe(false); + expect((svc as any).aliveRunByTeam.has(teamName)).toBe(false); + expect((svc as any).provisioningRunByTeam.has(teamName)).toBe(false); + }); + + it('clears primary lane storage when OpenCode runtime adapter launch fails', async () => { + const svc = new TeamProvisioningService(); + const teamName = 'opencode-team'; + const adapterLaunch = vi.fn(async () => { + throw new Error('launch boom'); + }); + svc.setRuntimeAdapterRegistry( + new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: adapterLaunch, + reconcile: vi.fn(), + stop: vi.fn(), + } as any, + ]) + ); + + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: 'primary', + state: 'active', + }); + await fsPromises.mkdir( + path.dirname( + getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: 'primary', + fileName: 'opencode-launch-transaction.json', + }) + ), + { recursive: true } + ); + await fsPromises.writeFile( + getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: 'primary', + fileName: 'opencode-launch-transaction.json', + }), + '{"transactionId":"tx-1"}\n', + 'utf8' + ); + + await expect( + (svc as any).runOpenCodeTeamRuntimeAdapterLaunch({ + request: { + teamName, + cwd: '/tmp/opencode-team', + providerId: 'opencode', + model: 'minimax-m2.5-free', + effort: 'medium', + skipPermissions: true, + }, + members: [ + { + name: 'alice', + providerId: 'opencode', + model: 'minimax-m2.5-free', + effort: 'medium', + }, + ], + prompt: 'Launch team', + onProgress: vi.fn(), + }) + ).rejects.toThrow('launch boom'); + + await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({ + lanes: {}, + }); + await expect( + fsPromises.stat( + path.dirname( + getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: 'primary', + fileName: 'opencode-launch-transaction.json', + }) + ) + ) + ).rejects.toThrow(); + expect((svc as any).provisioningRunByTeam.has(teamName)).toBe(false); + }); + + it('does not keep a pure OpenCode team alive when the runtime adapter returns partial_failure', async () => { + const svc = new TeamProvisioningService(); + const teamName = 'opencode-team'; + const adapterLaunch = vi.fn(async (input: Record) => ({ + runId: String(input.runId), + teamName, + launchPhase: 'finished', + teamLaunchState: 'partial_failure', + members: { + alice: { + memberName: 'alice', + providerId: 'opencode', + launchState: 'failed_to_start', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + diagnostics: ['launch failed'], + }, + }, + warnings: [], + diagnostics: ['launch failed'], + })); + svc.setRuntimeAdapterRegistry( + new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: adapterLaunch, + reconcile: vi.fn(), + stop: vi.fn(), + } as any, + ]) + ); + + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: 'primary', + state: 'active', + }); + await fsPromises.mkdir( + path.dirname( + getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: 'primary', + fileName: 'opencode-diagnostics.json', + }) + ), + { recursive: true } + ); + await fsPromises.writeFile( + getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: 'primary', + fileName: 'opencode-diagnostics.json', + }), + '{"events":[]}\n', + 'utf8' + ); + + const response = await (svc as any).runOpenCodeTeamRuntimeAdapterLaunch({ + request: { + teamName, + cwd: '/tmp/opencode-team', + providerId: 'opencode', + model: 'minimax-m2.5-free', + effort: 'medium', + skipPermissions: true, + }, + members: [ + { + name: 'alice', + providerId: 'opencode', + model: 'minimax-m2.5-free', + effort: 'medium', + }, + ], + prompt: 'Launch team', + onProgress: vi.fn(), + }); + + expect(response).toMatchObject({ + runId: expect.any(String), + }); + expect((svc as any).runtimeAdapterRunByTeam.has(teamName)).toBe(false); + expect((svc as any).aliveRunByTeam.has(teamName)).toBe(false); + expect((svc as any).provisioningRunByTeam.has(teamName)).toBe(false); + await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({ + lanes: {}, + }); + await expect( + fsPromises.stat( + path.dirname( + getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: 'primary', + fileName: 'opencode-diagnostics.json', + }) + ) + ) + ).rejects.toThrow(); + }); + it('fails early when the previous tmux pane does not exit before restart', async () => { vi.useFakeTimers(); diff --git a/test/main/services/team/TeamProvisioningServiceAuditWarnings.test.ts b/test/main/services/team/TeamProvisioningServiceAuditWarnings.test.ts index aa2dec00..295de18d 100644 --- a/test/main/services/team/TeamProvisioningServiceAuditWarnings.test.ts +++ b/test/main/services/team/TeamProvisioningServiceAuditWarnings.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + getOpenCodeMixedProviderProvisioningError, shouldWarnOnMissingRegisteredMember, shouldWarnOnUnreadableMemberAuditConfig, } from '@main/services/team/TeamProvisioningService'; @@ -69,4 +70,13 @@ describe('TeamProvisioningService audit warning policy', () => { }) ).toBe(true); }); + + it('surfaces a specific error for mixed-provider teams that include OpenCode', () => { + expect(getOpenCodeMixedProviderProvisioningError()).toContain( + 'outside the current support scope' + ); + expect(getOpenCodeMixedProviderProvisioningError()).toContain( + 'OpenCode-led mixed teams still remain blocked in this phase' + ); + }); }); diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 5689b01f..25295dd0 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -381,6 +381,308 @@ describe('TeamProvisioningService prepare/auth behavior', () => { ); }); + it('checks every selected OpenCode model instead of only the first one', async () => { + const prepare = vi.fn(async (input: { model?: string }) => { + if (input.model === 'opencode/nemotron-3-super-free') { + return { + ok: false as const, + providerId: 'opencode' as const, + reason: 'e2e_missing', + retryable: false, + diagnostics: [ + 'OpenCode production E2E evidence artifact has no entry for selected model opencode/nemotron-3-super-free', + ], + warnings: [], + }; + } + + return { + ok: true as const, + providerId: 'opencode' as const, + modelId: input.model ?? null, + diagnostics: [], + 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/nemotron-3-super-free'], + }); + + expect(prepare).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + runtimeOnly: false, + }) + ); + expect(prepare).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + providerId: 'opencode', + model: 'opencode/nemotron-3-super-free', + runtimeOnly: false, + }) + ); + expect(result.ready).toBe(false); + expect(result.details).toContain( + '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' + ); + }); + + it('runs OpenCode model verification with bounded concurrency and preserves model order', async () => { + const started: string[] = []; + let activeCount = 0; + let maxActiveCount = 0; + const releases = new Map void>(); + const prepare = vi.fn((input: { model?: string }) => { + const modelId = input.model ?? 'unknown-model'; + started.push(modelId); + activeCount += 1; + maxActiveCount = Math.max(maxActiveCount, activeCount); + + return new Promise((resolve) => { + releases.set(modelId, () => { + activeCount -= 1; + if (modelId === 'opencode/big-pickle') { + resolve({ + ok: false as const, + providerId: 'opencode' as const, + reason: 'provider_busy', + retryable: true, + diagnostics: ['provider busy'], + warnings: [], + }); + return; + } + + resolve({ + ok: true as const, + providerId: 'opencode' as const, + modelId, + diagnostics: [], + 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 resultPromise = svc.prepareForProvisioning(tempRoot, { + providerId: 'opencode', + forceFresh: true, + modelIds: [ + 'opencode/minimax-m2.5-free', + 'opencode/nemotron-3-super-free', + 'opencode/big-pickle', + ], + }); + + await vi.waitFor(() => + expect(started).toEqual([ + 'opencode/minimax-m2.5-free', + 'opencode/nemotron-3-super-free', + ]) + ); + expect(maxActiveCount).toBe(2); + expect(releases.has('opencode/big-pickle')).toBe(false); + + releases.get('opencode/nemotron-3-super-free')?.(); + await vi.waitFor(() => + expect(started).toEqual([ + 'opencode/minimax-m2.5-free', + 'opencode/nemotron-3-super-free', + 'opencode/big-pickle', + ]) + ); + expect(maxActiveCount).toBe(2); + + releases.get('opencode/big-pickle')?.(); + releases.get('opencode/minimax-m2.5-free')?.(); + + const result = await resultPromise; + + expect(result.ready).toBe(true); + expect(result.details).toEqual([ + 'Selected model opencode/minimax-m2.5-free verified for launch.', + 'Selected model opencode/nemotron-3-super-free verified for launch.', + ]); + expect(result.warnings).toEqual([ + 'Selected model opencode/big-pickle could not be verified. provider busy', + ]); + }); + + it('runs OpenCode compatibility-only selected model checks without the deep execution probe', async () => { + const prepare = vi.fn(async (input: { model?: string; runtimeOnly?: boolean }) => ({ + ok: true as const, + providerId: 'opencode' as const, + modelId: input.model ?? null, + diagnostics: [], + warnings: [], + })); + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare, + getLastOpenCodeTeamLaunchReadiness: vi.fn(() => ({ + state: 'ready', + launchAllowed: true, + modelId: 'openrouter/minimax-m2.5-free', + availableModels: [ + 'opencode/minimax-m2.5-free', + 'opencode/nemotron-3-super-free', + ], + opencodeVersion: '1.0.0', + installMethod: 'unknown', + binaryPath: 'opencode', + hostHealthy: true, + appMcpConnected: true, + requiredToolsPresent: true, + permissionBridgeReady: true, + runtimeStoresReady: true, + supportLevel: 'production_supported', + missing: [], + diagnostics: [], + evidence: { + capabilitiesReady: true, + mcpToolProofRoute: 'mcp:tools/list', + observedMcpTools: [], + runtimeStoreReadinessReason: 'runtime_store_manifest_valid', + }, + })), + 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/nemotron-3-super-free'], + modelVerificationMode: 'compatibility', + }); + + expect(result.ready).toBe(true); + expect(result.details).toEqual([ + 'Selected model opencode/minimax-m2.5-free is compatible. Deep verification pending.', + 'Selected model opencode/nemotron-3-super-free is compatible. Deep verification pending.', + ]); + 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, + providerId: 'opencode' as const, + reason: 'not_authenticated', + retryable: true, + diagnostics: ['OpenCode provider authentication failed'], + 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'], + modelVerificationMode: 'compatibility', + }); + + expect(result.ready).toBe(false); + expect(result.message).toBe( + 'Selected model opencode/minimax-m2.5-free could not be verified. OpenCode provider authentication failed' + ); + expect(result.warnings).toEqual([ + 'Selected model opencode/minimax-m2.5-free could not be verified. OpenCode provider authentication failed', + ]); + }); + + it('normalizes unexpected OpenCode model prepare exceptions into a blocking diagnostic', async () => { + const prepare = vi.fn(async (input: { model?: string }) => { + if (input.model === 'opencode/nemotron-3-super-free') { + throw new Error('bridge exploded'); + } + + return { + ok: true as const, + providerId: 'opencode' as const, + modelId: input.model ?? null, + diagnostics: [], + 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/nemotron-3-super-free'], + }); + + expect(result.ready).toBe(false); + expect(result.details).toEqual(['Selected model opencode/minimax-m2.5-free verified for launch.']); + expect(result.message).toBe( + 'Selected model opencode/nemotron-3-super-free is unavailable. bridge exploded' + ); + }); + it('keys the prepare probe cache by cwd', async () => { const svc = new TeamProvisioningService(); vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ diff --git a/test/main/services/team/TeamProvisioningServiceRoster.test.ts b/test/main/services/team/TeamProvisioningServiceRoster.test.ts index aa86048d..c5d43997 100644 --- a/test/main/services/team/TeamProvisioningServiceRoster.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRoster.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; -import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService'; +import { + getMixedLaunchFallbackRecoveryError, + TeamProvisioningService, +} from '@main/services/team/TeamProvisioningService'; describe('TeamProvisioningService (launch roster discovery)', () => { it('inbox fallback keeps -1 names but drops auto-suffixed -2+ when base exists', async () => { @@ -116,4 +119,40 @@ describe('TeamProvisioningService (launch roster discovery)', () => { expect(result.source).toBe('config-fallback'); expect(result.members.map((m: { name: string }) => m.name)).toEqual(['bob']); }); + + it('rejects inbox fallback when it would reconstruct a mixed OpenCode side lane without members.meta truth', async () => { + const svc = new TeamProvisioningService( + {} as never, + { listInboxNames: vi.fn(async () => ['tom']) } as never, + { getMembers: vi.fn(async () => []) } as never, + {} as never + ); + + const configRaw = JSON.stringify({ + name: 't', + members: [{ name: 'tom', role: 'developer', provider: 'opencode', model: 'minimax-m2.5-free' }], + }); + + await expect( + (svc as unknown as any).resolveLaunchExpectedMembers('t', configRaw, 'codex') + ).rejects.toThrow(getMixedLaunchFallbackRecoveryError()); + }); + + it('rejects config fallback when it would reconstruct a mixed OpenCode side lane without members.meta truth', async () => { + const svc = new TeamProvisioningService( + {} as never, + { listInboxNames: vi.fn(async () => []) } as never, + { getMembers: vi.fn(async () => []) } as never, + {} as never + ); + + const configRaw = JSON.stringify({ + name: 't', + members: [{ name: 'tom', role: 'developer', provider: 'opencode', model: 'minimax-m2.5-free' }], + }); + + await expect( + (svc as unknown as any).resolveLaunchExpectedMembers('t', configRaw, 'codex') + ).rejects.toThrow(getMixedLaunchFallbackRecoveryError()); + }); }); diff --git a/test/renderer/components/cli/CliStatusVisibility.test.ts b/test/renderer/components/cli/CliStatusVisibility.test.ts index d0bb16d8..e83f1b47 100644 --- a/test/renderer/components/cli/CliStatusVisibility.test.ts +++ b/test/renderer/components/cli/CliStatusVisibility.test.ts @@ -322,6 +322,7 @@ describe('CLI status visibility during completed install state', () => { }; storeState.updateConfig = vi.fn().mockResolvedValue(undefined); storeState.openExtensionsTab = vi.fn(); + window.localStorage.clear(); }); it('keeps the Multimodel toggle visible and enabled on the dashboard while login is still required', async () => { @@ -631,6 +632,189 @@ describe('CLI status visibility during completed install state', () => { }); }); + it('collapses dashboard provider cards down to the header summary', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + storeState.cliStatus = createInstalledCliStatus({ + flavor: 'agent_teams_orchestrator', + displayName: 'agent_teams_orchestrator', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + authLoggedIn: true, + providers: [ + { + providerId: 'anthropic', + displayName: 'Anthropic', + supported: true, + authenticated: true, + authMethod: 'oauth', + verificationState: 'verified', + statusMessage: 'Connected via Anthropic subscription', + models: ['claude-sonnet-4-5'], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + }, + connection: { + supportsOAuth: true, + supportsApiKey: true, + configurableAuthModes: ['auto', 'oauth', 'api_key'], + configuredAuthMode: 'oauth', + apiKeyConfigured: false, + apiKeySource: null, + apiKeySourceLabel: null, + }, + }, + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Providers: 1/1 connected'); + expect(host.textContent).toContain('Anthropic'); + + const collapseButton = host.querySelector( + 'button[aria-label="Collapse provider details"]' + ) as HTMLButtonElement | null; + expect(collapseButton).not.toBeNull(); + + await act(async () => { + collapseButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Providers: 1/1 connected'); + expect(host.textContent).not.toContain('Anthropic'); + expect(host.textContent).not.toContain('Manage'); + expect( + host.querySelector('button[aria-label="Expand provider details"]') + ).not.toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('restores the collapsed dashboard provider banner after remount', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + storeState.cliStatus = createInstalledCliStatus({ + flavor: 'agent_teams_orchestrator', + displayName: 'agent_teams_orchestrator', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + authLoggedIn: true, + providers: [ + { + providerId: 'codex', + displayName: 'Codex', + supported: true, + authenticated: true, + authMethod: 'chatgpt', + verificationState: 'verified', + statusMessage: 'ChatGPT account ready', + models: ['gpt-5.4'], + canLoginFromUi: false, + capabilities: { + teamLaunch: true, + oneShot: true, + }, + connection: { + supportsOAuth: false, + supportsApiKey: true, + configurableAuthModes: ['auto', 'chatgpt', 'api_key'], + configuredAuthMode: 'chatgpt', + apiKeyConfigured: true, + apiKeySource: 'environment', + apiKeySourceLabel: 'Detected from OPENAI_API_KEY', + codex: { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'pro', + }, + requiresOpenaiAuth: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + }, + }, + backend: { + kind: 'codex-native', + label: 'Codex native', + endpointLabel: 'codex exec --json', + authMethodDetail: 'chatgpt', + }, + }, + ], + }); + + const firstHost = document.createElement('div'); + document.body.appendChild(firstHost); + const firstRoot = createRoot(firstHost); + + await act(async () => { + firstRoot.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + const collapseButton = firstHost.querySelector( + 'button[aria-label="Collapse provider details"]' + ) as HTMLButtonElement | null; + expect(collapseButton).not.toBeNull(); + + await act(async () => { + collapseButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + await act(async () => { + firstRoot.unmount(); + await Promise.resolve(); + }); + + const secondHost = document.createElement('div'); + document.body.appendChild(secondHost); + const secondRoot = createRoot(secondHost); + + await act(async () => { + secondRoot.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + expect(secondHost.textContent).toContain('Providers: 1/1 connected'); + expect(secondHost.textContent).not.toContain('ChatGPT account ready'); + expect( + secondHost.querySelector('button[aria-label="Expand provider details"]') + ).not.toBeNull(); + + await act(async () => { + secondRoot.unmount(); + await Promise.resolve(); + }); + }); + it('shows a degraded runtime warning when a binary is found but the health check fails', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliInstallerState = 'idle'; @@ -1533,6 +1717,17 @@ describe('CLI status visibility during completed install state', () => { expect(host.textContent).toContain( 'Usage limits appear only after Codex refreshes the currently selected ChatGPT session. Right now the local session needs reconnect. API key fallback is available if you switch auth mode.' ); + const reconnectButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Reconnect ChatGPT' + ); + expect(reconnectButton).toBeTruthy(); + + await act(async () => { + reconnectButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(codexAccountHookState.startChatgptLogin).toHaveBeenCalledTimes(1); expect(host.textContent).not.toContain('5h left'); await act(async () => { diff --git a/test/renderer/components/common/GlobalProviderStatusHeader.test.ts b/test/renderer/components/common/GlobalProviderStatusHeader.test.ts new file mode 100644 index 00000000..18d78657 --- /dev/null +++ b/test/renderer/components/common/GlobalProviderStatusHeader.test.ts @@ -0,0 +1,368 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; + +interface StoreState { + cliStatus: Record | null; + cliStatusLoading: boolean; + cliProviderStatusLoading: Record; + appConfig: { + general: { + multimodelEnabled: boolean; + }; + }; + paneLayout: { + focusedPaneId: string; + panes: Array<{ + id: string; + activeTabId: string | null; + tabs: Array<{ + id: string; + type: string; + }>; + }>; + }; +} + +const storeState = {} as StoreState; +const codexAccountHookState = { + snapshot: null as CodexAccountSnapshotDto | null, + loading: false, + error: null as string | null, + refresh: vi.fn(() => Promise.resolve(undefined)), + startChatgptLogin: vi.fn(() => Promise.resolve(true)), + cancelChatgptLogin: vi.fn(() => Promise.resolve(true)), + logout: vi.fn(() => Promise.resolve(true)), +}; + +vi.mock('@renderer/api', () => ({ + isElectronMode: () => true, +})); + +vi.mock('@renderer/components/common/ProviderBrandLogo', () => ({ + ProviderBrandLogo: ({ providerId }: { providerId: string }) => + React.createElement('span', { 'data-testid': `provider-logo-${providerId}` }, providerId), +})); + +vi.mock('@features/codex-account/renderer', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useCodexAccountSnapshot: () => codexAccountHookState, + }; +}); + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: StoreState) => unknown) => selector(storeState), +})); + +import { GlobalProviderStatusHeader } from '@renderer/components/common/GlobalProviderStatusHeader'; + +function createProvider( + overrides: Partial> & { + providerId: string; + displayName: string; + } +): Record { + return { + supported: true, + authenticated: false, + authMethod: null, + verificationState: 'verified', + statusMessage: null, + detailMessage: null, + models: [], + modelVerificationState: 'idle', + modelAvailability: [], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { + plugins: { status: 'unsupported' }, + mcp: { status: 'unsupported' }, + }, + }, + backend: null, + availableBackends: [], + connection: null, + ...overrides, + }; +} + +function createMultimodelStatus(providers: Record[]): Record { + return { + flavor: 'agent_teams_orchestrator', + displayName: 'Multimodel runtime', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + installed: true, + installedVersion: '0.0.3', + binaryPath: '/tmp/claude-multimodel', + latestVersion: null, + updateAvailable: false, + authLoggedIn: providers.some((provider) => provider.authenticated === true), + authStatusChecking: false, + authMethod: null, + providers, + }; +} + +function setFocusedTab(type: string): void { + storeState.paneLayout = { + focusedPaneId: 'pane-1', + panes: [ + { + id: 'pane-1', + activeTabId: type === 'empty' ? null : 'tab-1', + tabs: type === 'empty' ? [] : [{ id: 'tab-1', type }], + }, + ], + }; +} + +describe('GlobalProviderStatusHeader', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = null; + storeState.cliStatusLoading = false; + storeState.cliProviderStatusLoading = {}; + storeState.appConfig = { + general: { + multimodelEnabled: true, + }, + }; + setFocusedTab('team'); + codexAccountHookState.snapshot = null; + codexAccountHookState.loading = false; + codexAccountHookState.error = null; + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('shows loading providers on non-dashboard screens', async () => { + storeState.cliStatus = createMultimodelStatus([ + createProvider({ + providerId: 'anthropic', + displayName: 'Anthropic', + verificationState: 'unknown', + statusMessage: 'Checking...', + }), + ]); + storeState.cliProviderStatusLoading = { anthropic: true }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(GlobalProviderStatusHeader)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Provider Activity'); + expect(host.textContent).toContain('Anthropic'); + expect(host.textContent).toContain('Checking...'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('hides on dashboard tabs', async () => { + setFocusedTab('dashboard'); + storeState.cliStatus = createMultimodelStatus([ + createProvider({ + providerId: 'anthropic', + displayName: 'Anthropic', + verificationState: 'unknown', + statusMessage: 'Checking...', + }), + ]); + storeState.cliProviderStatusLoading = { anthropic: true }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(GlobalProviderStatusHeader)); + await Promise.resolve(); + }); + + expect(host.textContent).toBe(''); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps completed providers visible as Checked while the same cycle still has loading work, then hides when clean', async () => { + storeState.cliStatus = createMultimodelStatus([ + createProvider({ + providerId: 'anthropic', + displayName: 'Anthropic', + verificationState: 'unknown', + statusMessage: 'Checking...', + }), + createProvider({ + providerId: 'codex', + displayName: 'Codex', + verificationState: 'unknown', + statusMessage: 'Checking...', + }), + ]); + storeState.cliProviderStatusLoading = { anthropic: true, codex: true }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(GlobalProviderStatusHeader)); + await Promise.resolve(); + }); + + storeState.cliStatus = createMultimodelStatus([ + createProvider({ + providerId: 'anthropic', + displayName: 'Anthropic', + verificationState: 'verified', + statusMessage: 'Not connected', + }), + createProvider({ + providerId: 'codex', + displayName: 'Codex', + verificationState: 'unknown', + statusMessage: 'Checking...', + }), + ]); + storeState.cliProviderStatusLoading = { anthropic: false, codex: true }; + + await act(async () => { + root.render(React.createElement(GlobalProviderStatusHeader)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Anthropic'); + expect(host.textContent).toContain('Checked'); + expect(host.textContent).toContain('Codex'); + expect(host.textContent).toContain('Checking...'); + + storeState.cliStatus = createMultimodelStatus([ + createProvider({ + providerId: 'anthropic', + displayName: 'Anthropic', + verificationState: 'verified', + statusMessage: 'Not connected', + }), + createProvider({ + providerId: 'codex', + displayName: 'Codex', + verificationState: 'verified', + statusMessage: 'ChatGPT account ready', + authenticated: true, + authMethod: 'chatgpt', + }), + ]); + storeState.cliProviderStatusLoading = { anthropic: false, codex: false }; + + await act(async () => { + root.render(React.createElement(GlobalProviderStatusHeader)); + await Promise.resolve(); + }); + + expect(host.textContent).toBe(''); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('stays visible for provider errors after loading finishes', async () => { + storeState.cliStatus = createMultimodelStatus([ + createProvider({ + providerId: 'anthropic', + displayName: 'Anthropic', + verificationState: 'error', + statusMessage: 'Failed to refresh anthropic status', + }), + ]); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(GlobalProviderStatusHeader)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Anthropic'); + expect(host.textContent).toContain('Failed to refresh anthropic status'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('masks the negative Codex bootstrap snapshot while placeholder loading is still active', async () => { + storeState.cliStatus = null; + storeState.cliStatusLoading = true; + codexAccountHookState.snapshot = { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + launchAllowed: false, + launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.', + launchReadinessState: 'missing_auth', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + apiKey: { + available: false, + source: null, + sourceLabel: null, + }, + requiresOpenaiAuth: true, + localAccountArtifactsPresent: false, + localActiveChatgptAccountPresent: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: new Date().toISOString(), + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(GlobalProviderStatusHeader)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Codex'); + expect(host.textContent).toContain('Checking...'); + expect(host.textContent).not.toContain( + 'Connect a ChatGPT account to use your Codex subscription.' + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts b/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts index ae9e8f75..389b7c7d 100644 --- a/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts +++ b/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts @@ -819,6 +819,9 @@ describe('ProviderRuntimeSettingsDialog', () => { expect(host.textContent).toContain( 'Codex has a locally selected ChatGPT account, but the current session needs reconnect before usage limits can load here. The detected API key is only used after you switch Codex to API key mode.' ); + expect(host.textContent).toContain('Reconnect ChatGPT'); + expect(host.textContent).not.toContain('Disconnect account'); + expect(host.textContent).toContain('Reconnect required'); }); it('disables Codex account actions while a Codex account request is already in flight', async () => { diff --git a/test/renderer/components/team/TeamModelSelector.test.ts b/test/renderer/components/team/TeamModelSelector.test.ts index 9af4cd85..e944ee85 100644 --- a/test/renderer/components/team/TeamModelSelector.test.ts +++ b/test/renderer/components/team/TeamModelSelector.test.ts @@ -119,8 +119,8 @@ describe('formatTeamModelSummary', () => { expect(normalizeTeamModelForUi('codex', 'gpt-5.4', codexProviderStatus)).toBe('gpt-5.4'); }); - it('waits for the runtime model list before validating explicit Codex selections', () => { - expect(getTeamModelSelectionError('codex', 'gpt-5.4')).toContain('waiting for Codex runtime verification'); + it('does not raise a hard validation error while explicit Codex models are still loading', () => { + expect(getTeamModelSelectionError('codex', 'gpt-5.4')).toBeNull(); expect(getTeamModelSelectionError('codex', '')).toBeNull(); expect(getTeamModelSelectionError('anthropic', 'opus')).toBeNull(); expect(getTeamModelSelectionError('anthropic', 'claude-opus-4-7')).toBeNull(); diff --git a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts index 51001be9..b4fd09ab 100644 --- a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts +++ b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts @@ -287,6 +287,16 @@ vi.mock('@renderer/components/team/dialogs/provisioningModelIssues', () => ({ vi.mock('@renderer/components/team/dialogs/ProvisioningProviderStatusList', () => ({ ProvisioningProviderStatusList: () => React.createElement('div', null, 'provider-status-list'), + deriveEffectiveProvisioningPrepareState: ({ + state, + message, + }: { + state: 'idle' | 'loading' | 'ready' | 'failed'; + message: string | null; + }) => ({ + state, + message, + }), failIncompleteProviderChecks: (checks: unknown) => checks, getPrimaryProvisioningFailureDetail: () => null, getProvisioningFailureHint: () => 'hint', @@ -914,4 +924,230 @@ describe('LaunchTeamDialog', () => { await flush(); }); }); + + it('does not restart provider preflight when cli status refresh keeps the same semantic inputs', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + flavor: 'agent_teams_orchestrator', + providers: [ + { + providerId: 'codex', + supported: true, + authenticated: true, + authMethod: 'chatgpt', + verificationState: 'verified', + modelVerificationState: 'verified', + statusMessage: null, + detailMessage: null, + selectedBackendId: 'codex-native', + resolvedBackendId: 'codex-native', + models: ['gpt-5.4'], + modelCatalog: { + source: 'app-server', + status: 'ready', + models: [{ id: 'gpt-5.4' }], + }, + capabilities: { + teamLaunch: true, + oneShot: false, + }, + }, + ], + } as any; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + const renderDialog = async (): Promise => { + root.render( + React.createElement(LaunchTeamDialog, { + mode: 'launch', + open: true, + teamName: 'team-alpha', + members: [], + defaultProjectPath: '/tmp/project', + provisioningError: null, + clearProvisioningError: vi.fn(), + activeTeams: [], + onClose: vi.fn(), + onLaunch: vi.fn(async () => {}), + }) + ); + await flush(); + await flush(); + }; + + await act(async () => { + await renderDialog(); + }); + + expect(vi.mocked(runProviderPrepareDiagnostics)).toHaveBeenCalledTimes(1); + + storeState.cliStatus = { + flavor: 'agent_teams_orchestrator', + providers: [ + { + providerId: 'codex', + supported: true, + authenticated: true, + authMethod: 'chatgpt', + verificationState: 'verified', + modelVerificationState: 'verified', + statusMessage: null, + detailMessage: null, + selectedBackendId: 'codex-native', + resolvedBackendId: 'codex-native', + models: ['gpt-5.4'], + modelCatalog: { + source: 'app-server', + status: 'ready', + models: [{ id: 'gpt-5.4' }], + }, + capabilities: { + teamLaunch: true, + oneShot: false, + }, + }, + ], + } as any; + + await act(async () => { + await renderDialog(); + }); + + expect(vi.mocked(runProviderPrepareDiagnostics)).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await flush(); + }); + }); + + it('keeps the in-flight preflight result after a same-signature rerender', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + flavor: 'agent_teams_orchestrator', + providers: [ + { + providerId: 'opencode', + supported: true, + authenticated: true, + authMethod: 'opencode_managed', + verificationState: 'verified', + modelVerificationState: 'verified', + statusMessage: 'warming up', + detailMessage: 'first render', + models: ['opencode/minimax-m2.5-free'], + modelCatalog: { + source: 'app-server', + status: 'ready', + models: [{ id: 'opencode/minimax-m2.5-free' }], + }, + capabilities: { + teamLaunch: true, + oneShot: false, + }, + }, + ], + } as any; + + let resolvePrepare!: (value: { + status: 'ready'; + warnings: []; + details: []; + modelResultsById: {}; + }) => void; + const preparePromise = new Promise<{ + status: 'ready'; + warnings: []; + details: []; + modelResultsById: {}; + }>((resolve) => { + resolvePrepare = resolve; + }); + vi.mocked(runProviderPrepareDiagnostics).mockReturnValueOnce(preparePromise as any); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + const renderDialog = async (): Promise => { + root.render( + React.createElement(LaunchTeamDialog, { + mode: 'launch', + open: true, + teamName: 'team-alpha', + members: [ + { + name: 'alice', + role: 'Reviewer', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + ] as any, + defaultProjectPath: '/tmp/project', + provisioningError: null, + clearProvisioningError: vi.fn(), + activeTeams: [], + onClose: vi.fn(), + onLaunch: vi.fn(async () => {}), + }) + ); + await flush(); + }; + + await act(async () => { + await renderDialog(); + }); + + storeState.cliStatus = { + flavor: 'agent_teams_orchestrator', + providers: [ + { + providerId: 'opencode', + supported: true, + authenticated: true, + authMethod: 'opencode_managed', + verificationState: 'verified', + modelVerificationState: 'verified', + statusMessage: 'still warming', + detailMessage: 'same semantic status', + models: ['opencode/minimax-m2.5-free'], + modelCatalog: { + source: 'app-server', + status: 'ready', + models: [{ id: 'opencode/minimax-m2.5-free' }], + }, + capabilities: { + teamLaunch: true, + oneShot: false, + }, + }, + ], + } as any; + + await act(async () => { + await renderDialog(); + }); + + await act(async () => { + resolvePrepare({ + status: 'ready', + warnings: [], + details: [], + modelResultsById: {}, + }); + await flush(); + await flush(); + }); + + expect(vi.mocked(runProviderPrepareDiagnostics)).toHaveBeenCalledTimes(1); + expect(host.textContent).toContain('Selected providers are ready.'); + + await act(async () => { + root.unmount(); + await flush(); + }); + }); }); diff --git a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts index cbb07ad7..d4859a99 100644 --- a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts +++ b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { + deriveEffectiveProvisioningPrepareState, getPrimaryProvisioningFailureDetail, getProvisioningProviderBackendSummary, ProvisioningProviderStatusList, @@ -130,6 +131,45 @@ describe('ProvisioningProviderStatusList', () => { }); }); + it('summarizes compatibility-pending OpenCode model checks separately from verified ones', 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(ProvisioningProviderStatusList, { + checks: [ + { + providerId: 'opencode', + status: 'checking', + backendSummary: 'OpenCode CLI', + details: [ + 'minimax-m2.5-free - compatible, deep verification pending...', + 'nemotron-3-super-free - verified', + ], + }, + ], + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain( + 'OpenCode (OpenCode CLI): Selected model checks - 1 compatible, deep verification pending, 1 verified' + ); + expect(host.textContent).toContain( + 'minimax-m2.5-free - compatible, deep verification pending...' + ); + expect(host.textContent).toContain('nemotron-3-super-free - verified'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('normalizes generic preflight timeout notes without depending on a hardcoded CLI name', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); @@ -214,4 +254,76 @@ describe('ProvisioningProviderStatusList', () => { }) ).toBe('Codex native'); }); + + it('promotes loading to ready once every provider check is already terminal', () => { + expect( + deriveEffectiveProvisioningPrepareState({ + state: 'loading', + message: 'Checking selected providers in parallel...', + warnings: [], + checks: [ + { + providerId: 'codex', + status: 'ready', + details: ['5.4 - verified', 'Default - verified'], + }, + { + providerId: 'opencode', + status: 'ready', + details: ['minimax-m2.5-free - verified', 'nemotron-3-super-free - verified'], + }, + ], + }) + ).toEqual({ + state: 'ready', + message: 'Selected providers are ready.', + }); + }); + + it('promotes loading to failed once a terminal provider failure is already known', () => { + expect( + deriveEffectiveProvisioningPrepareState({ + state: 'loading', + message: 'Checking selected providers in parallel...', + warnings: [], + checks: [ + { + 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', + ], + }, + ], + }) + ).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', + }); + }); + + it('shows a more honest loading message while OpenCode deep verification is still pending', () => { + expect( + deriveEffectiveProvisioningPrepareState({ + state: 'loading', + message: 'Checking selected providers in parallel...', + warnings: [], + checks: [ + { + providerId: 'opencode', + status: 'checking', + details: [ + 'minimax-m2.5-free - compatible, deep verification pending...', + 'nemotron-3-super-free - compatible, deep verification pending...', + ], + }, + ], + }) + ).toEqual({ + state: 'loading', + message: + 'Deep verification is still running. OpenCode free models may take around 20 seconds.', + }); + }); }); diff --git a/test/renderer/components/team/dialogs/providerPrepareCacheKey.test.ts b/test/renderer/components/team/dialogs/providerPrepareCacheKey.test.ts index ef5aff67..4a0c42bb 100644 --- a/test/renderer/components/team/dialogs/providerPrepareCacheKey.test.ts +++ b/test/renderer/components/team/dialogs/providerPrepareCacheKey.test.ts @@ -8,6 +8,7 @@ describe('buildProviderPrepareModelCacheKey', () => { cwd: '/tmp/project', providerId: 'anthropic' as const, backendSummary: 'Claude Code', + runtimeStatusSignature: 'status:v1', }; expect( @@ -29,8 +30,30 @@ describe('buildProviderPrepareModelCacheKey', () => { providerId: 'codex' as const, backendSummary: 'Codex native', limitContext: false, + runtimeStatusSignature: 'status:v1', }; expect(buildProviderPrepareModelCacheKey(input)).toBe(buildProviderPrepareModelCacheKey(input)); }); + + it('separates runtime-status variants for the same provider runtime', () => { + const sharedInput = { + cwd: '/tmp/project', + providerId: 'codex' as const, + backendSummary: 'Codex native', + limitContext: false, + }; + + expect( + buildProviderPrepareModelCacheKey({ + ...sharedInput, + runtimeStatusSignature: 'status:v1', + }) + ).not.toBe( + buildProviderPrepareModelCacheKey({ + ...sharedInput, + runtimeStatusSignature: 'status:v2', + }) + ); + }); }); diff --git a/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts index c4ec3bc9..baede8bd 100644 --- a/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts +++ b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts @@ -59,7 +59,9 @@ describe('runProviderPrepareDiagnostics', () => { cwd?: string, providerId?: TeamProviderId, providerIds?: TeamProviderId[], - selectedModels?: string[] + selectedModels?: string[], + limitContext?: boolean, + modelVerificationMode?: 'compatibility' | 'deep' ) => Promise >().mockResolvedValue({ ready: false, @@ -80,8 +82,12 @@ describe('runProviderPrepareDiagnostics', () => { it('batches uncached model probes per provider and keeps failures scoped to the affected model', async () => { const deferredBatch = createDeferred(); - const progressUpdates: Array<{ details: string[]; completedCount: number; totalCount: number }> = - []; + const progressUpdates: Array<{ + status: 'checking' | 'ready' | 'notes' | 'failed'; + details: string[]; + completedCount: number; + totalCount: number; + }> = []; const prepareProvisioning = vi.fn< ( @@ -91,12 +97,6 @@ describe('runProviderPrepareDiagnostics', () => { selectedModels?: string[] ) => Promise >((_, __, ___, selectedModels) => { - if (!selectedModels || selectedModels.length === 0) { - return Promise.resolve({ - ready: true, - message: 'CLI is warmed up and ready to launch', - }); - } expect(selectedModels).toEqual(['gpt-5.4', 'gpt-5.2-codex']); return deferredBatch.promise; }); @@ -111,6 +111,7 @@ describe('runProviderPrepareDiagnostics', () => { await Promise.resolve(); expect(progressUpdates[0]).toEqual({ + status: 'checking', completedCount: 0, totalCount: 2, details: ['5.4 - checking...', '5.2 Codex - checking...'], @@ -132,6 +133,7 @@ describe('runProviderPrepareDiagnostics', () => { '5.2 Codex - unavailable - Not available on this Codex native runtime', ]); expect(progressUpdates.at(-1)).toEqual({ + status: 'failed', completedCount: 2, totalCount: 2, details: [ @@ -139,7 +141,117 @@ describe('runProviderPrepareDiagnostics', () => { '5.2 Codex - unavailable - Not available on this Codex native runtime', ], }); - expect(prepareProvisioning).toHaveBeenCalledTimes(2); + expect(prepareProvisioning).toHaveBeenCalledTimes(1); + }); + + it('runs OpenCode uncached selected models through compatibility first and deep verification second', async () => { + const deferredCompatibility = createDeferred(); + const deferredDeep = createDeferred(); + const progressUpdates: Array<{ + status: 'checking' | 'ready' | 'notes' | 'failed'; + details: string[]; + completedCount: number; + totalCount: number; + }> = []; + + const prepareProvisioning = vi.fn( + ( + _cwd?: string, + _providerId?: TeamProviderId, + _providerIds?: TeamProviderId[], + selectedModels?: string[], + _limitContext?: boolean, + modelVerificationMode?: 'compatibility' | 'deep' + ) => { + if (modelVerificationMode === 'compatibility') { + expect(selectedModels).toEqual([ + 'opencode/minimax-m2.5-free', + 'opencode/nemotron-3-super-free', + ]); + return deferredCompatibility.promise; + } + expect(modelVerificationMode).toBe('deep'); + expect(selectedModels).toEqual([ + 'opencode/minimax-m2.5-free', + 'opencode/nemotron-3-super-free', + ]); + return deferredDeep.promise; + } + ); + + const resultPromise = runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'opencode', + selectedModelIds: ['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'], + prepareProvisioning, + onModelProgress: (progress) => progressUpdates.push(progress), + }); + + await Promise.resolve(); + expect(progressUpdates[0]).toEqual({ + status: 'checking', + completedCount: 0, + totalCount: 2, + details: ['minimax-m2.5-free - checking...', 'nemotron-3-super-free - checking...'], + }); + + deferredCompatibility.resolve({ + ready: true, + message: 'CLI is ready to launch', + details: [ + 'Selected model opencode/minimax-m2.5-free is compatible. Deep verification pending.', + 'Selected model opencode/nemotron-3-super-free is compatible. Deep verification pending.', + ], + warnings: [], + }); + + await vi.waitFor(() => + expect(progressUpdates.at(-1)).toEqual({ + status: 'checking', + completedCount: 0, + totalCount: 2, + details: [ + 'minimax-m2.5-free - compatible, deep verification pending...', + 'nemotron-3-super-free - compatible, deep verification pending...', + ], + }) + ); + + deferredDeep.resolve({ + ready: true, + message: 'CLI is ready to launch', + details: [ + 'Selected model opencode/minimax-m2.5-free verified for launch.', + 'Selected model opencode/nemotron-3-super-free verified for launch.', + ], + warnings: [], + }); + + const result = await resultPromise; + + expect(result.status).toBe('ready'); + expect(result.details).toEqual([ + 'minimax-m2.5-free - verified', + 'nemotron-3-super-free - verified', + ]); + expect(prepareProvisioning).toHaveBeenNthCalledWith( + 1, + '/tmp/project', + 'opencode', + ['opencode'], + ['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'], + undefined, + 'compatibility' + ); + expect(prepareProvisioning).toHaveBeenNthCalledWith( + 2, + '/tmp/project', + 'opencode', + ['opencode'], + ['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'], + undefined, + 'deep' + ); }); it('normalizes raw Codex API error envelopes into a clean model reason', async () => { @@ -151,12 +263,6 @@ describe('runProviderPrepareDiagnostics', () => { selectedModels?: string[] ) => Promise >((_, __, ___, selectedModels) => { - if (!selectedModels || selectedModels.length === 0) { - return Promise.resolve({ - ready: true, - message: 'CLI is warmed up and ready to launch', - }); - } return Promise.resolve({ ready: false, message: @@ -186,12 +292,6 @@ describe('runProviderPrepareDiagnostics', () => { selectedModels?: string[] ) => Promise >((_, __, ___, selectedModels) => { - if (!selectedModels || selectedModels.length === 0) { - return Promise.resolve({ - ready: true, - message: 'CLI is warmed up and ready to launch', - }); - } return Promise.resolve({ ready: true, message: 'CLI is warmed up and ready to launch', @@ -213,8 +313,12 @@ describe('runProviderPrepareDiagnostics', () => { }); it('renders the provider default model as a dedicated Default check line', async () => { - const progressUpdates: Array<{ details: string[]; completedCount: number; totalCount: number }> = - []; + const progressUpdates: Array<{ + status: 'checking' | 'ready' | 'notes' | 'failed'; + details: string[]; + completedCount: number; + totalCount: number; + }> = []; const prepareProvisioning = vi.fn< ( cwd?: string, @@ -223,12 +327,6 @@ describe('runProviderPrepareDiagnostics', () => { selectedModels?: string[] ) => Promise >((_, __, ___, selectedModels) => { - if (!selectedModels || selectedModels.length === 0) { - return Promise.resolve({ - ready: true, - message: 'CLI is warmed up and ready to launch', - }); - } return Promise.resolve({ ready: true, message: 'CLI is warmed up and ready to launch', @@ -245,6 +343,7 @@ describe('runProviderPrepareDiagnostics', () => { }); expect(progressUpdates[0]).toEqual({ + status: 'checking', completedCount: 0, totalCount: 1, details: ['Default - checking...'], @@ -263,12 +362,6 @@ describe('runProviderPrepareDiagnostics', () => { limitContext?: boolean ) => Promise >((_, __, ___, selectedModels) => { - if (!selectedModels || selectedModels.length === 0) { - return Promise.resolve({ - ready: true, - message: 'CLI is warmed up and ready to launch', - }); - } return Promise.resolve({ ready: true, message: 'CLI is warmed up and ready to launch', @@ -285,27 +378,18 @@ describe('runProviderPrepareDiagnostics', () => { }); expect(result.details).toEqual(['Default - verified']); - expect(prepareProvisioning).toHaveBeenNthCalledWith( - 1, - '/tmp/project', - 'anthropic', - ['anthropic'], - undefined, - true - ); - expect(prepareProvisioning).toHaveBeenNthCalledWith( - 2, - '/tmp/project', - 'anthropic', - ['anthropic'], - [DEFAULT_PROVIDER_MODEL_SELECTION], - true - ); + expect(prepareProvisioning).toHaveBeenNthCalledWith(1, '/tmp/project', 'anthropic', ['anthropic'], [ + DEFAULT_PROVIDER_MODEL_SELECTION, + ], true); }); it('reuses cached model results and probes only newly selected models', async () => { - const progressUpdates: Array<{ details: string[]; completedCount: number; totalCount: number }> = - []; + const progressUpdates: Array<{ + status: 'checking' | 'ready' | 'notes' | 'failed'; + details: string[]; + completedCount: number; + totalCount: number; + }> = []; const prepareProvisioning = vi.fn< ( cwd?: string, @@ -314,13 +398,6 @@ describe('runProviderPrepareDiagnostics', () => { selectedModels?: string[] ) => Promise >((_, __, ___, selectedModels) => { - if (!selectedModels || selectedModels.length === 0) { - return Promise.resolve({ - ready: true, - message: 'CLI is warmed up and ready to launch', - }); - } - expect(selectedModels).toEqual(['gpt-5.2-codex']); return Promise.resolve({ ready: false, @@ -350,6 +427,7 @@ describe('runProviderPrepareDiagnostics', () => { }); expect(progressUpdates[0]).toEqual({ + status: 'checking', completedCount: 2, totalCount: 3, details: ['5.2 - verified', '5.4 Mini - verified', '5.2 Codex - checking...'], @@ -359,16 +437,8 @@ describe('runProviderPrepareDiagnostics', () => { '5.4 Mini - verified', '5.2 Codex - unavailable - Not available on this Codex native runtime', ]); - expect(prepareProvisioning).toHaveBeenCalledTimes(2); - expect(prepareProvisioning).toHaveBeenNthCalledWith( - 1, - '/tmp/project', - 'codex', - ['codex'], - undefined, - undefined - ); - expect(prepareProvisioning).toHaveBeenNthCalledWith(2, '/tmp/project', 'codex', ['codex'], [ + expect(prepareProvisioning).toHaveBeenCalledTimes(1); + expect(prepareProvisioning).toHaveBeenNthCalledWith(1, '/tmp/project', 'codex', ['codex'], [ 'gpt-5.2-codex', ], undefined); }); @@ -382,23 +452,16 @@ describe('runProviderPrepareDiagnostics', () => { selectedModels?: string[] ) => Promise >((_, __, ___, selectedModels) => { - if (!selectedModels || selectedModels.length === 0) { - return Promise.resolve({ - ready: true, - message: 'CLI is ready to launch (see notes)', - warnings: [ - 'Preflight check for `orchestrator-cli -p` did not complete. Proceeding anyway. Details: Timeout running: orchestrator-cli -p Output only the single word PONG. --output-format text --model gpt-5.4-mini --max-turns 1 --no-session-persistence', - ], - }); - } - return Promise.resolve({ ready: true, - message: 'CLI is warmed up and ready to launch', + message: 'CLI is ready to launch (see notes)', details: [ 'Selected model gpt-5.4-mini verified for launch.', 'Selected model gpt-5.4 verified for launch.', ], + warnings: [ + 'Preflight check for `orchestrator-cli -p` did not complete. Proceeding anyway. Details: Timeout running: orchestrator-cli -p Output only the single word PONG. --output-format text --model gpt-5.4-mini --max-turns 1 --no-session-persistence', + ], }); }); @@ -423,14 +486,6 @@ describe('runProviderPrepareDiagnostics', () => { selectedModels?: string[] ) => Promise >((_, __, ___, selectedModels) => { - if (!selectedModels || selectedModels.length === 0) { - return Promise.resolve({ - ready: true, - message: 'CLI is ready to launch (see notes)', - warnings: ['orchestrator-cli preflight check failed (exit code 1).'], - }); - } - return Promise.resolve({ ready: true, message: 'CLI is ready to launch (see notes)', @@ -457,7 +512,13 @@ describe('runProviderPrepareDiagnostics', () => { }); }); - it('prefers detailed OpenCode auth diagnostics over a generic not_authenticated batch message', async () => { + it('suppresses a generic runtime preflight note during progress when cached selected models are already verified', async () => { + const progressUpdates: Array<{ + status: 'checking' | 'ready' | 'notes' | 'failed'; + details: string[]; + completedCount: number; + totalCount: number; + }> = []; const prepareProvisioning = vi.fn< ( cwd?: string, @@ -469,10 +530,61 @@ describe('runProviderPrepareDiagnostics', () => { if (!selectedModels || selectedModels.length === 0) { return Promise.resolve({ ready: true, - message: 'CLI is warmed up and ready to launch', + message: 'CLI is ready to launch (see notes)', + warnings: ['orchestrator-cli preflight check failed (exit code 1).'], }); } + return Promise.resolve({ + ready: true, + message: 'CLI is ready to launch (see notes)', + warnings: ['orchestrator-cli preflight check failed (exit code 1).'], + }); + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'codex', + selectedModelIds: [DEFAULT_PROVIDER_MODEL_SELECTION, 'gpt-5.4'], + prepareProvisioning, + onModelProgress: (progress) => progressUpdates.push(progress), + cachedModelResultsById: { + [DEFAULT_PROVIDER_MODEL_SELECTION]: { + status: 'ready', + line: 'Default - verified', + warningLine: null, + }, + 'gpt-5.4': { + status: 'ready', + line: '5.4 - verified', + warningLine: null, + }, + }, + }); + + expect(prepareProvisioning).toHaveBeenCalledTimes(1); + expect(progressUpdates).toEqual([ + { + status: 'ready', + completedCount: 2, + totalCount: 2, + details: ['Default - verified', '5.4 - verified'], + }, + ]); + expect(result.status).toBe('ready'); + expect(result.warnings).toEqual([]); + expect(result.details).toEqual(['Default - verified', '5.4 - verified']); + }); + + it('prefers detailed OpenCode auth diagnostics over a generic not_authenticated batch message', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: TeamProviderId, + providerIds?: TeamProviderId[], + selectedModels?: string[] + ) => Promise + >((_, __, ___, selectedModels) => { return Promise.resolve({ ready: false, message: 'OpenCode: not_authenticated', diff --git a/test/renderer/components/team/dialogs/providerPrepareRequestSignature.test.ts b/test/renderer/components/team/dialogs/providerPrepareRequestSignature.test.ts new file mode 100644 index 00000000..e220ee5b --- /dev/null +++ b/test/renderer/components/team/dialogs/providerPrepareRequestSignature.test.ts @@ -0,0 +1,464 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildProviderPrepareMembersSignature, + buildProviderPrepareModelChecksSignature, + buildProviderPrepareRequestSignature, + buildProviderPrepareRuntimeStatusSignature, +} from '@renderer/components/team/dialogs/providerPrepareRequestSignature'; + +describe('providerPrepareRequestSignature', () => { + it('stays stable for semantically identical provider runtime snapshots', () => { + const providerIds = ['codex'] as const; + const first = buildProviderPrepareRuntimeStatusSignature( + providerIds, + new Map([ + [ + 'codex', + { + providerId: 'codex', + supported: true, + authenticated: true, + authMethod: 'chatgpt', + verificationState: 'verified', + modelVerificationState: 'verified', + statusMessage: null, + detailMessage: null, + selectedBackendId: 'codex-native', + resolvedBackendId: 'codex-native', + models: ['gpt-5.4', 'gpt-5.4-mini'], + modelCatalog: { + source: 'app-server', + status: 'ready', + models: [{ id: 'gpt-5.4-mini' }, { id: 'gpt-5.4' }], + }, + availableBackends: [ + { + id: 'codex-native', + available: true, + selectable: true, + state: 'ready', + recommended: true, + audience: 'general', + }, + ], + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { + displayAvailable: true, + installAvailable: true, + }, + }, + canLoginFromUi: true, + }, + ], + ]) as any + ); + const second = buildProviderPrepareRuntimeStatusSignature( + providerIds, + new Map([ + [ + 'codex', + { + providerId: 'codex', + supported: true, + authenticated: true, + authMethod: 'chatgpt', + verificationState: 'verified', + modelVerificationState: 'verified', + statusMessage: null, + detailMessage: null, + selectedBackendId: 'codex-native', + resolvedBackendId: 'codex-native', + models: ['gpt-5.4-mini', 'gpt-5.4'], + modelCatalog: { + source: 'app-server', + status: 'ready', + models: [{ id: 'gpt-5.4' }, { id: 'gpt-5.4-mini' }], + }, + availableBackends: [ + { + id: 'codex-native', + available: true, + selectable: true, + state: 'ready', + recommended: true, + audience: 'general', + }, + ], + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { + displayAvailable: true, + installAvailable: true, + }, + }, + canLoginFromUi: true, + }, + ], + ]) as any + ); + + expect(first).toBe(second); + }); + + it('changes when a provider auth/runtime field that affects preflight changes', () => { + const providerIds = ['codex'] as const; + const authenticated = buildProviderPrepareRuntimeStatusSignature( + providerIds, + new Map([ + [ + 'codex', + { + providerId: 'codex', + supported: true, + authenticated: true, + authMethod: 'chatgpt', + verificationState: 'verified', + models: ['gpt-5.4'], + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { + displayAvailable: true, + installAvailable: true, + }, + }, + canLoginFromUi: true, + }, + ], + ]) as any + ); + const unauthenticated = buildProviderPrepareRuntimeStatusSignature( + providerIds, + new Map([ + [ + 'codex', + { + providerId: 'codex', + supported: true, + authenticated: false, + authMethod: null, + verificationState: 'error', + detailMessage: 'Reconnect required', + models: ['gpt-5.4'], + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { + displayAvailable: true, + installAvailable: true, + }, + }, + canLoginFromUi: true, + }, + ], + ]) as any + ); + + expect(authenticated).not.toBe(unauthenticated); + }); + + it('changes when provider connection auth truth changes even if model lists stay the same', () => { + const providerIds = ['codex'] as const; + const first = buildProviderPrepareRuntimeStatusSignature( + providerIds, + new Map([ + [ + 'codex', + { + providerId: 'codex', + supported: true, + authenticated: true, + authMethod: 'chatgpt', + verificationState: 'verified', + models: ['gpt-5.4'], + connection: { + supportsOAuth: false, + supportsApiKey: true, + configurableAuthModes: ['auto', 'chatgpt', 'api_key'], + configuredAuthMode: 'chatgpt', + apiKeyConfigured: true, + apiKeySource: 'environment', + codex: { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + }, + requiresOpenaiAuth: false, + localAccountArtifactsPresent: true, + localActiveChatgptAccountPresent: true, + login: { + status: 'idle', + error: null, + }, + rateLimits: null, + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + }, + }, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { + displayAvailable: true, + installAvailable: true, + }, + }, + canLoginFromUi: true, + }, + ], + ]) as any + ); + const second = buildProviderPrepareRuntimeStatusSignature( + providerIds, + new Map([ + [ + 'codex', + { + providerId: 'codex', + supported: true, + authenticated: true, + authMethod: 'chatgpt', + verificationState: 'verified', + models: ['gpt-5.4'], + connection: { + supportsOAuth: false, + supportsApiKey: true, + configurableAuthModes: ['auto', 'chatgpt', 'api_key'], + configuredAuthMode: 'api_key', + apiKeyConfigured: true, + apiKeySource: 'environment', + codex: { + preferredAuthMode: 'auto', + effectiveAuthMode: 'api_key', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + }, + requiresOpenaiAuth: false, + localAccountArtifactsPresent: true, + localActiveChatgptAccountPresent: true, + login: { + status: 'idle', + error: null, + }, + rateLimits: null, + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_api_key', + }, + }, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { + displayAvailable: true, + installAvailable: true, + }, + }, + canLoginFromUi: true, + }, + ], + ]) as any + ); + + expect(first).not.toBe(second); + }); + + it('ignores volatile provider status copy that should not retrigger preflight', () => { + const providerIds = ['opencode'] as const; + const first = buildProviderPrepareRuntimeStatusSignature( + providerIds, + new Map([ + [ + 'opencode', + { + providerId: 'opencode', + supported: true, + authenticated: true, + authMethod: 'oauth', + verificationState: 'verified', + modelVerificationState: 'verified', + statusMessage: 'Syncing provider details...', + detailMessage: 'Polling host readiness', + models: ['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'], + modelCatalog: { + source: 'live', + status: 'ready', + models: [ + { id: 'opencode/minimax-m2.5-free' }, + { id: 'opencode/nemotron-3-super-free' }, + ], + }, + modelAvailability: [ + { + modelId: 'opencode/minimax-m2.5-free', + status: 'available', + reason: 'Warm host pending', + }, + ], + }, + ], + ]) as any + ); + const second = buildProviderPrepareRuntimeStatusSignature( + providerIds, + new Map([ + [ + 'opencode', + { + providerId: 'opencode', + supported: true, + authenticated: true, + authMethod: 'oauth', + verificationState: 'verified', + modelVerificationState: 'verified', + statusMessage: 'Healthy', + detailMessage: 'MCP ready', + models: ['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'], + modelCatalog: { + source: 'live', + status: 'ready', + models: [ + { id: 'opencode/minimax-m2.5-free' }, + { id: 'opencode/nemotron-3-super-free' }, + ], + }, + modelAvailability: [ + { + modelId: 'opencode/minimax-m2.5-free', + status: 'available', + reason: 'Deep probe still running', + }, + ], + }, + ], + ]) as any + ); + + expect(first).toBe(second); + }); + + it('ignores live verification fields that can drift while preflight is already running', () => { + const providerIds = ['opencode'] as const; + const first = buildProviderPrepareRuntimeStatusSignature( + providerIds, + new Map([ + [ + 'opencode', + { + providerId: 'opencode', + supported: true, + authenticated: true, + authMethod: 'oauth', + verificationState: 'unknown', + modelVerificationState: 'unknown', + models: ['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'], + modelCatalog: { + source: 'live', + status: 'ready', + models: [ + { id: 'opencode/minimax-m2.5-free' }, + { id: 'opencode/nemotron-3-super-free' }, + ], + }, + modelAvailability: [ + { + modelId: 'opencode/minimax-m2.5-free', + status: 'unknown', + reason: null, + }, + ], + }, + ], + ]) as any + ); + const second = buildProviderPrepareRuntimeStatusSignature( + providerIds, + new Map([ + [ + 'opencode', + { + providerId: 'opencode', + supported: true, + authenticated: true, + authMethod: 'oauth', + verificationState: 'verified', + modelVerificationState: 'verified', + models: ['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'], + modelCatalog: { + source: 'live', + status: 'ready', + models: [ + { id: 'opencode/minimax-m2.5-free' }, + { id: 'opencode/nemotron-3-super-free' }, + ], + }, + modelAvailability: [ + { + modelId: 'opencode/minimax-m2.5-free', + status: 'available', + reason: 'verified', + }, + ], + }, + ], + ]) as any + ); + + expect(first).toBe(second); + }); + + it('builds a stable composite request signature for unchanged member/model selections', () => { + const membersSignature = buildProviderPrepareMembersSignature([ + { + id: 'member-1', + name: 'alice', + roleSelection: '', + customRole: 'Reviewer', + providerId: 'codex', + model: 'gpt-5.4', + }, + ]); + const modelChecksSignature = buildProviderPrepareModelChecksSignature( + new Map([ + ['codex', ['gpt-5.4', 'default']], + ['opencode', ['opencode/nemotron-3-super-free']], + ]) + ); + + expect( + buildProviderPrepareRequestSignature({ + cwd: '/tmp/project', + selectedProviderId: 'codex', + selectedModel: 'gpt-5.4', + selectedMemberProviders: ['codex', 'opencode'], + limitContext: false, + runtimeStatusSignature: 'runtime-a', + membersSignature, + modelChecksSignature, + }) + ).toBe( + buildProviderPrepareRequestSignature({ + cwd: '/tmp/project', + selectedProviderId: 'codex', + selectedModel: 'gpt-5.4', + selectedMemberProviders: ['opencode', 'codex'], + limitContext: false, + runtimeStatusSignature: 'runtime-a', + membersSignature, + modelChecksSignature, + }) + ); + }); +}); diff --git a/test/renderer/components/team/dialogs/providerPrepareShortLivedCache.test.ts b/test/renderer/components/team/dialogs/providerPrepareShortLivedCache.test.ts new file mode 100644 index 00000000..e2a30896 --- /dev/null +++ b/test/renderer/components/team/dialogs/providerPrepareShortLivedCache.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + __resetShortLivedProviderPrepareCacheForTests, + getShortLivedProviderPrepareModelResults, + storeShortLivedProviderPrepareModelResults, +} from '@renderer/components/team/dialogs/providerPrepareShortLivedCache'; + +describe('providerPrepareShortLivedCache', () => { + afterEach(() => { + __resetShortLivedProviderPrepareCacheForTests(); + vi.useRealTimers(); + }); + + it('stores only successful OpenCode deep verification results', () => { + storeShortLivedProviderPrepareModelResults({ + providerId: 'opencode', + cacheKey: 'key-1', + modelResultsById: { + 'opencode/minimax-m2.5-free': { + status: 'ready', + line: 'minimax-m2.5-free - verified', + warningLine: null, + }, + 'opencode/nemotron-3-super-free': { + status: 'notes', + line: 'nemotron-3-super-free - check failed - timed out', + warningLine: 'nemotron-3-super-free - check failed - timed out', + }, + }, + }); + + expect( + getShortLivedProviderPrepareModelResults({ + providerId: 'opencode', + cacheKey: 'key-1', + }) + ).toEqual({ + 'opencode/minimax-m2.5-free': { + status: 'ready', + line: 'minimax-m2.5-free - verified', + warningLine: null, + }, + }); + }); + + it('expires cached OpenCode results after the short-lived TTL', () => { + vi.useFakeTimers(); + storeShortLivedProviderPrepareModelResults({ + providerId: 'opencode', + cacheKey: 'key-2', + modelResultsById: { + 'opencode/minimax-m2.5-free': { + status: 'ready', + line: 'minimax-m2.5-free - verified', + warningLine: null, + }, + }, + }); + + vi.advanceTimersByTime(45_001); + + expect( + getShortLivedProviderPrepareModelResults({ + providerId: 'opencode', + cacheKey: 'key-2', + }) + ).toEqual({}); + }); + + it('does not store short-lived cache for non-OpenCode providers', () => { + storeShortLivedProviderPrepareModelResults({ + providerId: 'codex', + cacheKey: 'key-3', + modelResultsById: { + 'gpt-5.4': { + status: 'ready', + line: '5.4 - verified', + warningLine: null, + }, + }, + }); + + expect( + getShortLivedProviderPrepareModelResults({ + providerId: 'codex', + cacheKey: 'key-3', + }) + ).toEqual({}); + }); +}); diff --git a/test/renderer/components/team/dialogs/provisioningMemberScope.test.ts b/test/renderer/components/team/dialogs/provisioningMemberScope.test.ts new file mode 100644 index 00000000..0caf5082 --- /dev/null +++ b/test/renderer/components/team/dialogs/provisioningMemberScope.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { collectActiveMemberProviderIds } from '@renderer/components/team/dialogs/provisioningMemberScope'; + +import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes'; + +function member(overrides: Partial = {}): MemberDraft { + return { + id: overrides.id ?? 'member-1', + name: overrides.name ?? 'alice', + roleSelection: overrides.roleSelection ?? 'developer', + customRole: overrides.customRole ?? '', + ...overrides, + }; +} + +describe('collectActiveMemberProviderIds', () => { + it('collects only active member provider ids', () => { + expect( + collectActiveMemberProviderIds([ + member({ id: '1', providerId: 'codex' }), + member({ id: '2', providerId: 'opencode' }), + member({ id: '3', providerId: 'codex', removedAt: Date.now() }), + member({ id: '4' }), + ]) + ).toEqual(['codex', 'opencode']); + }); + + it('ignores removed members even when they still carry provider overrides', () => { + expect( + collectActiveMemberProviderIds([ + member({ id: '1', providerId: 'codex', removedAt: Date.now() }), + member({ id: '2', providerId: 'gemini', removedAt: '2026-04-22T00:00:00.000Z' }), + ]) + ).toEqual([]); + }); +}); diff --git a/test/renderer/store/cliInstallerSlice.test.ts b/test/renderer/store/cliInstallerSlice.test.ts index 9b0b5ef2..12385635 100644 --- a/test/renderer/store/cliInstallerSlice.test.ts +++ b/test/renderer/store/cliInstallerSlice.test.ts @@ -127,6 +127,9 @@ describe('cliInstallerSlice', () => { // Reset store state useStore.setState({ cliStatus: null, + cliStatusLoading: false, + cliProviderStatusLoading: {}, + cliStatusError: null, cliInstallerState: 'idle', cliDownloadProgress: 0, cliDownloadTransferred: 0, @@ -705,6 +708,100 @@ describe('cliInstallerSlice', () => { }); }); + describe('fetchCliProviderStatus', () => { + it('materializes provider fetch failures into provider-scoped error state', async () => { + useStore.setState({ + cliStatus: createMultimodelStatus([ + createMultimodelProvider({ + providerId: 'anthropic', + displayName: 'Anthropic', + verificationState: 'unknown', + statusMessage: 'Checking...', + }), + createMultimodelProvider({ + providerId: 'codex', + displayName: 'Codex', + authenticated: true, + authMethod: 'chatgpt', + statusMessage: 'ChatGPT account ready', + }), + ]), + }); + vi.mocked(api.cliInstaller.getProviderStatus).mockRejectedValue( + new Error('Failed to refresh anthropic status') + ); + + await useStore.getState().fetchCliProviderStatus('anthropic'); + + expect(useStore.getState().cliProviderStatusLoading).toEqual({ + anthropic: false, + }); + expect(useStore.getState().cliStatusError).toBe('Failed to refresh anthropic status'); + expect( + useStore.getState().cliStatus?.providers.find((provider) => provider.providerId === 'anthropic') + ).toMatchObject({ + displayName: 'Anthropic', + authenticated: false, + authMethod: null, + verificationState: 'error', + statusMessage: 'Failed to refresh anthropic status', + }); + expect(useStore.getState().cliStatus?.authStatusChecking).toBe(false); + }); + + it('marks authStatusChecking true while a multimodel provider refresh is in flight and clears it on success', async () => { + let resolveProviderStatus!: (value: CliInstallationStatus['providers'][number]) => void; + const pendingProviderStatus = new Promise((resolve) => { + resolveProviderStatus = resolve; + }); + + useStore.setState({ + cliStatus: createMultimodelStatus([ + createMultimodelProvider({ + providerId: 'anthropic', + displayName: 'Anthropic', + authenticated: true, + authMethod: 'oauth_token', + statusMessage: 'Connected', + }), + ]), + }); + vi.mocked(api.cliInstaller.getProviderStatus).mockImplementation(async (providerId) => { + if (providerId === 'anthropic') { + return pendingProviderStatus; + } + + throw new Error(`Unexpected provider status request for ${providerId}`); + }); + + const refreshPromise = useStore.getState().fetchCliProviderStatus('anthropic'); + + await vi.waitFor(() => { + expect(useStore.getState().cliStatus?.authStatusChecking).toBe(true); + }); + + expect(useStore.getState().cliProviderStatusLoading).toEqual({ + anthropic: true, + }); + + resolveProviderStatus( + createMultimodelProvider({ + providerId: 'anthropic', + displayName: 'Anthropic', + authenticated: true, + authMethod: 'oauth_token', + statusMessage: 'Connected', + }) + ); + await refreshPromise; + + expect(useStore.getState().cliProviderStatusLoading).toEqual({ + anthropic: false, + }); + expect(useStore.getState().cliStatus?.authStatusChecking).toBe(false); + }); + }); + describe('progress event handling', () => { it('updates download progress from events', () => { useStore.setState({ diff --git a/test/renderer/utils/teamModelAvailability.test.ts b/test/renderer/utils/teamModelAvailability.test.ts index dc5d9b8e..04249b71 100644 --- a/test/renderer/utils/teamModelAvailability.test.ts +++ b/test/renderer/utils/teamModelAvailability.test.ts @@ -184,10 +184,8 @@ describe('teamModelAvailability', () => { expect(getTeamModelSelectionError('codex', 'gpt-5.4', providerStatus)).toBeNull(); }); - it('waits for the runtime model list before validating explicit Codex selections', () => { - expect(getTeamModelSelectionError('codex', 'gpt-5.4')).toContain( - 'waiting for Codex runtime verification' - ); + it('does not raise a hard validation error while explicit Codex models are still loading', () => { + expect(getTeamModelSelectionError('codex', 'gpt-5.4')).toBeNull(); expect(getTeamModelSelectionError('codex', '')).toBeNull(); }); @@ -232,6 +230,25 @@ describe('teamModelAvailability', () => { ]); }); + it('keeps known Codex selections stable while Codex native account truth is loaded before the runtime model catalog', () => { + const providerStatus = createCodexProviderStatus([], { + authMethod: 'chatgpt', + backend: { + kind: 'codex-native', + label: 'Codex native', + endpointLabel: 'codex exec --json', + }, + authenticated: true, + supported: true, + verificationState: 'verified', + modelVerificationState: 'idle', + statusMessage: 'ChatGPT account ready', + }); + + expect(normalizeTeamModelForUi('codex', 'gpt-5.4', providerStatus)).toBe('gpt-5.4'); + expect(getTeamModelSelectionError('codex', 'gpt-5.4', providerStatus)).toBeNull(); + }); + it('keeps runtime models selectable without per-model verification state', () => { const providerStatus = createCodexProviderStatus(['gpt-5.4']); expect(normalizeTeamModelForUi('codex', 'gpt-5.4', providerStatus)).toBe('gpt-5.4');