fix: harden opencode launch recovery
This commit is contained in:
parent
3240ea6406
commit
5224fe4cda
11 changed files with 2021 additions and 94 deletions
|
|
@ -6,6 +6,7 @@ Start here:
|
|||
- Repo overview and commands: [README.md](README.md)
|
||||
- Working instructions and project conventions: [CLAUDE.md](CLAUDE.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:
|
||||
- Default home for medium and large features: `src/features/<feature-name>/`
|
||||
|
|
@ -15,6 +16,7 @@ For new features:
|
|||
## Review guidelines
|
||||
|
||||
- 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.
|
||||
- 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)`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
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
|
||||
- **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.
|
||||
|
|
|
|||
176
docs/team-management/debugging-agent-teams.md
Normal file
176
docs/team-management/debugging-agent-teams.md
Normal 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
|
|
@ -11,11 +11,13 @@ import {
|
|||
createRuntimeStoreManifestStore,
|
||||
OPENCODE_RUNTIME_STORE_DESCRIPTORS,
|
||||
OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION,
|
||||
RuntimeStoreFileInspector,
|
||||
validateRuntimeStoreManifest,
|
||||
} from './RuntimeStoreManifest';
|
||||
|
||||
import type { RuntimeStoreManifestEvidence } from '../bridge/OpenCodeBridgeCommandContract';
|
||||
import type { RuntimeStoreManifestReader } from '../bridge/OpenCodeStateChangingBridgeCommandService';
|
||||
import type { RuntimeStoreManifestEntryState } from './RuntimeStoreManifest';
|
||||
|
||||
const logger = createLogger('OpenCodeRuntimeManifestEvidenceReader');
|
||||
|
||||
|
|
@ -56,6 +58,23 @@ export interface OpenCodeRuntimeLaneIndex {
|
|||
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(
|
||||
updatedAt = new Date().toISOString()
|
||||
): 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(
|
||||
teamsBasePath: 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(
|
||||
teamsBasePath: string,
|
||||
teamName: string
|
||||
|
|
|
|||
|
|
@ -85,11 +85,80 @@ const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
|
|||
const NOOP_TEAM_CLICK = (): void => undefined;
|
||||
|
||||
type ViewerMarkdownMode = 'default' | 'compact-preview';
|
||||
type HastElementLike = {
|
||||
tagName?: string;
|
||||
value?: string;
|
||||
children?: unknown[];
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// 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.
|
||||
* react-markdown v10 strips non-standard protocols by default.
|
||||
|
|
@ -354,6 +423,18 @@ function createViewerMarkdownComponents(
|
|||
</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 {
|
||||
// Headings
|
||||
h1: ({ children }) =>
|
||||
|
|
@ -705,9 +786,9 @@ function createViewerMarkdownComponents(
|
|||
),
|
||||
|
||||
// Tables
|
||||
table: ({ children }) =>
|
||||
table: ({ children, node }) =>
|
||||
isCompactPreview ? (
|
||||
<span>{children}</span>
|
||||
renderCompactTableSummary(node, children)
|
||||
) : (
|
||||
<div className="my-3 overflow-x-auto">
|
||||
<table
|
||||
|
|
@ -720,13 +801,21 @@ function createViewerMarkdownComponents(
|
|||
),
|
||||
thead: ({ children }) =>
|
||||
isCompactPreview ? (
|
||||
<span>{children}</span>
|
||||
<thead style={{ backgroundColor: PROSE_TABLE_HEADER_BG }}>{children}</thead>
|
||||
) : (
|
||||
<thead style={{ backgroundColor: PROSE_TABLE_HEADER_BG }}>{children}</thead>
|
||||
),
|
||||
th: ({ children }) =>
|
||||
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
|
||||
className="px-3 py-2 text-left font-semibold"
|
||||
|
|
@ -740,7 +829,15 @@ function createViewerMarkdownComponents(
|
|||
),
|
||||
td: ({ children }) =>
|
||||
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
|
||||
className="px-3 py-2"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ interface ProvisioningMemberLike {
|
|||
name: string;
|
||||
removedAt?: number;
|
||||
agentType?: string;
|
||||
providerId?: string;
|
||||
laneId?: string;
|
||||
laneKind?: 'primary' | 'secondary';
|
||||
laneOwnerProviderId?: string;
|
||||
status?: string;
|
||||
currentTaskId?: string | null;
|
||||
taskCount?: number;
|
||||
|
|
@ -146,6 +150,12 @@ function buildAwaitingPermissionPhrase(count: number): string {
|
|||
: `${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: {
|
||||
memberSpawnStatuses: MemberSpawnStatusCollection;
|
||||
memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
|
||||
|
|
@ -223,13 +233,71 @@ function getPendingDiagnosticNameGroups(params: {
|
|||
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 {
|
||||
if (names.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const listedNames = names.slice(0, MAX_PENDING_DIAGNOSTIC_NAMES).join(', ');
|
||||
const remainingCount = names.length - Math.min(names.length, MAX_PENDING_DIAGNOSTIC_NAMES);
|
||||
return `${label}: ${listedNames}${remainingCount > 0 ? `, +${remainingCount} more` : ''}`;
|
||||
return `${label}: ${formatMemberNameList(names)}`;
|
||||
}
|
||||
|
||||
function formatCountPendingDiagnostic(count: number | undefined, label: string): string | null {
|
||||
|
|
@ -578,6 +646,12 @@ export function buildTeamProvisioningPresentation({
|
|||
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
|
||||
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
|
||||
});
|
||||
const openCodeSecondaryWaitPhrase = buildOpenCodeSecondaryWaitPhrase({
|
||||
members,
|
||||
memberSpawnStatuses,
|
||||
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
|
||||
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
|
||||
});
|
||||
|
||||
const { allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount } =
|
||||
getLaunchJoinState({
|
||||
|
|
@ -637,13 +711,14 @@ export function buildTeamProvisioningPresentation({
|
|||
permissionBlockedCount === remainingJoinCount;
|
||||
const pendingDetailPhrase = pendingMembersAwaitApproval
|
||||
? buildAwaitingPermissionPhrase(permissionBlockedCount)
|
||||
: buildPendingDiagnosticPhrase({
|
||||
: (openCodeSecondaryWaitPhrase ??
|
||||
buildPendingDiagnosticPhrase({
|
||||
summary: memberSpawnSnapshot?.summary,
|
||||
memberSpawnStatuses,
|
||||
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
|
||||
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
|
||||
fallbackJoiningPhrase: joiningPhrase,
|
||||
});
|
||||
}));
|
||||
const readyCompactDetail =
|
||||
failedSpawnCount > 0
|
||||
? (failedSpawnCompactDetail ??
|
||||
|
|
@ -684,7 +759,9 @@ export function buildTeamProvisioningPresentation({
|
|||
? 'Team launched - lead online'
|
||||
: allTeammatesConfirmedAlive
|
||||
? `Team launched - all ${expectedTeammateCount} teammates joined`
|
||||
: 'Finishing launch';
|
||||
: openCodeSecondaryWaitPhrase
|
||||
? 'Core team ready'
|
||||
: 'Finishing launch';
|
||||
|
||||
return {
|
||||
progress,
|
||||
|
|
@ -721,7 +798,9 @@ export function buildTeamProvisioningPresentation({
|
|||
: skippedSpawnCount > 0
|
||||
? 'Launch continued with skipped teammates'
|
||||
: hasMembersStillJoining
|
||||
? 'Finishing launch'
|
||||
? openCodeSecondaryWaitPhrase
|
||||
? 'Core team ready'
|
||||
: 'Finishing launch'
|
||||
: 'Team launched',
|
||||
compactDetail: readyCompactDetail,
|
||||
compactTone:
|
||||
|
|
@ -750,13 +829,14 @@ export function buildTeamProvisioningPresentation({
|
|||
permissionBlockedCount > 0 &&
|
||||
permissionBlockedCount === remainingJoinCount
|
||||
? buildAwaitingPermissionPhrase(permissionBlockedCount)
|
||||
: buildPendingDiagnosticPhrase({
|
||||
: (openCodeSecondaryWaitPhrase ??
|
||||
buildPendingDiagnosticPhrase({
|
||||
summary: memberSpawnSnapshot?.summary,
|
||||
memberSpawnStatuses,
|
||||
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
|
||||
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
|
||||
fallbackJoiningPhrase: activeJoiningPhrase,
|
||||
});
|
||||
}));
|
||||
return {
|
||||
progress,
|
||||
isActive: true,
|
||||
|
|
@ -773,22 +853,24 @@ export function buildTeamProvisioningPresentation({
|
|||
allTeammatesConfirmedAlive,
|
||||
hasMembersStillJoining,
|
||||
remainingJoinCount,
|
||||
panelTitle: 'Launching team',
|
||||
panelTitle: openCodeSecondaryWaitPhrase ? 'Core team ready' : 'Launching team',
|
||||
panelMessage:
|
||||
failedSpawnCount > 0
|
||||
? (failedSpawnPanelMessage ?? genericFailedSpawnPanelMessage ?? progress.message)
|
||||
: skippedSpawnCount > 0
|
||||
? (skippedSpawnPanelMessage ??
|
||||
`${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped for this launch`)
|
||||
: hasMembersStillJoining &&
|
||||
permissionBlockedCount > 0 &&
|
||||
permissionBlockedCount === remainingJoinCount
|
||||
? activePendingDetailPhrase
|
||||
: progress.message,
|
||||
: openCodeSecondaryWaitPhrase
|
||||
? openCodeSecondaryWaitPhrase
|
||||
: hasMembersStillJoining &&
|
||||
permissionBlockedCount > 0 &&
|
||||
permissionBlockedCount === remainingJoinCount
|
||||
? activePendingDetailPhrase
|
||||
: progress.message,
|
||||
panelMessageSeverity:
|
||||
failedSpawnCount > 0 || skippedSpawnCount > 0 ? 'warning' : progress.messageSeverity,
|
||||
defaultLiveOutputOpen: false,
|
||||
compactTitle: 'Launching team',
|
||||
compactTitle: openCodeSecondaryWaitPhrase ? 'Core team ready' : 'Launching team',
|
||||
compactDetail:
|
||||
failedSpawnCount > 0
|
||||
? (failedSpawnCompactDetail ??
|
||||
|
|
@ -796,13 +878,15 @@ export function buildTeamProvisioningPresentation({
|
|||
: skippedSpawnCount > 0
|
||||
? (skippedSpawnCompactDetail ??
|
||||
`${skippedSpawnCount} teammate${skippedSpawnCount === 1 ? '' : 's'} skipped`)
|
||||
: hasMembersStillJoining && failedSpawnCount === 0 && permissionBlockedCount > 0
|
||||
? permissionBlockedCount === remainingJoinCount
|
||||
? buildAwaitingPermissionPhrase(permissionBlockedCount)
|
||||
: `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed`
|
||||
: expectedTeammateCount > 0 && progressStepIndex >= 2
|
||||
? `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed`
|
||||
: progress.message,
|
||||
: openCodeSecondaryWaitPhrase
|
||||
? openCodeSecondaryWaitPhrase
|
||||
: hasMembersStillJoining && failedSpawnCount === 0 && permissionBlockedCount > 0
|
||||
? permissionBlockedCount === remainingJoinCount
|
||||
? buildAwaitingPermissionPhrase(permissionBlockedCount)
|
||||
: `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed`
|
||||
: expectedTeammateCount > 0 && progressStepIndex >= 2
|
||||
? `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed`
|
||||
: progress.message,
|
||||
compactTone: failedSpawnCount > 0 || skippedSpawnCount > 0 ? 'warning' : 'default',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,12 +12,19 @@ import {
|
|||
getOpenCodeTeamRuntimeDirectory,
|
||||
inspectOpenCodeRuntimeLaneStorage,
|
||||
migrateLegacyOpenCodeRuntimeState,
|
||||
readCommittedOpenCodeBootstrapSessionEvidence,
|
||||
readOpenCodeRuntimeLaneIndex,
|
||||
recoverStaleOpenCodeRuntimeLaneIndexEntry,
|
||||
setOpenCodeRuntimeActiveRunManifest,
|
||||
upsertOpenCodeRuntimeLaneIndexEntry,
|
||||
} 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', () => {
|
||||
let tempDir: string;
|
||||
|
|
@ -32,6 +39,127 @@ describe('OpenCodeRuntimeManifestEvidenceReader migration', () => {
|
|||
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 () => {
|
||||
const teamName = 'team-alpha';
|
||||
const laneId = 'secondary:opencode:alice';
|
||||
|
|
|
|||
|
|
@ -16958,6 +16958,7 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
hardFailure: failed,
|
||||
hardFailureReason: failed ? 'fake_open_code_launch_failure' : undefined,
|
||||
pendingPermissionRequestIds: permissionPending ? [`perm-${member.name}`] : undefined,
|
||||
sessionId: failed ? undefined : `session-${member.name}`,
|
||||
runtimePid: failed ? undefined : 10_000 + index,
|
||||
livenessKind,
|
||||
pidSource: failed ? undefined : 'opencode_bridge',
|
||||
|
|
|
|||
|
|
@ -139,7 +139,13 @@ import {
|
|||
setOpenCodeRuntimeActiveRunManifest,
|
||||
upsertOpenCodeRuntimeLaneIndexEntry,
|
||||
} 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 { TeamRuntimeAdapterRegistry } from '@main/services/team/runtime/TeamRuntimeAdapter';
|
||||
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(
|
||||
overrides: 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 () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const adapterLaunch = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
runId: String(input.runId),
|
||||
teamName: String(input.teamName),
|
||||
launchPhase: 'finished',
|
||||
teamLaunchState: 'clean_success',
|
||||
members: {
|
||||
bob: {
|
||||
memberName: 'bob',
|
||||
providerId: 'opencode',
|
||||
launchState: 'confirmed_alive',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
diagnostics: [],
|
||||
const adapterLaunch = vi.fn(async (input: Record<string, unknown>) => {
|
||||
const teamName = String(input.teamName);
|
||||
const laneId = String(input.laneId);
|
||||
const runId = String(input.runId);
|
||||
const manifestPath = getOpenCodeRuntimeManifestPath(tempTeamsBase, teamName, laneId);
|
||||
await fsPromises.mkdir(path.dirname(manifestPath), { recursive: true });
|
||||
await fsPromises.writeFile(
|
||||
manifestPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
...createDefaultRuntimeStoreManifest(teamName, '2026-04-22T12:00:00.000Z'),
|
||||
activeRunId: runId,
|
||||
},
|
||||
null,
|
||||
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: [],
|
||||
diagnostics: [],
|
||||
}));
|
||||
warnings: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
});
|
||||
|
||||
const registry = new TeamRuntimeAdapterRegistry([
|
||||
{
|
||||
|
|
@ -6589,6 +6654,90 @@ describe('TeamProvisioningService', () => {
|
|||
).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 () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const delivered = new Map<
|
||||
|
|
@ -8177,6 +8326,67 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
|
||||
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?: {
|
||||
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 () => {
|
||||
allowConsoleLogs();
|
||||
const teamName = 'zz-live-bootstrap-transcript-success-without-runtime';
|
||||
|
|
|
|||
|
|
@ -556,6 +556,204 @@ describe('buildTeamProvisioningPresentation', () => {
|
|||
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', () => {
|
||||
const presentation = buildTeamProvisioningPresentation({
|
||||
progress: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue