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
|
- [ ] Run terminal commands
|
||||||
- [ ] Monitor agents processes/stats
|
- [ ] Monitor agents processes/stats
|
||||||
- [ ] Reusable agents with SOUL.md
|
- [ ] Reusable agents with SOUL.md
|
||||||
|
- [ ] Сommunicate via messenger
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@
|
||||||
- Runtime tools принимают `metadata`, но `recordOpenCodeRuntimeBootstrapCheckin()` и `recordOpenCodeRuntimeHeartbeat()` сейчас используют только `diagnostics`. Если runtime присылает PID/version/command в `metadata`, эта информация теряется.
|
- Runtime tools принимают `metadata`, но `recordOpenCodeRuntimeBootstrapCheckin()` и `recordOpenCodeRuntimeHeartbeat()` сейчас используют только `diagnostics`. Если runtime присылает PID/version/command в `metadata`, эта информация теряется.
|
||||||
- `handleMemberSpawnToolResult()` раньше при reason `already_running` делал `setMemberSpawnStatus(..., "online", ..., "process")`. В strict model это заменено на `waiting` + runtime re-evaluation.
|
- `handleMemberSpawnToolResult()` раньше при reason `already_running` делал `setMemberSpawnStatus(..., "online", ..., "process")`. В strict model это заменено на `waiting` + runtime re-evaluation.
|
||||||
- `waitForTmuxPanesToExit()` использует `listTmuxPanePidsForCurrentPlatform()` только как "pane exists" check. Поэтому старый `listPanePids()` wrapper должен остаться ровно pane-existence helper, а не получить новую liveness-семантику.
|
- `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.
|
- `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.
|
- `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.
|
- `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 |
|
| Area | Strict-only behavior |
|
||||||
| ------------------------------ | ---------------------------------------- |
|
| ------------------------------ | ------------------------------------- |
|
||||||
| `livenessKind` | always filled when evidence exists |
|
| `livenessKind` | always filled when evidence exists |
|
||||||
| UI labels | enabled |
|
| UI labels | enabled |
|
||||||
| `runtimeAlive` from shell-only | always false |
|
| `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.
|
- 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.
|
- `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`.
|
- 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.
|
- `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.
|
- 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.
|
||||||
- `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.
|
|
||||||
- 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.
|
- 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.
|
- `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.
|
- `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/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/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/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/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/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/OpenCodeTaskLogStreamSource.ts`
|
||||||
- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.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:
|
`agent_teams_orchestrator` files:
|
||||||
|
|
||||||
|
|
@ -222,7 +217,9 @@ Example:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
function normalizeRuntimeProvider(value) {
|
function normalizeRuntimeProvider(value) {
|
||||||
const normalized = String(value || '').trim().toLowerCase();
|
const normalized = String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
return normalized === 'opencode' ? 'opencode' : 'native';
|
return normalized === 'opencode' ? 'opencode' : 'native';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -267,7 +264,9 @@ function createMemberMessagingProtocol(runtimeProvider) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function isOpenCodeMember(member) {
|
function isOpenCodeMember(member) {
|
||||||
const provider = String(member?.providerId || member?.provider || '').trim().toLowerCase();
|
const provider = String(member?.providerId || member?.provider || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
return provider === 'opencode';
|
return provider === 'opencode';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -302,9 +301,7 @@ Edit pattern:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
function copyTrimmedString(member, key) {
|
function copyTrimmedString(member, key) {
|
||||||
return typeof member[key] === 'string' && member[key].trim()
|
return typeof member[key] === 'string' && member[key].trim() ? { [key]: member[key].trim() } : {};
|
||||||
? { [key]: member[key].trim() }
|
|
||||||
: {};
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -314,9 +311,15 @@ Then preserve fields:
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
...(typeof member.role === 'string' && member.role.trim() ? { role: member.role.trim() } : {}),
|
...(typeof member.role === 'string' && member.role.trim() ? { role: member.role.trim() } : {}),
|
||||||
...(typeof member.workflow === 'string' && member.workflow.trim() ? { workflow: member.workflow.trim() } : {}),
|
...(typeof member.workflow === 'string' && member.workflow.trim()
|
||||||
...(typeof member.agentType === 'string' && member.agentType.trim() ? { agentType: member.agentType.trim() } : {}),
|
? { workflow: member.workflow.trim() }
|
||||||
...(typeof member.color === 'string' && member.color.trim() ? { color: member.color.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() } : {}),
|
...(typeof member.cwd === 'string' && member.cwd.trim() ? { cwd: member.cwd.trim() } : {}),
|
||||||
...copyTrimmedString(member, 'providerId'),
|
...copyTrimmedString(member, 'providerId'),
|
||||||
...copyTrimmedString(member, 'providerBackendId'),
|
...copyTrimmedString(member, 'providerBackendId'),
|
||||||
|
|
@ -398,7 +401,8 @@ Inside the function:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const explicitRuntimeProvider = options.runtimeProvider;
|
const explicitRuntimeProvider = options.runtimeProvider;
|
||||||
const inferredRuntimeProvider = explicitRuntimeProvider || (isOpenCodeMember(effectiveMember) ? 'opencode' : 'native');
|
const inferredRuntimeProvider =
|
||||||
|
explicitRuntimeProvider || (isOpenCodeMember(effectiveMember) ? 'opencode' : 'native');
|
||||||
const messagingProtocol = createMemberMessagingProtocol(inferredRuntimeProvider);
|
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:
|
Instead, make tool descriptions and OpenCode prompts explicitly route normal replies:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
description:
|
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.';
|
||||||
'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
|
```ts
|
||||||
description:
|
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.';
|
||||||
'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:
|
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.');
|
throw new Error('message_send cannot target another team. Use cross_team_send with toTeam.');
|
||||||
}
|
}
|
||||||
if (!resolvedTo && runtimeHelpers.looksLikeCrossTeamToolRecipient?.(rawTo)) {
|
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) {
|
if (!resolvedTo) {
|
||||||
throw new Error(`Unknown to: ${rawTo}. Use a configured team member name.`);
|
throw new Error(`Unknown to: ${rawTo}. Use a configured team member name.`);
|
||||||
|
|
@ -903,7 +907,7 @@ Current risk:
|
||||||
`TeamProvisioningService.captureSendMessages()` recognizes only:
|
`TeamProvisioningService.captureSendMessages()` recognizes only:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
part.name === 'mcp__agent-teams__message_send'
|
part.name === 'mcp__agent-teams__message_send';
|
||||||
```
|
```
|
||||||
|
|
||||||
But OpenCode and MCP tooling can expose names as:
|
But OpenCode and MCP tooling can expose names as:
|
||||||
|
|
@ -957,8 +961,7 @@ export function isAgentTeamsToolUse(input: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasKnownPrefix =
|
const hasKnownPrefix =
|
||||||
rawName !== canonical ||
|
rawName !== canonical || AGENT_TEAMS_PREFIXES.some((prefix) => rawName.startsWith(prefix));
|
||||||
AGENT_TEAMS_PREFIXES.some((prefix) => rawName.startsWith(prefix));
|
|
||||||
if (hasKnownPrefix) {
|
if (hasKnownPrefix) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -1078,23 +1081,23 @@ Add a helper:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
function buildOpenCodeRuntimeIdentityBlock(input: {
|
function buildOpenCodeRuntimeIdentityBlock(input: {
|
||||||
teamName: string
|
teamName: string;
|
||||||
memberName: string
|
memberName: string;
|
||||||
runId: string
|
runId: string;
|
||||||
runtimeSessionId: string
|
runtimeSessionId: string;
|
||||||
}): string {
|
}): string {
|
||||||
const checkinPayload = {
|
const checkinPayload = {
|
||||||
teamName: input.teamName,
|
teamName: input.teamName,
|
||||||
runId: input.runId,
|
runId: input.runId,
|
||||||
memberName: input.memberName,
|
memberName: input.memberName,
|
||||||
runtimeSessionId: input.runtimeSessionId,
|
runtimeSessionId: input.runtimeSessionId,
|
||||||
}
|
};
|
||||||
|
|
||||||
const briefingPayload = {
|
const briefingPayload = {
|
||||||
teamName: input.teamName,
|
teamName: input.teamName,
|
||||||
memberName: input.memberName,
|
memberName: input.memberName,
|
||||||
runtimeProvider: 'opencode',
|
runtimeProvider: 'opencode',
|
||||||
}
|
};
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'<opencode_runtime_identity>',
|
'<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)}`,
|
`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.',
|
'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>',
|
'</opencode_runtime_identity>',
|
||||||
].join('\n')
|
].join('\n');
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -1117,12 +1120,12 @@ const runtimeIdentityBlock = buildOpenCodeRuntimeIdentityBlock({
|
||||||
memberName: name,
|
memberName: name,
|
||||||
runId,
|
runId,
|
||||||
runtimeSessionId: record.opencodeSessionId,
|
runtimeSessionId: record.opencodeSessionId,
|
||||||
})
|
});
|
||||||
|
|
||||||
await openCodeSessionBridge.promptAsync(record, {
|
await openCodeSessionBridge.promptAsync(record, {
|
||||||
text: `${runtimeIdentityBlock}\n\n${prompt}`,
|
text: `${runtimeIdentityBlock}\n\n${prompt}`,
|
||||||
agent: 'teammate',
|
agent: 'teammate',
|
||||||
})
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
Then add a bounded launch-settle helper before mapping the member as final `created`/`confirmed_alive`.
|
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>',
|
'</opencode_app_message_delivery>',
|
||||||
'',
|
'',
|
||||||
input.text,
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
Update call sites so pending-reply state reflects actual delivery truth:
|
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_deliver_message',
|
||||||
'runtime_task_event',
|
'runtime_task_event',
|
||||||
'runtime_heartbeat',
|
'runtime_heartbeat',
|
||||||
] as const
|
] as const;
|
||||||
```
|
```
|
||||||
|
|
||||||
Change to route-specific direct MCP names.
|
Change to route-specific direct MCP names.
|
||||||
|
|
@ -1913,10 +1918,10 @@ Important:
|
||||||
|
|
||||||
- `Client.listTools()` returns plain names such as `message_send`.
|
- `Client.listTools()` returns plain names such as `message_send`.
|
||||||
- Do not prefix direct stdio results with `agent-teams_`.
|
- 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')`.
|
- `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.
|
- 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
|
```ts
|
||||||
const REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS = [
|
const REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS = [
|
||||||
|
|
@ -1924,7 +1929,7 @@ const REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS = [
|
||||||
'runtime_deliver_message',
|
'runtime_deliver_message',
|
||||||
'runtime_task_event',
|
'runtime_task_event',
|
||||||
'runtime_heartbeat',
|
'runtime_heartbeat',
|
||||||
] as const
|
] as const;
|
||||||
|
|
||||||
const REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS = [
|
const REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS = [
|
||||||
'member_briefing',
|
'member_briefing',
|
||||||
|
|
@ -1956,20 +1961,20 @@ const REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS = [
|
||||||
'cross_team_send',
|
'cross_team_send',
|
||||||
'cross_team_list_targets',
|
'cross_team_list_targets',
|
||||||
'cross_team_get_outbox',
|
'cross_team_get_outbox',
|
||||||
] as const
|
] as const;
|
||||||
|
|
||||||
const REQUIRED_AGENT_TEAMS_DIRECT_MCP_TOOL_NAMES = [
|
const REQUIRED_AGENT_TEAMS_DIRECT_MCP_TOOL_NAMES = [
|
||||||
...REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS,
|
...REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS,
|
||||||
...REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS,
|
...REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS,
|
||||||
] as const
|
] as const;
|
||||||
```
|
```
|
||||||
|
|
||||||
Update direct listTools mapping:
|
Update direct listTools mapping:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
return (result.tools ?? [])
|
return (result.tools ?? [])
|
||||||
.map(tool => tool.name)
|
.map((tool) => tool.name)
|
||||||
.filter((name): name is string => typeof name === 'string' && name.trim().length > 0)
|
.filter((name): name is string => typeof name === 'string' && name.trim().length > 0);
|
||||||
```
|
```
|
||||||
|
|
||||||
Compare plain names internally:
|
Compare plain names internally:
|
||||||
|
|
@ -1988,14 +1993,14 @@ But emit canonical ids for bridge readiness/evidence:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
function buildOpenCodeCanonicalMcpToolId(toolName: string): string {
|
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 {
|
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(
|
const missingTools = REQUIRED_AGENT_TEAMS_DIRECT_MCP_TOOL_NAMES.filter(
|
||||||
tool => !observedDirect.has(tool)
|
(tool) => !observedDirect.has(tool)
|
||||||
)
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: missingTools.length === 0,
|
ok: missingTools.length === 0,
|
||||||
|
|
@ -2004,11 +2009,12 @@ function matchAppMcpTools(observedDirectToolNames: string[], route: string): App
|
||||||
),
|
),
|
||||||
observedDirectToolNames: uniqueSortedStrings(observedDirectToolNames),
|
observedDirectToolNames: uniqueSortedStrings(observedDirectToolNames),
|
||||||
missingTools,
|
missingTools,
|
||||||
diagnostics: missingTools.length === 0
|
diagnostics:
|
||||||
|
missingTools.length === 0
|
||||||
? []
|
? []
|
||||||
: [`OpenCode app MCP tools missing from ${route}: ${missingTools.join(', ')}`],
|
: [`OpenCode app MCP tools missing from ${route}: ${missingTools.join(', ')}`],
|
||||||
route,
|
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 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`.
|
- 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:
|
Files:
|
||||||
|
|
||||||
```text
|
```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/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/mcp/OpenCodeMcpToolAvailability.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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Current risk:
|
Current rule:
|
||||||
|
|
||||||
```ts
|
- OpenCode launch readiness should not require a project-scoped proof artifact.
|
||||||
requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
|
- App tool proof belongs in the live readiness path through capability, runtime-store, MCP tool, and execution checks.
|
||||||
buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
|
- If tool requirements change, update `OpenCodeMcpToolAvailability` and readiness tests directly.
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
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`.
|
|
||||||
|
|
||||||
Acceptance:
|
Acceptance:
|
||||||
|
|
||||||
- Production evidence must include canonical ids for runtime and teammate-operational tools.
|
- `message_send`, `member_briefing`, `task_start`, and `cross_team_send` are covered by readiness tests.
|
||||||
- `message_send`, `member_briefing`, `task_start`, and `cross_team_send` are explicitly covered by tests.
|
- Missing app MCP tools fails readiness directly with a clear diagnostic.
|
||||||
- Old runtime-only evidence fails production mode with a clear diagnostic listing missing app MCP tools.
|
- No project-specific artifact is required to create or launch a team.
|
||||||
- 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.
|
|
||||||
|
|
||||||
### Step 13 - Resolve secondary lane current-run evidence from lane manifest
|
### 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:
|
Change to async durable resolution:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const currentRunId = await this.resolveDurableOpenCodeRuntimeRunId(
|
const currentRunId = await this.resolveDurableOpenCodeRuntimeRunId(input.teamName, input.laneId);
|
||||||
input.teamName,
|
|
||||||
input.laneId
|
|
||||||
);
|
|
||||||
|
|
||||||
await store.assertEvidenceAccepted({
|
await store.assertEvidenceAccepted({
|
||||||
teamName: input.teamName,
|
teamName: input.teamName,
|
||||||
|
|
@ -2384,7 +2317,7 @@ emit: (event) => {
|
||||||
teamName: event.teamName,
|
teamName: event.teamName,
|
||||||
detail: typeof event.data?.detail === 'string' ? event.data.detail : undefined,
|
detail: typeof event.data?.detail === 'string' ? event.data.detail : undefined,
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
Reason:
|
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.
|
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
|
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
|
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.
|
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' }],
|
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' }]);
|
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',
|
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({
|
expect(rows.at(-1)).toMatchObject({
|
||||||
from: 'user',
|
from: 'user',
|
||||||
to: 'alice',
|
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', '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',
|
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' });
|
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',
|
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(
|
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' }]);
|
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
|
```ts
|
||||||
it('uses full app tool ids for OpenCode production E2E gate expectations', async () => {
|
it('uses full app tool ids for OpenCode readiness expectations', async () => {
|
||||||
const evidence = buildEvidence({
|
|
||||||
observedTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await bridge.runReadiness({
|
const result = await bridge.runReadiness({
|
||||||
launchMode: 'production',
|
|
||||||
selectedModel: 'minimax-m2.5-free',
|
selectedModel: 'minimax-m2.5-free',
|
||||||
// ...
|
// ...
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.supportLevel).toBe('production_supported');
|
expect(result.supportLevel).toBe('production_supported');
|
||||||
expect(productionE2eEvidence.read).toHaveBeenCalled();
|
expect(result.evidence.observedMcpTools).toEqual(
|
||||||
});
|
expect.arrayContaining(REQUIRED_AGENT_TEAMS_APP_TOOL_IDS)
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
it('rejects stale runtime-only OpenCode production evidence', async () => {
|
|
||||||
const runtimeOnlyToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS.map((tool) =>
|
|
||||||
buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
|
|
||||||
);
|
);
|
||||||
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
|
cd /Users/belief/dev/projects/claude/claude_team
|
||||||
pnpm --filter agent-teams-controller test -- test/controller.test.js
|
pnpm --filter agent-teams-controller test -- test/controller.test.js
|
||||||
pnpm --filter agent-teams-mcp test -- test/tools.test.ts
|
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
|
```bash
|
||||||
|
|
@ -3499,7 +3398,7 @@ Avoid heavy E2E until targeted tests pass.
|
||||||
19. Add OpenCode-targeted inbox runtime relay with dedupe/read marking.
|
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.
|
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.
|
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.
|
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.
|
24. Add runtime delivery `TeamChangeEvent.detail` adapter guard tests.
|
||||||
25. Add 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 `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.
|
- 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.
|
- 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.
|
- Readiness passes with runtime-only app tool coverage. This means `OpenCodeMcpToolAvailability` still uses only runtime tools instead of the full app tool id list.
|
||||||
- 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.
|
|
||||||
- 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.
|
- 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.
|
- 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.
|
- 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 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 cross-team instructions consistently use `agent-teams_cross_team_send`, not `message_send`.
|
||||||
- OpenCode readiness fails if required app MCP tools are absent.
|
- 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.
|
- 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.
|
- 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`.
|
- OpenCode can prove liveness through `runtime_bootstrap_checkin`.
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@
|
||||||
"dev": "node ./scripts/dev-with-runtime.mjs",
|
"dev": "node ./scripts/dev-with-runtime.mjs",
|
||||||
"dev:web": "node ./scripts/dev-web.mjs",
|
"dev:web": "node ./scripts/dev-web.mjs",
|
||||||
"dev:kill": "node bin/kill-dev.js",
|
"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-mixed-recovery": "node ./scripts/prove-opencode-mixed-recovery.mjs",
|
||||||
"opencode:prove-team-provisioning": "node ./scripts/prove-opencode-team-provisioning.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",
|
"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'
|
| 'registered_only'
|
||||||
| 'stale_runtime'
|
| 'stale_runtime'
|
||||||
| 'settling'
|
| 'settling'
|
||||||
| 'error';
|
| 'error'
|
||||||
|
| 'skipped';
|
||||||
|
|
||||||
// ─── Edge & Particle Types ───────────────────────────────────────────────────
|
// ─── Edge & Particle Types ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -86,7 +87,7 @@ export interface GraphNode {
|
||||||
/** Avatar image URL (e.g., robohash) */
|
/** Avatar image URL (e.g., robohash) */
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
/** Spawn lifecycle status */
|
/** 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 */
|
/** Shared launch-stage visual derived by the host app */
|
||||||
launchVisualState?: GraphLaunchVisualState;
|
launchVisualState?: GraphLaunchVisualState;
|
||||||
/** Shared launch-stage text shown beside the node during launch only */
|
/** 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,
|
OpenCodeBridgeCommandHandshakePort,
|
||||||
} from './services/team/opencode/bridge/OpenCodeBridgeHandshakeClient';
|
} from './services/team/opencode/bridge/OpenCodeBridgeHandshakeClient';
|
||||||
import { OpenCodeStateChangingBridgeCommandService } from './services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
|
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 { OpenCodeRuntimeManifestEvidenceReader } from './services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
|
||||||
import {
|
import {
|
||||||
buildTeamControlApiBaseUrl,
|
buildTeamControlApiBaseUrl,
|
||||||
|
|
@ -273,13 +270,7 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise<TeamRuntimeAdapte
|
||||||
new OpenCodeTeamRuntimeAdapter(
|
new OpenCodeTeamRuntimeAdapter(
|
||||||
new OpenCodeReadinessBridge(bridgeClient, {
|
new OpenCodeReadinessBridge(bridgeClient, {
|
||||||
stateChangingCommands,
|
stateChangingCommands,
|
||||||
productionE2eEvidence: new OpenCodeProductionE2EEvidenceStore({
|
})
|
||||||
filePath: resolveOpenCodeProductionE2EEvidencePath({ bridgeControlDir }),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
launchMode: resolveOpenCodeTeamLaunchModeFromEnv(),
|
|
||||||
}
|
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ import {
|
||||||
TEAM_SET_TASK_LOG_STREAM_TRACKING,
|
TEAM_SET_TASK_LOG_STREAM_TRACKING,
|
||||||
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
||||||
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
||||||
|
TEAM_SKIP_MEMBER_FOR_LAUNCH,
|
||||||
TEAM_SOFT_DELETE_TASK,
|
TEAM_SOFT_DELETE_TASK,
|
||||||
TEAM_START_TASK,
|
TEAM_START_TASK,
|
||||||
TEAM_START_TASK_BY_USER,
|
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_MEMBER_SPAWN_STATUSES, handleMemberSpawnStatuses);
|
||||||
ipcMain.handle(TEAM_GET_AGENT_RUNTIME, handleGetAgentRuntime);
|
ipcMain.handle(TEAM_GET_AGENT_RUNTIME, handleGetAgentRuntime);
|
||||||
ipcMain.handle(TEAM_RESTART_MEMBER, handleRestartMember);
|
ipcMain.handle(TEAM_RESTART_MEMBER, handleRestartMember);
|
||||||
|
ipcMain.handle(TEAM_SKIP_MEMBER_FOR_LAUNCH, handleSkipMemberForLaunch);
|
||||||
ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask);
|
ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask);
|
||||||
ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask);
|
ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask);
|
||||||
ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks);
|
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_MEMBER_SPAWN_STATUSES);
|
||||||
ipcMain.removeHandler(TEAM_GET_AGENT_RUNTIME);
|
ipcMain.removeHandler(TEAM_GET_AGENT_RUNTIME);
|
||||||
ipcMain.removeHandler(TEAM_RESTART_MEMBER);
|
ipcMain.removeHandler(TEAM_RESTART_MEMBER);
|
||||||
|
ipcMain.removeHandler(TEAM_SKIP_MEMBER_FOR_LAUNCH);
|
||||||
ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK);
|
ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK);
|
||||||
ipcMain.removeHandler(TEAM_RESTORE_TASK);
|
ipcMain.removeHandler(TEAM_RESTORE_TASK);
|
||||||
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
|
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(
|
async function handleStopTeam(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
teamName: unknown
|
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
|
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. */
|
/** Bound best-effort catch-up work per tick so it cannot monopolize the event loop. */
|
||||||
const CATCH_UP_SCAN_BUDGET = 24;
|
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 {
|
interface AppendedParseResult {
|
||||||
messages: ParsedMessage[];
|
messages: ParsedMessage[];
|
||||||
|
|
@ -93,6 +95,8 @@ export class FileWatcher extends EventEmitter {
|
||||||
private catchUpInProgress = false;
|
private catchUpInProgress = false;
|
||||||
/** Round-robin cursor so catch-up work is spread across tracked files. */
|
/** Round-robin cursor so catch-up work is spread across tracked files. */
|
||||||
private catchUpCursor = 0;
|
private catchUpCursor = 0;
|
||||||
|
/** Consecutive catch-up stat timeouts per file. */
|
||||||
|
private catchUpStatFailures = new Map<string, number>();
|
||||||
/** Timer for SSH polling mode (replaces fs.watch) */
|
/** Timer for SSH polling mode (replaces fs.watch) */
|
||||||
private pollingTimer: NodeJS.Timeout | null = null;
|
private pollingTimer: NodeJS.Timeout | null = null;
|
||||||
/** Polling interval for SSH mode */
|
/** Polling interval for SSH mode */
|
||||||
|
|
@ -232,6 +236,7 @@ export class FileWatcher extends EventEmitter {
|
||||||
this.lastProcessedLineCount.clear();
|
this.lastProcessedLineCount.clear();
|
||||||
this.lastProcessedSize.clear();
|
this.lastProcessedSize.clear();
|
||||||
this.activeSessionFiles.clear();
|
this.activeSessionFiles.clear();
|
||||||
|
this.catchUpStatFailures.clear();
|
||||||
this.processingInProgress.clear();
|
this.processingInProgress.clear();
|
||||||
this.pendingReprocess.clear();
|
this.pendingReprocess.clear();
|
||||||
|
|
||||||
|
|
@ -284,6 +289,7 @@ export class FileWatcher extends EventEmitter {
|
||||||
this.lastProcessedLineCount.clear();
|
this.lastProcessedLineCount.clear();
|
||||||
this.lastProcessedSize.clear();
|
this.lastProcessedSize.clear();
|
||||||
this.activeSessionFiles.clear();
|
this.activeSessionFiles.clear();
|
||||||
|
this.catchUpStatFailures.clear();
|
||||||
this.polledFileSizes.clear();
|
this.polledFileSizes.clear();
|
||||||
this.processingInProgress.clear();
|
this.processingInProgress.clear();
|
||||||
this.pendingReprocess.clear();
|
this.pendingReprocess.clear();
|
||||||
|
|
@ -867,6 +873,7 @@ export class FileWatcher extends EventEmitter {
|
||||||
this.lastProcessedLineCount.delete(filePath);
|
this.lastProcessedLineCount.delete(filePath);
|
||||||
this.lastProcessedSize.delete(filePath);
|
this.lastProcessedSize.delete(filePath);
|
||||||
this.activeSessionFiles.delete(filePath);
|
this.activeSessionFiles.delete(filePath);
|
||||||
|
this.catchUpStatFailures.delete(filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -876,6 +883,7 @@ export class FileWatcher extends EventEmitter {
|
||||||
this.lastProcessedLineCount.clear();
|
this.lastProcessedLineCount.clear();
|
||||||
this.lastProcessedSize.clear();
|
this.lastProcessedSize.clear();
|
||||||
this.activeSessionFiles.clear();
|
this.activeSessionFiles.clear();
|
||||||
|
this.catchUpStatFailures.clear();
|
||||||
this.catchUpCursor = 0;
|
this.catchUpCursor = 0;
|
||||||
this.catchUpInProgress = false;
|
this.catchUpInProgress = false;
|
||||||
}
|
}
|
||||||
|
|
@ -1119,6 +1127,7 @@ export class FileWatcher extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = await this.fsProvider.stat(filePath);
|
const stats = await this.fsProvider.stat(filePath);
|
||||||
|
this.catchUpStatFailures.delete(filePath);
|
||||||
|
|
||||||
// Skip files not modified recently
|
// Skip files not modified recently
|
||||||
if (now - stats.mtimeMs > CATCH_UP_MAX_AGE_MS) {
|
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
|
// File may have been deleted between iterations
|
||||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
this.clearErrorTracking(filePath);
|
this.clearErrorTracking(filePath);
|
||||||
|
} else if (this.isStatTimeoutError(err)) {
|
||||||
|
this.handleCatchUpStatTimeout(filePath);
|
||||||
} else {
|
} else {
|
||||||
logger.error(`FileWatcher: Error during catch-up stat for ${filePath}:`, err);
|
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
|
// Debouncing
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ type RuntimeMemberSpawnState = Pick<
|
||||||
| 'runtimeAlive'
|
| 'runtimeAlive'
|
||||||
| 'bootstrapConfirmed'
|
| 'bootstrapConfirmed'
|
||||||
| 'hardFailure'
|
| 'hardFailure'
|
||||||
|
| 'skippedForLaunch'
|
||||||
|
| 'skipReason'
|
||||||
|
| 'skippedAt'
|
||||||
| 'pendingPermissionRequestIds'
|
| 'pendingPermissionRequestIds'
|
||||||
| 'livenessKind'
|
| 'livenessKind'
|
||||||
| 'runtimeDiagnostic'
|
| 'runtimeDiagnostic'
|
||||||
|
|
@ -130,6 +133,8 @@ function buildDiagnostics(
|
||||||
| 'runtimeAlive'
|
| 'runtimeAlive'
|
||||||
| 'bootstrapConfirmed'
|
| 'bootstrapConfirmed'
|
||||||
| 'hardFailureReason'
|
| 'hardFailureReason'
|
||||||
|
| 'skippedForLaunch'
|
||||||
|
| 'skipReason'
|
||||||
| 'sources'
|
| 'sources'
|
||||||
| 'pendingPermissionRequestIds'
|
| 'pendingPermissionRequestIds'
|
||||||
>
|
>
|
||||||
|
|
@ -145,6 +150,13 @@ function buildDiagnostics(
|
||||||
}
|
}
|
||||||
if (member.hardFailureReason)
|
if (member.hardFailureReason)
|
||||||
diagnostics.push(`hard failure reason: ${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?.duplicateRespawnBlocked) diagnostics.push('respawn blocked as duplicate');
|
||||||
if (member.sources?.configDrift) diagnostics.push('config drift detected');
|
if (member.sources?.configDrift) diagnostics.push('config drift detected');
|
||||||
return diagnostics;
|
return diagnostics;
|
||||||
|
|
@ -159,6 +171,9 @@ export function deriveTeamLaunchAggregateState(
|
||||||
if (summary.pendingCount > 0) {
|
if (summary.pendingCount > 0) {
|
||||||
return 'partial_pending';
|
return 'partial_pending';
|
||||||
}
|
}
|
||||||
|
if ((summary.skippedCount ?? 0) > 0) {
|
||||||
|
return 'partial_skipped';
|
||||||
|
}
|
||||||
return 'clean_success';
|
return 'clean_success';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,6 +184,7 @@ export function summarizePersistedLaunchMembers(
|
||||||
let confirmedCount = 0;
|
let confirmedCount = 0;
|
||||||
let pendingCount = 0;
|
let pendingCount = 0;
|
||||||
let failedCount = 0;
|
let failedCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
let runtimeAlivePendingCount = 0;
|
let runtimeAlivePendingCount = 0;
|
||||||
let shellOnlyPendingCount = 0;
|
let shellOnlyPendingCount = 0;
|
||||||
let runtimeProcessPendingCount = 0;
|
let runtimeProcessPendingCount = 0;
|
||||||
|
|
@ -193,6 +209,10 @@ export function summarizePersistedLaunchMembers(
|
||||||
confirmedCount += 1;
|
confirmedCount += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) {
|
||||||
|
skippedCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (entry.launchState === 'failed_to_start') {
|
if (entry.launchState === 'failed_to_start') {
|
||||||
failedCount += 1;
|
failedCount += 1;
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -223,6 +243,7 @@ export function summarizePersistedLaunchMembers(
|
||||||
confirmedCount,
|
confirmedCount,
|
||||||
pendingCount,
|
pendingCount,
|
||||||
failedCount,
|
failedCount,
|
||||||
|
skippedCount,
|
||||||
runtimeAlivePendingCount,
|
runtimeAlivePendingCount,
|
||||||
shellOnlyPendingCount,
|
shellOnlyPendingCount,
|
||||||
runtimeProcessPendingCount,
|
runtimeProcessPendingCount,
|
||||||
|
|
@ -260,9 +281,13 @@ function deriveMemberLaunchState(
|
||||||
| 'bootstrapConfirmed'
|
| 'bootstrapConfirmed'
|
||||||
| 'runtimeAlive'
|
| 'runtimeAlive'
|
||||||
| 'agentToolAccepted'
|
| 'agentToolAccepted'
|
||||||
|
| 'skippedForLaunch'
|
||||||
| 'pendingPermissionRequestIds'
|
| 'pendingPermissionRequestIds'
|
||||||
>
|
>
|
||||||
): MemberLaunchState {
|
): MemberLaunchState {
|
||||||
|
if (member.skippedForLaunch) {
|
||||||
|
return 'skipped_for_launch';
|
||||||
|
}
|
||||||
if (member.hardFailure) {
|
if (member.hardFailure) {
|
||||||
return 'failed_to_start';
|
return 'failed_to_start';
|
||||||
}
|
}
|
||||||
|
|
@ -390,10 +415,15 @@ function normalizePersistedMemberState(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const providerId = normalizeOptionalTeamProviderId(parsed.providerId);
|
const providerId = normalizeOptionalTeamProviderId(parsed.providerId);
|
||||||
|
const skippedForLaunch =
|
||||||
|
toBoolean(parsed.skippedForLaunch) || parsed.launchState === 'skipped_for_launch';
|
||||||
const bootstrapConfirmed =
|
const bootstrapConfirmed =
|
||||||
toBoolean(parsed.bootstrapConfirmed) || parsed.launchState === 'confirmed_alive';
|
!skippedForLaunch &&
|
||||||
|
(toBoolean(parsed.bootstrapConfirmed) || parsed.launchState === 'confirmed_alive');
|
||||||
const livenessKind = normalizeLivenessKind(parsed.livenessKind);
|
const livenessKind = normalizeLivenessKind(parsed.livenessKind);
|
||||||
const runtimeAlive = preservesStrongRuntimeAlive({
|
const runtimeAlive = skippedForLaunch
|
||||||
|
? false
|
||||||
|
: preservesStrongRuntimeAlive({
|
||||||
runtimeAlive: toBoolean(parsed.runtimeAlive),
|
runtimeAlive: toBoolean(parsed.runtimeAlive),
|
||||||
bootstrapConfirmed,
|
bootstrapConfirmed,
|
||||||
livenessKind,
|
livenessKind,
|
||||||
|
|
@ -431,12 +461,16 @@ function normalizePersistedMemberState(
|
||||||
laneOwnerProviderId: normalizeOptionalTeamProviderId(parsed.laneOwnerProviderId),
|
laneOwnerProviderId: normalizeOptionalTeamProviderId(parsed.laneOwnerProviderId),
|
||||||
launchIdentity: normalizeLaunchIdentity(parsed.launchIdentity, providerId),
|
launchIdentity: normalizeLaunchIdentity(parsed.launchIdentity, providerId),
|
||||||
launchState: 'starting',
|
launchState: 'starting',
|
||||||
agentToolAccepted: toBoolean(parsed.agentToolAccepted),
|
skippedForLaunch,
|
||||||
|
skipReason: normalizeOptionalString(parsed.skipReason),
|
||||||
|
skippedAt: normalizeOptionalString(parsed.skippedAt),
|
||||||
|
agentToolAccepted: skippedForLaunch ? false : toBoolean(parsed.agentToolAccepted),
|
||||||
runtimeAlive,
|
runtimeAlive,
|
||||||
bootstrapConfirmed,
|
bootstrapConfirmed,
|
||||||
hardFailure: toBoolean(parsed.hardFailure),
|
hardFailure: skippedForLaunch ? false : toBoolean(parsed.hardFailure),
|
||||||
hardFailureReason:
|
hardFailureReason: skippedForLaunch
|
||||||
typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0
|
? undefined
|
||||||
|
: typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0
|
||||||
? parsed.hardFailureReason.trim()
|
? parsed.hardFailureReason.trim()
|
||||||
: undefined,
|
: undefined,
|
||||||
pendingPermissionRequestIds: normalizePendingPermissionRequestIds(
|
pendingPermissionRequestIds: normalizePendingPermissionRequestIds(
|
||||||
|
|
@ -469,7 +503,8 @@ function normalizePersistedMemberState(
|
||||||
parsed.launchState === 'runtime_pending_bootstrap' ||
|
parsed.launchState === 'runtime_pending_bootstrap' ||
|
||||||
parsed.launchState === 'runtime_pending_permission' ||
|
parsed.launchState === 'runtime_pending_permission' ||
|
||||||
parsed.launchState === 'confirmed_alive' ||
|
parsed.launchState === 'confirmed_alive' ||
|
||||||
parsed.launchState === 'failed_to_start'
|
parsed.launchState === 'failed_to_start' ||
|
||||||
|
parsed.launchState === 'skipped_for_launch'
|
||||||
? parsed.launchState
|
? parsed.launchState
|
||||||
: deriveMemberLaunchState(next);
|
: deriveMemberLaunchState(next);
|
||||||
next.launchState = launchState;
|
next.launchState = launchState;
|
||||||
|
|
@ -511,6 +546,7 @@ export function createPersistedLaunchSnapshot(params: {
|
||||||
members[name] = {
|
members[name] = {
|
||||||
name,
|
name,
|
||||||
launchState: 'starting',
|
launchState: 'starting',
|
||||||
|
skippedForLaunch: false,
|
||||||
agentToolAccepted: false,
|
agentToolAccepted: false,
|
||||||
runtimeAlive: false,
|
runtimeAlive: false,
|
||||||
bootstrapConfirmed: false,
|
bootstrapConfirmed: false,
|
||||||
|
|
@ -587,18 +623,29 @@ export function snapshotFromRuntimeMemberStatuses(params: {
|
||||||
sources.nativeHeartbeat = true;
|
sources.nativeHeartbeat = true;
|
||||||
sources.inboxHeartbeat = 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) {
|
if (runtime?.livenessSource === 'process' && runtimeAlive) {
|
||||||
sources.processAlive = true;
|
sources.processAlive = true;
|
||||||
}
|
}
|
||||||
const entry: PersistedTeamLaunchMemberState = {
|
const entry: PersistedTeamLaunchMemberState = {
|
||||||
name,
|
name,
|
||||||
launchState: runtime?.launchState ?? 'starting',
|
launchState: runtime?.launchState ?? 'starting',
|
||||||
agentToolAccepted: runtime?.agentToolAccepted === true,
|
skippedForLaunch,
|
||||||
|
skipReason: runtime?.skipReason,
|
||||||
|
skippedAt: runtime?.skippedAt,
|
||||||
|
agentToolAccepted: skippedForLaunch ? false : runtime?.agentToolAccepted === true,
|
||||||
runtimeAlive,
|
runtimeAlive,
|
||||||
bootstrapConfirmed: runtime?.bootstrapConfirmed === true,
|
bootstrapConfirmed: skippedForLaunch ? false : runtime?.bootstrapConfirmed === true,
|
||||||
hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start',
|
hardFailure:
|
||||||
hardFailureReason: runtime?.hardFailureReason ?? runtime?.error,
|
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
|
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length
|
||||||
? [...new Set(runtime.pendingPermissionRequestIds)]
|
? [...new Set(runtime.pendingPermissionRequestIds)]
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
@ -644,9 +691,13 @@ export function snapshotToMemberSpawnStatuses(
|
||||||
if (!entry) continue;
|
if (!entry) continue;
|
||||||
let status: MemberSpawnStatusEntry['status'] = 'offline';
|
let status: MemberSpawnStatusEntry['status'] = 'offline';
|
||||||
let livenessSource: MemberSpawnLivenessSource | undefined;
|
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') {
|
if (entry.launchState === 'failed_to_start') {
|
||||||
status = 'error';
|
status = 'error';
|
||||||
|
} else if (entry.launchState === 'skipped_for_launch') {
|
||||||
|
status = 'skipped';
|
||||||
} else if (entry.launchState === 'confirmed_alive') {
|
} else if (entry.launchState === 'confirmed_alive') {
|
||||||
status = 'online';
|
status = 'online';
|
||||||
livenessSource = 'heartbeat';
|
livenessSource = 'heartbeat';
|
||||||
|
|
@ -664,11 +715,14 @@ export function snapshotToMemberSpawnStatuses(
|
||||||
launchState: entry.launchState,
|
launchState: entry.launchState,
|
||||||
error: entry.hardFailure ? entry.hardFailureReason : undefined,
|
error: entry.hardFailure ? entry.hardFailureReason : undefined,
|
||||||
hardFailureReason: entry.hardFailureReason,
|
hardFailureReason: entry.hardFailureReason,
|
||||||
|
skippedForLaunch: entry.skippedForLaunch,
|
||||||
|
skipReason: entry.skipReason,
|
||||||
|
skippedAt: entry.skippedAt,
|
||||||
livenessSource,
|
livenessSource,
|
||||||
agentToolAccepted: entry.agentToolAccepted,
|
agentToolAccepted: skippedForLaunch ? false : entry.agentToolAccepted,
|
||||||
runtimeAlive,
|
runtimeAlive,
|
||||||
bootstrapConfirmed: entry.bootstrapConfirmed,
|
bootstrapConfirmed: skippedForLaunch ? false : entry.bootstrapConfirmed,
|
||||||
hardFailure: entry.hardFailure,
|
hardFailure: skippedForLaunch ? false : entry.hardFailure,
|
||||||
pendingPermissionRequestIds: entry.pendingPermissionRequestIds,
|
pendingPermissionRequestIds: entry.pendingPermissionRequestIds,
|
||||||
livenessKind: entry.livenessKind,
|
livenessKind: entry.livenessKind,
|
||||||
runtimeDiagnostic: entry.runtimeDiagnostic,
|
runtimeDiagnostic: entry.runtimeDiagnostic,
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,13 @@ export interface LaunchStateSummary {
|
||||||
expectedMemberCount?: number;
|
expectedMemberCount?: number;
|
||||||
confirmedMemberCount?: number;
|
confirmedMemberCount?: number;
|
||||||
missingMembers?: string[];
|
missingMembers?: string[];
|
||||||
|
skippedMembers?: string[];
|
||||||
teamLaunchState?: TeamSummary['teamLaunchState'];
|
teamLaunchState?: TeamSummary['teamLaunchState'];
|
||||||
launchUpdatedAt?: string;
|
launchUpdatedAt?: string;
|
||||||
confirmedCount?: number;
|
confirmedCount?: number;
|
||||||
pendingCount?: number;
|
pendingCount?: number;
|
||||||
failedCount?: number;
|
failedCount?: number;
|
||||||
|
skippedCount?: number;
|
||||||
runtimeAlivePendingCount?: number;
|
runtimeAlivePendingCount?: number;
|
||||||
shellOnlyPendingCount?: number;
|
shellOnlyPendingCount?: number;
|
||||||
runtimeProcessPendingCount?: number;
|
runtimeProcessPendingCount?: number;
|
||||||
|
|
@ -60,6 +62,10 @@ export function createLaunchStateSummary(
|
||||||
const member = snapshot.members[name];
|
const member = snapshot.members[name];
|
||||||
return member?.launchState === 'failed_to_start';
|
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 {
|
return {
|
||||||
...(snapshot.teamLaunchState === 'partial_failure'
|
...(snapshot.teamLaunchState === 'partial_failure'
|
||||||
|
|
@ -72,11 +78,13 @@ export function createLaunchStateSummary(
|
||||||
? { confirmedMemberCount: snapshot.summary.confirmedCount }
|
? { confirmedMemberCount: snapshot.summary.confirmedCount }
|
||||||
: {}),
|
: {}),
|
||||||
...(missingMembers.length > 0 ? { missingMembers } : {}),
|
...(missingMembers.length > 0 ? { missingMembers } : {}),
|
||||||
|
...(skippedMembers.length > 0 ? { skippedMembers } : {}),
|
||||||
teamLaunchState: snapshot.teamLaunchState,
|
teamLaunchState: snapshot.teamLaunchState,
|
||||||
launchUpdatedAt: snapshot.updatedAt,
|
launchUpdatedAt: snapshot.updatedAt,
|
||||||
confirmedCount: snapshot.summary.confirmedCount,
|
confirmedCount: snapshot.summary.confirmedCount,
|
||||||
pendingCount: snapshot.summary.pendingCount,
|
pendingCount: snapshot.summary.pendingCount,
|
||||||
failedCount: snapshot.summary.failedCount,
|
failedCount: snapshot.summary.failedCount,
|
||||||
|
skippedCount: snapshot.summary.skippedCount,
|
||||||
runtimeAlivePendingCount: snapshot.summary.runtimeAlivePendingCount,
|
runtimeAlivePendingCount: snapshot.summary.runtimeAlivePendingCount,
|
||||||
shellOnlyPendingCount: snapshot.summary.shellOnlyPendingCount,
|
shellOnlyPendingCount: snapshot.summary.shellOnlyPendingCount,
|
||||||
runtimeProcessPendingCount: snapshot.summary.runtimeProcessPendingCount,
|
runtimeProcessPendingCount: snapshot.summary.runtimeProcessPendingCount,
|
||||||
|
|
@ -138,8 +146,17 @@ export function normalizePersistedLaunchSummaryProjection(
|
||||||
normalized.missingMembers = missingMembers;
|
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 (
|
if (
|
||||||
record.teamLaunchState === 'partial_failure' ||
|
record.teamLaunchState === 'partial_failure' ||
|
||||||
|
record.teamLaunchState === 'partial_skipped' ||
|
||||||
record.teamLaunchState === 'partial_pending' ||
|
record.teamLaunchState === 'partial_pending' ||
|
||||||
record.teamLaunchState === 'clean_success'
|
record.teamLaunchState === 'clean_success'
|
||||||
) {
|
) {
|
||||||
|
|
@ -154,6 +171,9 @@ export function normalizePersistedLaunchSummaryProjection(
|
||||||
if (typeof record.failedCount === 'number' && record.failedCount >= 0) {
|
if (typeof record.failedCount === 'number' && record.failedCount >= 0) {
|
||||||
normalized.failedCount = record.failedCount;
|
normalized.failedCount = record.failedCount;
|
||||||
}
|
}
|
||||||
|
if (typeof record.skippedCount === 'number' && record.skippedCount >= 0) {
|
||||||
|
normalized.skippedCount = record.skippedCount;
|
||||||
|
}
|
||||||
if (typeof record.runtimeAlivePendingCount === 'number' && record.runtimeAlivePendingCount >= 0) {
|
if (typeof record.runtimeAlivePendingCount === 'number' && record.runtimeAlivePendingCount >= 0) {
|
||||||
normalized.runtimeAlivePendingCount = record.runtimeAlivePendingCount;
|
normalized.runtimeAlivePendingCount = record.runtimeAlivePendingCount;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -563,32 +563,6 @@ const PREFLIGHT_BINARY_TIMEOUT_MS = 8000;
|
||||||
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
||||||
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
||||||
const OPENCODE_PREFLIGHT_MODEL_PROBE_CONCURRENCY = 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<
|
function applyDistinctProvisioningMemberColors<
|
||||||
T extends { name: string; color?: string; removedAt?: number },
|
T extends { name: string; color?: string; removedAt?: number },
|
||||||
|
|
@ -1576,6 +1550,7 @@ function summarizeMemberSpawnStatusRecord(
|
||||||
let confirmedCount = 0;
|
let confirmedCount = 0;
|
||||||
let pendingCount = 0;
|
let pendingCount = 0;
|
||||||
let failedCount = 0;
|
let failedCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
let runtimeAlivePendingCount = 0;
|
let runtimeAlivePendingCount = 0;
|
||||||
let shellOnlyPendingCount = 0;
|
let shellOnlyPendingCount = 0;
|
||||||
let runtimeProcessPendingCount = 0;
|
let runtimeProcessPendingCount = 0;
|
||||||
|
|
@ -1594,6 +1569,10 @@ function summarizeMemberSpawnStatusRecord(
|
||||||
confirmedCount += 1;
|
confirmedCount += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) {
|
||||||
|
skippedCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (entry.launchState === 'failed_to_start') {
|
if (entry.launchState === 'failed_to_start') {
|
||||||
failedCount += 1;
|
failedCount += 1;
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -1624,6 +1603,7 @@ function summarizeMemberSpawnStatusRecord(
|
||||||
confirmedCount,
|
confirmedCount,
|
||||||
pendingCount,
|
pendingCount,
|
||||||
failedCount,
|
failedCount,
|
||||||
|
skippedCount,
|
||||||
runtimeAlivePendingCount,
|
runtimeAlivePendingCount,
|
||||||
shellOnlyPendingCount,
|
shellOnlyPendingCount,
|
||||||
runtimeProcessPendingCount,
|
runtimeProcessPendingCount,
|
||||||
|
|
@ -1828,8 +1808,12 @@ function deriveMemberLaunchState(entry: {
|
||||||
runtimeAlive?: boolean;
|
runtimeAlive?: boolean;
|
||||||
bootstrapConfirmed?: boolean;
|
bootstrapConfirmed?: boolean;
|
||||||
hardFailure?: boolean;
|
hardFailure?: boolean;
|
||||||
|
skippedForLaunch?: boolean;
|
||||||
pendingPermissionRequestIds?: string[];
|
pendingPermissionRequestIds?: string[];
|
||||||
}): MemberLaunchState {
|
}): MemberLaunchState {
|
||||||
|
if (entry.skippedForLaunch) {
|
||||||
|
return 'skipped_for_launch';
|
||||||
|
}
|
||||||
if (entry.hardFailure) {
|
if (entry.hardFailure) {
|
||||||
return 'failed_to_start';
|
return 'failed_to_start';
|
||||||
}
|
}
|
||||||
|
|
@ -2364,7 +2348,9 @@ ${getAgentLanguageInstruction()}
|
||||||
Your FIRST action: call MCP tool member_briefing with:
|
Your FIRST action: call MCP tool member_briefing with:
|
||||||
{ teamName: "${teamName}", memberName: "${member.name}" }
|
{ teamName: "${teamName}", memberName: "${member.name}" }
|
||||||
Call member_briefing directly. Do NOT use Agent, any subagent, or any delegated helper for this step.
|
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()}
|
${getCanonicalSendMessageFieldRule()}
|
||||||
Correct example:
|
Correct example:
|
||||||
${buildCanonicalSendMessageExample({ to: leadName, summary: 'bootstrap error', message: 'exact error text' })}
|
${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:
|
Your FIRST action: call MCP tool member_briefing with:
|
||||||
{ teamName: "${teamName}", memberName: "${member.name}" }
|
{ teamName: "${teamName}", memberName: "${member.name}" }
|
||||||
Call member_briefing directly. Do NOT use Agent, any subagent, or any delegated helper for this step.
|
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()}
|
${getCanonicalSendMessageFieldRule()}
|
||||||
Correct example:
|
Correct example:
|
||||||
${buildCanonicalSendMessageExample({ to: leadName, summary: 'bootstrap error', message: 'exact error text' })}
|
${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.
|
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.
|
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.
|
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").
|
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()}
|
${getCanonicalSendMessageFieldRule()}
|
||||||
Correct example:
|
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.
|
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.
|
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.
|
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").
|
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()}
|
${buildTeammateAgentBlockReminder()}
|
||||||
${actionModeProtocol}
|
${actionModeProtocol}
|
||||||
|
|
@ -6754,6 +6746,9 @@ export class TeamProvisioningService {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (status === 'spawning') {
|
if (status === 'spawning') {
|
||||||
|
next.skippedForLaunch = false;
|
||||||
|
next.skipReason = undefined;
|
||||||
|
next.skippedAt = undefined;
|
||||||
next.agentToolAccepted = false;
|
next.agentToolAccepted = false;
|
||||||
next.runtimeAlive = false;
|
next.runtimeAlive = false;
|
||||||
next.bootstrapConfirmed = false;
|
next.bootstrapConfirmed = false;
|
||||||
|
|
@ -6769,6 +6764,9 @@ export class TeamProvisioningService {
|
||||||
next.lastHeartbeatAt = undefined;
|
next.lastHeartbeatAt = undefined;
|
||||||
next.launchState = 'starting';
|
next.launchState = 'starting';
|
||||||
} else if (status === 'waiting') {
|
} else if (status === 'waiting') {
|
||||||
|
next.skippedForLaunch = false;
|
||||||
|
next.skipReason = undefined;
|
||||||
|
next.skippedAt = undefined;
|
||||||
next.agentToolAccepted = true;
|
next.agentToolAccepted = true;
|
||||||
next.runtimeAlive = false;
|
next.runtimeAlive = false;
|
||||||
next.bootstrapConfirmed = false;
|
next.bootstrapConfirmed = false;
|
||||||
|
|
@ -6784,6 +6782,9 @@ export class TeamProvisioningService {
|
||||||
next.lastHeartbeatAt = undefined;
|
next.lastHeartbeatAt = undefined;
|
||||||
next.launchState = 'runtime_pending_bootstrap';
|
next.launchState = 'runtime_pending_bootstrap';
|
||||||
} else if (status === 'online') {
|
} else if (status === 'online') {
|
||||||
|
next.skippedForLaunch = false;
|
||||||
|
next.skipReason = undefined;
|
||||||
|
next.skippedAt = undefined;
|
||||||
next.agentToolAccepted = true;
|
next.agentToolAccepted = true;
|
||||||
next.runtimeAlive = true;
|
next.runtimeAlive = true;
|
||||||
next.livenessSource = livenessSource;
|
next.livenessSource = livenessSource;
|
||||||
|
|
@ -6803,14 +6804,39 @@ export class TeamProvisioningService {
|
||||||
next.hardFailureReason = undefined;
|
next.hardFailureReason = undefined;
|
||||||
next.launchState = deriveMemberLaunchState(next);
|
next.launchState = deriveMemberLaunchState(next);
|
||||||
} else if (status === 'error') {
|
} else if (status === 'error') {
|
||||||
|
next.skippedForLaunch = false;
|
||||||
|
next.skipReason = undefined;
|
||||||
|
next.skippedAt = undefined;
|
||||||
next.error = error;
|
next.error = error;
|
||||||
next.hardFailure = true;
|
next.hardFailure = true;
|
||||||
next.hardFailureReason = error;
|
next.hardFailureReason = error;
|
||||||
next.launchState = 'failed_to_start';
|
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') {
|
} else if (status === 'offline') {
|
||||||
Object.assign(next, createInitialMemberSpawnStatusEntry(), { updatedAt });
|
Object.assign(next, createInitialMemberSpawnStatusEntry(), { updatedAt });
|
||||||
next.error = undefined;
|
next.error = undefined;
|
||||||
next.hardFailureReason = undefined;
|
next.hardFailureReason = undefined;
|
||||||
|
next.skippedForLaunch = false;
|
||||||
|
next.skipReason = undefined;
|
||||||
|
next.skippedAt = undefined;
|
||||||
next.livenessSource = undefined;
|
next.livenessSource = undefined;
|
||||||
next.livenessKind = undefined;
|
next.livenessKind = undefined;
|
||||||
next.runtimeDiagnostic = undefined;
|
next.runtimeDiagnostic = undefined;
|
||||||
|
|
@ -6826,6 +6852,9 @@ export class TeamProvisioningService {
|
||||||
prev.launchState === next.launchState &&
|
prev.launchState === next.launchState &&
|
||||||
prev.error === next.error &&
|
prev.error === next.error &&
|
||||||
prev.hardFailureReason === next.hardFailureReason &&
|
prev.hardFailureReason === next.hardFailureReason &&
|
||||||
|
(prev.skippedForLaunch === true) === (next.skippedForLaunch === true) &&
|
||||||
|
prev.skipReason === next.skipReason &&
|
||||||
|
prev.skippedAt === next.skippedAt &&
|
||||||
prev.livenessSource === next.livenessSource &&
|
prev.livenessSource === next.livenessSource &&
|
||||||
prev.agentToolAccepted === next.agentToolAccepted &&
|
prev.agentToolAccepted === next.agentToolAccepted &&
|
||||||
prev.runtimeAlive === next.runtimeAlive &&
|
prev.runtimeAlive === next.runtimeAlive &&
|
||||||
|
|
@ -6844,7 +6873,8 @@ export class TeamProvisioningService {
|
||||||
if (
|
if (
|
||||||
(status === 'online' && (next.bootstrapConfirmed || livenessSource === 'process')) ||
|
(status === 'online' && (next.bootstrapConfirmed || livenessSource === 'process')) ||
|
||||||
status === 'offline' ||
|
status === 'offline' ||
|
||||||
status === 'error'
|
status === 'error' ||
|
||||||
|
status === 'skipped'
|
||||||
) {
|
) {
|
||||||
run.pendingMemberRestarts?.delete(memberName);
|
run.pendingMemberRestarts?.delete(memberName);
|
||||||
}
|
}
|
||||||
|
|
@ -6885,6 +6915,14 @@ export class TeamProvisioningService {
|
||||||
memberName,
|
memberName,
|
||||||
error?.trim().length ? error.trim() : 'bootstrap failed'
|
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;
|
if (!this.isCurrentTrackedRun(run)) return;
|
||||||
this.emitMemberSpawnChange(run, memberName);
|
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 {
|
private getMutableAliveRunOrThrow(teamName: string): ProvisioningRun {
|
||||||
const runId = this.getAliveRunId(teamName);
|
const runId = this.getAliveRunId(teamName);
|
||||||
if (!runId) {
|
if (!runId) {
|
||||||
|
|
@ -7801,7 +7977,11 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
return run.expectedMembers.every((memberName) => {
|
return run.expectedMembers.every((memberName) => {
|
||||||
const entry = run.memberSpawnStatuses.get(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 =
|
const primaryReason =
|
||||||
prepare.diagnostics.find((entry) => entry.trim().length > 0) ?? prepare.reason;
|
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 unavailableLine = `Selected model ${modelId} is unavailable. ${primaryReason}`;
|
||||||
const verificationWarningLine = `Selected model ${modelId} could not be verified. ${primaryReason}`;
|
const verificationWarningLine = `Selected model ${modelId} could not be verified. ${primaryReason}`;
|
||||||
if (prepare.retryable) {
|
if (prepare.retryable) {
|
||||||
|
|
@ -8362,14 +8536,6 @@ export class TeamProvisioningService {
|
||||||
if (!sharedPrepare.ok) {
|
if (!sharedPrepare.ok) {
|
||||||
const primaryReason =
|
const primaryReason =
|
||||||
sharedPrepare.diagnostics.find((entry) => entry.trim().length > 0) ?? sharedPrepare.reason;
|
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) {
|
for (const modelId of modelIds) {
|
||||||
const unavailableLine = `Selected model ${modelId} is unavailable. ${primaryReason}`;
|
const unavailableLine = `Selected model ${modelId} is unavailable. ${primaryReason}`;
|
||||||
const verificationWarningLine = `Selected model ${modelId} could not be verified. ${primaryReason}`;
|
const verificationWarningLine = `Selected model ${modelId} could not be verified. ${primaryReason}`;
|
||||||
|
|
@ -12378,7 +12544,9 @@ export class TeamProvisioningService {
|
||||||
const current = run.memberSpawnStatuses.get(expected);
|
const current = run.memberSpawnStatuses.get(expected);
|
||||||
if (
|
if (
|
||||||
current?.launchState === 'failed_to_start' ||
|
current?.launchState === 'failed_to_start' ||
|
||||||
current?.launchState === 'confirmed_alive'
|
current?.launchState === 'confirmed_alive' ||
|
||||||
|
current?.launchState === 'skipped_for_launch' ||
|
||||||
|
current?.skippedForLaunch === true
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -12466,6 +12634,8 @@ export class TeamProvisioningService {
|
||||||
const current = run.memberSpawnStatuses.get(expected);
|
const current = run.memberSpawnStatuses.get(expected);
|
||||||
if (
|
if (
|
||||||
current?.launchState === 'failed_to_start' ||
|
current?.launchState === 'failed_to_start' ||
|
||||||
|
current?.launchState === 'skipped_for_launch' ||
|
||||||
|
current?.skippedForLaunch === true ||
|
||||||
current?.bootstrapConfirmed ||
|
current?.bootstrapConfirmed ||
|
||||||
current?.runtimeAlive
|
current?.runtimeAlive
|
||||||
) {
|
) {
|
||||||
|
|
@ -12504,6 +12674,22 @@ export class TeamProvisioningService {
|
||||||
if (!current) {
|
if (!current) {
|
||||||
continue;
|
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 runtimeDiagnostic = buildRuntimeDiagnosticForSpawn(metadata);
|
||||||
const nextEntry: MemberSpawnStatusEntry = {
|
const nextEntry: MemberSpawnStatusEntry = {
|
||||||
...current,
|
...current,
|
||||||
|
|
@ -13196,6 +13382,7 @@ export class TeamProvisioningService {
|
||||||
confirmedCount: number;
|
confirmedCount: number;
|
||||||
pendingCount: number;
|
pendingCount: number;
|
||||||
failedCount: number;
|
failedCount: number;
|
||||||
|
skippedCount?: number;
|
||||||
runtimeAlivePendingCount: number;
|
runtimeAlivePendingCount: number;
|
||||||
shellOnlyPendingCount?: number;
|
shellOnlyPendingCount?: number;
|
||||||
runtimeProcessPendingCount?: number;
|
runtimeProcessPendingCount?: number;
|
||||||
|
|
@ -13208,6 +13395,7 @@ export class TeamProvisioningService {
|
||||||
let confirmedCount = 0;
|
let confirmedCount = 0;
|
||||||
let pendingCount = 0;
|
let pendingCount = 0;
|
||||||
let failedCount = 0;
|
let failedCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
let runtimeAlivePendingCount = 0;
|
let runtimeAlivePendingCount = 0;
|
||||||
let shellOnlyPendingCount = 0;
|
let shellOnlyPendingCount = 0;
|
||||||
let runtimeProcessPendingCount = 0;
|
let runtimeProcessPendingCount = 0;
|
||||||
|
|
@ -13220,6 +13408,10 @@ export class TeamProvisioningService {
|
||||||
confirmedCount += 1;
|
confirmedCount += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) {
|
||||||
|
skippedCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (entry.launchState === 'failed_to_start') {
|
if (entry.launchState === 'failed_to_start') {
|
||||||
failedCount += 1;
|
failedCount += 1;
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -13249,6 +13441,7 @@ export class TeamProvisioningService {
|
||||||
confirmedCount,
|
confirmedCount,
|
||||||
pendingCount,
|
pendingCount,
|
||||||
failedCount,
|
failedCount,
|
||||||
|
skippedCount,
|
||||||
runtimeAlivePendingCount,
|
runtimeAlivePendingCount,
|
||||||
shellOnlyPendingCount,
|
shellOnlyPendingCount,
|
||||||
runtimeProcessPendingCount,
|
runtimeProcessPendingCount,
|
||||||
|
|
@ -13339,12 +13532,17 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const persistedMemberNames = this.getPersistedLaunchMemberNames(snapshot);
|
const persistedMemberNames = this.getPersistedLaunchMemberNames(snapshot);
|
||||||
const allPendingMembers = persistedMemberNames.filter((memberName) => {
|
const allPendingMembers = persistedMemberNames
|
||||||
|
.filter((memberName) => {
|
||||||
const member = snapshot.members[memberName];
|
const member = snapshot.members[memberName];
|
||||||
if (!member) {
|
if (!member) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return member.launchState !== 'confirmed_alive' && member.launchState !== 'failed_to_start';
|
return member.launchState !== 'confirmed_alive' && member.launchState !== 'failed_to_start';
|
||||||
|
})
|
||||||
|
.filter((memberName) => {
|
||||||
|
const member = snapshot.members[memberName];
|
||||||
|
return member?.launchState !== 'skipped_for_launch';
|
||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
allPendingMembers.length > 0 &&
|
allPendingMembers.length > 0 &&
|
||||||
|
|
@ -13374,7 +13572,11 @@ export class TeamProvisioningService {
|
||||||
if (!member) {
|
if (!member) {
|
||||||
return true;
|
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) {
|
if (secondaryPendingMembers.length === 0) {
|
||||||
return this.buildPendingBootstrapStatusMessage(prefix, run, launchSummary);
|
return this.buildPendingBootstrapStatusMessage(prefix, run, launchSummary);
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,6 @@ export type {
|
||||||
export { OpenCodeReadinessBridge } from './opencode/bridge/OpenCodeReadinessBridge';
|
export { OpenCodeReadinessBridge } from './opencode/bridge/OpenCodeReadinessBridge';
|
||||||
export { ReviewApplierService } from './ReviewApplierService';
|
export { ReviewApplierService } from './ReviewApplierService';
|
||||||
export type {
|
export type {
|
||||||
OpenCodeTeamLaunchMode,
|
|
||||||
OpenCodeTeamRuntimeAdapterOptions,
|
|
||||||
OpenCodeTeamRuntimeBridgePort,
|
OpenCodeTeamRuntimeBridgePort,
|
||||||
TeamLaunchRuntimeAdapter,
|
TeamLaunchRuntimeAdapter,
|
||||||
TeamRuntimeLaunchInput,
|
TeamRuntimeLaunchInput,
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,6 @@ export type OpenCodeBridgeCommandName =
|
||||||
| 'opencode.getRuntimeTranscript'
|
| 'opencode.getRuntimeTranscript'
|
||||||
| 'opencode.recoverDeliveryJournal';
|
| 'opencode.recoverDeliveryJournal';
|
||||||
|
|
||||||
export type OpenCodeTeamLaunchMode = 'disabled' | 'dogfood' | 'production';
|
|
||||||
|
|
||||||
export type OpenCodeTeamLaunchBridgeState =
|
export type OpenCodeTeamLaunchBridgeState =
|
||||||
| 'blocked'
|
| 'blocked'
|
||||||
| 'launching'
|
| 'launching'
|
||||||
|
|
@ -48,7 +46,6 @@ export interface OpenCodeTeamLaunchMemberCommandSpec {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenCodeLaunchTeamCommandBody {
|
export interface OpenCodeLaunchTeamCommandBody {
|
||||||
mode: OpenCodeTeamLaunchMode;
|
|
||||||
runId: string;
|
runId: string;
|
||||||
laneId: string;
|
laneId: string;
|
||||||
teamId: 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 { OpenCodeTeamRuntimeBridgePort } from '../../runtime/OpenCodeTeamRuntimeAdapter';
|
||||||
import type {
|
import type {
|
||||||
OpenCodeTeamLaunchReadiness,
|
OpenCodeTeamLaunchReadiness,
|
||||||
|
|
@ -26,7 +16,6 @@ import type {
|
||||||
OpenCodeSendMessageCommandData,
|
OpenCodeSendMessageCommandData,
|
||||||
OpenCodeStopTeamCommandBody,
|
OpenCodeStopTeamCommandBody,
|
||||||
OpenCodeStopTeamCommandData,
|
OpenCodeStopTeamCommandData,
|
||||||
OpenCodeTeamLaunchMode,
|
|
||||||
} from './OpenCodeBridgeCommandContract';
|
} from './OpenCodeBridgeCommandContract';
|
||||||
import type { OpenCodeStateChangingBridgeCommandService } from './OpenCodeStateChangingBridgeCommandService';
|
import type { OpenCodeStateChangingBridgeCommandService } from './OpenCodeStateChangingBridgeCommandService';
|
||||||
|
|
||||||
|
|
@ -51,29 +40,12 @@ export interface OpenCodeReadinessBridgeOptions {
|
||||||
sendTimeoutMs?: number;
|
sendTimeoutMs?: number;
|
||||||
stopTimeoutMs?: number;
|
stopTimeoutMs?: number;
|
||||||
stateChangingCommands?: Pick<OpenCodeStateChangingBridgeCommandService, 'execute'>;
|
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 {
|
export interface OpenCodeReadinessBridgeCommandBody {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
selectedModel: string | null;
|
selectedModel: string | null;
|
||||||
requireExecutionProbe: boolean;
|
requireExecutionProbe: boolean;
|
||||||
launchMode?: OpenCodeTeamLaunchMode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_READINESS_TIMEOUT_MS = 120_000;
|
const DEFAULT_READINESS_TIMEOUT_MS = 120_000;
|
||||||
|
|
@ -106,11 +78,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
||||||
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
this.lastRuntimeSnapshotsByProjectPath.set(input.projectPath, result.runtime);
|
this.lastRuntimeSnapshotsByProjectPath.set(input.projectPath, result.runtime);
|
||||||
return this.applyProductionE2EGate({
|
return result.data;
|
||||||
input,
|
|
||||||
readiness: result.data,
|
|
||||||
runtime: result.runtime,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastRuntimeSnapshotsByProjectPath.delete(input.projectPath);
|
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 {
|
getLastOpenCodeRuntimeSnapshot(projectPath: string): OpenCodeBridgeRuntimeSnapshot | null {
|
||||||
return this.lastRuntimeSnapshotsByProjectPath.get(projectPath) ?? 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,
|
evaluateOpenCodeSupport,
|
||||||
OPENCODE_TEAM_LAUNCH_VERSION_POLICY,
|
OPENCODE_TEAM_LAUNCH_VERSION_POLICY,
|
||||||
type OpenCodeInstallMethod,
|
type OpenCodeInstallMethod,
|
||||||
type OpenCodeProductionE2EEvidence,
|
|
||||||
type OpenCodeSupportedVersionPolicy,
|
type OpenCodeSupportedVersionPolicy,
|
||||||
type OpenCodeSupportLevel,
|
type OpenCodeSupportLevel,
|
||||||
} from '../version/OpenCodeVersionPolicy';
|
} from '../version/OpenCodeVersionPolicy';
|
||||||
|
|
||||||
import type { OpenCodeTeamLaunchMode } from '../bridge/OpenCodeBridgeCommandContract';
|
|
||||||
import type { OpenCodeApiCapabilities } from '../capabilities/OpenCodeApiCapabilities';
|
import type { OpenCodeApiCapabilities } from '../capabilities/OpenCodeApiCapabilities';
|
||||||
import type { OpenCodeMcpToolProof } from '../mcp/OpenCodeMcpToolAvailability';
|
import type { OpenCodeMcpToolProof } from '../mcp/OpenCodeMcpToolAvailability';
|
||||||
import type { RuntimeStoreReadinessCheck } from '../store/RuntimeStoreManifest';
|
import type { RuntimeStoreReadinessCheck } from '../store/RuntimeStoreManifest';
|
||||||
|
|
@ -18,7 +16,6 @@ export type OpenCodeTeamLaunchReadinessState =
|
||||||
| 'not_authenticated'
|
| 'not_authenticated'
|
||||||
| 'unsupported_version'
|
| 'unsupported_version'
|
||||||
| 'capabilities_missing'
|
| 'capabilities_missing'
|
||||||
| 'e2e_missing'
|
|
||||||
| 'runtime_store_blocked'
|
| 'runtime_store_blocked'
|
||||||
| 'mcp_unavailable'
|
| 'mcp_unavailable'
|
||||||
| 'model_unavailable'
|
| 'model_unavailable'
|
||||||
|
|
@ -98,21 +95,8 @@ export interface OpenCodeModelExecutionProbePort {
|
||||||
}): Promise<OpenCodeModelExecutionProbeResult>;
|
}): Promise<OpenCodeModelExecutionProbeResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenCodeProductionE2EEvidencePort {
|
|
||||||
read(input: {
|
|
||||||
projectPath: string;
|
|
||||||
inventory: OpenCodeRuntimeInventory;
|
|
||||||
capabilities: OpenCodeApiCapabilities;
|
|
||||||
}): Promise<OpenCodeProductionE2EEvidence | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OpenCodeTeamLaunchReadinessServiceOptions {
|
export interface OpenCodeTeamLaunchReadinessServiceOptions {
|
||||||
versionPolicy?: OpenCodeSupportedVersionPolicy;
|
versionPolicy?: OpenCodeSupportedVersionPolicy;
|
||||||
launchMode?: OpenCodeTeamLaunchMode;
|
|
||||||
/**
|
|
||||||
* @deprecated Use launchMode. Kept for callers that still pass a boolean feature gate.
|
|
||||||
*/
|
|
||||||
adapterEnabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OpenCodeTeamLaunchReadinessService {
|
export class OpenCodeTeamLaunchReadinessService {
|
||||||
|
|
@ -122,7 +106,6 @@ export class OpenCodeTeamLaunchReadinessService {
|
||||||
private readonly mcpTools: OpenCodeMcpToolProofPort,
|
private readonly mcpTools: OpenCodeMcpToolProofPort,
|
||||||
private readonly runtimeStores: OpenCodeRuntimeStoreReadinessPort,
|
private readonly runtimeStores: OpenCodeRuntimeStoreReadinessPort,
|
||||||
private readonly modelExecution: OpenCodeModelExecutionProbePort,
|
private readonly modelExecution: OpenCodeModelExecutionProbePort,
|
||||||
private readonly e2eEvidence: OpenCodeProductionE2EEvidencePort,
|
|
||||||
private readonly options: OpenCodeTeamLaunchReadinessServiceOptions = {}
|
private readonly options: OpenCodeTeamLaunchReadinessServiceOptions = {}
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|
@ -130,21 +113,8 @@ export class OpenCodeTeamLaunchReadinessService {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
selectedModel: string | null;
|
selectedModel: string | null;
|
||||||
requireExecutionProbe: boolean;
|
requireExecutionProbe: boolean;
|
||||||
launchMode?: OpenCodeTeamLaunchMode;
|
|
||||||
}): Promise<OpenCodeTeamLaunchReadiness> {
|
}): Promise<OpenCodeTeamLaunchReadiness> {
|
||||||
const launchMode = resolveReadinessLaunchMode(input.launchMode, this.options);
|
|
||||||
const policy = this.options.versionPolicy ?? OPENCODE_TEAM_LAUNCH_VERSION_POLICY;
|
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 {
|
try {
|
||||||
const inventory = await this.inventory.probe({ projectPath: input.projectPath });
|
const inventory = await this.inventory.probe({ projectPath: input.projectPath });
|
||||||
|
|
@ -184,24 +154,13 @@ export class OpenCodeTeamLaunchReadinessService {
|
||||||
projectPath: input.projectPath,
|
projectPath: input.projectPath,
|
||||||
inventory,
|
inventory,
|
||||||
});
|
});
|
||||||
const evidence = await this.e2eEvidence.read({
|
|
||||||
projectPath: input.projectPath,
|
|
||||||
inventory,
|
|
||||||
capabilities,
|
|
||||||
});
|
|
||||||
const support = evaluateOpenCodeSupport({
|
const support = evaluateOpenCodeSupport({
|
||||||
version: inventory.version ?? '0.0.0',
|
version: inventory.version ?? '0.0.0',
|
||||||
capabilities,
|
capabilities,
|
||||||
evidence,
|
|
||||||
policy,
|
policy,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!support.supported) {
|
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({
|
return readiness({
|
||||||
state: mapSupportLevelToReadinessState(support.supportLevel),
|
state: mapSupportLevelToReadinessState(support.supportLevel),
|
||||||
inventory,
|
inventory,
|
||||||
|
|
@ -212,7 +171,6 @@ export class OpenCodeTeamLaunchReadinessService {
|
||||||
diagnostics: appendDiagnostics(inventory.diagnostics, support.diagnostics),
|
diagnostics: appendDiagnostics(inventory.diagnostics, support.diagnostics),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const runtimeStoreReadiness = await this.runtimeStores.check({
|
const runtimeStoreReadiness = await this.runtimeStores.check({
|
||||||
projectPath: input.projectPath,
|
projectPath: input.projectPath,
|
||||||
|
|
@ -280,7 +238,7 @@ export class OpenCodeTeamLaunchReadinessService {
|
||||||
runtimeStoreReadiness,
|
runtimeStoreReadiness,
|
||||||
supportLevel: support.supportLevel,
|
supportLevel: support.supportLevel,
|
||||||
launchAllowed: true,
|
launchAllowed: true,
|
||||||
diagnostics: appendDiagnostics(inventory.diagnostics, dogfoodWarnings),
|
diagnostics: inventory.diagnostics,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return readiness({
|
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: {
|
function readiness(input: {
|
||||||
state: OpenCodeTeamLaunchReadinessState;
|
state: OpenCodeTeamLaunchReadinessState;
|
||||||
inventory: OpenCodeRuntimeInventory | null;
|
inventory: OpenCodeRuntimeInventory | null;
|
||||||
|
|
@ -361,8 +303,6 @@ function mapSupportLevelToReadinessState(
|
||||||
return 'unsupported_version';
|
return 'unsupported_version';
|
||||||
case 'supported_capabilities_pending':
|
case 'supported_capabilities_pending':
|
||||||
return 'capabilities_missing';
|
return 'capabilities_missing';
|
||||||
case 'supported_e2e_pending':
|
|
||||||
return 'e2e_missing';
|
|
||||||
case 'production_supported':
|
case 'production_supported':
|
||||||
return 'ready';
|
return 'ready';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
|
|
||||||
import {
|
|
||||||
assertOpenCodeProductionE2EEvidenceBasics,
|
|
||||||
type OpenCodeProductionE2EEvidence,
|
|
||||||
} from '../e2e/OpenCodeProductionE2EEvidence';
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
OpenCodeApiCapabilities,
|
OpenCodeApiCapabilities,
|
||||||
OpenCodeApiEndpointKey,
|
OpenCodeApiEndpointKey,
|
||||||
|
|
@ -14,18 +9,14 @@ import type {
|
||||||
|
|
||||||
export interface OpenCodeSupportedVersionPolicy {
|
export interface OpenCodeSupportedVersionPolicy {
|
||||||
minimumVersion: string;
|
minimumVersion: string;
|
||||||
testedVersion: string;
|
|
||||||
allowedPrerelease: boolean;
|
allowedPrerelease: boolean;
|
||||||
requireCapabilities: boolean;
|
requireCapabilities: boolean;
|
||||||
requireE2EArtifactsForTestedVersion: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OPENCODE_TEAM_LAUNCH_VERSION_POLICY: OpenCodeSupportedVersionPolicy = {
|
export const OPENCODE_TEAM_LAUNCH_VERSION_POLICY: OpenCodeSupportedVersionPolicy = {
|
||||||
minimumVersion: '1.14.19',
|
minimumVersion: '1.14.19',
|
||||||
testedVersion: '1.14.19',
|
|
||||||
allowedPrerelease: false,
|
allowedPrerelease: false,
|
||||||
requireCapabilities: true,
|
requireCapabilities: true,
|
||||||
requireE2EArtifactsForTestedVersion: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OpenCodeInstallMethod = 'brew' | 'npm' | 'bun' | 'manual' | 'unknown';
|
export type OpenCodeInstallMethod = 'brew' | 'npm' | 'bun' | 'manual' | 'unknown';
|
||||||
|
|
@ -41,11 +32,8 @@ export type OpenCodeSupportLevel =
|
||||||
| 'unsupported_too_old'
|
| 'unsupported_too_old'
|
||||||
| 'unsupported_prerelease'
|
| 'unsupported_prerelease'
|
||||||
| 'supported_capabilities_pending'
|
| 'supported_capabilities_pending'
|
||||||
| 'supported_e2e_pending'
|
|
||||||
| 'production_supported';
|
| 'production_supported';
|
||||||
|
|
||||||
export { type OpenCodeProductionE2EEvidence } from '../e2e/OpenCodeProductionE2EEvidence';
|
|
||||||
|
|
||||||
export interface OpenCodeCompatibilitySnapshot {
|
export interface OpenCodeCompatibilitySnapshot {
|
||||||
schemaVersion: 1;
|
schemaVersion: 1;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
@ -57,7 +45,6 @@ export interface OpenCodeCompatibilitySnapshot {
|
||||||
supported: boolean;
|
supported: boolean;
|
||||||
supportLevel: OpenCodeSupportLevel;
|
supportLevel: OpenCodeSupportLevel;
|
||||||
apiCapabilities: OpenCodeApiCapabilities;
|
apiCapabilities: OpenCodeApiCapabilities;
|
||||||
testedEvidencePath: string | null;
|
|
||||||
diagnostics: string[];
|
diagnostics: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,7 +108,6 @@ export function shouldReuseCompatibilitySnapshot(input: {
|
||||||
export function evaluateOpenCodeSupport(input: {
|
export function evaluateOpenCodeSupport(input: {
|
||||||
version: string;
|
version: string;
|
||||||
capabilities: OpenCodeApiCapabilities;
|
capabilities: OpenCodeApiCapabilities;
|
||||||
evidence: OpenCodeProductionE2EEvidence | null;
|
|
||||||
policy?: OpenCodeSupportedVersionPolicy;
|
policy?: OpenCodeSupportedVersionPolicy;
|
||||||
}): OpenCodeSupportDecision {
|
}): OpenCodeSupportDecision {
|
||||||
const policy = input.policy ?? OPENCODE_TEAM_LAUNCH_VERSION_POLICY;
|
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 {
|
return {
|
||||||
supported: true,
|
supported: true,
|
||||||
supportLevel: 'production_supported',
|
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(
|
export function selectPermissionReplyRouteFromCache(
|
||||||
cache: OpenCodeRouteCompatibilityCache
|
cache: OpenCodeRouteCompatibilityCache
|
||||||
): OpenCodePermissionReplyRoute | null {
|
): OpenCodePermissionReplyRoute | null {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import type {
|
||||||
OpenCodeSendMessageCommandData,
|
OpenCodeSendMessageCommandData,
|
||||||
OpenCodeStopTeamCommandBody,
|
OpenCodeStopTeamCommandBody,
|
||||||
OpenCodeStopTeamCommandData,
|
OpenCodeStopTeamCommandData,
|
||||||
OpenCodeTeamLaunchMode,
|
|
||||||
OpenCodeTeamMemberLaunchBridgeState,
|
OpenCodeTeamMemberLaunchBridgeState,
|
||||||
} from '../opencode/bridge/OpenCodeBridgeCommandContract';
|
} from '../opencode/bridge/OpenCodeBridgeCommandContract';
|
||||||
import type { OpenCodeTeamLaunchReadiness } from '../opencode/readiness/OpenCodeTeamLaunchReadiness';
|
import type { OpenCodeTeamLaunchReadiness } from '../opencode/readiness/OpenCodeTeamLaunchReadiness';
|
||||||
|
|
@ -31,7 +30,6 @@ export interface OpenCodeTeamRuntimeBridgePort {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
selectedModel: string | null;
|
selectedModel: string | null;
|
||||||
requireExecutionProbe: boolean;
|
requireExecutionProbe: boolean;
|
||||||
launchMode?: OpenCodeTeamLaunchMode;
|
|
||||||
}): Promise<OpenCodeTeamLaunchReadiness>;
|
}): Promise<OpenCodeTeamLaunchReadiness>;
|
||||||
getLastOpenCodeRuntimeSnapshot?(projectPath: string): OpenCodeBridgeRuntimeSnapshot | null;
|
getLastOpenCodeRuntimeSnapshot?(projectPath: string): OpenCodeBridgeRuntimeSnapshot | null;
|
||||||
launchOpenCodeTeam?(input: OpenCodeLaunchTeamCommandBody): Promise<OpenCodeLaunchTeamCommandData>;
|
launchOpenCodeTeam?(input: OpenCodeLaunchTeamCommandBody): Promise<OpenCodeLaunchTeamCommandData>;
|
||||||
|
|
@ -44,14 +42,6 @@ export interface OpenCodeTeamRuntimeBridgePort {
|
||||||
): Promise<OpenCodeSendMessageCommandData>;
|
): 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 {
|
export interface OpenCodeTeamRuntimeMessageInput {
|
||||||
runId?: string;
|
runId?: string;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -71,8 +61,6 @@ export interface OpenCodeTeamRuntimeMessageResult {
|
||||||
diagnostics: string[];
|
diagnostics: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export { type OpenCodeTeamLaunchMode } from '../opencode/bridge/OpenCodeBridgeCommandContract';
|
|
||||||
|
|
||||||
const REQUIRED_READY_CHECKPOINTS = new Set([
|
const REQUIRED_READY_CHECKPOINTS = new Set([
|
||||||
'required_tools_proven',
|
'required_tools_proven',
|
||||||
'delivery_ready',
|
'delivery_ready',
|
||||||
|
|
@ -85,32 +73,14 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
||||||
private readonly lastProjectPathByTeamName = new Map<string, string>();
|
private readonly lastProjectPathByTeamName = new Map<string, string>();
|
||||||
private readonly lastReadinessByProjectPath = new Map<string, OpenCodeTeamLaunchReadiness>();
|
private readonly lastReadinessByProjectPath = new Map<string, OpenCodeTeamLaunchReadiness>();
|
||||||
|
|
||||||
constructor(
|
constructor(private readonly bridge: OpenCodeTeamRuntimeBridgePort) {}
|
||||||
private readonly bridge: OpenCodeTeamRuntimeBridgePort,
|
|
||||||
private readonly options: OpenCodeTeamRuntimeAdapterOptions = {}
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async prepare(input: TeamRuntimeLaunchInput): Promise<TeamRuntimePrepareResult> {
|
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 runtimeOnly = input.runtimeOnly === true;
|
||||||
const readiness = await this.bridge.checkOpenCodeTeamLaunchReadiness({
|
const readiness = await this.bridge.checkOpenCodeTeamLaunchReadiness({
|
||||||
projectPath: input.cwd,
|
projectPath: input.cwd,
|
||||||
selectedModel: input.model ?? null,
|
selectedModel: input.model ?? null,
|
||||||
requireExecutionProbe: !runtimeOnly,
|
requireExecutionProbe: !runtimeOnly,
|
||||||
launchMode: runtimeOnly ? undefined : configuredLaunchMode,
|
|
||||||
});
|
});
|
||||||
this.lastReadinessByProjectPath.set(input.cwd, readiness);
|
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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
providerId: this.providerId,
|
providerId: this.providerId,
|
||||||
modelId: readiness.modelId,
|
modelId: readiness.modelId,
|
||||||
diagnostics: readiness.diagnostics,
|
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);
|
const prepared = await this.prepare(input);
|
||||||
if (!prepared.ok) {
|
if (!prepared.ok) {
|
||||||
return blockedLaunchResult(input, prepared.reason, prepared.diagnostics, prepared.warnings);
|
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;
|
const runtimeSnapshot = this.bridge.getLastOpenCodeRuntimeSnapshot?.(input.cwd) ?? null;
|
||||||
this.lastProjectPathByTeamName.set(input.teamName, input.cwd);
|
this.lastProjectPathByTeamName.set(input.teamName, input.cwd);
|
||||||
const data = await this.bridge.launchOpenCodeTeam({
|
const data = await this.bridge.launchOpenCodeTeam({
|
||||||
mode: configuredLaunchMode,
|
|
||||||
runId: input.runId,
|
runId: input.runId,
|
||||||
laneId: input.laneId?.trim() || 'primary',
|
laneId: input.laneId?.trim() || 'primary',
|
||||||
teamId: input.teamName,
|
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(
|
function mapOpenCodeLaunchDataToRuntimeResult(
|
||||||
input: TeamRuntimeLaunchInput,
|
input: TeamRuntimeLaunchInput,
|
||||||
data: OpenCodeLaunchTeamCommandData,
|
data: OpenCodeLaunchTeamCommandData,
|
||||||
|
|
@ -738,7 +670,6 @@ function isRetryableReadinessState(state: OpenCodeTeamLaunchReadiness['state']):
|
||||||
return (
|
return (
|
||||||
state === 'not_installed' ||
|
state === 'not_installed' ||
|
||||||
state === 'not_authenticated' ||
|
state === 'not_authenticated' ||
|
||||||
state === 'e2e_missing' ||
|
|
||||||
state === 'runtime_store_blocked' ||
|
state === 'runtime_store_blocked' ||
|
||||||
state === 'mcp_unavailable' ||
|
state === 'mcp_unavailable' ||
|
||||||
state === 'model_unavailable' ||
|
state === 'model_unavailable' ||
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
export type {
|
export type {
|
||||||
OpenCodeTeamLaunchMode,
|
|
||||||
OpenCodeTeamRuntimeAdapterOptions,
|
|
||||||
OpenCodeTeamRuntimeBridgePort,
|
OpenCodeTeamRuntimeBridgePort,
|
||||||
OpenCodeTeamRuntimeMessageInput,
|
OpenCodeTeamRuntimeMessageInput,
|
||||||
OpenCodeTeamRuntimeMessageResult,
|
OpenCodeTeamRuntimeMessageResult,
|
||||||
|
|
|
||||||
|
|
@ -385,6 +385,9 @@ export const TEAM_GET_AGENT_RUNTIME = 'team:getAgentRuntime';
|
||||||
/** Restart a specific teammate runtime */
|
/** Restart a specific teammate runtime */
|
||||||
export const TEAM_RESTART_MEMBER = 'team:restartMember';
|
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) */
|
/** Soft-delete a task (set status to 'deleted' with deletedAt timestamp) */
|
||||||
export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask';
|
export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,7 @@ import {
|
||||||
TEAM_SET_TASK_LOG_STREAM_TRACKING,
|
TEAM_SET_TASK_LOG_STREAM_TRACKING,
|
||||||
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
||||||
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
||||||
|
TEAM_SKIP_MEMBER_FOR_LAUNCH,
|
||||||
TEAM_SOFT_DELETE_TASK,
|
TEAM_SOFT_DELETE_TASK,
|
||||||
TEAM_START_TASK,
|
TEAM_START_TASK,
|
||||||
TEAM_START_TASK_BY_USER,
|
TEAM_START_TASK_BY_USER,
|
||||||
|
|
@ -1093,6 +1094,9 @@ const electronAPI: ElectronAPI = {
|
||||||
restartMember: async (teamName: string, memberName: string) => {
|
restartMember: async (teamName: string, memberName: string) => {
|
||||||
return invokeIpcWithResult<void>(TEAM_RESTART_MEMBER, teamName, memberName);
|
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) => {
|
softDeleteTask: async (teamName: string, taskId: string) => {
|
||||||
return invokeIpcWithResult<void>(TEAM_SOFT_DELETE_TASK, teamName, taskId);
|
return invokeIpcWithResult<void>(TEAM_SOFT_DELETE_TASK, teamName, taskId);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -959,6 +959,9 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
restartMember: async (): Promise<void> => {
|
restartMember: async (): Promise<void> => {
|
||||||
throw new Error('Member restart is not available in browser mode');
|
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> => {
|
softDeleteTask: async (_teamName: string, _taskId: string): Promise<void> => {
|
||||||
// Not available via HTTP client — no-op
|
// Not available via HTTP client — no-op
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,6 @@ import { buildPendingRuntimeSummaryCopy } from '@renderer/utils/teamLaunchSummar
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
import { deriveContextMetrics } from '@shared/utils/contextMetrics';
|
import { deriveContextMetrics } from '@shared/utils/contextMetrics';
|
||||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
|
||||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
|
@ -158,11 +157,6 @@ interface CreateTaskDialogState {
|
||||||
defaultChip?: InlineChip;
|
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;
|
const TEAM_PENDING_REPLY_REFRESH_DELAY_MS = 10_000;
|
||||||
|
|
||||||
function areResolvedMembersEqual(
|
function areResolvedMembersEqual(
|
||||||
|
|
@ -940,13 +934,6 @@ export const TeamDetailView = ({
|
||||||
teamName,
|
teamName,
|
||||||
isPaneFocused = false,
|
isPaneFocused = false,
|
||||||
}: TeamDetailViewProps): React.JSX.Element => {
|
}: 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 { isLight } = useTheme();
|
||||||
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
|
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
|
||||||
const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | null>(null);
|
const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | null>(null);
|
||||||
|
|
@ -1247,6 +1234,7 @@ export const TeamDetailView = ({
|
||||||
reviewActionError,
|
reviewActionError,
|
||||||
addMember,
|
addMember,
|
||||||
restartMember,
|
restartMember,
|
||||||
|
skipMemberForLaunch,
|
||||||
removeMember,
|
removeMember,
|
||||||
updateMemberRole,
|
updateMemberRole,
|
||||||
launchTeam,
|
launchTeam,
|
||||||
|
|
@ -1296,6 +1284,7 @@ export const TeamDetailView = ({
|
||||||
reviewActionError: s.reviewActionError,
|
reviewActionError: s.reviewActionError,
|
||||||
addMember: s.addMember,
|
addMember: s.addMember,
|
||||||
restartMember: s.restartMember,
|
restartMember: s.restartMember,
|
||||||
|
skipMemberForLaunch: s.skipMemberForLaunch,
|
||||||
removeMember: s.removeMember,
|
removeMember: s.removeMember,
|
||||||
updateMemberRole: s.updateMemberRole,
|
updateMemberRole: s.updateMemberRole,
|
||||||
launchTeam: s.launchTeam,
|
launchTeam: s.launchTeam,
|
||||||
|
|
@ -1334,38 +1323,6 @@ export const TeamDetailView = ({
|
||||||
const isThisTabActive = tabId ? activeTabId === tabId : false;
|
const isThisTabActive = tabId ? activeTabId === tabId : false;
|
||||||
const wasInteractiveRef = useRef(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
|
// Messages panel resize
|
||||||
const { isResizing: isMessagesPanelResizing, handleProps: messagesPanelHandleProps } =
|
const { isResizing: isMessagesPanelResizing, handleProps: messagesPanelHandleProps } =
|
||||||
useResizablePanel({
|
useResizablePanel({
|
||||||
|
|
@ -1801,6 +1758,20 @@ export const TeamDetailView = ({
|
||||||
openLaunchDialog(data?.isAlive && !isTeamProvisioning ? 'relaunch' : 'launch');
|
openLaunchDialog(data?.isAlive && !isTeamProvisioning ? 'relaunch' : 'launch');
|
||||||
}, [data?.isAlive, isTeamProvisioning, openLaunchDialog]);
|
}, [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) => {
|
const handleSelectMember = useCallback((member: ResolvedTeamMember) => {
|
||||||
setSelectedMember(member);
|
setSelectedMember(member);
|
||||||
setSelectedMemberView(null);
|
setSelectedMemberView(null);
|
||||||
|
|
@ -2490,6 +2461,8 @@ export const TeamDetailView = ({
|
||||||
onSendMessage={handleSendMessageToMember}
|
onSendMessage={handleSendMessageToMember}
|
||||||
onAssignTask={handleAssignTaskToMember}
|
onAssignTask={handleAssignTaskToMember}
|
||||||
onOpenTask={handleOpenTaskById}
|
onOpenTask={handleOpenTaskById}
|
||||||
|
onRestartMember={handleRestartMember}
|
||||||
|
onSkipMemberForLaunch={handleSkipMemberForLaunch}
|
||||||
/>
|
/>
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
|
|
@ -2789,7 +2762,7 @@ export const TeamDetailView = ({
|
||||||
closeSelectedMemberDialog();
|
closeSelectedMemberDialog();
|
||||||
openCreateTaskDialog('', '', name);
|
openCreateTaskDialog('', '', name);
|
||||||
}}
|
}}
|
||||||
onRestartMember={(memberName) => restartMember(teamName, memberName)}
|
onRestartMember={handleRestartMember}
|
||||||
onTaskClick={(task) => {
|
onTaskClick={(task) => {
|
||||||
closeSelectedMemberDialog();
|
closeSelectedMemberDialog();
|
||||||
setSelectedTask(task);
|
setSelectedTask(task);
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ type TeamStatus =
|
||||||
| 'provisioning'
|
| 'provisioning'
|
||||||
| 'offline'
|
| 'offline'
|
||||||
| 'partial_failure'
|
| 'partial_failure'
|
||||||
|
| 'partial_skipped'
|
||||||
| 'partial_pending';
|
| 'partial_pending';
|
||||||
|
|
||||||
function getRecentProjects(team: TeamSummary): string[] {
|
function getRecentProjects(team: TeamSummary): string[] {
|
||||||
|
|
@ -206,6 +207,9 @@ function resolveTeamStatus(
|
||||||
if (team.teamLaunchState === 'partial_pending') {
|
if (team.teamLaunchState === 'partial_pending') {
|
||||||
return 'partial_pending';
|
return 'partial_pending';
|
||||||
}
|
}
|
||||||
|
if (team.teamLaunchState === 'partial_skipped') {
|
||||||
|
return 'partial_skipped';
|
||||||
|
}
|
||||||
if (team.partialLaunchFailure || team.teamLaunchState === 'partial_failure') {
|
if (team.partialLaunchFailure || team.teamLaunchState === 'partial_failure') {
|
||||||
return 'partial_failure';
|
return 'partial_failure';
|
||||||
}
|
}
|
||||||
|
|
@ -249,6 +253,13 @@ const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => {
|
||||||
Launch failed partway
|
Launch failed partway
|
||||||
</span>
|
</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':
|
case 'partial_pending':
|
||||||
return (
|
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">
|
<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">
|
<div className="flex shrink-0 gap-1">
|
||||||
{(status === 'offline' ||
|
{(status === 'offline' ||
|
||||||
status === 'partial_failure' ||
|
status === 'partial_failure' ||
|
||||||
|
status === 'partial_skipped' ||
|
||||||
status === 'partial_pending') &&
|
status === 'partial_pending') &&
|
||||||
team.projectPath && (
|
team.projectPath && (
|
||||||
<Tooltip>
|
<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 ${team.missingMembers.length}/${team.expectedMemberCount ?? team.missingMembers.length} teammate${team.missingMembers.length === 1 ? '' : 's'} joined.`
|
||||||
: 'Last launch stopped before all teammates joined.'}
|
: 'Last launch stopped before all teammates joined.'}
|
||||||
</p>
|
</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}
|
) : null}
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||||
{team.members && team.members.length > 0 ? (
|
{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 { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
|
|
@ -20,7 +20,16 @@ import {
|
||||||
} from '@renderer/utils/memberLaunchDiagnostics';
|
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||||
import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary';
|
import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary';
|
||||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
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 { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
||||||
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
||||||
|
|
@ -63,6 +72,8 @@ interface MemberCardProps {
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
onSendMessage?: () => void;
|
onSendMessage?: () => void;
|
||||||
onAssignTask?: () => void;
|
onAssignTask?: () => void;
|
||||||
|
onRestartMember?: (memberName: string) => Promise<void> | void;
|
||||||
|
onSkipMemberForLaunch?: (memberName: string) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): {
|
function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): {
|
||||||
|
|
@ -111,6 +122,8 @@ export const MemberCard = ({
|
||||||
onClick,
|
onClick,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
|
onRestartMember,
|
||||||
|
onSkipMemberForLaunch,
|
||||||
}: MemberCardProps): React.JSX.Element => {
|
}: MemberCardProps): React.JSX.Element => {
|
||||||
// NOTE: lead context display disabled — usage formula is inaccurate
|
// NOTE: lead context display disabled — usage formula is inaccurate
|
||||||
// const teamName = useStore((s) => s.selectedTeamName);
|
// const teamName = useStore((s) => s.selectedTeamName);
|
||||||
|
|
@ -118,6 +131,10 @@ export const MemberCard = ({
|
||||||
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
||||||
// );
|
// );
|
||||||
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
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) =>
|
const teamMembers = useStore((s) =>
|
||||||
selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : []
|
selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : []
|
||||||
);
|
);
|
||||||
|
|
@ -214,12 +231,68 @@ export const MemberCard = ({
|
||||||
!isRemoved &&
|
!isRemoved &&
|
||||||
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
|
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
|
||||||
hasMemberLaunchDiagnosticsDetails(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 =
|
const showRuntimeAdvisoryBadge =
|
||||||
!isRemoved &&
|
!isRemoved &&
|
||||||
Boolean(runtimeAdvisoryLabel) &&
|
Boolean(runtimeAdvisoryLabel) &&
|
||||||
!showLaunchBadge &&
|
!showLaunchBadge &&
|
||||||
spawnStatus !== 'error' &&
|
!isFailedLaunch &&
|
||||||
|
!isSkippedLaunch &&
|
||||||
(Boolean(activityTask) || !isAwaitingReply);
|
(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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -360,7 +433,7 @@ export const MemberCard = ({
|
||||||
{launchBadgeLabel}
|
{launchBadgeLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
</span>
|
</span>
|
||||||
) : spawnStatus === 'error' ? (
|
) : showFailedLaunchBadge ? (
|
||||||
<span className="flex shrink-0 items-center gap-1">
|
<span className="flex shrink-0 items-center gap-1">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<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"
|
className="size-auto rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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>
|
</span>
|
||||||
) : showRuntimeAdvisoryBadge ? (
|
) : showRuntimeAdvisoryBadge ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ interface MemberListProps {
|
||||||
onSendMessage?: (member: ResolvedTeamMember) => void;
|
onSendMessage?: (member: ResolvedTeamMember) => void;
|
||||||
onAssignTask?: (member: ResolvedTeamMember) => void;
|
onAssignTask?: (member: ResolvedTeamMember) => void;
|
||||||
onOpenTask?: (taskId: string) => void;
|
onOpenTask?: (taskId: string) => void;
|
||||||
|
onRestartMember?: (memberName: string) => Promise<void> | void;
|
||||||
|
onSkipMemberForLaunch?: (memberName: string) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function areResolvedMembersEquivalent(
|
function areResolvedMembersEquivalent(
|
||||||
|
|
@ -151,6 +153,9 @@ function areMemberSpawnStatusesEquivalent(
|
||||||
leftEntry.error !== rightEntry.error ||
|
leftEntry.error !== rightEntry.error ||
|
||||||
leftEntry.hardFailure !== rightEntry.hardFailure ||
|
leftEntry.hardFailure !== rightEntry.hardFailure ||
|
||||||
leftEntry.hardFailureReason !== rightEntry.hardFailureReason ||
|
leftEntry.hardFailureReason !== rightEntry.hardFailureReason ||
|
||||||
|
leftEntry.skippedForLaunch !== rightEntry.skippedForLaunch ||
|
||||||
|
leftEntry.skipReason !== rightEntry.skipReason ||
|
||||||
|
leftEntry.skippedAt !== rightEntry.skippedAt ||
|
||||||
leftEntry.livenessSource !== rightEntry.livenessSource ||
|
leftEntry.livenessSource !== rightEntry.livenessSource ||
|
||||||
leftEntry.livenessKind !== rightEntry.livenessKind ||
|
leftEntry.livenessKind !== rightEntry.livenessKind ||
|
||||||
leftEntry.runtimeDiagnostic !== rightEntry.runtimeDiagnostic ||
|
leftEntry.runtimeDiagnostic !== rightEntry.runtimeDiagnostic ||
|
||||||
|
|
@ -244,6 +249,8 @@ function areMemberListPropsEqual(
|
||||||
prev.isTeamAlive === next.isTeamAlive &&
|
prev.isTeamAlive === next.isTeamAlive &&
|
||||||
prev.isTeamProvisioning === next.isTeamProvisioning &&
|
prev.isTeamProvisioning === next.isTeamProvisioning &&
|
||||||
prev.leadActivity === next.leadActivity &&
|
prev.leadActivity === next.leadActivity &&
|
||||||
|
prev.onRestartMember === next.onRestartMember &&
|
||||||
|
prev.onSkipMemberForLaunch === next.onSkipMemberForLaunch &&
|
||||||
areLaunchParamsEquivalent(prev.launchParams, next.launchParams)
|
areLaunchParamsEquivalent(prev.launchParams, next.launchParams)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -265,6 +272,8 @@ export const MemberList = memo(function MemberList({
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
|
onRestartMember,
|
||||||
|
onSkipMemberForLaunch,
|
||||||
}: MemberListProps): React.JSX.Element {
|
}: MemberListProps): React.JSX.Element {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [isWide, setIsWide] = useState(false);
|
const [isWide, setIsWide] = useState(false);
|
||||||
|
|
@ -372,6 +381,8 @@ export const MemberList = memo(function MemberList({
|
||||||
onClick={() => onMemberClick?.(member)}
|
onClick={() => onMemberClick?.(member)}
|
||||||
onSendMessage={() => onSendMessage?.(member)}
|
onSendMessage={() => onSendMessage?.(member)}
|
||||||
onAssignTask={() => onAssignTask?.(member)}
|
onAssignTask={() => onAssignTask?.(member)}
|
||||||
|
onRestartMember={isRemoved ? undefined : onRestartMember}
|
||||||
|
onSkipMemberForLaunch={isRemoved ? undefined : onSkipMemberForLaunch}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export interface LaunchJoinMilestones {
|
||||||
processOnlyAliveCount: number;
|
processOnlyAliveCount: number;
|
||||||
pendingSpawnCount: number;
|
pendingSpawnCount: number;
|
||||||
failedSpawnCount: number;
|
failedSpawnCount: number;
|
||||||
|
skippedSpawnCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DisplayStepMilestones = LaunchJoinMilestones & {
|
type DisplayStepMilestones = LaunchJoinMilestones & {
|
||||||
|
|
@ -106,6 +107,7 @@ function summarizeLiveLaunchJoinMilestones(params: {
|
||||||
let processOnlyAliveCount = 0;
|
let processOnlyAliveCount = 0;
|
||||||
let pendingSpawnCount = 0;
|
let pendingSpawnCount = 0;
|
||||||
let failedSpawnCount = 0;
|
let failedSpawnCount = 0;
|
||||||
|
let skippedSpawnCount = 0;
|
||||||
let observedTeammateCount = 0;
|
let observedTeammateCount = 0;
|
||||||
|
|
||||||
for (const memberName of teammateNames) {
|
for (const memberName of teammateNames) {
|
||||||
|
|
@ -127,6 +129,10 @@ function summarizeLiveLaunchJoinMilestones(params: {
|
||||||
failedSpawnCount += 1;
|
failedSpawnCount += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) {
|
||||||
|
skippedSpawnCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (entry.launchState === 'confirmed_alive') {
|
if (entry.launchState === 'confirmed_alive') {
|
||||||
heartbeatConfirmedCount += 1;
|
heartbeatConfirmedCount += 1;
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -153,6 +159,7 @@ function summarizeLiveLaunchJoinMilestones(params: {
|
||||||
processOnlyAliveCount,
|
processOnlyAliveCount,
|
||||||
pendingSpawnCount,
|
pendingSpawnCount,
|
||||||
failedSpawnCount,
|
failedSpawnCount,
|
||||||
|
skippedSpawnCount,
|
||||||
observedTeammateCount,
|
observedTeammateCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -208,19 +215,23 @@ export function getLaunchJoinMilestonesFromMembers({
|
||||||
processOnlyAliveCount: snapshotProcessOnlyAliveCount,
|
processOnlyAliveCount: snapshotProcessOnlyAliveCount,
|
||||||
pendingSpawnCount: Math.max(0, snapshotSummary.pendingCount - snapshotProcessOnlyAliveCount),
|
pendingSpawnCount: Math.max(0, snapshotSummary.pendingCount - snapshotProcessOnlyAliveCount),
|
||||||
failedSpawnCount: snapshotSummary.failedCount,
|
failedSpawnCount: snapshotSummary.failedCount,
|
||||||
|
skippedSpawnCount: snapshotSummary.skippedCount ?? 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const snapshotAccountedFor =
|
const snapshotAccountedFor =
|
||||||
snapshotMilestones.heartbeatConfirmedCount +
|
snapshotMilestones.heartbeatConfirmedCount +
|
||||||
snapshotMilestones.processOnlyAliveCount +
|
snapshotMilestones.processOnlyAliveCount +
|
||||||
snapshotMilestones.failedSpawnCount;
|
snapshotMilestones.failedSpawnCount +
|
||||||
|
snapshotMilestones.skippedSpawnCount;
|
||||||
const liveAccountedFor =
|
const liveAccountedFor =
|
||||||
liveSummary.heartbeatConfirmedCount +
|
liveSummary.heartbeatConfirmedCount +
|
||||||
liveSummary.processOnlyAliveCount +
|
liveSummary.processOnlyAliveCount +
|
||||||
liveSummary.failedSpawnCount;
|
liveSummary.failedSpawnCount +
|
||||||
|
liveSummary.skippedSpawnCount;
|
||||||
|
|
||||||
const liveSummaryIsMoreAdvanced =
|
const liveSummaryIsMoreAdvanced =
|
||||||
liveSummary.failedSpawnCount > snapshotMilestones.failedSpawnCount ||
|
liveSummary.failedSpawnCount > snapshotMilestones.failedSpawnCount ||
|
||||||
|
liveSummary.skippedSpawnCount > snapshotMilestones.skippedSpawnCount ||
|
||||||
liveSummary.heartbeatConfirmedCount > snapshotMilestones.heartbeatConfirmedCount ||
|
liveSummary.heartbeatConfirmedCount > snapshotMilestones.heartbeatConfirmedCount ||
|
||||||
liveSummary.processOnlyAliveCount > snapshotMilestones.processOnlyAliveCount ||
|
liveSummary.processOnlyAliveCount > snapshotMilestones.processOnlyAliveCount ||
|
||||||
(snapshotMilestones.failedSpawnCount === 0 &&
|
(snapshotMilestones.failedSpawnCount === 0 &&
|
||||||
|
|
@ -248,6 +259,7 @@ export function getLaunchJoinState({
|
||||||
processOnlyAliveCount,
|
processOnlyAliveCount,
|
||||||
pendingSpawnCount,
|
pendingSpawnCount,
|
||||||
failedSpawnCount,
|
failedSpawnCount,
|
||||||
|
skippedSpawnCount,
|
||||||
}: LaunchJoinMilestones): {
|
}: LaunchJoinMilestones): {
|
||||||
allTeammatesConfirmedAlive: boolean;
|
allTeammatesConfirmedAlive: boolean;
|
||||||
hasMembersStillJoining: boolean;
|
hasMembersStillJoining: boolean;
|
||||||
|
|
@ -256,14 +268,16 @@ export function getLaunchJoinState({
|
||||||
const allTeammatesConfirmedAlive =
|
const allTeammatesConfirmedAlive =
|
||||||
expectedTeammateCount > 0 &&
|
expectedTeammateCount > 0 &&
|
||||||
failedSpawnCount === 0 &&
|
failedSpawnCount === 0 &&
|
||||||
|
skippedSpawnCount === 0 &&
|
||||||
heartbeatConfirmedCount >= expectedTeammateCount;
|
heartbeatConfirmedCount >= expectedTeammateCount;
|
||||||
const remainingJoinCount =
|
const remainingJoinCount =
|
||||||
expectedTeammateCount > 0 && failedSpawnCount === 0
|
expectedTeammateCount > 0 && failedSpawnCount === 0 && skippedSpawnCount === 0
|
||||||
? Math.max(0, expectedTeammateCount - heartbeatConfirmedCount)
|
? Math.max(0, expectedTeammateCount - heartbeatConfirmedCount)
|
||||||
: 0;
|
: 0;
|
||||||
const hasMembersStillJoining =
|
const hasMembersStillJoining =
|
||||||
expectedTeammateCount > 0 &&
|
expectedTeammateCount > 0 &&
|
||||||
failedSpawnCount === 0 &&
|
failedSpawnCount === 0 &&
|
||||||
|
skippedSpawnCount === 0 &&
|
||||||
remainingJoinCount > 0 &&
|
remainingJoinCount > 0 &&
|
||||||
(processOnlyAliveCount > 0 || pendingSpawnCount > 0);
|
(processOnlyAliveCount > 0 || pendingSpawnCount > 0);
|
||||||
|
|
||||||
|
|
@ -295,6 +309,7 @@ export function getDisplayStepIndex({
|
||||||
processOnlyAliveCount,
|
processOnlyAliveCount,
|
||||||
pendingSpawnCount,
|
pendingSpawnCount,
|
||||||
failedSpawnCount,
|
failedSpawnCount,
|
||||||
|
skippedSpawnCount,
|
||||||
}: DisplayStepMilestones): number {
|
}: DisplayStepMilestones): number {
|
||||||
switch (progress.state) {
|
switch (progress.state) {
|
||||||
case 'ready':
|
case 'ready':
|
||||||
|
|
@ -322,8 +337,12 @@ export function getDisplayStepIndex({
|
||||||
if (failedSpawnCount > 0) {
|
if (failedSpawnCount > 0) {
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
if (skippedSpawnCount > 0) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
const accountedForTeammates = heartbeatConfirmedCount + processOnlyAliveCount + failedSpawnCount;
|
const accountedForTeammates =
|
||||||
|
heartbeatConfirmedCount + processOnlyAliveCount + failedSpawnCount + skippedSpawnCount;
|
||||||
|
|
||||||
if (pendingSpawnCount > 0 || accountedForTeammates < expectedTeammateCount) {
|
if (pendingSpawnCount > 0 || accountedForTeammates < expectedTeammateCount) {
|
||||||
return 2;
|
return 2;
|
||||||
|
|
|
||||||
|
|
@ -71,14 +71,7 @@ const logger = createLogger('teamSlice');
|
||||||
const TEAM_GET_DATA_TIMEOUT_MS = 30_000;
|
const TEAM_GET_DATA_TIMEOUT_MS = 30_000;
|
||||||
const TEAM_FETCH_TIMEOUT_MS = 30_000;
|
const TEAM_FETCH_TIMEOUT_MS = 30_000;
|
||||||
const MEMBER_SPAWN_STATUSES_IPC_RETRY_BACKOFF_MS = 5_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_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 MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS = 2_000;
|
||||||
const inFlightTeamDataRequests = new Map<string, Promise<TeamViewSnapshot>>();
|
const inFlightTeamDataRequests = new Map<string, Promise<TeamViewSnapshot>>();
|
||||||
const inFlightRefreshTeamDataCalls = new Map<string, Set<symbol>>();
|
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 {
|
function noteTeamRefreshBurst(teamName: string): number {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const diagnostic = teamRefreshBurstDiagnostics.get(teamName) ?? {
|
const diagnostic = teamRefreshBurstDiagnostics.get(teamName) ?? {
|
||||||
|
|
@ -621,77 +575,10 @@ function noteTeamRefreshBurst(teamName: string): number {
|
||||||
|
|
||||||
diagnostic.count += 1;
|
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);
|
teamRefreshBurstDiagnostics.set(teamName, diagnostic);
|
||||||
return diagnostic.count;
|
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(
|
function areLaunchSummaryCountsEqual(
|
||||||
left: PersistedTeamLaunchSummary | undefined,
|
left: PersistedTeamLaunchSummary | undefined,
|
||||||
right: PersistedTeamLaunchSummary | undefined
|
right: PersistedTeamLaunchSummary | undefined
|
||||||
|
|
@ -702,6 +589,7 @@ function areLaunchSummaryCountsEqual(
|
||||||
left.confirmedCount === right.confirmedCount &&
|
left.confirmedCount === right.confirmedCount &&
|
||||||
left.pendingCount === right.pendingCount &&
|
left.pendingCount === right.pendingCount &&
|
||||||
left.failedCount === right.failedCount &&
|
left.failedCount === right.failedCount &&
|
||||||
|
left.skippedCount === right.skippedCount &&
|
||||||
left.runtimeAlivePendingCount === right.runtimeAlivePendingCount &&
|
left.runtimeAlivePendingCount === right.runtimeAlivePendingCount &&
|
||||||
left.shellOnlyPendingCount === right.shellOnlyPendingCount &&
|
left.shellOnlyPendingCount === right.shellOnlyPendingCount &&
|
||||||
left.runtimeProcessPendingCount === right.runtimeProcessPendingCount &&
|
left.runtimeProcessPendingCount === right.runtimeProcessPendingCount &&
|
||||||
|
|
@ -741,6 +629,9 @@ function areMemberSpawnStatusEntriesEqual(
|
||||||
left.launchState === right.launchState &&
|
left.launchState === right.launchState &&
|
||||||
left.error === right.error &&
|
left.error === right.error &&
|
||||||
left.hardFailureReason === right.hardFailureReason &&
|
left.hardFailureReason === right.hardFailureReason &&
|
||||||
|
left.skippedForLaunch === right.skippedForLaunch &&
|
||||||
|
left.skipReason === right.skipReason &&
|
||||||
|
left.skippedAt === right.skippedAt &&
|
||||||
left.livenessSource === right.livenessSource &&
|
left.livenessSource === right.livenessSource &&
|
||||||
left.runtimeAlive === right.runtimeAlive &&
|
left.runtimeAlive === right.runtimeAlive &&
|
||||||
left.runtimeModel === right.runtimeModel &&
|
left.runtimeModel === right.runtimeModel &&
|
||||||
|
|
@ -2158,6 +2049,7 @@ export interface TeamSlice {
|
||||||
) => Promise<TaskComment>;
|
) => Promise<TaskComment>;
|
||||||
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
|
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
|
||||||
restartMember: (teamName: string, memberName: string) => Promise<void>;
|
restartMember: (teamName: string, memberName: string) => Promise<void>;
|
||||||
|
skipMemberForLaunch: (teamName: string, memberName: string) => Promise<void>;
|
||||||
removeMember: (teamName: string, memberName: string) => Promise<void>;
|
removeMember: (teamName: string, memberName: string) => Promise<void>;
|
||||||
updateMemberRole: (
|
updateMemberRole: (
|
||||||
teamName: string,
|
teamName: string,
|
||||||
|
|
@ -3234,7 +3126,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
},
|
},
|
||||||
|
|
||||||
selectTeam: async (teamName: string, opts) => {
|
selectTeam: async (teamName: string, opts) => {
|
||||||
const startedAt = performance.now();
|
|
||||||
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
|
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
|
||||||
const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true;
|
const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true;
|
||||||
// Guard: prevent duplicate in-flight fetches for the same team.
|
// 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)) {
|
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ipcMs = performance.now() - startedAt;
|
|
||||||
// Stale check: user may have switched to another team during the async call
|
// Stale check: user may have switched to another team during the async call
|
||||||
if (get().selectedTeamName !== teamName || get().selectedTeamLoadNonce !== requestNonce) {
|
if (get().selectedTeamName !== teamName || get().selectedTeamLoadNonce !== requestNonce) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -3299,7 +3189,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
}
|
}
|
||||||
: data;
|
: data;
|
||||||
const nextTeamData = structurallyShareTeamSnapshot(previousData, projectedTeamData);
|
const nextTeamData = structurallyShareTeamSnapshot(previousData, projectedTeamData);
|
||||||
const setStartedAt = performance.now();
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const nextCache =
|
const nextCache =
|
||||||
state.teamDataCacheByName[teamName] === nextTeamData
|
state.teamDataCacheByName[teamName] === nextTeamData
|
||||||
|
|
@ -3318,8 +3207,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
|
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
|
||||||
const setMs = performance.now() - setStartedAt;
|
|
||||||
const postStartedAt = performance.now();
|
|
||||||
const invalidationState = previousData
|
const invalidationState = previousData
|
||||||
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
|
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
|
||||||
: { cacheKeys: [], taskIds: [] };
|
: { cacheKeys: [], taskIds: [] };
|
||||||
|
|
@ -3329,19 +3216,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
if (invalidationState.taskIds.length > 0) {
|
if (invalidationState.taskIds.length > 0) {
|
||||||
await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds);
|
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
|
// Sync tab label with the team's display name from config
|
||||||
const displayName = data.config.name || teamName;
|
const displayName = data.config.name || teamName;
|
||||||
const allTabs = get().getAllPaneTabs();
|
const allTabs = get().getAllPaneTabs();
|
||||||
|
|
@ -3445,19 +3319,15 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
},
|
},
|
||||||
|
|
||||||
refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => {
|
refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => {
|
||||||
const startedAt = performance.now();
|
|
||||||
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
|
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
|
||||||
const refreshToken = beginInFlightTeamDataRefresh(teamName);
|
const refreshToken = beginInFlightTeamDataRefresh(teamName);
|
||||||
// Silent refresh — update data without showing loading skeleton.
|
// Silent refresh — update data without showing loading skeleton.
|
||||||
// Only selectTeam() sets loading: true (for initial load).
|
// Only selectTeam() sets loading: true (for initial load).
|
||||||
const reusedInFlightRequest =
|
const reusedInFlightRequest =
|
||||||
opts?.withDedup === true && inFlightTeamDataRequests.has(teamName);
|
opts?.withDedup === true && inFlightTeamDataRequests.has(teamName);
|
||||||
const burstCount = noteTeamRefreshBurst(teamName);
|
noteTeamRefreshBurst(teamName);
|
||||||
if (reusedInFlightRequest) {
|
if (reusedInFlightRequest) {
|
||||||
pendingFreshTeamDataRefreshes.add(teamName);
|
pendingFreshTeamDataRefreshes.add(teamName);
|
||||||
logger.warn(
|
|
||||||
`[perf] refreshTeamData queued-fresh team=${teamName} burst=${burstCount} reason=inFlightDedup`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const previousData = selectTeamDataForName(get(), teamName);
|
const previousData = selectTeamDataForName(get(), teamName);
|
||||||
|
|
@ -3467,7 +3337,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ipcMs = performance.now() - startedAt;
|
|
||||||
const projectedTeamData = previousData
|
const projectedTeamData = previousData
|
||||||
? {
|
? {
|
||||||
...data,
|
...data,
|
||||||
|
|
@ -3475,7 +3344,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
}
|
}
|
||||||
: data;
|
: data;
|
||||||
const nextTeamData = structurallyShareTeamSnapshot(previousData, projectedTeamData);
|
const nextTeamData = structurallyShareTeamSnapshot(previousData, projectedTeamData);
|
||||||
const setStartedAt = performance.now();
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const nextCache =
|
const nextCache =
|
||||||
state.teamDataCacheByName[teamName] === nextTeamData
|
state.teamDataCacheByName[teamName] === nextTeamData
|
||||||
|
|
@ -3507,8 +3375,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
|
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
|
||||||
const setMs = performance.now() - setStartedAt;
|
|
||||||
const postStartedAt = performance.now();
|
|
||||||
const invalidationState = previousData
|
const invalidationState = previousData
|
||||||
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
|
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
|
||||||
: { cacheKeys: [], taskIds: [] };
|
: { cacheKeys: [], taskIds: [] };
|
||||||
|
|
@ -3518,20 +3384,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
if (invalidationState.taskIds.length > 0) {
|
if (invalidationState.taskIds.length > 0) {
|
||||||
await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds);
|
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) {
|
} catch (error) {
|
||||||
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
||||||
return;
|
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) => {
|
removeMember: async (teamName: string, memberName: string) => {
|
||||||
await unwrapIpc('team:removeMember', () => api.teams.removeMember(teamName, memberName));
|
await unwrapIpc('team:removeMember', () => api.teams.removeMember(teamName, memberName));
|
||||||
await get().refreshTeamData(teamName);
|
await get().refreshTeamData(teamName);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON,
|
|
||||||
getAvailableTeamProviderModelOptions,
|
getAvailableTeamProviderModelOptions,
|
||||||
getAvailableTeamProviderModels,
|
getAvailableTeamProviderModels,
|
||||||
getTeamModelSelectionError,
|
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([
|
const providerStatus = createCodexProviderStatus([
|
||||||
{
|
{
|
||||||
id: 'gpt-5.5',
|
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({
|
expect(getAvailableTeamProviderModelOptions('codex', providerStatus)[1]).toMatchObject({
|
||||||
value: 'gpt-5.5',
|
value: 'gpt-5.5',
|
||||||
label: '5.5',
|
label: '5.5',
|
||||||
badgeLabel: 'New',
|
badgeLabel: 'New',
|
||||||
availabilityStatus: null,
|
availabilityStatus: 'available',
|
||||||
});
|
});
|
||||||
expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toContain(
|
expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toBeNull();
|
||||||
CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps existing disabled model policy on top of the dynamic catalog', () => {
|
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',
|
spawning: 'bg-amber-400',
|
||||||
online: 'bg-emerald-400 animate-[dot-online-jelly_0.45s_ease-out]',
|
online: 'bg-emerald-400 animate-[dot-online-jelly_0.45s_ease-out]',
|
||||||
error: 'bg-red-400',
|
error: 'bg-red-400',
|
||||||
|
skipped: 'bg-zinc-500',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
|
export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
|
||||||
|
|
@ -127,6 +128,7 @@ export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
|
||||||
spawning: 'starting',
|
spawning: 'starting',
|
||||||
online: 'ready',
|
online: 'ready',
|
||||||
error: 'spawn failed',
|
error: 'spawn failed',
|
||||||
|
skipped: 'skipped',
|
||||||
};
|
};
|
||||||
|
|
||||||
function isLaunchStillStarting(
|
function isLaunchStillStarting(
|
||||||
|
|
@ -138,6 +140,9 @@ function isLaunchStillStarting(
|
||||||
if (spawnLaunchState === 'failed_to_start') {
|
if (spawnLaunchState === 'failed_to_start') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (spawnLaunchState === 'skipped_for_launch') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (spawnLaunchState === 'runtime_pending_permission') {
|
if (spawnLaunchState === 'runtime_pending_permission') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -171,6 +176,9 @@ export function getSpawnAwareDotClass(
|
||||||
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
||||||
return SPAWN_DOT_COLORS.error;
|
return SPAWN_DOT_COLORS.error;
|
||||||
}
|
}
|
||||||
|
if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') {
|
||||||
|
return SPAWN_DOT_COLORS.skipped;
|
||||||
|
}
|
||||||
if (spawnLaunchState === 'runtime_pending_permission') {
|
if (spawnLaunchState === 'runtime_pending_permission') {
|
||||||
return 'bg-amber-400 animate-pulse';
|
return 'bg-amber-400 animate-pulse';
|
||||||
}
|
}
|
||||||
|
|
@ -218,6 +226,9 @@ export function getSpawnAwarePresenceLabel(
|
||||||
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
||||||
return SPAWN_PRESENCE_LABELS.error;
|
return SPAWN_PRESENCE_LABELS.error;
|
||||||
}
|
}
|
||||||
|
if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') {
|
||||||
|
return SPAWN_PRESENCE_LABELS.skipped;
|
||||||
|
}
|
||||||
if (spawnLaunchState === 'runtime_pending_permission') {
|
if (spawnLaunchState === 'runtime_pending_permission') {
|
||||||
return 'connecting';
|
return 'connecting';
|
||||||
}
|
}
|
||||||
|
|
@ -259,6 +270,9 @@ export function getSpawnCardClass(
|
||||||
) {
|
) {
|
||||||
return 'member-waiting-shimmer';
|
return 'member-waiting-shimmer';
|
||||||
}
|
}
|
||||||
|
if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') {
|
||||||
|
return 'opacity-70';
|
||||||
|
}
|
||||||
if (spawnLaunchState === 'runtime_pending_permission') {
|
if (spawnLaunchState === 'runtime_pending_permission') {
|
||||||
return 'member-waiting-shimmer';
|
return 'member-waiting-shimmer';
|
||||||
}
|
}
|
||||||
|
|
@ -518,6 +532,7 @@ export function getLaunchAwarePresenceLabel(
|
||||||
basePresenceLabel === 'starting' ||
|
basePresenceLabel === 'starting' ||
|
||||||
basePresenceLabel === 'connecting' ||
|
basePresenceLabel === 'connecting' ||
|
||||||
basePresenceLabel === 'spawn failed' ||
|
basePresenceLabel === 'spawn failed' ||
|
||||||
|
basePresenceLabel === 'skipped' ||
|
||||||
basePresenceLabel === 'offline' ||
|
basePresenceLabel === 'offline' ||
|
||||||
basePresenceLabel === 'terminated'
|
basePresenceLabel === 'terminated'
|
||||||
) {
|
) {
|
||||||
|
|
@ -538,6 +553,7 @@ export type MemberLaunchVisualState =
|
||||||
| 'stale_runtime'
|
| 'stale_runtime'
|
||||||
| 'settling'
|
| 'settling'
|
||||||
| 'error'
|
| 'error'
|
||||||
|
| 'skipped'
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
export interface MemberLaunchPresentation {
|
export interface MemberLaunchPresentation {
|
||||||
|
|
@ -574,6 +590,8 @@ export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState)
|
||||||
return 'joining team';
|
return 'joining team';
|
||||||
case 'error':
|
case 'error':
|
||||||
return 'failed';
|
return 'failed';
|
||||||
|
case 'skipped':
|
||||||
|
return 'skipped';
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -643,6 +661,8 @@ export function buildMemberLaunchPresentation({
|
||||||
if (isTeamAlive !== false || isTeamProvisioning) {
|
if (isTeamAlive !== false || isTeamProvisioning) {
|
||||||
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
||||||
launchVisualState = 'error';
|
launchVisualState = 'error';
|
||||||
|
} else if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') {
|
||||||
|
launchVisualState = 'skipped';
|
||||||
} else if (spawnLaunchState === 'runtime_pending_permission') {
|
} else if (spawnLaunchState === 'runtime_pending_permission') {
|
||||||
launchVisualState = 'permission_pending';
|
launchVisualState = 'permission_pending';
|
||||||
} else if (runtimeEntry?.livenessKind === 'shell_only') {
|
} else if (runtimeEntry?.livenessKind === 'shell_only') {
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import type {
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON,
|
|
||||||
GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_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_MODEL,
|
||||||
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
|
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export {
|
||||||
type SupportedProviderId = CliProviderId | TeamProviderId;
|
type SupportedProviderId = CliProviderId | TeamProviderId;
|
||||||
type RuntimeAwareProviderStatus = Pick<
|
type RuntimeAwareProviderStatus = Pick<
|
||||||
CliProviderStatus,
|
CliProviderStatus,
|
||||||
'providerId' | 'authMethod' | 'backend' | 'modelCatalog' | 'runtimeCapabilities'
|
'providerId' | 'authMethod' | 'backend' | 'modelCatalog'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export interface TeamProviderModelOption {
|
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.';
|
'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 =
|
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.';
|
'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> = {
|
const TEAM_PROVIDER_LABELS: Record<SupportedProviderId, string> = {
|
||||||
anthropic: 'Anthropic',
|
anthropic: 'Anthropic',
|
||||||
|
|
@ -166,13 +164,6 @@ function getKnownTeamProviderModelOption(
|
||||||
return TEAM_PROVIDER_MODEL_OPTIONS[providerId].find((option) => option.value === trimmed);
|
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(
|
export function getTeamProviderModelOptions(
|
||||||
providerId: SupportedProviderId
|
providerId: SupportedProviderId
|
||||||
): readonly TeamProviderModelOption[] {
|
): readonly TeamProviderModelOption[] {
|
||||||
|
|
@ -471,18 +462,6 @@ export function getRuntimeAwareTeamModelUiDisabledReason(
|
||||||
return null;
|
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)
|
return isRuntimeHiddenTeamModel(providerId, trimmed, providerStatus)
|
||||||
? GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON
|
? GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON
|
||||||
: null;
|
: null;
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,11 @@ interface FailedSpawnDetail {
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SkippedSpawnDetail {
|
||||||
|
name: string;
|
||||||
|
reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
type PendingDiagnosticBucket =
|
type PendingDiagnosticBucket =
|
||||||
| 'shellOnly'
|
| 'shellOnly'
|
||||||
| 'runtimeProcess'
|
| 'runtimeProcess'
|
||||||
|
|
@ -55,6 +60,10 @@ function isFailedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean
|
||||||
return entry?.launchState === 'failed_to_start' || entry?.status === 'error';
|
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: {
|
function shouldPreferSnapshotEntryOverLive(params: {
|
||||||
liveEntry: MemberSpawnStatusEntry | undefined;
|
liveEntry: MemberSpawnStatusEntry | undefined;
|
||||||
snapshotEntry: MemberSpawnStatusEntry | undefined;
|
snapshotEntry: MemberSpawnStatusEntry | undefined;
|
||||||
|
|
@ -181,7 +190,12 @@ function getPendingDiagnosticNameGroups(params: {
|
||||||
snapshotEntry,
|
snapshotEntry,
|
||||||
snapshotUpdatedAt: params.memberSpawnSnapshotUpdatedAt,
|
snapshotUpdatedAt: params.memberSpawnSnapshotUpdatedAt,
|
||||||
});
|
});
|
||||||
if (!entry || entry.launchState === 'confirmed_alive' || isFailedSpawnEntry(entry)) {
|
if (
|
||||||
|
!entry ||
|
||||||
|
entry.launchState === 'confirmed_alive' ||
|
||||||
|
isFailedSpawnEntry(entry) ||
|
||||||
|
isSkippedSpawnEntry(entry)
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|
@ -326,6 +340,56 @@ function getFailedSpawnDetails(params: {
|
||||||
.sort((left, right) => left.name.localeCompare(right.name));
|
.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 {
|
function truncateFailureReason(reason: string, maxLength = 160): string {
|
||||||
const normalized = reason.replace(/\s+/g, ' ').trim();
|
const normalized = reason.replace(/\s+/g, ' ').trim();
|
||||||
if (normalized.length <= maxLength) {
|
if (normalized.length <= maxLength) {
|
||||||
|
|
@ -381,6 +445,42 @@ function buildGenericFailedSpawnPanelMessage(
|
||||||
return `${failedSpawnCount}/${Math.max(expectedTeammateCount, failedSpawnCount)} teammates failed to start`;
|
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 {
|
export interface TeamProvisioningPresentation {
|
||||||
progress: TeamProvisioningProgress;
|
progress: TeamProvisioningProgress;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
|
@ -393,6 +493,7 @@ export interface TeamProvisioningPresentation {
|
||||||
processOnlyAliveCount: number;
|
processOnlyAliveCount: number;
|
||||||
pendingSpawnCount: number;
|
pendingSpawnCount: number;
|
||||||
failedSpawnCount: number;
|
failedSpawnCount: number;
|
||||||
|
skippedSpawnCount: number;
|
||||||
allTeammatesConfirmedAlive: boolean;
|
allTeammatesConfirmedAlive: boolean;
|
||||||
hasMembersStillJoining: boolean;
|
hasMembersStillJoining: boolean;
|
||||||
remainingJoinCount: number;
|
remainingJoinCount: number;
|
||||||
|
|
@ -454,6 +555,7 @@ export function buildTeamProvisioningPresentation({
|
||||||
processOnlyAliveCount,
|
processOnlyAliveCount,
|
||||||
pendingSpawnCount,
|
pendingSpawnCount,
|
||||||
failedSpawnCount,
|
failedSpawnCount,
|
||||||
|
skippedSpawnCount,
|
||||||
} = getLaunchJoinMilestonesFromMembers({
|
} = getLaunchJoinMilestonesFromMembers({
|
||||||
members,
|
members,
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
|
|
@ -470,6 +572,13 @@ export function buildTeamProvisioningPresentation({
|
||||||
failedSpawnCount,
|
failedSpawnCount,
|
||||||
expectedTeammateCount
|
expectedTeammateCount
|
||||||
);
|
);
|
||||||
|
const skippedSpawnDetails = getSkippedSpawnDetails({
|
||||||
|
memberSpawnStatuses,
|
||||||
|
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
|
||||||
|
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
|
||||||
|
});
|
||||||
|
const skippedSpawnPanelMessage = buildSkippedSpawnPanelMessage(skippedSpawnDetails);
|
||||||
|
const skippedSpawnCompactDetail = buildSkippedSpawnCompactDetail(skippedSpawnDetails);
|
||||||
const permissionBlockedCount = countPermissionBlockedMembers({
|
const permissionBlockedCount = countPermissionBlockedMembers({
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
|
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
|
||||||
|
|
@ -483,6 +592,7 @@ export function buildTeamProvisioningPresentation({
|
||||||
processOnlyAliveCount,
|
processOnlyAliveCount,
|
||||||
pendingSpawnCount,
|
pendingSpawnCount,
|
||||||
failedSpawnCount,
|
failedSpawnCount,
|
||||||
|
skippedSpawnCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
const progressStepIndex = getDisplayStepIndex({
|
const progressStepIndex = getDisplayStepIndex({
|
||||||
|
|
@ -492,6 +602,7 @@ export function buildTeamProvisioningPresentation({
|
||||||
processOnlyAliveCount,
|
processOnlyAliveCount,
|
||||||
pendingSpawnCount,
|
pendingSpawnCount,
|
||||||
failedSpawnCount,
|
failedSpawnCount,
|
||||||
|
skippedSpawnCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isFailed) {
|
if (isFailed) {
|
||||||
|
|
@ -507,6 +618,7 @@ export function buildTeamProvisioningPresentation({
|
||||||
processOnlyAliveCount,
|
processOnlyAliveCount,
|
||||||
pendingSpawnCount,
|
pendingSpawnCount,
|
||||||
failedSpawnCount,
|
failedSpawnCount,
|
||||||
|
skippedSpawnCount,
|
||||||
allTeammatesConfirmedAlive,
|
allTeammatesConfirmedAlive,
|
||||||
hasMembersStillJoining,
|
hasMembersStillJoining,
|
||||||
remainingJoinCount,
|
remainingJoinCount,
|
||||||
|
|
@ -542,6 +654,9 @@ export function buildTeamProvisioningPresentation({
|
||||||
failedSpawnCount > 0
|
failedSpawnCount > 0
|
||||||
? (failedSpawnCompactDetail ??
|
? (failedSpawnCompactDetail ??
|
||||||
`${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`)
|
`${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`)
|
||||||
|
: skippedSpawnCount > 0
|
||||||
|
? (skippedSpawnCompactDetail ??
|
||||||
|
`${skippedSpawnCount} teammate${skippedSpawnCount === 1 ? '' : 's'} skipped`)
|
||||||
: hasMembersStillJoining
|
: hasMembersStillJoining
|
||||||
? pendingDetailPhrase
|
? pendingDetailPhrase
|
||||||
: expectedTeammateCount === 0
|
: expectedTeammateCount === 0
|
||||||
|
|
@ -550,6 +665,9 @@ export function buildTeamProvisioningPresentation({
|
||||||
const readyDetailMessage =
|
const readyDetailMessage =
|
||||||
failedSpawnCount > 0
|
failedSpawnCount > 0
|
||||||
? (failedSpawnPanelMessage ?? genericFailedSpawnPanelMessage ?? progress.message)
|
? (failedSpawnPanelMessage ?? genericFailedSpawnPanelMessage ?? progress.message)
|
||||||
|
: skippedSpawnCount > 0
|
||||||
|
? (skippedSpawnPanelMessage ??
|
||||||
|
`${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped for this launch`)
|
||||||
: expectedTeammateCount === 0
|
: expectedTeammateCount === 0
|
||||||
? 'Team provisioned - lead online'
|
? 'Team provisioned - lead online'
|
||||||
: allTeammatesConfirmedAlive
|
: allTeammatesConfirmedAlive
|
||||||
|
|
@ -558,10 +676,16 @@ export function buildTeamProvisioningPresentation({
|
||||||
? pendingDetailPhrase
|
? pendingDetailPhrase
|
||||||
: 'Team provisioned - teammates are still joining';
|
: 'Team provisioned - teammates are still joining';
|
||||||
const readyDetailSeverity =
|
const readyDetailSeverity =
|
||||||
failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'info' : undefined;
|
failedSpawnCount > 0 || skippedSpawnCount > 0
|
||||||
|
? 'warning'
|
||||||
|
: hasMembersStillJoining
|
||||||
|
? 'info'
|
||||||
|
: undefined;
|
||||||
const readyMessage =
|
const readyMessage =
|
||||||
failedSpawnCount > 0
|
failedSpawnCount > 0
|
||||||
? `Launch finished with errors - ${failedSpawnCount}/${Math.max(expectedTeammateCount, failedSpawnCount)} teammates failed to start`
|
? `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
|
: expectedTeammateCount === 0
|
||||||
? 'Team launched - lead online'
|
? 'Team launched - lead online'
|
||||||
: allTeammatesConfirmedAlive
|
: allTeammatesConfirmedAlive
|
||||||
|
|
@ -579,27 +703,45 @@ export function buildTeamProvisioningPresentation({
|
||||||
processOnlyAliveCount,
|
processOnlyAliveCount,
|
||||||
pendingSpawnCount,
|
pendingSpawnCount,
|
||||||
failedSpawnCount,
|
failedSpawnCount,
|
||||||
|
skippedSpawnCount,
|
||||||
allTeammatesConfirmedAlive,
|
allTeammatesConfirmedAlive,
|
||||||
hasMembersStillJoining,
|
hasMembersStillJoining,
|
||||||
remainingJoinCount,
|
remainingJoinCount,
|
||||||
panelTitle: 'Launch details',
|
panelTitle: 'Launch details',
|
||||||
panelMessage: failedSpawnCount > 0 || hasMembersStillJoining ? readyDetailMessage : null,
|
panelMessage:
|
||||||
|
failedSpawnCount > 0 || skippedSpawnCount > 0 || hasMembersStillJoining
|
||||||
|
? readyDetailMessage
|
||||||
|
: null,
|
||||||
panelMessageSeverity: readyDetailSeverity,
|
panelMessageSeverity: readyDetailSeverity,
|
||||||
successMessage: readyMessage,
|
successMessage: readyMessage,
|
||||||
successMessageSeverity:
|
successMessageSeverity:
|
||||||
failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'info' : 'success',
|
failedSpawnCount > 0 || skippedSpawnCount > 0
|
||||||
|
? 'warning'
|
||||||
|
: hasMembersStillJoining
|
||||||
|
? 'info'
|
||||||
|
: 'success',
|
||||||
defaultLiveOutputOpen: false,
|
defaultLiveOutputOpen: false,
|
||||||
compactTitle:
|
compactTitle:
|
||||||
failedSpawnCount > 0
|
failedSpawnCount > 0
|
||||||
? 'Launch finished with errors'
|
? 'Launch finished with errors'
|
||||||
|
: skippedSpawnCount > 0
|
||||||
|
? 'Launch continued with skipped teammates'
|
||||||
: hasMembersStillJoining
|
: hasMembersStillJoining
|
||||||
? 'Finishing launch'
|
? 'Finishing launch'
|
||||||
: 'Team launched',
|
: 'Team launched',
|
||||||
compactDetail: readyCompactDetail,
|
compactDetail: readyCompactDetail,
|
||||||
compactTone:
|
compactTone:
|
||||||
failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'default' : 'success',
|
failedSpawnCount > 0 || skippedSpawnCount > 0
|
||||||
|
? 'warning'
|
||||||
|
: hasMembersStillJoining
|
||||||
|
? 'default'
|
||||||
|
: 'success',
|
||||||
currentStepIndex:
|
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,
|
processOnlyAliveCount,
|
||||||
pendingSpawnCount,
|
pendingSpawnCount,
|
||||||
failedSpawnCount,
|
failedSpawnCount,
|
||||||
|
skippedSpawnCount,
|
||||||
allTeammatesConfirmedAlive,
|
allTeammatesConfirmedAlive,
|
||||||
hasMembersStillJoining,
|
hasMembersStillJoining,
|
||||||
remainingJoinCount,
|
remainingJoinCount,
|
||||||
|
|
@ -640,18 +783,25 @@ export function buildTeamProvisioningPresentation({
|
||||||
panelMessage:
|
panelMessage:
|
||||||
failedSpawnCount > 0
|
failedSpawnCount > 0
|
||||||
? (failedSpawnPanelMessage ?? genericFailedSpawnPanelMessage ?? progress.message)
|
? (failedSpawnPanelMessage ?? genericFailedSpawnPanelMessage ?? progress.message)
|
||||||
|
: skippedSpawnCount > 0
|
||||||
|
? (skippedSpawnPanelMessage ??
|
||||||
|
`${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped for this launch`)
|
||||||
: hasMembersStillJoining &&
|
: hasMembersStillJoining &&
|
||||||
permissionBlockedCount > 0 &&
|
permissionBlockedCount > 0 &&
|
||||||
permissionBlockedCount === remainingJoinCount
|
permissionBlockedCount === remainingJoinCount
|
||||||
? activePendingDetailPhrase
|
? activePendingDetailPhrase
|
||||||
: progress.message,
|
: progress.message,
|
||||||
panelMessageSeverity: failedSpawnCount > 0 ? 'warning' : progress.messageSeverity,
|
panelMessageSeverity:
|
||||||
|
failedSpawnCount > 0 || skippedSpawnCount > 0 ? 'warning' : progress.messageSeverity,
|
||||||
defaultLiveOutputOpen: false,
|
defaultLiveOutputOpen: false,
|
||||||
compactTitle: 'Launching team',
|
compactTitle: 'Launching team',
|
||||||
compactDetail:
|
compactDetail:
|
||||||
failedSpawnCount > 0
|
failedSpawnCount > 0
|
||||||
? (failedSpawnCompactDetail ??
|
? (failedSpawnCompactDetail ??
|
||||||
`${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`)
|
`${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`)
|
||||||
|
: skippedSpawnCount > 0
|
||||||
|
? (skippedSpawnCompactDetail ??
|
||||||
|
`${skippedSpawnCount} teammate${skippedSpawnCount === 1 ? '' : 's'} skipped`)
|
||||||
: hasMembersStillJoining && failedSpawnCount === 0 && permissionBlockedCount > 0
|
: hasMembersStillJoining && failedSpawnCount === 0 && permissionBlockedCount > 0
|
||||||
? permissionBlockedCount === remainingJoinCount
|
? permissionBlockedCount === remainingJoinCount
|
||||||
? buildAwaitingPermissionPhrase(permissionBlockedCount)
|
? buildAwaitingPermissionPhrase(permissionBlockedCount)
|
||||||
|
|
@ -659,7 +809,7 @@ export function buildTeamProvisioningPresentation({
|
||||||
: expectedTeammateCount > 0 && progressStepIndex >= 2
|
: expectedTeammateCount > 0 && progressStepIndex >= 2
|
||||||
? `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed`
|
? `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed`
|
||||||
: progress.message,
|
: 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>;
|
getMemberSpawnStatuses: (teamName: string) => Promise<MemberSpawnStatusesSnapshot>;
|
||||||
getTeamAgentRuntime: (teamName: string) => Promise<TeamAgentRuntimeSnapshot>;
|
getTeamAgentRuntime: (teamName: string) => Promise<TeamAgentRuntimeSnapshot>;
|
||||||
restartMember: (teamName: string, memberName: string) => Promise<void>;
|
restartMember: (teamName: string, memberName: string) => Promise<void>;
|
||||||
|
skipMemberForLaunch: (teamName: string, memberName: string) => Promise<void>;
|
||||||
softDeleteTask: (teamName: string, taskId: string) => Promise<void>;
|
softDeleteTask: (teamName: string, taskId: string) => Promise<void>;
|
||||||
restoreTask: (teamName: string, taskId: string) => Promise<void>;
|
restoreTask: (teamName: string, taskId: string) => Promise<void>;
|
||||||
getDeletedTasks: (teamName: string) => Promise<TeamTask[]>;
|
getDeletedTasks: (teamName: string) => Promise<TeamTask[]>;
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,8 @@ export interface TeamSummary {
|
||||||
confirmedMemberCount?: number;
|
confirmedMemberCount?: number;
|
||||||
/** Missing teammate names from the last partial launch marker. */
|
/** Missing teammate names from the last partial launch marker. */
|
||||||
missingMembers?: string[];
|
missingMembers?: string[];
|
||||||
|
/** Teammates intentionally skipped for the last launch. */
|
||||||
|
skippedMembers?: string[];
|
||||||
/** Durable aggregate launch state derived from persisted launch-state evidence. */
|
/** Durable aggregate launch state derived from persisted launch-state evidence. */
|
||||||
teamLaunchState?: TeamLaunchAggregateState;
|
teamLaunchState?: TeamLaunchAggregateState;
|
||||||
/** ISO timestamp of the last durable launch-state evaluation. */
|
/** ISO timestamp of the last durable launch-state evaluation. */
|
||||||
|
|
@ -81,6 +83,7 @@ export interface TeamSummary {
|
||||||
confirmedCount?: number;
|
confirmedCount?: number;
|
||||||
pendingCount?: number;
|
pendingCount?: number;
|
||||||
failedCount?: number;
|
failedCount?: number;
|
||||||
|
skippedCount?: number;
|
||||||
runtimeAlivePendingCount?: number;
|
runtimeAlivePendingCount?: number;
|
||||||
shellOnlyPendingCount?: number;
|
shellOnlyPendingCount?: number;
|
||||||
runtimeProcessPendingCount?: number;
|
runtimeProcessPendingCount?: number;
|
||||||
|
|
@ -683,14 +686,19 @@ export interface AddTaskCommentRequest {
|
||||||
|
|
||||||
export type MemberStatus = 'active' | 'idle' | 'terminated' | 'unknown';
|
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 =
|
export type MemberLaunchState =
|
||||||
| 'starting'
|
| 'starting'
|
||||||
| 'runtime_pending_bootstrap'
|
| 'runtime_pending_bootstrap'
|
||||||
| 'runtime_pending_permission'
|
| 'runtime_pending_permission'
|
||||||
| 'confirmed_alive'
|
| 'confirmed_alive'
|
||||||
| 'failed_to_start';
|
| 'failed_to_start'
|
||||||
export type TeamLaunchAggregateState = 'clean_success' | 'partial_pending' | 'partial_failure';
|
| 'skipped_for_launch';
|
||||||
|
export type TeamLaunchAggregateState =
|
||||||
|
| 'clean_success'
|
||||||
|
| 'partial_pending'
|
||||||
|
| 'partial_failure'
|
||||||
|
| 'partial_skipped';
|
||||||
export type PersistedTeamLaunchPhase = 'active' | 'finished' | 'reconciled';
|
export type PersistedTeamLaunchPhase = 'active' | 'finished' | 'reconciled';
|
||||||
|
|
||||||
export type KanbanColumnId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved';
|
export type KanbanColumnId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved';
|
||||||
|
|
@ -939,6 +947,9 @@ export interface PersistedTeamLaunchMemberState {
|
||||||
laneOwnerProviderId?: TeamProviderId;
|
laneOwnerProviderId?: TeamProviderId;
|
||||||
launchIdentity?: ProviderModelLaunchIdentity;
|
launchIdentity?: ProviderModelLaunchIdentity;
|
||||||
launchState: MemberLaunchState;
|
launchState: MemberLaunchState;
|
||||||
|
skippedForLaunch?: boolean;
|
||||||
|
skipReason?: string;
|
||||||
|
skippedAt?: string;
|
||||||
agentToolAccepted: boolean;
|
agentToolAccepted: boolean;
|
||||||
runtimeAlive: boolean;
|
runtimeAlive: boolean;
|
||||||
bootstrapConfirmed: boolean;
|
bootstrapConfirmed: boolean;
|
||||||
|
|
@ -964,6 +975,7 @@ export interface PersistedTeamLaunchSummary {
|
||||||
confirmedCount: number;
|
confirmedCount: number;
|
||||||
pendingCount: number;
|
pendingCount: number;
|
||||||
failedCount: number;
|
failedCount: number;
|
||||||
|
skippedCount?: number;
|
||||||
runtimeAlivePendingCount: number;
|
runtimeAlivePendingCount: number;
|
||||||
shellOnlyPendingCount?: number;
|
shellOnlyPendingCount?: number;
|
||||||
runtimeProcessPendingCount?: number;
|
runtimeProcessPendingCount?: number;
|
||||||
|
|
@ -1092,6 +1104,10 @@ export interface MemberSpawnStatusEntry {
|
||||||
error?: string;
|
error?: string;
|
||||||
/** Hard failure reason for failed_to_start. */
|
/** Hard failure reason for failed_to_start. */
|
||||||
hardFailureReason?: string;
|
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`.
|
* Optional provenance for `online`.
|
||||||
* - heartbeat: teammate sent a real inbox/native message after bootstrap
|
* - 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 type * as FsType from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { Readable } from 'stream';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
vi.mock('@shared/utils/logger', () => ({
|
vi.mock('@shared/utils/logger', () => ({
|
||||||
|
|
@ -500,6 +501,69 @@ describe('FileWatcher', () => {
|
||||||
|
|
||||||
watcher.stop();
|
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';
|
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient';
|
||||||
import { OpenCodeReadinessBridge } from '../../../../src/main/services/team/opencode/bridge/OpenCodeReadinessBridge';
|
import { OpenCodeReadinessBridge } from '../../../../src/main/services/team/opencode/bridge/OpenCodeReadinessBridge';
|
||||||
import { OpenCodeStateChangingBridgeCommandService } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
|
import { OpenCodeStateChangingBridgeCommandService } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
|
||||||
import {
|
import { getTeamBootstrapStatePath } from '../../../../src/main/services/team/TeamBootstrapStateReader';
|
||||||
getTeamBootstrapStatePath,
|
|
||||||
} from '../../../../src/main/services/team/TeamBootstrapStateReader';
|
|
||||||
import { TeamMembersMetaStore } from '../../../../src/main/services/team/TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from '../../../../src/main/services/team/TeamMembersMetaStore';
|
||||||
import { TeamMetaStore } from '../../../../src/main/services/team/TeamMetaStore';
|
import { TeamMetaStore } from '../../../../src/main/services/team/TeamMetaStore';
|
||||||
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
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 });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it(
|
it('recovers active mixed OpenCode side lanes from live runtime reconcile instead of marking them never spawned', async () => {
|
||||||
'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 selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL;
|
||||||
const orchestratorCli =
|
const orchestratorCli =
|
||||||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
|
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,
|
reconcileTimeoutMs: 90_000,
|
||||||
stopTimeoutMs: 90_000,
|
stopTimeoutMs: 90_000,
|
||||||
});
|
});
|
||||||
const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge, {
|
const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge);
|
||||||
launchMode: 'dogfood',
|
|
||||||
});
|
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
||||||
|
|
||||||
|
|
@ -146,15 +140,15 @@ liveDescribe('OpenCode mixed recovery live e2e', () => {
|
||||||
launchState: 'confirmed_alive',
|
launchState: 'confirmed_alive',
|
||||||
});
|
});
|
||||||
expect(result.statuses.bob.error).toBeUndefined();
|
expect(result.statuses.bob.error).toBeUndefined();
|
||||||
await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject(
|
await expect(
|
||||||
{
|
readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)
|
||||||
|
).resolves.toMatchObject({
|
||||||
lanes: {
|
lanes: {
|
||||||
[launchInput.laneId ?? 'secondary:opencode:bob']: {
|
[launchInput.laneId ?? 'secondary:opencode:bob']: {
|
||||||
state: 'active',
|
state: 'active',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
});
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
for (const launchInput of launchedLanes) {
|
for (const launchInput of launchedLanes) {
|
||||||
await adapter
|
await adapter
|
||||||
|
|
@ -171,9 +165,7 @@ liveDescribe('OpenCode mixed recovery live e2e', () => {
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}, 240_000);
|
||||||
240_000
|
|
||||||
);
|
|
||||||
|
|
||||||
liveMultiLaneIt(
|
liveMultiLaneIt(
|
||||||
'recovers multiple active mixed OpenCode side lanes from live runtime reconcile',
|
'recovers multiple active mixed OpenCode side lanes from live runtime reconcile',
|
||||||
|
|
@ -207,9 +199,7 @@ liveDescribe('OpenCode mixed recovery live e2e', () => {
|
||||||
reconcileTimeoutMs: 90_000,
|
reconcileTimeoutMs: 90_000,
|
||||||
stopTimeoutMs: 90_000,
|
stopTimeoutMs: 90_000,
|
||||||
});
|
});
|
||||||
const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge, {
|
const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge);
|
||||||
launchMode: 'dogfood',
|
|
||||||
});
|
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
||||||
|
|
||||||
|
|
@ -259,16 +249,16 @@ liveDescribe('OpenCode mixed recovery live e2e', () => {
|
||||||
});
|
});
|
||||||
expect(result.statuses[memberName]?.error).toBeUndefined();
|
expect(result.statuses[memberName]?.error).toBeUndefined();
|
||||||
}
|
}
|
||||||
await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject(
|
await expect(
|
||||||
{
|
readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)
|
||||||
|
).resolves.toMatchObject({
|
||||||
lanes: Object.fromEntries(
|
lanes: Object.fromEntries(
|
||||||
sideMembers.map((memberName) => [
|
sideMembers.map((memberName) => [
|
||||||
`secondary:opencode:${memberName}`,
|
`secondary:opencode:${memberName}`,
|
||||||
{ state: 'active' },
|
{ state: 'active' },
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
}
|
});
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
for (const launchInput of launchedLanes) {
|
for (const launchInput of launchedLanes) {
|
||||||
await adapter
|
await adapter
|
||||||
|
|
@ -360,10 +350,7 @@ async function writeMixedRecoveryFixtures(input: {
|
||||||
name: input.teamName,
|
name: input.teamName,
|
||||||
projectPath: input.projectPath,
|
projectPath: input.projectPath,
|
||||||
leadSessionId: 'lead-session',
|
leadSessionId: 'lead-session',
|
||||||
members: [
|
members: [{ name: 'team-lead', agentType: 'team-lead' }, { name: 'alice' }],
|
||||||
{ name: 'team-lead', agentType: 'team-lead' },
|
|
||||||
{ name: 'alice' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2
|
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 {
|
import {
|
||||||
OpenCodeReadinessBridge,
|
OpenCodeReadinessBridge,
|
||||||
type OpenCodeReadinessBridgeCommandExecutor,
|
type OpenCodeReadinessBridgeCommandExecutor,
|
||||||
type OpenCodeProductionE2EEvidenceReadPort,
|
|
||||||
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeReadinessBridge';
|
} 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 { OpenCodeTeamLaunchReadiness } from '../../../../src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -93,114 +82,6 @@ describe('OpenCodeReadinessBridge', () => {
|
||||||
expect(bridge.getLastOpenCodeRuntimeSnapshot('/repo')).toBeNull();
|
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 () => {
|
it('routes state-changing launch commands through the guarded command service when configured', async () => {
|
||||||
const executor = fakeExecutor(
|
const executor = fakeExecutor(
|
||||||
bridgeFailure('internal_error', 'direct bridge must not run', [])
|
bridgeFailure('internal_error', 'direct bridge must not run', [])
|
||||||
|
|
@ -231,7 +112,6 @@ describe('OpenCodeReadinessBridge', () => {
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
bridge.launchOpenCodeTeam({
|
bridge.launchOpenCodeTeam({
|
||||||
mode: 'dogfood',
|
|
||||||
runId: 'run-1',
|
runId: 'run-1',
|
||||||
laneId: 'primary',
|
laneId: 'primary',
|
||||||
teamId: 'team-a',
|
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(
|
function bridgeSuccess(
|
||||||
data: OpenCodeTeamLaunchReadiness
|
data: OpenCodeTeamLaunchReadiness
|
||||||
): OpenCodeBridgeSuccess<OpenCodeTeamLaunchReadiness> {
|
): OpenCodeBridgeSuccess<OpenCodeTeamLaunchReadiness> {
|
||||||
|
|
@ -379,65 +246,3 @@ function readiness(
|
||||||
...overrides,
|
...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 { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { createEmptyEndpointMap } from '../../../../src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities';
|
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 { REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
|
||||||
import {
|
import {
|
||||||
OpenCodeTeamLaunchReadinessService,
|
OpenCodeTeamLaunchReadinessService,
|
||||||
type OpenCodeApiCapabilityPort,
|
type OpenCodeApiCapabilityPort,
|
||||||
type OpenCodeModelExecutionProbePort,
|
type OpenCodeModelExecutionProbePort,
|
||||||
type OpenCodeMcpToolProofPort,
|
type OpenCodeMcpToolProofPort,
|
||||||
type OpenCodeProductionE2EEvidencePort,
|
|
||||||
type OpenCodeRuntimeInventory,
|
type OpenCodeRuntimeInventory,
|
||||||
type OpenCodeRuntimeInventoryPort,
|
type OpenCodeRuntimeInventoryPort,
|
||||||
type OpenCodeRuntimeStoreReadinessPort,
|
type OpenCodeRuntimeStoreReadinessPort,
|
||||||
|
|
@ -23,7 +18,6 @@ import type {
|
||||||
} from '../../../../src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities';
|
} from '../../../../src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities';
|
||||||
import type { OpenCodeMcpToolProof } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
|
import type { OpenCodeMcpToolProof } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
|
||||||
import type { RuntimeStoreReadinessCheck } from '../../../../src/main/services/team/opencode/store/RuntimeStoreManifest';
|
import type { RuntimeStoreReadinessCheck } from '../../../../src/main/services/team/opencode/store/RuntimeStoreManifest';
|
||||||
import type { OpenCodeProductionE2EEvidence } from '../../../../src/main/services/team/opencode/version/OpenCodeVersionPolicy';
|
|
||||||
|
|
||||||
describe('OpenCodeTeamLaunchReadinessService', () => {
|
describe('OpenCodeTeamLaunchReadinessService', () => {
|
||||||
it('returns not_installed before probing deeper runtime dependencies', async () => {
|
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 () => {
|
it('does not require project-specific E2E evidence before runtime readiness checks', async () => {
|
||||||
const ports = createPorts({
|
const ports = createPorts();
|
||||||
evidence: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(service(ports).check(readinessInput())).resolves.toMatchObject({
|
await expect(service(ports).check(readinessInput())).resolves.toMatchObject({
|
||||||
state: 'e2e_missing',
|
state: 'ready',
|
||||||
launchAllowed: false,
|
launchAllowed: true,
|
||||||
supportLevel: 'supported_e2e_pending',
|
supportLevel: 'production_supported',
|
||||||
missing: ['OpenCode version is capability-compatible but production E2E evidence is missing'],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -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();
|
const ports = createPorts();
|
||||||
|
|
||||||
await expect(
|
await expect(service(ports).check(readinessInput())).resolves.toMatchObject({
|
||||||
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({
|
|
||||||
state: 'ready',
|
state: 'ready',
|
||||||
launchAllowed: true,
|
launchAllowed: true,
|
||||||
modelId: 'openai/gpt-5.4-mini',
|
modelId: 'openai/gpt-5.4-mini',
|
||||||
|
|
@ -218,20 +173,13 @@ describe('OpenCodeTeamLaunchReadinessService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function service(
|
function service(ports: ReturnType<typeof createPorts>): OpenCodeTeamLaunchReadinessService {
|
||||||
ports: ReturnType<typeof createPorts>,
|
|
||||||
options: { adapterEnabled?: boolean; launchMode?: 'disabled' | 'dogfood' | 'production' } = {}
|
|
||||||
): OpenCodeTeamLaunchReadinessService {
|
|
||||||
return new OpenCodeTeamLaunchReadinessService(
|
return new OpenCodeTeamLaunchReadinessService(
|
||||||
ports.inventory,
|
ports.inventory,
|
||||||
ports.capabilities,
|
ports.capabilities,
|
||||||
ports.mcpTools,
|
ports.mcpTools,
|
||||||
ports.runtimeStores,
|
ports.runtimeStores,
|
||||||
ports.modelExecution,
|
ports.modelExecution
|
||||||
ports.e2eEvidence,
|
|
||||||
options.launchMode
|
|
||||||
? { launchMode: options.launchMode }
|
|
||||||
: { adapterEnabled: options.adapterEnabled ?? true }
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -261,7 +209,6 @@ function createPorts(
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
diagnostics: string[];
|
diagnostics: string[];
|
||||||
};
|
};
|
||||||
evidence?: OpenCodeProductionE2EEvidence | null;
|
|
||||||
} = {}
|
} = {}
|
||||||
): {
|
): {
|
||||||
inventory: OpenCodeRuntimeInventoryPort & { probe: ReturnType<typeof vi.fn> };
|
inventory: OpenCodeRuntimeInventoryPort & { probe: ReturnType<typeof vi.fn> };
|
||||||
|
|
@ -269,7 +216,6 @@ function createPorts(
|
||||||
mcpTools: OpenCodeMcpToolProofPort & { prove: ReturnType<typeof vi.fn> };
|
mcpTools: OpenCodeMcpToolProofPort & { prove: ReturnType<typeof vi.fn> };
|
||||||
runtimeStores: OpenCodeRuntimeStoreReadinessPort & { check: ReturnType<typeof vi.fn> };
|
runtimeStores: OpenCodeRuntimeStoreReadinessPort & { check: ReturnType<typeof vi.fn> };
|
||||||
modelExecution: OpenCodeModelExecutionProbePort & { verify: ReturnType<typeof vi.fn> };
|
modelExecution: OpenCodeModelExecutionProbePort & { verify: ReturnType<typeof vi.fn> };
|
||||||
e2eEvidence: OpenCodeProductionE2EEvidencePort & { read: ReturnType<typeof vi.fn> };
|
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
inventory: {
|
inventory: {
|
||||||
|
|
@ -287,9 +233,6 @@ function createPorts(
|
||||||
modelExecution: {
|
modelExecution: {
|
||||||
verify: vi.fn(async () => overrides.modelProbe ?? modelProbe()),
|
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: [],
|
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 });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it(
|
it('creates and stops a pure OpenCode team through TeamProvisioningService using the live runtime adapter', async () => {
|
||||||
'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 selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL;
|
||||||
const orchestratorCli =
|
const orchestratorCli =
|
||||||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
|
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,
|
reconcileTimeoutMs: 90_000,
|
||||||
stopTimeoutMs: 90_000,
|
stopTimeoutMs: 90_000,
|
||||||
});
|
});
|
||||||
const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge, {
|
const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge);
|
||||||
launchMode: 'dogfood',
|
|
||||||
});
|
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
||||||
|
|
||||||
|
|
@ -155,15 +151,15 @@ liveDescribe('OpenCode team provisioning live e2e', () => {
|
||||||
alive: true,
|
alive: true,
|
||||||
runtimeModel: selectedModel,
|
runtimeModel: selectedModel,
|
||||||
});
|
});
|
||||||
await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject(
|
await expect(
|
||||||
{
|
readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)
|
||||||
|
).resolves.toMatchObject({
|
||||||
lanes: {
|
lanes: {
|
||||||
primary: {
|
primary: {
|
||||||
state: 'active',
|
state: 'active',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
svc.stopTeam(teamName);
|
svc.stopTeam(teamName);
|
||||||
await waitUntil(async () => {
|
await waitUntil(async () => {
|
||||||
|
|
@ -173,9 +169,7 @@ liveDescribe('OpenCode team provisioning live e2e', () => {
|
||||||
} finally {
|
} finally {
|
||||||
svc.stopTeam(teamName);
|
svc.stopTeam(teamName);
|
||||||
}
|
}
|
||||||
},
|
}, 300_000);
|
||||||
300_000
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function createStateChangingCommands(input: {
|
function createStateChangingCommands(input: {
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
diagnostics: ['OpenCode missing canonical app MCP tool id'],
|
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({
|
await expect(adapter.prepare(launchInput())).resolves.toEqual({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|
@ -34,13 +34,12 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
projectPath: '/repo',
|
projectPath: '/repo',
|
||||||
selectedModel: 'openai/gpt-5.4-mini',
|
selectedModel: 'openai/gpt-5.4-mini',
|
||||||
requireExecutionProbe: true,
|
requireExecutionProbe: true,
|
||||||
launchMode: 'production',
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses runtime-only readiness for model-less preflight checks', async () => {
|
it('uses runtime-only readiness for model-less preflight checks', async () => {
|
||||||
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true, modelId: null }));
|
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true, modelId: null }));
|
||||||
const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' });
|
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
adapter.prepare(launchInput({ model: undefined, runtimeOnly: true }))
|
adapter.prepare(launchInput({ model: undefined, runtimeOnly: true }))
|
||||||
|
|
@ -54,7 +53,6 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
projectPath: '/repo',
|
projectPath: '/repo',
|
||||||
selectedModel: null,
|
selectedModel: null,
|
||||||
requireExecutionProbe: false,
|
requireExecutionProbe: false,
|
||||||
launchMode: undefined,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -69,7 +67,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
missing: ['OpenCode bridge command timed out'],
|
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({
|
await expect(adapter.launch(launchInput())).resolves.toMatchObject({
|
||||||
teamLaunchState: 'partial_failure',
|
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 () => {
|
it('rejects non-OpenCode members before readiness or launch bridge dispatch', async () => {
|
||||||
const launchOpenCodeTeam = vi.fn();
|
const launchOpenCodeTeam = vi.fn();
|
||||||
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||||
launchOpenCodeTeam,
|
launchOpenCodeTeam,
|
||||||
});
|
});
|
||||||
const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' });
|
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
|
||||||
|
|
||||||
const result = await adapter.launch(
|
const result = await adapter.launch(
|
||||||
launchInput({
|
launchInput({
|
||||||
|
|
@ -138,7 +123,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||||
launchOpenCodeTeam,
|
launchOpenCodeTeam,
|
||||||
});
|
});
|
||||||
const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' });
|
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
|
||||||
|
|
||||||
const result = await adapter.launch(launchInput({ expectedMembers: [] }));
|
const result = await adapter.launch(launchInput({ expectedMembers: [] }));
|
||||||
|
|
||||||
|
|
@ -187,8 +172,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
capabilitySnapshotId: 'cap-1',
|
capabilitySnapshotId: 'cap-1',
|
||||||
})),
|
})),
|
||||||
launchOpenCodeTeam,
|
launchOpenCodeTeam,
|
||||||
}),
|
})
|
||||||
{ launchMode: 'dogfood' }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(adapter.launch(launchInput())).resolves.toMatchObject({
|
await expect(adapter.launch(launchInput())).resolves.toMatchObject({
|
||||||
|
|
@ -257,8 +241,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
const adapter = new OpenCodeTeamRuntimeAdapter(
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||||
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||||
launchOpenCodeTeam,
|
launchOpenCodeTeam,
|
||||||
}),
|
})
|
||||||
{ launchMode: 'dogfood' }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await adapter.launch({
|
const result = await adapter.launch({
|
||||||
|
|
@ -393,8 +376,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
const adapter = new OpenCodeTeamRuntimeAdapter(
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||||
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||||
reconcileOpenCodeTeam,
|
reconcileOpenCodeTeam,
|
||||||
}),
|
})
|
||||||
{ launchMode: 'dogfood' }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await adapter.reconcile({
|
const result = await adapter.reconcile({
|
||||||
|
|
@ -488,8 +470,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
capabilitySnapshotId: 'cap-1',
|
capabilitySnapshotId: 'cap-1',
|
||||||
})),
|
})),
|
||||||
launchOpenCodeTeam,
|
launchOpenCodeTeam,
|
||||||
}),
|
})
|
||||||
{ launchMode: 'dogfood' }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await adapter.launch(launchInput());
|
const result = await adapter.launch(launchInput());
|
||||||
|
|
@ -539,8 +520,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
const adapter = new OpenCodeTeamRuntimeAdapter(
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||||
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||||
launchOpenCodeTeam,
|
launchOpenCodeTeam,
|
||||||
}),
|
})
|
||||||
{ launchMode: 'dogfood' }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await adapter.launch(launchInput());
|
const result = await adapter.launch(launchInput());
|
||||||
|
|
@ -576,8 +556,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
const adapter = new OpenCodeTeamRuntimeAdapter(
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||||
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||||
launchOpenCodeTeam,
|
launchOpenCodeTeam,
|
||||||
}),
|
})
|
||||||
{ launchMode: 'dogfood' }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await adapter.launch(launchInput());
|
const result = await adapter.launch(launchInput());
|
||||||
|
|
@ -588,7 +567,8 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
runtimeAlive: false,
|
runtimeAlive: false,
|
||||||
livenessKind: 'runtime_process_candidate',
|
livenessKind: 'runtime_process_candidate',
|
||||||
runtimePid: 123,
|
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(
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||||
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||||
launchOpenCodeTeam,
|
launchOpenCodeTeam,
|
||||||
}),
|
})
|
||||||
{ launchMode: 'dogfood' }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await adapter.launch(launchInput());
|
const result = await adapter.launch(launchInput());
|
||||||
|
|
@ -663,8 +642,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
capabilitySnapshotId: 'cap-1',
|
capabilitySnapshotId: 'cap-1',
|
||||||
})),
|
})),
|
||||||
launchOpenCodeTeam,
|
launchOpenCodeTeam,
|
||||||
}),
|
})
|
||||||
{ launchMode: 'dogfood' }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await adapter.launch(
|
const result = await adapter.launch(
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import {
|
||||||
type OpenCodeApiEndpointKey,
|
type OpenCodeApiEndpointKey,
|
||||||
} from '../../../../src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities';
|
} from '../../../../src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities';
|
||||||
import {
|
import {
|
||||||
assertOpenCodeProductionE2EGate,
|
|
||||||
buildOpenCodeBinaryFingerprint,
|
buildOpenCodeBinaryFingerprint,
|
||||||
evaluateOpenCodeSupport,
|
evaluateOpenCodeSupport,
|
||||||
parseOpenCodeSemver,
|
parseOpenCodeSemver,
|
||||||
|
|
@ -19,12 +18,6 @@ import {
|
||||||
type OpenCodeCompatibilitySnapshot,
|
type OpenCodeCompatibilitySnapshot,
|
||||||
type OpenCodeRouteCompatibilityCache,
|
type OpenCodeRouteCompatibilityCache,
|
||||||
} from '../../../../src/main/services/team/opencode/version/OpenCodeVersionPolicy';
|
} 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', () => {
|
describe('OpenCodeVersionPolicy', () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
|
|
@ -58,7 +51,6 @@ describe('OpenCodeVersionPolicy', () => {
|
||||||
evaluateOpenCodeSupport({
|
evaluateOpenCodeSupport({
|
||||||
version: '1.4.0',
|
version: '1.4.0',
|
||||||
capabilities: readyCapabilities(),
|
capabilities: readyCapabilities(),
|
||||||
evidence: passingEvidence(),
|
|
||||||
})
|
})
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
supported: false,
|
supported: false,
|
||||||
|
|
@ -70,7 +62,6 @@ describe('OpenCodeVersionPolicy', () => {
|
||||||
evaluateOpenCodeSupport({
|
evaluateOpenCodeSupport({
|
||||||
version: '1.14.19-beta.1',
|
version: '1.14.19-beta.1',
|
||||||
capabilities: readyCapabilities(),
|
capabilities: readyCapabilities(),
|
||||||
evidence: passingEvidence(),
|
|
||||||
})
|
})
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
supported: false,
|
supported: false,
|
||||||
|
|
@ -79,12 +70,11 @@ describe('OpenCodeVersionPolicy', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requires capabilities and production E2E evidence before production support', () => {
|
it('requires capabilities before support', () => {
|
||||||
expect(
|
expect(
|
||||||
evaluateOpenCodeSupport({
|
evaluateOpenCodeSupport({
|
||||||
version: '1.14.19',
|
version: '1.14.19',
|
||||||
capabilities: missingCapabilities(['POST permission reply route']),
|
capabilities: missingCapabilities(['POST permission reply route']),
|
||||||
evidence: passingEvidence(),
|
|
||||||
})
|
})
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
supported: false,
|
supported: false,
|
||||||
|
|
@ -96,23 +86,6 @@ describe('OpenCodeVersionPolicy', () => {
|
||||||
evaluateOpenCodeSupport({
|
evaluateOpenCodeSupport({
|
||||||
version: '1.14.19',
|
version: '1.14.19',
|
||||||
capabilities: readyCapabilities(),
|
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({
|
).toMatchObject({
|
||||||
supported: true,
|
supported: true,
|
||||||
|
|
@ -121,31 +94,16 @@ describe('OpenCodeVersionPolicy', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects stale or incomplete production E2E evidence', () => {
|
it('accepts supported version when capabilities pass', () => {
|
||||||
expect(
|
expect(
|
||||||
assertOpenCodeProductionE2EGate({
|
evaluateOpenCodeSupport({
|
||||||
evidence: passingEvidence({ version: '1.14.18' }),
|
version: '1.14.19',
|
||||||
testedVersion: '1.14.19',
|
capabilities: readyCapabilities(),
|
||||||
})
|
})
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
ok: false,
|
supported: true,
|
||||||
diagnostics: expect.arrayContaining([
|
supportLevel: 'production_supported',
|
||||||
'OpenCode production E2E evidence version 1.14.18 does not match tested version 1.14.19',
|
diagnostics: [],
|
||||||
]),
|
|
||||||
});
|
|
||||||
|
|
||||||
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',
|
|
||||||
]),
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -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(
|
function compatibilitySnapshot(
|
||||||
overrides: Partial<OpenCodeCompatibilitySnapshot>
|
overrides: Partial<OpenCodeCompatibilitySnapshot>
|
||||||
): OpenCodeCompatibilitySnapshot {
|
): OpenCodeCompatibilitySnapshot {
|
||||||
|
|
@ -349,7 +237,6 @@ function compatibilitySnapshot(
|
||||||
supported: true,
|
supported: true,
|
||||||
supportLevel: 'production_supported',
|
supportLevel: 'production_supported',
|
||||||
apiCapabilities: readyCapabilities(),
|
apiCapabilities: readyCapabilities(),
|
||||||
testedEvidencePath: '/tmp/opencode-e2e',
|
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ describe('TeamLaunchStateEvaluator', () => {
|
||||||
confirmedCount: 0,
|
confirmedCount: 0,
|
||||||
pendingCount: 2,
|
pendingCount: 2,
|
||||||
failedCount: 0,
|
failedCount: 0,
|
||||||
|
skippedCount: 0,
|
||||||
runtimeAlivePendingCount: 0,
|
runtimeAlivePendingCount: 0,
|
||||||
shellOnlyPendingCount: 0,
|
shellOnlyPendingCount: 0,
|
||||||
runtimeProcessPendingCount: 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', () => {
|
it('counts registered-only persisted liveness as no-runtime pending', () => {
|
||||||
const summary = summarizePersistedLaunchMembers(['alice'], {
|
const summary = summarizePersistedLaunchMembers(['alice'], {
|
||||||
alice: {
|
alice: {
|
||||||
|
|
|
||||||
|
|
@ -1550,6 +1550,191 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(restartMessage).toContain('Their workflow: Use the updated checklist');
|
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 () => {
|
it('does not let removed base-member metadata override a suffixed teammate during restart', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const run = createMemberSpawnRun({
|
const run = createMemberSpawnRun({
|
||||||
|
|
@ -7106,7 +7291,8 @@ describe('TeamProvisioningService', () => {
|
||||||
{
|
{
|
||||||
alive: false,
|
alive: false,
|
||||||
livenessKind: 'runtime_process_candidate',
|
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',
|
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', () => {
|
it('does not downgrade an already-online teammate when waiting is reported later', () => {
|
||||||
const run = createMemberSpawnRun({
|
const run = createMemberSpawnRun({
|
||||||
memberSpawnStatuses: new Map([
|
memberSpawnStatuses: new Map([
|
||||||
|
|
|
||||||
|
|
@ -473,11 +473,9 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
||||||
return {
|
return {
|
||||||
ok: false as const,
|
ok: false as const,
|
||||||
providerId: 'opencode' as const,
|
providerId: 'opencode' as const,
|
||||||
reason: 'e2e_missing',
|
reason: 'model_unavailable',
|
||||||
retryable: false,
|
retryable: false,
|
||||||
diagnostics: [
|
diagnostics: ['Selected model opencode/nemotron-3-super-free is not available'],
|
||||||
'OpenCode production E2E evidence artifact has no entry for selected model opencode/nemotron-3-super-free',
|
|
||||||
],
|
|
||||||
warnings: [],
|
warnings: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -529,7 +527,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
||||||
'Selected model opencode/minimax-m2.5-free verified for launch.'
|
'Selected model opencode/minimax-m2.5-free verified for launch.'
|
||||||
);
|
);
|
||||||
expect(result.message).toBe(
|
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 () => {
|
it('treats retryable OpenCode compatibility failures as blocking selected-model diagnostics', async () => {
|
||||||
const prepare = vi.fn(async () => ({
|
const prepare = vi.fn(async () => ({
|
||||||
ok: false as const,
|
ok: false as const,
|
||||||
|
|
|
||||||
|
|
@ -613,6 +613,8 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
||||||
expect(prompt).toContain(
|
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.'
|
'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 () => {
|
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"}'],
|
['mcp-server/1.3.0/package.json', '{"type":"module"}'],
|
||||||
['opencode-bridge/command-ledger.json', '{"commands":[]}'],
|
['opencode-bridge/command-ledger.json', '{"commands":[]}'],
|
||||||
['opencode-bridge/command-leases.json', '{"leases":[]}'],
|
['opencode-bridge/command-leases.json', '{"leases":[]}'],
|
||||||
['opencode-bridge/production-e2e-evidence.json', '{"ok":true}'],
|
|
||||||
['logs/claude-cli-auth-diag.ndjson', '{"event":"auth"}\n'],
|
['logs/claude-cli-auth-diag.ndjson', '{"event":"auth"}\n'],
|
||||||
['Local Storage/leveldb/000003.log', 'renderer localStorage bytes'],
|
['Local Storage/leveldb/000003.log', 'renderer localStorage bytes'],
|
||||||
['future-feature/state.json', '{"kept":true}'],
|
['future-feature/state.json', '{"kept":true}'],
|
||||||
|
|
|
||||||
|
|
@ -746,7 +746,7 @@ describe('TeamModelSelector disabled Codex models', () => {
|
||||||
supported: true,
|
supported: true,
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
statusMessage: 'OpenCode team launch is gated',
|
statusMessage: 'OpenCode team launch is gated',
|
||||||
detailMessage: 'OpenCode production E2E evidence is missing',
|
detailMessage: 'OpenCode runtime store needs recovery',
|
||||||
capabilities: { teamLaunch: false },
|
capabilities: { teamLaunch: false },
|
||||||
models: [],
|
models: [],
|
||||||
},
|
},
|
||||||
|
|
@ -773,7 +773,7 @@ describe('TeamModelSelector disabled Codex models', () => {
|
||||||
);
|
);
|
||||||
expect(openCodeButton?.hasAttribute('disabled')).toBe(true);
|
expect(openCodeButton?.hasAttribute('disabled')).toBe(true);
|
||||||
expect(openCodeButton?.getAttribute('title')).toContain(
|
expect(openCodeButton?.getAttribute('title')).toContain(
|
||||||
'OpenCode production E2E evidence is missing'
|
'OpenCode runtime store needs recovery'
|
||||||
);
|
);
|
||||||
expect(openCodeButton?.textContent).toContain('Gate');
|
expect(openCodeButton?.textContent).toContain('Gate');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,10 @@ describe('TeamProvisioningBanner launch-step alignment', () => {
|
||||||
cliLogsTail: '',
|
cliLogsTail: '',
|
||||||
assistantOutput: '',
|
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'] = {
|
storeState.memberSpawnStatusesByTeam['northstar-core'] = {
|
||||||
alice: { status: 'waiting', launchState: 'starting' },
|
alice: { status: 'waiting', launchState: 'starting' },
|
||||||
bob: { status: 'waiting', launchState: 'starting' },
|
bob: { status: 'waiting', launchState: 'starting' },
|
||||||
|
|
@ -281,6 +284,7 @@ describe('TeamProvisioningBanner launch-step alignment', () => {
|
||||||
pendingCount: 3,
|
pendingCount: 3,
|
||||||
failedCount: 0,
|
failedCount: 0,
|
||||||
runtimeAlivePendingCount: 3,
|
runtimeAlivePendingCount: 3,
|
||||||
|
runtimeProcessPendingCount: 3,
|
||||||
},
|
},
|
||||||
source: 'merged',
|
source: 'merged',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -327,16 +327,13 @@ describe('ProvisioningProviderStatusList', () => {
|
||||||
{
|
{
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
details: [
|
details: ['nemotron-3-super-free - unavailable - selected model is not available'],
|
||||||
'nemotron-3-super-free - unavailable - OpenCode production E2E evidence artifact has no entry for selected model opencode/nemotron-3-super-free',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
).toEqual({
|
).toEqual({
|
||||||
state: 'failed',
|
state: 'failed',
|
||||||
message:
|
message: 'nemotron-3-super-free - unavailable - selected model is not available',
|
||||||
'nemotron-3-super-free - unavailable - OpenCode production E2E evidence artifact has no entry for selected model opencode/nemotron-3-super-free',
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
it('normalizes raw Codex API error envelopes into a clean model reason', async () => {
|
||||||
const prepareProvisioning = vi.fn<
|
const prepareProvisioning = vi.fn<
|
||||||
(
|
(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import React, { act } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
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', () => ({
|
vi.mock('@renderer/components/ui/badge', () => ({
|
||||||
Badge: ({
|
Badge: ({
|
||||||
|
|
@ -56,6 +56,33 @@ const currentTask: TeamTaskWithKanban = {
|
||||||
status: 'in_progress',
|
status: 'in_progress',
|
||||||
} as unknown as TeamTaskWithKanban;
|
} 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', () => {
|
describe('MemberCard starting-state visuals', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
|
|
@ -582,4 +609,309 @@ describe('MemberCard starting-state visuals', () => {
|
||||||
await Promise.resolve();
|
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: ({
|
MemberCard: ({
|
||||||
member,
|
member,
|
||||||
spawnError,
|
spawnError,
|
||||||
|
spawnStatus,
|
||||||
|
spawnLaunchState,
|
||||||
|
onRestartMember,
|
||||||
|
onSkipMemberForLaunch,
|
||||||
}: {
|
}: {
|
||||||
member: ResolvedTeamMember;
|
member: ResolvedTeamMember;
|
||||||
spawnError?: string;
|
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';
|
import { MemberList } from '@renderer/components/team/members/MemberList';
|
||||||
|
|
@ -98,4 +133,126 @@ describe('MemberList spawn-status memoization', () => {
|
||||||
await Promise.resolve();
|
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.processOnlyAliveCount).toBe(0);
|
||||||
expect(milestones.pendingSpawnCount).toBe(4);
|
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(),
|
permanentlyDeleteTeam: vi.fn(),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
restartMember: vi.fn(),
|
restartMember: vi.fn(),
|
||||||
|
skipMemberForLaunch: vi.fn(),
|
||||||
requestReview: vi.fn(),
|
requestReview: vi.fn(),
|
||||||
updateKanban: vi.fn(),
|
updateKanban: vi.fn(),
|
||||||
invalidateTaskChangeSummaries: vi.fn(),
|
invalidateTaskChangeSummaries: vi.fn(),
|
||||||
|
|
@ -53,6 +54,7 @@ vi.mock('@renderer/api', () => ({
|
||||||
permanentlyDeleteTeam: hoisted.permanentlyDeleteTeam,
|
permanentlyDeleteTeam: hoisted.permanentlyDeleteTeam,
|
||||||
sendMessage: hoisted.sendMessage,
|
sendMessage: hoisted.sendMessage,
|
||||||
restartMember: hoisted.restartMember,
|
restartMember: hoisted.restartMember,
|
||||||
|
skipMemberForLaunch: hoisted.skipMemberForLaunch,
|
||||||
requestReview: hoisted.requestReview,
|
requestReview: hoisted.requestReview,
|
||||||
updateKanban: hoisted.updateKanban,
|
updateKanban: hoisted.updateKanban,
|
||||||
onProvisioningProgress: hoisted.onProvisioningProgress,
|
onProvisioningProgress: hoisted.onProvisioningProgress,
|
||||||
|
|
@ -233,6 +235,7 @@ describe('teamSlice actions', () => {
|
||||||
hoisted.restoreTeam.mockResolvedValue(undefined);
|
hoisted.restoreTeam.mockResolvedValue(undefined);
|
||||||
hoisted.permanentlyDeleteTeam.mockResolvedValue(undefined);
|
hoisted.permanentlyDeleteTeam.mockResolvedValue(undefined);
|
||||||
hoisted.restartMember.mockResolvedValue(undefined);
|
hoisted.restartMember.mockResolvedValue(undefined);
|
||||||
|
hoisted.skipMemberForLaunch.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps inbox verify failure to user-friendly text', async () => {
|
it('maps inbox verify failure to user-friendly text', async () => {
|
||||||
|
|
@ -2537,6 +2540,57 @@ describe('teamSlice actions', () => {
|
||||||
expect(refreshRuntimeSnapshot).toHaveBeenCalledWith('my-team');
|
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 () => {
|
it('clears stale runtime snapshots on delete', async () => {
|
||||||
const store = createSliceStore();
|
const store = createSliceStore();
|
||||||
store.setState({
|
store.setState({
|
||||||
|
|
|
||||||
|
|
@ -293,6 +293,75 @@ describe('buildTeamProvisioningPresentation', () => {
|
||||||
expect(presentation?.compactTone).toBe('warning');
|
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', () => {
|
it('prefers live member spawn statuses over a stale persisted launch summary', () => {
|
||||||
const presentation = buildTeamProvisioningPresentation({
|
const presentation = buildTeamProvisioningPresentation({
|
||||||
progress: {
|
progress: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue