feat(team): harden launch liveness and recovery
This commit is contained in:
parent
2c286a453e
commit
3fe9a24e41
64 changed files with 2423 additions and 3660 deletions
|
|
@ -320,6 +320,7 @@ pnpm dist # macOS + Windows + Linux
|
|||
- [ ] Run terminal commands
|
||||
- [ ] Monitor agents processes/stats
|
||||
- [ ] Reusable agents with SOUL.md
|
||||
- [ ] Сommunicate via messenger
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@
|
|||
- Runtime tools принимают `metadata`, но `recordOpenCodeRuntimeBootstrapCheckin()` и `recordOpenCodeRuntimeHeartbeat()` сейчас используют только `diagnostics`. Если runtime присылает PID/version/command в `metadata`, эта информация теряется.
|
||||
- `handleMemberSpawnToolResult()` раньше при reason `already_running` делал `setMemberSpawnStatus(..., "online", ..., "process")`. В strict model это заменено на `waiting` + runtime re-evaluation.
|
||||
- `waitForTmuxPanesToExit()` использует `listTmuxPanePidsForCurrentPlatform()` только как "pane exists" check. Поэтому старый `listPanePids()` wrapper должен остаться ровно pane-existence helper, а не получить новую liveness-семантику.
|
||||
- В проекте есть env-mode precedent: `CLAUDE_TEAM_OPENCODE_LAUNCH_MODE` с `dogfood`/`production`/`disabled`. Для member liveness финальное решение другое: strict model включена по умолчанию без отдельного env-флага.
|
||||
- Для member liveness strict model включена по умолчанию без отдельного env-флага.
|
||||
- `src/shared/types/api.ts`, `src/preload/index.ts` и `src/renderer/api/httpClient.ts` уже прокидывают `getMemberSpawnStatuses()` и `getTeamAgentRuntime()` через shared snapshot types. Новый контракт можно добавить optional fields без нового IPC channel, но browser HTTP fallback должен возвращать валидный старый shape.
|
||||
- `TeamProvisioningService.readUnixProcessTableRows()` сейчас приватный, sync и читает только `pid,command`. Для надежного liveness нужен `ppid`, WSL-aware execution и unit-test seam. Это не должно оставаться приватным ad hoc helper внутри огромного service.
|
||||
- `getLiveTeamAgentRuntimeMetadata()` сейчас читает tmux panes и process table внутри одного метода. После strict model там станет слишком много правил, поэтому план должен вынести pure resolution в отдельный helper/module.
|
||||
|
|
@ -148,7 +148,7 @@ const MEMBER_BOOTSTRAP_STALL_MS = 5 * 60_000;
|
|||
Актуальное поведение:
|
||||
|
||||
| Area | Strict-only behavior |
|
||||
| ------------------------------ | ---------------------------------------- |
|
||||
| ------------------------------ | ------------------------------------- |
|
||||
| `livenessKind` | always filled when evidence exists |
|
||||
| UI labels | enabled |
|
||||
| `runtimeAlive` from shell-only | always false |
|
||||
|
|
|
|||
|
|
@ -54,11 +54,9 @@ This section records the higher-risk places that were checked after the first dr
|
|||
- The required app-tool proof must cover all teammate-operational tools that `member_briefing` can instruct, not just `message_send` and four task tools.
|
||||
- `claude_team` already exports `AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES` from `agent-teams-controller`; app-side required tools should derive from that instead of duplicating a second list.
|
||||
- Orchestrator direct `mcp:tools/list` proof sees plain MCP names like `message_send`, not OpenCode canonical ids. Do not compare direct stdio results against `agent_teams_message_send` or `agent-teams_message_send`.
|
||||
- However, orchestrator readiness currently exposes `toolProof.observedTools` through `readiness.evidence.observedMcpTools`, and the production live evidence builder reuses that field as `evidence.mcpTools.observedTools`. So direct proof should match plain names internally, but should still emit canonical OpenCode ids for public bridge/evidence output, or add a separate explicit `observedDirectToolNames` field. Do not silently change `observedMcpTools` to plain names.
|
||||
- However, orchestrator readiness currently exposes `toolProof.observedTools` through `readiness.evidence.observedMcpTools`. Direct proof should match plain names internally, but bridge output should keep a clearly named field if canonical OpenCode ids are needed later. Do not silently change `observedMcpTools` semantics.
|
||||
- `agent_teams_orchestrator` does not currently depend on `agent-teams-controller`. Do not import the controller catalog into the orchestrator in v1 unless intentionally adding a new cross-repo/package dependency.
|
||||
- `OpenCodeReadinessBridge.applyProductionE2EGate()` still builds `requiredMcpTools` from runtime-only tools. If app-side readiness starts requiring teammate-operational tools, the production E2E gate must be moved to the same app tool contract or it will validate a weaker/stale artifact.
|
||||
- `assertOpenCodeProductionE2EArtifactGate()` compares expected tool ids against `evidence.mcpTools.observedTools` exactly. Stale evidence generated before this change should fail production mode clearly instead of silently pretending the stronger proof exists.
|
||||
- `scripts/prove-opencode-production.mjs` is only a launcher. The production evidence JSON is built in `test/main/services/team/OpenCodeProductionGate.live.test.ts` inside `buildCandidateEvidence()`. Updating the script alone does nothing; update the live test builder and gate expectations.
|
||||
- The old project-proof gate was removed from OpenCode launch readiness. Do not reintroduce project-scoped launch blocking for selected models; runtime readiness should be based on inventory, capabilities, runtime stores, app MCP tool proof, and the execution probe.
|
||||
- Current controller teammate-operational catalog includes more than the obvious message/task-start tools: `task_attach_comment_file`, `task_attach_file`, `task_create`, `task_create_from_message`, `task_link`, and `task_unlink` are also teammate-operational and must be included in any explicit orchestrator v1 list.
|
||||
- `mcp-server/src/agent-teams-controller.d.ts` and `src/types/agent-teams-controller.d.ts` mirror controller signatures and must be updated when `memberBriefing(memberName, options)` is added.
|
||||
- `agent-teams-controller` is CommonJS and existing TS code imports it as `import * as agentTeamsControllerModule from 'agent-teams-controller'`; use that pattern in new app-side imports instead of assuming a default ESM export.
|
||||
|
|
@ -190,13 +188,10 @@ Implementation rule:
|
|||
- `/Users/belief/dev/projects/claude/claude_team/src/types/agent-teams-controller.d.ts`
|
||||
- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts`
|
||||
- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts`
|
||||
- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts`
|
||||
- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/delivery/RuntimeDeliveryService.ts`
|
||||
- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts`
|
||||
- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts`
|
||||
- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts`
|
||||
- `/Users/belief/dev/projects/claude/claude_team/test/main/services/team/OpenCodeProductionGate.live.test.ts`
|
||||
- `/Users/belief/dev/projects/claude/claude_team/scripts/prove-opencode-production.mjs`
|
||||
|
||||
`agent_teams_orchestrator` files:
|
||||
|
||||
|
|
@ -222,7 +217,9 @@ Example:
|
|||
|
||||
```js
|
||||
function normalizeRuntimeProvider(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
const normalized = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return normalized === 'opencode' ? 'opencode' : 'native';
|
||||
}
|
||||
|
||||
|
|
@ -267,7 +264,9 @@ function createMemberMessagingProtocol(runtimeProvider) {
|
|||
}
|
||||
|
||||
function isOpenCodeMember(member) {
|
||||
const provider = String(member?.providerId || member?.provider || '').trim().toLowerCase();
|
||||
const provider = String(member?.providerId || member?.provider || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return provider === 'opencode';
|
||||
}
|
||||
|
||||
|
|
@ -302,9 +301,7 @@ Edit pattern:
|
|||
|
||||
```js
|
||||
function copyTrimmedString(member, key) {
|
||||
return typeof member[key] === 'string' && member[key].trim()
|
||||
? { [key]: member[key].trim() }
|
||||
: {};
|
||||
return typeof member[key] === 'string' && member[key].trim() ? { [key]: member[key].trim() } : {};
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -314,9 +311,15 @@ Then preserve fields:
|
|||
return {
|
||||
name,
|
||||
...(typeof member.role === 'string' && member.role.trim() ? { role: member.role.trim() } : {}),
|
||||
...(typeof member.workflow === 'string' && member.workflow.trim() ? { workflow: member.workflow.trim() } : {}),
|
||||
...(typeof member.agentType === 'string' && member.agentType.trim() ? { agentType: member.agentType.trim() } : {}),
|
||||
...(typeof member.color === 'string' && member.color.trim() ? { color: member.color.trim() } : {}),
|
||||
...(typeof member.workflow === 'string' && member.workflow.trim()
|
||||
? { workflow: member.workflow.trim() }
|
||||
: {}),
|
||||
...(typeof member.agentType === 'string' && member.agentType.trim()
|
||||
? { agentType: member.agentType.trim() }
|
||||
: {}),
|
||||
...(typeof member.color === 'string' && member.color.trim()
|
||||
? { color: member.color.trim() }
|
||||
: {}),
|
||||
...(typeof member.cwd === 'string' && member.cwd.trim() ? { cwd: member.cwd.trim() } : {}),
|
||||
...copyTrimmedString(member, 'providerId'),
|
||||
...copyTrimmedString(member, 'providerBackendId'),
|
||||
|
|
@ -398,7 +401,8 @@ Inside the function:
|
|||
|
||||
```js
|
||||
const explicitRuntimeProvider = options.runtimeProvider;
|
||||
const inferredRuntimeProvider = explicitRuntimeProvider || (isOpenCodeMember(effectiveMember) ? 'opencode' : 'native');
|
||||
const inferredRuntimeProvider =
|
||||
explicitRuntimeProvider || (isOpenCodeMember(effectiveMember) ? 'opencode' : 'native');
|
||||
const messagingProtocol = createMemberMessagingProtocol(inferredRuntimeProvider);
|
||||
```
|
||||
|
||||
|
|
@ -702,13 +706,11 @@ Do not hide `runtime_deliver_message` from readiness or app tool availability pr
|
|||
Instead, make tool descriptions and OpenCode prompts explicitly route normal replies:
|
||||
|
||||
```ts
|
||||
description:
|
||||
'Send a visible team/user message. OpenCode teammates should use this for normal replies to the human user, lead, or same-team teammates. When to is "user", from is required and must be your configured teammate name.'
|
||||
description: 'Send a visible team/user message. OpenCode teammates should use this for normal replies to the human user, lead, or same-team teammates. When to is "user", from is required and must be your configured teammate name.';
|
||||
```
|
||||
|
||||
```ts
|
||||
description:
|
||||
'Low-level OpenCode runtime delivery journal tool. Use only when the runtime/app prompt explicitly provides runId, runtimeSessionId, idempotencyKey, and asks for runtime delivery. For normal visible replies, use message_send.'
|
||||
description: 'Low-level OpenCode runtime delivery journal tool. Use only when the runtime/app prompt explicitly provides runId, runtimeSessionId, idempotencyKey, and asks for runtime delivery. For normal visible replies, use message_send.';
|
||||
```
|
||||
|
||||
OpenCode-specific prompt wording should avoid generic "deliver message" language:
|
||||
|
|
@ -778,7 +780,9 @@ function normalizeMessageSendFlags(context, flags) {
|
|||
throw new Error('message_send cannot target another team. Use cross_team_send with toTeam.');
|
||||
}
|
||||
if (!resolvedTo && runtimeHelpers.looksLikeCrossTeamToolRecipient?.(rawTo)) {
|
||||
throw new Error('message_send cannot target cross_team_send. Use cross_team_send with toTeam.');
|
||||
throw new Error(
|
||||
'message_send cannot target cross_team_send. Use cross_team_send with toTeam.'
|
||||
);
|
||||
}
|
||||
if (!resolvedTo) {
|
||||
throw new Error(`Unknown to: ${rawTo}. Use a configured team member name.`);
|
||||
|
|
@ -903,7 +907,7 @@ Current risk:
|
|||
`TeamProvisioningService.captureSendMessages()` recognizes only:
|
||||
|
||||
```ts
|
||||
part.name === 'mcp__agent-teams__message_send'
|
||||
part.name === 'mcp__agent-teams__message_send';
|
||||
```
|
||||
|
||||
But OpenCode and MCP tooling can expose names as:
|
||||
|
|
@ -957,8 +961,7 @@ export function isAgentTeamsToolUse(input: {
|
|||
}
|
||||
|
||||
const hasKnownPrefix =
|
||||
rawName !== canonical ||
|
||||
AGENT_TEAMS_PREFIXES.some((prefix) => rawName.startsWith(prefix));
|
||||
rawName !== canonical || AGENT_TEAMS_PREFIXES.some((prefix) => rawName.startsWith(prefix));
|
||||
if (hasKnownPrefix) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1078,23 +1081,23 @@ Add a helper:
|
|||
|
||||
```ts
|
||||
function buildOpenCodeRuntimeIdentityBlock(input: {
|
||||
teamName: string
|
||||
memberName: string
|
||||
runId: string
|
||||
runtimeSessionId: string
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
runId: string;
|
||||
runtimeSessionId: string;
|
||||
}): string {
|
||||
const checkinPayload = {
|
||||
teamName: input.teamName,
|
||||
runId: input.runId,
|
||||
memberName: input.memberName,
|
||||
runtimeSessionId: input.runtimeSessionId,
|
||||
}
|
||||
};
|
||||
|
||||
const briefingPayload = {
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
runtimeProvider: 'opencode',
|
||||
}
|
||||
};
|
||||
|
||||
return [
|
||||
'<opencode_runtime_identity>',
|
||||
|
|
@ -1105,7 +1108,7 @@ function buildOpenCodeRuntimeIdentityBlock(input: {
|
|||
`Call the exposed Agent Teams member_briefing tool, usually agent-teams_member_briefing or mcp__agent-teams__member_briefing, with: ${JSON.stringify(briefingPayload)}`,
|
||||
'For visible team/app messages, use the exposed Agent Teams message_send tool, usually agent-teams_message_send or mcp__agent-teams__message_send. Do not use SendMessage.',
|
||||
'</opencode_runtime_identity>',
|
||||
].join('\n')
|
||||
].join('\n');
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -1117,12 +1120,12 @@ const runtimeIdentityBlock = buildOpenCodeRuntimeIdentityBlock({
|
|||
memberName: name,
|
||||
runId,
|
||||
runtimeSessionId: record.opencodeSessionId,
|
||||
})
|
||||
});
|
||||
|
||||
await openCodeSessionBridge.promptAsync(record, {
|
||||
text: `${runtimeIdentityBlock}\n\n${prompt}`,
|
||||
agent: 'teammate',
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
Then add a bounded launch-settle helper before mapping the member as final `created`/`confirmed_alive`.
|
||||
|
|
@ -1512,7 +1515,9 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
|
|||
'</opencode_app_message_delivery>',
|
||||
'',
|
||||
input.text,
|
||||
].filter((line): line is string => line !== null).join('\n');
|
||||
]
|
||||
.filter((line): line is string => line !== null)
|
||||
.join('\n');
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -1672,7 +1677,7 @@ sendTeamMessage: async (teamName, request) => {
|
|||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
Update call sites so pending-reply state reflects actual delivery truth:
|
||||
|
|
@ -1904,7 +1909,7 @@ const REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS = [
|
|||
'runtime_deliver_message',
|
||||
'runtime_task_event',
|
||||
'runtime_heartbeat',
|
||||
] as const
|
||||
] as const;
|
||||
```
|
||||
|
||||
Change to route-specific direct MCP names.
|
||||
|
|
@ -1913,10 +1918,10 @@ Important:
|
|||
|
||||
- `Client.listTools()` returns plain names such as `message_send`.
|
||||
- Do not prefix direct stdio results with `agent-teams_`.
|
||||
- Only OpenCode app/API tool-id proof and production E2E evidence should deal with canonical ids like `agent-teams_message_send`.
|
||||
- Only OpenCode app/API tool-id proof should deal with canonical ids like `agent-teams_message_send`.
|
||||
- `agent_teams_message_send` is an accepted alias, not the canonical id produced by `buildOpenCodeCanonicalMcpToolId('agent-teams', 'message_send')`.
|
||||
- The orchestrator explicit list should be treated as a boundary adapter, not as the source of truth for app-side UI/readiness.
|
||||
- `readiness.evidence.observedMcpTools` is already consumed by production evidence as OpenCode tool ids. Keep that public field canonical. If direct proof needs plain diagnostics, add a second private/internal field such as `observedDirectToolNames`.
|
||||
- Keep `readiness.evidence.observedMcpTools` canonical if it is exposed through the bridge. If direct proof needs plain diagnostics, add a second private/internal field such as `observedDirectToolNames`.
|
||||
|
||||
```ts
|
||||
const REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS = [
|
||||
|
|
@ -1924,7 +1929,7 @@ const REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS = [
|
|||
'runtime_deliver_message',
|
||||
'runtime_task_event',
|
||||
'runtime_heartbeat',
|
||||
] as const
|
||||
] as const;
|
||||
|
||||
const REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS = [
|
||||
'member_briefing',
|
||||
|
|
@ -1956,20 +1961,20 @@ const REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS = [
|
|||
'cross_team_send',
|
||||
'cross_team_list_targets',
|
||||
'cross_team_get_outbox',
|
||||
] as const
|
||||
] as const;
|
||||
|
||||
const REQUIRED_AGENT_TEAMS_DIRECT_MCP_TOOL_NAMES = [
|
||||
...REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS,
|
||||
...REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS,
|
||||
] as const
|
||||
] as const;
|
||||
```
|
||||
|
||||
Update direct listTools mapping:
|
||||
|
||||
```ts
|
||||
return (result.tools ?? [])
|
||||
.map(tool => tool.name)
|
||||
.filter((name): name is string => typeof name === 'string' && name.trim().length > 0)
|
||||
.map((tool) => tool.name)
|
||||
.filter((name): name is string => typeof name === 'string' && name.trim().length > 0);
|
||||
```
|
||||
|
||||
Compare plain names internally:
|
||||
|
|
@ -1988,14 +1993,14 @@ But emit canonical ids for bridge readiness/evidence:
|
|||
|
||||
```ts
|
||||
function buildOpenCodeCanonicalMcpToolId(toolName: string): string {
|
||||
return `${OPEN_CODE_APP_MCP_SERVER_NAME}_${toolName}`
|
||||
return `${OPEN_CODE_APP_MCP_SERVER_NAME}_${toolName}`;
|
||||
}
|
||||
|
||||
function matchAppMcpTools(observedDirectToolNames: string[], route: string): AppMcpToolProof {
|
||||
const observedDirect = new Set(observedDirectToolNames)
|
||||
const observedDirect = new Set(observedDirectToolNames);
|
||||
const missingTools = REQUIRED_AGENT_TEAMS_DIRECT_MCP_TOOL_NAMES.filter(
|
||||
tool => !observedDirect.has(tool)
|
||||
)
|
||||
(tool) => !observedDirect.has(tool)
|
||||
);
|
||||
|
||||
return {
|
||||
ok: missingTools.length === 0,
|
||||
|
|
@ -2004,11 +2009,12 @@ function matchAppMcpTools(observedDirectToolNames: string[], route: string): App
|
|||
),
|
||||
observedDirectToolNames: uniqueSortedStrings(observedDirectToolNames),
|
||||
missingTools,
|
||||
diagnostics: missingTools.length === 0
|
||||
diagnostics:
|
||||
missingTools.length === 0
|
||||
? []
|
||||
: [`OpenCode app MCP tools missing from ${route}: ${missingTools.join(', ')}`],
|
||||
route,
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -2093,96 +2099,26 @@ Acceptance:
|
|||
- Existing callers that truly mean runtime schema tools should use `REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS`, not the full app list.
|
||||
- Existing callers that mean launch-visible app tools should use `REQUIRED_AGENT_TEAMS_APP_TOOLS` or `REQUIRED_AGENT_TEAMS_APP_TOOL_IDS`.
|
||||
|
||||
### Step 12 - Update production E2E gate to prove the same app tools
|
||||
### Step 12 - Keep app tool proof in readiness only
|
||||
|
||||
Files:
|
||||
|
||||
```text
|
||||
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts
|
||||
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts
|
||||
/Users/belief/dev/projects/claude/claude_team/test/main/services/team/OpenCodeProductionGate.live.test.ts
|
||||
/Users/belief/dev/projects/claude/claude_team/scripts/prove-opencode-production.mjs
|
||||
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts
|
||||
```
|
||||
|
||||
Current risk:
|
||||
Current rule:
|
||||
|
||||
```ts
|
||||
requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
|
||||
buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
|
||||
)
|
||||
```
|
||||
|
||||
That is weaker than the planned app-tool readiness proof. It can create two bad states:
|
||||
|
||||
- Production gate passes an artifact that proves runtime tools only, while OpenCode can still miss `message_send` or `member_briefing`.
|
||||
- Production gate fails confusingly after the required list changes because old evidence was generated with the weaker runtime-only set.
|
||||
|
||||
Change `OpenCodeReadinessBridge.applyProductionE2EGate()` to use the full app tool id list:
|
||||
|
||||
```ts
|
||||
import { REQUIRED_AGENT_TEAMS_APP_TOOL_IDS } from '../mcp/OpenCodeMcpToolAvailability';
|
||||
|
||||
// ...
|
||||
requiredMcpTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS,
|
||||
```
|
||||
|
||||
Update the live evidence builder in `OpenCodeProductionGate.live.test.ts`.
|
||||
|
||||
Current builder path:
|
||||
|
||||
```ts
|
||||
mcpTools: {
|
||||
requiredTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
|
||||
buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
|
||||
),
|
||||
observedTools: input.readinessObservedTools,
|
||||
},
|
||||
```
|
||||
|
||||
Change it to:
|
||||
|
||||
```ts
|
||||
mcpTools: {
|
||||
requiredTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS,
|
||||
observedTools: input.readinessObservedTools,
|
||||
},
|
||||
```
|
||||
|
||||
Guard the shape explicitly because `input.readinessObservedTools` comes from the orchestrator bridge:
|
||||
|
||||
```ts
|
||||
const missingObservedAppToolIds = REQUIRED_AGENT_TEAMS_APP_TOOL_IDS.filter(
|
||||
(toolId) => !input.readinessObservedTools.includes(toolId)
|
||||
);
|
||||
expect(missingObservedAppToolIds).toEqual([]);
|
||||
```
|
||||
|
||||
If that assertion fails after Step 10, the orchestrator is probably returning plain direct tool names in `readiness.evidence.observedMcpTools`. Fix the bridge output instead of weakening production evidence.
|
||||
|
||||
Keep the evidence validator exact:
|
||||
|
||||
```ts
|
||||
const observedTools = new Set(evidence.mcpTools.observedTools);
|
||||
const missingTools = requiredTools.filter((tool) => !observedTools.has(tool));
|
||||
```
|
||||
|
||||
Do not add alias fallback here. Evidence should prove the canonical OpenCode app ids that production readiness expects. Alias tolerance belongs in transcript/capture parsing, not in production artifact gating.
|
||||
|
||||
Update live/evidence test fixtures:
|
||||
|
||||
```ts
|
||||
const requiredToolIds = REQUIRED_AGENT_TEAMS_APP_TOOL_IDS;
|
||||
```
|
||||
|
||||
`scripts/prove-opencode-production.mjs` should not need evidence JSON logic changes because it only launches the live test. Do update its console copy only if it refers to runtime-only proof. The real acceptance point is the artifact written by `OpenCodeProductionGate.live.test.ts`.
|
||||
- OpenCode launch readiness should not require a project-scoped proof artifact.
|
||||
- App tool proof belongs in the live readiness path through capability, runtime-store, MCP tool, and execution checks.
|
||||
- If tool requirements change, update `OpenCodeMcpToolAvailability` and readiness tests directly.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Production evidence must include canonical ids for runtime and teammate-operational tools.
|
||||
- `message_send`, `member_briefing`, `task_start`, and `cross_team_send` are explicitly covered by tests.
|
||||
- Old runtime-only evidence fails production mode with a clear diagnostic listing missing app MCP tools.
|
||||
- Dogfood mode can still warn instead of blocking if current policy already allows degraded evidence there.
|
||||
- No schema validation is added for all operational tools in this step. That would be a separate hardening pass.
|
||||
- `message_send`, `member_briefing`, `task_start`, and `cross_team_send` are covered by readiness tests.
|
||||
- Missing app MCP tools fails readiness directly with a clear diagnostic.
|
||||
- No project-specific artifact is required to create or launch a team.
|
||||
|
||||
### Step 13 - Resolve secondary lane current-run evidence from lane manifest
|
||||
|
||||
|
|
@ -2268,10 +2204,7 @@ currentRunId: this.getCurrentOpenCodeRuntimeRunId(input.teamName, input.laneId),
|
|||
Change to async durable resolution:
|
||||
|
||||
```ts
|
||||
const currentRunId = await this.resolveDurableOpenCodeRuntimeRunId(
|
||||
input.teamName,
|
||||
input.laneId
|
||||
);
|
||||
const currentRunId = await this.resolveDurableOpenCodeRuntimeRunId(input.teamName, input.laneId);
|
||||
|
||||
await store.assertEvidenceAccepted({
|
||||
teamName: input.teamName,
|
||||
|
|
@ -2384,7 +2317,7 @@ emit: (event) => {
|
|||
teamName: event.teamName,
|
||||
detail: typeof event.data?.detail === 'string' ? event.data.detail : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
Reason:
|
||||
|
|
@ -2422,7 +2355,7 @@ Do not add a frontend fake "agent answered" path. Frontend may show "message sav
|
|||
These are the places most likely to produce regressions if implemented casually.
|
||||
|
||||
1. Canonical OpenCode MCP id spelling - 🎯 8 🛡️ 8 🧠 3, about 20-50 LOC in tests
|
||||
`buildOpenCodeCanonicalMcpToolId('agent-teams', 'message_send')` keeps the dash in `agent-teams_message_send`. Direct MCP stdio proof uses plain `message_send`. Transcript parsing accepts aliases. Production E2E evidence should use the canonical dash form only. Add tests for all three contexts so nobody normalizes everything to underscore by accident.
|
||||
`buildOpenCodeCanonicalMcpToolId('agent-teams', 'message_send')` keeps the dash in `agent-teams_message_send`. Direct MCP stdio proof uses plain `message_send`. Transcript parsing accepts aliases. Add tests for these contexts so nobody normalizes everything to underscore by accident.
|
||||
|
||||
2. Orchestrator explicit teammate tool list drift - 🎯 7 🛡️ 7 🧠 4, about 40-90 LOC in tests
|
||||
The v1 orchestrator list is duplicated by design to avoid adding a dependency. This is acceptable only if tests cover the current controller teammate-operational catalog snapshot, including attachment/link/create/cross-team tools. If this fails repeatedly, move to Option C from Step 10: generated shared protocol contract.
|
||||
|
|
@ -2547,7 +2480,9 @@ it('persists taskRefs through message_send', async () => {
|
|||
taskRefs: [{ teamName, taskId: 'task-1', displayId: 'abcd1234' }],
|
||||
});
|
||||
|
||||
const rows = JSON.parse(fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'user.json'), 'utf8'));
|
||||
const rows = JSON.parse(
|
||||
fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'user.json'), 'utf8')
|
||||
);
|
||||
expect(rows[0].taskRefs).toEqual([{ teamName, taskId: 'task-1', displayId: 'abcd1234' }]);
|
||||
});
|
||||
```
|
||||
|
|
@ -2574,7 +2509,9 @@ it('keeps legacy user-to-member message_send valid without from', async () => {
|
|||
text: 'Please check this',
|
||||
});
|
||||
|
||||
const rows = JSON.parse(fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'), 'utf8'));
|
||||
const rows = JSON.parse(
|
||||
fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'), 'utf8')
|
||||
);
|
||||
expect(rows.at(-1)).toMatchObject({
|
||||
from: 'user',
|
||||
to: 'alice',
|
||||
|
|
@ -2609,7 +2546,9 @@ it('canonicalizes message_send lead aliases before writing inbox files', async (
|
|||
});
|
||||
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'lead.json'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'team-lead.json'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'team-lead.json'))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
|
|
@ -2624,7 +2563,9 @@ it('canonicalizes message_send sender aliases before persistence', async () => {
|
|||
text: 'Please review',
|
||||
});
|
||||
|
||||
const rows = JSON.parse(fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'), 'utf8'));
|
||||
const rows = JSON.parse(
|
||||
fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'), 'utf8')
|
||||
);
|
||||
expect(rows.at(-1)).toMatchObject({ from: 'lead', to: 'alice' });
|
||||
});
|
||||
```
|
||||
|
|
@ -2682,7 +2623,9 @@ it('keeps configured dotted local members valid before applying cross-team heuri
|
|||
text: 'Local dotted member',
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'qa.bot.json'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'qa.bot.json'))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
|
|
@ -2717,11 +2660,18 @@ it('persists taskRefs through cross_team_send when enabled', async () => {
|
|||
});
|
||||
|
||||
const targetInbox = JSON.parse(
|
||||
fs.readFileSync(path.join(claudeDir, 'teams', 'review-team', 'inboxes', 'team-lead.json'), 'utf8')
|
||||
fs.readFileSync(
|
||||
path.join(claudeDir, 'teams', 'review-team', 'inboxes', 'team-lead.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
expect(targetInbox.at(-1).taskRefs).toEqual([{ teamName, taskId: 'task-1', displayId: 'abcd1234' }]);
|
||||
expect(targetInbox.at(-1).taskRefs).toEqual([
|
||||
{ teamName, taskId: 'task-1', displayId: 'abcd1234' },
|
||||
]);
|
||||
|
||||
const outbox = JSON.parse(fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'sent-cross-team.json'), 'utf8'));
|
||||
const outbox = JSON.parse(
|
||||
fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'sent-cross-team.json'), 'utf8')
|
||||
);
|
||||
expect(outbox.at(-1).taskRefs).toEqual([{ teamName, taskId: 'task-1', displayId: 'abcd1234' }]);
|
||||
});
|
||||
```
|
||||
|
|
@ -2812,70 +2762,19 @@ it('does not classify unrelated plain message_send tool_use without Agent Teams
|
|||
});
|
||||
```
|
||||
|
||||
Add app-side OpenCode readiness/evidence tests:
|
||||
Add app-side OpenCode readiness tests:
|
||||
|
||||
```ts
|
||||
it('uses full app tool ids for OpenCode production E2E gate expectations', async () => {
|
||||
const evidence = buildEvidence({
|
||||
observedTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS,
|
||||
});
|
||||
|
||||
it('uses full app tool ids for OpenCode readiness expectations', async () => {
|
||||
const result = await bridge.runReadiness({
|
||||
launchMode: 'production',
|
||||
selectedModel: 'minimax-m2.5-free',
|
||||
// ...
|
||||
});
|
||||
|
||||
expect(result.supportLevel).toBe('production_supported');
|
||||
expect(productionE2eEvidence.read).toHaveBeenCalled();
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
it('rejects stale runtime-only OpenCode production evidence', async () => {
|
||||
const runtimeOnlyToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS.map((tool) =>
|
||||
buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
|
||||
expect(result.evidence.observedMcpTools).toEqual(
|
||||
expect.arrayContaining(REQUIRED_AGENT_TEAMS_APP_TOOL_IDS)
|
||||
);
|
||||
const evidence = buildEvidence({
|
||||
observedTools: runtimeOnlyToolIds,
|
||||
requiredTools: runtimeOnlyToolIds,
|
||||
});
|
||||
|
||||
const gate = assertOpenCodeProductionE2EArtifactGate({
|
||||
evidence,
|
||||
artifactPath: '/tmp/opencode-e2e.json',
|
||||
expected: {
|
||||
requiredMcpTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS,
|
||||
},
|
||||
});
|
||||
|
||||
expect(gate.ok).toBe(false);
|
||||
expect(gate.diagnostics.join('\n')).toContain('agent-teams_message_send');
|
||||
expect(gate.diagnostics.join('\n')).toContain('agent-teams_member_briefing');
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
it('live production evidence builder writes full app tool ids', () => {
|
||||
const evidence = buildCandidateEvidence({
|
||||
readinessObservedTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS,
|
||||
// other required live-test fields
|
||||
});
|
||||
|
||||
expect(evidence.mcpTools.requiredTools).toEqual(REQUIRED_AGENT_TEAMS_APP_TOOL_IDS);
|
||||
expect(evidence.mcpTools.observedTools).toContain('agent-teams_message_send');
|
||||
expect(evidence.mcpTools.observedTools).toContain('agent-teams_cross_team_send');
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
it('live production evidence builder rejects plain direct tool names for artifact output', () => {
|
||||
expect(() =>
|
||||
buildCandidateEvidence({
|
||||
readinessObservedTools: ['message_send', 'member_briefing'],
|
||||
// other required live-test fields
|
||||
})
|
||||
).toThrow(/agent-teams_message_send/);
|
||||
});
|
||||
```
|
||||
|
||||
|
|
@ -3437,7 +3336,7 @@ Run targeted tests first:
|
|||
cd /Users/belief/dev/projects/claude/claude_team
|
||||
pnpm --filter agent-teams-controller test -- test/controller.test.js
|
||||
pnpm --filter agent-teams-mcp test -- test/tools.test.ts
|
||||
pnpm vitest run test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts test/main/services/team/TeamProvisioningService.test.ts test/main/services/team/TeamProvisioningServiceRelay.test.ts test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts test/main/ipc/teams.test.ts test/main/services/team/OpenCodeMcpToolAvailability.test.ts test/main/services/team/OpenCodeReadinessBridge.test.ts test/main/services/team/OpenCodeProductionE2EEvidence.test.ts test/renderer/store/teamChangeThrottle.test.ts test/renderer/store/teamSlice.test.ts test/renderer/components/team/messages/MessagesPanel.test.ts test/renderer/components/team/dialogs/SendMessageDialog.test.tsx
|
||||
pnpm vitest run test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts test/main/services/team/TeamProvisioningService.test.ts test/main/services/team/TeamProvisioningServiceRelay.test.ts test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts test/main/ipc/teams.test.ts test/main/services/team/OpenCodeMcpToolAvailability.test.ts test/main/services/team/OpenCodeReadinessBridge.test.ts test/renderer/store/teamChangeThrottle.test.ts test/renderer/store/teamSlice.test.ts test/renderer/components/team/messages/MessagesPanel.test.ts test/renderer/components/team/dialogs/SendMessageDialog.test.tsx
|
||||
```
|
||||
|
||||
```bash
|
||||
|
|
@ -3499,7 +3398,7 @@ Avoid heavy E2E until targeted tests pass.
|
|||
19. Add OpenCode-targeted inbox runtime relay with dedupe/read marking.
|
||||
20. Expand orchestrator direct MCP proof with the explicit plain-name adapter list while keeping public observed evidence as canonical OpenCode ids.
|
||||
21. Expand app-side OpenCode MCP availability proof from controller catalog.
|
||||
22. Update production E2E gate and evidence fixtures to require the full app tool id list.
|
||||
22. Keep OpenCode readiness requiring the full app tool id list without project-scoped artifacts.
|
||||
23. Add lane-scoped manifest `activeRunId` recovery and consume it in evidence acceptance/message delivery/runtime delivery service.
|
||||
24. Add runtime delivery `TeamChangeEvent.detail` adapter guard tests.
|
||||
25. Add tests.
|
||||
|
|
@ -3534,9 +3433,7 @@ Avoid heavy E2E until targeted tests pass.
|
|||
- Readiness passes while `message_send` is missing. This means proof list is still incomplete.
|
||||
- Readiness passes while review/process/task-set tools are missing. This means proof only checked a small subset instead of all teammate-operational briefing tools.
|
||||
- Direct MCP readiness fails even though `tools/list` contains `message_send`. This usually means direct stdio proof is incorrectly comparing plain names against OpenCode canonical ids.
|
||||
- Production live evidence fails after direct MCP proof succeeds. This usually means the orchestrator started exposing plain names in `readiness.evidence.observedMcpTools`; keep plain names internal and expose canonical `agent-teams_*` ids there.
|
||||
- Production mode passes with runtime-only evidence. This means `OpenCodeReadinessBridge` still uses `REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS` instead of the full app tool id list.
|
||||
- Production mode blocks with stale evidence after this change. That is expected until the OpenCode production E2E artifact is regenerated, but the diagnostic must list the missing app tools clearly.
|
||||
- Readiness passes with runtime-only app tool coverage. This means `OpenCodeMcpToolAvailability` still uses only runtime tools instead of the full app tool id list.
|
||||
- App-side and orchestrator required tool lists drift. For v1, this is controlled by tests and explicit comments. If drift keeps recurring, move to a generated shared contract artifact.
|
||||
- OpenCode member stays `created` even though the prompt was accepted. This usually means `promptAsync()` was reconciled too early; use the bounded launch-settle helper before final launch mapping.
|
||||
- Preview observation times out and marks a teammate failed. That is wrong. Preview timeout should only fall back to reconcile and keep the member pending.
|
||||
|
|
@ -3557,7 +3454,6 @@ Avoid heavy E2E until targeted tests pass.
|
|||
- OpenCode launch, briefing, assignment, completion, and clarification instructions consistently use `agent-teams_message_send`.
|
||||
- OpenCode cross-team instructions consistently use `agent-teams_cross_team_send`, not `message_send`.
|
||||
- OpenCode readiness fails if required app MCP tools are absent.
|
||||
- OpenCode production E2E gate proves the same app MCP tools that readiness requires.
|
||||
- Orchestrator direct proof matches plain MCP names internally and emits canonical OpenCode ids in readiness evidence.
|
||||
- Runtime tool descriptions make `message_send` the normal visible reply API and keep `runtime_deliver_message` scoped to explicit low-level runtime delivery flows.
|
||||
- OpenCode can prove liveness through `runtime_bootstrap_checkin`.
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@
|
|||
"dev": "node ./scripts/dev-with-runtime.mjs",
|
||||
"dev:web": "node ./scripts/dev-web.mjs",
|
||||
"dev:kill": "node bin/kill-dev.js",
|
||||
"opencode:prove-production": "node ./scripts/prove-opencode-production.mjs",
|
||||
"opencode:prove-mixed-recovery": "node ./scripts/prove-opencode-mixed-recovery.mjs",
|
||||
"opencode:prove-team-provisioning": "node ./scripts/prove-opencode-team-provisioning.mjs",
|
||||
"team:prove-launch-matrix": "pnpm exec vitest run --maxWorkers 1 --minWorkers 1 test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts",
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ export type GraphLaunchVisualState =
|
|||
| 'registered_only'
|
||||
| 'stale_runtime'
|
||||
| 'settling'
|
||||
| 'error';
|
||||
| 'error'
|
||||
| 'skipped';
|
||||
|
||||
// ─── Edge & Particle Types ───────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -86,7 +87,7 @@ export interface GraphNode {
|
|||
/** Avatar image URL (e.g., robohash) */
|
||||
avatarUrl?: string;
|
||||
/** Spawn lifecycle status */
|
||||
spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error';
|
||||
spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error' | 'skipped';
|
||||
/** Shared launch-stage visual derived by the host app */
|
||||
launchVisualState?: GraphLaunchVisualState;
|
||||
/** Shared launch-stage text shown beside the node during launch only */
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const defaultEvidencePath = path.join(
|
||||
resolveAppDataDir(),
|
||||
'Agent Teams UI',
|
||||
'opencode-bridge',
|
||||
'production-e2e-evidence.json'
|
||||
);
|
||||
const orchestratorRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim();
|
||||
const siblingOrchestrator = path.resolve(repoRoot, '..', 'agent_teams_orchestrator');
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCODE_E2E: '1',
|
||||
OPENCODE_E2E_PROJECT_PATH: process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || repoRoot,
|
||||
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL?.trim() || 'opencode/big-pickle',
|
||||
OPENCODE_E2E_WRITE_APP_EVIDENCE: '1',
|
||||
OPENCODE_E2E_WRITE_EVIDENCE_PATH:
|
||||
process.env.OPENCODE_E2E_WRITE_EVIDENCE_PATH?.trim() ||
|
||||
process.env.CLAUDE_TEAM_OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH?.trim() ||
|
||||
defaultEvidencePath,
|
||||
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
|
||||
};
|
||||
|
||||
if (!env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim()) {
|
||||
const runtimeRoot = orchestratorRoot ? path.resolve(orchestratorRoot) : siblingOrchestrator;
|
||||
env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = path.join(runtimeRoot, 'cli');
|
||||
}
|
||||
|
||||
console.log('Running OpenCode production proof');
|
||||
console.log(`Model: ${env.OPENCODE_E2E_MODEL}`);
|
||||
console.log(`Project: ${env.OPENCODE_E2E_PROJECT_PATH}`);
|
||||
console.log(`Evidence: ${env.OPENCODE_E2E_WRITE_EVIDENCE_PATH}`);
|
||||
console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`);
|
||||
|
||||
const result = spawnSync(
|
||||
'pnpm',
|
||||
['exec', 'vitest', 'run', 'test/main/services/team/OpenCodeProductionGate.live.test.ts'],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
}
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
console.error(`Failed to run OpenCode production proof: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
|
||||
function resolveAppDataDir() {
|
||||
if (process.platform === 'darwin') {
|
||||
return path.join(os.homedir(), 'Library', 'Application Support');
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
||||
}
|
||||
|
||||
return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
||||
}
|
||||
|
|
@ -116,9 +116,6 @@ import {
|
|||
OpenCodeBridgeCommandHandshakePort,
|
||||
} from './services/team/opencode/bridge/OpenCodeBridgeHandshakeClient';
|
||||
import { OpenCodeStateChangingBridgeCommandService } from './services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
|
||||
import { resolveOpenCodeTeamLaunchModeFromEnv } from './services/team/opencode/config/OpenCodeLaunchModeEnv';
|
||||
import { resolveOpenCodeProductionE2EEvidencePath } from './services/team/opencode/e2e/OpenCodeProductionE2EEvidencePath';
|
||||
import { OpenCodeProductionE2EEvidenceStore } from './services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore';
|
||||
import { OpenCodeRuntimeManifestEvidenceReader } from './services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
|
||||
import {
|
||||
buildTeamControlApiBaseUrl,
|
||||
|
|
@ -273,13 +270,7 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise<TeamRuntimeAdapte
|
|||
new OpenCodeTeamRuntimeAdapter(
|
||||
new OpenCodeReadinessBridge(bridgeClient, {
|
||||
stateChangingCommands,
|
||||
productionE2eEvidence: new OpenCodeProductionE2EEvidenceStore({
|
||||
filePath: resolveOpenCodeProductionE2EEvidencePath({ bridgeControlDir }),
|
||||
}),
|
||||
}),
|
||||
{
|
||||
launchMode: resolveOpenCodeTeamLaunchModeFromEnv(),
|
||||
}
|
||||
})
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ import {
|
|||
TEAM_SET_TASK_LOG_STREAM_TRACKING,
|
||||
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
||||
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
||||
TEAM_SKIP_MEMBER_FOR_LAUNCH,
|
||||
TEAM_SOFT_DELETE_TASK,
|
||||
TEAM_START_TASK,
|
||||
TEAM_START_TASK_BY_USER,
|
||||
|
|
@ -599,6 +600,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_MEMBER_SPAWN_STATUSES, handleMemberSpawnStatuses);
|
||||
ipcMain.handle(TEAM_GET_AGENT_RUNTIME, handleGetAgentRuntime);
|
||||
ipcMain.handle(TEAM_RESTART_MEMBER, handleRestartMember);
|
||||
ipcMain.handle(TEAM_SKIP_MEMBER_FOR_LAUNCH, handleSkipMemberForLaunch);
|
||||
ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask);
|
||||
ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask);
|
||||
ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks);
|
||||
|
|
@ -676,6 +678,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_MEMBER_SPAWN_STATUSES);
|
||||
ipcMain.removeHandler(TEAM_GET_AGENT_RUNTIME);
|
||||
ipcMain.removeHandler(TEAM_RESTART_MEMBER);
|
||||
ipcMain.removeHandler(TEAM_SKIP_MEMBER_FOR_LAUNCH);
|
||||
ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK);
|
||||
ipcMain.removeHandler(TEAM_RESTORE_TASK);
|
||||
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
|
||||
|
|
@ -3391,6 +3394,27 @@ async function handleRestartMember(
|
|||
);
|
||||
}
|
||||
|
||||
async function handleSkipMemberForLaunch(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
memberName: unknown
|
||||
): Promise<IpcResult<void>> {
|
||||
const validatedTeamName = validateTeamName(teamName);
|
||||
if (!validatedTeamName.valid) {
|
||||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||||
}
|
||||
const validatedMemberName = validateMemberName(memberName);
|
||||
if (!validatedMemberName.valid) {
|
||||
return { success: false, error: validatedMemberName.error ?? 'Invalid memberName' };
|
||||
}
|
||||
return wrapTeamHandler('skipMemberForLaunch', async () =>
|
||||
getTeamProvisioningService().skipMemberForLaunch(
|
||||
validatedTeamName.value!,
|
||||
validatedMemberName.value!
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleStopTeam(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ const CATCH_UP_SESSION_RETENTION_MS = 20 * 60 * 1000; // 20 minutes
|
|||
const CATCH_UP_SUBAGENT_RETENTION_MS = 5 * 60 * 1000; // 5 minutes
|
||||
/** Bound best-effort catch-up work per tick so it cannot monopolize the event loop. */
|
||||
const CATCH_UP_SCAN_BUDGET = 24;
|
||||
/** Retire one file from catch-up after repeated local stat timeouts. */
|
||||
const CATCH_UP_STAT_TIMEOUT_RETIRE_COUNT = 3;
|
||||
|
||||
interface AppendedParseResult {
|
||||
messages: ParsedMessage[];
|
||||
|
|
@ -93,6 +95,8 @@ export class FileWatcher extends EventEmitter {
|
|||
private catchUpInProgress = false;
|
||||
/** Round-robin cursor so catch-up work is spread across tracked files. */
|
||||
private catchUpCursor = 0;
|
||||
/** Consecutive catch-up stat timeouts per file. */
|
||||
private catchUpStatFailures = new Map<string, number>();
|
||||
/** Timer for SSH polling mode (replaces fs.watch) */
|
||||
private pollingTimer: NodeJS.Timeout | null = null;
|
||||
/** Polling interval for SSH mode */
|
||||
|
|
@ -232,6 +236,7 @@ export class FileWatcher extends EventEmitter {
|
|||
this.lastProcessedLineCount.clear();
|
||||
this.lastProcessedSize.clear();
|
||||
this.activeSessionFiles.clear();
|
||||
this.catchUpStatFailures.clear();
|
||||
this.processingInProgress.clear();
|
||||
this.pendingReprocess.clear();
|
||||
|
||||
|
|
@ -284,6 +289,7 @@ export class FileWatcher extends EventEmitter {
|
|||
this.lastProcessedLineCount.clear();
|
||||
this.lastProcessedSize.clear();
|
||||
this.activeSessionFiles.clear();
|
||||
this.catchUpStatFailures.clear();
|
||||
this.polledFileSizes.clear();
|
||||
this.processingInProgress.clear();
|
||||
this.pendingReprocess.clear();
|
||||
|
|
@ -867,6 +873,7 @@ export class FileWatcher extends EventEmitter {
|
|||
this.lastProcessedLineCount.delete(filePath);
|
||||
this.lastProcessedSize.delete(filePath);
|
||||
this.activeSessionFiles.delete(filePath);
|
||||
this.catchUpStatFailures.delete(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -876,6 +883,7 @@ export class FileWatcher extends EventEmitter {
|
|||
this.lastProcessedLineCount.clear();
|
||||
this.lastProcessedSize.clear();
|
||||
this.activeSessionFiles.clear();
|
||||
this.catchUpStatFailures.clear();
|
||||
this.catchUpCursor = 0;
|
||||
this.catchUpInProgress = false;
|
||||
}
|
||||
|
|
@ -1119,6 +1127,7 @@ export class FileWatcher extends EventEmitter {
|
|||
}
|
||||
|
||||
const stats = await this.fsProvider.stat(filePath);
|
||||
this.catchUpStatFailures.delete(filePath);
|
||||
|
||||
// Skip files not modified recently
|
||||
if (now - stats.mtimeMs > CATCH_UP_MAX_AGE_MS) {
|
||||
|
|
@ -1145,6 +1154,8 @@ export class FileWatcher extends EventEmitter {
|
|||
// File may have been deleted between iterations
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
this.clearErrorTracking(filePath);
|
||||
} else if (this.isStatTimeoutError(err)) {
|
||||
this.handleCatchUpStatTimeout(filePath);
|
||||
} else {
|
||||
logger.error(`FileWatcher: Error during catch-up stat for ${filePath}:`, err);
|
||||
}
|
||||
|
|
@ -1156,6 +1167,32 @@ export class FileWatcher extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
private isStatTimeoutError(err: unknown): boolean {
|
||||
return err instanceof Error && err.message === 'stat timeout';
|
||||
}
|
||||
|
||||
private handleCatchUpStatTimeout(filePath: string): void {
|
||||
const failures = (this.catchUpStatFailures.get(filePath) ?? 0) + 1;
|
||||
|
||||
if (failures >= CATCH_UP_STAT_TIMEOUT_RETIRE_COUNT) {
|
||||
logger.warn(
|
||||
`FileWatcher: Retiring ${filePath} from catch-up after ${failures} stat timeouts`
|
||||
);
|
||||
this.retireCatchUpFile(filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
this.catchUpStatFailures.set(filePath, failures);
|
||||
logger.debug(
|
||||
`FileWatcher: Catch-up stat timeout for ${filePath} (${failures}/${CATCH_UP_STAT_TIMEOUT_RETIRE_COUNT})`
|
||||
);
|
||||
}
|
||||
|
||||
private retireCatchUpFile(filePath: string): void {
|
||||
this.activeSessionFiles.delete(filePath);
|
||||
this.catchUpStatFailures.delete(filePath);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Debouncing
|
||||
// ===========================================================================
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ type RuntimeMemberSpawnState = Pick<
|
|||
| 'runtimeAlive'
|
||||
| 'bootstrapConfirmed'
|
||||
| 'hardFailure'
|
||||
| 'skippedForLaunch'
|
||||
| 'skipReason'
|
||||
| 'skippedAt'
|
||||
| 'pendingPermissionRequestIds'
|
||||
| 'livenessKind'
|
||||
| 'runtimeDiagnostic'
|
||||
|
|
@ -130,6 +133,8 @@ function buildDiagnostics(
|
|||
| 'runtimeAlive'
|
||||
| 'bootstrapConfirmed'
|
||||
| 'hardFailureReason'
|
||||
| 'skippedForLaunch'
|
||||
| 'skipReason'
|
||||
| 'sources'
|
||||
| 'pendingPermissionRequestIds'
|
||||
>
|
||||
|
|
@ -145,6 +150,13 @@ function buildDiagnostics(
|
|||
}
|
||||
if (member.hardFailureReason)
|
||||
diagnostics.push(`hard failure reason: ${member.hardFailureReason}`);
|
||||
if (member.skippedForLaunch) {
|
||||
diagnostics.push(
|
||||
member.skipReason
|
||||
? `skipped for this launch: ${member.skipReason}`
|
||||
: 'skipped for this launch'
|
||||
);
|
||||
}
|
||||
if (member.sources?.duplicateRespawnBlocked) diagnostics.push('respawn blocked as duplicate');
|
||||
if (member.sources?.configDrift) diagnostics.push('config drift detected');
|
||||
return diagnostics;
|
||||
|
|
@ -159,6 +171,9 @@ export function deriveTeamLaunchAggregateState(
|
|||
if (summary.pendingCount > 0) {
|
||||
return 'partial_pending';
|
||||
}
|
||||
if ((summary.skippedCount ?? 0) > 0) {
|
||||
return 'partial_skipped';
|
||||
}
|
||||
return 'clean_success';
|
||||
}
|
||||
|
||||
|
|
@ -169,6 +184,7 @@ export function summarizePersistedLaunchMembers(
|
|||
let confirmedCount = 0;
|
||||
let pendingCount = 0;
|
||||
let failedCount = 0;
|
||||
let skippedCount = 0;
|
||||
let runtimeAlivePendingCount = 0;
|
||||
let shellOnlyPendingCount = 0;
|
||||
let runtimeProcessPendingCount = 0;
|
||||
|
|
@ -193,6 +209,10 @@ export function summarizePersistedLaunchMembers(
|
|||
confirmedCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) {
|
||||
skippedCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (entry.launchState === 'failed_to_start') {
|
||||
failedCount += 1;
|
||||
continue;
|
||||
|
|
@ -223,6 +243,7 @@ export function summarizePersistedLaunchMembers(
|
|||
confirmedCount,
|
||||
pendingCount,
|
||||
failedCount,
|
||||
skippedCount,
|
||||
runtimeAlivePendingCount,
|
||||
shellOnlyPendingCount,
|
||||
runtimeProcessPendingCount,
|
||||
|
|
@ -260,9 +281,13 @@ function deriveMemberLaunchState(
|
|||
| 'bootstrapConfirmed'
|
||||
| 'runtimeAlive'
|
||||
| 'agentToolAccepted'
|
||||
| 'skippedForLaunch'
|
||||
| 'pendingPermissionRequestIds'
|
||||
>
|
||||
): MemberLaunchState {
|
||||
if (member.skippedForLaunch) {
|
||||
return 'skipped_for_launch';
|
||||
}
|
||||
if (member.hardFailure) {
|
||||
return 'failed_to_start';
|
||||
}
|
||||
|
|
@ -390,10 +415,15 @@ function normalizePersistedMemberState(
|
|||
return null;
|
||||
}
|
||||
const providerId = normalizeOptionalTeamProviderId(parsed.providerId);
|
||||
const skippedForLaunch =
|
||||
toBoolean(parsed.skippedForLaunch) || parsed.launchState === 'skipped_for_launch';
|
||||
const bootstrapConfirmed =
|
||||
toBoolean(parsed.bootstrapConfirmed) || parsed.launchState === 'confirmed_alive';
|
||||
!skippedForLaunch &&
|
||||
(toBoolean(parsed.bootstrapConfirmed) || parsed.launchState === 'confirmed_alive');
|
||||
const livenessKind = normalizeLivenessKind(parsed.livenessKind);
|
||||
const runtimeAlive = preservesStrongRuntimeAlive({
|
||||
const runtimeAlive = skippedForLaunch
|
||||
? false
|
||||
: preservesStrongRuntimeAlive({
|
||||
runtimeAlive: toBoolean(parsed.runtimeAlive),
|
||||
bootstrapConfirmed,
|
||||
livenessKind,
|
||||
|
|
@ -431,12 +461,16 @@ function normalizePersistedMemberState(
|
|||
laneOwnerProviderId: normalizeOptionalTeamProviderId(parsed.laneOwnerProviderId),
|
||||
launchIdentity: normalizeLaunchIdentity(parsed.launchIdentity, providerId),
|
||||
launchState: 'starting',
|
||||
agentToolAccepted: toBoolean(parsed.agentToolAccepted),
|
||||
skippedForLaunch,
|
||||
skipReason: normalizeOptionalString(parsed.skipReason),
|
||||
skippedAt: normalizeOptionalString(parsed.skippedAt),
|
||||
agentToolAccepted: skippedForLaunch ? false : toBoolean(parsed.agentToolAccepted),
|
||||
runtimeAlive,
|
||||
bootstrapConfirmed,
|
||||
hardFailure: toBoolean(parsed.hardFailure),
|
||||
hardFailureReason:
|
||||
typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0
|
||||
hardFailure: skippedForLaunch ? false : toBoolean(parsed.hardFailure),
|
||||
hardFailureReason: skippedForLaunch
|
||||
? undefined
|
||||
: typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0
|
||||
? parsed.hardFailureReason.trim()
|
||||
: undefined,
|
||||
pendingPermissionRequestIds: normalizePendingPermissionRequestIds(
|
||||
|
|
@ -469,7 +503,8 @@ function normalizePersistedMemberState(
|
|||
parsed.launchState === 'runtime_pending_bootstrap' ||
|
||||
parsed.launchState === 'runtime_pending_permission' ||
|
||||
parsed.launchState === 'confirmed_alive' ||
|
||||
parsed.launchState === 'failed_to_start'
|
||||
parsed.launchState === 'failed_to_start' ||
|
||||
parsed.launchState === 'skipped_for_launch'
|
||||
? parsed.launchState
|
||||
: deriveMemberLaunchState(next);
|
||||
next.launchState = launchState;
|
||||
|
|
@ -511,6 +546,7 @@ export function createPersistedLaunchSnapshot(params: {
|
|||
members[name] = {
|
||||
name,
|
||||
launchState: 'starting',
|
||||
skippedForLaunch: false,
|
||||
agentToolAccepted: false,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
|
|
@ -587,18 +623,29 @@ export function snapshotFromRuntimeMemberStatuses(params: {
|
|||
sources.nativeHeartbeat = true;
|
||||
sources.inboxHeartbeat = true;
|
||||
}
|
||||
const runtimeAlive = preservesStrongRuntimeAlive(runtime);
|
||||
const skippedForLaunch =
|
||||
runtime?.skippedForLaunch === true || runtime?.launchState === 'skipped_for_launch';
|
||||
const runtimeAlive = skippedForLaunch ? false : preservesStrongRuntimeAlive(runtime);
|
||||
if (runtime?.livenessSource === 'process' && runtimeAlive) {
|
||||
sources.processAlive = true;
|
||||
}
|
||||
const entry: PersistedTeamLaunchMemberState = {
|
||||
name,
|
||||
launchState: runtime?.launchState ?? 'starting',
|
||||
agentToolAccepted: runtime?.agentToolAccepted === true,
|
||||
skippedForLaunch,
|
||||
skipReason: runtime?.skipReason,
|
||||
skippedAt: runtime?.skippedAt,
|
||||
agentToolAccepted: skippedForLaunch ? false : runtime?.agentToolAccepted === true,
|
||||
runtimeAlive,
|
||||
bootstrapConfirmed: runtime?.bootstrapConfirmed === true,
|
||||
hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start',
|
||||
hardFailureReason: runtime?.hardFailureReason ?? runtime?.error,
|
||||
bootstrapConfirmed: skippedForLaunch ? false : runtime?.bootstrapConfirmed === true,
|
||||
hardFailure:
|
||||
runtime?.launchState === 'skipped_for_launch'
|
||||
? false
|
||||
: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start',
|
||||
hardFailureReason:
|
||||
runtime?.launchState === 'skipped_for_launch'
|
||||
? undefined
|
||||
: (runtime?.hardFailureReason ?? runtime?.error),
|
||||
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length
|
||||
? [...new Set(runtime.pendingPermissionRequestIds)]
|
||||
: undefined,
|
||||
|
|
@ -644,9 +691,13 @@ export function snapshotToMemberSpawnStatuses(
|
|||
if (!entry) continue;
|
||||
let status: MemberSpawnStatusEntry['status'] = 'offline';
|
||||
let livenessSource: MemberSpawnLivenessSource | undefined;
|
||||
const runtimeAlive = preservesStrongRuntimeAlive(entry);
|
||||
const skippedForLaunch =
|
||||
entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true;
|
||||
const runtimeAlive = skippedForLaunch ? false : preservesStrongRuntimeAlive(entry);
|
||||
if (entry.launchState === 'failed_to_start') {
|
||||
status = 'error';
|
||||
} else if (entry.launchState === 'skipped_for_launch') {
|
||||
status = 'skipped';
|
||||
} else if (entry.launchState === 'confirmed_alive') {
|
||||
status = 'online';
|
||||
livenessSource = 'heartbeat';
|
||||
|
|
@ -664,11 +715,14 @@ export function snapshotToMemberSpawnStatuses(
|
|||
launchState: entry.launchState,
|
||||
error: entry.hardFailure ? entry.hardFailureReason : undefined,
|
||||
hardFailureReason: entry.hardFailureReason,
|
||||
skippedForLaunch: entry.skippedForLaunch,
|
||||
skipReason: entry.skipReason,
|
||||
skippedAt: entry.skippedAt,
|
||||
livenessSource,
|
||||
agentToolAccepted: entry.agentToolAccepted,
|
||||
agentToolAccepted: skippedForLaunch ? false : entry.agentToolAccepted,
|
||||
runtimeAlive,
|
||||
bootstrapConfirmed: entry.bootstrapConfirmed,
|
||||
hardFailure: entry.hardFailure,
|
||||
bootstrapConfirmed: skippedForLaunch ? false : entry.bootstrapConfirmed,
|
||||
hardFailure: skippedForLaunch ? false : entry.hardFailure,
|
||||
pendingPermissionRequestIds: entry.pendingPermissionRequestIds,
|
||||
livenessKind: entry.livenessKind,
|
||||
runtimeDiagnostic: entry.runtimeDiagnostic,
|
||||
|
|
|
|||
|
|
@ -13,11 +13,13 @@ export interface LaunchStateSummary {
|
|||
expectedMemberCount?: number;
|
||||
confirmedMemberCount?: number;
|
||||
missingMembers?: string[];
|
||||
skippedMembers?: string[];
|
||||
teamLaunchState?: TeamSummary['teamLaunchState'];
|
||||
launchUpdatedAt?: string;
|
||||
confirmedCount?: number;
|
||||
pendingCount?: number;
|
||||
failedCount?: number;
|
||||
skippedCount?: number;
|
||||
runtimeAlivePendingCount?: number;
|
||||
shellOnlyPendingCount?: number;
|
||||
runtimeProcessPendingCount?: number;
|
||||
|
|
@ -60,6 +62,10 @@ export function createLaunchStateSummary(
|
|||
const member = snapshot.members[name];
|
||||
return member?.launchState === 'failed_to_start';
|
||||
});
|
||||
const skippedMembers = persistedMemberNames.filter((name) => {
|
||||
const member = snapshot.members[name];
|
||||
return member?.launchState === 'skipped_for_launch' || member?.skippedForLaunch === true;
|
||||
});
|
||||
|
||||
return {
|
||||
...(snapshot.teamLaunchState === 'partial_failure'
|
||||
|
|
@ -72,11 +78,13 @@ export function createLaunchStateSummary(
|
|||
? { confirmedMemberCount: snapshot.summary.confirmedCount }
|
||||
: {}),
|
||||
...(missingMembers.length > 0 ? { missingMembers } : {}),
|
||||
...(skippedMembers.length > 0 ? { skippedMembers } : {}),
|
||||
teamLaunchState: snapshot.teamLaunchState,
|
||||
launchUpdatedAt: snapshot.updatedAt,
|
||||
confirmedCount: snapshot.summary.confirmedCount,
|
||||
pendingCount: snapshot.summary.pendingCount,
|
||||
failedCount: snapshot.summary.failedCount,
|
||||
skippedCount: snapshot.summary.skippedCount,
|
||||
runtimeAlivePendingCount: snapshot.summary.runtimeAlivePendingCount,
|
||||
shellOnlyPendingCount: snapshot.summary.shellOnlyPendingCount,
|
||||
runtimeProcessPendingCount: snapshot.summary.runtimeProcessPendingCount,
|
||||
|
|
@ -138,8 +146,17 @@ export function normalizePersistedLaunchSummaryProjection(
|
|||
normalized.missingMembers = missingMembers;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(record.skippedMembers)) {
|
||||
const skippedMembers = record.skippedMembers.filter(
|
||||
(member): member is string => typeof member === 'string' && member.trim().length > 0
|
||||
);
|
||||
if (skippedMembers.length > 0) {
|
||||
normalized.skippedMembers = skippedMembers;
|
||||
}
|
||||
}
|
||||
if (
|
||||
record.teamLaunchState === 'partial_failure' ||
|
||||
record.teamLaunchState === 'partial_skipped' ||
|
||||
record.teamLaunchState === 'partial_pending' ||
|
||||
record.teamLaunchState === 'clean_success'
|
||||
) {
|
||||
|
|
@ -154,6 +171,9 @@ export function normalizePersistedLaunchSummaryProjection(
|
|||
if (typeof record.failedCount === 'number' && record.failedCount >= 0) {
|
||||
normalized.failedCount = record.failedCount;
|
||||
}
|
||||
if (typeof record.skippedCount === 'number' && record.skippedCount >= 0) {
|
||||
normalized.skippedCount = record.skippedCount;
|
||||
}
|
||||
if (typeof record.runtimeAlivePendingCount === 'number' && record.runtimeAlivePendingCount >= 0) {
|
||||
normalized.runtimeAlivePendingCount = record.runtimeAlivePendingCount;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -563,32 +563,6 @@ const PREFLIGHT_BINARY_TIMEOUT_MS = 8000;
|
|||
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
||||
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
||||
const OPENCODE_PREFLIGHT_MODEL_PROBE_CONCURRENCY = 2;
|
||||
const OPENCODE_PROJECT_EVIDENCE_MISSING_DIAGNOSTIC =
|
||||
'OpenCode production E2E evidence artifact has no entry for the current working directory';
|
||||
const OPENCODE_PROJECT_EVIDENCE_NOTE =
|
||||
'OpenCode has not been verified on this project yet. This does not mean the selected models are broken.';
|
||||
|
||||
function pushUniqueProvisioningWarning(warnings: string[], warning: string): void {
|
||||
if (!warnings.includes(warning)) {
|
||||
warnings.push(warning);
|
||||
}
|
||||
}
|
||||
|
||||
function isOpenCodeProjectEvidenceMissingDiagnostic(value: string): boolean {
|
||||
return value.trim() === OPENCODE_PROJECT_EVIDENCE_MISSING_DIAGNOSTIC;
|
||||
}
|
||||
|
||||
function isOpenCodeProjectEvidenceMissingPrepareFailure(
|
||||
prepare: TeamRuntimePrepareResult
|
||||
): boolean {
|
||||
if (prepare.ok || prepare.reason !== 'e2e_missing') {
|
||||
return false;
|
||||
}
|
||||
const diagnostics = prepare.diagnostics
|
||||
.map((diagnostic) => diagnostic.trim())
|
||||
.filter((diagnostic) => diagnostic.length > 0);
|
||||
return diagnostics.length > 0 && diagnostics.every(isOpenCodeProjectEvidenceMissingDiagnostic);
|
||||
}
|
||||
|
||||
function applyDistinctProvisioningMemberColors<
|
||||
T extends { name: string; color?: string; removedAt?: number },
|
||||
|
|
@ -1576,6 +1550,7 @@ function summarizeMemberSpawnStatusRecord(
|
|||
let confirmedCount = 0;
|
||||
let pendingCount = 0;
|
||||
let failedCount = 0;
|
||||
let skippedCount = 0;
|
||||
let runtimeAlivePendingCount = 0;
|
||||
let shellOnlyPendingCount = 0;
|
||||
let runtimeProcessPendingCount = 0;
|
||||
|
|
@ -1594,6 +1569,10 @@ function summarizeMemberSpawnStatusRecord(
|
|||
confirmedCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) {
|
||||
skippedCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (entry.launchState === 'failed_to_start') {
|
||||
failedCount += 1;
|
||||
continue;
|
||||
|
|
@ -1624,6 +1603,7 @@ function summarizeMemberSpawnStatusRecord(
|
|||
confirmedCount,
|
||||
pendingCount,
|
||||
failedCount,
|
||||
skippedCount,
|
||||
runtimeAlivePendingCount,
|
||||
shellOnlyPendingCount,
|
||||
runtimeProcessPendingCount,
|
||||
|
|
@ -1828,8 +1808,12 @@ function deriveMemberLaunchState(entry: {
|
|||
runtimeAlive?: boolean;
|
||||
bootstrapConfirmed?: boolean;
|
||||
hardFailure?: boolean;
|
||||
skippedForLaunch?: boolean;
|
||||
pendingPermissionRequestIds?: string[];
|
||||
}): MemberLaunchState {
|
||||
if (entry.skippedForLaunch) {
|
||||
return 'skipped_for_launch';
|
||||
}
|
||||
if (entry.hardFailure) {
|
||||
return 'failed_to_start';
|
||||
}
|
||||
|
|
@ -2364,7 +2348,9 @@ ${getAgentLanguageInstruction()}
|
|||
Your FIRST action: call MCP tool member_briefing with:
|
||||
{ teamName: "${teamName}", memberName: "${member.name}" }
|
||||
Call member_briefing directly. Do NOT use Agent, any subagent, or any delegated helper for this step.
|
||||
If member_briefing fails, SendMessage "${leadName}" one short natural-language sentence with the exact error text. Do NOT send only "bootstrap failed".
|
||||
If tool search says agent-teams is still connecting, wait briefly and retry tool search at most once.
|
||||
If member_briefing is still unavailable after that one retry, SendMessage "${leadName}" exactly one short natural-language sentence with the exact error text, then stop this turn and wait. Do NOT send only "bootstrap failed".
|
||||
Do NOT keep searching for member_briefing, check tasks, or send repeated status/idle messages after reporting the bootstrap failure.
|
||||
${getCanonicalSendMessageFieldRule()}
|
||||
Correct example:
|
||||
${buildCanonicalSendMessageExample({ to: leadName, summary: 'bootstrap error', message: 'exact error text' })}
|
||||
|
|
@ -2393,7 +2379,9 @@ The team has just been reconnected after a restart.
|
|||
Your FIRST action: call MCP tool member_briefing with:
|
||||
{ teamName: "${teamName}", memberName: "${member.name}" }
|
||||
Call member_briefing directly. Do NOT use Agent, any subagent, or any delegated helper for this step.
|
||||
If member_briefing fails, SendMessage "${leadName}" one short natural-language sentence with the exact error text. Do NOT send only "bootstrap failed".
|
||||
If tool search says agent-teams is still connecting, wait briefly and retry tool search at most once.
|
||||
If member_briefing is still unavailable after that one retry, SendMessage "${leadName}" exactly one short natural-language sentence with the exact error text, then stop this turn and wait. Do NOT send only "bootstrap failed".
|
||||
Do NOT keep searching for member_briefing, check tasks, or send repeated status/idle messages after reporting the bootstrap failure.
|
||||
${getCanonicalSendMessageFieldRule()}
|
||||
Correct example:
|
||||
${buildCanonicalSendMessageExample({ to: leadName, summary: 'bootstrap error', message: 'exact error text' })}
|
||||
|
|
@ -2439,7 +2427,9 @@ Your FIRST action: call MCP tool member_briefing with:
|
|||
Call member_briefing directly as your own MCP tool call. Do NOT use the Agent tool, any subagent, or any delegated helper for this step.
|
||||
member_briefing is expected to be available in your initial MCP tool list. If it is missing or unavailable, treat that as a real bootstrap error and report the exact error text to your team lead.
|
||||
Do NOT start work, claim tasks, or improvise workflow/task/process rules before member_briefing succeeds.
|
||||
If member_briefing fails, send one short natural-language message to your team lead "${leadName}" that includes the exact failure reason (for example the API error, validation error, or lookup failure), then wait. Do NOT send only "bootstrap failed".
|
||||
If tool search says agent-teams is still connecting, wait briefly and retry tool search at most once.
|
||||
If member_briefing is still unavailable after that one retry, send exactly one short natural-language message to your team lead "${leadName}" that includes the exact failure reason (for example the API error, validation error, or lookup failure), then stop this turn and wait. Do NOT send only "bootstrap failed".
|
||||
Do NOT keep searching for member_briefing, check tasks, or send repeated status/idle messages after reporting the bootstrap failure.
|
||||
IMPORTANT: When sending messages to the team lead, always use the exact name "${leadName}" in the \`to\` field of SendMessage. Never abbreviate or shorten it (e.g. do NOT use "lead" instead of "team-lead").
|
||||
${getCanonicalSendMessageFieldRule()}
|
||||
Correct example:
|
||||
|
|
@ -2513,7 +2503,9 @@ ${providerArgLine}${modelArgLine}${effortArgLine} - prompt:
|
|||
Call member_briefing directly as your own MCP tool call. Do NOT use the Agent tool, any subagent, or any delegated helper for this step.
|
||||
member_briefing is expected to be available in your initial MCP tool list. If it is missing or unavailable, treat that as a real bootstrap error and report the exact error text to your team lead.
|
||||
Do NOT start work, claim tasks, or improvise workflow/task/process rules before member_briefing succeeds.
|
||||
If member_briefing fails, send one short natural-language message to your team lead "${leadName}" that includes the exact failure reason (for example the API error, validation error, or lookup failure), then wait. Do NOT send only "bootstrap failed".
|
||||
If tool search says agent-teams is still connecting, wait briefly and retry tool search at most once.
|
||||
If member_briefing is still unavailable after that one retry, send exactly one short natural-language message to your team lead "${leadName}" that includes the exact failure reason (for example the API error, validation error, or lookup failure), then stop this turn and wait. Do NOT send only "bootstrap failed".
|
||||
Do NOT keep searching for member_briefing, check tasks, or send repeated status/idle messages after reporting the bootstrap failure.
|
||||
IMPORTANT: When sending messages to the team lead, always use the exact name "${leadName}" in the \`to\` field of SendMessage. Never abbreviate or shorten it (e.g. do NOT use "lead" instead of "team-lead").
|
||||
${buildTeammateAgentBlockReminder()}
|
||||
${actionModeProtocol}
|
||||
|
|
@ -6754,6 +6746,9 @@ export class TeamProvisioningService {
|
|||
};
|
||||
|
||||
if (status === 'spawning') {
|
||||
next.skippedForLaunch = false;
|
||||
next.skipReason = undefined;
|
||||
next.skippedAt = undefined;
|
||||
next.agentToolAccepted = false;
|
||||
next.runtimeAlive = false;
|
||||
next.bootstrapConfirmed = false;
|
||||
|
|
@ -6769,6 +6764,9 @@ export class TeamProvisioningService {
|
|||
next.lastHeartbeatAt = undefined;
|
||||
next.launchState = 'starting';
|
||||
} else if (status === 'waiting') {
|
||||
next.skippedForLaunch = false;
|
||||
next.skipReason = undefined;
|
||||
next.skippedAt = undefined;
|
||||
next.agentToolAccepted = true;
|
||||
next.runtimeAlive = false;
|
||||
next.bootstrapConfirmed = false;
|
||||
|
|
@ -6784,6 +6782,9 @@ export class TeamProvisioningService {
|
|||
next.lastHeartbeatAt = undefined;
|
||||
next.launchState = 'runtime_pending_bootstrap';
|
||||
} else if (status === 'online') {
|
||||
next.skippedForLaunch = false;
|
||||
next.skipReason = undefined;
|
||||
next.skippedAt = undefined;
|
||||
next.agentToolAccepted = true;
|
||||
next.runtimeAlive = true;
|
||||
next.livenessSource = livenessSource;
|
||||
|
|
@ -6803,14 +6804,39 @@ export class TeamProvisioningService {
|
|||
next.hardFailureReason = undefined;
|
||||
next.launchState = deriveMemberLaunchState(next);
|
||||
} else if (status === 'error') {
|
||||
next.skippedForLaunch = false;
|
||||
next.skipReason = undefined;
|
||||
next.skippedAt = undefined;
|
||||
next.error = error;
|
||||
next.hardFailure = true;
|
||||
next.hardFailureReason = error;
|
||||
next.launchState = 'failed_to_start';
|
||||
} else if (status === 'skipped') {
|
||||
next.skippedForLaunch = true;
|
||||
next.skipReason =
|
||||
error?.trim() || prev.hardFailureReason || prev.error || 'Skipped for this launch';
|
||||
next.skippedAt = updatedAt;
|
||||
next.agentToolAccepted = false;
|
||||
next.runtimeAlive = false;
|
||||
next.bootstrapConfirmed = false;
|
||||
next.hardFailure = false;
|
||||
next.error = undefined;
|
||||
next.hardFailureReason = undefined;
|
||||
next.livenessSource = undefined;
|
||||
next.livenessKind = undefined;
|
||||
next.runtimeDiagnostic = undefined;
|
||||
next.runtimeDiagnosticSeverity = undefined;
|
||||
next.livenessLastCheckedAt = undefined;
|
||||
next.firstSpawnAcceptedAt = undefined;
|
||||
next.lastHeartbeatAt = undefined;
|
||||
next.launchState = 'skipped_for_launch';
|
||||
} else if (status === 'offline') {
|
||||
Object.assign(next, createInitialMemberSpawnStatusEntry(), { updatedAt });
|
||||
next.error = undefined;
|
||||
next.hardFailureReason = undefined;
|
||||
next.skippedForLaunch = false;
|
||||
next.skipReason = undefined;
|
||||
next.skippedAt = undefined;
|
||||
next.livenessSource = undefined;
|
||||
next.livenessKind = undefined;
|
||||
next.runtimeDiagnostic = undefined;
|
||||
|
|
@ -6826,6 +6852,9 @@ export class TeamProvisioningService {
|
|||
prev.launchState === next.launchState &&
|
||||
prev.error === next.error &&
|
||||
prev.hardFailureReason === next.hardFailureReason &&
|
||||
(prev.skippedForLaunch === true) === (next.skippedForLaunch === true) &&
|
||||
prev.skipReason === next.skipReason &&
|
||||
prev.skippedAt === next.skippedAt &&
|
||||
prev.livenessSource === next.livenessSource &&
|
||||
prev.agentToolAccepted === next.agentToolAccepted &&
|
||||
prev.runtimeAlive === next.runtimeAlive &&
|
||||
|
|
@ -6844,7 +6873,8 @@ export class TeamProvisioningService {
|
|||
if (
|
||||
(status === 'online' && (next.bootstrapConfirmed || livenessSource === 'process')) ||
|
||||
status === 'offline' ||
|
||||
status === 'error'
|
||||
status === 'error' ||
|
||||
status === 'skipped'
|
||||
) {
|
||||
run.pendingMemberRestarts?.delete(memberName);
|
||||
}
|
||||
|
|
@ -6885,6 +6915,14 @@ export class TeamProvisioningService {
|
|||
memberName,
|
||||
error?.trim().length ? error.trim() : 'bootstrap failed'
|
||||
);
|
||||
} else if (status === 'skipped') {
|
||||
this.appendMemberBootstrapDiagnostic(
|
||||
run,
|
||||
memberName,
|
||||
error?.trim().length
|
||||
? `skipped for this launch: ${error.trim()}`
|
||||
: 'skipped for this launch'
|
||||
);
|
||||
}
|
||||
if (!this.isCurrentTrackedRun(run)) return;
|
||||
this.emitMemberSpawnChange(run, memberName);
|
||||
|
|
@ -7515,6 +7553,144 @@ export class TeamProvisioningService {
|
|||
}
|
||||
}
|
||||
|
||||
async skipMemberForLaunch(teamName: string, memberName: string): Promise<void> {
|
||||
const normalizedMemberName = memberName.trim();
|
||||
if (!normalizedMemberName) {
|
||||
throw new Error('Member name is required');
|
||||
}
|
||||
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
if (!config) {
|
||||
throw new Error(`Team "${teamName}" configuration is no longer available`);
|
||||
}
|
||||
|
||||
let metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>> = [];
|
||||
try {
|
||||
metaMembers = await this.membersMetaStore.getMembers(teamName);
|
||||
} catch {
|
||||
metaMembers = [];
|
||||
}
|
||||
|
||||
const configuredMember = this.resolveEffectiveConfiguredMember(
|
||||
config.members ?? [],
|
||||
metaMembers,
|
||||
normalizedMemberName
|
||||
);
|
||||
if (!configuredMember) {
|
||||
throw new Error(`Member "${normalizedMemberName}" is not configured in team "${teamName}"`);
|
||||
}
|
||||
if (configuredMember.removedAt) {
|
||||
throw new Error(`Member "${normalizedMemberName}" has been removed`);
|
||||
}
|
||||
if (isLeadMember({ name: normalizedMemberName, agentType: configuredMember.agentType })) {
|
||||
throw new Error('Lead cannot be skipped for a launch');
|
||||
}
|
||||
|
||||
const runId = this.getTrackedRunId(teamName);
|
||||
const run = runId ? this.runs.get(runId) : undefined;
|
||||
const persistedSnapshot = await this.launchStateStore.read(teamName).catch(() => null);
|
||||
const runEntry = run?.memberSpawnStatuses.get(normalizedMemberName);
|
||||
const persistedMember = persistedSnapshot?.members[normalizedMemberName];
|
||||
const alreadySkipped =
|
||||
runEntry?.launchState === 'skipped_for_launch' ||
|
||||
runEntry?.skippedForLaunch === true ||
|
||||
persistedMember?.launchState === 'skipped_for_launch' ||
|
||||
persistedMember?.skippedForLaunch === true;
|
||||
|
||||
if (alreadySkipped) {
|
||||
return;
|
||||
}
|
||||
|
||||
const failedThisLaunch =
|
||||
runEntry?.launchState === 'failed_to_start' ||
|
||||
runEntry?.status === 'error' ||
|
||||
persistedMember?.launchState === 'failed_to_start' ||
|
||||
persistedMember?.hardFailure === true;
|
||||
if (!failedThisLaunch) {
|
||||
throw new Error(`Member "${normalizedMemberName}" has not failed this launch`);
|
||||
}
|
||||
|
||||
if (run?.pendingMemberRestarts.has(normalizedMemberName)) {
|
||||
throw new Error(`Restart for teammate "${normalizedMemberName}" is already in progress`);
|
||||
}
|
||||
|
||||
const previousFailureReason =
|
||||
runEntry?.hardFailureReason ??
|
||||
runEntry?.error ??
|
||||
persistedMember?.hardFailureReason ??
|
||||
persistedMember?.runtimeDiagnostic;
|
||||
const reason = previousFailureReason?.trim()
|
||||
? `Skipped by user after launch failure: ${previousFailureReason.trim()}`
|
||||
: 'Skipped by user for this launch';
|
||||
|
||||
if (run && !run.processKilled && !run.cancelRequested) {
|
||||
this.agentRuntimeSnapshotCache.delete(teamName);
|
||||
this.liveTeamAgentRuntimeMetadataCache.delete(teamName);
|
||||
this.resetRuntimeToolActivity(run, normalizedMemberName);
|
||||
this.clearMemberSpawnToolTracking(run, normalizedMemberName);
|
||||
this.setMemberSpawnStatus(run, normalizedMemberName, 'skipped', reason);
|
||||
if (run.isLaunch) {
|
||||
await this.persistLaunchStateSnapshot(
|
||||
run,
|
||||
run.provisioningComplete ? 'finished' : 'active'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.sendMessageToRun(
|
||||
run,
|
||||
`Teammate "${normalizedMemberName}" was skipped for this launch after a startup failure. Continue without waiting for this teammate unless the user retries it.`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`[${teamName}] Failed to notify lead about skipped teammate "${normalizedMemberName}": ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!persistedSnapshot || !persistedMember) {
|
||||
throw new Error(`No launch state is available for member "${normalizedMemberName}"`);
|
||||
}
|
||||
|
||||
const updatedAt = nowIso();
|
||||
const nextMembers = {
|
||||
...persistedSnapshot.members,
|
||||
[normalizedMemberName]: {
|
||||
...persistedMember,
|
||||
launchState: 'skipped_for_launch' as const,
|
||||
skippedForLaunch: true,
|
||||
skipReason: reason,
|
||||
skippedAt: updatedAt,
|
||||
agentToolAccepted: false,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
hardFailureReason: undefined,
|
||||
pendingPermissionRequestIds: undefined,
|
||||
livenessKind: undefined,
|
||||
runtimeDiagnostic: undefined,
|
||||
runtimeDiagnosticSeverity: undefined,
|
||||
lastEvaluatedAt: updatedAt,
|
||||
diagnostics: [`skipped for this launch: ${reason}`],
|
||||
},
|
||||
};
|
||||
const nextSnapshot = createPersistedLaunchSnapshot({
|
||||
teamName: persistedSnapshot.teamName,
|
||||
expectedMembers: persistedSnapshot.expectedMembers,
|
||||
bootstrapExpectedMembers: persistedSnapshot.bootstrapExpectedMembers,
|
||||
leadSessionId: persistedSnapshot.leadSessionId,
|
||||
launchPhase: persistedSnapshot.launchPhase,
|
||||
members: nextMembers,
|
||||
updatedAt,
|
||||
});
|
||||
await this.launchStateStore.write(teamName, nextSnapshot);
|
||||
this.agentRuntimeSnapshotCache.delete(teamName);
|
||||
this.liveTeamAgentRuntimeMetadataCache.delete(teamName);
|
||||
}
|
||||
|
||||
private getMutableAliveRunOrThrow(teamName: string): ProvisioningRun {
|
||||
const runId = this.getAliveRunId(teamName);
|
||||
if (!runId) {
|
||||
|
|
@ -7801,7 +7977,11 @@ export class TeamProvisioningService {
|
|||
}
|
||||
return run.expectedMembers.every((memberName) => {
|
||||
const entry = run.memberSpawnStatuses.get(memberName);
|
||||
return entry?.launchState === 'failed_to_start' || entry?.launchState === 'confirmed_alive';
|
||||
return (
|
||||
entry?.launchState === 'failed_to_start' ||
|
||||
entry?.launchState === 'confirmed_alive' ||
|
||||
entry?.launchState === 'skipped_for_launch'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -8267,12 +8447,6 @@ export class TeamProvisioningService {
|
|||
|
||||
const primaryReason =
|
||||
prepare.diagnostics.find((entry) => entry.trim().length > 0) ?? prepare.reason;
|
||||
if (isOpenCodeProjectEvidenceMissingPrepareFailure(prepare)) {
|
||||
details.push(`Selected model ${modelId} verified for launch.`);
|
||||
pushUniqueProvisioningWarning(warnings, OPENCODE_PROJECT_EVIDENCE_NOTE);
|
||||
continue;
|
||||
}
|
||||
|
||||
const unavailableLine = `Selected model ${modelId} is unavailable. ${primaryReason}`;
|
||||
const verificationWarningLine = `Selected model ${modelId} could not be verified. ${primaryReason}`;
|
||||
if (prepare.retryable) {
|
||||
|
|
@ -8362,14 +8536,6 @@ export class TeamProvisioningService {
|
|||
if (!sharedPrepare.ok) {
|
||||
const primaryReason =
|
||||
sharedPrepare.diagnostics.find((entry) => entry.trim().length > 0) ?? sharedPrepare.reason;
|
||||
if (isOpenCodeProjectEvidenceMissingPrepareFailure(sharedPrepare)) {
|
||||
pushUniqueProvisioningWarning(warnings, OPENCODE_PROJECT_EVIDENCE_NOTE);
|
||||
for (const modelId of modelIds) {
|
||||
details.push(`Selected model ${modelId} verified for launch.`);
|
||||
}
|
||||
return { details, warnings, blockingMessages };
|
||||
}
|
||||
|
||||
for (const modelId of modelIds) {
|
||||
const unavailableLine = `Selected model ${modelId} is unavailable. ${primaryReason}`;
|
||||
const verificationWarningLine = `Selected model ${modelId} could not be verified. ${primaryReason}`;
|
||||
|
|
@ -12378,7 +12544,9 @@ export class TeamProvisioningService {
|
|||
const current = run.memberSpawnStatuses.get(expected);
|
||||
if (
|
||||
current?.launchState === 'failed_to_start' ||
|
||||
current?.launchState === 'confirmed_alive'
|
||||
current?.launchState === 'confirmed_alive' ||
|
||||
current?.launchState === 'skipped_for_launch' ||
|
||||
current?.skippedForLaunch === true
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -12466,6 +12634,8 @@ export class TeamProvisioningService {
|
|||
const current = run.memberSpawnStatuses.get(expected);
|
||||
if (
|
||||
current?.launchState === 'failed_to_start' ||
|
||||
current?.launchState === 'skipped_for_launch' ||
|
||||
current?.skippedForLaunch === true ||
|
||||
current?.bootstrapConfirmed ||
|
||||
current?.runtimeAlive
|
||||
) {
|
||||
|
|
@ -12504,6 +12674,22 @@ export class TeamProvisioningService {
|
|||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
if (current.launchState === 'skipped_for_launch' || current.skippedForLaunch === true) {
|
||||
nextStatuses[resolvedStatusKey] = {
|
||||
...current,
|
||||
status: 'skipped',
|
||||
launchState: 'skipped_for_launch',
|
||||
skippedForLaunch: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
hardFailureReason: undefined,
|
||||
error: undefined,
|
||||
livenessSource: undefined,
|
||||
livenessLastCheckedAt: nowIso(),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
const runtimeDiagnostic = buildRuntimeDiagnosticForSpawn(metadata);
|
||||
const nextEntry: MemberSpawnStatusEntry = {
|
||||
...current,
|
||||
|
|
@ -13196,6 +13382,7 @@ export class TeamProvisioningService {
|
|||
confirmedCount: number;
|
||||
pendingCount: number;
|
||||
failedCount: number;
|
||||
skippedCount?: number;
|
||||
runtimeAlivePendingCount: number;
|
||||
shellOnlyPendingCount?: number;
|
||||
runtimeProcessPendingCount?: number;
|
||||
|
|
@ -13208,6 +13395,7 @@ export class TeamProvisioningService {
|
|||
let confirmedCount = 0;
|
||||
let pendingCount = 0;
|
||||
let failedCount = 0;
|
||||
let skippedCount = 0;
|
||||
let runtimeAlivePendingCount = 0;
|
||||
let shellOnlyPendingCount = 0;
|
||||
let runtimeProcessPendingCount = 0;
|
||||
|
|
@ -13220,6 +13408,10 @@ export class TeamProvisioningService {
|
|||
confirmedCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) {
|
||||
skippedCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (entry.launchState === 'failed_to_start') {
|
||||
failedCount += 1;
|
||||
continue;
|
||||
|
|
@ -13249,6 +13441,7 @@ export class TeamProvisioningService {
|
|||
confirmedCount,
|
||||
pendingCount,
|
||||
failedCount,
|
||||
skippedCount,
|
||||
runtimeAlivePendingCount,
|
||||
shellOnlyPendingCount,
|
||||
runtimeProcessPendingCount,
|
||||
|
|
@ -13339,12 +13532,17 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
const persistedMemberNames = this.getPersistedLaunchMemberNames(snapshot);
|
||||
const allPendingMembers = persistedMemberNames.filter((memberName) => {
|
||||
const allPendingMembers = persistedMemberNames
|
||||
.filter((memberName) => {
|
||||
const member = snapshot.members[memberName];
|
||||
if (!member) {
|
||||
return false;
|
||||
}
|
||||
return member.launchState !== 'confirmed_alive' && member.launchState !== 'failed_to_start';
|
||||
})
|
||||
.filter((memberName) => {
|
||||
const member = snapshot.members[memberName];
|
||||
return member?.launchState !== 'skipped_for_launch';
|
||||
});
|
||||
if (
|
||||
allPendingMembers.length > 0 &&
|
||||
|
|
@ -13374,7 +13572,11 @@ export class TeamProvisioningService {
|
|||
if (!member) {
|
||||
return true;
|
||||
}
|
||||
return member.launchState !== 'confirmed_alive' && member.launchState !== 'failed_to_start';
|
||||
return (
|
||||
member.launchState !== 'confirmed_alive' &&
|
||||
member.launchState !== 'failed_to_start' &&
|
||||
member.launchState !== 'skipped_for_launch'
|
||||
);
|
||||
});
|
||||
if (secondaryPendingMembers.length === 0) {
|
||||
return this.buildPendingBootstrapStatusMessage(prefix, run, launchSummary);
|
||||
|
|
|
|||
|
|
@ -22,8 +22,6 @@ export type {
|
|||
export { OpenCodeReadinessBridge } from './opencode/bridge/OpenCodeReadinessBridge';
|
||||
export { ReviewApplierService } from './ReviewApplierService';
|
||||
export type {
|
||||
OpenCodeTeamLaunchMode,
|
||||
OpenCodeTeamRuntimeAdapterOptions,
|
||||
OpenCodeTeamRuntimeBridgePort,
|
||||
TeamLaunchRuntimeAdapter,
|
||||
TeamRuntimeLaunchInput,
|
||||
|
|
|
|||
|
|
@ -15,8 +15,6 @@ export type OpenCodeBridgeCommandName =
|
|||
| 'opencode.getRuntimeTranscript'
|
||||
| 'opencode.recoverDeliveryJournal';
|
||||
|
||||
export type OpenCodeTeamLaunchMode = 'disabled' | 'dogfood' | 'production';
|
||||
|
||||
export type OpenCodeTeamLaunchBridgeState =
|
||||
| 'blocked'
|
||||
| 'launching'
|
||||
|
|
@ -48,7 +46,6 @@ export interface OpenCodeTeamLaunchMemberCommandSpec {
|
|||
}
|
||||
|
||||
export interface OpenCodeLaunchTeamCommandBody {
|
||||
mode: OpenCodeTeamLaunchMode;
|
||||
runId: string;
|
||||
laneId: string;
|
||||
teamId: string;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,3 @@
|
|||
import {
|
||||
assertOpenCodeProductionE2EArtifactGate,
|
||||
buildOpenCodeProjectPathFingerprint,
|
||||
type OpenCodeProductionE2EEvidence,
|
||||
} from '../e2e/OpenCodeProductionE2EEvidence';
|
||||
import {
|
||||
buildOpenCodeCanonicalMcpToolId,
|
||||
REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS,
|
||||
} from '../mcp/OpenCodeMcpToolAvailability';
|
||||
|
||||
import type { OpenCodeTeamRuntimeBridgePort } from '../../runtime/OpenCodeTeamRuntimeAdapter';
|
||||
import type {
|
||||
OpenCodeTeamLaunchReadiness,
|
||||
|
|
@ -26,7 +16,6 @@ import type {
|
|||
OpenCodeSendMessageCommandData,
|
||||
OpenCodeStopTeamCommandBody,
|
||||
OpenCodeStopTeamCommandData,
|
||||
OpenCodeTeamLaunchMode,
|
||||
} from './OpenCodeBridgeCommandContract';
|
||||
import type { OpenCodeStateChangingBridgeCommandService } from './OpenCodeStateChangingBridgeCommandService';
|
||||
|
||||
|
|
@ -51,29 +40,12 @@ export interface OpenCodeReadinessBridgeOptions {
|
|||
sendTimeoutMs?: number;
|
||||
stopTimeoutMs?: number;
|
||||
stateChangingCommands?: Pick<OpenCodeStateChangingBridgeCommandService, 'execute'>;
|
||||
productionE2eEvidence?: OpenCodeProductionE2EEvidenceReadPort;
|
||||
}
|
||||
|
||||
export interface OpenCodeProductionE2EEvidenceReadPort {
|
||||
read(input?: {
|
||||
selectedModel?: string | null;
|
||||
projectPathFingerprint?: string | null;
|
||||
opencodeVersion?: string | null;
|
||||
binaryFingerprint?: string | null;
|
||||
capabilitySnapshotId?: string | null;
|
||||
}): Promise<{
|
||||
ok: boolean;
|
||||
evidence: OpenCodeProductionE2EEvidence | null;
|
||||
artifactPath: string;
|
||||
diagnostics: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface OpenCodeReadinessBridgeCommandBody {
|
||||
projectPath: string;
|
||||
selectedModel: string | null;
|
||||
requireExecutionProbe: boolean;
|
||||
launchMode?: OpenCodeTeamLaunchMode;
|
||||
}
|
||||
|
||||
const DEFAULT_READINESS_TIMEOUT_MS = 120_000;
|
||||
|
|
@ -106,11 +78,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
|||
|
||||
if (result.ok) {
|
||||
this.lastRuntimeSnapshotsByProjectPath.set(input.projectPath, result.runtime);
|
||||
return this.applyProductionE2EGate({
|
||||
input,
|
||||
readiness: result.data,
|
||||
runtime: result.runtime,
|
||||
});
|
||||
return result.data;
|
||||
}
|
||||
|
||||
this.lastRuntimeSnapshotsByProjectPath.delete(input.projectPath);
|
||||
|
|
@ -125,86 +93,6 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
|||
});
|
||||
}
|
||||
|
||||
private async applyProductionE2EGate(input: {
|
||||
input: OpenCodeReadinessBridgeCommandBody;
|
||||
readiness: OpenCodeTeamLaunchReadiness;
|
||||
runtime: OpenCodeBridgeRuntimeSnapshot;
|
||||
}): Promise<OpenCodeTeamLaunchReadiness> {
|
||||
const launchMode = input.input.launchMode;
|
||||
if (launchMode !== 'production' && launchMode !== 'dogfood') {
|
||||
return input.readiness;
|
||||
}
|
||||
if (!input.readiness.launchAllowed) {
|
||||
return input.readiness;
|
||||
}
|
||||
|
||||
const expectedModel = input.readiness.modelId ?? input.input.selectedModel;
|
||||
const projectPathFingerprint = buildOpenCodeProjectPathFingerprint(input.input.projectPath);
|
||||
const evidenceRead = this.options.productionE2eEvidence
|
||||
? await this.options.productionE2eEvidence.read({
|
||||
selectedModel: expectedModel,
|
||||
projectPathFingerprint,
|
||||
opencodeVersion: input.runtime.version,
|
||||
binaryFingerprint: input.runtime.binaryFingerprint,
|
||||
capabilitySnapshotId: input.runtime.capabilitySnapshotId,
|
||||
})
|
||||
: {
|
||||
ok: false,
|
||||
evidence: null,
|
||||
artifactPath: '',
|
||||
diagnostics: ['OpenCode production E2E evidence store is not configured'],
|
||||
};
|
||||
const gate = evidenceRead.ok
|
||||
? assertOpenCodeProductionE2EArtifactGate({
|
||||
evidence: evidenceRead.evidence,
|
||||
artifactPath: evidenceRead.artifactPath,
|
||||
expected: {
|
||||
opencodeVersion: input.runtime.version,
|
||||
binaryFingerprint: input.runtime.binaryFingerprint,
|
||||
capabilitySnapshotId: input.runtime.capabilitySnapshotId,
|
||||
selectedModel: expectedModel,
|
||||
projectPathFingerprint,
|
||||
requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
|
||||
buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
|
||||
),
|
||||
},
|
||||
})
|
||||
: {
|
||||
ok: false,
|
||||
diagnostics: evidenceRead.diagnostics,
|
||||
};
|
||||
|
||||
if (gate.ok) {
|
||||
return {
|
||||
...input.readiness,
|
||||
diagnostics: dedupe([...input.readiness.diagnostics, ...evidenceRead.diagnostics]),
|
||||
supportLevel: 'production_supported',
|
||||
};
|
||||
}
|
||||
|
||||
const diagnostics = dedupe([
|
||||
...input.readiness.diagnostics,
|
||||
...evidenceRead.diagnostics,
|
||||
...gate.diagnostics,
|
||||
]);
|
||||
if (launchMode === 'dogfood') {
|
||||
return {
|
||||
...input.readiness,
|
||||
supportLevel: 'supported_e2e_pending',
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...input.readiness,
|
||||
state: 'e2e_missing',
|
||||
launchAllowed: false,
|
||||
supportLevel: 'supported_e2e_pending',
|
||||
missing: dedupe([...input.readiness.missing, ...gate.diagnostics]),
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
getLastOpenCodeRuntimeSnapshot(projectPath: string): OpenCodeBridgeRuntimeSnapshot | null {
|
||||
return this.lastRuntimeSnapshotsByProjectPath.get(projectPath) ?? null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
import type { OpenCodeTeamLaunchMode } from '../bridge/OpenCodeBridgeCommandContract';
|
||||
|
||||
export const CLAUDE_TEAM_OPENCODE_LAUNCH_MODE_ENV = 'CLAUDE_TEAM_OPENCODE_LAUNCH_MODE';
|
||||
export const CLAUDE_TEAM_OPENCODE_DOGFOOD_ENV = 'CLAUDE_TEAM_OPENCODE_DOGFOOD';
|
||||
|
||||
export function resolveOpenCodeTeamLaunchModeFromEnv(
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): OpenCodeTeamLaunchMode {
|
||||
const raw = env[CLAUDE_TEAM_OPENCODE_LAUNCH_MODE_ENV]?.trim().toLowerCase();
|
||||
if (raw === 'dogfood' || raw === 'production' || raw === 'disabled') {
|
||||
return raw;
|
||||
}
|
||||
if (env[CLAUDE_TEAM_OPENCODE_DOGFOOD_ENV] === '1') {
|
||||
return 'dogfood';
|
||||
}
|
||||
return 'production';
|
||||
}
|
||||
|
|
@ -1,612 +0,0 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export const OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION = 1;
|
||||
export const OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION = 1;
|
||||
|
||||
export const OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export const OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS = [
|
||||
'required_tools_proven',
|
||||
'delivery_ready',
|
||||
'member_ready',
|
||||
'run_ready',
|
||||
] as const;
|
||||
|
||||
export const OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS = [
|
||||
'app_mcp_tools_visible',
|
||||
'state_changing_launch_completed',
|
||||
'session_records_persisted',
|
||||
'bootstrap_confirmed_alive',
|
||||
'canonical_log_projection_observed',
|
||||
'reconcile_completed',
|
||||
'stop_completed',
|
||||
'stale_run_rejected',
|
||||
] as const;
|
||||
|
||||
export type OpenCodeProductionE2ERequiredSignal =
|
||||
(typeof OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS)[number];
|
||||
|
||||
export interface OpenCodeProductionE2ECheckpointEvidence {
|
||||
name: string;
|
||||
observedAt: string;
|
||||
}
|
||||
|
||||
export interface OpenCodeProductionE2ESessionEvidence {
|
||||
memberName: string;
|
||||
sessionId: string;
|
||||
launchState: 'confirmed_alive';
|
||||
}
|
||||
|
||||
export interface OpenCodeProductionE2EEvidence {
|
||||
schemaVersion: typeof OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION;
|
||||
evidenceId: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
version: string;
|
||||
passed: boolean;
|
||||
artifactPath: string | null;
|
||||
binaryFingerprint: string;
|
||||
capabilitySnapshotId: string;
|
||||
selectedModel: string;
|
||||
projectPathFingerprint: string | null;
|
||||
requiredSignals: Record<OpenCodeProductionE2ERequiredSignal, boolean>;
|
||||
mcpTools: {
|
||||
requiredTools: string[];
|
||||
observedTools: string[];
|
||||
};
|
||||
launch: {
|
||||
runId: string;
|
||||
teamId: string;
|
||||
teamLaunchState: 'ready';
|
||||
memberCount: number;
|
||||
sessions: OpenCodeProductionE2ESessionEvidence[];
|
||||
durableCheckpoints: OpenCodeProductionE2ECheckpointEvidence[];
|
||||
};
|
||||
reconcile: {
|
||||
runId: string;
|
||||
teamLaunchState: 'ready';
|
||||
memberCount: number;
|
||||
};
|
||||
stop: {
|
||||
runId: string;
|
||||
stopped: true;
|
||||
stoppedSessionIds: string[];
|
||||
};
|
||||
logProjection: {
|
||||
observed: true;
|
||||
projectedMessageCount: number;
|
||||
};
|
||||
diagnostics?: string[];
|
||||
}
|
||||
|
||||
export interface OpenCodeProductionE2EEvidenceCollection {
|
||||
collectionSchemaVersion: typeof OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION;
|
||||
entriesByModel: Record<string, OpenCodeProductionE2EEvidence>;
|
||||
}
|
||||
|
||||
export type OpenCodeProductionE2EEvidenceStoreData =
|
||||
| OpenCodeProductionE2EEvidence
|
||||
| OpenCodeProductionE2EEvidenceCollection
|
||||
| null;
|
||||
|
||||
export interface OpenCodeProductionE2EGateExpectation {
|
||||
opencodeVersion: string | null;
|
||||
binaryFingerprint: string | null;
|
||||
capabilitySnapshotId: string | null;
|
||||
/**
|
||||
* The currently selected raw model id. Kept for observability and evidence
|
||||
* lookup preference, but not as a hard production-proof gate.
|
||||
*/
|
||||
selectedModel: string | null;
|
||||
projectPathFingerprint?: string | null;
|
||||
requiredMcpTools?: string[];
|
||||
}
|
||||
|
||||
export interface OpenCodeProductionE2EGateResult {
|
||||
ok: boolean;
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
export function buildOpenCodeProjectPathFingerprint(
|
||||
projectPath: string | null | undefined
|
||||
): string | null {
|
||||
const trimmed = projectPath?.trim() ?? '';
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = path.resolve(trimmed).replace(/\\/g, '/');
|
||||
return `project:${createHash('sha256').update(normalized).digest('hex')}`;
|
||||
}
|
||||
|
||||
export function validateOpenCodeProductionE2EEvidence(
|
||||
value: unknown
|
||||
): OpenCodeProductionE2EEvidence {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
throw new Error('OpenCode production E2E evidence must be an object');
|
||||
}
|
||||
|
||||
if (record.schemaVersion !== OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION) {
|
||||
throw new Error('OpenCode production E2E evidence has unsupported schemaVersion');
|
||||
}
|
||||
|
||||
const evidence: OpenCodeProductionE2EEvidence = {
|
||||
schemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION,
|
||||
evidenceId: requireString(record.evidenceId, 'evidenceId'),
|
||||
createdAt: requireIsoDate(record.createdAt, 'createdAt'),
|
||||
expiresAt: requireIsoDate(record.expiresAt, 'expiresAt'),
|
||||
version: requireString(record.version, 'version'),
|
||||
passed: requireBoolean(record.passed, 'passed'),
|
||||
artifactPath: optionalString(record.artifactPath, 'artifactPath'),
|
||||
binaryFingerprint: requireString(record.binaryFingerprint, 'binaryFingerprint'),
|
||||
capabilitySnapshotId: requireString(record.capabilitySnapshotId, 'capabilitySnapshotId'),
|
||||
selectedModel: requireString(record.selectedModel, 'selectedModel'),
|
||||
projectPathFingerprint: optionalString(record.projectPathFingerprint, 'projectPathFingerprint'),
|
||||
requiredSignals: normalizeRequiredSignals(record.requiredSignals),
|
||||
mcpTools: normalizeMcpTools(record.mcpTools),
|
||||
launch: normalizeLaunch(record.launch),
|
||||
reconcile: normalizeReconcile(record.reconcile),
|
||||
stop: normalizeStop(record.stop),
|
||||
logProjection: normalizeLogProjection(record.logProjection),
|
||||
diagnostics: optionalStringArray(record.diagnostics, 'diagnostics'),
|
||||
};
|
||||
|
||||
return evidence;
|
||||
}
|
||||
|
||||
export function validateNullableOpenCodeProductionE2EEvidence(
|
||||
value: unknown
|
||||
): OpenCodeProductionE2EEvidence | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
return validateOpenCodeProductionE2EEvidence(value);
|
||||
}
|
||||
|
||||
export function validateOpenCodeProductionE2EEvidenceStoreData(
|
||||
value: unknown
|
||||
): OpenCodeProductionE2EEvidenceStoreData {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = asRecord(value);
|
||||
if (
|
||||
record?.collectionSchemaVersion === OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION
|
||||
) {
|
||||
return validateOpenCodeProductionE2EEvidenceCollection(record);
|
||||
}
|
||||
|
||||
return validateOpenCodeProductionE2EEvidence(value);
|
||||
}
|
||||
|
||||
export function isOpenCodeProductionE2EEvidenceCollection(
|
||||
value: OpenCodeProductionE2EEvidenceStoreData
|
||||
): value is OpenCodeProductionE2EEvidenceCollection {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
'collectionSchemaVersion' in value &&
|
||||
value.collectionSchemaVersion === OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
export function assertOpenCodeProductionE2EEvidenceBasics(input: {
|
||||
evidence: OpenCodeProductionE2EEvidence | null;
|
||||
testedVersion: string;
|
||||
now?: Date;
|
||||
artifactPath?: string | null;
|
||||
}): OpenCodeProductionE2EGateResult {
|
||||
const diagnostics: string[] = [];
|
||||
const now = input.now ?? new Date();
|
||||
const artifactPath = input.artifactPath ?? input.evidence?.artifactPath ?? null;
|
||||
|
||||
if (!input.evidence) {
|
||||
return {
|
||||
ok: false,
|
||||
diagnostics: [
|
||||
'OpenCode version is capability-compatible but production E2E evidence is missing',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
diagnostics.push(...collectArtifactShapeDiagnostics(input.evidence, now, artifactPath));
|
||||
|
||||
if (input.evidence.version !== input.testedVersion) {
|
||||
diagnostics.push(
|
||||
`OpenCode production E2E evidence version ${input.evidence.version} does not match tested version ${input.testedVersion}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: diagnostics.length === 0,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertOpenCodeProductionE2EArtifactGate(input: {
|
||||
evidence: OpenCodeProductionE2EEvidence | null;
|
||||
expected: OpenCodeProductionE2EGateExpectation;
|
||||
now?: Date;
|
||||
artifactPath?: string | null;
|
||||
}): OpenCodeProductionE2EGateResult {
|
||||
const diagnostics: string[] = [];
|
||||
const now = input.now ?? new Date();
|
||||
const artifactPath = input.artifactPath ?? input.evidence?.artifactPath ?? null;
|
||||
|
||||
if (!input.evidence) {
|
||||
return {
|
||||
ok: false,
|
||||
diagnostics: [
|
||||
'OpenCode production launch requires a current production E2E evidence artifact',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
diagnostics.push(...collectArtifactShapeDiagnostics(input.evidence, now, artifactPath));
|
||||
diagnostics.push(...collectExpectedRuntimeDiagnostics(input.evidence, input.expected));
|
||||
|
||||
return {
|
||||
ok: diagnostics.length === 0,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
function collectArtifactShapeDiagnostics(
|
||||
evidence: OpenCodeProductionE2EEvidence,
|
||||
now: Date,
|
||||
artifactPath: string | null
|
||||
): string[] {
|
||||
const diagnostics: string[] = [];
|
||||
const createdAtMs = Date.parse(evidence.createdAt);
|
||||
const expiresAtMs = Date.parse(evidence.expiresAt);
|
||||
|
||||
if (!evidence.passed) {
|
||||
diagnostics.push('OpenCode production E2E evidence did not pass');
|
||||
}
|
||||
|
||||
if (!artifactPath) {
|
||||
diagnostics.push('OpenCode production E2E evidence artifact path is missing');
|
||||
}
|
||||
|
||||
if (!Number.isFinite(createdAtMs)) {
|
||||
diagnostics.push('OpenCode production E2E evidence createdAt is invalid');
|
||||
} else if (now.getTime() - createdAtMs > OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS) {
|
||||
diagnostics.push('OpenCode production E2E evidence is older than the maximum allowed age');
|
||||
}
|
||||
|
||||
if (!Number.isFinite(expiresAtMs)) {
|
||||
diagnostics.push('OpenCode production E2E evidence expiresAt is invalid');
|
||||
} else if (expiresAtMs <= now.getTime()) {
|
||||
diagnostics.push('OpenCode production E2E evidence is expired');
|
||||
}
|
||||
|
||||
const missingSignals = OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.filter(
|
||||
(signal) => evidence.requiredSignals[signal] !== true
|
||||
);
|
||||
if (missingSignals.length > 0) {
|
||||
diagnostics.push(
|
||||
`OpenCode production E2E evidence is missing signals: ${missingSignals.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const checkpointNames = new Set(
|
||||
evidence.launch.durableCheckpoints.map((checkpoint) => checkpoint.name)
|
||||
);
|
||||
const missingCheckpoints = OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.filter(
|
||||
(checkpoint) => !checkpointNames.has(checkpoint)
|
||||
);
|
||||
if (missingCheckpoints.length > 0) {
|
||||
diagnostics.push(
|
||||
`OpenCode production E2E evidence is missing durable checkpoints: ${missingCheckpoints.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
evidence.launch.memberCount <= 0 ||
|
||||
evidence.launch.sessions.length !== evidence.launch.memberCount
|
||||
) {
|
||||
diagnostics.push(
|
||||
'OpenCode production E2E evidence must include confirmed session evidence for every member'
|
||||
);
|
||||
}
|
||||
|
||||
if (evidence.reconcile.runId !== evidence.launch.runId) {
|
||||
diagnostics.push(
|
||||
'OpenCode production E2E reconcile evidence runId does not match launch runId'
|
||||
);
|
||||
}
|
||||
|
||||
if (evidence.reconcile.memberCount !== evidence.launch.memberCount) {
|
||||
diagnostics.push(
|
||||
'OpenCode production E2E reconcile member count does not match launch member count'
|
||||
);
|
||||
}
|
||||
|
||||
if (evidence.stop.runId !== evidence.launch.runId) {
|
||||
diagnostics.push('OpenCode production E2E stop evidence runId does not match launch runId');
|
||||
}
|
||||
|
||||
if (evidence.stop.stoppedSessionIds.length < evidence.launch.sessions.length) {
|
||||
diagnostics.push(
|
||||
'OpenCode production E2E evidence does not prove every launched session was stopped'
|
||||
);
|
||||
}
|
||||
|
||||
if (evidence.logProjection.projectedMessageCount <= 0) {
|
||||
diagnostics.push('OpenCode production E2E evidence must include projected log messages');
|
||||
}
|
||||
|
||||
const observedTools = new Set(evidence.mcpTools.observedTools);
|
||||
const missingTools = evidence.mcpTools.requiredTools.filter((tool) => !observedTools.has(tool));
|
||||
if (missingTools.length > 0) {
|
||||
diagnostics.push(
|
||||
`OpenCode production E2E evidence is missing observed MCP tools: ${missingTools.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function collectExpectedRuntimeDiagnostics(
|
||||
evidence: OpenCodeProductionE2EEvidence,
|
||||
expected: OpenCodeProductionE2EGateExpectation
|
||||
): string[] {
|
||||
const diagnostics: string[] = [];
|
||||
|
||||
if (!expected.opencodeVersion) {
|
||||
diagnostics.push('OpenCode production gate cannot verify runtime version');
|
||||
} else if (evidence.version !== expected.opencodeVersion) {
|
||||
diagnostics.push(
|
||||
`OpenCode production E2E evidence version ${evidence.version} does not match runtime version ${expected.opencodeVersion}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!expected.binaryFingerprint) {
|
||||
diagnostics.push('OpenCode production gate cannot verify runtime binary fingerprint');
|
||||
} else if (evidence.binaryFingerprint !== expected.binaryFingerprint) {
|
||||
diagnostics.push(
|
||||
'OpenCode production E2E evidence binary fingerprint does not match runtime binary fingerprint'
|
||||
);
|
||||
}
|
||||
|
||||
if (!expected.capabilitySnapshotId) {
|
||||
diagnostics.push('OpenCode production gate cannot verify capability snapshot id');
|
||||
} else if (evidence.capabilitySnapshotId !== expected.capabilitySnapshotId) {
|
||||
diagnostics.push(
|
||||
'OpenCode production E2E evidence capability snapshot does not match current runtime'
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
expected.projectPathFingerprint &&
|
||||
evidence.projectPathFingerprint !== expected.projectPathFingerprint
|
||||
) {
|
||||
diagnostics.push(
|
||||
'OpenCode production E2E evidence project context does not match the current working directory'
|
||||
);
|
||||
}
|
||||
|
||||
const requiredTools = expected.requiredMcpTools ?? [];
|
||||
if (requiredTools.length > 0) {
|
||||
const observedTools = new Set(evidence.mcpTools.observedTools);
|
||||
const missingTools = requiredTools.filter((tool) => !observedTools.has(tool));
|
||||
if (missingTools.length > 0) {
|
||||
diagnostics.push(
|
||||
`OpenCode production E2E evidence does not prove required app MCP tools: ${missingTools.join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function normalizeRequiredSignals(
|
||||
value: unknown
|
||||
): Record<OpenCodeProductionE2ERequiredSignal, boolean> {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
throw new Error('OpenCode production E2E evidence requiredSignals must be an object');
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [
|
||||
signal,
|
||||
requireBoolean(record[signal], `requiredSignals.${signal}`),
|
||||
])
|
||||
) as Record<OpenCodeProductionE2ERequiredSignal, boolean>;
|
||||
}
|
||||
|
||||
function normalizeMcpTools(value: unknown): OpenCodeProductionE2EEvidence['mcpTools'] {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
throw new Error('OpenCode production E2E evidence mcpTools must be an object');
|
||||
}
|
||||
return {
|
||||
requiredTools: requireStringArray(record.requiredTools, 'mcpTools.requiredTools'),
|
||||
observedTools: requireStringArray(record.observedTools, 'mcpTools.observedTools'),
|
||||
};
|
||||
}
|
||||
|
||||
function validateOpenCodeProductionE2EEvidenceCollection(
|
||||
value: Record<string, unknown>
|
||||
): OpenCodeProductionE2EEvidenceCollection {
|
||||
const entriesRecord = asRecord(value.entriesByModel);
|
||||
if (!entriesRecord) {
|
||||
throw new Error('OpenCode production E2E evidence collection entriesByModel must be an object');
|
||||
}
|
||||
|
||||
const entries: Record<string, OpenCodeProductionE2EEvidence> = {};
|
||||
for (const [entryKey, rawEvidence] of Object.entries(entriesRecord)) {
|
||||
const trimmedEntryKey = entryKey.trim();
|
||||
if (!trimmedEntryKey) {
|
||||
throw new Error('OpenCode production E2E evidence collection key must be non-empty');
|
||||
}
|
||||
|
||||
const evidence = validateOpenCodeProductionE2EEvidence(rawEvidence);
|
||||
entries[trimmedEntryKey] = evidence;
|
||||
}
|
||||
|
||||
return {
|
||||
collectionSchemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION,
|
||||
entriesByModel: entries,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLaunch(value: unknown): OpenCodeProductionE2EEvidence['launch'] {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
throw new Error('OpenCode production E2E evidence launch must be an object');
|
||||
}
|
||||
if (record.teamLaunchState !== 'ready') {
|
||||
throw new Error('OpenCode production E2E evidence launch.teamLaunchState must be ready');
|
||||
}
|
||||
return {
|
||||
runId: requireString(record.runId, 'launch.runId'),
|
||||
teamId: requireString(record.teamId, 'launch.teamId'),
|
||||
teamLaunchState: 'ready',
|
||||
memberCount: requirePositiveInteger(record.memberCount, 'launch.memberCount'),
|
||||
sessions: requireArray(record.sessions, 'launch.sessions').map(normalizeSession),
|
||||
durableCheckpoints: requireArray(record.durableCheckpoints, 'launch.durableCheckpoints').map(
|
||||
normalizeCheckpoint
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSession(value: unknown): OpenCodeProductionE2ESessionEvidence {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
throw new Error('OpenCode production E2E evidence launch session must be an object');
|
||||
}
|
||||
if (record.launchState !== 'confirmed_alive') {
|
||||
throw new Error('OpenCode production E2E evidence launch session must be confirmed_alive');
|
||||
}
|
||||
return {
|
||||
memberName: requireString(record.memberName, 'launch.sessions.memberName'),
|
||||
sessionId: requireString(record.sessionId, 'launch.sessions.sessionId'),
|
||||
launchState: 'confirmed_alive',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCheckpoint(value: unknown): OpenCodeProductionE2ECheckpointEvidence {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
throw new Error('OpenCode production E2E evidence durable checkpoint must be an object');
|
||||
}
|
||||
return {
|
||||
name: requireString(record.name, 'launch.durableCheckpoints.name'),
|
||||
observedAt: requireIsoDate(record.observedAt, 'launch.durableCheckpoints.observedAt'),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReconcile(value: unknown): OpenCodeProductionE2EEvidence['reconcile'] {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
throw new Error('OpenCode production E2E evidence reconcile must be an object');
|
||||
}
|
||||
if (record.teamLaunchState !== 'ready') {
|
||||
throw new Error('OpenCode production E2E evidence reconcile.teamLaunchState must be ready');
|
||||
}
|
||||
return {
|
||||
runId: requireString(record.runId, 'reconcile.runId'),
|
||||
teamLaunchState: 'ready',
|
||||
memberCount: requirePositiveInteger(record.memberCount, 'reconcile.memberCount'),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStop(value: unknown): OpenCodeProductionE2EEvidence['stop'] {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
throw new Error('OpenCode production E2E evidence stop must be an object');
|
||||
}
|
||||
if (record.stopped !== true) {
|
||||
throw new Error('OpenCode production E2E evidence stop.stopped must be true');
|
||||
}
|
||||
return {
|
||||
runId: requireString(record.runId, 'stop.runId'),
|
||||
stopped: true,
|
||||
stoppedSessionIds: requireStringArray(record.stoppedSessionIds, 'stop.stoppedSessionIds'),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLogProjection(value: unknown): OpenCodeProductionE2EEvidence['logProjection'] {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
throw new Error('OpenCode production E2E evidence logProjection must be an object');
|
||||
}
|
||||
if (record.observed !== true) {
|
||||
throw new Error('OpenCode production E2E evidence logProjection.observed must be true');
|
||||
}
|
||||
return {
|
||||
observed: true,
|
||||
projectedMessageCount: requirePositiveInteger(
|
||||
record.projectedMessageCount,
|
||||
'logProjection.projectedMessageCount'
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function requireString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error(`OpenCode production E2E evidence ${field} must be a non-empty string`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, field: string): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error(`OpenCode production E2E evidence ${field} must be a non-empty string or null`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function requireBoolean(value: unknown, field: string): boolean {
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new Error(`OpenCode production E2E evidence ${field} must be boolean`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requirePositiveInteger(value: unknown, field: string): number {
|
||||
if (!Number.isInteger(value) || (value as number) <= 0) {
|
||||
throw new Error(`OpenCode production E2E evidence ${field} must be a positive integer`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function requireIsoDate(value: unknown, field: string): string {
|
||||
const text = requireString(value, field);
|
||||
if (!Number.isFinite(Date.parse(text))) {
|
||||
throw new Error(`OpenCode production E2E evidence ${field} must be an ISO timestamp`);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function requireArray(value: unknown, field: string): unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`OpenCode production E2E evidence ${field} must be an array`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireStringArray(value: unknown, field: string): string[] {
|
||||
return requireArray(value, field).map((item, index) => requireString(item, `${field}[${index}]`));
|
||||
}
|
||||
|
||||
function optionalStringArray(value: unknown, field: string): string[] | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return requireStringArray(value, field);
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { join, resolve } from 'path';
|
||||
|
||||
export const OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH_ENV =
|
||||
'CLAUDE_TEAM_OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH';
|
||||
|
||||
export const OPENCODE_PRODUCTION_E2E_EVIDENCE_FILE = 'production-e2e-evidence.json';
|
||||
|
||||
export function resolveOpenCodeProductionE2EEvidencePath(input: {
|
||||
bridgeControlDir: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): string {
|
||||
const env = input.env ?? process.env;
|
||||
const overridePath = env[OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH_ENV]?.trim();
|
||||
|
||||
if (overridePath) {
|
||||
return resolve(overridePath);
|
||||
}
|
||||
|
||||
return join(input.bridgeControlDir, OPENCODE_PRODUCTION_E2E_EVIDENCE_FILE);
|
||||
}
|
||||
|
|
@ -1,253 +0,0 @@
|
|||
import * as path from 'path';
|
||||
|
||||
import { VersionedJsonStore } from '../store/VersionedJsonStore';
|
||||
|
||||
import {
|
||||
isOpenCodeProductionE2EEvidenceCollection,
|
||||
OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION,
|
||||
type OpenCodeProductionE2EEvidence,
|
||||
type OpenCodeProductionE2EEvidenceCollection,
|
||||
type OpenCodeProductionE2EEvidenceStoreData,
|
||||
validateOpenCodeProductionE2EEvidence,
|
||||
validateOpenCodeProductionE2EEvidenceStoreData,
|
||||
} from './OpenCodeProductionE2EEvidence';
|
||||
|
||||
export interface OpenCodeProductionE2EEvidenceStoreReadResult {
|
||||
ok: boolean;
|
||||
evidence: OpenCodeProductionE2EEvidence | null;
|
||||
artifactPath: string;
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
export interface OpenCodeProductionE2EEvidenceStoreOptions {
|
||||
filePath: string;
|
||||
clock?: () => Date;
|
||||
}
|
||||
|
||||
export interface OpenCodeProductionE2EEvidenceStoreReadOptions {
|
||||
/**
|
||||
* Preferred exact raw model id when a matching project-scoped proof exists.
|
||||
* Production proof is primarily scoped to the runtime/project integration, not
|
||||
* to a mandatory per-model whitelist.
|
||||
*/
|
||||
selectedModel?: string | null;
|
||||
projectPathFingerprint?: string | null;
|
||||
opencodeVersion?: string | null;
|
||||
binaryFingerprint?: string | null;
|
||||
capabilitySnapshotId?: string | null;
|
||||
}
|
||||
|
||||
export class OpenCodeProductionE2EEvidenceStore {
|
||||
private readonly filePath: string;
|
||||
private readonly store: VersionedJsonStore<OpenCodeProductionE2EEvidenceStoreData>;
|
||||
|
||||
constructor(options: OpenCodeProductionE2EEvidenceStoreOptions) {
|
||||
this.filePath = options.filePath;
|
||||
this.store = new VersionedJsonStore<OpenCodeProductionE2EEvidenceStoreData>({
|
||||
filePath: options.filePath,
|
||||
schemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION,
|
||||
defaultData: () => null,
|
||||
validate: validateOpenCodeProductionE2EEvidenceStoreData,
|
||||
clock: options.clock,
|
||||
quarantineDir: path.dirname(options.filePath),
|
||||
});
|
||||
}
|
||||
|
||||
async read(
|
||||
options: OpenCodeProductionE2EEvidenceStoreReadOptions = {}
|
||||
): Promise<OpenCodeProductionE2EEvidenceStoreReadResult> {
|
||||
const result = await this.store.read();
|
||||
if (!result.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
evidence: null,
|
||||
artifactPath: this.filePath,
|
||||
diagnostics: [
|
||||
`OpenCode production E2E evidence store is unreadable: ${result.message}`,
|
||||
...(result.quarantinePath
|
||||
? [`Quarantined corrupt evidence at ${result.quarantinePath}`]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const selection = selectEvidence(result.data, options);
|
||||
return {
|
||||
ok: true,
|
||||
evidence: selection.evidence,
|
||||
artifactPath: this.filePath,
|
||||
diagnostics: [
|
||||
...selection.diagnostics,
|
||||
...(result.status === 'missing'
|
||||
? ['OpenCode production E2E evidence artifact has not been written yet']
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async write(evidence: OpenCodeProductionE2EEvidence): Promise<void> {
|
||||
const validated = validateOpenCodeProductionE2EEvidence(evidence);
|
||||
await this.store.updateLocked((current) => {
|
||||
const nextEvidence = {
|
||||
...validated,
|
||||
artifactPath: validated.artifactPath ?? this.filePath,
|
||||
};
|
||||
return upsertEvidence(current, nextEvidence);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function selectEvidence(
|
||||
data: OpenCodeProductionE2EEvidenceStoreData,
|
||||
options: OpenCodeProductionE2EEvidenceStoreReadOptions
|
||||
): {
|
||||
evidence: OpenCodeProductionE2EEvidence | null;
|
||||
diagnostics: string[];
|
||||
} {
|
||||
if (!data) {
|
||||
return { evidence: null, diagnostics: [] };
|
||||
}
|
||||
|
||||
if (!isOpenCodeProductionE2EEvidenceCollection(data)) {
|
||||
return { evidence: data, diagnostics: [] };
|
||||
}
|
||||
|
||||
const modelId = options.selectedModel?.trim() ?? '';
|
||||
const projectPathFingerprint = options.projectPathFingerprint?.trim() ?? '';
|
||||
const entries = Object.values(data.entriesByModel);
|
||||
const pickBestForRuntime = (
|
||||
candidates: OpenCodeProductionE2EEvidence[]
|
||||
): OpenCodeProductionE2EEvidence | null => {
|
||||
const runtimeMatched = candidates.filter((entry) => runtimeIdentityMatches(entry, options));
|
||||
return pickNewestEvidence(runtimeMatched.length > 0 ? runtimeMatched : candidates);
|
||||
};
|
||||
|
||||
if (projectPathFingerprint) {
|
||||
const pathEntries = entries.filter(
|
||||
(entry) => entry.projectPathFingerprint === projectPathFingerprint
|
||||
);
|
||||
if (pathEntries.length === 0) {
|
||||
return {
|
||||
evidence: null,
|
||||
diagnostics: [
|
||||
'OpenCode production E2E evidence artifact has no entry for the current working directory',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (modelId) {
|
||||
const exactModelMatch = pickBestForRuntime(
|
||||
pathEntries.filter((entry) => entry.selectedModel === modelId)
|
||||
);
|
||||
if (exactModelMatch) {
|
||||
return {
|
||||
evidence: exactModelMatch,
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
evidence: pickBestForRuntime(pathEntries),
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (modelId) {
|
||||
const exactModelEntries = entries.filter((entry) => entry.selectedModel === modelId);
|
||||
if (exactModelEntries.length === 0) {
|
||||
return {
|
||||
evidence: null,
|
||||
diagnostics: [
|
||||
`OpenCode production E2E evidence artifact has no entry for selected model ${modelId}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
evidence: pickNewestEvidence(exactModelEntries),
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (entries.length === 1) {
|
||||
return { evidence: entries[0] ?? null, diagnostics: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
evidence: null,
|
||||
diagnostics:
|
||||
entries.length === 0
|
||||
? ['OpenCode production E2E evidence artifact has no model entries']
|
||||
: [
|
||||
`OpenCode production E2E evidence artifact contains ${entries.length} model entries; selected model is required`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function upsertEvidence(
|
||||
current: OpenCodeProductionE2EEvidenceStoreData,
|
||||
evidence: OpenCodeProductionE2EEvidence
|
||||
): OpenCodeProductionE2EEvidenceCollection {
|
||||
const entriesByModel: Record<string, OpenCodeProductionE2EEvidence> = {};
|
||||
if (isOpenCodeProductionE2EEvidenceCollection(current)) {
|
||||
Object.assign(entriesByModel, current.entriesByModel);
|
||||
} else if (current) {
|
||||
entriesByModel[current.selectedModel] = current;
|
||||
}
|
||||
|
||||
entriesByModel[buildEvidenceKey(evidence)] = evidence;
|
||||
return {
|
||||
collectionSchemaVersion: 1,
|
||||
entriesByModel,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEvidenceKey(evidence: OpenCodeProductionE2EEvidence): string {
|
||||
return [evidence.selectedModel, evidence.projectPathFingerprint ?? 'global'].join('::');
|
||||
}
|
||||
|
||||
function runtimeIdentityMatches(
|
||||
evidence: OpenCodeProductionE2EEvidence,
|
||||
options: OpenCodeProductionE2EEvidenceStoreReadOptions
|
||||
): boolean {
|
||||
const expectedVersion = options.opencodeVersion?.trim() ?? '';
|
||||
if (expectedVersion && evidence.version !== expectedVersion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedBinaryFingerprint = options.binaryFingerprint?.trim() ?? '';
|
||||
if (expectedBinaryFingerprint && evidence.binaryFingerprint !== expectedBinaryFingerprint) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedCapabilitySnapshotId = options.capabilitySnapshotId?.trim() ?? '';
|
||||
if (
|
||||
expectedCapabilitySnapshotId &&
|
||||
evidence.capabilitySnapshotId !== expectedCapabilitySnapshotId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function pickNewestEvidence(
|
||||
entries: OpenCodeProductionE2EEvidence[]
|
||||
): OpenCodeProductionE2EEvidence | null {
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entries.slice(1).reduce<OpenCodeProductionE2EEvidence>((latest, entry) => {
|
||||
const latestAt = Date.parse(latest.createdAt);
|
||||
const entryAt = Date.parse(entry.createdAt);
|
||||
if (!Number.isFinite(entryAt)) {
|
||||
return latest;
|
||||
}
|
||||
if (!Number.isFinite(latestAt) || entryAt >= latestAt) {
|
||||
return entry;
|
||||
}
|
||||
return latest;
|
||||
}, entries[0]);
|
||||
}
|
||||
|
|
@ -2,12 +2,10 @@ import {
|
|||
evaluateOpenCodeSupport,
|
||||
OPENCODE_TEAM_LAUNCH_VERSION_POLICY,
|
||||
type OpenCodeInstallMethod,
|
||||
type OpenCodeProductionE2EEvidence,
|
||||
type OpenCodeSupportedVersionPolicy,
|
||||
type OpenCodeSupportLevel,
|
||||
} from '../version/OpenCodeVersionPolicy';
|
||||
|
||||
import type { OpenCodeTeamLaunchMode } from '../bridge/OpenCodeBridgeCommandContract';
|
||||
import type { OpenCodeApiCapabilities } from '../capabilities/OpenCodeApiCapabilities';
|
||||
import type { OpenCodeMcpToolProof } from '../mcp/OpenCodeMcpToolAvailability';
|
||||
import type { RuntimeStoreReadinessCheck } from '../store/RuntimeStoreManifest';
|
||||
|
|
@ -18,7 +16,6 @@ export type OpenCodeTeamLaunchReadinessState =
|
|||
| 'not_authenticated'
|
||||
| 'unsupported_version'
|
||||
| 'capabilities_missing'
|
||||
| 'e2e_missing'
|
||||
| 'runtime_store_blocked'
|
||||
| 'mcp_unavailable'
|
||||
| 'model_unavailable'
|
||||
|
|
@ -98,21 +95,8 @@ export interface OpenCodeModelExecutionProbePort {
|
|||
}): Promise<OpenCodeModelExecutionProbeResult>;
|
||||
}
|
||||
|
||||
export interface OpenCodeProductionE2EEvidencePort {
|
||||
read(input: {
|
||||
projectPath: string;
|
||||
inventory: OpenCodeRuntimeInventory;
|
||||
capabilities: OpenCodeApiCapabilities;
|
||||
}): Promise<OpenCodeProductionE2EEvidence | null>;
|
||||
}
|
||||
|
||||
export interface OpenCodeTeamLaunchReadinessServiceOptions {
|
||||
versionPolicy?: OpenCodeSupportedVersionPolicy;
|
||||
launchMode?: OpenCodeTeamLaunchMode;
|
||||
/**
|
||||
* @deprecated Use launchMode. Kept for callers that still pass a boolean feature gate.
|
||||
*/
|
||||
adapterEnabled?: boolean;
|
||||
}
|
||||
|
||||
export class OpenCodeTeamLaunchReadinessService {
|
||||
|
|
@ -122,7 +106,6 @@ export class OpenCodeTeamLaunchReadinessService {
|
|||
private readonly mcpTools: OpenCodeMcpToolProofPort,
|
||||
private readonly runtimeStores: OpenCodeRuntimeStoreReadinessPort,
|
||||
private readonly modelExecution: OpenCodeModelExecutionProbePort,
|
||||
private readonly e2eEvidence: OpenCodeProductionE2EEvidencePort,
|
||||
private readonly options: OpenCodeTeamLaunchReadinessServiceOptions = {}
|
||||
) {}
|
||||
|
||||
|
|
@ -130,21 +113,8 @@ export class OpenCodeTeamLaunchReadinessService {
|
|||
projectPath: string;
|
||||
selectedModel: string | null;
|
||||
requireExecutionProbe: boolean;
|
||||
launchMode?: OpenCodeTeamLaunchMode;
|
||||
}): Promise<OpenCodeTeamLaunchReadiness> {
|
||||
const launchMode = resolveReadinessLaunchMode(input.launchMode, this.options);
|
||||
const policy = this.options.versionPolicy ?? OPENCODE_TEAM_LAUNCH_VERSION_POLICY;
|
||||
const dogfoodWarnings: string[] = [];
|
||||
|
||||
if (launchMode === 'disabled') {
|
||||
return readiness({
|
||||
state: 'adapter_disabled',
|
||||
inventory: null,
|
||||
modelId: input.selectedModel,
|
||||
missing: ['OpenCode team launch adapter is disabled by feature gate'],
|
||||
diagnostics: ['OpenCode team launch adapter is disabled by feature gate'],
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const inventory = await this.inventory.probe({ projectPath: input.projectPath });
|
||||
|
|
@ -184,24 +154,13 @@ export class OpenCodeTeamLaunchReadinessService {
|
|||
projectPath: input.projectPath,
|
||||
inventory,
|
||||
});
|
||||
const evidence = await this.e2eEvidence.read({
|
||||
projectPath: input.projectPath,
|
||||
inventory,
|
||||
capabilities,
|
||||
});
|
||||
const support = evaluateOpenCodeSupport({
|
||||
version: inventory.version ?? '0.0.0',
|
||||
capabilities,
|
||||
evidence,
|
||||
policy,
|
||||
});
|
||||
|
||||
if (!support.supported) {
|
||||
if (launchMode === 'dogfood' && support.supportLevel === 'supported_e2e_pending') {
|
||||
dogfoodWarnings.push(
|
||||
'OpenCode production E2E evidence is missing; dogfood launch remains allowed after runtime checks.'
|
||||
);
|
||||
} else {
|
||||
return readiness({
|
||||
state: mapSupportLevelToReadinessState(support.supportLevel),
|
||||
inventory,
|
||||
|
|
@ -212,7 +171,6 @@ export class OpenCodeTeamLaunchReadinessService {
|
|||
diagnostics: appendDiagnostics(inventory.diagnostics, support.diagnostics),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeStoreReadiness = await this.runtimeStores.check({
|
||||
projectPath: input.projectPath,
|
||||
|
|
@ -280,7 +238,7 @@ export class OpenCodeTeamLaunchReadinessService {
|
|||
runtimeStoreReadiness,
|
||||
supportLevel: support.supportLevel,
|
||||
launchAllowed: true,
|
||||
diagnostics: appendDiagnostics(inventory.diagnostics, dogfoodWarnings),
|
||||
diagnostics: inventory.diagnostics,
|
||||
});
|
||||
} catch (error) {
|
||||
return readiness({
|
||||
|
|
@ -293,22 +251,6 @@ export class OpenCodeTeamLaunchReadinessService {
|
|||
}
|
||||
}
|
||||
|
||||
function resolveReadinessLaunchMode(
|
||||
requested: OpenCodeTeamLaunchMode | undefined,
|
||||
options: OpenCodeTeamLaunchReadinessServiceOptions
|
||||
): OpenCodeTeamLaunchMode {
|
||||
if (requested) {
|
||||
return requested;
|
||||
}
|
||||
if (options.launchMode) {
|
||||
return options.launchMode;
|
||||
}
|
||||
if (options.adapterEnabled === true) {
|
||||
return 'production';
|
||||
}
|
||||
return 'disabled';
|
||||
}
|
||||
|
||||
function readiness(input: {
|
||||
state: OpenCodeTeamLaunchReadinessState;
|
||||
inventory: OpenCodeRuntimeInventory | null;
|
||||
|
|
@ -361,8 +303,6 @@ function mapSupportLevelToReadinessState(
|
|||
return 'unsupported_version';
|
||||
case 'supported_capabilities_pending':
|
||||
return 'capabilities_missing';
|
||||
case 'supported_e2e_pending':
|
||||
return 'e2e_missing';
|
||||
case 'production_supported':
|
||||
return 'ready';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
import { createHash } from 'crypto';
|
||||
import { promises as fs } from 'fs';
|
||||
|
||||
import {
|
||||
assertOpenCodeProductionE2EEvidenceBasics,
|
||||
type OpenCodeProductionE2EEvidence,
|
||||
} from '../e2e/OpenCodeProductionE2EEvidence';
|
||||
|
||||
import type {
|
||||
OpenCodeApiCapabilities,
|
||||
OpenCodeApiEndpointKey,
|
||||
|
|
@ -14,18 +9,14 @@ import type {
|
|||
|
||||
export interface OpenCodeSupportedVersionPolicy {
|
||||
minimumVersion: string;
|
||||
testedVersion: string;
|
||||
allowedPrerelease: boolean;
|
||||
requireCapabilities: boolean;
|
||||
requireE2EArtifactsForTestedVersion: boolean;
|
||||
}
|
||||
|
||||
export const OPENCODE_TEAM_LAUNCH_VERSION_POLICY: OpenCodeSupportedVersionPolicy = {
|
||||
minimumVersion: '1.14.19',
|
||||
testedVersion: '1.14.19',
|
||||
allowedPrerelease: false,
|
||||
requireCapabilities: true,
|
||||
requireE2EArtifactsForTestedVersion: true,
|
||||
};
|
||||
|
||||
export type OpenCodeInstallMethod = 'brew' | 'npm' | 'bun' | 'manual' | 'unknown';
|
||||
|
|
@ -41,11 +32,8 @@ export type OpenCodeSupportLevel =
|
|||
| 'unsupported_too_old'
|
||||
| 'unsupported_prerelease'
|
||||
| 'supported_capabilities_pending'
|
||||
| 'supported_e2e_pending'
|
||||
| 'production_supported';
|
||||
|
||||
export { type OpenCodeProductionE2EEvidence } from '../e2e/OpenCodeProductionE2EEvidence';
|
||||
|
||||
export interface OpenCodeCompatibilitySnapshot {
|
||||
schemaVersion: 1;
|
||||
createdAt: string;
|
||||
|
|
@ -57,7 +45,6 @@ export interface OpenCodeCompatibilitySnapshot {
|
|||
supported: boolean;
|
||||
supportLevel: OpenCodeSupportLevel;
|
||||
apiCapabilities: OpenCodeApiCapabilities;
|
||||
testedEvidencePath: string | null;
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
|
|
@ -121,7 +108,6 @@ export function shouldReuseCompatibilitySnapshot(input: {
|
|||
export function evaluateOpenCodeSupport(input: {
|
||||
version: string;
|
||||
capabilities: OpenCodeApiCapabilities;
|
||||
evidence: OpenCodeProductionE2EEvidence | null;
|
||||
policy?: OpenCodeSupportedVersionPolicy;
|
||||
}): OpenCodeSupportDecision {
|
||||
const policy = input.policy ?? OPENCODE_TEAM_LAUNCH_VERSION_POLICY;
|
||||
|
|
@ -157,21 +143,6 @@ export function evaluateOpenCodeSupport(input: {
|
|||
};
|
||||
}
|
||||
|
||||
if (policy.requireE2EArtifactsForTestedVersion) {
|
||||
const evidenceDecision = assertOpenCodeProductionE2EGate({
|
||||
evidence: input.evidence,
|
||||
testedVersion: policy.testedVersion,
|
||||
});
|
||||
if (!evidenceDecision.ok) {
|
||||
return {
|
||||
supported: false,
|
||||
supportLevel: 'supported_e2e_pending',
|
||||
semver: parsed,
|
||||
diagnostics: evidenceDecision.diagnostics,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
supported: true,
|
||||
supportLevel: 'production_supported',
|
||||
|
|
@ -180,14 +151,6 @@ export function evaluateOpenCodeSupport(input: {
|
|||
};
|
||||
}
|
||||
|
||||
export function assertOpenCodeProductionE2EGate(input: {
|
||||
evidence: OpenCodeProductionE2EEvidence | null;
|
||||
testedVersion: string;
|
||||
now?: Date;
|
||||
}): { ok: boolean; diagnostics: string[] } {
|
||||
return assertOpenCodeProductionE2EEvidenceBasics(input);
|
||||
}
|
||||
|
||||
export function selectPermissionReplyRouteFromCache(
|
||||
cache: OpenCodeRouteCompatibilityCache
|
||||
): OpenCodePermissionReplyRoute | null {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import type {
|
|||
OpenCodeSendMessageCommandData,
|
||||
OpenCodeStopTeamCommandBody,
|
||||
OpenCodeStopTeamCommandData,
|
||||
OpenCodeTeamLaunchMode,
|
||||
OpenCodeTeamMemberLaunchBridgeState,
|
||||
} from '../opencode/bridge/OpenCodeBridgeCommandContract';
|
||||
import type { OpenCodeTeamLaunchReadiness } from '../opencode/readiness/OpenCodeTeamLaunchReadiness';
|
||||
|
|
@ -31,7 +30,6 @@ export interface OpenCodeTeamRuntimeBridgePort {
|
|||
projectPath: string;
|
||||
selectedModel: string | null;
|
||||
requireExecutionProbe: boolean;
|
||||
launchMode?: OpenCodeTeamLaunchMode;
|
||||
}): Promise<OpenCodeTeamLaunchReadiness>;
|
||||
getLastOpenCodeRuntimeSnapshot?(projectPath: string): OpenCodeBridgeRuntimeSnapshot | null;
|
||||
launchOpenCodeTeam?(input: OpenCodeLaunchTeamCommandBody): Promise<OpenCodeLaunchTeamCommandData>;
|
||||
|
|
@ -44,14 +42,6 @@ export interface OpenCodeTeamRuntimeBridgePort {
|
|||
): Promise<OpenCodeSendMessageCommandData>;
|
||||
}
|
||||
|
||||
export interface OpenCodeTeamRuntimeAdapterOptions {
|
||||
launchMode?: OpenCodeTeamLaunchMode;
|
||||
/**
|
||||
* @deprecated Use launchMode. Kept for older tests/callers until the production gate is fully wired.
|
||||
*/
|
||||
launchEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface OpenCodeTeamRuntimeMessageInput {
|
||||
runId?: string;
|
||||
teamName: string;
|
||||
|
|
@ -71,8 +61,6 @@ export interface OpenCodeTeamRuntimeMessageResult {
|
|||
diagnostics: string[];
|
||||
}
|
||||
|
||||
export { type OpenCodeTeamLaunchMode } from '../opencode/bridge/OpenCodeBridgeCommandContract';
|
||||
|
||||
const REQUIRED_READY_CHECKPOINTS = new Set([
|
||||
'required_tools_proven',
|
||||
'delivery_ready',
|
||||
|
|
@ -85,32 +73,14 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
private readonly lastProjectPathByTeamName = new Map<string, string>();
|
||||
private readonly lastReadinessByProjectPath = new Map<string, OpenCodeTeamLaunchReadiness>();
|
||||
|
||||
constructor(
|
||||
private readonly bridge: OpenCodeTeamRuntimeBridgePort,
|
||||
private readonly options: OpenCodeTeamRuntimeAdapterOptions = {}
|
||||
) {}
|
||||
constructor(private readonly bridge: OpenCodeTeamRuntimeBridgePort) {}
|
||||
|
||||
async prepare(input: TeamRuntimeLaunchInput): Promise<TeamRuntimePrepareResult> {
|
||||
const configuredLaunchMode = resolveOpenCodeTeamLaunchMode(this.options);
|
||||
if (configuredLaunchMode === 'disabled') {
|
||||
return {
|
||||
ok: false,
|
||||
providerId: this.providerId,
|
||||
reason: 'opencode_team_launch_disabled',
|
||||
retryable: false,
|
||||
diagnostics: [
|
||||
'OpenCode team launch mode is disabled. Set CLAUDE_TEAM_OPENCODE_LAUNCH_MODE=dogfood for local dogfood testing or production after strict readiness evidence exists.',
|
||||
],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
const runtimeOnly = input.runtimeOnly === true;
|
||||
const readiness = await this.bridge.checkOpenCodeTeamLaunchReadiness({
|
||||
projectPath: input.cwd,
|
||||
selectedModel: input.model ?? null,
|
||||
requireExecutionProbe: !runtimeOnly,
|
||||
launchMode: runtimeOnly ? undefined : configuredLaunchMode,
|
||||
});
|
||||
this.lastReadinessByProjectPath.set(input.cwd, readiness);
|
||||
|
||||
|
|
@ -125,36 +95,12 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
};
|
||||
}
|
||||
|
||||
const warnings =
|
||||
configuredLaunchMode === 'dogfood' && !runtimeOnly
|
||||
? [
|
||||
'OpenCode dogfood launch mode is active. This is local test mode and may run without production E2E evidence.',
|
||||
]
|
||||
: [];
|
||||
|
||||
if (
|
||||
!runtimeOnly &&
|
||||
configuredLaunchMode === 'production' &&
|
||||
readiness.supportLevel !== 'production_supported'
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
providerId: this.providerId,
|
||||
reason: 'opencode_production_e2e_evidence_missing',
|
||||
retryable: false,
|
||||
diagnostics: [
|
||||
'OpenCode production launch requires strict production E2E evidence before enabling team launch.',
|
||||
],
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
providerId: this.providerId,
|
||||
modelId: readiness.modelId,
|
||||
diagnostics: readiness.diagnostics,
|
||||
warnings,
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -172,7 +118,6 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
);
|
||||
}
|
||||
|
||||
const configuredLaunchMode = resolveOpenCodeTeamLaunchMode(this.options);
|
||||
const prepared = await this.prepare(input);
|
||||
if (!prepared.ok) {
|
||||
return blockedLaunchResult(input, prepared.reason, prepared.diagnostics, prepared.warnings);
|
||||
|
|
@ -194,7 +139,6 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
const runtimeSnapshot = this.bridge.getLastOpenCodeRuntimeSnapshot?.(input.cwd) ?? null;
|
||||
this.lastProjectPathByTeamName.set(input.teamName, input.cwd);
|
||||
const data = await this.bridge.launchOpenCodeTeam({
|
||||
mode: configuredLaunchMode,
|
||||
runId: input.runId,
|
||||
laneId: input.laneId?.trim() || 'primary',
|
||||
teamId: input.teamName,
|
||||
|
|
@ -420,18 +364,6 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
export function resolveOpenCodeTeamLaunchMode(
|
||||
options: OpenCodeTeamRuntimeAdapterOptions = {}
|
||||
): OpenCodeTeamLaunchMode {
|
||||
if (options.launchMode) {
|
||||
return options.launchMode;
|
||||
}
|
||||
if (options.launchEnabled === true) {
|
||||
return 'production';
|
||||
}
|
||||
return 'disabled';
|
||||
}
|
||||
|
||||
function mapOpenCodeLaunchDataToRuntimeResult(
|
||||
input: TeamRuntimeLaunchInput,
|
||||
data: OpenCodeLaunchTeamCommandData,
|
||||
|
|
@ -738,7 +670,6 @@ function isRetryableReadinessState(state: OpenCodeTeamLaunchReadiness['state']):
|
|||
return (
|
||||
state === 'not_installed' ||
|
||||
state === 'not_authenticated' ||
|
||||
state === 'e2e_missing' ||
|
||||
state === 'runtime_store_blocked' ||
|
||||
state === 'mcp_unavailable' ||
|
||||
state === 'model_unavailable' ||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
export type {
|
||||
OpenCodeTeamLaunchMode,
|
||||
OpenCodeTeamRuntimeAdapterOptions,
|
||||
OpenCodeTeamRuntimeBridgePort,
|
||||
OpenCodeTeamRuntimeMessageInput,
|
||||
OpenCodeTeamRuntimeMessageResult,
|
||||
|
|
|
|||
|
|
@ -385,6 +385,9 @@ export const TEAM_GET_AGENT_RUNTIME = 'team:getAgentRuntime';
|
|||
/** Restart a specific teammate runtime */
|
||||
export const TEAM_RESTART_MEMBER = 'team:restartMember';
|
||||
|
||||
/** Skip a failed teammate for the current launch */
|
||||
export const TEAM_SKIP_MEMBER_FOR_LAUNCH = 'team:skipMemberForLaunch';
|
||||
|
||||
/** Soft-delete a task (set status to 'deleted' with deletedAt timestamp) */
|
||||
export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask';
|
||||
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@ import {
|
|||
TEAM_SET_TASK_LOG_STREAM_TRACKING,
|
||||
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
||||
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
||||
TEAM_SKIP_MEMBER_FOR_LAUNCH,
|
||||
TEAM_SOFT_DELETE_TASK,
|
||||
TEAM_START_TASK,
|
||||
TEAM_START_TASK_BY_USER,
|
||||
|
|
@ -1093,6 +1094,9 @@ const electronAPI: ElectronAPI = {
|
|||
restartMember: async (teamName: string, memberName: string) => {
|
||||
return invokeIpcWithResult<void>(TEAM_RESTART_MEMBER, teamName, memberName);
|
||||
},
|
||||
skipMemberForLaunch: async (teamName: string, memberName: string) => {
|
||||
return invokeIpcWithResult<void>(TEAM_SKIP_MEMBER_FOR_LAUNCH, teamName, memberName);
|
||||
},
|
||||
softDeleteTask: async (teamName: string, taskId: string) => {
|
||||
return invokeIpcWithResult<void>(TEAM_SOFT_DELETE_TASK, teamName, taskId);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -959,6 +959,9 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
restartMember: async (): Promise<void> => {
|
||||
throw new Error('Member restart is not available in browser mode');
|
||||
},
|
||||
skipMemberForLaunch: async (): Promise<void> => {
|
||||
throw new Error('Member launch skip is not available in browser mode');
|
||||
},
|
||||
softDeleteTask: async (_teamName: string, _taskId: string): Promise<void> => {
|
||||
// Not available via HTTP client — no-op
|
||||
},
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ import { buildPendingRuntimeSummaryCopy } from '@renderer/utils/teamLaunchSummar
|
|||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { deriveContextMetrics } from '@shared/utils/contextMetrics';
|
||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import {
|
||||
AlertTriangle,
|
||||
|
|
@ -158,11 +157,6 @@ interface CreateTaskDialogState {
|
|||
defaultChip?: InlineChip;
|
||||
}
|
||||
|
||||
const logger = createLogger('Component:TeamDetailView');
|
||||
const TEAM_DETAIL_COMMIT_WARN_MS = 32;
|
||||
const TEAM_DETAIL_RENDER_BURST_WINDOW_MS = 4_000;
|
||||
const TEAM_DETAIL_RENDER_BURST_WARN_COUNT = 8;
|
||||
const TEAM_DETAIL_RENDER_WARN_THROTTLE_MS = 2_000;
|
||||
const TEAM_PENDING_REPLY_REFRESH_DELAY_MS = 10_000;
|
||||
|
||||
function areResolvedMembersEqual(
|
||||
|
|
@ -940,13 +934,6 @@ export const TeamDetailView = ({
|
|||
teamName,
|
||||
isPaneFocused = false,
|
||||
}: TeamDetailViewProps): React.JSX.Element => {
|
||||
const renderStartedAtRef = useRef(performance.now());
|
||||
const renderDiagnosticsRef = useRef({
|
||||
windowStartedAt: Date.now(),
|
||||
count: 0,
|
||||
lastWarnAt: 0,
|
||||
});
|
||||
renderStartedAtRef.current = performance.now();
|
||||
const { isLight } = useTheme();
|
||||
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
|
||||
const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | null>(null);
|
||||
|
|
@ -1247,6 +1234,7 @@ export const TeamDetailView = ({
|
|||
reviewActionError,
|
||||
addMember,
|
||||
restartMember,
|
||||
skipMemberForLaunch,
|
||||
removeMember,
|
||||
updateMemberRole,
|
||||
launchTeam,
|
||||
|
|
@ -1296,6 +1284,7 @@ export const TeamDetailView = ({
|
|||
reviewActionError: s.reviewActionError,
|
||||
addMember: s.addMember,
|
||||
restartMember: s.restartMember,
|
||||
skipMemberForLaunch: s.skipMemberForLaunch,
|
||||
removeMember: s.removeMember,
|
||||
updateMemberRole: s.updateMemberRole,
|
||||
launchTeam: s.launchTeam,
|
||||
|
|
@ -1334,38 +1323,6 @@ export const TeamDetailView = ({
|
|||
const isThisTabActive = tabId ? activeTabId === tabId : false;
|
||||
const wasInteractiveRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const now = Date.now();
|
||||
const diagnostic = renderDiagnosticsRef.current;
|
||||
if (now - diagnostic.windowStartedAt > TEAM_DETAIL_RENDER_BURST_WINDOW_MS) {
|
||||
diagnostic.windowStartedAt = now;
|
||||
diagnostic.count = 0;
|
||||
}
|
||||
diagnostic.count += 1;
|
||||
|
||||
const commitMs = performance.now() - renderStartedAtRef.current;
|
||||
const tasksCount = data?.tasks.length ?? 0;
|
||||
const membersCount = members.length;
|
||||
const processesCount = data?.processes.length ?? 0;
|
||||
const shouldWarnSlow = commitMs >= TEAM_DETAIL_COMMIT_WARN_MS;
|
||||
const shouldWarnBurst = diagnostic.count >= TEAM_DETAIL_RENDER_BURST_WARN_COUNT;
|
||||
const shouldWarnLarge = tasksCount >= 80;
|
||||
|
||||
if (
|
||||
(shouldWarnSlow || shouldWarnBurst || shouldWarnLarge) &&
|
||||
now - diagnostic.lastWarnAt >= TEAM_DETAIL_RENDER_WARN_THROTTLE_MS
|
||||
) {
|
||||
diagnostic.lastWarnAt = now;
|
||||
logger.warn(
|
||||
`[perf] commit team=${teamName} ms=${commitMs.toFixed(1)} renders=${diagnostic.count} windowMs=${
|
||||
now - diagnostic.windowStartedAt
|
||||
} activeTab=${isThisTabActive ? 'yes' : 'no'} paneFocused=${isPaneFocused ? 'yes' : 'no'} loading=${
|
||||
loading ? 'yes' : 'no'
|
||||
} tasks=${tasksCount} members=${membersCount} processes=${processesCount} panel=${messagesPanelMode}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Messages panel resize
|
||||
const { isResizing: isMessagesPanelResizing, handleProps: messagesPanelHandleProps } =
|
||||
useResizablePanel({
|
||||
|
|
@ -1801,6 +1758,20 @@ export const TeamDetailView = ({
|
|||
openLaunchDialog(data?.isAlive && !isTeamProvisioning ? 'relaunch' : 'launch');
|
||||
}, [data?.isAlive, isTeamProvisioning, openLaunchDialog]);
|
||||
|
||||
const handleRestartMember = useCallback(
|
||||
async (memberName: string): Promise<void> => {
|
||||
await restartMember(teamName, memberName);
|
||||
},
|
||||
[restartMember, teamName]
|
||||
);
|
||||
|
||||
const handleSkipMemberForLaunch = useCallback(
|
||||
async (memberName: string): Promise<void> => {
|
||||
await skipMemberForLaunch(teamName, memberName);
|
||||
},
|
||||
[skipMemberForLaunch, teamName]
|
||||
);
|
||||
|
||||
const handleSelectMember = useCallback((member: ResolvedTeamMember) => {
|
||||
setSelectedMember(member);
|
||||
setSelectedMemberView(null);
|
||||
|
|
@ -2490,6 +2461,8 @@ export const TeamDetailView = ({
|
|||
onSendMessage={handleSendMessageToMember}
|
||||
onAssignTask={handleAssignTaskToMember}
|
||||
onOpenTask={handleOpenTaskById}
|
||||
onRestartMember={handleRestartMember}
|
||||
onSkipMemberForLaunch={handleSkipMemberForLaunch}
|
||||
/>
|
||||
</CollapsibleTeamSection>
|
||||
|
||||
|
|
@ -2789,7 +2762,7 @@ export const TeamDetailView = ({
|
|||
closeSelectedMemberDialog();
|
||||
openCreateTaskDialog('', '', name);
|
||||
}}
|
||||
onRestartMember={(memberName) => restartMember(teamName, memberName)}
|
||||
onRestartMember={handleRestartMember}
|
||||
onTaskClick={(task) => {
|
||||
closeSelectedMemberDialog();
|
||||
setSelectedTask(task);
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ type TeamStatus =
|
|||
| 'provisioning'
|
||||
| 'offline'
|
||||
| 'partial_failure'
|
||||
| 'partial_skipped'
|
||||
| 'partial_pending';
|
||||
|
||||
function getRecentProjects(team: TeamSummary): string[] {
|
||||
|
|
@ -206,6 +207,9 @@ function resolveTeamStatus(
|
|||
if (team.teamLaunchState === 'partial_pending') {
|
||||
return 'partial_pending';
|
||||
}
|
||||
if (team.teamLaunchState === 'partial_skipped') {
|
||||
return 'partial_skipped';
|
||||
}
|
||||
if (team.partialLaunchFailure || team.teamLaunchState === 'partial_failure') {
|
||||
return 'partial_failure';
|
||||
}
|
||||
|
|
@ -249,6 +253,13 @@ const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => {
|
|||
Launch failed partway
|
||||
</span>
|
||||
);
|
||||
case 'partial_skipped':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-sky-500/15 px-2 py-0.5 text-[10px] font-medium text-sky-300">
|
||||
<span className="size-1.5 rounded-full bg-sky-300" />
|
||||
Launch skipped member
|
||||
</span>
|
||||
);
|
||||
case 'partial_pending':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-300">
|
||||
|
|
@ -905,6 +916,7 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
<div className="flex shrink-0 gap-1">
|
||||
{(status === 'offline' ||
|
||||
status === 'partial_failure' ||
|
||||
status === 'partial_skipped' ||
|
||||
status === 'partial_pending') &&
|
||||
team.projectPath && (
|
||||
<Tooltip>
|
||||
|
|
@ -997,6 +1009,12 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
? `Last launch stopped before ${team.missingMembers.length}/${team.expectedMemberCount ?? team.missingMembers.length} teammate${team.missingMembers.length === 1 ? '' : 's'} joined.`
|
||||
: 'Last launch stopped before all teammates joined.'}
|
||||
</p>
|
||||
) : team.teamLaunchState === 'partial_skipped' ? (
|
||||
<p className="mt-2 text-[11px] text-sky-300">
|
||||
{team.skippedMembers?.length
|
||||
? `Last launch skipped ${team.skippedMembers.length}/${team.expectedMemberCount ?? team.skippedMembers.length} teammate${team.skippedMembers.length === 1 ? '' : 's'}.`
|
||||
: 'Last launch has skipped teammates.'}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
{team.members && team.members.length > 0 ? (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
|
|
@ -20,7 +20,16 @@ import {
|
|||
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||
import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary';
|
||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||
import { AlertTriangle, GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Ban,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
||||
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
||||
|
|
@ -63,6 +72,8 @@ interface MemberCardProps {
|
|||
onClick?: () => void;
|
||||
onSendMessage?: () => void;
|
||||
onAssignTask?: () => void;
|
||||
onRestartMember?: (memberName: string) => Promise<void> | void;
|
||||
onSkipMemberForLaunch?: (memberName: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): {
|
||||
|
|
@ -111,6 +122,8 @@ export const MemberCard = ({
|
|||
onClick,
|
||||
onSendMessage,
|
||||
onAssignTask,
|
||||
onRestartMember,
|
||||
onSkipMemberForLaunch,
|
||||
}: MemberCardProps): React.JSX.Element => {
|
||||
// NOTE: lead context display disabled — usage formula is inaccurate
|
||||
// const teamName = useStore((s) => s.selectedTeamName);
|
||||
|
|
@ -118,6 +131,10 @@ export const MemberCard = ({
|
|||
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
||||
// );
|
||||
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
||||
const [retryingLaunch, setRetryingLaunch] = useState(false);
|
||||
const [retryLaunchError, setRetryLaunchError] = useState<string | null>(null);
|
||||
const [skippingLaunch, setSkippingLaunch] = useState(false);
|
||||
const [skipLaunchError, setSkipLaunchError] = useState<string | null>(null);
|
||||
const teamMembers = useStore((s) =>
|
||||
selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : []
|
||||
);
|
||||
|
|
@ -214,12 +231,68 @@ export const MemberCard = ({
|
|||
!isRemoved &&
|
||||
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
|
||||
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
|
||||
const isFailedLaunch = spawnStatus === 'error' || spawnLaunchState === 'failed_to_start';
|
||||
const isSkippedLaunch =
|
||||
spawnStatus === 'skipped' ||
|
||||
spawnLaunchState === 'skipped_for_launch' ||
|
||||
spawnEntry?.skippedForLaunch === true;
|
||||
const showFailedLaunchBadge = !isRemoved && isFailedLaunch;
|
||||
const showSkippedLaunchBadge = !isRemoved && isSkippedLaunch;
|
||||
const hasLiveLaunchControls =
|
||||
isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true;
|
||||
const canRetryLaunch =
|
||||
(showFailedLaunchBadge || showSkippedLaunchBadge) &&
|
||||
!isLeadMember(member) &&
|
||||
Boolean(onRestartMember) &&
|
||||
hasLiveLaunchControls;
|
||||
const canSkipFailedLaunch =
|
||||
showFailedLaunchBadge &&
|
||||
!isLeadMember(member) &&
|
||||
Boolean(onSkipMemberForLaunch) &&
|
||||
hasLiveLaunchControls;
|
||||
const showRuntimeAdvisoryBadge =
|
||||
!isRemoved &&
|
||||
Boolean(runtimeAdvisoryLabel) &&
|
||||
!showLaunchBadge &&
|
||||
spawnStatus !== 'error' &&
|
||||
!isFailedLaunch &&
|
||||
!isSkippedLaunch &&
|
||||
(Boolean(activityTask) || !isAwaitingReply);
|
||||
const handleRetryFailedLaunch = async (
|
||||
event: React.MouseEvent<HTMLButtonElement>
|
||||
): Promise<void> => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!onRestartMember || retryingLaunch) {
|
||||
return;
|
||||
}
|
||||
setRetryLaunchError(null);
|
||||
setRetryingLaunch(true);
|
||||
try {
|
||||
await onRestartMember(member.name);
|
||||
} catch (error) {
|
||||
setRetryLaunchError(error instanceof Error ? error.message : 'Failed to retry teammate');
|
||||
} finally {
|
||||
setRetryingLaunch(false);
|
||||
}
|
||||
};
|
||||
const handleSkipFailedLaunch = async (
|
||||
event: React.MouseEvent<HTMLButtonElement>
|
||||
): Promise<void> => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!onSkipMemberForLaunch || skippingLaunch) {
|
||||
return;
|
||||
}
|
||||
setSkipLaunchError(null);
|
||||
setSkippingLaunch(true);
|
||||
try {
|
||||
await onSkipMemberForLaunch(member.name);
|
||||
} catch (error) {
|
||||
setSkipLaunchError(error instanceof Error ? error.message : 'Failed to skip teammate');
|
||||
} finally {
|
||||
setSkippingLaunch(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -360,7 +433,7 @@ export const MemberCard = ({
|
|||
{launchBadgeLabel}
|
||||
</Badge>
|
||||
</span>
|
||||
) : spawnStatus === 'error' ? (
|
||||
) : showFailedLaunchBadge ? (
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -382,6 +455,94 @@ export const MemberCard = ({
|
|||
className="size-auto rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200"
|
||||
/>
|
||||
) : null}
|
||||
{canSkipFailedLaunch ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={skippingLaunch ? 'Skipping teammate' : 'Skip for this launch'}
|
||||
className="rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={skippingLaunch || retryingLaunch}
|
||||
onClick={handleSkipFailedLaunch}
|
||||
>
|
||||
{skippingLaunch ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Ban className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{skipLaunchError ??
|
||||
(skippingLaunch ? 'Skipping teammate...' : 'Skip for this launch')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{canRetryLaunch ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={retryingLaunch ? 'Retrying teammate' : 'Retry teammate'}
|
||||
className="rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={retryingLaunch || skippingLaunch}
|
||||
onClick={handleRetryFailedLaunch}
|
||||
>
|
||||
{retryingLaunch ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{retryLaunchError ??
|
||||
(retryingLaunch ? 'Retrying teammate...' : 'Retry teammate')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
) : showSkippedLaunchBadge ? (
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Ban className="size-3.5 shrink-0 text-zinc-400" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 bg-zinc-500/15 px-1.5 py-0.5 text-[10px] font-normal leading-none text-zinc-300"
|
||||
>
|
||||
{displayPresenceLabel}
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{spawnEntry?.skipReason ?? 'Skipped for this launch'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{canRetryLaunch ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={retryingLaunch ? 'Retrying teammate' : 'Retry teammate'}
|
||||
className="rounded p-1 text-zinc-300 transition-colors hover:bg-zinc-500/10 hover:text-zinc-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={retryingLaunch}
|
||||
onClick={handleRetryFailedLaunch}
|
||||
>
|
||||
{retryingLaunch ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{retryLaunchError ??
|
||||
(retryingLaunch ? 'Retrying teammate...' : 'Retry teammate')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
) : showRuntimeAdvisoryBadge ? (
|
||||
<Tooltip>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ interface MemberListProps {
|
|||
onSendMessage?: (member: ResolvedTeamMember) => void;
|
||||
onAssignTask?: (member: ResolvedTeamMember) => void;
|
||||
onOpenTask?: (taskId: string) => void;
|
||||
onRestartMember?: (memberName: string) => Promise<void> | void;
|
||||
onSkipMemberForLaunch?: (memberName: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function areResolvedMembersEquivalent(
|
||||
|
|
@ -151,6 +153,9 @@ function areMemberSpawnStatusesEquivalent(
|
|||
leftEntry.error !== rightEntry.error ||
|
||||
leftEntry.hardFailure !== rightEntry.hardFailure ||
|
||||
leftEntry.hardFailureReason !== rightEntry.hardFailureReason ||
|
||||
leftEntry.skippedForLaunch !== rightEntry.skippedForLaunch ||
|
||||
leftEntry.skipReason !== rightEntry.skipReason ||
|
||||
leftEntry.skippedAt !== rightEntry.skippedAt ||
|
||||
leftEntry.livenessSource !== rightEntry.livenessSource ||
|
||||
leftEntry.livenessKind !== rightEntry.livenessKind ||
|
||||
leftEntry.runtimeDiagnostic !== rightEntry.runtimeDiagnostic ||
|
||||
|
|
@ -244,6 +249,8 @@ function areMemberListPropsEqual(
|
|||
prev.isTeamAlive === next.isTeamAlive &&
|
||||
prev.isTeamProvisioning === next.isTeamProvisioning &&
|
||||
prev.leadActivity === next.leadActivity &&
|
||||
prev.onRestartMember === next.onRestartMember &&
|
||||
prev.onSkipMemberForLaunch === next.onSkipMemberForLaunch &&
|
||||
areLaunchParamsEquivalent(prev.launchParams, next.launchParams)
|
||||
);
|
||||
}
|
||||
|
|
@ -265,6 +272,8 @@ export const MemberList = memo(function MemberList({
|
|||
onSendMessage,
|
||||
onAssignTask,
|
||||
onOpenTask,
|
||||
onRestartMember,
|
||||
onSkipMemberForLaunch,
|
||||
}: MemberListProps): React.JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isWide, setIsWide] = useState(false);
|
||||
|
|
@ -372,6 +381,8 @@ export const MemberList = memo(function MemberList({
|
|||
onClick={() => onMemberClick?.(member)}
|
||||
onSendMessage={() => onSendMessage?.(member)}
|
||||
onAssignTask={() => onAssignTask?.(member)}
|
||||
onRestartMember={isRemoved ? undefined : onRestartMember}
|
||||
onSkipMemberForLaunch={isRemoved ? undefined : onSkipMemberForLaunch}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export interface LaunchJoinMilestones {
|
|||
processOnlyAliveCount: number;
|
||||
pendingSpawnCount: number;
|
||||
failedSpawnCount: number;
|
||||
skippedSpawnCount: number;
|
||||
}
|
||||
|
||||
type DisplayStepMilestones = LaunchJoinMilestones & {
|
||||
|
|
@ -106,6 +107,7 @@ function summarizeLiveLaunchJoinMilestones(params: {
|
|||
let processOnlyAliveCount = 0;
|
||||
let pendingSpawnCount = 0;
|
||||
let failedSpawnCount = 0;
|
||||
let skippedSpawnCount = 0;
|
||||
let observedTeammateCount = 0;
|
||||
|
||||
for (const memberName of teammateNames) {
|
||||
|
|
@ -127,6 +129,10 @@ function summarizeLiveLaunchJoinMilestones(params: {
|
|||
failedSpawnCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) {
|
||||
skippedSpawnCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (entry.launchState === 'confirmed_alive') {
|
||||
heartbeatConfirmedCount += 1;
|
||||
continue;
|
||||
|
|
@ -153,6 +159,7 @@ function summarizeLiveLaunchJoinMilestones(params: {
|
|||
processOnlyAliveCount,
|
||||
pendingSpawnCount,
|
||||
failedSpawnCount,
|
||||
skippedSpawnCount,
|
||||
observedTeammateCount,
|
||||
};
|
||||
}
|
||||
|
|
@ -208,19 +215,23 @@ export function getLaunchJoinMilestonesFromMembers({
|
|||
processOnlyAliveCount: snapshotProcessOnlyAliveCount,
|
||||
pendingSpawnCount: Math.max(0, snapshotSummary.pendingCount - snapshotProcessOnlyAliveCount),
|
||||
failedSpawnCount: snapshotSummary.failedCount,
|
||||
skippedSpawnCount: snapshotSummary.skippedCount ?? 0,
|
||||
};
|
||||
|
||||
const snapshotAccountedFor =
|
||||
snapshotMilestones.heartbeatConfirmedCount +
|
||||
snapshotMilestones.processOnlyAliveCount +
|
||||
snapshotMilestones.failedSpawnCount;
|
||||
snapshotMilestones.failedSpawnCount +
|
||||
snapshotMilestones.skippedSpawnCount;
|
||||
const liveAccountedFor =
|
||||
liveSummary.heartbeatConfirmedCount +
|
||||
liveSummary.processOnlyAliveCount +
|
||||
liveSummary.failedSpawnCount;
|
||||
liveSummary.failedSpawnCount +
|
||||
liveSummary.skippedSpawnCount;
|
||||
|
||||
const liveSummaryIsMoreAdvanced =
|
||||
liveSummary.failedSpawnCount > snapshotMilestones.failedSpawnCount ||
|
||||
liveSummary.skippedSpawnCount > snapshotMilestones.skippedSpawnCount ||
|
||||
liveSummary.heartbeatConfirmedCount > snapshotMilestones.heartbeatConfirmedCount ||
|
||||
liveSummary.processOnlyAliveCount > snapshotMilestones.processOnlyAliveCount ||
|
||||
(snapshotMilestones.failedSpawnCount === 0 &&
|
||||
|
|
@ -248,6 +259,7 @@ export function getLaunchJoinState({
|
|||
processOnlyAliveCount,
|
||||
pendingSpawnCount,
|
||||
failedSpawnCount,
|
||||
skippedSpawnCount,
|
||||
}: LaunchJoinMilestones): {
|
||||
allTeammatesConfirmedAlive: boolean;
|
||||
hasMembersStillJoining: boolean;
|
||||
|
|
@ -256,14 +268,16 @@ export function getLaunchJoinState({
|
|||
const allTeammatesConfirmedAlive =
|
||||
expectedTeammateCount > 0 &&
|
||||
failedSpawnCount === 0 &&
|
||||
skippedSpawnCount === 0 &&
|
||||
heartbeatConfirmedCount >= expectedTeammateCount;
|
||||
const remainingJoinCount =
|
||||
expectedTeammateCount > 0 && failedSpawnCount === 0
|
||||
expectedTeammateCount > 0 && failedSpawnCount === 0 && skippedSpawnCount === 0
|
||||
? Math.max(0, expectedTeammateCount - heartbeatConfirmedCount)
|
||||
: 0;
|
||||
const hasMembersStillJoining =
|
||||
expectedTeammateCount > 0 &&
|
||||
failedSpawnCount === 0 &&
|
||||
skippedSpawnCount === 0 &&
|
||||
remainingJoinCount > 0 &&
|
||||
(processOnlyAliveCount > 0 || pendingSpawnCount > 0);
|
||||
|
||||
|
|
@ -295,6 +309,7 @@ export function getDisplayStepIndex({
|
|||
processOnlyAliveCount,
|
||||
pendingSpawnCount,
|
||||
failedSpawnCount,
|
||||
skippedSpawnCount,
|
||||
}: DisplayStepMilestones): number {
|
||||
switch (progress.state) {
|
||||
case 'ready':
|
||||
|
|
@ -322,8 +337,12 @@ export function getDisplayStepIndex({
|
|||
if (failedSpawnCount > 0) {
|
||||
return 2;
|
||||
}
|
||||
if (skippedSpawnCount > 0) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
const accountedForTeammates = heartbeatConfirmedCount + processOnlyAliveCount + failedSpawnCount;
|
||||
const accountedForTeammates =
|
||||
heartbeatConfirmedCount + processOnlyAliveCount + failedSpawnCount + skippedSpawnCount;
|
||||
|
||||
if (pendingSpawnCount > 0 || accountedForTeammates < expectedTeammateCount) {
|
||||
return 2;
|
||||
|
|
|
|||
|
|
@ -71,14 +71,7 @@ const logger = createLogger('teamSlice');
|
|||
const TEAM_GET_DATA_TIMEOUT_MS = 30_000;
|
||||
const TEAM_FETCH_TIMEOUT_MS = 30_000;
|
||||
const MEMBER_SPAWN_STATUSES_IPC_RETRY_BACKOFF_MS = 5_000;
|
||||
const TEAM_DATA_IPC_WARN_MS = 350;
|
||||
const TEAM_DATA_SET_WARN_MS = 12;
|
||||
const TEAM_DATA_POST_WARN_MS = 24;
|
||||
const TEAM_DATA_LARGE_MESSAGES = 150;
|
||||
const TEAM_DATA_LARGE_TASKS = 80;
|
||||
const TEAM_REFRESH_BURST_WINDOW_MS = 4_000;
|
||||
const TEAM_REFRESH_BURST_WARN_COUNT = 5;
|
||||
const TEAM_REFRESH_WARN_THROTTLE_MS = 2_000;
|
||||
const MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS = 2_000;
|
||||
const inFlightTeamDataRequests = new Map<string, Promise<TeamViewSnapshot>>();
|
||||
const inFlightRefreshTeamDataCalls = new Map<string, Set<symbol>>();
|
||||
|
|
@ -567,45 +560,6 @@ function fetchTeamDataFresh(teamName: string): Promise<TeamViewSnapshot> {
|
|||
);
|
||||
}
|
||||
|
||||
function summarizeTeamDataCounts(data: TeamViewSnapshot | null | undefined): {
|
||||
tasks: number;
|
||||
members: number;
|
||||
activeMembers: number;
|
||||
processes: number;
|
||||
} {
|
||||
if (!data) {
|
||||
return { tasks: 0, members: 0, activeMembers: 0, processes: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
tasks: data.tasks.length,
|
||||
members: data.members.length,
|
||||
activeMembers: data.members.filter((member) => !member.removedAt).length,
|
||||
processes: data.processes.length,
|
||||
};
|
||||
}
|
||||
|
||||
function estimateTeamPayloadWeight(data: TeamViewSnapshot): {
|
||||
taskComments: number;
|
||||
taskHistoryEvents: number;
|
||||
taskDescriptionChars: number;
|
||||
} {
|
||||
let taskComments = 0;
|
||||
let taskHistoryEvents = 0;
|
||||
let taskDescriptionChars = 0;
|
||||
for (const task of data.tasks) {
|
||||
taskComments += task.comments?.length ?? 0;
|
||||
taskHistoryEvents += task.historyEvents?.length ?? 0;
|
||||
taskDescriptionChars += task.description?.length ?? 0;
|
||||
}
|
||||
|
||||
return {
|
||||
taskComments,
|
||||
taskHistoryEvents,
|
||||
taskDescriptionChars,
|
||||
};
|
||||
}
|
||||
|
||||
function noteTeamRefreshBurst(teamName: string): number {
|
||||
const now = Date.now();
|
||||
const diagnostic = teamRefreshBurstDiagnostics.get(teamName) ?? {
|
||||
|
|
@ -621,77 +575,10 @@ function noteTeamRefreshBurst(teamName: string): number {
|
|||
|
||||
diagnostic.count += 1;
|
||||
|
||||
if (
|
||||
diagnostic.count >= TEAM_REFRESH_BURST_WARN_COUNT &&
|
||||
now - diagnostic.lastWarnAt >= TEAM_REFRESH_WARN_THROTTLE_MS
|
||||
) {
|
||||
diagnostic.lastWarnAt = now;
|
||||
logger.warn(
|
||||
`[perf] refreshTeamData burst team=${teamName} count=${diagnostic.count} windowMs=${
|
||||
now - diagnostic.windowStartedAt
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
teamRefreshBurstDiagnostics.set(teamName, diagnostic);
|
||||
return diagnostic.count;
|
||||
}
|
||||
|
||||
function maybeLogTeamDataPerf(params: {
|
||||
phase: 'selectTeam' | 'refreshTeamData';
|
||||
teamName: string;
|
||||
ipcMs: number;
|
||||
setMs: number;
|
||||
postMs: number;
|
||||
totalMs: number;
|
||||
previousData: TeamViewSnapshot | null | undefined;
|
||||
nextData: TeamViewSnapshot;
|
||||
deduped: boolean;
|
||||
reusedInFlightRequest: boolean;
|
||||
burstCount?: number;
|
||||
}): void {
|
||||
const {
|
||||
phase,
|
||||
teamName,
|
||||
ipcMs,
|
||||
setMs,
|
||||
postMs,
|
||||
totalMs,
|
||||
previousData,
|
||||
nextData,
|
||||
deduped,
|
||||
reusedInFlightRequest,
|
||||
burstCount,
|
||||
} = params;
|
||||
|
||||
const nextCounts = summarizeTeamDataCounts(nextData);
|
||||
const previousCounts = summarizeTeamDataCounts(previousData);
|
||||
const largePayload = nextCounts.tasks >= TEAM_DATA_LARGE_TASKS;
|
||||
const slow =
|
||||
ipcMs >= TEAM_DATA_IPC_WARN_MS ||
|
||||
setMs >= TEAM_DATA_SET_WARN_MS ||
|
||||
postMs >= TEAM_DATA_POST_WARN_MS;
|
||||
|
||||
if (!slow && !largePayload && !reusedInFlightRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadWeight = estimateTeamPayloadWeight(nextData);
|
||||
logger.warn(
|
||||
`[perf] ${phase} team=${teamName} ipc=${ipcMs.toFixed(1)}ms set=${setMs.toFixed(
|
||||
1
|
||||
)}ms post=${postMs.toFixed(1)}ms total=${totalMs.toFixed(1)}ms deduped=${deduped} reusedInFlight=${
|
||||
reusedInFlightRequest ? 'yes' : 'no'
|
||||
} burst=${burstCount ?? 1} counts=tasks:${previousCounts.tasks}->${nextCounts.tasks},members:${
|
||||
previousCounts.members
|
||||
}->${nextCounts.members},activeMembers:${
|
||||
previousCounts.activeMembers
|
||||
}->${nextCounts.activeMembers},processes:${previousCounts.processes}->${nextCounts.processes} payload=textChars:${
|
||||
payloadWeight.taskDescriptionChars
|
||||
},taskComments=${payloadWeight.taskComments},historyEvents=${payloadWeight.taskHistoryEvents}`
|
||||
);
|
||||
}
|
||||
|
||||
function areLaunchSummaryCountsEqual(
|
||||
left: PersistedTeamLaunchSummary | undefined,
|
||||
right: PersistedTeamLaunchSummary | undefined
|
||||
|
|
@ -702,6 +589,7 @@ function areLaunchSummaryCountsEqual(
|
|||
left.confirmedCount === right.confirmedCount &&
|
||||
left.pendingCount === right.pendingCount &&
|
||||
left.failedCount === right.failedCount &&
|
||||
left.skippedCount === right.skippedCount &&
|
||||
left.runtimeAlivePendingCount === right.runtimeAlivePendingCount &&
|
||||
left.shellOnlyPendingCount === right.shellOnlyPendingCount &&
|
||||
left.runtimeProcessPendingCount === right.runtimeProcessPendingCount &&
|
||||
|
|
@ -741,6 +629,9 @@ function areMemberSpawnStatusEntriesEqual(
|
|||
left.launchState === right.launchState &&
|
||||
left.error === right.error &&
|
||||
left.hardFailureReason === right.hardFailureReason &&
|
||||
left.skippedForLaunch === right.skippedForLaunch &&
|
||||
left.skipReason === right.skipReason &&
|
||||
left.skippedAt === right.skippedAt &&
|
||||
left.livenessSource === right.livenessSource &&
|
||||
left.runtimeAlive === right.runtimeAlive &&
|
||||
left.runtimeModel === right.runtimeModel &&
|
||||
|
|
@ -2158,6 +2049,7 @@ export interface TeamSlice {
|
|||
) => Promise<TaskComment>;
|
||||
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
|
||||
restartMember: (teamName: string, memberName: string) => Promise<void>;
|
||||
skipMemberForLaunch: (teamName: string, memberName: string) => Promise<void>;
|
||||
removeMember: (teamName: string, memberName: string) => Promise<void>;
|
||||
updateMemberRole: (
|
||||
teamName: string,
|
||||
|
|
@ -3234,7 +3126,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
},
|
||||
|
||||
selectTeam: async (teamName: string, opts) => {
|
||||
const startedAt = performance.now();
|
||||
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
|
||||
const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true;
|
||||
// Guard: prevent duplicate in-flight fetches for the same team.
|
||||
|
|
@ -3267,7 +3158,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
||||
return;
|
||||
}
|
||||
const ipcMs = performance.now() - startedAt;
|
||||
// Stale check: user may have switched to another team during the async call
|
||||
if (get().selectedTeamName !== teamName || get().selectedTeamLoadNonce !== requestNonce) {
|
||||
return;
|
||||
|
|
@ -3299,7 +3189,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
}
|
||||
: data;
|
||||
const nextTeamData = structurallyShareTeamSnapshot(previousData, projectedTeamData);
|
||||
const setStartedAt = performance.now();
|
||||
set((state) => {
|
||||
const nextCache =
|
||||
state.teamDataCacheByName[teamName] === nextTeamData
|
||||
|
|
@ -3318,8 +3207,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
};
|
||||
});
|
||||
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
|
||||
const setMs = performance.now() - setStartedAt;
|
||||
const postStartedAt = performance.now();
|
||||
const invalidationState = previousData
|
||||
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
|
||||
: { cacheKeys: [], taskIds: [] };
|
||||
|
|
@ -3329,19 +3216,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
if (invalidationState.taskIds.length > 0) {
|
||||
await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds);
|
||||
}
|
||||
const postMs = performance.now() - postStartedAt;
|
||||
maybeLogTeamDataPerf({
|
||||
phase: 'selectTeam',
|
||||
teamName,
|
||||
ipcMs,
|
||||
setMs,
|
||||
postMs,
|
||||
totalMs: performance.now() - startedAt,
|
||||
previousData,
|
||||
nextData: nextTeamData,
|
||||
deduped: true,
|
||||
reusedInFlightRequest: false,
|
||||
});
|
||||
// Sync tab label with the team's display name from config
|
||||
const displayName = data.config.name || teamName;
|
||||
const allTabs = get().getAllPaneTabs();
|
||||
|
|
@ -3445,19 +3319,15 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
},
|
||||
|
||||
refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => {
|
||||
const startedAt = performance.now();
|
||||
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
|
||||
const refreshToken = beginInFlightTeamDataRefresh(teamName);
|
||||
// Silent refresh — update data without showing loading skeleton.
|
||||
// Only selectTeam() sets loading: true (for initial load).
|
||||
const reusedInFlightRequest =
|
||||
opts?.withDedup === true && inFlightTeamDataRequests.has(teamName);
|
||||
const burstCount = noteTeamRefreshBurst(teamName);
|
||||
noteTeamRefreshBurst(teamName);
|
||||
if (reusedInFlightRequest) {
|
||||
pendingFreshTeamDataRefreshes.add(teamName);
|
||||
logger.warn(
|
||||
`[perf] refreshTeamData queued-fresh team=${teamName} burst=${burstCount} reason=inFlightDedup`
|
||||
);
|
||||
}
|
||||
try {
|
||||
const previousData = selectTeamDataForName(get(), teamName);
|
||||
|
|
@ -3467,7 +3337,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
||||
return;
|
||||
}
|
||||
const ipcMs = performance.now() - startedAt;
|
||||
const projectedTeamData = previousData
|
||||
? {
|
||||
...data,
|
||||
|
|
@ -3475,7 +3344,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
}
|
||||
: data;
|
||||
const nextTeamData = structurallyShareTeamSnapshot(previousData, projectedTeamData);
|
||||
const setStartedAt = performance.now();
|
||||
set((state) => {
|
||||
const nextCache =
|
||||
state.teamDataCacheByName[teamName] === nextTeamData
|
||||
|
|
@ -3507,8 +3375,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
};
|
||||
});
|
||||
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
|
||||
const setMs = performance.now() - setStartedAt;
|
||||
const postStartedAt = performance.now();
|
||||
const invalidationState = previousData
|
||||
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
|
||||
: { cacheKeys: [], taskIds: [] };
|
||||
|
|
@ -3518,20 +3384,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
if (invalidationState.taskIds.length > 0) {
|
||||
await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds);
|
||||
}
|
||||
const postMs = performance.now() - postStartedAt;
|
||||
maybeLogTeamDataPerf({
|
||||
phase: 'refreshTeamData',
|
||||
teamName,
|
||||
ipcMs,
|
||||
setMs,
|
||||
postMs,
|
||||
totalMs: performance.now() - startedAt,
|
||||
previousData,
|
||||
nextData: nextTeamData,
|
||||
deduped: opts?.withDedup === true,
|
||||
reusedInFlightRequest,
|
||||
burstCount,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
||||
return;
|
||||
|
|
@ -4224,6 +4076,20 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
}
|
||||
},
|
||||
|
||||
skipMemberForLaunch: async (teamName: string, memberName: string) => {
|
||||
try {
|
||||
await unwrapIpc('team:skipMemberForLaunch', () =>
|
||||
api.teams.skipMemberForLaunch(teamName, memberName)
|
||||
);
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
get().fetchMemberSpawnStatuses(teamName),
|
||||
get().fetchTeamAgentRuntime(teamName),
|
||||
get().fetchTeams(),
|
||||
]);
|
||||
}
|
||||
},
|
||||
|
||||
removeMember: async (teamName: string, memberName: string) => {
|
||||
await unwrapIpc('team:removeMember', () => api.teams.removeMember(teamName, memberName));
|
||||
await get().refreshTeamData(teamName);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON,
|
||||
getAvailableTeamProviderModelOptions,
|
||||
getAvailableTeamProviderModels,
|
||||
getTeamModelSelectionError,
|
||||
|
|
@ -151,7 +150,7 @@ describe('team model availability Codex catalog integration', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('shows app-server future models but blocks launch until runtime declares dynamic support', () => {
|
||||
it('allows app-server catalog models even when the runtime does not declare dynamic model launch', () => {
|
||||
const providerStatus = createCodexProviderStatus([
|
||||
{
|
||||
id: 'gpt-5.5',
|
||||
|
|
@ -168,16 +167,14 @@ describe('team model availability Codex catalog integration', () => {
|
|||
},
|
||||
]);
|
||||
|
||||
expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual([]);
|
||||
expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual(['gpt-5.5']);
|
||||
expect(getAvailableTeamProviderModelOptions('codex', providerStatus)[1]).toMatchObject({
|
||||
value: 'gpt-5.5',
|
||||
label: '5.5',
|
||||
badgeLabel: 'New',
|
||||
availabilityStatus: null,
|
||||
availabilityStatus: 'available',
|
||||
});
|
||||
expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toContain(
|
||||
CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON
|
||||
);
|
||||
expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps existing disabled model policy on top of the dynamic catalog', () => {
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ export const SPAWN_DOT_COLORS: Record<MemberSpawnStatus, string> = {
|
|||
spawning: 'bg-amber-400',
|
||||
online: 'bg-emerald-400 animate-[dot-online-jelly_0.45s_ease-out]',
|
||||
error: 'bg-red-400',
|
||||
skipped: 'bg-zinc-500',
|
||||
};
|
||||
|
||||
export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
|
||||
|
|
@ -127,6 +128,7 @@ export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
|
|||
spawning: 'starting',
|
||||
online: 'ready',
|
||||
error: 'spawn failed',
|
||||
skipped: 'skipped',
|
||||
};
|
||||
|
||||
function isLaunchStillStarting(
|
||||
|
|
@ -138,6 +140,9 @@ function isLaunchStillStarting(
|
|||
if (spawnLaunchState === 'failed_to_start') {
|
||||
return false;
|
||||
}
|
||||
if (spawnLaunchState === 'skipped_for_launch') {
|
||||
return false;
|
||||
}
|
||||
if (spawnLaunchState === 'runtime_pending_permission') {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -171,6 +176,9 @@ export function getSpawnAwareDotClass(
|
|||
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
||||
return SPAWN_DOT_COLORS.error;
|
||||
}
|
||||
if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') {
|
||||
return SPAWN_DOT_COLORS.skipped;
|
||||
}
|
||||
if (spawnLaunchState === 'runtime_pending_permission') {
|
||||
return 'bg-amber-400 animate-pulse';
|
||||
}
|
||||
|
|
@ -218,6 +226,9 @@ export function getSpawnAwarePresenceLabel(
|
|||
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
||||
return SPAWN_PRESENCE_LABELS.error;
|
||||
}
|
||||
if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') {
|
||||
return SPAWN_PRESENCE_LABELS.skipped;
|
||||
}
|
||||
if (spawnLaunchState === 'runtime_pending_permission') {
|
||||
return 'connecting';
|
||||
}
|
||||
|
|
@ -259,6 +270,9 @@ export function getSpawnCardClass(
|
|||
) {
|
||||
return 'member-waiting-shimmer';
|
||||
}
|
||||
if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') {
|
||||
return 'opacity-70';
|
||||
}
|
||||
if (spawnLaunchState === 'runtime_pending_permission') {
|
||||
return 'member-waiting-shimmer';
|
||||
}
|
||||
|
|
@ -518,6 +532,7 @@ export function getLaunchAwarePresenceLabel(
|
|||
basePresenceLabel === 'starting' ||
|
||||
basePresenceLabel === 'connecting' ||
|
||||
basePresenceLabel === 'spawn failed' ||
|
||||
basePresenceLabel === 'skipped' ||
|
||||
basePresenceLabel === 'offline' ||
|
||||
basePresenceLabel === 'terminated'
|
||||
) {
|
||||
|
|
@ -538,6 +553,7 @@ export type MemberLaunchVisualState =
|
|||
| 'stale_runtime'
|
||||
| 'settling'
|
||||
| 'error'
|
||||
| 'skipped'
|
||||
| null;
|
||||
|
||||
export interface MemberLaunchPresentation {
|
||||
|
|
@ -574,6 +590,8 @@ export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState)
|
|||
return 'joining team';
|
||||
case 'error':
|
||||
return 'failed';
|
||||
case 'skipped':
|
||||
return 'skipped';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
@ -643,6 +661,8 @@ export function buildMemberLaunchPresentation({
|
|||
if (isTeamAlive !== false || isTeamProvisioning) {
|
||||
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
||||
launchVisualState = 'error';
|
||||
} else if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') {
|
||||
launchVisualState = 'skipped';
|
||||
} else if (spawnLaunchState === 'runtime_pending_permission') {
|
||||
launchVisualState = 'permission_pending';
|
||||
} else if (runtimeEntry?.livenessKind === 'shell_only') {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import type {
|
|||
} from '@shared/types';
|
||||
|
||||
export {
|
||||
CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON,
|
||||
GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON,
|
||||
GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL,
|
||||
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export {
|
|||
type SupportedProviderId = CliProviderId | TeamProviderId;
|
||||
type RuntimeAwareProviderStatus = Pick<
|
||||
CliProviderStatus,
|
||||
'providerId' | 'authMethod' | 'backend' | 'modelCatalog' | 'runtimeCapabilities'
|
||||
'providerId' | 'authMethod' | 'backend' | 'modelCatalog'
|
||||
>;
|
||||
|
||||
export interface TeamProviderModelOption {
|
||||
|
|
@ -40,8 +40,6 @@ export const GPT_5_2_CODEX_UI_DISABLED_REASON =
|
|||
'Temporarily disabled for team agents - this model is not currently available on the Codex native runtime.';
|
||||
export const GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON =
|
||||
'Temporarily disabled for team agents - this model has been less reliable with bootstrap, task, and reply tool contracts.';
|
||||
export const CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON =
|
||||
'Available in Codex, waiting for Agent Teams runtime support.';
|
||||
|
||||
const TEAM_PROVIDER_LABELS: Record<SupportedProviderId, string> = {
|
||||
anthropic: 'Anthropic',
|
||||
|
|
@ -166,13 +164,6 @@ function getKnownTeamProviderModelOption(
|
|||
return TEAM_PROVIDER_MODEL_OPTIONS[providerId].find((option) => option.value === trimmed);
|
||||
}
|
||||
|
||||
function isKnownTeamProviderModel(
|
||||
providerId: SupportedProviderId | undefined,
|
||||
model: string | undefined
|
||||
): boolean {
|
||||
return Boolean(getKnownTeamProviderModelOption(providerId, model));
|
||||
}
|
||||
|
||||
export function getTeamProviderModelOptions(
|
||||
providerId: SupportedProviderId
|
||||
): readonly TeamProviderModelOption[] {
|
||||
|
|
@ -471,18 +462,6 @@ export function getRuntimeAwareTeamModelUiDisabledReason(
|
|||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
providerId === 'codex' &&
|
||||
providerStatus?.modelCatalog?.providerId === 'codex' &&
|
||||
providerStatus.modelCatalog.models.some(
|
||||
(item) => item.launchModel === trimmed || item.id === trimmed
|
||||
) &&
|
||||
!isKnownTeamProviderModel(providerId, trimmed) &&
|
||||
providerStatus.runtimeCapabilities?.modelCatalog?.dynamic !== true
|
||||
) {
|
||||
return CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON;
|
||||
}
|
||||
|
||||
return isRuntimeHiddenTeamModel(providerId, trimmed, providerStatus)
|
||||
? GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON
|
||||
: null;
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ interface FailedSpawnDetail {
|
|||
reason: string | null;
|
||||
}
|
||||
|
||||
interface SkippedSpawnDetail {
|
||||
name: string;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
type PendingDiagnosticBucket =
|
||||
| 'shellOnly'
|
||||
| 'runtimeProcess'
|
||||
|
|
@ -55,6 +60,10 @@ function isFailedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean
|
|||
return entry?.launchState === 'failed_to_start' || entry?.status === 'error';
|
||||
}
|
||||
|
||||
function isSkippedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean {
|
||||
return entry?.launchState === 'skipped_for_launch' || entry?.skippedForLaunch === true;
|
||||
}
|
||||
|
||||
function shouldPreferSnapshotEntryOverLive(params: {
|
||||
liveEntry: MemberSpawnStatusEntry | undefined;
|
||||
snapshotEntry: MemberSpawnStatusEntry | undefined;
|
||||
|
|
@ -181,7 +190,12 @@ function getPendingDiagnosticNameGroups(params: {
|
|||
snapshotEntry,
|
||||
snapshotUpdatedAt: params.memberSpawnSnapshotUpdatedAt,
|
||||
});
|
||||
if (!entry || entry.launchState === 'confirmed_alive' || isFailedSpawnEntry(entry)) {
|
||||
if (
|
||||
!entry ||
|
||||
entry.launchState === 'confirmed_alive' ||
|
||||
isFailedSpawnEntry(entry) ||
|
||||
isSkippedSpawnEntry(entry)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
|
|
@ -326,6 +340,56 @@ function getFailedSpawnDetails(params: {
|
|||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
function getSkippedSpawnDetails(params: {
|
||||
memberSpawnStatuses: MemberSpawnStatusCollection;
|
||||
memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
|
||||
memberSpawnSnapshotUpdatedAt?: string;
|
||||
}): SkippedSpawnDetail[] {
|
||||
const names = new Set<string>();
|
||||
if (params.memberSpawnStatuses instanceof Map) {
|
||||
for (const name of params.memberSpawnStatuses.keys()) {
|
||||
names.add(name);
|
||||
}
|
||||
} else if (params.memberSpawnStatuses) {
|
||||
for (const name of Object.keys(params.memberSpawnStatuses)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
for (const name of Object.keys(params.memberSpawnSnapshotStatuses ?? {})) {
|
||||
names.add(name);
|
||||
}
|
||||
|
||||
if (names.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...names]
|
||||
.map((name) => {
|
||||
const liveEntry =
|
||||
params.memberSpawnStatuses instanceof Map
|
||||
? params.memberSpawnStatuses.get(name)
|
||||
: params.memberSpawnStatuses?.[name];
|
||||
const snapshotEntry = params.memberSpawnSnapshotStatuses?.[name];
|
||||
return [
|
||||
name,
|
||||
getPreferredSpawnEntry({
|
||||
liveEntry,
|
||||
snapshotEntry,
|
||||
snapshotUpdatedAt: params.memberSpawnSnapshotUpdatedAt,
|
||||
}),
|
||||
] as const;
|
||||
})
|
||||
.filter(([, entry]) => isSkippedSpawnEntry(entry))
|
||||
.map(([name, entry]) => ({
|
||||
name,
|
||||
reason:
|
||||
typeof entry?.skipReason === 'string' && entry.skipReason.trim().length > 0
|
||||
? entry.skipReason.trim()
|
||||
: null,
|
||||
}))
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
function truncateFailureReason(reason: string, maxLength = 160): string {
|
||||
const normalized = reason.replace(/\s+/g, ' ').trim();
|
||||
if (normalized.length <= maxLength) {
|
||||
|
|
@ -381,6 +445,42 @@ function buildGenericFailedSpawnPanelMessage(
|
|||
return `${failedSpawnCount}/${Math.max(expectedTeammateCount, failedSpawnCount)} teammates failed to start`;
|
||||
}
|
||||
|
||||
function buildSkippedSpawnPanelMessage(
|
||||
skippedSpawnDetails: readonly SkippedSpawnDetail[]
|
||||
): string | null {
|
||||
if (skippedSpawnDetails.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (skippedSpawnDetails.length === 1) {
|
||||
const [skipped] = skippedSpawnDetails;
|
||||
return skipped.reason
|
||||
? `${skipped.name} skipped for this launch - ${truncateFailureReason(skipped.reason, 220)}`
|
||||
: `${skipped.name} skipped for this launch`;
|
||||
}
|
||||
const listedSkipped = skippedSpawnDetails
|
||||
.slice(0, 3)
|
||||
.map((skipped) =>
|
||||
skipped.reason
|
||||
? `${skipped.name} - ${truncateFailureReason(skipped.reason, 100)}`
|
||||
: skipped.name
|
||||
)
|
||||
.join('; ');
|
||||
const remainingCount = skippedSpawnDetails.length - Math.min(skippedSpawnDetails.length, 3);
|
||||
return `Skipped teammates: ${listedSkipped}${remainingCount > 0 ? `; +${remainingCount} more` : ''}`;
|
||||
}
|
||||
|
||||
function buildSkippedSpawnCompactDetail(
|
||||
skippedSpawnDetails: readonly SkippedSpawnDetail[]
|
||||
): string | null {
|
||||
if (skippedSpawnDetails.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (skippedSpawnDetails.length === 1) {
|
||||
return `${skippedSpawnDetails[0].name} skipped`;
|
||||
}
|
||||
return `${skippedSpawnDetails.length} teammates skipped`;
|
||||
}
|
||||
|
||||
export interface TeamProvisioningPresentation {
|
||||
progress: TeamProvisioningProgress;
|
||||
isActive: boolean;
|
||||
|
|
@ -393,6 +493,7 @@ export interface TeamProvisioningPresentation {
|
|||
processOnlyAliveCount: number;
|
||||
pendingSpawnCount: number;
|
||||
failedSpawnCount: number;
|
||||
skippedSpawnCount: number;
|
||||
allTeammatesConfirmedAlive: boolean;
|
||||
hasMembersStillJoining: boolean;
|
||||
remainingJoinCount: number;
|
||||
|
|
@ -454,6 +555,7 @@ export function buildTeamProvisioningPresentation({
|
|||
processOnlyAliveCount,
|
||||
pendingSpawnCount,
|
||||
failedSpawnCount,
|
||||
skippedSpawnCount,
|
||||
} = getLaunchJoinMilestonesFromMembers({
|
||||
members,
|
||||
memberSpawnStatuses,
|
||||
|
|
@ -470,6 +572,13 @@ export function buildTeamProvisioningPresentation({
|
|||
failedSpawnCount,
|
||||
expectedTeammateCount
|
||||
);
|
||||
const skippedSpawnDetails = getSkippedSpawnDetails({
|
||||
memberSpawnStatuses,
|
||||
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
|
||||
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
|
||||
});
|
||||
const skippedSpawnPanelMessage = buildSkippedSpawnPanelMessage(skippedSpawnDetails);
|
||||
const skippedSpawnCompactDetail = buildSkippedSpawnCompactDetail(skippedSpawnDetails);
|
||||
const permissionBlockedCount = countPermissionBlockedMembers({
|
||||
memberSpawnStatuses,
|
||||
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
|
||||
|
|
@ -483,6 +592,7 @@ export function buildTeamProvisioningPresentation({
|
|||
processOnlyAliveCount,
|
||||
pendingSpawnCount,
|
||||
failedSpawnCount,
|
||||
skippedSpawnCount,
|
||||
});
|
||||
|
||||
const progressStepIndex = getDisplayStepIndex({
|
||||
|
|
@ -492,6 +602,7 @@ export function buildTeamProvisioningPresentation({
|
|||
processOnlyAliveCount,
|
||||
pendingSpawnCount,
|
||||
failedSpawnCount,
|
||||
skippedSpawnCount,
|
||||
});
|
||||
|
||||
if (isFailed) {
|
||||
|
|
@ -507,6 +618,7 @@ export function buildTeamProvisioningPresentation({
|
|||
processOnlyAliveCount,
|
||||
pendingSpawnCount,
|
||||
failedSpawnCount,
|
||||
skippedSpawnCount,
|
||||
allTeammatesConfirmedAlive,
|
||||
hasMembersStillJoining,
|
||||
remainingJoinCount,
|
||||
|
|
@ -542,6 +654,9 @@ export function buildTeamProvisioningPresentation({
|
|||
failedSpawnCount > 0
|
||||
? (failedSpawnCompactDetail ??
|
||||
`${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`)
|
||||
: skippedSpawnCount > 0
|
||||
? (skippedSpawnCompactDetail ??
|
||||
`${skippedSpawnCount} teammate${skippedSpawnCount === 1 ? '' : 's'} skipped`)
|
||||
: hasMembersStillJoining
|
||||
? pendingDetailPhrase
|
||||
: expectedTeammateCount === 0
|
||||
|
|
@ -550,6 +665,9 @@ export function buildTeamProvisioningPresentation({
|
|||
const readyDetailMessage =
|
||||
failedSpawnCount > 0
|
||||
? (failedSpawnPanelMessage ?? genericFailedSpawnPanelMessage ?? progress.message)
|
||||
: skippedSpawnCount > 0
|
||||
? (skippedSpawnPanelMessage ??
|
||||
`${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped for this launch`)
|
||||
: expectedTeammateCount === 0
|
||||
? 'Team provisioned - lead online'
|
||||
: allTeammatesConfirmedAlive
|
||||
|
|
@ -558,10 +676,16 @@ export function buildTeamProvisioningPresentation({
|
|||
? pendingDetailPhrase
|
||||
: 'Team provisioned - teammates are still joining';
|
||||
const readyDetailSeverity =
|
||||
failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'info' : undefined;
|
||||
failedSpawnCount > 0 || skippedSpawnCount > 0
|
||||
? 'warning'
|
||||
: hasMembersStillJoining
|
||||
? 'info'
|
||||
: undefined;
|
||||
const readyMessage =
|
||||
failedSpawnCount > 0
|
||||
? `Launch finished with errors - ${failedSpawnCount}/${Math.max(expectedTeammateCount, failedSpawnCount)} teammates failed to start`
|
||||
: skippedSpawnCount > 0
|
||||
? `Launch continued - ${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped`
|
||||
: expectedTeammateCount === 0
|
||||
? 'Team launched - lead online'
|
||||
: allTeammatesConfirmedAlive
|
||||
|
|
@ -579,27 +703,45 @@ export function buildTeamProvisioningPresentation({
|
|||
processOnlyAliveCount,
|
||||
pendingSpawnCount,
|
||||
failedSpawnCount,
|
||||
skippedSpawnCount,
|
||||
allTeammatesConfirmedAlive,
|
||||
hasMembersStillJoining,
|
||||
remainingJoinCount,
|
||||
panelTitle: 'Launch details',
|
||||
panelMessage: failedSpawnCount > 0 || hasMembersStillJoining ? readyDetailMessage : null,
|
||||
panelMessage:
|
||||
failedSpawnCount > 0 || skippedSpawnCount > 0 || hasMembersStillJoining
|
||||
? readyDetailMessage
|
||||
: null,
|
||||
panelMessageSeverity: readyDetailSeverity,
|
||||
successMessage: readyMessage,
|
||||
successMessageSeverity:
|
||||
failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'info' : 'success',
|
||||
failedSpawnCount > 0 || skippedSpawnCount > 0
|
||||
? 'warning'
|
||||
: hasMembersStillJoining
|
||||
? 'info'
|
||||
: 'success',
|
||||
defaultLiveOutputOpen: false,
|
||||
compactTitle:
|
||||
failedSpawnCount > 0
|
||||
? 'Launch finished with errors'
|
||||
: skippedSpawnCount > 0
|
||||
? 'Launch continued with skipped teammates'
|
||||
: hasMembersStillJoining
|
||||
? 'Finishing launch'
|
||||
: 'Team launched',
|
||||
compactDetail: readyCompactDetail,
|
||||
compactTone:
|
||||
failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'default' : 'success',
|
||||
failedSpawnCount > 0 || skippedSpawnCount > 0
|
||||
? 'warning'
|
||||
: hasMembersStillJoining
|
||||
? 'default'
|
||||
: 'success',
|
||||
currentStepIndex:
|
||||
failedSpawnCount > 0 ? 2 : hasMembersStillJoining ? 2 : DISPLAY_COMPLETE_STEP_INDEX,
|
||||
failedSpawnCount > 0 || skippedSpawnCount > 0
|
||||
? 2
|
||||
: hasMembersStillJoining
|
||||
? 2
|
||||
: DISPLAY_COMPLETE_STEP_INDEX,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -633,6 +775,7 @@ export function buildTeamProvisioningPresentation({
|
|||
processOnlyAliveCount,
|
||||
pendingSpawnCount,
|
||||
failedSpawnCount,
|
||||
skippedSpawnCount,
|
||||
allTeammatesConfirmedAlive,
|
||||
hasMembersStillJoining,
|
||||
remainingJoinCount,
|
||||
|
|
@ -640,18 +783,25 @@ export function buildTeamProvisioningPresentation({
|
|||
panelMessage:
|
||||
failedSpawnCount > 0
|
||||
? (failedSpawnPanelMessage ?? genericFailedSpawnPanelMessage ?? progress.message)
|
||||
: skippedSpawnCount > 0
|
||||
? (skippedSpawnPanelMessage ??
|
||||
`${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped for this launch`)
|
||||
: hasMembersStillJoining &&
|
||||
permissionBlockedCount > 0 &&
|
||||
permissionBlockedCount === remainingJoinCount
|
||||
? activePendingDetailPhrase
|
||||
: progress.message,
|
||||
panelMessageSeverity: failedSpawnCount > 0 ? 'warning' : progress.messageSeverity,
|
||||
panelMessageSeverity:
|
||||
failedSpawnCount > 0 || skippedSpawnCount > 0 ? 'warning' : progress.messageSeverity,
|
||||
defaultLiveOutputOpen: false,
|
||||
compactTitle: 'Launching team',
|
||||
compactDetail:
|
||||
failedSpawnCount > 0
|
||||
? (failedSpawnCompactDetail ??
|
||||
`${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`)
|
||||
: skippedSpawnCount > 0
|
||||
? (skippedSpawnCompactDetail ??
|
||||
`${skippedSpawnCount} teammate${skippedSpawnCount === 1 ? '' : 's'} skipped`)
|
||||
: hasMembersStillJoining && failedSpawnCount === 0 && permissionBlockedCount > 0
|
||||
? permissionBlockedCount === remainingJoinCount
|
||||
? buildAwaitingPermissionPhrase(permissionBlockedCount)
|
||||
|
|
@ -659,7 +809,7 @@ export function buildTeamProvisioningPresentation({
|
|||
: expectedTeammateCount > 0 && progressStepIndex >= 2
|
||||
? `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed`
|
||||
: progress.message,
|
||||
compactTone: failedSpawnCount > 0 ? 'warning' : 'default',
|
||||
compactTone: failedSpawnCount > 0 || skippedSpawnCount > 0 ? 'warning' : 'default',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -542,6 +542,7 @@ export interface TeamsAPI {
|
|||
getMemberSpawnStatuses: (teamName: string) => Promise<MemberSpawnStatusesSnapshot>;
|
||||
getTeamAgentRuntime: (teamName: string) => Promise<TeamAgentRuntimeSnapshot>;
|
||||
restartMember: (teamName: string, memberName: string) => Promise<void>;
|
||||
skipMemberForLaunch: (teamName: string, memberName: string) => Promise<void>;
|
||||
softDeleteTask: (teamName: string, taskId: string) => Promise<void>;
|
||||
restoreTask: (teamName: string, taskId: string) => Promise<void>;
|
||||
getDeletedTasks: (teamName: string) => Promise<TeamTask[]>;
|
||||
|
|
|
|||
|
|
@ -73,6 +73,8 @@ export interface TeamSummary {
|
|||
confirmedMemberCount?: number;
|
||||
/** Missing teammate names from the last partial launch marker. */
|
||||
missingMembers?: string[];
|
||||
/** Teammates intentionally skipped for the last launch. */
|
||||
skippedMembers?: string[];
|
||||
/** Durable aggregate launch state derived from persisted launch-state evidence. */
|
||||
teamLaunchState?: TeamLaunchAggregateState;
|
||||
/** ISO timestamp of the last durable launch-state evaluation. */
|
||||
|
|
@ -81,6 +83,7 @@ export interface TeamSummary {
|
|||
confirmedCount?: number;
|
||||
pendingCount?: number;
|
||||
failedCount?: number;
|
||||
skippedCount?: number;
|
||||
runtimeAlivePendingCount?: number;
|
||||
shellOnlyPendingCount?: number;
|
||||
runtimeProcessPendingCount?: number;
|
||||
|
|
@ -683,14 +686,19 @@ export interface AddTaskCommentRequest {
|
|||
|
||||
export type MemberStatus = 'active' | 'idle' | 'terminated' | 'unknown';
|
||||
|
||||
export type MemberSpawnStatus = 'offline' | 'waiting' | 'spawning' | 'online' | 'error';
|
||||
export type MemberSpawnStatus = 'offline' | 'waiting' | 'spawning' | 'online' | 'error' | 'skipped';
|
||||
export type MemberLaunchState =
|
||||
| 'starting'
|
||||
| 'runtime_pending_bootstrap'
|
||||
| 'runtime_pending_permission'
|
||||
| 'confirmed_alive'
|
||||
| 'failed_to_start';
|
||||
export type TeamLaunchAggregateState = 'clean_success' | 'partial_pending' | 'partial_failure';
|
||||
| 'failed_to_start'
|
||||
| 'skipped_for_launch';
|
||||
export type TeamLaunchAggregateState =
|
||||
| 'clean_success'
|
||||
| 'partial_pending'
|
||||
| 'partial_failure'
|
||||
| 'partial_skipped';
|
||||
export type PersistedTeamLaunchPhase = 'active' | 'finished' | 'reconciled';
|
||||
|
||||
export type KanbanColumnId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved';
|
||||
|
|
@ -939,6 +947,9 @@ export interface PersistedTeamLaunchMemberState {
|
|||
laneOwnerProviderId?: TeamProviderId;
|
||||
launchIdentity?: ProviderModelLaunchIdentity;
|
||||
launchState: MemberLaunchState;
|
||||
skippedForLaunch?: boolean;
|
||||
skipReason?: string;
|
||||
skippedAt?: string;
|
||||
agentToolAccepted: boolean;
|
||||
runtimeAlive: boolean;
|
||||
bootstrapConfirmed: boolean;
|
||||
|
|
@ -964,6 +975,7 @@ export interface PersistedTeamLaunchSummary {
|
|||
confirmedCount: number;
|
||||
pendingCount: number;
|
||||
failedCount: number;
|
||||
skippedCount?: number;
|
||||
runtimeAlivePendingCount: number;
|
||||
shellOnlyPendingCount?: number;
|
||||
runtimeProcessPendingCount?: number;
|
||||
|
|
@ -1092,6 +1104,10 @@ export interface MemberSpawnStatusEntry {
|
|||
error?: string;
|
||||
/** Hard failure reason for failed_to_start. */
|
||||
hardFailureReason?: string;
|
||||
/** True when the user intentionally skipped this teammate for the current launch only. */
|
||||
skippedForLaunch?: boolean;
|
||||
skipReason?: string;
|
||||
skippedAt?: string;
|
||||
/**
|
||||
* Optional provenance for `online`.
|
||||
* - heartbeat: teammate sent a real inbox/native message after bootstrap
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { EventEmitter } from 'events';
|
|||
import type * as FsType from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { Readable } from 'stream';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@shared/utils/logger', () => ({
|
||||
|
|
@ -500,6 +501,69 @@ describe('FileWatcher', () => {
|
|||
|
||||
watcher.stop();
|
||||
});
|
||||
|
||||
it('retires catch-up files after repeated stat timeouts', async () => {
|
||||
vi.useRealTimers();
|
||||
vi.mocked(errorDetector.detectErrors).mockClear();
|
||||
|
||||
const fsProvider = {
|
||||
type: 'local' as const,
|
||||
exists: vi.fn().mockResolvedValue(true),
|
||||
readFile: vi.fn().mockResolvedValue(''),
|
||||
stat: vi.fn().mockRejectedValue(new Error('stat timeout')),
|
||||
readdir: vi.fn().mockResolvedValue([]),
|
||||
createReadStream: vi.fn(() => Readable.from([])),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
|
||||
const dataCache = new DataCache(50, 10, false);
|
||||
const notificationManager = createMockNotificationManager();
|
||||
const watcher = new FileWatcher(
|
||||
dataCache,
|
||||
'/watch-root/projects',
|
||||
'/watch-root/todos',
|
||||
fsProvider
|
||||
);
|
||||
watcher.setNotificationManager(notificationManager);
|
||||
|
||||
const filePath = '/watch-root/projects/test-project/session-timeout.jsonl';
|
||||
const watcherAny = watcher as unknown as {
|
||||
isWatching: boolean;
|
||||
activeSessionFiles: Map<
|
||||
string,
|
||||
{ projectId: string; sessionId: string; lastObservedAt: number }
|
||||
>;
|
||||
catchUpStatFailures: Map<string, number>;
|
||||
lastProcessedSize: Map<string, number>;
|
||||
lastProcessedLineCount: Map<string, number>;
|
||||
runCatchUpScan: () => Promise<void>;
|
||||
};
|
||||
watcherAny.isWatching = true;
|
||||
watcherAny.activeSessionFiles.set(filePath, {
|
||||
projectId: 'test-project',
|
||||
sessionId: 'session-timeout',
|
||||
lastObservedAt: Date.now(),
|
||||
});
|
||||
watcherAny.lastProcessedSize.set(filePath, 100);
|
||||
watcherAny.lastProcessedLineCount.set(filePath, 5);
|
||||
|
||||
await watcherAny.runCatchUpScan();
|
||||
expect(watcherAny.activeSessionFiles.has(filePath)).toBe(true);
|
||||
expect(watcherAny.catchUpStatFailures.get(filePath)).toBe(1);
|
||||
|
||||
await watcherAny.runCatchUpScan();
|
||||
expect(watcherAny.activeSessionFiles.has(filePath)).toBe(true);
|
||||
expect(watcherAny.catchUpStatFailures.get(filePath)).toBe(2);
|
||||
|
||||
await watcherAny.runCatchUpScan();
|
||||
expect(watcherAny.activeSessionFiles.has(filePath)).toBe(false);
|
||||
expect(watcherAny.catchUpStatFailures.has(filePath)).toBe(false);
|
||||
expect(watcherAny.lastProcessedSize.get(filePath)).toBe(100);
|
||||
expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(5);
|
||||
expect(errorDetector.detectErrors).not.toHaveBeenCalled();
|
||||
|
||||
watcher.stop();
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveOpenCodeTeamLaunchModeFromEnv } from '../../../../src/main/services/team/opencode/config/OpenCodeLaunchModeEnv';
|
||||
|
||||
describe('resolveOpenCodeTeamLaunchModeFromEnv', () => {
|
||||
it('defaults to production so OpenCode is visible while strict readiness remains authoritative', () => {
|
||||
expect(resolveOpenCodeTeamLaunchModeFromEnv({})).toBe('production');
|
||||
});
|
||||
|
||||
it('preserves explicit launch mode overrides', () => {
|
||||
expect(
|
||||
resolveOpenCodeTeamLaunchModeFromEnv({ CLAUDE_TEAM_OPENCODE_LAUNCH_MODE: 'disabled' })
|
||||
).toBe('disabled');
|
||||
expect(
|
||||
resolveOpenCodeTeamLaunchModeFromEnv({ CLAUDE_TEAM_OPENCODE_LAUNCH_MODE: 'dogfood' })
|
||||
).toBe('dogfood');
|
||||
expect(
|
||||
resolveOpenCodeTeamLaunchModeFromEnv({ CLAUDE_TEAM_OPENCODE_LAUNCH_MODE: 'production' })
|
||||
).toBe('production');
|
||||
});
|
||||
|
||||
it('keeps the legacy dogfood flag as an explicit opt-in', () => {
|
||||
expect(resolveOpenCodeTeamLaunchModeFromEnv({ CLAUDE_TEAM_OPENCODE_DOGFOOD: '1' })).toBe(
|
||||
'dogfood'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to production for invalid launch mode values', () => {
|
||||
expect(
|
||||
resolveOpenCodeTeamLaunchModeFromEnv({ CLAUDE_TEAM_OPENCODE_LAUNCH_MODE: 'enabled' })
|
||||
).toBe('production');
|
||||
});
|
||||
});
|
||||
|
|
@ -15,9 +15,7 @@ import {
|
|||
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient';
|
||||
import { OpenCodeReadinessBridge } from '../../../../src/main/services/team/opencode/bridge/OpenCodeReadinessBridge';
|
||||
import { OpenCodeStateChangingBridgeCommandService } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
|
||||
import {
|
||||
getTeamBootstrapStatePath,
|
||||
} from '../../../../src/main/services/team/TeamBootstrapStateReader';
|
||||
import { getTeamBootstrapStatePath } from '../../../../src/main/services/team/TeamBootstrapStateReader';
|
||||
import { TeamMembersMetaStore } from '../../../../src/main/services/team/TeamMembersMetaStore';
|
||||
import { TeamMetaStore } from '../../../../src/main/services/team/TeamMetaStore';
|
||||
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
||||
|
|
@ -68,9 +66,7 @@ liveDescribe('OpenCode mixed recovery live e2e', () => {
|
|||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it(
|
||||
'recovers active mixed OpenCode side lanes from live runtime reconcile instead of marking them never spawned',
|
||||
async () => {
|
||||
it('recovers active mixed OpenCode side lanes from live runtime reconcile instead of marking them never spawned', async () => {
|
||||
const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL;
|
||||
const orchestratorCli =
|
||||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
|
||||
|
|
@ -100,9 +96,7 @@ liveDescribe('OpenCode mixed recovery live e2e', () => {
|
|||
reconcileTimeoutMs: 90_000,
|
||||
stopTimeoutMs: 90_000,
|
||||
});
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge, {
|
||||
launchMode: 'dogfood',
|
||||
});
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge);
|
||||
const svc = new TeamProvisioningService();
|
||||
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
||||
|
||||
|
|
@ -146,15 +140,15 @@ liveDescribe('OpenCode mixed recovery live e2e', () => {
|
|||
launchState: 'confirmed_alive',
|
||||
});
|
||||
expect(result.statuses.bob.error).toBeUndefined();
|
||||
await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject(
|
||||
{
|
||||
await expect(
|
||||
readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)
|
||||
).resolves.toMatchObject({
|
||||
lanes: {
|
||||
[launchInput.laneId ?? 'secondary:opencode:bob']: {
|
||||
state: 'active',
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
for (const launchInput of launchedLanes) {
|
||||
await adapter
|
||||
|
|
@ -171,9 +165,7 @@ liveDescribe('OpenCode mixed recovery live e2e', () => {
|
|||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
},
|
||||
240_000
|
||||
);
|
||||
}, 240_000);
|
||||
|
||||
liveMultiLaneIt(
|
||||
'recovers multiple active mixed OpenCode side lanes from live runtime reconcile',
|
||||
|
|
@ -207,9 +199,7 @@ liveDescribe('OpenCode mixed recovery live e2e', () => {
|
|||
reconcileTimeoutMs: 90_000,
|
||||
stopTimeoutMs: 90_000,
|
||||
});
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge, {
|
||||
launchMode: 'dogfood',
|
||||
});
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge);
|
||||
const svc = new TeamProvisioningService();
|
||||
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
||||
|
||||
|
|
@ -259,16 +249,16 @@ liveDescribe('OpenCode mixed recovery live e2e', () => {
|
|||
});
|
||||
expect(result.statuses[memberName]?.error).toBeUndefined();
|
||||
}
|
||||
await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject(
|
||||
{
|
||||
await expect(
|
||||
readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)
|
||||
).resolves.toMatchObject({
|
||||
lanes: Object.fromEntries(
|
||||
sideMembers.map((memberName) => [
|
||||
`secondary:opencode:${memberName}`,
|
||||
{ state: 'active' },
|
||||
])
|
||||
),
|
||||
}
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
for (const launchInput of launchedLanes) {
|
||||
await adapter
|
||||
|
|
@ -360,10 +350,7 @@ async function writeMixedRecoveryFixtures(input: {
|
|||
name: input.teamName,
|
||||
projectPath: input.projectPath,
|
||||
leadSessionId: 'lead-session',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'alice' },
|
||||
],
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }, { name: 'alice' }],
|
||||
},
|
||||
null,
|
||||
2
|
||||
|
|
|
|||
|
|
@ -1,375 +0,0 @@
|
|||
import { promises as fs } from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
assertOpenCodeProductionE2EArtifactGate,
|
||||
buildOpenCodeProjectPathFingerprint,
|
||||
OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS,
|
||||
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS,
|
||||
validateOpenCodeProductionE2EEvidence,
|
||||
type OpenCodeProductionE2EEvidence,
|
||||
} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence';
|
||||
import { OpenCodeProductionE2EEvidenceStore } from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore';
|
||||
import {
|
||||
buildOpenCodeCanonicalMcpToolId,
|
||||
REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS,
|
||||
} from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
|
||||
|
||||
describe('OpenCodeProductionE2EEvidence', () => {
|
||||
let tempDir: string;
|
||||
const now = new Date('2026-04-21T12:00:00.000Z');
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-production-e2e-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('accepts production evidence when runtime identity, project context and required MCP tools match', () => {
|
||||
const evidence = passingEvidence();
|
||||
|
||||
expect(validateOpenCodeProductionE2EEvidence(evidence)).toEqual(evidence);
|
||||
expect(
|
||||
assertOpenCodeProductionE2EArtifactGate({
|
||||
evidence,
|
||||
artifactPath: '/tmp/opencode-e2e',
|
||||
now,
|
||||
expected: {
|
||||
opencodeVersion: '1.14.19',
|
||||
binaryFingerprint: 'version:1.14.19',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
projectPathFingerprint: 'project-a',
|
||||
requiredMcpTools: ['agent-teams_runtime_deliver_message'],
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
ok: true,
|
||||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed for stale, mismatched or incomplete evidence', () => {
|
||||
const expired = passingEvidence({
|
||||
expiresAt: '2026-04-21T11:59:59.000Z',
|
||||
selectedModel: 'openrouter/anthropic/claude-sonnet-4.5',
|
||||
requiredSignals: requiredSignals({ stale_run_rejected: false }),
|
||||
mcpTools: {
|
||||
requiredTools: ['agent-teams_runtime_deliver_message'],
|
||||
observedTools: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
assertOpenCodeProductionE2EArtifactGate({
|
||||
evidence: expired,
|
||||
artifactPath: '/tmp/opencode-e2e',
|
||||
now,
|
||||
expected: {
|
||||
opencodeVersion: '1.14.19',
|
||||
binaryFingerprint: 'version:1.14.19',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
projectPathFingerprint: 'project-a',
|
||||
requiredMcpTools: ['agent-teams_runtime_deliver_message'],
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
diagnostics: expect.arrayContaining([
|
||||
'OpenCode production E2E evidence is expired',
|
||||
'OpenCode production E2E evidence is missing signals: stale_run_rejected',
|
||||
'OpenCode production E2E evidence is missing observed MCP tools: agent-teams_runtime_deliver_message',
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('reads missing evidence as a production-blocking diagnostic and quarantines corrupt artifacts', async () => {
|
||||
const filePath = path.join(tempDir, 'production-e2e-evidence.json');
|
||||
const store = new OpenCodeProductionE2EEvidenceStore({
|
||||
filePath,
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
await expect(store.read()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
evidence: null,
|
||||
artifactPath: filePath,
|
||||
diagnostics: ['OpenCode production E2E evidence artifact has not been written yet'],
|
||||
});
|
||||
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, '{broken', 'utf8');
|
||||
const corrupt = await store.read();
|
||||
expect(corrupt).toMatchObject({
|
||||
ok: false,
|
||||
evidence: null,
|
||||
artifactPath: filePath,
|
||||
});
|
||||
expect(corrupt.diagnostics[0]).toContain(
|
||||
'OpenCode production E2E evidence store is unreadable'
|
||||
);
|
||||
});
|
||||
|
||||
it('writes evidence with the store path as artifactPath when the input omits it', async () => {
|
||||
const filePath = path.join(tempDir, 'production-e2e-evidence.json');
|
||||
const store = new OpenCodeProductionE2EEvidenceStore({
|
||||
filePath,
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
await store.write({
|
||||
...passingEvidence(),
|
||||
artifactPath: null,
|
||||
});
|
||||
|
||||
await expect(store.read()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
evidence: {
|
||||
artifactPath: filePath,
|
||||
evidenceId: 'e2e-1',
|
||||
},
|
||||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('stores production evidence for multiple raw model ids and reads exact model matches when no project context is provided', async () => {
|
||||
const filePath = path.join(tempDir, 'production-e2e-evidence.json');
|
||||
const store = new OpenCodeProductionE2EEvidenceStore({
|
||||
filePath,
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
await store.write(passingEvidence({ selectedModel: 'opencode/big-pickle' }));
|
||||
await store.write(
|
||||
passingEvidence({
|
||||
evidenceId: 'e2e-2',
|
||||
selectedModel: 'opencode/minimax-m2.5-free',
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
store.read({ selectedModel: 'opencode/minimax-m2.5-free' })
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
evidence: {
|
||||
evidenceId: 'e2e-2',
|
||||
selectedModel: 'opencode/minimax-m2.5-free',
|
||||
},
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
await expect(store.read({ selectedModel: 'openai/gpt-5.4-mini' })).resolves.toMatchObject({
|
||||
ok: true,
|
||||
evidence: null,
|
||||
diagnostics: [
|
||||
'OpenCode production E2E evidence artifact has no entry for selected model openai/gpt-5.4-mini',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('reuses the current project production proof even when the requested OpenCode model differs', async () => {
|
||||
const filePath = path.join(tempDir, 'production-e2e-evidence.json');
|
||||
const store = new OpenCodeProductionE2EEvidenceStore({
|
||||
filePath,
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
await store.write(
|
||||
passingEvidence({
|
||||
evidenceId: 'e2e-project-a',
|
||||
selectedModel: 'opencode/minimax-m2.5-free',
|
||||
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'),
|
||||
})
|
||||
);
|
||||
await store.write(
|
||||
passingEvidence({
|
||||
evidenceId: 'e2e-project-b',
|
||||
selectedModel: 'opencode/minimax-m2.5-free',
|
||||
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-b'),
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
store.read({
|
||||
selectedModel: 'opencode/nemotron-3-super-free',
|
||||
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-b'),
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
evidence: {
|
||||
evidenceId: 'e2e-project-b',
|
||||
selectedModel: 'opencode/minimax-m2.5-free',
|
||||
},
|
||||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers a runtime-compatible project proof over a newer stale one from the same cwd', async () => {
|
||||
const filePath = path.join(tempDir, 'production-e2e-evidence.json');
|
||||
const store = new OpenCodeProductionE2EEvidenceStore({
|
||||
filePath,
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
await store.write(
|
||||
passingEvidence({
|
||||
evidenceId: 'stale-newer',
|
||||
createdAt: '2026-04-21T12:05:00.000Z',
|
||||
selectedModel: 'opencode/big-pickle',
|
||||
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'),
|
||||
capabilitySnapshotId: 'cap-stale',
|
||||
})
|
||||
);
|
||||
await store.write(
|
||||
passingEvidence({
|
||||
evidenceId: 'matching-older',
|
||||
createdAt: '2026-04-21T12:00:00.000Z',
|
||||
selectedModel: 'opencode/minimax-m2.5-free',
|
||||
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'),
|
||||
capabilitySnapshotId: 'cap-current',
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
store.read({
|
||||
selectedModel: 'opencode/nemotron-3-super-free',
|
||||
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'),
|
||||
opencodeVersion: '1.14.19',
|
||||
binaryFingerprint: 'version:1.14.19',
|
||||
capabilitySnapshotId: 'cap-current',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
evidence: {
|
||||
evidenceId: 'matching-older',
|
||||
selectedModel: 'opencode/minimax-m2.5-free',
|
||||
},
|
||||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('stores production evidence for the same raw model across multiple project contexts', async () => {
|
||||
const filePath = path.join(tempDir, 'production-e2e-evidence.json');
|
||||
const store = new OpenCodeProductionE2EEvidenceStore({
|
||||
filePath,
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
await store.write(
|
||||
passingEvidence({
|
||||
evidenceId: 'e2e-project-a',
|
||||
selectedModel: 'opencode/minimax-m2.5-free',
|
||||
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'),
|
||||
})
|
||||
);
|
||||
await store.write(
|
||||
passingEvidence({
|
||||
evidenceId: 'e2e-project-b',
|
||||
selectedModel: 'opencode/minimax-m2.5-free',
|
||||
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-b'),
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
store.read({
|
||||
selectedModel: 'opencode/minimax-m2.5-free',
|
||||
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-b'),
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
evidence: {
|
||||
evidenceId: 'e2e-project-b',
|
||||
selectedModel: 'opencode/minimax-m2.5-free',
|
||||
},
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
store.read({
|
||||
selectedModel: 'opencode/minimax-m2.5-free',
|
||||
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-c'),
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
evidence: null,
|
||||
diagnostics: ['OpenCode production E2E evidence artifact has no entry for the current working directory'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function passingEvidence(
|
||||
overrides: Partial<OpenCodeProductionE2EEvidence> = {}
|
||||
): OpenCodeProductionE2EEvidence {
|
||||
const createdAt = '2026-04-21T12:00:00.000Z';
|
||||
const sessionId = 'session-1';
|
||||
const requiredToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
|
||||
buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
|
||||
);
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
evidenceId: 'e2e-1',
|
||||
createdAt,
|
||||
expiresAt: '2026-04-21T12:10:00.000Z',
|
||||
version: '1.14.19',
|
||||
passed: true,
|
||||
artifactPath: '/tmp/opencode-e2e',
|
||||
binaryFingerprint: 'version:1.14.19',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
projectPathFingerprint: 'project-a',
|
||||
requiredSignals: requiredSignals(),
|
||||
mcpTools: {
|
||||
requiredTools: requiredToolIds,
|
||||
observedTools: requiredToolIds,
|
||||
},
|
||||
launch: {
|
||||
runId: 'run-1',
|
||||
teamId: 'team-a',
|
||||
teamLaunchState: 'ready',
|
||||
memberCount: 1,
|
||||
sessions: [
|
||||
{
|
||||
memberName: 'Dev',
|
||||
sessionId,
|
||||
launchState: 'confirmed_alive',
|
||||
},
|
||||
],
|
||||
durableCheckpoints: OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.map((name) => ({
|
||||
name,
|
||||
observedAt: createdAt,
|
||||
})),
|
||||
},
|
||||
reconcile: {
|
||||
runId: 'run-1',
|
||||
teamLaunchState: 'ready',
|
||||
memberCount: 1,
|
||||
},
|
||||
stop: {
|
||||
runId: 'run-1',
|
||||
stopped: true,
|
||||
stoppedSessionIds: [sessionId],
|
||||
},
|
||||
logProjection: {
|
||||
observed: true,
|
||||
projectedMessageCount: 1,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function requiredSignals(
|
||||
overrides: Partial<
|
||||
Record<(typeof OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS)[number], boolean>
|
||||
> = {}
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, overrides[signal] ?? true])
|
||||
) as OpenCodeProductionE2EEvidence['requiredSignals'];
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import * as path from 'path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
OPENCODE_PRODUCTION_E2E_EVIDENCE_FILE,
|
||||
OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH_ENV,
|
||||
resolveOpenCodeProductionE2EEvidencePath,
|
||||
} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidencePath';
|
||||
|
||||
describe('OpenCodeProductionE2EEvidencePath', () => {
|
||||
it('defaults to the app-owned bridge control directory', () => {
|
||||
expect(
|
||||
resolveOpenCodeProductionE2EEvidencePath({
|
||||
bridgeControlDir: '/app/user-data/opencode-bridge',
|
||||
env: {},
|
||||
})
|
||||
).toBe(path.join('/app/user-data/opencode-bridge', OPENCODE_PRODUCTION_E2E_EVIDENCE_FILE));
|
||||
});
|
||||
|
||||
it('allows release and local proof runs to point production at an explicit artifact', () => {
|
||||
const relativeOverride = 'tmp/opencode-production-evidence.json';
|
||||
|
||||
expect(
|
||||
resolveOpenCodeProductionE2EEvidencePath({
|
||||
bridgeControlDir: '/app/user-data/opencode-bridge',
|
||||
env: {
|
||||
[OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH_ENV]: ` ${relativeOverride} `,
|
||||
},
|
||||
})
|
||||
).toBe(path.resolve(relativeOverride));
|
||||
});
|
||||
});
|
||||
|
|
@ -1,468 +0,0 @@
|
|||
import { constants as fsConstants, promises as fs } from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { OpenCodeBridgeCommandClient } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient';
|
||||
import {
|
||||
createOpenCodeBridgeCommandLeaseStore,
|
||||
createOpenCodeBridgeCommandLedgerStore,
|
||||
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore';
|
||||
import {
|
||||
createOpenCodeBridgeClientIdentity,
|
||||
OpenCodeBridgeCommandHandshakePort,
|
||||
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient';
|
||||
import { OpenCodeReadinessBridge } from '../../../../src/main/services/team/opencode/bridge/OpenCodeReadinessBridge';
|
||||
import { OpenCodeStateChangingBridgeCommandService } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
|
||||
import {
|
||||
assertOpenCodeProductionE2EArtifactGate,
|
||||
buildOpenCodeProjectPathFingerprint,
|
||||
OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION,
|
||||
OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS,
|
||||
OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS,
|
||||
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS,
|
||||
type OpenCodeProductionE2EEvidence,
|
||||
} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence';
|
||||
import { OpenCodeProductionE2EEvidenceStore } from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore';
|
||||
import {
|
||||
buildOpenCodeCanonicalMcpToolId,
|
||||
REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS,
|
||||
} from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
|
||||
import { resolveAgentTeamsMcpLaunchSpec } from '../../../../src/main/services/team/TeamMcpConfigBuilder';
|
||||
import { applyOpenCodeAutoUpdatePolicy } from '../../../../src/main/services/runtime/openCodeAutoUpdatePolicy';
|
||||
|
||||
import type {
|
||||
OpenCodeBridgeRuntimeSnapshot,
|
||||
OpenCodeLaunchTeamCommandData,
|
||||
OpenCodeStopTeamCommandData,
|
||||
RuntimeStoreManifestEvidence,
|
||||
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
|
||||
import type { RuntimeStoreManifestReader } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
|
||||
import type { OpenCodeBridgeCommandExecutor } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
|
||||
|
||||
const liveDescribe = process.env.OPENCODE_E2E === '1' ? describe : describe.skip;
|
||||
|
||||
const DEFAULT_APP_PRODUCTION_E2E_EVIDENCE_PATH = path.join(
|
||||
os.userInfo().homedir,
|
||||
'Library',
|
||||
'Application Support',
|
||||
'Agent Teams UI',
|
||||
'opencode-bridge',
|
||||
'production-e2e-evidence.json'
|
||||
);
|
||||
const PROJECT_PATH = process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || process.cwd();
|
||||
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli';
|
||||
const DEFAULT_MODEL = 'opencode/big-pickle';
|
||||
|
||||
liveDescribe('OpenCode production gate live e2e', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-production-gate-e2e-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('runs live launch/reconcile/transcript/stop and accepts production evidence with app MCP tool proof', async () => {
|
||||
const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL;
|
||||
const orchestratorCli =
|
||||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
|
||||
await assertExecutable(orchestratorCli);
|
||||
|
||||
const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec();
|
||||
const bridgeEnv = {
|
||||
...createStableBridgeEnv(),
|
||||
PATH: withBunOnPath(process.env.PATH ?? ''),
|
||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command,
|
||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
|
||||
};
|
||||
const bridgeClient = new OpenCodeBridgeCommandClient({
|
||||
binaryPath: orchestratorCli,
|
||||
tempDirectory: path.join(tempDir, 'bridge-input'),
|
||||
env: bridgeEnv,
|
||||
});
|
||||
const stateChangingCommands = createStateChangingCommands({
|
||||
bridge: bridgeClient,
|
||||
controlDir: path.join(tempDir, 'control'),
|
||||
});
|
||||
const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, {
|
||||
stateChangingCommands,
|
||||
timeoutMs: 180_000,
|
||||
launchTimeoutMs: 180_000,
|
||||
reconcileTimeoutMs: 90_000,
|
||||
stopTimeoutMs: 90_000,
|
||||
});
|
||||
|
||||
const readiness = await readinessBridge.checkOpenCodeTeamLaunchReadiness({
|
||||
projectPath: PROJECT_PATH,
|
||||
selectedModel,
|
||||
requireExecutionProbe: false,
|
||||
});
|
||||
const initialRuntime = readinessBridge.getLastOpenCodeRuntimeSnapshot(PROJECT_PATH);
|
||||
if (!initialRuntime) {
|
||||
throw new Error(
|
||||
`OpenCode live readiness did not return runtime snapshot: ${[
|
||||
...readiness.diagnostics,
|
||||
...readiness.missing,
|
||||
].join('; ')}`
|
||||
);
|
||||
}
|
||||
expect(initialRuntime?.version).toBe('1.14.19');
|
||||
expect(initialRuntime?.capabilitySnapshotId).toBeTruthy();
|
||||
|
||||
const runId = `opencode-e2e-${Date.now()}`;
|
||||
const teamName = `opencode-e2e-team-${Date.now()}`;
|
||||
const memberName = 'E2E';
|
||||
let launch: OpenCodeLaunchTeamCommandData | null = null;
|
||||
let reconcile: OpenCodeLaunchTeamCommandData | null = null;
|
||||
let stop: OpenCodeStopTeamCommandData | null = null;
|
||||
let transcriptMessages = 0;
|
||||
let staleRunRejected = false;
|
||||
|
||||
try {
|
||||
launch = await readinessBridge.launchOpenCodeTeam({
|
||||
mode: 'dogfood',
|
||||
runId,
|
||||
laneId: 'primary',
|
||||
teamId: teamName,
|
||||
teamName,
|
||||
projectPath: PROJECT_PATH,
|
||||
selectedModel,
|
||||
members: [
|
||||
{
|
||||
name: memberName,
|
||||
role: 'e2e',
|
||||
prompt: 'Reply with exactly: opencode-production-gate-e2e',
|
||||
},
|
||||
],
|
||||
leadPrompt: 'Live OpenCode production gate e2e',
|
||||
expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null,
|
||||
manifestHighWatermark: null,
|
||||
});
|
||||
|
||||
expect(launch.teamLaunchState).toBe('ready');
|
||||
expect(launch.members[memberName]?.launchState).toBe('confirmed_alive');
|
||||
|
||||
reconcile = await readinessBridge.reconcileOpenCodeTeam({
|
||||
runId,
|
||||
laneId: 'primary',
|
||||
teamId: teamName,
|
||||
teamName,
|
||||
projectPath: PROJECT_PATH,
|
||||
expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null,
|
||||
manifestHighWatermark: null,
|
||||
expectedMembers: [{ name: memberName, model: selectedModel }],
|
||||
reason: 'production_gate_e2e',
|
||||
});
|
||||
expect(reconcile.teamLaunchState).toBe('ready');
|
||||
|
||||
const transcript = await bridgeClient.execute<
|
||||
{ teamId: string; teamName: string; laneId: string; memberName: string },
|
||||
{ logProjection?: { messages?: unknown[] }; messages?: unknown[] }
|
||||
>(
|
||||
'opencode.getRuntimeTranscript',
|
||||
{ teamId: teamName, teamName, laneId: 'primary', memberName },
|
||||
{ cwd: PROJECT_PATH, timeoutMs: 60_000 }
|
||||
);
|
||||
expect(transcript.ok).toBe(true);
|
||||
if (transcript.ok) {
|
||||
transcriptMessages =
|
||||
transcript.data.logProjection?.messages?.length ?? transcript.data.messages?.length ?? 0;
|
||||
expect(transcriptMessages).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
staleRunRejected = await rejectsStaleCapability({
|
||||
stateChangingCommands,
|
||||
teamName,
|
||||
runId: `${runId}-stale`,
|
||||
selectedModel,
|
||||
});
|
||||
|
||||
stop = await readinessBridge.stopOpenCodeTeam({
|
||||
runId,
|
||||
laneId: 'primary',
|
||||
teamId: teamName,
|
||||
teamName,
|
||||
projectPath: PROJECT_PATH,
|
||||
expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null,
|
||||
manifestHighWatermark: null,
|
||||
reason: 'production_gate_e2e_cleanup',
|
||||
force: true,
|
||||
});
|
||||
expect(stop.stopped).toBe(true);
|
||||
|
||||
const finalReadiness = await readinessBridge.checkOpenCodeTeamLaunchReadiness({
|
||||
projectPath: PROJECT_PATH,
|
||||
selectedModel,
|
||||
requireExecutionProbe: true,
|
||||
});
|
||||
const finalRuntime = readinessBridge.getLastOpenCodeRuntimeSnapshot(PROJECT_PATH);
|
||||
if (!finalRuntime) {
|
||||
throw new Error(
|
||||
`OpenCode final readiness did not return runtime snapshot: ${[
|
||||
...finalReadiness.diagnostics,
|
||||
...finalReadiness.missing,
|
||||
].join('; ')}`
|
||||
);
|
||||
}
|
||||
expect(finalRuntime.version).toBe('1.14.19');
|
||||
expect(finalRuntime.capabilitySnapshotId).toBeTruthy();
|
||||
|
||||
const candidate = buildCandidateEvidence({
|
||||
runId,
|
||||
teamName,
|
||||
memberName,
|
||||
selectedModel,
|
||||
runtime: finalRuntime,
|
||||
readinessObservedTools: readiness.evidence.observedMcpTools,
|
||||
launch,
|
||||
reconcile,
|
||||
stop,
|
||||
transcriptMessages,
|
||||
staleRunRejected,
|
||||
appMcpToolsVisible: readiness.requiredToolsPresent,
|
||||
});
|
||||
const gate = assertOpenCodeProductionE2EArtifactGate({
|
||||
evidence: candidate,
|
||||
artifactPath: candidate.artifactPath,
|
||||
expected: {
|
||||
opencodeVersion: finalRuntime.version ?? null,
|
||||
binaryFingerprint: finalRuntime.binaryFingerprint ?? null,
|
||||
capabilitySnapshotId: finalRuntime.capabilitySnapshotId ?? null,
|
||||
selectedModel,
|
||||
projectPathFingerprint: buildOpenCodeProjectPathFingerprint(PROJECT_PATH),
|
||||
requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
|
||||
buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
expect(gate).toEqual({
|
||||
ok: true,
|
||||
diagnostics: [],
|
||||
});
|
||||
await writeProductionEvidenceIfRequested(candidate);
|
||||
} finally {
|
||||
if (!stop) {
|
||||
await readinessBridge
|
||||
.stopOpenCodeTeam({
|
||||
runId,
|
||||
laneId: 'primary',
|
||||
teamId: teamName,
|
||||
teamName,
|
||||
projectPath: PROJECT_PATH,
|
||||
expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null,
|
||||
manifestHighWatermark: null,
|
||||
reason: 'production_gate_e2e_finally_cleanup',
|
||||
force: true,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}, 240_000);
|
||||
});
|
||||
|
||||
async function writeProductionEvidenceIfRequested(
|
||||
evidence: OpenCodeProductionE2EEvidence
|
||||
): Promise<void> {
|
||||
const explicitPath = process.env.OPENCODE_E2E_WRITE_EVIDENCE_PATH?.trim();
|
||||
const writeAppEvidence = process.env.OPENCODE_E2E_WRITE_APP_EVIDENCE === '1';
|
||||
const filePath =
|
||||
explicitPath || (writeAppEvidence ? DEFAULT_APP_PRODUCTION_E2E_EVIDENCE_PATH : '');
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = new OpenCodeProductionE2EEvidenceStore({ filePath });
|
||||
await store.write({
|
||||
...evidence,
|
||||
artifactPath: filePath,
|
||||
});
|
||||
}
|
||||
|
||||
function createStateChangingCommands(input: {
|
||||
bridge: OpenCodeBridgeCommandExecutor;
|
||||
controlDir: string;
|
||||
}): OpenCodeStateChangingBridgeCommandService {
|
||||
const clientIdentity = createOpenCodeBridgeClientIdentity({
|
||||
appVersion: '1.3.0-e2e',
|
||||
gitSha: null,
|
||||
buildId: 'opencode-production-gate-e2e',
|
||||
});
|
||||
|
||||
return new OpenCodeStateChangingBridgeCommandService({
|
||||
expectedClientIdentity: clientIdentity,
|
||||
handshakePort: new OpenCodeBridgeCommandHandshakePort({
|
||||
bridge: input.bridge,
|
||||
clientIdentity,
|
||||
}),
|
||||
leaseStore: createOpenCodeBridgeCommandLeaseStore({
|
||||
filePath: path.join(input.controlDir, 'leases.json'),
|
||||
}),
|
||||
ledger: createOpenCodeBridgeCommandLedgerStore({
|
||||
filePath: path.join(input.controlDir, 'ledger.json'),
|
||||
}),
|
||||
bridge: input.bridge,
|
||||
manifestReader: new StaticManifestReader(),
|
||||
});
|
||||
}
|
||||
|
||||
class StaticManifestReader implements RuntimeStoreManifestReader {
|
||||
async read(): Promise<RuntimeStoreManifestEvidence> {
|
||||
return {
|
||||
highWatermark: 0,
|
||||
activeRunId: null,
|
||||
capabilitySnapshotId: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectsStaleCapability(input: {
|
||||
stateChangingCommands: OpenCodeStateChangingBridgeCommandService;
|
||||
teamName: string;
|
||||
runId: string;
|
||||
selectedModel: string;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
await input.stateChangingCommands.execute({
|
||||
command: 'opencode.reconcileTeam',
|
||||
teamName: input.teamName,
|
||||
laneId: 'primary',
|
||||
runId: input.runId,
|
||||
capabilitySnapshotId: 'opencode:stale-capability',
|
||||
behaviorFingerprint: null,
|
||||
body: {
|
||||
runId: input.runId,
|
||||
laneId: 'primary',
|
||||
teamId: input.teamName,
|
||||
teamName: input.teamName,
|
||||
projectPath: PROJECT_PATH,
|
||||
expectedCapabilitySnapshotId: 'opencode:stale-capability',
|
||||
manifestHighWatermark: null,
|
||||
expectedMembers: [{ name: 'E2E', model: input.selectedModel }],
|
||||
reason: 'production_gate_stale_run_probe',
|
||||
},
|
||||
cwd: PROJECT_PATH,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
return false;
|
||||
} catch (error) {
|
||||
return error instanceof Error && error.message.includes('capability snapshot mismatch');
|
||||
}
|
||||
}
|
||||
|
||||
function buildCandidateEvidence(input: {
|
||||
runId: string;
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
selectedModel: string;
|
||||
runtime: OpenCodeBridgeRuntimeSnapshot;
|
||||
readinessObservedTools: string[];
|
||||
launch: OpenCodeLaunchTeamCommandData;
|
||||
reconcile: OpenCodeLaunchTeamCommandData;
|
||||
stop: OpenCodeStopTeamCommandData;
|
||||
transcriptMessages: number;
|
||||
staleRunRejected: boolean;
|
||||
appMcpToolsVisible: boolean;
|
||||
}): OpenCodeProductionE2EEvidence {
|
||||
const now = new Date();
|
||||
const createdAt = now.toISOString();
|
||||
const sessionId = input.launch.members[input.memberName]?.sessionId ?? 'missing-session';
|
||||
const checkpointByName = new Map<string, { name: string; observedAt: string }>();
|
||||
for (const checkpoint of input.launch.durableCheckpoints ?? []) {
|
||||
checkpointByName.set(checkpoint.name, {
|
||||
name: checkpoint.name,
|
||||
observedAt: checkpoint.observedAt,
|
||||
});
|
||||
}
|
||||
for (const evidence of input.launch.members[input.memberName]?.evidence ?? []) {
|
||||
checkpointByName.set(evidence.kind, {
|
||||
name: evidence.kind,
|
||||
observedAt: evidence.observedAt,
|
||||
});
|
||||
}
|
||||
for (const name of OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS) {
|
||||
checkpointByName.set(name, checkpointByName.get(name) ?? { name, observedAt: createdAt });
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION,
|
||||
evidenceId: `live-${input.runId}`,
|
||||
createdAt,
|
||||
expiresAt: new Date(now.getTime() + OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS).toISOString(),
|
||||
version: input.runtime.version ?? 'unknown',
|
||||
passed: true,
|
||||
artifactPath: path.join(os.tmpdir(), `opencode-production-e2e-${input.runId}.json`),
|
||||
binaryFingerprint: input.runtime.binaryFingerprint ?? 'unknown',
|
||||
capabilitySnapshotId: input.runtime.capabilitySnapshotId ?? 'unknown',
|
||||
selectedModel: input.selectedModel,
|
||||
projectPathFingerprint: buildOpenCodeProjectPathFingerprint(PROJECT_PATH),
|
||||
requiredSignals: {
|
||||
...Object.fromEntries(
|
||||
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, true])
|
||||
),
|
||||
app_mcp_tools_visible: input.appMcpToolsVisible,
|
||||
stale_run_rejected: input.staleRunRejected,
|
||||
} as OpenCodeProductionE2EEvidence['requiredSignals'],
|
||||
mcpTools: {
|
||||
requiredTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
|
||||
buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
|
||||
),
|
||||
observedTools: input.readinessObservedTools,
|
||||
},
|
||||
launch: {
|
||||
runId: input.runId,
|
||||
teamId: input.teamName,
|
||||
teamLaunchState: 'ready',
|
||||
memberCount: 1,
|
||||
sessions: [
|
||||
{
|
||||
memberName: input.memberName,
|
||||
sessionId,
|
||||
launchState: 'confirmed_alive',
|
||||
},
|
||||
],
|
||||
durableCheckpoints: Array.from(checkpointByName.values()),
|
||||
},
|
||||
reconcile: {
|
||||
runId: input.reconcile.runId,
|
||||
teamLaunchState: 'ready',
|
||||
memberCount: Object.keys(input.reconcile.members).length,
|
||||
},
|
||||
stop: {
|
||||
runId: input.stop.runId,
|
||||
stopped: true,
|
||||
stoppedSessionIds: Object.values(input.stop.members)
|
||||
.map((member) => member.sessionId)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
},
|
||||
logProjection: {
|
||||
observed: true,
|
||||
projectedMessageCount: input.transcriptMessages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function assertExecutable(filePath: string): Promise<void> {
|
||||
await fs.access(filePath, fsConstants.X_OK);
|
||||
}
|
||||
|
||||
function withBunOnPath(pathValue: string): string {
|
||||
const bunDir = '/Users/belief/.bun/bin';
|
||||
return pathValue.split(path.delimiter).includes(bunDir)
|
||||
? pathValue
|
||||
: `${bunDir}${path.delimiter}${pathValue}`;
|
||||
}
|
||||
|
||||
function createStableBridgeEnv(): NodeJS.ProcessEnv {
|
||||
const realHome = os.userInfo().homedir;
|
||||
const env = applyOpenCodeAutoUpdatePolicy({ ...process.env });
|
||||
return {
|
||||
...env,
|
||||
HOME: realHome,
|
||||
USERPROFILE: realHome,
|
||||
};
|
||||
}
|
||||
|
|
@ -3,18 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||
import {
|
||||
OpenCodeReadinessBridge,
|
||||
type OpenCodeReadinessBridgeCommandExecutor,
|
||||
type OpenCodeProductionE2EEvidenceReadPort,
|
||||
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeReadinessBridge';
|
||||
import {
|
||||
OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS,
|
||||
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS,
|
||||
buildOpenCodeProjectPathFingerprint,
|
||||
type OpenCodeProductionE2EEvidence,
|
||||
} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence';
|
||||
import {
|
||||
buildOpenCodeCanonicalMcpToolId,
|
||||
REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS,
|
||||
} from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
|
||||
|
||||
import type { OpenCodeTeamLaunchReadiness } from '../../../../src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness';
|
||||
import type {
|
||||
|
|
@ -93,114 +82,6 @@ describe('OpenCodeReadinessBridge', () => {
|
|||
expect(bridge.getLastOpenCodeRuntimeSnapshot('/repo')).toBeNull();
|
||||
});
|
||||
|
||||
it('blocks production readiness when strict production E2E evidence is missing', async () => {
|
||||
const executor = fakeExecutor(
|
||||
bridgeSuccess(readiness({ state: 'ready', launchAllowed: true }))
|
||||
);
|
||||
const evidence = fakeEvidenceStore(null);
|
||||
const bridge = new OpenCodeReadinessBridge(executor, { productionE2eEvidence: evidence });
|
||||
|
||||
await expect(
|
||||
bridge.checkOpenCodeTeamLaunchReadiness({
|
||||
projectPath: '/repo',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
requireExecutionProbe: true,
|
||||
launchMode: 'production',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
state: 'e2e_missing',
|
||||
launchAllowed: false,
|
||||
supportLevel: 'supported_e2e_pending',
|
||||
missing: ['OpenCode production launch requires a current production E2E evidence artifact'],
|
||||
diagnostics: [
|
||||
'OpenCode production launch requires a current production E2E evidence artifact',
|
||||
],
|
||||
});
|
||||
expect(evidence.read).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('allows dogfood readiness while surfacing missing production E2E evidence diagnostics', async () => {
|
||||
const executor = fakeExecutor(
|
||||
bridgeSuccess(readiness({ state: 'ready', launchAllowed: true }))
|
||||
);
|
||||
const bridge = new OpenCodeReadinessBridge(executor, {
|
||||
productionE2eEvidence: fakeEvidenceStore(null),
|
||||
});
|
||||
|
||||
await expect(
|
||||
bridge.checkOpenCodeTeamLaunchReadiness({
|
||||
projectPath: '/repo',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
requireExecutionProbe: true,
|
||||
launchMode: 'dogfood',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
state: 'ready',
|
||||
launchAllowed: true,
|
||||
supportLevel: 'supported_e2e_pending',
|
||||
diagnostics: [
|
||||
'OpenCode production launch requires a current production E2E evidence artifact',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps production readiness open when evidence matches runtime identity and project context', async () => {
|
||||
const executor = fakeExecutor(
|
||||
bridgeSuccess(readiness({ state: 'ready', launchAllowed: true }))
|
||||
);
|
||||
const evidence = fakeEvidenceStore(productionEvidence());
|
||||
const bridge = new OpenCodeReadinessBridge(executor, {
|
||||
productionE2eEvidence: evidence,
|
||||
});
|
||||
|
||||
await expect(
|
||||
bridge.checkOpenCodeTeamLaunchReadiness({
|
||||
projectPath: '/repo',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
requireExecutionProbe: true,
|
||||
launchMode: 'production',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
state: 'ready',
|
||||
launchAllowed: true,
|
||||
supportLevel: 'production_supported',
|
||||
diagnostics: [],
|
||||
});
|
||||
expect(evidence.read).toHaveBeenCalledWith({
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo'),
|
||||
opencodeVersion: '1.14.19',
|
||||
binaryFingerprint: 'bin-1',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts production evidence recorded with a different OpenCode model when runtime identity matches', async () => {
|
||||
const executor = fakeExecutor(
|
||||
bridgeSuccess(readiness({ state: 'ready', launchAllowed: true }))
|
||||
);
|
||||
const evidence = fakeEvidenceStore(
|
||||
productionEvidence({ selectedModel: 'opencode/minimax-m2.5-free' })
|
||||
);
|
||||
const bridge = new OpenCodeReadinessBridge(executor, {
|
||||
productionE2eEvidence: evidence,
|
||||
});
|
||||
|
||||
await expect(
|
||||
bridge.checkOpenCodeTeamLaunchReadiness({
|
||||
projectPath: '/repo',
|
||||
selectedModel: 'opencode/nemotron-3-super-free',
|
||||
requireExecutionProbe: true,
|
||||
launchMode: 'production',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
state: 'ready',
|
||||
launchAllowed: true,
|
||||
supportLevel: 'production_supported',
|
||||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('routes state-changing launch commands through the guarded command service when configured', async () => {
|
||||
const executor = fakeExecutor(
|
||||
bridgeFailure('internal_error', 'direct bridge must not run', [])
|
||||
|
|
@ -231,7 +112,6 @@ describe('OpenCodeReadinessBridge', () => {
|
|||
|
||||
await expect(
|
||||
bridge.launchOpenCodeTeam({
|
||||
mode: 'dogfood',
|
||||
runId: 'run-1',
|
||||
laneId: 'primary',
|
||||
teamId: 'team-a',
|
||||
|
|
@ -271,19 +151,6 @@ function fakeExecutor(
|
|||
};
|
||||
}
|
||||
|
||||
function fakeEvidenceStore(
|
||||
evidence: OpenCodeProductionE2EEvidence | null
|
||||
): OpenCodeProductionE2EEvidenceReadPort & { read: ReturnType<typeof vi.fn> } {
|
||||
return {
|
||||
read: vi.fn(async () => ({
|
||||
ok: true,
|
||||
evidence,
|
||||
artifactPath: '/tmp/opencode-production-e2e.json',
|
||||
diagnostics: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function bridgeSuccess(
|
||||
data: OpenCodeTeamLaunchReadiness
|
||||
): OpenCodeBridgeSuccess<OpenCodeTeamLaunchReadiness> {
|
||||
|
|
@ -379,65 +246,3 @@ function readiness(
|
|||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function productionEvidence(
|
||||
overrides: Partial<OpenCodeProductionE2EEvidence> = {}
|
||||
): OpenCodeProductionE2EEvidence {
|
||||
const createdAt = new Date().toISOString();
|
||||
const sessionId = 'session-1';
|
||||
const requiredToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
|
||||
buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
|
||||
);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
evidenceId: 'e2e-1',
|
||||
createdAt,
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
version: '1.14.19',
|
||||
passed: true,
|
||||
artifactPath: '/tmp/opencode-production-e2e.json',
|
||||
binaryFingerprint: 'bin-1',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo'),
|
||||
requiredSignals: Object.fromEntries(
|
||||
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, true])
|
||||
) as OpenCodeProductionE2EEvidence['requiredSignals'],
|
||||
mcpTools: {
|
||||
requiredTools: requiredToolIds,
|
||||
observedTools: requiredToolIds,
|
||||
},
|
||||
launch: {
|
||||
runId: 'run-1',
|
||||
teamId: 'team-a',
|
||||
teamLaunchState: 'ready',
|
||||
memberCount: 1,
|
||||
sessions: [
|
||||
{
|
||||
memberName: 'Dev',
|
||||
sessionId,
|
||||
launchState: 'confirmed_alive',
|
||||
},
|
||||
],
|
||||
durableCheckpoints: OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.map((name) => ({
|
||||
name,
|
||||
observedAt: createdAt,
|
||||
})),
|
||||
},
|
||||
reconcile: {
|
||||
runId: 'run-1',
|
||||
teamLaunchState: 'ready',
|
||||
memberCount: 1,
|
||||
},
|
||||
stop: {
|
||||
runId: 'run-1',
|
||||
stopped: true,
|
||||
stoppedSessionIds: [sessionId],
|
||||
},
|
||||
logProjection: {
|
||||
observed: true,
|
||||
projectedMessageCount: 1,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,12 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createEmptyEndpointMap } from '../../../../src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities';
|
||||
import {
|
||||
OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS,
|
||||
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS,
|
||||
} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence';
|
||||
import { REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
|
||||
import {
|
||||
OpenCodeTeamLaunchReadinessService,
|
||||
type OpenCodeApiCapabilityPort,
|
||||
type OpenCodeModelExecutionProbePort,
|
||||
type OpenCodeMcpToolProofPort,
|
||||
type OpenCodeProductionE2EEvidencePort,
|
||||
type OpenCodeRuntimeInventory,
|
||||
type OpenCodeRuntimeInventoryPort,
|
||||
type OpenCodeRuntimeStoreReadinessPort,
|
||||
|
|
@ -23,7 +18,6 @@ import type {
|
|||
} from '../../../../src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities';
|
||||
import type { OpenCodeMcpToolProof } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
|
||||
import type { RuntimeStoreReadinessCheck } from '../../../../src/main/services/team/opencode/store/RuntimeStoreManifest';
|
||||
import type { OpenCodeProductionE2EEvidence } from '../../../../src/main/services/team/opencode/version/OpenCodeVersionPolicy';
|
||||
|
||||
describe('OpenCodeTeamLaunchReadinessService', () => {
|
||||
it('returns not_installed before probing deeper runtime dependencies', async () => {
|
||||
|
|
@ -84,16 +78,13 @@ describe('OpenCodeTeamLaunchReadinessService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('blocks capability-compatible versions until production E2E evidence exists', async () => {
|
||||
const ports = createPorts({
|
||||
evidence: null,
|
||||
});
|
||||
it('does not require project-specific E2E evidence before runtime readiness checks', async () => {
|
||||
const ports = createPorts();
|
||||
|
||||
await expect(service(ports).check(readinessInput())).resolves.toMatchObject({
|
||||
state: 'e2e_missing',
|
||||
launchAllowed: false,
|
||||
supportLevel: 'supported_e2e_pending',
|
||||
missing: ['OpenCode version is capability-compatible but production E2E evidence is missing'],
|
||||
state: 'ready',
|
||||
launchAllowed: true,
|
||||
supportLevel: 'production_supported',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -159,46 +150,10 @@ describe('OpenCodeTeamLaunchReadinessService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('fails closed behind adapter feature gate after all runtime evidence is healthy', async () => {
|
||||
it('allows launch when inventory, capabilities, stores, MCP and model probe are healthy', async () => {
|
||||
const ports = createPorts();
|
||||
|
||||
await expect(
|
||||
service(ports, { adapterEnabled: false }).check(readinessInput())
|
||||
).resolves.toMatchObject({
|
||||
state: 'adapter_disabled',
|
||||
launchAllowed: false,
|
||||
missing: ['OpenCode team launch adapter is disabled by feature gate'],
|
||||
});
|
||||
expect(ports.inventory.probe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows dogfood launch to continue without production E2E evidence after runtime checks pass', async () => {
|
||||
const ports = createPorts({ evidence: null });
|
||||
|
||||
await expect(
|
||||
service(ports, { launchMode: 'dogfood' }).check(
|
||||
readinessInput({ requireExecutionProbe: true })
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
state: 'ready',
|
||||
launchAllowed: true,
|
||||
supportLevel: 'supported_e2e_pending',
|
||||
requiredToolsPresent: true,
|
||||
runtimeStoresReady: true,
|
||||
diagnostics: [
|
||||
'OpenCode production E2E evidence is missing; dogfood launch remains allowed after runtime checks.',
|
||||
],
|
||||
});
|
||||
expect(ports.mcpTools.prove).toHaveBeenCalled();
|
||||
expect(ports.modelExecution.verify).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows launch only when inventory, capabilities, E2E, stores, MCP and model probe are healthy', async () => {
|
||||
const ports = createPorts();
|
||||
|
||||
await expect(
|
||||
service(ports, { adapterEnabled: true }).check(readinessInput())
|
||||
).resolves.toMatchObject({
|
||||
await expect(service(ports).check(readinessInput())).resolves.toMatchObject({
|
||||
state: 'ready',
|
||||
launchAllowed: true,
|
||||
modelId: 'openai/gpt-5.4-mini',
|
||||
|
|
@ -218,20 +173,13 @@ describe('OpenCodeTeamLaunchReadinessService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
function service(
|
||||
ports: ReturnType<typeof createPorts>,
|
||||
options: { adapterEnabled?: boolean; launchMode?: 'disabled' | 'dogfood' | 'production' } = {}
|
||||
): OpenCodeTeamLaunchReadinessService {
|
||||
function service(ports: ReturnType<typeof createPorts>): OpenCodeTeamLaunchReadinessService {
|
||||
return new OpenCodeTeamLaunchReadinessService(
|
||||
ports.inventory,
|
||||
ports.capabilities,
|
||||
ports.mcpTools,
|
||||
ports.runtimeStores,
|
||||
ports.modelExecution,
|
||||
ports.e2eEvidence,
|
||||
options.launchMode
|
||||
? { launchMode: options.launchMode }
|
||||
: { adapterEnabled: options.adapterEnabled ?? true }
|
||||
ports.modelExecution
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -261,7 +209,6 @@ function createPorts(
|
|||
reason: string | null;
|
||||
diagnostics: string[];
|
||||
};
|
||||
evidence?: OpenCodeProductionE2EEvidence | null;
|
||||
} = {}
|
||||
): {
|
||||
inventory: OpenCodeRuntimeInventoryPort & { probe: ReturnType<typeof vi.fn> };
|
||||
|
|
@ -269,7 +216,6 @@ function createPorts(
|
|||
mcpTools: OpenCodeMcpToolProofPort & { prove: ReturnType<typeof vi.fn> };
|
||||
runtimeStores: OpenCodeRuntimeStoreReadinessPort & { check: ReturnType<typeof vi.fn> };
|
||||
modelExecution: OpenCodeModelExecutionProbePort & { verify: ReturnType<typeof vi.fn> };
|
||||
e2eEvidence: OpenCodeProductionE2EEvidencePort & { read: ReturnType<typeof vi.fn> };
|
||||
} {
|
||||
return {
|
||||
inventory: {
|
||||
|
|
@ -287,9 +233,6 @@ function createPorts(
|
|||
modelExecution: {
|
||||
verify: vi.fn(async () => overrides.modelProbe ?? modelProbe()),
|
||||
},
|
||||
e2eEvidence: {
|
||||
read: vi.fn(async () => (overrides.evidence === undefined ? evidence() : overrides.evidence)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -373,60 +316,3 @@ function modelProbe() {
|
|||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
function evidence(): OpenCodeProductionE2EEvidence {
|
||||
const createdAt = new Date().toISOString();
|
||||
const sessionId = 'session-1';
|
||||
const requiredToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => `agent_teams_${tool}`);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
evidenceId: 'e2e-1',
|
||||
createdAt,
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
version: '1.14.19',
|
||||
passed: true,
|
||||
artifactPath: '/tmp/opencode-e2e',
|
||||
binaryFingerprint: 'version:1.14.19',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
projectPathFingerprint: 'project-a',
|
||||
requiredSignals: Object.fromEntries(
|
||||
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, true])
|
||||
) as OpenCodeProductionE2EEvidence['requiredSignals'],
|
||||
mcpTools: {
|
||||
requiredTools: requiredToolIds,
|
||||
observedTools: requiredToolIds,
|
||||
},
|
||||
launch: {
|
||||
runId: 'run-1',
|
||||
teamId: 'team-a',
|
||||
teamLaunchState: 'ready',
|
||||
memberCount: 1,
|
||||
sessions: [
|
||||
{
|
||||
memberName: 'Dev',
|
||||
sessionId,
|
||||
launchState: 'confirmed_alive',
|
||||
},
|
||||
],
|
||||
durableCheckpoints: OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.map((name) => ({
|
||||
name,
|
||||
observedAt: createdAt,
|
||||
})),
|
||||
},
|
||||
reconcile: {
|
||||
runId: 'run-1',
|
||||
teamLaunchState: 'ready',
|
||||
memberCount: 1,
|
||||
},
|
||||
stop: {
|
||||
runId: 'run-1',
|
||||
stopped: true,
|
||||
stoppedSessionIds: [sessionId],
|
||||
},
|
||||
logProjection: {
|
||||
observed: true,
|
||||
projectedMessageCount: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,9 +56,7 @@ liveDescribe('OpenCode team provisioning live e2e', () => {
|
|||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it(
|
||||
'creates and stops a pure OpenCode team through TeamProvisioningService using the live runtime adapter',
|
||||
async () => {
|
||||
it('creates and stops a pure OpenCode team through TeamProvisioningService using the live runtime adapter', async () => {
|
||||
const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL;
|
||||
const orchestratorCli =
|
||||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
|
||||
|
|
@ -88,9 +86,7 @@ liveDescribe('OpenCode team provisioning live e2e', () => {
|
|||
reconcileTimeoutMs: 90_000,
|
||||
stopTimeoutMs: 90_000,
|
||||
});
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge, {
|
||||
launchMode: 'dogfood',
|
||||
});
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge);
|
||||
const svc = new TeamProvisioningService();
|
||||
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
||||
|
||||
|
|
@ -155,15 +151,15 @@ liveDescribe('OpenCode team provisioning live e2e', () => {
|
|||
alive: true,
|
||||
runtimeModel: selectedModel,
|
||||
});
|
||||
await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject(
|
||||
{
|
||||
await expect(
|
||||
readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)
|
||||
).resolves.toMatchObject({
|
||||
lanes: {
|
||||
primary: {
|
||||
state: 'active',
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
svc.stopTeam(teamName);
|
||||
await waitUntil(async () => {
|
||||
|
|
@ -173,9 +169,7 @@ liveDescribe('OpenCode team provisioning live e2e', () => {
|
|||
} finally {
|
||||
svc.stopTeam(teamName);
|
||||
}
|
||||
},
|
||||
300_000
|
||||
);
|
||||
}, 300_000);
|
||||
});
|
||||
|
||||
function createStateChangingCommands(input: {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
diagnostics: ['OpenCode missing canonical app MCP tool id'],
|
||||
})
|
||||
);
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' });
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
|
||||
|
||||
await expect(adapter.prepare(launchInput())).resolves.toEqual({
|
||||
ok: false,
|
||||
|
|
@ -34,13 +34,12 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
projectPath: '/repo',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
requireExecutionProbe: true,
|
||||
launchMode: 'production',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses runtime-only readiness for model-less preflight checks', async () => {
|
||||
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true, modelId: null }));
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' });
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
|
||||
|
||||
await expect(
|
||||
adapter.prepare(launchInput({ model: undefined, runtimeOnly: true }))
|
||||
|
|
@ -54,7 +53,6 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
projectPath: '/repo',
|
||||
selectedModel: null,
|
||||
requireExecutionProbe: false,
|
||||
launchMode: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -69,7 +67,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
missing: ['OpenCode bridge command timed out'],
|
||||
})
|
||||
);
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' });
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
|
||||
|
||||
await expect(adapter.launch(launchInput())).resolves.toMatchObject({
|
||||
teamLaunchState: 'partial_failure',
|
||||
|
|
@ -87,25 +85,12 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('fails closed when launch mode is disabled', async () => {
|
||||
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }));
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
|
||||
|
||||
await expect(adapter.prepare(launchInput())).resolves.toMatchObject({
|
||||
ok: false,
|
||||
providerId: 'opencode',
|
||||
reason: 'opencode_team_launch_disabled',
|
||||
retryable: false,
|
||||
});
|
||||
expect(bridge.checkOpenCodeTeamLaunchReadiness).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects non-OpenCode members before readiness or launch bridge dispatch', async () => {
|
||||
const launchOpenCodeTeam = vi.fn();
|
||||
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||
launchOpenCodeTeam,
|
||||
});
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' });
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
|
||||
|
||||
const result = await adapter.launch(
|
||||
launchInput({
|
||||
|
|
@ -138,7 +123,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||
launchOpenCodeTeam,
|
||||
});
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' });
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
|
||||
|
||||
const result = await adapter.launch(launchInput({ expectedMembers: [] }));
|
||||
|
||||
|
|
@ -187,8 +172,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
capabilitySnapshotId: 'cap-1',
|
||||
})),
|
||||
launchOpenCodeTeam,
|
||||
}),
|
||||
{ launchMode: 'dogfood' }
|
||||
})
|
||||
);
|
||||
|
||||
await expect(adapter.launch(launchInput())).resolves.toMatchObject({
|
||||
|
|
@ -257,8 +241,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||
launchOpenCodeTeam,
|
||||
}),
|
||||
{ launchMode: 'dogfood' }
|
||||
})
|
||||
);
|
||||
|
||||
const result = await adapter.launch({
|
||||
|
|
@ -393,8 +376,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||
reconcileOpenCodeTeam,
|
||||
}),
|
||||
{ launchMode: 'dogfood' }
|
||||
})
|
||||
);
|
||||
|
||||
const result = await adapter.reconcile({
|
||||
|
|
@ -488,8 +470,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
capabilitySnapshotId: 'cap-1',
|
||||
})),
|
||||
launchOpenCodeTeam,
|
||||
}),
|
||||
{ launchMode: 'dogfood' }
|
||||
})
|
||||
);
|
||||
|
||||
const result = await adapter.launch(launchInput());
|
||||
|
|
@ -539,8 +520,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||
launchOpenCodeTeam,
|
||||
}),
|
||||
{ launchMode: 'dogfood' }
|
||||
})
|
||||
);
|
||||
|
||||
const result = await adapter.launch(launchInput());
|
||||
|
|
@ -576,8 +556,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||
launchOpenCodeTeam,
|
||||
}),
|
||||
{ launchMode: 'dogfood' }
|
||||
})
|
||||
);
|
||||
|
||||
const result = await adapter.launch(launchInput());
|
||||
|
|
@ -588,7 +567,8 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
runtimeAlive: false,
|
||||
livenessKind: 'runtime_process_candidate',
|
||||
runtimePid: 123,
|
||||
runtimeDiagnostic: 'OpenCode runtime pid reported by bridge without local process verification',
|
||||
runtimeDiagnostic:
|
||||
'OpenCode runtime pid reported by bridge without local process verification',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -613,8 +593,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||
launchOpenCodeTeam,
|
||||
}),
|
||||
{ launchMode: 'dogfood' }
|
||||
})
|
||||
);
|
||||
|
||||
const result = await adapter.launch(launchInput());
|
||||
|
|
@ -663,8 +642,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
capabilitySnapshotId: 'cap-1',
|
||||
})),
|
||||
launchOpenCodeTeam,
|
||||
}),
|
||||
{ launchMode: 'dogfood' }
|
||||
})
|
||||
);
|
||||
|
||||
const result = await adapter.launch(
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
type OpenCodeApiEndpointKey,
|
||||
} from '../../../../src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities';
|
||||
import {
|
||||
assertOpenCodeProductionE2EGate,
|
||||
buildOpenCodeBinaryFingerprint,
|
||||
evaluateOpenCodeSupport,
|
||||
parseOpenCodeSemver,
|
||||
|
|
@ -19,12 +18,6 @@ import {
|
|||
type OpenCodeCompatibilitySnapshot,
|
||||
type OpenCodeRouteCompatibilityCache,
|
||||
} from '../../../../src/main/services/team/opencode/version/OpenCodeVersionPolicy';
|
||||
import {
|
||||
OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS,
|
||||
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS,
|
||||
type OpenCodeProductionE2EEvidence,
|
||||
} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence';
|
||||
import { REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
|
||||
|
||||
describe('OpenCodeVersionPolicy', () => {
|
||||
let tempDir: string;
|
||||
|
|
@ -58,7 +51,6 @@ describe('OpenCodeVersionPolicy', () => {
|
|||
evaluateOpenCodeSupport({
|
||||
version: '1.4.0',
|
||||
capabilities: readyCapabilities(),
|
||||
evidence: passingEvidence(),
|
||||
})
|
||||
).toMatchObject({
|
||||
supported: false,
|
||||
|
|
@ -70,7 +62,6 @@ describe('OpenCodeVersionPolicy', () => {
|
|||
evaluateOpenCodeSupport({
|
||||
version: '1.14.19-beta.1',
|
||||
capabilities: readyCapabilities(),
|
||||
evidence: passingEvidence(),
|
||||
})
|
||||
).toMatchObject({
|
||||
supported: false,
|
||||
|
|
@ -79,12 +70,11 @@ describe('OpenCodeVersionPolicy', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('requires capabilities and production E2E evidence before production support', () => {
|
||||
it('requires capabilities before support', () => {
|
||||
expect(
|
||||
evaluateOpenCodeSupport({
|
||||
version: '1.14.19',
|
||||
capabilities: missingCapabilities(['POST permission reply route']),
|
||||
evidence: passingEvidence(),
|
||||
})
|
||||
).toMatchObject({
|
||||
supported: false,
|
||||
|
|
@ -96,23 +86,6 @@ describe('OpenCodeVersionPolicy', () => {
|
|||
evaluateOpenCodeSupport({
|
||||
version: '1.14.19',
|
||||
capabilities: readyCapabilities(),
|
||||
evidence: null,
|
||||
})
|
||||
).toMatchObject({
|
||||
supported: false,
|
||||
supportLevel: 'supported_e2e_pending',
|
||||
diagnostics: [
|
||||
'OpenCode version is capability-compatible but production E2E evidence is missing',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts supported version only when capabilities and E2E evidence pass', () => {
|
||||
expect(
|
||||
evaluateOpenCodeSupport({
|
||||
version: '1.14.19',
|
||||
capabilities: readyCapabilities(),
|
||||
evidence: passingEvidence(),
|
||||
})
|
||||
).toMatchObject({
|
||||
supported: true,
|
||||
|
|
@ -121,31 +94,16 @@ describe('OpenCodeVersionPolicy', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('rejects stale or incomplete production E2E evidence', () => {
|
||||
it('accepts supported version when capabilities pass', () => {
|
||||
expect(
|
||||
assertOpenCodeProductionE2EGate({
|
||||
evidence: passingEvidence({ version: '1.14.18' }),
|
||||
testedVersion: '1.14.19',
|
||||
evaluateOpenCodeSupport({
|
||||
version: '1.14.19',
|
||||
capabilities: readyCapabilities(),
|
||||
})
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
diagnostics: expect.arrayContaining([
|
||||
'OpenCode production E2E evidence version 1.14.18 does not match tested version 1.14.19',
|
||||
]),
|
||||
});
|
||||
|
||||
expect(
|
||||
assertOpenCodeProductionE2EGate({
|
||||
evidence: passingEvidence({
|
||||
requiredSignals: requiredSignals({ canonical_log_projection_observed: false }),
|
||||
}),
|
||||
testedVersion: '1.14.19',
|
||||
})
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
diagnostics: expect.arrayContaining([
|
||||
'OpenCode production E2E evidence is missing signals: canonical_log_projection_observed',
|
||||
]),
|
||||
supported: true,
|
||||
supportLevel: 'production_supported',
|
||||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -260,76 +218,6 @@ function missingCapabilities(missing: string[]) {
|
|||
};
|
||||
}
|
||||
|
||||
function passingEvidence(
|
||||
overrides: Partial<OpenCodeProductionE2EEvidence> = {}
|
||||
): OpenCodeProductionE2EEvidence {
|
||||
const createdAt = new Date().toISOString();
|
||||
const expiresAt = new Date(Date.now() + 60_000).toISOString();
|
||||
const requiredToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => `agent_teams_${tool}`);
|
||||
const durableCheckpoints = OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.map((name) => ({
|
||||
name,
|
||||
observedAt: createdAt,
|
||||
}));
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
evidenceId: 'e2e-1',
|
||||
createdAt,
|
||||
expiresAt,
|
||||
version: '1.14.19',
|
||||
passed: true,
|
||||
artifactPath: '/tmp/opencode-e2e',
|
||||
binaryFingerprint: 'version:1.14.19',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
projectPathFingerprint: 'project-a',
|
||||
requiredSignals: requiredSignals(),
|
||||
mcpTools: {
|
||||
requiredTools: requiredToolIds,
|
||||
observedTools: requiredToolIds,
|
||||
},
|
||||
launch: {
|
||||
runId: 'run-1',
|
||||
teamId: 'team-a',
|
||||
teamLaunchState: 'ready',
|
||||
memberCount: 1,
|
||||
sessions: [
|
||||
{
|
||||
memberName: 'Dev',
|
||||
sessionId: 'ses-1',
|
||||
launchState: 'confirmed_alive',
|
||||
},
|
||||
],
|
||||
durableCheckpoints,
|
||||
},
|
||||
reconcile: {
|
||||
runId: 'run-1',
|
||||
teamLaunchState: 'ready',
|
||||
memberCount: 1,
|
||||
},
|
||||
stop: {
|
||||
runId: 'run-1',
|
||||
stopped: true,
|
||||
stoppedSessionIds: ['ses-1'],
|
||||
},
|
||||
logProjection: {
|
||||
observed: true,
|
||||
projectedMessageCount: 1,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function requiredSignals(
|
||||
overrides: Partial<
|
||||
Record<(typeof OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS)[number], boolean>
|
||||
> = {}
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, overrides[signal] ?? true])
|
||||
) as OpenCodeProductionE2EEvidence['requiredSignals'];
|
||||
}
|
||||
|
||||
function compatibilitySnapshot(
|
||||
overrides: Partial<OpenCodeCompatibilitySnapshot>
|
||||
): OpenCodeCompatibilitySnapshot {
|
||||
|
|
@ -349,7 +237,6 @@ function compatibilitySnapshot(
|
|||
supported: true,
|
||||
supportLevel: 'production_supported',
|
||||
apiCapabilities: readyCapabilities(),
|
||||
testedEvidencePath: '/tmp/opencode-e2e',
|
||||
diagnostics: [],
|
||||
...overrides,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ describe('TeamLaunchStateEvaluator', () => {
|
|||
confirmedCount: 0,
|
||||
pendingCount: 2,
|
||||
failedCount: 0,
|
||||
skippedCount: 0,
|
||||
runtimeAlivePendingCount: 0,
|
||||
shellOnlyPendingCount: 0,
|
||||
runtimeProcessPendingCount: 0,
|
||||
|
|
@ -86,6 +87,72 @@ describe('TeamLaunchStateEvaluator', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('keeps skipped members terminal and out of pending counts', () => {
|
||||
const summary = summarizePersistedLaunchMembers(['alice', 'bob'], {
|
||||
alice: {
|
||||
launchState: 'skipped_for_launch',
|
||||
skippedForLaunch: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
},
|
||||
bob: {
|
||||
launchState: 'confirmed_alive',
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
confirmedCount: 1,
|
||||
pendingCount: 0,
|
||||
failedCount: 0,
|
||||
skippedCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not preserve runtimeAlive for skipped persisted members', () => {
|
||||
const snapshot = normalizePersistedLaunchSnapshot('demo', {
|
||||
version: 2,
|
||||
teamName: 'demo',
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
launchPhase: 'finished',
|
||||
expectedMembers: ['alice'],
|
||||
members: {
|
||||
alice: {
|
||||
name: 'alice',
|
||||
launchState: 'skipped_for_launch',
|
||||
skippedForLaunch: true,
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
livenessKind: 'runtime_process',
|
||||
lastEvaluatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot?.members.alice).toMatchObject({
|
||||
launchState: 'skipped_for_launch',
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
agentToolAccepted: false,
|
||||
skippedForLaunch: true,
|
||||
});
|
||||
|
||||
const statuses = snapshotToMemberSpawnStatuses(snapshot!);
|
||||
expect(statuses.alice).toMatchObject({
|
||||
status: 'skipped',
|
||||
launchState: 'skipped_for_launch',
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
agentToolAccepted: false,
|
||||
skippedForLaunch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts registered-only persisted liveness as no-runtime pending', () => {
|
||||
const summary = summarizePersistedLaunchMembers(['alice'], {
|
||||
alice: {
|
||||
|
|
|
|||
|
|
@ -1550,6 +1550,191 @@ describe('TeamProvisioningService', () => {
|
|||
expect(restartMessage).toContain('Their workflow: Use the updated checklist');
|
||||
});
|
||||
|
||||
it('retries a failed teammate without live runtime by resetting spawn status to spawning', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
teamName: 'codex-team',
|
||||
expectedMembers: ['bob'],
|
||||
memberSpawnStatuses: new Map([
|
||||
[
|
||||
'bob',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'Teammate "bob" failed to start: spawn failed',
|
||||
error: 'Teammate "bob" failed to start: spawn failed',
|
||||
agentToolAccepted: false,
|
||||
firstSpawnAcceptedAt: undefined,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
});
|
||||
run.child = { pid: 111 };
|
||||
run.processKilled = false;
|
||||
run.cancelRequested = false;
|
||||
|
||||
const sendMessageToRun = vi.fn(async () => {});
|
||||
(svc as any).sendMessageToRun = sendMessageToRun;
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
name: 'Codex Team',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
})),
|
||||
};
|
||||
(svc as any).membersMetaStore = {
|
||||
getMembers: vi.fn(async () => [
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'Developer',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'medium',
|
||||
agentType: 'general-purpose',
|
||||
},
|
||||
]),
|
||||
};
|
||||
(svc as any).readPersistedRuntimeMembers = vi.fn(() => []);
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map());
|
||||
(svc as any).aliveRunByTeam.set('codex-team', run.runId);
|
||||
(svc as any).runs.set(run.runId, run);
|
||||
|
||||
await svc.restartMember('codex-team', 'bob');
|
||||
|
||||
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
|
||||
status: 'spawning',
|
||||
launchState: 'starting',
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
hardFailureReason: undefined,
|
||||
error: undefined,
|
||||
agentToolAccepted: false,
|
||||
});
|
||||
expect(run.pendingMemberRestarts.has('bob')).toBe(true);
|
||||
expect(sendMessageToRun).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageToRun).toHaveBeenCalledWith(
|
||||
run,
|
||||
expect.stringContaining('Teammate "bob" with role "Developer" was restarted from the UI.')
|
||||
);
|
||||
});
|
||||
|
||||
it('skips a failed teammate for the current launch without marking it alive', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
teamName: 'codex-team',
|
||||
expectedMembers: ['bob'],
|
||||
memberSpawnStatuses: new Map([
|
||||
[
|
||||
'bob',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'Teammate "bob" failed to start: spawn failed',
|
||||
error: 'Teammate "bob" failed to start: spawn failed',
|
||||
agentToolAccepted: false,
|
||||
firstSpawnAcceptedAt: undefined,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
});
|
||||
run.child = { pid: 111 };
|
||||
run.processKilled = false;
|
||||
run.cancelRequested = false;
|
||||
run.isLaunch = true;
|
||||
|
||||
const sendMessageToRun = vi.fn(async () => {});
|
||||
(svc as any).sendMessageToRun = sendMessageToRun;
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
name: 'Codex Team',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
})),
|
||||
};
|
||||
(svc as any).membersMetaStore = {
|
||||
getMembers: vi.fn(async () => [
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'Developer',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'medium',
|
||||
agentType: 'general-purpose',
|
||||
},
|
||||
]),
|
||||
};
|
||||
(svc as any).aliveRunByTeam.set('codex-team', run.runId);
|
||||
(svc as any).runs.set(run.runId, run);
|
||||
|
||||
await svc.skipMemberForLaunch('codex-team', 'bob');
|
||||
|
||||
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
|
||||
status: 'skipped',
|
||||
launchState: 'skipped_for_launch',
|
||||
skippedForLaunch: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
hardFailureReason: undefined,
|
||||
error: undefined,
|
||||
agentToolAccepted: false,
|
||||
});
|
||||
expect(run.pendingMemberRestarts.has('bob')).toBe(false);
|
||||
expect(sendMessageToRun).toHaveBeenCalledWith(
|
||||
run,
|
||||
expect.stringContaining('Teammate "bob" was skipped for this launch')
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects skipping a failed teammate while a retry is already in progress', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
teamName: 'codex-team',
|
||||
expectedMembers: ['bob'],
|
||||
memberSpawnStatuses: new Map([
|
||||
[
|
||||
'bob',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'spawn failed',
|
||||
error: 'spawn failed',
|
||||
}),
|
||||
],
|
||||
]),
|
||||
});
|
||||
run.child = { pid: 111 };
|
||||
run.processKilled = false;
|
||||
run.cancelRequested = false;
|
||||
run.pendingMemberRestarts.set('bob', {
|
||||
requestedAt: new Date().toISOString(),
|
||||
desired: { name: 'bob', role: 'Developer' },
|
||||
});
|
||||
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
name: 'Codex Team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'bob', role: 'Developer' },
|
||||
],
|
||||
})),
|
||||
};
|
||||
(svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) };
|
||||
(svc as any).aliveRunByTeam.set('codex-team', run.runId);
|
||||
(svc as any).runs.set(run.runId, run);
|
||||
|
||||
await expect(svc.skipMemberForLaunch('codex-team', 'bob')).rejects.toThrow(
|
||||
'already in progress'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not let removed base-member metadata override a suffixed teammate during restart', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
|
|
@ -7106,7 +7291,8 @@ describe('TeamProvisioningService', () => {
|
|||
{
|
||||
alive: false,
|
||||
livenessKind: 'runtime_process_candidate',
|
||||
runtimeDiagnostic: 'OpenCode runtime pid is alive, but process identity is unverified',
|
||||
runtimeDiagnostic:
|
||||
'OpenCode runtime pid is alive, but process identity is unverified',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
},
|
||||
],
|
||||
|
|
@ -7337,6 +7523,117 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not resurrect a skipped teammate when live runtime metadata is strong', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
|
||||
async () =>
|
||||
new Map([
|
||||
[
|
||||
'bob',
|
||||
{
|
||||
alive: true,
|
||||
livenessKind: 'runtime_process',
|
||||
pid: 123,
|
||||
providerId: 'codex',
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const result = await (svc as any).attachLiveRuntimeMetadataToStatuses('codex-team', {
|
||||
bob: createMemberSpawnStatusEntry({
|
||||
status: 'skipped',
|
||||
launchState: 'skipped_for_launch',
|
||||
skippedForLaunch: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
agentToolAccepted: false,
|
||||
skipReason: 'Skipped by user after launch failure: spawn failed',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.bob).toMatchObject({
|
||||
status: 'skipped',
|
||||
launchState: 'skipped_for_launch',
|
||||
skippedForLaunch: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
error: undefined,
|
||||
livenessSource: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not resurrect a skipped teammate during spawn status audit', async () => {
|
||||
const run = createMemberSpawnRun({
|
||||
expectedMembers: ['bob'],
|
||||
memberSpawnStatuses: new Map([
|
||||
[
|
||||
'bob',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'skipped',
|
||||
launchState: 'skipped_for_launch',
|
||||
skippedForLaunch: true,
|
||||
skipReason: 'Skipped by user after launch failure: spawn failed',
|
||||
agentToolAccepted: false,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
firstSpawnAcceptedAt: undefined,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
});
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).getRegisteredTeamMemberNames = vi.fn(async () => new Set(['bob']));
|
||||
(svc as any).getLiveTeamAgentNames = vi.fn(async () => new Set(['bob']));
|
||||
|
||||
await (svc as any).auditMemberSpawnStatuses(run);
|
||||
|
||||
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
|
||||
status: 'skipped',
|
||||
launchState: 'skipped_for_launch',
|
||||
skippedForLaunch: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not convert a skipped teammate to failed during final missing-member reconciliation', async () => {
|
||||
const run = createMemberSpawnRun({
|
||||
expectedMembers: ['bob'],
|
||||
memberSpawnStatuses: new Map([
|
||||
[
|
||||
'bob',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'skipped',
|
||||
launchState: 'skipped_for_launch',
|
||||
skippedForLaunch: true,
|
||||
skipReason: 'Skipped by user after launch failure: spawn failed',
|
||||
agentToolAccepted: false,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
firstSpawnAcceptedAt: undefined,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
});
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).getRegisteredTeamMemberNames = vi.fn(async () => new Set());
|
||||
|
||||
await (svc as any).finalizeMissingRegisteredMembersAsFailed(run);
|
||||
|
||||
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
|
||||
status: 'skipped',
|
||||
launchState: 'skipped_for_launch',
|
||||
skippedForLaunch: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not downgrade an already-online teammate when waiting is reported later', () => {
|
||||
const run = createMemberSpawnRun({
|
||||
memberSpawnStatuses: new Map([
|
||||
|
|
|
|||
|
|
@ -473,11 +473,9 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
return {
|
||||
ok: false as const,
|
||||
providerId: 'opencode' as const,
|
||||
reason: 'e2e_missing',
|
||||
reason: 'model_unavailable',
|
||||
retryable: false,
|
||||
diagnostics: [
|
||||
'OpenCode production E2E evidence artifact has no entry for selected model opencode/nemotron-3-super-free',
|
||||
],
|
||||
diagnostics: ['Selected model opencode/nemotron-3-super-free is not available'],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
|
@ -529,7 +527,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
'Selected model opencode/minimax-m2.5-free verified for launch.'
|
||||
);
|
||||
expect(result.message).toBe(
|
||||
'Selected model opencode/nemotron-3-super-free is unavailable. OpenCode production E2E evidence artifact has no entry for selected model opencode/nemotron-3-super-free'
|
||||
'Selected model opencode/nemotron-3-super-free is unavailable. Selected model opencode/nemotron-3-super-free is not available'
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -687,56 +685,6 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('reports missing OpenCode project evidence as a provider note instead of model failures', async () => {
|
||||
const projectEvidenceDiagnostic =
|
||||
'OpenCode production E2E evidence artifact has no entry for the current working directory';
|
||||
const projectEvidenceNote =
|
||||
'OpenCode has not been verified on this project yet. This does not mean the selected models are broken.';
|
||||
const prepare = vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
providerId: 'opencode' as const,
|
||||
reason: 'e2e_missing',
|
||||
retryable: false,
|
||||
diagnostics: [projectEvidenceDiagnostic],
|
||||
warnings: [],
|
||||
}));
|
||||
const registry = new TeamRuntimeAdapterRegistry([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
prepare,
|
||||
launch: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
} as any,
|
||||
]);
|
||||
const svc = new TeamProvisioningService();
|
||||
svc.setRuntimeAdapterRegistry(registry);
|
||||
|
||||
const result = await svc.prepareForProvisioning(tempRoot, {
|
||||
providerId: 'opencode',
|
||||
forceFresh: true,
|
||||
modelIds: ['opencode/minimax-m2.5-free', 'opencode/ling-2.6-flash-free'],
|
||||
modelVerificationMode: 'compatibility',
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.message).toBe('CLI is ready to launch (see notes)');
|
||||
expect(result.details).toEqual([
|
||||
'Selected model opencode/minimax-m2.5-free verified for launch.',
|
||||
'Selected model opencode/ling-2.6-flash-free verified for launch.',
|
||||
]);
|
||||
expect(result.warnings).toEqual([projectEvidenceNote]);
|
||||
expect(result.message).not.toContain('unavailable');
|
||||
expect(prepare).toHaveBeenCalledTimes(1);
|
||||
expect(prepare).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
providerId: 'opencode',
|
||||
model: undefined,
|
||||
runtimeOnly: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('treats retryable OpenCode compatibility failures as blocking selected-model diagnostics', async () => {
|
||||
const prepare = vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
|
|
|
|||
|
|
@ -613,6 +613,8 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
|||
expect(prompt).toContain(
|
||||
'Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after bootstrap.'
|
||||
);
|
||||
expect(prompt).toContain('retry tool search at most once');
|
||||
expect(prompt).toContain('Do NOT keep searching for member_briefing');
|
||||
});
|
||||
|
||||
it('launchTeam hydration prompt includes task-comment handling guidance by default', async () => {
|
||||
|
|
|
|||
|
|
@ -99,7 +99,6 @@ describe('electron userData migration', () => {
|
|||
['mcp-server/1.3.0/package.json', '{"type":"module"}'],
|
||||
['opencode-bridge/command-ledger.json', '{"commands":[]}'],
|
||||
['opencode-bridge/command-leases.json', '{"leases":[]}'],
|
||||
['opencode-bridge/production-e2e-evidence.json', '{"ok":true}'],
|
||||
['logs/claude-cli-auth-diag.ndjson', '{"event":"auth"}\n'],
|
||||
['Local Storage/leveldb/000003.log', 'renderer localStorage bytes'],
|
||||
['future-feature/state.json', '{"kept":true}'],
|
||||
|
|
|
|||
|
|
@ -746,7 +746,7 @@ describe('TeamModelSelector disabled Codex models', () => {
|
|||
supported: true,
|
||||
authenticated: true,
|
||||
statusMessage: 'OpenCode team launch is gated',
|
||||
detailMessage: 'OpenCode production E2E evidence is missing',
|
||||
detailMessage: 'OpenCode runtime store needs recovery',
|
||||
capabilities: { teamLaunch: false },
|
||||
models: [],
|
||||
},
|
||||
|
|
@ -773,7 +773,7 @@ describe('TeamModelSelector disabled Codex models', () => {
|
|||
);
|
||||
expect(openCodeButton?.hasAttribute('disabled')).toBe(true);
|
||||
expect(openCodeButton?.getAttribute('title')).toContain(
|
||||
'OpenCode production E2E evidence is missing'
|
||||
'OpenCode runtime store needs recovery'
|
||||
);
|
||||
expect(openCodeButton?.textContent).toContain('Gate');
|
||||
|
||||
|
|
|
|||
|
|
@ -167,7 +167,10 @@ describe('TeamProvisioningBanner launch-step alignment', () => {
|
|||
cliLogsTail: '',
|
||||
assistantOutput: '',
|
||||
};
|
||||
storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined as unknown as Record<string, unknown>;
|
||||
storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
storeState.memberSpawnStatusesByTeam['northstar-core'] = {
|
||||
alice: { status: 'waiting', launchState: 'starting' },
|
||||
bob: { status: 'waiting', launchState: 'starting' },
|
||||
|
|
@ -281,6 +284,7 @@ describe('TeamProvisioningBanner launch-step alignment', () => {
|
|||
pendingCount: 3,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 3,
|
||||
runtimeProcessPendingCount: 3,
|
||||
},
|
||||
source: 'merged',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -327,16 +327,13 @@ describe('ProvisioningProviderStatusList', () => {
|
|||
{
|
||||
providerId: 'opencode',
|
||||
status: 'failed',
|
||||
details: [
|
||||
'nemotron-3-super-free - unavailable - OpenCode production E2E evidence artifact has no entry for selected model opencode/nemotron-3-super-free',
|
||||
],
|
||||
details: ['nemotron-3-super-free - unavailable - selected model is not available'],
|
||||
},
|
||||
],
|
||||
})
|
||||
).toEqual({
|
||||
state: 'failed',
|
||||
message:
|
||||
'nemotron-3-super-free - unavailable - OpenCode production E2E evidence artifact has no entry for selected model opencode/nemotron-3-super-free',
|
||||
message: 'nemotron-3-super-free - unavailable - selected model is not available',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -256,68 +256,6 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('shows missing OpenCode project evidence as a provider note instead of model failures', async () => {
|
||||
const projectEvidenceNote =
|
||||
'OpenCode has not been verified on this project yet. This does not mean the selected models are broken.';
|
||||
const prepareProvisioning = vi
|
||||
.fn<
|
||||
(
|
||||
cwd?: string,
|
||||
providerId?: TeamProviderId,
|
||||
providerIds?: TeamProviderId[],
|
||||
selectedModels?: string[],
|
||||
limitContext?: boolean,
|
||||
modelVerificationMode?: 'compatibility' | 'deep'
|
||||
) => Promise<TeamProvisioningPrepareResult>
|
||||
>()
|
||||
.mockResolvedValue({
|
||||
ready: true,
|
||||
message: 'CLI is ready to launch (see notes)',
|
||||
details: [
|
||||
'Selected model opencode/minimax-m2.5-free verified for launch.',
|
||||
'Selected model opencode/ling-2.6-flash-free verified for launch.',
|
||||
],
|
||||
warnings: [projectEvidenceNote],
|
||||
});
|
||||
|
||||
const result = await runProviderPrepareDiagnostics({
|
||||
cwd: '/tmp/project',
|
||||
providerId: 'opencode',
|
||||
selectedModelIds: ['opencode/minimax-m2.5-free', 'opencode/ling-2.6-flash-free'],
|
||||
prepareProvisioning,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('notes');
|
||||
expect(result.details).toEqual([
|
||||
projectEvidenceNote,
|
||||
'minimax-m2.5-free - verified',
|
||||
'ling-2.6-flash-free - verified',
|
||||
]);
|
||||
expect(result.details.filter((detail) => detail === projectEvidenceNote)).toHaveLength(1);
|
||||
expect(result.warnings).toEqual([projectEvidenceNote]);
|
||||
expect(result.modelResultsById).toEqual({
|
||||
'opencode/minimax-m2.5-free': {
|
||||
status: 'ready',
|
||||
line: 'minimax-m2.5-free - verified',
|
||||
warningLine: null,
|
||||
},
|
||||
'opencode/ling-2.6-flash-free': {
|
||||
status: 'ready',
|
||||
line: 'ling-2.6-flash-free - verified',
|
||||
warningLine: null,
|
||||
},
|
||||
});
|
||||
expect(prepareProvisioning).toHaveBeenCalledTimes(1);
|
||||
expect(prepareProvisioning).toHaveBeenCalledWith(
|
||||
'/tmp/project',
|
||||
'opencode',
|
||||
['opencode'],
|
||||
['opencode/minimax-m2.5-free', 'opencode/ling-2.6-flash-free'],
|
||||
undefined,
|
||||
'compatibility'
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes raw Codex API error envelopes into a clean model reason', async () => {
|
||||
const prepareProvisioning = vi.fn<
|
||||
(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React, { act } from 'react';
|
|||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
vi.mock('@renderer/components/ui/badge', () => ({
|
||||
Badge: ({
|
||||
|
|
@ -56,6 +56,33 @@ const currentTask: TeamTaskWithKanban = {
|
|||
status: 'in_progress',
|
||||
} as unknown as TeamTaskWithKanban;
|
||||
|
||||
const failedSpawnEntry: MemberSpawnStatusEntry = {
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'spawn failed',
|
||||
agentToolAccepted: false,
|
||||
livenessKind: 'not_found',
|
||||
runtimeDiagnostic: 'spawn failed',
|
||||
runtimeDiagnosticSeverity: 'error',
|
||||
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||
};
|
||||
|
||||
const skippedSpawnEntry: MemberSpawnStatusEntry = {
|
||||
status: 'skipped',
|
||||
launchState: 'skipped_for_launch',
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
agentToolAccepted: false,
|
||||
skippedForLaunch: true,
|
||||
skipReason: 'Skipped by user after launch failure: spawn failed',
|
||||
skippedAt: '2026-04-24T12:01:00.000Z',
|
||||
updatedAt: '2026-04-24T12:01:00.000Z',
|
||||
};
|
||||
|
||||
describe('MemberCard starting-state visuals', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
|
|
@ -582,4 +609,309 @@ describe('MemberCard starting-state visuals', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders retry for failed teammate launches', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberCard, {
|
||||
member,
|
||||
memberColor: 'blue',
|
||||
isTeamAlive: true,
|
||||
isTeamProvisioning: false,
|
||||
spawnStatus: 'error',
|
||||
spawnLaunchState: 'failed_to_start',
|
||||
spawnRuntimeAlive: false,
|
||||
spawnError: 'spawn failed',
|
||||
spawnEntry: failedSpawnEntry,
|
||||
onRestartMember: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[aria-label="Retry teammate"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders skip for failed teammate launches', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberCard, {
|
||||
member,
|
||||
memberColor: 'blue',
|
||||
isTeamAlive: true,
|
||||
isTeamProvisioning: false,
|
||||
spawnStatus: 'error',
|
||||
spawnLaunchState: 'failed_to_start',
|
||||
spawnRuntimeAlive: false,
|
||||
spawnError: 'spawn failed',
|
||||
spawnEntry: failedSpawnEntry,
|
||||
onSkipMemberForLaunch: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[aria-label="Skip for this launch"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('retries failed teammate launches without opening the member row', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const onClick = vi.fn();
|
||||
let resolveRetry!: () => void;
|
||||
const retryPromise = new Promise<void>((resolve) => {
|
||||
resolveRetry = resolve;
|
||||
});
|
||||
const onRestartMember = vi.fn(() => retryPromise);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberCard, {
|
||||
member,
|
||||
memberColor: 'blue',
|
||||
isTeamAlive: true,
|
||||
isTeamProvisioning: false,
|
||||
spawnStatus: 'error',
|
||||
spawnLaunchState: 'failed_to_start',
|
||||
spawnRuntimeAlive: false,
|
||||
spawnError: 'spawn failed',
|
||||
spawnEntry: failedSpawnEntry,
|
||||
onClick,
|
||||
onRestartMember,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const button = host.querySelector('[aria-label="Retry teammate"]') as HTMLButtonElement;
|
||||
expect(button).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
button.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onRestartMember).toHaveBeenCalledWith('alice');
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
expect(host.querySelector('[aria-label="Retrying teammate"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
resolveRetry();
|
||||
await retryPromise;
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[aria-label="Retry teammate"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('skips failed teammate launches without opening the member row', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const onClick = vi.fn();
|
||||
let resolveSkip!: () => void;
|
||||
const skipPromise = new Promise<void>((resolve) => {
|
||||
resolveSkip = resolve;
|
||||
});
|
||||
const onSkipMemberForLaunch = vi.fn(() => skipPromise);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberCard, {
|
||||
member,
|
||||
memberColor: 'blue',
|
||||
isTeamAlive: true,
|
||||
isTeamProvisioning: false,
|
||||
spawnStatus: 'error',
|
||||
spawnLaunchState: 'failed_to_start',
|
||||
spawnRuntimeAlive: false,
|
||||
spawnError: 'spawn failed',
|
||||
spawnEntry: failedSpawnEntry,
|
||||
onClick,
|
||||
onSkipMemberForLaunch,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const button = host.querySelector('[aria-label="Skip for this launch"]') as HTMLButtonElement;
|
||||
expect(button).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
button.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onSkipMemberForLaunch).toHaveBeenCalledWith('alice');
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
expect(host.querySelector('[aria-label="Skipping teammate"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
resolveSkip();
|
||||
await skipPromise;
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[aria-label="Skip for this launch"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps retry available and exposes retry errors after rejection', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const onRestartMember = vi.fn(async () => {
|
||||
throw new Error('restart failed');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberCard, {
|
||||
member,
|
||||
memberColor: 'blue',
|
||||
isTeamAlive: true,
|
||||
isTeamProvisioning: false,
|
||||
spawnStatus: 'error',
|
||||
spawnLaunchState: 'failed_to_start',
|
||||
spawnRuntimeAlive: false,
|
||||
spawnError: 'spawn failed',
|
||||
spawnEntry: failedSpawnEntry,
|
||||
onRestartMember,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const button = host.querySelector('[aria-label="Retry teammate"]') as HTMLButtonElement;
|
||||
expect(button).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
button.click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onRestartMember).toHaveBeenCalledWith('alice');
|
||||
expect(host.querySelector('[aria-label="Retry teammate"]')).not.toBeNull();
|
||||
expect(host.textContent).toContain('restart failed');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps skip available and exposes skip errors after rejection', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const onSkipMemberForLaunch = vi.fn(async () => {
|
||||
throw new Error('skip failed');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberCard, {
|
||||
member,
|
||||
memberColor: 'blue',
|
||||
isTeamAlive: true,
|
||||
isTeamProvisioning: false,
|
||||
spawnStatus: 'error',
|
||||
spawnLaunchState: 'failed_to_start',
|
||||
spawnRuntimeAlive: false,
|
||||
spawnError: 'spawn failed',
|
||||
spawnEntry: failedSpawnEntry,
|
||||
onSkipMemberForLaunch,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const button = host.querySelector('[aria-label="Skip for this launch"]') as HTMLButtonElement;
|
||||
expect(button).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
button.click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onSkipMemberForLaunch).toHaveBeenCalledWith('alice');
|
||||
expect(host.querySelector('[aria-label="Skip for this launch"]')).not.toBeNull();
|
||||
expect(host.textContent).toContain('skip failed');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows skipped teammates as skipped and keeps retry available', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberCard, {
|
||||
member,
|
||||
memberColor: 'blue',
|
||||
isTeamAlive: true,
|
||||
isTeamProvisioning: false,
|
||||
spawnStatus: 'skipped',
|
||||
spawnLaunchState: 'skipped_for_launch',
|
||||
spawnRuntimeAlive: false,
|
||||
spawnEntry: skippedSpawnEntry,
|
||||
onRestartMember: vi.fn(),
|
||||
onSkipMemberForLaunch: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('skipped');
|
||||
expect(host.textContent).toContain('Skipped by user after launch failure');
|
||||
expect(host.querySelector('[aria-label="Retry teammate"]')).not.toBeNull();
|
||||
expect(host.querySelector('[aria-label="Skip for this launch"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,10 +8,45 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({
|
|||
MemberCard: ({
|
||||
member,
|
||||
spawnError,
|
||||
spawnStatus,
|
||||
spawnLaunchState,
|
||||
onRestartMember,
|
||||
onSkipMemberForLaunch,
|
||||
}: {
|
||||
member: ResolvedTeamMember;
|
||||
spawnError?: string;
|
||||
}) => React.createElement('div', { 'data-testid': `member-${member.name}` }, spawnError ?? ''),
|
||||
spawnStatus?: string;
|
||||
spawnLaunchState?: string;
|
||||
onRestartMember?: (memberName: string) => void;
|
||||
onSkipMemberForLaunch?: (memberName: string) => void;
|
||||
}) =>
|
||||
React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': `member-${member.name}` },
|
||||
spawnError ?? '',
|
||||
onRestartMember && (spawnStatus === 'error' || spawnLaunchState === 'failed_to_start')
|
||||
? React.createElement(
|
||||
'button',
|
||||
{
|
||||
'data-testid': `retry-${member.name}`,
|
||||
type: 'button',
|
||||
onClick: () => onRestartMember(member.name),
|
||||
},
|
||||
'retry'
|
||||
)
|
||||
: null,
|
||||
onSkipMemberForLaunch && (spawnStatus === 'error' || spawnLaunchState === 'failed_to_start')
|
||||
? React.createElement(
|
||||
'button',
|
||||
{
|
||||
'data-testid': `skip-${member.name}`,
|
||||
type: 'button',
|
||||
onClick: () => onSkipMemberForLaunch(member.name),
|
||||
},
|
||||
'skip'
|
||||
)
|
||||
: null
|
||||
),
|
||||
}));
|
||||
|
||||
import { MemberList } from '@renderer/components/team/members/MemberList';
|
||||
|
|
@ -98,4 +133,126 @@ describe('MemberList spawn-status memoization', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('passes retry callbacks to failed member cards and rerenders when the callback changes', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const members = [member];
|
||||
const firstRestart = vi.fn();
|
||||
const secondRestart = vi.fn();
|
||||
const spawnStatuses = new Map([['bob', failedSpawnStatus('OpenCode failed')]]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberList, {
|
||||
members,
|
||||
isTeamAlive: true,
|
||||
memberSpawnStatuses: spawnStatuses,
|
||||
onRestartMember: firstRestart,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const firstRetry = host.querySelector('[data-testid="retry-bob"]') as HTMLButtonElement;
|
||||
expect(firstRetry).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
firstRetry.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(firstRestart).toHaveBeenCalledWith('bob');
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberList, {
|
||||
members,
|
||||
isTeamAlive: true,
|
||||
memberSpawnStatuses: spawnStatuses,
|
||||
onRestartMember: secondRestart,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const secondRetry = host.querySelector('[data-testid="retry-bob"]') as HTMLButtonElement;
|
||||
expect(secondRetry).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
secondRetry.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(secondRestart).toHaveBeenCalledWith('bob');
|
||||
expect(firstRestart).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('passes skip callbacks to failed member cards and rerenders when the callback changes', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const members = [member];
|
||||
const firstSkip = vi.fn();
|
||||
const secondSkip = vi.fn();
|
||||
const spawnStatuses = new Map([['bob', failedSpawnStatus('OpenCode failed')]]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberList, {
|
||||
members,
|
||||
isTeamAlive: true,
|
||||
memberSpawnStatuses: spawnStatuses,
|
||||
onSkipMemberForLaunch: firstSkip,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const firstButton = host.querySelector('[data-testid="skip-bob"]') as HTMLButtonElement;
|
||||
expect(firstButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
firstButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(firstSkip).toHaveBeenCalledWith('bob');
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberList, {
|
||||
members,
|
||||
isTeamAlive: true,
|
||||
memberSpawnStatuses: spawnStatuses,
|
||||
onSkipMemberForLaunch: secondSkip,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const secondButton = host.querySelector('[data-testid="skip-bob"]') as HTMLButtonElement;
|
||||
expect(secondButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
secondButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(secondSkip).toHaveBeenCalledWith('bob');
|
||||
expect(firstSkip).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -104,4 +104,25 @@ describe('getLaunchJoinMilestonesFromMembers', () => {
|
|||
expect(milestones.processOnlyAliveCount).toBe(0);
|
||||
expect(milestones.pendingSpawnCount).toBe(4);
|
||||
});
|
||||
|
||||
it('counts skipped teammates separately from pending and failed launch members', () => {
|
||||
const milestones = getLaunchJoinMilestonesFromMembers({
|
||||
members,
|
||||
memberSpawnStatuses: {
|
||||
alice: {
|
||||
status: 'skipped',
|
||||
launchState: 'skipped_for_launch',
|
||||
skippedForLaunch: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(milestones.skippedSpawnCount).toBe(1);
|
||||
expect(milestones.failedSpawnCount).toBe(0);
|
||||
expect(milestones.pendingSpawnCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const hoisted = vi.hoisted(() => ({
|
|||
permanentlyDeleteTeam: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
restartMember: vi.fn(),
|
||||
skipMemberForLaunch: vi.fn(),
|
||||
requestReview: vi.fn(),
|
||||
updateKanban: vi.fn(),
|
||||
invalidateTaskChangeSummaries: vi.fn(),
|
||||
|
|
@ -53,6 +54,7 @@ vi.mock('@renderer/api', () => ({
|
|||
permanentlyDeleteTeam: hoisted.permanentlyDeleteTeam,
|
||||
sendMessage: hoisted.sendMessage,
|
||||
restartMember: hoisted.restartMember,
|
||||
skipMemberForLaunch: hoisted.skipMemberForLaunch,
|
||||
requestReview: hoisted.requestReview,
|
||||
updateKanban: hoisted.updateKanban,
|
||||
onProvisioningProgress: hoisted.onProvisioningProgress,
|
||||
|
|
@ -233,6 +235,7 @@ describe('teamSlice actions', () => {
|
|||
hoisted.restoreTeam.mockResolvedValue(undefined);
|
||||
hoisted.permanentlyDeleteTeam.mockResolvedValue(undefined);
|
||||
hoisted.restartMember.mockResolvedValue(undefined);
|
||||
hoisted.skipMemberForLaunch.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('maps inbox verify failure to user-friendly text', async () => {
|
||||
|
|
@ -2537,6 +2540,57 @@ describe('teamSlice actions', () => {
|
|||
expect(refreshRuntimeSnapshot).toHaveBeenCalledWith('my-team');
|
||||
});
|
||||
|
||||
it('skipMemberForLaunch refreshes spawn statuses, runtime snapshot, and team list', async () => {
|
||||
const store = createSliceStore();
|
||||
const refreshTeams = vi.fn(async () => undefined);
|
||||
store.setState({ fetchTeams: refreshTeams });
|
||||
hoisted.getMemberSpawnStatuses.mockResolvedValue({
|
||||
statuses: {
|
||||
alice: createMemberSpawnStatus({
|
||||
status: 'skipped',
|
||||
launchState: 'skipped_for_launch',
|
||||
skippedForLaunch: true,
|
||||
}),
|
||||
},
|
||||
runId: 'runtime-run',
|
||||
});
|
||||
hoisted.getTeamAgentRuntime.mockResolvedValue(createRuntimeSnapshot());
|
||||
|
||||
await store.getState().skipMemberForLaunch('my-team', 'alice');
|
||||
|
||||
expect(hoisted.skipMemberForLaunch).toHaveBeenCalledWith('my-team', 'alice');
|
||||
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toEqual({
|
||||
alice: expect.objectContaining({
|
||||
status: 'skipped',
|
||||
launchState: 'skipped_for_launch',
|
||||
skippedForLaunch: true,
|
||||
}),
|
||||
});
|
||||
expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(createRuntimeSnapshot());
|
||||
expect(refreshTeams).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skipMemberForLaunch refreshes launch data even when skip fails', async () => {
|
||||
const store = createSliceStore();
|
||||
const refreshSpawnStatuses = vi.fn(async (_teamName: string) => undefined);
|
||||
const refreshRuntimeSnapshot = vi.fn(async (_teamName: string) => undefined);
|
||||
const refreshTeams = vi.fn(async () => undefined);
|
||||
store.setState({
|
||||
fetchMemberSpawnStatuses: refreshSpawnStatuses,
|
||||
fetchTeamAgentRuntime: refreshRuntimeSnapshot,
|
||||
fetchTeams: refreshTeams,
|
||||
});
|
||||
hoisted.skipMemberForLaunch.mockRejectedValueOnce(new Error('skip failed'));
|
||||
|
||||
await expect(store.getState().skipMemberForLaunch('my-team', 'alice')).rejects.toThrow(
|
||||
'skip failed'
|
||||
);
|
||||
|
||||
expect(refreshSpawnStatuses).toHaveBeenCalledWith('my-team');
|
||||
expect(refreshRuntimeSnapshot).toHaveBeenCalledWith('my-team');
|
||||
expect(refreshTeams).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears stale runtime snapshots on delete', async () => {
|
||||
const store = createSliceStore();
|
||||
store.setState({
|
||||
|
|
|
|||
|
|
@ -293,6 +293,75 @@ describe('buildTeamProvisioningPresentation', () => {
|
|||
expect(presentation?.compactTone).toBe('warning');
|
||||
});
|
||||
|
||||
it('shows skipped teammates as a continued launch instead of still joining', () => {
|
||||
const presentation = buildTeamProvisioningPresentation({
|
||||
progress: {
|
||||
runId: 'run-3d',
|
||||
teamName: 'mixed-team',
|
||||
state: 'ready',
|
||||
startedAt: '2026-04-13T10:00:00.000Z',
|
||||
updatedAt: '2026-04-13T10:00:08.000Z',
|
||||
message: 'Launch completed',
|
||||
messageSeverity: undefined,
|
||||
pid: 4321,
|
||||
configReady: true,
|
||||
cliLogsTail: '',
|
||||
assistantOutput: '',
|
||||
},
|
||||
members: [
|
||||
{
|
||||
name: 'team-lead',
|
||||
agentType: 'team-lead',
|
||||
status: 'active',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
lastActiveAt: null,
|
||||
messageCount: 0,
|
||||
},
|
||||
{
|
||||
name: 'bob',
|
||||
agentType: 'developer',
|
||||
status: 'unknown',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
lastActiveAt: null,
|
||||
messageCount: 0,
|
||||
},
|
||||
],
|
||||
memberSpawnStatuses: {
|
||||
bob: {
|
||||
status: 'skipped',
|
||||
launchState: 'skipped_for_launch',
|
||||
updatedAt: '2026-04-13T10:00:07.000Z',
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
agentToolAccepted: false,
|
||||
skippedForLaunch: true,
|
||||
skipReason: 'Skipped by user after launch failure: OpenCode lane failed',
|
||||
},
|
||||
},
|
||||
memberSpawnSnapshot: {
|
||||
expectedMembers: ['bob'],
|
||||
summary: {
|
||||
confirmedCount: 0,
|
||||
pendingCount: 0,
|
||||
failedCount: 0,
|
||||
skippedCount: 1,
|
||||
runtimeAlivePendingCount: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(presentation?.successMessage).toBe('Launch continued - 1/1 teammates skipped');
|
||||
expect(presentation?.panelMessage).toContain('bob skipped for this launch');
|
||||
expect(presentation?.compactTitle).toBe('Launch continued with skipped teammates');
|
||||
expect(presentation?.compactDetail).toBe('bob skipped');
|
||||
expect(presentation?.compactTone).toBe('warning');
|
||||
expect(presentation?.currentStepIndex).toBe(2);
|
||||
expect(presentation?.hasMembersStillJoining).toBe(false);
|
||||
});
|
||||
|
||||
it('prefers live member spawn statuses over a stale persisted launch summary', () => {
|
||||
const presentation = buildTeamProvisioningPresentation({
|
||||
progress: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue