fix: harden opencode launch recovery

This commit is contained in:
777genius 2026-05-01 17:00:20 +03:00
parent 3240ea6406
commit 5224fe4cda
11 changed files with 2021 additions and 94 deletions

View file

@ -6,6 +6,7 @@ Start here:
- Repo overview and commands: [README.md](README.md) - Repo overview and commands: [README.md](README.md)
- Working instructions and project conventions: [CLAUDE.md](CLAUDE.md) - Working instructions and project conventions: [CLAUDE.md](CLAUDE.md)
- Canonical feature architecture standard: [docs/FEATURE_ARCHITECTURE_STANDARD.md](docs/FEATURE_ARCHITECTURE_STANDARD.md) - Canonical feature architecture standard: [docs/FEATURE_ARCHITECTURE_STANDARD.md](docs/FEATURE_ARCHITECTURE_STANDARD.md)
- Agent team launch/runtime debugging runbook: [docs/team-management/debugging-agent-teams.md](docs/team-management/debugging-agent-teams.md)
For new features: For new features:
- Default home for medium and large features: `src/features/<feature-name>/` - Default home for medium and large features: `src/features/<feature-name>/`
@ -15,6 +16,7 @@ For new features:
## Review guidelines ## Review guidelines
- Treat regressions in agent team messaging, task lifecycle, session parsing, code review UI, and provider/runtime detection as high priority. - Treat regressions in agent team messaging, task lifecycle, session parsing, code review UI, and provider/runtime detection as high priority.
- For team launch hangs, OpenCode `registered`/`bootstrap unconfirmed`, missing teammate replies, or suspicious task logs, follow [docs/team-management/debugging-agent-teams.md](docs/team-management/debugging-agent-teams.md) before changing code.
- Verify new medium and large features follow `docs/FEATURE_ARCHITECTURE_STANDARD.md`, especially cross-process boundaries and public feature entrypoints. - Verify new medium and large features follow `docs/FEATURE_ARCHITECTURE_STANDARD.md`, especially cross-process boundaries and public feature entrypoints.
- Check that Electron main, preload, renderer, and shared code keep their responsibilities separate and use the documented path aliases. - Check that Electron main, preload, renderer, and shared code keep their responsibilities separate and use the documented path aliases.
- Flag changes that manually concatenate agent block markers instead of using `wrapAgentBlock(text)`. - Flag changes that manually concatenate agent block markers instead of using `wrapAgentBlock(text)`.

View file

@ -110,6 +110,12 @@ Keep orphaned Task calls (no matching subagent) for visibility.
Claude Code's "Orchestrate Teams" feature: multiple sessions coordinate as a team. Claude Code's "Orchestrate Teams" feature: multiple sessions coordinate as a team.
Official docs: https://code.claude.com/docs/en/agent-teams Official docs: https://code.claude.com/docs/en/agent-teams
#### Debugging Team Launches And Teammates
- Use [`docs/team-management/debugging-agent-teams.md`](docs/team-management/debugging-agent-teams.md) when a team launch hangs, a teammate remains `registered`, OpenCode shows `bootstrap unconfirmed`, messages are missing, or Task Log Stream looks wrong.
- Always correlate UI diagnostics with persisted files under `~/.claude/teams/<teamName>/`, live process state, and runtime-specific evidence before changing code.
- For OpenCode secondary lanes, do not confuse primary filesystem readiness with lane bootstrap readiness. A missing OpenCode inbox during primary launch is not automatically a bug.
- Do not treat `member_briefing` as runtime evidence. OpenCode deliverability requires lane-scoped committed runtime evidence such as `opencode-sessions.json` plus its manifest entry.
#### Message Delivery Architecture #### Message Delivery Architecture
- **Lead** reads ONLY stdin (stream-json). Messages to lead must go through `relayLeadInboxMessages()` which converts inbox entries to stdin. - **Lead** reads ONLY stdin (stream-json). Messages to lead must go through `relayLeadInboxMessages()` which converts inbox entries to stdin.
- **Teammates** are independent CLI processes. Claude Code runtime monitors each teammate's inbox file and delivers messages between turns. No relay through lead needed. - **Teammates** are independent CLI processes. Claude Code runtime monitors each teammate's inbox file and delivers messages between turns. No relay through lead needed.

View file

@ -0,0 +1,176 @@
# Debugging Agent Teams
Use this runbook when a team launch hangs, a teammate is marked `registered` or `failed_to_start`, messages do not appear, or OpenCode participants look online but do not answer.
## First Rule
Do not guess from the UI alone. Always correlate:
- UI diagnostics copied from the launch/member detail panel
- persisted team files under `~/.claude/teams/<teamName>/`
- live process table
- runtime-specific evidence, especially OpenCode lane manifests
## Key Files
Team root:
```bash
TEAM="<team-name>"
TEAM_DIR="$HOME/.claude/teams/$TEAM"
```
Important files and folders:
- `config.json` - configured members, provider/model selection, project path
- `members-meta.json` - member metadata, removed members, worktree settings if present
- `launch-state.json` - current app-side truth for member launch/liveness
- `bootstrap-state.json` - bootstrap phase summary when present
- `bootstrap-journal.jsonl` - ordered bootstrap events from the CLI/runtime
- `inboxes/*.json` - durable inbox messages for user, lead, and native teammates
- `sentMessages.json` - app-side sent-message records
- `tasks/*.json` - task board state
- `.opencode-runtime/lanes.json` - OpenCode lane index
- `.opencode-runtime/lanes/<encoded-lane-id>/manifest.json` - lane-scoped runtime store manifest
- `.opencode-runtime/lanes/<encoded-lane-id>/opencode-sessions.json` - committed OpenCode session evidence
Quick inspection:
```bash
jq '.teamLaunchState, .summary, .members' "$TEAM_DIR/launch-state.json"
jq '.lanes' "$TEAM_DIR/.opencode-runtime/lanes.json" 2>/dev/null
find "$TEAM_DIR/.opencode-runtime" -maxdepth 3 -type f | sort
tail -80 "$TEAM_DIR/bootstrap-journal.jsonl" 2>/dev/null
```
## Launch Phases
Primary launch and OpenCode secondary lanes are different paths.
- Primary CLI members are created by the main provisioning process.
- OpenCode secondary members are launched as side lanes after primary filesystem readiness.
- Missing `inboxes/<opencode-member>.json` is not automatically a launch bug. OpenCode side lanes do not have to be primary inbox-created before they start.
- The UI can show the team still launching while primary members are already usable, because "all teammates joined" waits for secondary lanes too.
When a launch hangs at `Prepared communication channels for X/Y members`, check whether `Y` incorrectly includes secondary OpenCode members. The filesystem monitor should wait for `effectiveMembers`, not every requested member.
## Member State Meanings
Common `launch-state.json` cases:
- `confirmed_alive` with `bootstrapConfirmed: true` - member is usable.
- `registered` / `runtime_pending_bootstrap` - process or lane exists, but bootstrap proof is not committed yet.
- `registered_only` - app has persisted metadata, but no live runtime proof.
- `runtime_process_candidate` - process/session was observed, but committed runtime evidence is incomplete or pending.
- `failed_to_start` with `runtime_process` - a process exists, but the launch gate still failed. Inspect diagnostics and runtime evidence.
- `failed_to_start` with `stale_metadata` - persisted pid/session is old or dead.
Do not treat `member_briefing` alone as runtime evidence. For OpenCode, the authoritative proof is committed bootstrap/session evidence in the lane runtime store.
## OpenCode Debug Flow
For an OpenCode teammate:
```bash
MEMBER="<member-name>"
jq --arg member "$MEMBER" '.members[$member]' "$TEAM_DIR/launch-state.json"
jq '.lanes' "$TEAM_DIR/.opencode-runtime/lanes.json" 2>/dev/null
find "$TEAM_DIR/.opencode-runtime/lanes" -maxdepth 3 -type f | sort
```
Expected healthy OpenCode lane:
- `lanes.json` has the lane state `active`
- lane `manifest.json` has `activeRunId`
- lane manifest has at least one runtime evidence entry, usually `opencode.sessionStore`
- lane directory has `opencode-sessions.json`
- `launch-state.json` member has `runtimeRunId`, `runtimeSessionId`, and `bootstrapConfirmed: true`
If the bridge says bootstrap succeeded but the manifest has `entries: []`, the issue is evidence commit, not model behavior. The member must not be considered deliverable until `opencode-sessions.json` and its manifest entry exist.
OpenCode bridge ledger, if needed:
```bash
LEDGER="$HOME/Library/Application Support/claude-agent-teams-ui/opencode-bridge/command-ledger.json"
jq --arg team "$TEAM" '.data[] | select(.teamName == $team)' "$LEDGER" 2>/dev/null
```
Live process checks:
```bash
pgrep -af "opencode serve"
ps -p <pid> -o pid,ppid,etime,command
```
Do not kill all OpenCode processes as a debugging shortcut. First identify whether the pid belongs to the current team/lane. Some OpenCode temp `libopentui.dylib` files are held by live `opencode serve` processes and should only be cleaned after those processes are stopped.
## Messaging Debug Flow
Lead and teammates use different delivery paths:
- Lead reads stdin. Messages to lead go through `relayLeadInboxMessages()`.
- Native teammates read their inbox files directly.
- OpenCode teammates receive prompts through runtime delivery and must reply via `agent-teams_message_send`.
- Teammate-to-user replies should appear in `inboxes/user.json` or app sent-message projections.
If a notification appears but the Messages UI does not show it:
```bash
jq '.' "$TEAM_DIR/inboxes/user.json" 2>/dev/null
jq '.' "$TEAM_DIR/sentMessages.json" 2>/dev/null
```
Check `from`, `to`, `messageId`, `relayOfMessageId`, and `taskRefs`. Unknown authors should be rejected or normalized at the write boundary, not silently rendered as fake teammates.
For OpenCode "message saved but not delivered" cases, inspect the OpenCode prompt-delivery ledger and response proof. Do not synthesize visible replies in the frontend.
## Task And Work-Stall Debug Flow
For task stalls:
```bash
TASK="<short-or-full-task-id>"
rg -n "$TASK" "$TEAM_DIR/tasks" "$TEAM_DIR/inboxes" "$TEAM_DIR/bootstrap-journal.jsonl" 2>/dev/null
```
Important distinctions:
- Delivery proof means the agent received the message.
- Task progress proof means the agent made meaningful task progress.
- A weak comment like "starting work" is not strong progress.
- `task_add_comment` should be evaluated from the actual persisted comment text, not only from the tool call.
Task-stall monitor defaults:
- General task-stall monitor is for all agents.
- OpenCode direct remediation is provider-specific and should nudge the OpenCode owner first.
- If OpenCode remediation is not accepted, fallback to lead alert.
- Watchdog/remediation must not auto-start new OpenCode processes.
## Task Log Stream Debug Flow
Task Log Stream is a projection, not a separate source of truth.
For OpenCode tasks, a healthy stream should show native tool rows such as `read`, `bash`, `edit`, `write`, plus Agent Teams MCP rows. If it only shows `agent-teams_*` calls:
- confirm the task has OpenCode attribution for the member/session
- confirm the OpenCode transcript contains native tools inside the bounded task window
- check whether the task was assigned after the native work happened
- do not widen attribution so far that unrelated session work is pulled into the task
If Changes says "No file changes recorded" while native `write`/`edit` rows exist, inspect the ledger/backfill path. Task logs can show runtime tools even when `.board-task-changes/**` was not created.
## Safe Fix Checklist
Before changing launch or runtime logic:
- Preserve stale-run, tombstone, stopped-team, and removed-member guards.
- Do not make `member_briefing` runtime evidence.
- Do not make delivery/watchdog auto-launch a fresh OpenCode lane.
- Keep primary launch readiness separate from secondary OpenCode lane readiness.
- Keep runtime evidence lane-scoped. Never let one OpenCode lane satisfy another lane.
- Add a regression test for the exact state shape you found in `launch-state.json`.
Recommended verification:
```bash
pnpm vitest run test/main/services/team/TeamProvisioningService.test.ts
pnpm vitest run test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts
pnpm typecheck --pretty false
git diff --check
```
Use narrower test commands first when editing a focused path, then run the broader suite that covers launch, delivery, and liveness.

File diff suppressed because it is too large Load diff

View file

@ -11,11 +11,13 @@ import {
createRuntimeStoreManifestStore, createRuntimeStoreManifestStore,
OPENCODE_RUNTIME_STORE_DESCRIPTORS, OPENCODE_RUNTIME_STORE_DESCRIPTORS,
OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION, OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION,
RuntimeStoreFileInspector,
validateRuntimeStoreManifest, validateRuntimeStoreManifest,
} from './RuntimeStoreManifest'; } from './RuntimeStoreManifest';
import type { RuntimeStoreManifestEvidence } from '../bridge/OpenCodeBridgeCommandContract'; import type { RuntimeStoreManifestEvidence } from '../bridge/OpenCodeBridgeCommandContract';
import type { RuntimeStoreManifestReader } from '../bridge/OpenCodeStateChangingBridgeCommandService'; import type { RuntimeStoreManifestReader } from '../bridge/OpenCodeStateChangingBridgeCommandService';
import type { RuntimeStoreManifestEntryState } from './RuntimeStoreManifest';
const logger = createLogger('OpenCodeRuntimeManifestEvidenceReader'); const logger = createLogger('OpenCodeRuntimeManifestEvidenceReader');
@ -56,6 +58,23 @@ export interface OpenCodeRuntimeLaneIndex {
lanes: Record<string, OpenCodeRuntimeLaneIndexEntry>; lanes: Record<string, OpenCodeRuntimeLaneIndexEntry>;
} }
export interface OpenCodeCommittedBootstrapSessionRecord {
id: string;
teamName: string;
memberName: string;
laneId: string;
runId: string | null;
observedAt: string | null;
source: 'runtime_bootstrap_checkin';
}
export interface OpenCodeCommittedBootstrapSessionEvidence {
state: RuntimeStoreManifestEntryState | 'invalid_store' | 'descriptor_missing';
committed: boolean;
sessions: OpenCodeCommittedBootstrapSessionRecord[];
diagnostics: string[];
}
function createEmptyOpenCodeRuntimeLaneIndex( function createEmptyOpenCodeRuntimeLaneIndex(
updatedAt = new Date().toISOString() updatedAt = new Date().toISOString()
): OpenCodeRuntimeLaneIndex { ): OpenCodeRuntimeLaneIndex {
@ -233,6 +252,82 @@ async function fileExists(filePath: string): Promise<boolean> {
} }
} }
async function readOpenCodeBootstrapSessionStore(
filePath: string,
expected: {
teamName: string;
laneId: string;
}
): Promise<OpenCodeCommittedBootstrapSessionRecord[]> {
const raw = await readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
const record =
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
const data =
record && Object.prototype.hasOwnProperty.call(record, 'data') ? record.data : record;
const sessions =
data && typeof data === 'object' && !Array.isArray(data)
? (data as Record<string, unknown>).sessions
: null;
if (!Array.isArray(sessions)) {
return [];
}
return sessions.flatMap((session): OpenCodeCommittedBootstrapSessionRecord[] => {
const normalized = normalizeOpenCodeBootstrapSessionRecord(session);
if (!normalized) {
return [];
}
if (normalized.teamName !== expected.teamName || normalized.laneId !== expected.laneId) {
return [];
}
return [normalized];
});
}
function normalizeOpenCodeBootstrapSessionRecord(
value: unknown
): OpenCodeCommittedBootstrapSessionRecord | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const id = normalizeNonEmptyStoreString(record.id);
const teamName = normalizeNonEmptyStoreString(record.teamName);
const memberName = normalizeNonEmptyStoreString(record.memberName);
const laneId = normalizeNonEmptyStoreString(record.laneId);
const source = normalizeNonEmptyStoreString(record.source);
if (!id || !teamName || !memberName || !laneId || source !== 'runtime_bootstrap_checkin') {
return null;
}
const observedAt = normalizeOptionalStoreIso(record.observedAt);
return {
id,
teamName,
memberName,
laneId,
runId: normalizeNonEmptyStoreString(record.runId),
observedAt,
source: 'runtime_bootstrap_checkin',
};
}
function normalizeNonEmptyStoreString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
function normalizeOptionalStoreIso(value: unknown): string | null {
const text = normalizeNonEmptyStoreString(value);
if (!text) {
return null;
}
const parsed = Date.parse(text);
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : text;
}
async function resolveOpenCodeRuntimeManifestReadPath( async function resolveOpenCodeRuntimeManifestReadPath(
teamsBasePath: string, teamsBasePath: string,
teamName: string, teamName: string,
@ -390,6 +485,87 @@ export function getOpenCodeLaneScopedRuntimeFilePath(params: {
); );
} }
export async function readCommittedOpenCodeBootstrapSessionEvidence(params: {
teamsBasePath: string;
teamName: string;
laneId: string;
}): Promise<OpenCodeCommittedBootstrapSessionEvidence> {
const descriptor = OPENCODE_RUNTIME_STORE_DESCRIPTORS.find(
(candidate) => candidate.schemaName === 'opencode.sessionStore'
);
if (!descriptor) {
return {
state: 'descriptor_missing',
committed: false,
sessions: [],
diagnostics: ['OpenCode session store descriptor is not registered.'],
};
}
const runtimeDirectory = getOpenCodeTeamRuntimeLaneDirectory(
params.teamsBasePath,
params.teamName,
params.laneId
);
const manifestPath = getOpenCodeRuntimeManifestPath(
params.teamsBasePath,
params.teamName,
params.laneId
);
const manifestStore = createRuntimeStoreManifestStore({
filePath: manifestPath,
teamName: params.teamName,
});
const manifest = await manifestStore.read().catch(() => null);
if (!manifest) {
return {
state: 'invalid_store',
committed: false,
sessions: [],
diagnostics: ['OpenCode runtime manifest could not be read.'],
};
}
const inspection = await new RuntimeStoreFileInspector(runtimeDirectory)
.inspect({ descriptor, manifest })
.catch((error: unknown) => ({
state: 'invalid_store' as const,
message: `OpenCode session store inspection failed: ${
error instanceof Error ? error.message : String(error)
}`,
}));
const diagnostics = inspection.message ? [inspection.message] : [];
if (inspection.state !== 'healthy') {
return {
state: inspection.state,
committed: false,
sessions: [],
diagnostics,
};
}
const sessionStorePath = path.join(runtimeDirectory, descriptor.relativePath);
const sessions = await readOpenCodeBootstrapSessionStore(sessionStorePath, params).catch(
(error: unknown) => {
diagnostics.push(
`OpenCode session store could not be parsed: ${
error instanceof Error ? error.message : String(error)
}`
);
return [];
}
);
if (sessions.length === 0) {
diagnostics.push('OpenCode session store has no committed bootstrap check-in sessions.');
}
return {
state: 'healthy',
committed: true,
sessions,
diagnostics,
};
}
export async function readOpenCodeRuntimeLaneIndex( export async function readOpenCodeRuntimeLaneIndex(
teamsBasePath: string, teamsBasePath: string,
teamName: string teamName: string

View file

@ -85,11 +85,80 @@ const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
const NOOP_TEAM_CLICK = (): void => undefined; const NOOP_TEAM_CLICK = (): void => undefined;
type ViewerMarkdownMode = 'default' | 'compact-preview'; type ViewerMarkdownMode = 'default' | 'compact-preview';
type HastElementLike = {
tagName?: string;
value?: string;
children?: unknown[];
};
// ============================================================================= // =============================================================================
// Helpers // Helpers
// ============================================================================= // =============================================================================
function isHastElementLike(value: unknown): value is HastElementLike {
return typeof value === 'object' && value !== null;
}
function getHastChildren(value: unknown): unknown[] {
return isHastElementLike(value) && Array.isArray(value.children) ? value.children : [];
}
function getHastText(value: unknown): string {
if (typeof value === 'string' || typeof value === 'number') {
return String(value);
}
if (!isHastElementLike(value)) {
return '';
}
if (typeof value.value === 'string') {
return value.value;
}
return getHastChildren(value).map(getHastText).join(' ').replace(/\s+/g, ' ').trim();
}
function collectHastElementsByTag(value: unknown, tagName: string): HastElementLike[] {
const result: HastElementLike[] = [];
const visit = (node: unknown): void => {
if (!isHastElementLike(node)) return;
if (node.tagName === tagName) {
result.push(node);
}
for (const child of getHastChildren(node)) {
visit(child);
}
};
visit(value);
return result;
}
function getDirectCellElements(row: HastElementLike): HastElementLike[] {
return getHastChildren(row).filter(
(child): child is HastElementLike =>
isHastElementLike(child) && (child.tagName === 'th' || child.tagName === 'td')
);
}
function buildCompactTableSummary(node: unknown, fallbackChildren: React.ReactNode): string {
const rows = collectHastElementsByTag(node, 'tr');
const headerRow =
rows.find((row) => getDirectCellElements(row).some((cell) => cell.tagName === 'th')) ??
rows[0] ??
null;
const headerCells = headerRow ? getDirectCellElements(headerRow) : [];
const headers = headerCells.map(getHastText).filter(Boolean);
const bodyRowCount = rows.filter((row) => row !== headerRow).length;
const fallbackText = extractTextFromReactNode(fallbackChildren).replace(/\s+/g, ' ').trim();
const previewSource = headers.length > 0 ? headers.join(' | ') : fallbackText;
const preview =
previewSource.length > 72 ? `${previewSource.slice(0, 69).trimEnd()}...` : previewSource;
const rowLabel = bodyRowCount === 1 ? '1 row' : `${bodyRowCount} rows`;
if (preview) {
return `Table: ${preview} - ${rowLabel}`;
}
return `Table - ${rowLabel}`;
}
/** /**
* Custom URL transform that preserves task://, mention://, and team:// protocols. * Custom URL transform that preserves task://, mention://, and team:// protocols.
* react-markdown v10 strips non-standard protocols by default. * react-markdown v10 strips non-standard protocols by default.
@ -354,6 +423,18 @@ function createViewerMarkdownComponents(
</span> </span>
); );
const renderCompactTableSummary = (
node: unknown,
children: React.ReactNode
): React.ReactElement => {
const summary = buildCompactTableSummary(node, children);
return (
<span className="inline" style={{ color: PROSE_MUTED }}>
{summary}{' '}
</span>
);
};
return { return {
// Headings // Headings
h1: ({ children }) => h1: ({ children }) =>
@ -705,9 +786,9 @@ function createViewerMarkdownComponents(
), ),
// Tables // Tables
table: ({ children }) => table: ({ children, node }) =>
isCompactPreview ? ( isCompactPreview ? (
<span>{children}</span> renderCompactTableSummary(node, children)
) : ( ) : (
<div className="my-3 overflow-x-auto"> <div className="my-3 overflow-x-auto">
<table <table
@ -720,13 +801,21 @@ function createViewerMarkdownComponents(
), ),
thead: ({ children }) => thead: ({ children }) =>
isCompactPreview ? ( isCompactPreview ? (
<span>{children}</span> <thead style={{ backgroundColor: PROSE_TABLE_HEADER_BG }}>{children}</thead>
) : ( ) : (
<thead style={{ backgroundColor: PROSE_TABLE_HEADER_BG }}>{children}</thead> <thead style={{ backgroundColor: PROSE_TABLE_HEADER_BG }}>{children}</thead>
), ),
th: ({ children }) => th: ({ children }) =>
isCompactPreview ? ( isCompactPreview ? (
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING }) <th
className="px-3 py-1.5 text-left font-semibold"
style={{
border: `1px solid ${PROSE_TABLE_BORDER}`,
color: PROSE_HEADING,
}}
>
{hl(children)}
</th>
) : ( ) : (
<th <th
className="px-3 py-2 text-left font-semibold" className="px-3 py-2 text-left font-semibold"
@ -740,7 +829,15 @@ function createViewerMarkdownComponents(
), ),
td: ({ children }) => td: ({ children }) =>
isCompactPreview ? ( isCompactPreview ? (
renderCompactInline(children, '', { color: PROSE_BODY }) <td
className="px-3 py-1.5"
style={{
border: `1px solid ${PROSE_TABLE_BORDER}`,
color: PROSE_BODY,
}}
>
{hl(children)}
</td>
) : ( ) : (
<td <td
className="px-3 py-2" className="px-3 py-2"

View file

@ -20,6 +20,10 @@ interface ProvisioningMemberLike {
name: string; name: string;
removedAt?: number; removedAt?: number;
agentType?: string; agentType?: string;
providerId?: string;
laneId?: string;
laneKind?: 'primary' | 'secondary';
laneOwnerProviderId?: string;
status?: string; status?: string;
currentTaskId?: string | null; currentTaskId?: string | null;
taskCount?: number; taskCount?: number;
@ -146,6 +150,12 @@ function buildAwaitingPermissionPhrase(count: number): string {
: `${count} teammates awaiting permission approval`; : `${count} teammates awaiting permission approval`;
} }
function formatMemberNameList(names: readonly string[]): string {
const listedNames = names.slice(0, MAX_PENDING_DIAGNOSTIC_NAMES).join(', ');
const remainingCount = names.length - Math.min(names.length, MAX_PENDING_DIAGNOSTIC_NAMES);
return `${listedNames}${remainingCount > 0 ? `, +${remainingCount} more` : ''}`;
}
function getMemberNamesFromSpawnSources(params: { function getMemberNamesFromSpawnSources(params: {
memberSpawnStatuses: MemberSpawnStatusCollection; memberSpawnStatuses: MemberSpawnStatusCollection;
memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses']; memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
@ -223,13 +233,71 @@ function getPendingDiagnosticNameGroups(params: {
return groups; return groups;
} }
function getPendingSpawnNames(params: {
memberSpawnStatuses: MemberSpawnStatusCollection;
memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
memberSpawnSnapshotUpdatedAt?: string;
}): string[] {
return getMemberNamesFromSpawnSources(params).filter((name) => {
const liveEntry =
params.memberSpawnStatuses instanceof Map
? params.memberSpawnStatuses.get(name)
: params.memberSpawnStatuses?.[name];
const snapshotEntry = params.memberSpawnSnapshotStatuses?.[name];
const entry = getPreferredSpawnEntry({
liveEntry,
snapshotEntry,
snapshotUpdatedAt: params.memberSpawnSnapshotUpdatedAt,
});
return (
entry != null &&
entry.launchState !== 'confirmed_alive' &&
!isFailedSpawnEntry(entry) &&
!isSkippedSpawnEntry(entry)
);
});
}
function isOpenCodeSecondaryMember(member: ProvisioningMemberLike | undefined): boolean {
if (!member || member.removedAt != null || member.providerId !== 'opencode') {
return false;
}
return (
member.laneKind === 'secondary' ||
member.laneOwnerProviderId === 'opencode' ||
member.laneId?.startsWith('secondary:opencode:') === true
);
}
function buildOpenCodeSecondaryWaitPhrase(params: {
members: readonly ProvisioningMemberLike[];
memberSpawnStatuses: MemberSpawnStatusCollection;
memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
memberSpawnSnapshotUpdatedAt?: string;
}): string | null {
const pendingNames = getPendingSpawnNames({
memberSpawnStatuses: params.memberSpawnStatuses,
memberSpawnSnapshotStatuses: params.memberSpawnSnapshotStatuses,
memberSpawnSnapshotUpdatedAt: params.memberSpawnSnapshotUpdatedAt,
});
if (pendingNames.length === 0) {
return null;
}
const memberByName = new Map(params.members.map((member) => [member.name, member]));
const pendingOnlyOpenCodeSecondary = pendingNames.every((name) =>
isOpenCodeSecondaryMember(memberByName.get(name))
);
return pendingOnlyOpenCodeSecondary
? `Waiting for OpenCode: ${formatMemberNameList(pendingNames)}`
: null;
}
function formatNamedPendingDiagnostic(label: string, names: readonly string[]): string | null { function formatNamedPendingDiagnostic(label: string, names: readonly string[]): string | null {
if (names.length === 0) { if (names.length === 0) {
return null; return null;
} }
const listedNames = names.slice(0, MAX_PENDING_DIAGNOSTIC_NAMES).join(', '); return `${label}: ${formatMemberNameList(names)}`;
const remainingCount = names.length - Math.min(names.length, MAX_PENDING_DIAGNOSTIC_NAMES);
return `${label}: ${listedNames}${remainingCount > 0 ? `, +${remainingCount} more` : ''}`;
} }
function formatCountPendingDiagnostic(count: number | undefined, label: string): string | null { function formatCountPendingDiagnostic(count: number | undefined, label: string): string | null {
@ -578,6 +646,12 @@ export function buildTeamProvisioningPresentation({
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses, memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt, memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
}); });
const openCodeSecondaryWaitPhrase = buildOpenCodeSecondaryWaitPhrase({
members,
memberSpawnStatuses,
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
});
const { allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount } = const { allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount } =
getLaunchJoinState({ getLaunchJoinState({
@ -637,13 +711,14 @@ export function buildTeamProvisioningPresentation({
permissionBlockedCount === remainingJoinCount; permissionBlockedCount === remainingJoinCount;
const pendingDetailPhrase = pendingMembersAwaitApproval const pendingDetailPhrase = pendingMembersAwaitApproval
? buildAwaitingPermissionPhrase(permissionBlockedCount) ? buildAwaitingPermissionPhrase(permissionBlockedCount)
: buildPendingDiagnosticPhrase({ : (openCodeSecondaryWaitPhrase ??
buildPendingDiagnosticPhrase({
summary: memberSpawnSnapshot?.summary, summary: memberSpawnSnapshot?.summary,
memberSpawnStatuses, memberSpawnStatuses,
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses, memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt, memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
fallbackJoiningPhrase: joiningPhrase, fallbackJoiningPhrase: joiningPhrase,
}); }));
const readyCompactDetail = const readyCompactDetail =
failedSpawnCount > 0 failedSpawnCount > 0
? (failedSpawnCompactDetail ?? ? (failedSpawnCompactDetail ??
@ -684,7 +759,9 @@ export function buildTeamProvisioningPresentation({
? 'Team launched - lead online' ? 'Team launched - lead online'
: allTeammatesConfirmedAlive : allTeammatesConfirmedAlive
? `Team launched - all ${expectedTeammateCount} teammates joined` ? `Team launched - all ${expectedTeammateCount} teammates joined`
: 'Finishing launch'; : openCodeSecondaryWaitPhrase
? 'Core team ready'
: 'Finishing launch';
return { return {
progress, progress,
@ -721,7 +798,9 @@ export function buildTeamProvisioningPresentation({
: skippedSpawnCount > 0 : skippedSpawnCount > 0
? 'Launch continued with skipped teammates' ? 'Launch continued with skipped teammates'
: hasMembersStillJoining : hasMembersStillJoining
? 'Finishing launch' ? openCodeSecondaryWaitPhrase
? 'Core team ready'
: 'Finishing launch'
: 'Team launched', : 'Team launched',
compactDetail: readyCompactDetail, compactDetail: readyCompactDetail,
compactTone: compactTone:
@ -750,13 +829,14 @@ export function buildTeamProvisioningPresentation({
permissionBlockedCount > 0 && permissionBlockedCount > 0 &&
permissionBlockedCount === remainingJoinCount permissionBlockedCount === remainingJoinCount
? buildAwaitingPermissionPhrase(permissionBlockedCount) ? buildAwaitingPermissionPhrase(permissionBlockedCount)
: buildPendingDiagnosticPhrase({ : (openCodeSecondaryWaitPhrase ??
buildPendingDiagnosticPhrase({
summary: memberSpawnSnapshot?.summary, summary: memberSpawnSnapshot?.summary,
memberSpawnStatuses, memberSpawnStatuses,
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses, memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt, memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
fallbackJoiningPhrase: activeJoiningPhrase, fallbackJoiningPhrase: activeJoiningPhrase,
}); }));
return { return {
progress, progress,
isActive: true, isActive: true,
@ -773,22 +853,24 @@ export function buildTeamProvisioningPresentation({
allTeammatesConfirmedAlive, allTeammatesConfirmedAlive,
hasMembersStillJoining, hasMembersStillJoining,
remainingJoinCount, remainingJoinCount,
panelTitle: 'Launching team', panelTitle: openCodeSecondaryWaitPhrase ? 'Core team ready' : 'Launching team',
panelMessage: panelMessage:
failedSpawnCount > 0 failedSpawnCount > 0
? (failedSpawnPanelMessage ?? genericFailedSpawnPanelMessage ?? progress.message) ? (failedSpawnPanelMessage ?? genericFailedSpawnPanelMessage ?? progress.message)
: skippedSpawnCount > 0 : skippedSpawnCount > 0
? (skippedSpawnPanelMessage ?? ? (skippedSpawnPanelMessage ??
`${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped for this launch`) `${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped for this launch`)
: hasMembersStillJoining && : openCodeSecondaryWaitPhrase
permissionBlockedCount > 0 && ? openCodeSecondaryWaitPhrase
permissionBlockedCount === remainingJoinCount : hasMembersStillJoining &&
? activePendingDetailPhrase permissionBlockedCount > 0 &&
: progress.message, permissionBlockedCount === remainingJoinCount
? activePendingDetailPhrase
: progress.message,
panelMessageSeverity: panelMessageSeverity:
failedSpawnCount > 0 || skippedSpawnCount > 0 ? 'warning' : progress.messageSeverity, failedSpawnCount > 0 || skippedSpawnCount > 0 ? 'warning' : progress.messageSeverity,
defaultLiveOutputOpen: false, defaultLiveOutputOpen: false,
compactTitle: 'Launching team', compactTitle: openCodeSecondaryWaitPhrase ? 'Core team ready' : 'Launching team',
compactDetail: compactDetail:
failedSpawnCount > 0 failedSpawnCount > 0
? (failedSpawnCompactDetail ?? ? (failedSpawnCompactDetail ??
@ -796,13 +878,15 @@ export function buildTeamProvisioningPresentation({
: skippedSpawnCount > 0 : skippedSpawnCount > 0
? (skippedSpawnCompactDetail ?? ? (skippedSpawnCompactDetail ??
`${skippedSpawnCount} teammate${skippedSpawnCount === 1 ? '' : 's'} skipped`) `${skippedSpawnCount} teammate${skippedSpawnCount === 1 ? '' : 's'} skipped`)
: hasMembersStillJoining && failedSpawnCount === 0 && permissionBlockedCount > 0 : openCodeSecondaryWaitPhrase
? permissionBlockedCount === remainingJoinCount ? openCodeSecondaryWaitPhrase
? buildAwaitingPermissionPhrase(permissionBlockedCount) : hasMembersStillJoining && failedSpawnCount === 0 && permissionBlockedCount > 0
: `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed` ? permissionBlockedCount === remainingJoinCount
: expectedTeammateCount > 0 && progressStepIndex >= 2 ? buildAwaitingPermissionPhrase(permissionBlockedCount)
? `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed` : `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed`
: progress.message, : expectedTeammateCount > 0 && progressStepIndex >= 2
? `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed`
: progress.message,
compactTone: failedSpawnCount > 0 || skippedSpawnCount > 0 ? 'warning' : 'default', compactTone: failedSpawnCount > 0 || skippedSpawnCount > 0 ? 'warning' : 'default',
}; };
} }

View file

@ -12,12 +12,19 @@ import {
getOpenCodeTeamRuntimeDirectory, getOpenCodeTeamRuntimeDirectory,
inspectOpenCodeRuntimeLaneStorage, inspectOpenCodeRuntimeLaneStorage,
migrateLegacyOpenCodeRuntimeState, migrateLegacyOpenCodeRuntimeState,
readCommittedOpenCodeBootstrapSessionEvidence,
readOpenCodeRuntimeLaneIndex, readOpenCodeRuntimeLaneIndex,
recoverStaleOpenCodeRuntimeLaneIndexEntry, recoverStaleOpenCodeRuntimeLaneIndexEntry,
setOpenCodeRuntimeActiveRunManifest, setOpenCodeRuntimeActiveRunManifest,
upsertOpenCodeRuntimeLaneIndexEntry, upsertOpenCodeRuntimeLaneIndexEntry,
} from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader'; } from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
import { createDefaultRuntimeStoreManifest } from '../../../../src/main/services/team/opencode/store/RuntimeStoreManifest'; import {
createRuntimeStoreManifestStore,
createRuntimeStoreReceiptStore,
OPENCODE_RUNTIME_STORE_DESCRIPTORS,
RuntimeStoreBatchWriter,
createDefaultRuntimeStoreManifest,
} from '../../../../src/main/services/team/opencode/store/RuntimeStoreManifest';
describe('OpenCodeRuntimeManifestEvidenceReader migration', () => { describe('OpenCodeRuntimeManifestEvidenceReader migration', () => {
let tempDir: string; let tempDir: string;
@ -32,6 +39,127 @@ describe('OpenCodeRuntimeManifestEvidenceReader migration', () => {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
}); });
async function writeCommittedSessionStore(input: {
teamName: string;
laneId: string;
sessions: unknown[];
}) {
const descriptor = OPENCODE_RUNTIME_STORE_DESCRIPTORS.find(
(candidate) => candidate.schemaName === 'opencode.sessionStore'
);
if (!descriptor) throw new Error('session descriptor missing');
const manifestPath = getOpenCodeRuntimeManifestPath(tempDir, input.teamName, input.laneId);
const runtimeDirectory = path.dirname(manifestPath);
await fs.mkdir(runtimeDirectory, { recursive: true });
const writer = new RuntimeStoreBatchWriter(
runtimeDirectory,
createRuntimeStoreManifestStore({ filePath: manifestPath, teamName: input.teamName }),
createRuntimeStoreReceiptStore({
filePath: path.join(runtimeDirectory, 'opencode-runtime-receipts.json'),
}),
{
clock: () => now,
batchIdFactory: () => 'batch-1',
receiptIdFactory: () => 'receipt-1',
}
);
await writer.writeBatch({
teamName: input.teamName,
runId: 'runtime-run-1',
capabilitySnapshotId: null,
behaviorFingerprint: null,
reason: 'launch_checkpoint',
writes: [{ descriptor, data: { sessions: input.sessions } }],
});
}
it('reads only committed OpenCode bootstrap check-in session evidence', async () => {
const teamName = 'team-committed-session';
const laneId = 'secondary:opencode:tom';
await writeCommittedSessionStore({
teamName,
laneId,
sessions: [
{
id: 'ses-tom',
teamName,
memberName: 'tom',
runId: 'runtime-run-1',
laneId,
providerId: 'opencode',
observedAt: '2026-04-22T10:00:00.000Z',
source: 'runtime_bootstrap_checkin',
},
{
id: 'ses-ignored',
teamName,
memberName: 'tom',
runId: 'runtime-run-1',
laneId,
source: 'member_briefing',
},
],
});
await expect(
readCommittedOpenCodeBootstrapSessionEvidence({ teamsBasePath: tempDir, teamName, laneId })
).resolves.toMatchObject({
state: 'healthy',
committed: true,
sessions: [
{
id: 'ses-tom',
teamName,
memberName: 'tom',
laneId,
runId: 'runtime-run-1',
source: 'runtime_bootstrap_checkin',
},
],
});
});
it('does not treat an uncommitted session file as OpenCode bootstrap evidence', async () => {
const teamName = 'team-uncommitted-session';
const laneId = 'secondary:opencode:tom';
const sessionPath = getOpenCodeLaneScopedRuntimeFilePath({
teamsBasePath: tempDir,
teamName,
laneId,
fileName: 'opencode-sessions.json',
});
await fs.mkdir(path.dirname(sessionPath), { recursive: true });
await fs.writeFile(
sessionPath,
JSON.stringify({
schemaVersion: 1,
updatedAt: '2026-04-22T10:00:00.000Z',
data: {
sessions: [
{
id: 'ses-tom',
teamName,
memberName: 'tom',
laneId,
source: 'runtime_bootstrap_checkin',
},
],
},
}),
'utf8'
);
const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({
teamsBasePath: tempDir,
teamName,
laneId,
});
expect(evidence.committed).toBe(false);
expect(evidence.state).toBe('uncommitted_write');
expect(evidence.sessions).toEqual([]);
});
it('migrates legacy team-scoped OpenCode runtime files into the addressed lane', async () => { it('migrates legacy team-scoped OpenCode runtime files into the addressed lane', async () => {
const teamName = 'team-alpha'; const teamName = 'team-alpha';
const laneId = 'secondary:opencode:alice'; const laneId = 'secondary:opencode:alice';

View file

@ -16958,6 +16958,7 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter {
hardFailure: failed, hardFailure: failed,
hardFailureReason: failed ? 'fake_open_code_launch_failure' : undefined, hardFailureReason: failed ? 'fake_open_code_launch_failure' : undefined,
pendingPermissionRequestIds: permissionPending ? [`perm-${member.name}`] : undefined, pendingPermissionRequestIds: permissionPending ? [`perm-${member.name}`] : undefined,
sessionId: failed ? undefined : `session-${member.name}`,
runtimePid: failed ? undefined : 10_000 + index, runtimePid: failed ? undefined : 10_000 + index,
livenessKind, livenessKind,
pidSource: failed ? undefined : 'opencode_bridge', pidSource: failed ? undefined : 'opencode_bridge',

View file

@ -139,7 +139,13 @@ import {
setOpenCodeRuntimeActiveRunManifest, setOpenCodeRuntimeActiveRunManifest,
upsertOpenCodeRuntimeLaneIndexEntry, upsertOpenCodeRuntimeLaneIndexEntry,
} from '@main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader'; } from '@main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
import { createDefaultRuntimeStoreManifest } from '@main/services/team/opencode/store/RuntimeStoreManifest'; import {
createDefaultRuntimeStoreManifest,
createRuntimeStoreManifestStore,
createRuntimeStoreReceiptStore,
OPENCODE_RUNTIME_STORE_DESCRIPTORS,
RuntimeStoreBatchWriter,
} from '@main/services/team/opencode/store/RuntimeStoreManifest';
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { TeamRuntimeAdapterRegistry } from '@main/services/team/runtime/TeamRuntimeAdapter'; import { TeamRuntimeAdapterRegistry } from '@main/services/team/runtime/TeamRuntimeAdapter';
import { spawnCli } from '@main/utils/childProcess'; import { spawnCli } from '@main/utils/childProcess';
@ -344,6 +350,41 @@ function writeMembersMeta(
); );
} }
async function writeCommittedOpenCodeSessionStore(input: {
teamName: string;
laneId: string;
runId: string;
sessions: unknown[];
}): Promise<void> {
const descriptor = OPENCODE_RUNTIME_STORE_DESCRIPTORS.find(
(candidate) => candidate.schemaName === 'opencode.sessionStore'
);
if (!descriptor) throw new Error('session descriptor missing');
const manifestPath = getOpenCodeRuntimeManifestPath(tempTeamsBase, input.teamName, input.laneId);
const runtimeDirectory = path.dirname(manifestPath);
await fsPromises.mkdir(runtimeDirectory, { recursive: true });
const writer = new RuntimeStoreBatchWriter(
runtimeDirectory,
createRuntimeStoreManifestStore({ filePath: manifestPath, teamName: input.teamName }),
createRuntimeStoreReceiptStore({
filePath: path.join(runtimeDirectory, 'opencode-runtime-receipts.json'),
}),
{
clock: () => new Date('2026-04-22T12:00:00.000Z'),
batchIdFactory: () => `batch-${input.runId}`,
receiptIdFactory: () => `receipt-${input.runId}`,
}
);
await writer.writeBatch({
teamName: input.teamName,
runId: input.runId,
capabilitySnapshotId: null,
behaviorFingerprint: null,
reason: 'launch_checkpoint',
writes: [{ descriptor, data: { sessions: input.sessions } }],
});
}
function createMemberSpawnStatusEntry( function createMemberSpawnStatusEntry(
overrides: Record<string, unknown> = {} overrides: Record<string, unknown> = {}
): Record<string, unknown> { ): Record<string, unknown> {
@ -3116,26 +3157,50 @@ describe('TeamProvisioningService', () => {
it('launches the OpenCode secondary lane with side-lane provider and member runtime identity', async () => { it('launches the OpenCode secondary lane with side-lane provider and member runtime identity', async () => {
const svc = new TeamProvisioningService(); const svc = new TeamProvisioningService();
const adapterLaunch = vi.fn(async (input: Record<string, unknown>) => ({ const adapterLaunch = vi.fn(async (input: Record<string, unknown>) => {
runId: String(input.runId), const teamName = String(input.teamName);
teamName: String(input.teamName), const laneId = String(input.laneId);
launchPhase: 'finished', const runId = String(input.runId);
teamLaunchState: 'clean_success', const manifestPath = getOpenCodeRuntimeManifestPath(tempTeamsBase, teamName, laneId);
members: { await fsPromises.mkdir(path.dirname(manifestPath), { recursive: true });
bob: { await fsPromises.writeFile(
memberName: 'bob', manifestPath,
providerId: 'opencode', `${JSON.stringify(
launchState: 'confirmed_alive', {
agentToolAccepted: true, ...createDefaultRuntimeStoreManifest(teamName, '2026-04-22T12:00:00.000Z'),
runtimeAlive: true, activeRunId: runId,
bootstrapConfirmed: true, },
hardFailure: false, null,
diagnostics: [], 2
)}\n`,
'utf8'
);
await fsPromises.writeFile(
path.join(path.dirname(manifestPath), 'opencode-sessions.json'),
`${JSON.stringify({ sessions: [{ id: 'oc-session-bob' }] })}\n`,
'utf8'
);
return {
runId,
teamName,
launchPhase: 'finished',
teamLaunchState: 'clean_success',
members: {
bob: {
memberName: 'bob',
providerId: 'opencode',
launchState: 'confirmed_alive',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
diagnostics: [],
},
}, },
}, warnings: [],
warnings: [], diagnostics: [],
diagnostics: [], };
})); });
const registry = new TeamRuntimeAdapterRegistry([ const registry = new TeamRuntimeAdapterRegistry([
{ {
@ -6589,6 +6654,90 @@ describe('TeamProvisioningService', () => {
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
}); });
it('commits lane-scoped OpenCode session evidence when bootstrap check-in is accepted', async () => {
const svc = new TeamProvisioningService();
const teamName = 'mixed-team';
const laneId = 'secondary:opencode:bob';
const runId = 'opencode-run-1';
const teamDir = path.join(tempTeamsBase, teamName);
await fsPromises.mkdir(teamDir, { recursive: true });
await fsPromises.writeFile(
path.join(teamDir, 'config.json'),
`${JSON.stringify({
name: teamName,
projectPath: '/tmp/mixed-team',
members: [
{ name: 'team-lead', providerId: 'codex' },
{ name: 'bob', providerId: 'opencode' },
],
})}\n`,
'utf8'
);
(svc as any).aliveRunByTeam.set(teamName, 'lead-run');
(svc as any).runs.set('lead-run', {
runId: 'lead-run',
teamName,
request: {
providerId: 'codex',
},
});
(svc as any).setSecondaryRuntimeRun({
teamName,
runId,
providerId: 'opencode',
laneId,
memberName: 'bob',
cwd: '/tmp/mixed-team',
});
await expect(
svc.recordOpenCodeRuntimeBootstrapCheckin({
teamName,
runId,
memberName: 'bob',
runtimeSessionId: 'session-bob',
observedAt: '2026-04-22T12:05:00.000Z',
})
).resolves.toMatchObject({
ok: true,
state: 'accepted',
runtimeSessionId: 'session-bob',
});
const manifestPath = getOpenCodeRuntimeManifestPath(tempTeamsBase, teamName, laneId);
const manifest = JSON.parse(await fsPromises.readFile(manifestPath, 'utf8')) as {
data: { entries: Array<{ schemaName: string; relativePath: string; runId: string }> };
};
expect(manifest.data.entries).toEqual(
expect.arrayContaining([
expect.objectContaining({
schemaName: 'opencode.sessionStore',
relativePath: 'opencode-sessions.json',
runId,
}),
])
);
const sessionStore = JSON.parse(
await fsPromises.readFile(
path.join(path.dirname(manifestPath), 'opencode-sessions.json'),
'utf8'
)
) as {
data: {
sessions: Array<{ id: string; memberName: string; runId: string; laneId: string }>;
};
};
expect(sessionStore.data.sessions).toEqual([
expect.objectContaining({
id: 'session-bob',
memberName: 'bob',
runId,
laneId,
}),
]);
});
it('uses the secondary lane run id for OpenCode runtime delivery journal acceptance', async () => { it('uses the secondary lane run id for OpenCode runtime delivery journal acceptance', async () => {
const svc = new TeamProvisioningService(); const svc = new TeamProvisioningService();
const delivered = new Map< const delivered = new Map<
@ -8177,6 +8326,67 @@ describe('TeamProvisioningService', () => {
}); });
describe('safe app launch matrix', () => { describe('safe app launch matrix', () => {
it('does not wait for OpenCode secondary inboxes before completing primary filesystem readiness', async () => {
const teamName = 'mixed-secondary-fs-readiness';
const teamDir = path.join(tempTeamsBase, teamName);
fs.mkdirSync(path.join(teamDir, 'inboxes'), { recursive: true });
fs.writeFileSync(
path.join(teamDir, 'config.json'),
`${JSON.stringify(
{
name: teamName,
members: [
{ name: 'team-lead', agentType: 'team-lead', providerId: 'codex' },
{ name: 'alice', providerId: 'codex' },
],
},
null,
2
)}\n`,
'utf8'
);
fs.writeFileSync(path.join(teamDir, 'inboxes', 'alice.json'), '[]\n', 'utf8');
const svc = new TeamProvisioningService();
const complete = vi
.spyOn(svc as any, 'handleProvisioningTurnComplete')
.mockResolvedValue(undefined);
const run = {
runId: 'run-mixed-secondary-fs-readiness',
teamName,
cancelRequested: false,
processKilled: false,
provisioningComplete: false,
deterministicBootstrap: true,
fsPhase: 'waiting_members',
effectiveMembers: [{ name: 'alice', providerId: 'codex' }],
progress: { state: 'assembling' },
onProgress: vi.fn(),
fsMonitorHandle: null,
} as any;
(svc as any).startFilesystemMonitor(run, {
teamName,
cwd: tempClaudeRoot,
providerId: 'codex',
model: 'gpt-5.4',
members: [
{ name: 'alice', providerId: 'codex' },
{ name: 'tom', providerId: 'opencode' },
],
});
await vi.waitFor(() => expect(complete).toHaveBeenCalledTimes(1));
expect(run.fsPhase).toBe('all_files_found');
expect(run.onProgress).not.toHaveBeenCalledWith(
expect.objectContaining({
message: 'Prepared communication channels for 1/2 members',
})
);
(svc as any).stopFilesystemMonitor(run);
});
function createSafeLaunchService(options?: { function createSafeLaunchService(options?: {
memberWorktreeManager?: { ensureMemberWorktree: ReturnType<typeof vi.fn> }; memberWorktreeManager?: { ensureMemberWorktree: ReturnType<typeof vi.fn> };
}) { }) {
@ -10402,6 +10612,193 @@ describe('TeamProvisioningService', () => {
}); });
}); });
it('promotes OpenCode secondary pending launch state from committed bootstrap session evidence', async () => {
const teamName = 'zz-opencode-committed-overlay-promotes';
const leadSessionId = 'lead-session';
const laneId = 'secondary:opencode:tom';
const runId = 'opencode-run-tom';
writeTeamMeta(teamName, {
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
});
writeMembersMeta(teamName, [
{
name: 'tom',
providerId: 'opencode',
model: 'openrouter/minimax/minimax-m2.5',
},
]);
writeLaunchConfig(teamName, '/Users/test/proj', leadSessionId, ['tom']);
await upsertOpenCodeRuntimeLaneIndexEntry({
teamsBasePath: tempTeamsBase,
teamName,
laneId,
state: 'active',
diagnostics: ['OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.'],
});
await writeCommittedOpenCodeSessionStore({
teamName,
laneId,
runId,
sessions: [
{
id: 'ses-tom',
teamName,
memberName: 'tom',
laneId,
runId,
providerId: 'opencode',
observedAt: '2026-04-22T12:00:00.000Z',
source: 'runtime_bootstrap_checkin',
},
],
});
writeLaunchState(teamName, leadSessionId, {
tom: {
providerId: 'opencode',
laneId,
laneKind: 'secondary',
laneOwnerProviderId: 'opencode',
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
hardFailureReason: undefined,
diagnostics: [
'OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.',
],
lastEvaluatedAt: '2026-04-22T12:00:01.000Z',
},
});
const svc = new TeamProvisioningService();
const result = await svc.getMemberSpawnStatuses(teamName);
expect(result.statuses.tom).toMatchObject({
launchState: 'confirmed_alive',
agentToolAccepted: true,
bootstrapConfirmed: true,
runtimeAlive: false,
});
const persisted = JSON.parse(
await fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8')
);
expect(persisted.members.tom).toMatchObject({
launchState: 'confirmed_alive',
bootstrapConfirmed: true,
runtimeAlive: false,
runtimeSessionId: 'ses-tom',
});
});
it('prevents stale OpenCode secondary pending or missing writes from downgrading committed bootstrap evidence', async () => {
const teamName = 'zz-opencode-committed-overlay-write-boundary';
const leadSessionId = 'lead-session';
const laneId = 'secondary:opencode:tom';
const runId = 'opencode-run-tom';
writeMembersMeta(teamName, [{ name: 'tom', providerId: 'opencode' }]);
await upsertOpenCodeRuntimeLaneIndexEntry({
teamsBasePath: tempTeamsBase,
teamName,
laneId,
state: 'active',
});
await writeCommittedOpenCodeSessionStore({
teamName,
laneId,
runId,
sessions: [
{
id: 'ses-tom',
teamName,
memberName: 'tom',
laneId,
runId,
observedAt: '2026-04-22T12:00:00.000Z',
source: 'runtime_bootstrap_checkin',
},
],
});
writeLaunchState(teamName, leadSessionId, {
tom: {
providerId: 'opencode',
laneId,
laneKind: 'secondary',
laneOwnerProviderId: 'opencode',
launchState: 'confirmed_alive',
agentToolAccepted: true,
runtimeAlive: false,
bootstrapConfirmed: true,
hardFailure: false,
runtimeSessionId: 'ses-tom',
},
});
const staleSnapshot = createPersistedLaunchSnapshot({
teamName,
leadSessionId,
launchPhase: 'active',
expectedMembers: ['tom'],
members: {
tom: {
name: 'tom',
providerId: 'opencode',
laneId,
laneKind: 'secondary',
laneOwnerProviderId: 'opencode',
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
lastEvaluatedAt: '2026-04-22T12:00:02.000Z',
diagnostics: [
'OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.',
],
},
},
updatedAt: '2026-04-22T12:00:02.000Z',
});
const svc = new TeamProvisioningService();
await (svc as any).writeLaunchStateSnapshot(teamName, staleSnapshot);
const persisted = JSON.parse(
await fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8')
);
expect(persisted.members.tom).toMatchObject({
launchState: 'confirmed_alive',
bootstrapConfirmed: true,
runtimeAlive: false,
runtimeSessionId: 'ses-tom',
});
expect(persisted.teamLaunchState).toBe('clean_success');
const missingMemberSnapshot = createPersistedLaunchSnapshot({
teamName,
leadSessionId,
launchPhase: 'active',
expectedMembers: [],
members: {},
updatedAt: '2026-04-22T12:00:03.000Z',
});
await (svc as any).writeLaunchStateSnapshot(teamName, missingMemberSnapshot);
const persistedAfterMissingWrite = JSON.parse(
await fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8')
);
expect(persistedAfterMissingWrite.members.tom).toMatchObject({
launchState: 'confirmed_alive',
bootstrapConfirmed: true,
runtimeAlive: false,
runtimeSessionId: 'ses-tom',
});
expect(persistedAfterMissingWrite.teamLaunchState).toBe('clean_success');
});
it('marks a live teammate bootstrap as confirmed from transcript even when runtime discovery is stale', async () => { it('marks a live teammate bootstrap as confirmed from transcript even when runtime discovery is stale', async () => {
allowConsoleLogs(); allowConsoleLogs();
const teamName = 'zz-live-bootstrap-transcript-success-without-runtime'; const teamName = 'zz-live-bootstrap-transcript-success-without-runtime';

View file

@ -556,6 +556,204 @@ describe('buildTeamProvisioningPresentation', () => {
expect(presentation?.failedSpawnCount).toBe(0); expect(presentation?.failedSpawnCount).toBe(0);
}); });
it('shows core team ready when only OpenCode secondary lanes are still joining', () => {
const presentation = buildTeamProvisioningPresentation({
progress: {
runId: 'run-opencode-secondary-ready',
teamName: 'mixed-team',
state: 'ready',
startedAt: '2026-04-13T10:00:00.000Z',
updatedAt: '2026-04-13T10:00:08.000Z',
message: 'Team provisioned - waiting for secondary runtime lane: tom',
messageSeverity: undefined,
pid: 4321,
cliLogsTail: '',
assistantOutput: '',
},
members: [
{
name: 'team-lead',
agentType: 'team-lead',
providerId: 'codex',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
},
{
name: 'alice',
providerId: 'codex',
laneKind: 'primary',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
},
{
name: 'tom',
providerId: 'opencode',
laneId: 'secondary:opencode:tom',
laneKind: 'secondary',
laneOwnerProviderId: 'opencode',
status: 'unknown',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
},
],
memberSpawnStatuses: {
alice: {
status: 'online',
launchState: 'confirmed_alive',
updatedAt: '2026-04-13T10:00:05.000Z',
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
agentToolAccepted: true,
},
tom: {
status: 'online',
launchState: 'runtime_pending_bootstrap',
updatedAt: '2026-04-13T10:00:07.000Z',
runtimeAlive: true,
livenessSource: 'process',
livenessKind: 'runtime_process_candidate',
bootstrapConfirmed: false,
hardFailure: false,
agentToolAccepted: true,
},
},
memberSpawnSnapshot: {
expectedMembers: ['alice', 'tom'],
statuses: {
alice: {
status: 'online',
launchState: 'confirmed_alive',
updatedAt: '2026-04-13T10:00:05.000Z',
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
agentToolAccepted: true,
},
tom: {
status: 'online',
launchState: 'runtime_pending_bootstrap',
updatedAt: '2026-04-13T10:00:07.000Z',
runtimeAlive: true,
livenessSource: 'process',
livenessKind: 'runtime_process_candidate',
bootstrapConfirmed: false,
hardFailure: false,
agentToolAccepted: true,
},
},
summary: {
confirmedCount: 1,
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 0,
runtimeCandidatePendingCount: 1,
},
},
});
expect(presentation?.successMessage).toBe('Core team ready');
expect(presentation?.panelMessage).toBe('Waiting for OpenCode: tom');
expect(presentation?.compactTitle).toBe('Core team ready');
expect(presentation?.compactDetail).toBe('Waiting for OpenCode: tom');
expect(presentation?.currentStepIndex).toBe(2);
});
it('does not show core team ready while a primary member is still joining', () => {
const presentation = buildTeamProvisioningPresentation({
progress: {
runId: 'run-primary-still-starting',
teamName: 'mixed-team',
state: 'ready',
startedAt: '2026-04-13T10:00:00.000Z',
updatedAt: '2026-04-13T10:00:08.000Z',
message: 'Team provisioned - waiting for members',
messageSeverity: undefined,
pid: 4321,
cliLogsTail: '',
assistantOutput: '',
},
members: [
{
name: 'team-lead',
agentType: 'team-lead',
providerId: 'codex',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
},
{
name: 'alice',
providerId: 'codex',
laneKind: 'primary',
status: 'unknown',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
},
{
name: 'tom',
providerId: 'opencode',
laneId: 'secondary:opencode:tom',
laneKind: 'secondary',
laneOwnerProviderId: 'opencode',
status: 'unknown',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
},
],
memberSpawnStatuses: {
alice: {
status: 'waiting',
launchState: 'starting',
updatedAt: '2026-04-13T10:00:05.000Z',
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
agentToolAccepted: true,
},
tom: {
status: 'online',
launchState: 'runtime_pending_bootstrap',
updatedAt: '2026-04-13T10:00:07.000Z',
runtimeAlive: true,
livenessSource: 'process',
livenessKind: 'runtime_process_candidate',
bootstrapConfirmed: false,
hardFailure: false,
agentToolAccepted: true,
},
},
memberSpawnSnapshot: {
expectedMembers: ['alice', 'tom'],
summary: {
confirmedCount: 0,
pendingCount: 2,
failedCount: 0,
runtimeAlivePendingCount: 0,
runtimeCandidatePendingCount: 1,
},
},
});
expect(presentation?.successMessage).toBe('Finishing launch');
expect(presentation?.panelMessage).not.toBe('Waiting for OpenCode: tom');
expect(presentation?.compactTitle).toBe('Finishing launch');
});
it('surfaces permission-blocked teammates as awaiting approval while launch is finishing', () => { it('surfaces permission-blocked teammates as awaiting approval while launch is finishing', () => {
const presentation = buildTeamProvisioningPresentation({ const presentation = buildTeamProvisioningPresentation({
progress: { progress: {