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)
|
- 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)`.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
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,
|
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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue