chore: checkpoint team runtime work
This commit is contained in:
parent
dbd9ca7cf7
commit
d60abd54fe
26 changed files with 3146 additions and 124 deletions
|
|
@ -911,14 +911,17 @@ async function memberBriefing(context, memberName, options = {}) {
|
|||
config.projectPath.trim() :
|
||||
'';
|
||||
|
||||
const activeProcesses = processStore
|
||||
.listProcesses(context.paths)
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry &&
|
||||
entry.alive &&
|
||||
normalizeMemberName(entry.registeredBy) === normalizeMemberName(requestedMemberName)
|
||||
);
|
||||
const includeActiveProcesses = options.includeActiveProcesses !== false;
|
||||
const activeProcesses = includeActiveProcesses ?
|
||||
processStore
|
||||
.listProcesses(context.paths)
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry &&
|
||||
entry.alive &&
|
||||
normalizeMemberName(entry.registeredBy) === normalizeMemberName(requestedMemberName)
|
||||
) :
|
||||
[];
|
||||
|
||||
const taskQueue = await taskBriefing(context, requestedMemberName);
|
||||
const completionNotifyExample = messagingProtocol.buildLeadMessageExample({
|
||||
|
|
|
|||
2
mcp-server/src/agent-teams-controller.d.ts
vendored
2
mcp-server/src/agent-teams-controller.d.ts
vendored
|
|
@ -43,7 +43,7 @@ declare module 'agent-teams-controller' {
|
|||
unlinkTask(taskId: string, targetId: string, linkType: string): unknown;
|
||||
memberBriefing(
|
||||
memberName: string,
|
||||
options?: { runtimeProvider?: 'native' | 'opencode' }
|
||||
options?: { runtimeProvider?: 'native' | 'opencode'; includeActiveProcesses?: boolean }
|
||||
): Promise<string>;
|
||||
leadBriefing(): Promise<string>;
|
||||
taskBriefing(memberName: string): Promise<string>;
|
||||
|
|
|
|||
|
|
@ -622,8 +622,9 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
memberName: z.string().min(1),
|
||||
runtimeProvider: z.enum(['native', 'opencode']).optional(),
|
||||
includeActiveProcesses: z.boolean().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, memberName, runtimeProvider }) => {
|
||||
execute: async ({ teamName, claudeDir, memberName, runtimeProvider, includeActiveProcesses }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return {
|
||||
content: [
|
||||
|
|
@ -631,6 +632,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
type: 'text' as const,
|
||||
text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName, {
|
||||
...(runtimeProvider ? { runtimeProvider } : {}),
|
||||
...(includeActiveProcesses !== undefined ? { includeActiveProcesses } : {}),
|
||||
}),
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -22,7 +22,11 @@
|
|||
"dev:web": "node ./scripts/dev-web.mjs",
|
||||
"dev:kill": "node bin/kill-dev.js",
|
||||
"opencode:prove-mixed-recovery": "node ./scripts/prove-opencode-mixed-recovery.mjs",
|
||||
"opencode:prove-semantic-gauntlet": "node ./scripts/prove-opencode-semantic-gauntlet.mjs",
|
||||
"opencode:prove-semantic-messaging": "node ./scripts/prove-opencode-semantic-messaging.mjs",
|
||||
"opencode:prove-semantic-model-matrix": "node ./scripts/prove-opencode-semantic-model-matrix.mjs",
|
||||
"opencode:prove-team-provisioning": "node ./scripts/prove-opencode-team-provisioning.mjs",
|
||||
"team:prove-agent-cli-launch": "node ./scripts/prove-agent-cli-launch.mjs",
|
||||
"team:prove-launch-matrix": "pnpm exec vitest run --maxWorkers 1 --minWorkers 1 test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts",
|
||||
"prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
|
||||
"build": "node --max-old-space-size=8192 ./node_modules/electron-vite/bin/electron-vite.js build",
|
||||
|
|
|
|||
43
scripts/prove-agent-cli-launch.mjs
Normal file
43
scripts/prove-agent-cli-launch.mjs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
AGENT_CLI_LAUNCH_LIVE_E2E: '1',
|
||||
};
|
||||
|
||||
console.log('Running agent CLI launch live smoke');
|
||||
|
||||
const result = spawnSync(
|
||||
'pnpm',
|
||||
[
|
||||
'exec',
|
||||
'vitest',
|
||||
'run',
|
||||
'--maxWorkers',
|
||||
'1',
|
||||
'--minWorkers',
|
||||
'1',
|
||||
'test/main/utils/AgentCliLaunch.live-e2e.test.ts',
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
}
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
console.error(`Failed to run agent CLI launch smoke: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
73
scripts/prove-opencode-semantic-gauntlet.mjs
Normal file
73
scripts/prove-opencode-semantic-gauntlet.mjs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
exitForSkippedPreflight,
|
||||
preflightOpenCodeLiveEnvironment,
|
||||
} from './lib/opencode-live-preflight.mjs';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const orchestratorRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim();
|
||||
const siblingOrchestrator = path.resolve(repoRoot, '..', 'agent_teams_orchestrator');
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCODE_E2E: '1',
|
||||
OPENCODE_E2E_SEMANTIC_MODEL_GAUNTLET: '1',
|
||||
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL?.trim() || 'opencode/big-pickle',
|
||||
OPENCODE_E2E_GAUNTLET_RUNS: process.env.OPENCODE_E2E_GAUNTLET_RUNS?.trim() || '1',
|
||||
OPENCODE_E2E_GAUNTLET_MIN_AVERAGE_SCORE:
|
||||
process.env.OPENCODE_E2E_GAUNTLET_MIN_AVERAGE_SCORE?.trim() || '80',
|
||||
OPENCODE_E2E_GAUNTLET_MIN_SUCCESSFUL_RUNS:
|
||||
process.env.OPENCODE_E2E_GAUNTLET_MIN_SUCCESSFUL_RUNS?.trim() || '1',
|
||||
OPENCODE_E2E_GAUNTLET_MIN_CONSISTENCY_SCORE:
|
||||
process.env.OPENCODE_E2E_GAUNTLET_MIN_CONSISTENCY_SCORE?.trim() || '0',
|
||||
OPENCODE_E2E_GAUNTLET_REQUIRE_RECOMMENDED:
|
||||
process.env.OPENCODE_E2E_GAUNTLET_REQUIRE_RECOMMENDED?.trim() || '1',
|
||||
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
|
||||
};
|
||||
|
||||
if (!env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim()) {
|
||||
const runtimeRoot = orchestratorRoot ? path.resolve(orchestratorRoot) : siblingOrchestrator;
|
||||
env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = path.join(runtimeRoot, 'cli');
|
||||
}
|
||||
|
||||
console.log('Running OpenCode semantic gauntlet live smoke');
|
||||
console.log(`Models: ${env.OPENCODE_E2E_MODELS?.trim() || env.OPENCODE_E2E_MODEL}`);
|
||||
console.log(`Runs per model: ${env.OPENCODE_E2E_GAUNTLET_RUNS}`);
|
||||
console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`);
|
||||
|
||||
const preflight = await preflightOpenCodeLiveEnvironment({ repoRoot });
|
||||
exitForSkippedPreflight(preflight);
|
||||
|
||||
const result = spawnSync(
|
||||
'pnpm',
|
||||
[
|
||||
'exec',
|
||||
'vitest',
|
||||
'run',
|
||||
'--maxWorkers',
|
||||
'1',
|
||||
'--minWorkers',
|
||||
'1',
|
||||
'test/main/services/team/OpenCodeSemanticModelGauntlet.live.test.ts',
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
}
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
console.error(`Failed to run OpenCode semantic gauntlet smoke: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
65
scripts/prove-opencode-semantic-messaging.mjs
Normal file
65
scripts/prove-opencode-semantic-messaging.mjs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
exitForSkippedPreflight,
|
||||
preflightOpenCodeLiveEnvironment,
|
||||
} from './lib/opencode-live-preflight.mjs';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const orchestratorRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim();
|
||||
const siblingOrchestrator = path.resolve(repoRoot, '..', 'agent_teams_orchestrator');
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCODE_E2E: '1',
|
||||
OPENCODE_E2E_SEMANTIC_MESSAGING: '1',
|
||||
OPENCODE_E2E_PROJECT_PATH: process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || repoRoot,
|
||||
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL?.trim() || 'opencode/big-pickle',
|
||||
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
|
||||
};
|
||||
|
||||
if (!env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim()) {
|
||||
const runtimeRoot = orchestratorRoot ? path.resolve(orchestratorRoot) : siblingOrchestrator;
|
||||
env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = path.join(runtimeRoot, 'cli');
|
||||
}
|
||||
|
||||
console.log('Running OpenCode semantic messaging live smoke');
|
||||
console.log(`Model: ${env.OPENCODE_E2E_MODEL}`);
|
||||
console.log(`Project: ${env.OPENCODE_E2E_PROJECT_PATH}`);
|
||||
console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`);
|
||||
|
||||
const preflight = await preflightOpenCodeLiveEnvironment({ repoRoot });
|
||||
exitForSkippedPreflight(preflight);
|
||||
|
||||
const result = spawnSync(
|
||||
'pnpm',
|
||||
[
|
||||
'exec',
|
||||
'vitest',
|
||||
'run',
|
||||
'--maxWorkers',
|
||||
'1',
|
||||
'--minWorkers',
|
||||
'1',
|
||||
'test/main/services/team/OpenCodeSemanticMessaging.live.test.ts',
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
}
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
console.error(`Failed to run OpenCode semantic messaging smoke: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
63
scripts/prove-opencode-semantic-model-matrix.mjs
Normal file
63
scripts/prove-opencode-semantic-model-matrix.mjs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
exitForSkippedPreflight,
|
||||
preflightOpenCodeLiveEnvironment,
|
||||
} from './lib/opencode-live-preflight.mjs';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const orchestratorRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim();
|
||||
const siblingOrchestrator = path.resolve(repoRoot, '..', 'agent_teams_orchestrator');
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCODE_E2E: '1',
|
||||
OPENCODE_E2E_SEMANTIC_MODEL_MATRIX: '1',
|
||||
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL?.trim() || 'opencode/big-pickle',
|
||||
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
|
||||
};
|
||||
|
||||
if (!env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim()) {
|
||||
const runtimeRoot = orchestratorRoot ? path.resolve(orchestratorRoot) : siblingOrchestrator;
|
||||
env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = path.join(runtimeRoot, 'cli');
|
||||
}
|
||||
|
||||
console.log('Running OpenCode semantic model matrix live smoke');
|
||||
console.log(`Models: ${env.OPENCODE_E2E_MODELS?.trim() || env.OPENCODE_E2E_MODEL}`);
|
||||
console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`);
|
||||
|
||||
const preflight = await preflightOpenCodeLiveEnvironment({ repoRoot });
|
||||
exitForSkippedPreflight(preflight);
|
||||
|
||||
const result = spawnSync(
|
||||
'pnpm',
|
||||
[
|
||||
'exec',
|
||||
'vitest',
|
||||
'run',
|
||||
'--maxWorkers',
|
||||
'1',
|
||||
'--minWorkers',
|
||||
'1',
|
||||
'test/main/services/team/OpenCodeSemanticModelMatrix.live.test.ts',
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
}
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
console.error(`Failed to run OpenCode semantic model matrix smoke: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
|
|
@ -6,6 +6,9 @@ import type {
|
|||
MemberLaunchState,
|
||||
MemberSpawnLivenessSource,
|
||||
MemberSpawnStatusEntry,
|
||||
OpenCodeAppManagedBootstrapCandidate,
|
||||
OpenCodeBootstrapEvidenceSource,
|
||||
OpenCodeBootstrapMode,
|
||||
PersistedTeamLaunchMemberSources,
|
||||
PersistedTeamLaunchMemberState,
|
||||
PersistedTeamLaunchPhase,
|
||||
|
|
@ -43,6 +46,9 @@ export interface MixedSecondaryLaneMemberStateInput {
|
|||
runtimePid?: number;
|
||||
runtimeSessionId?: string;
|
||||
sessionId?: string;
|
||||
bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource;
|
||||
bootstrapMode?: OpenCodeBootstrapMode;
|
||||
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
|
||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||
pidSource?: TeamAgentRuntimePidSource;
|
||||
runtimeDiagnostic?: string;
|
||||
|
|
@ -348,6 +354,9 @@ function createSecondaryLaneMemberState(
|
|||
? Math.trunc(evidence.runtimePid)
|
||||
: undefined,
|
||||
runtimeSessionId: evidence?.runtimeSessionId ?? evidence?.sessionId,
|
||||
bootstrapEvidenceSource: evidence?.bootstrapEvidenceSource,
|
||||
bootstrapMode: evidence?.bootstrapMode,
|
||||
appManagedBootstrapCandidate: evidence?.appManagedBootstrapCandidate,
|
||||
livenessKind: evidence?.livenessKind,
|
||||
pidSource: evidence?.pidSource,
|
||||
runtimeDiagnostic: evidence?.runtimeDiagnostic,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
MemberLaunchState,
|
||||
MemberSpawnLivenessSource,
|
||||
MemberSpawnStatusEntry,
|
||||
OpenCodeAppManagedBootstrapCandidate,
|
||||
PersistedTeamLaunchMemberSources,
|
||||
PersistedTeamLaunchMemberState,
|
||||
PersistedTeamLaunchPhase,
|
||||
|
|
@ -176,6 +177,60 @@ function normalizeOptionalString(value: unknown): string | undefined {
|
|||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function normalizeOpenCodeAppManagedBootstrapCandidate(
|
||||
value: unknown
|
||||
): OpenCodeAppManagedBootstrapCandidate | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.schemaVersion !== 1 || record.source !== 'app_managed_bootstrap') {
|
||||
return undefined;
|
||||
}
|
||||
const teamName = normalizeOptionalString(record.teamName);
|
||||
const memberName = normalizeOptionalString(record.memberName);
|
||||
const runId = normalizeOptionalString(record.runId);
|
||||
const laneId = normalizeOptionalString(record.laneId);
|
||||
const runtimeSessionId = normalizeOptionalString(record.runtimeSessionId);
|
||||
const messageID = normalizeOptionalString(record.messageID);
|
||||
const contextHash = normalizeOptionalString(record.contextHash);
|
||||
const briefingHash = normalizeOptionalString(record.briefingHash);
|
||||
const injectionVerifiedAt = normalizeOptionalString(record.injectionVerifiedAt);
|
||||
const candidateAt = normalizeOptionalString(record.candidateAt);
|
||||
if (
|
||||
!teamName ||
|
||||
!memberName ||
|
||||
!runId ||
|
||||
!laneId ||
|
||||
!runtimeSessionId ||
|
||||
!messageID ||
|
||||
!contextHash ||
|
||||
!briefingHash ||
|
||||
!injectionVerifiedAt ||
|
||||
!candidateAt
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const model = normalizeOptionalString(record.model);
|
||||
const agent = normalizeOptionalString(record.agent);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
source: 'app_managed_bootstrap',
|
||||
teamName,
|
||||
memberName,
|
||||
runId,
|
||||
laneId,
|
||||
runtimeSessionId,
|
||||
messageID,
|
||||
contextHash,
|
||||
briefingHash,
|
||||
injectionVerifiedAt,
|
||||
candidateAt,
|
||||
...(model ? { model } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeJsonStringLiteral(value: string): string {
|
||||
try {
|
||||
return JSON.parse(`"${value}"`) as string;
|
||||
|
|
@ -601,6 +656,19 @@ function normalizePersistedMemberState(
|
|||
runtimePid: normalizeRuntimePid(parsed.runtimePid),
|
||||
runtimeRunId: normalizeOptionalString(parsed.runtimeRunId),
|
||||
runtimeSessionId: normalizeOptionalString(parsed.runtimeSessionId),
|
||||
bootstrapEvidenceSource:
|
||||
parsed.bootstrapEvidenceSource === 'runtime_bootstrap_checkin' ||
|
||||
parsed.bootstrapEvidenceSource === 'app_managed_bootstrap'
|
||||
? parsed.bootstrapEvidenceSource
|
||||
: undefined,
|
||||
bootstrapMode:
|
||||
parsed.bootstrapMode === 'model_tool_checkin' ||
|
||||
parsed.bootstrapMode === 'app_managed_context'
|
||||
? parsed.bootstrapMode
|
||||
: undefined,
|
||||
appManagedBootstrapCandidate: normalizeOpenCodeAppManagedBootstrapCandidate(
|
||||
parsed.appManagedBootstrapCandidate
|
||||
),
|
||||
livenessKind,
|
||||
pidSource: normalizePidSource(parsed.pidSource),
|
||||
runtimeDiagnostic: normalizeOptionalString(parsed.runtimeDiagnostic),
|
||||
|
|
|
|||
|
|
@ -402,6 +402,8 @@ import type {
|
|||
MemberSpawnStatus,
|
||||
MemberSpawnStatusEntry,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
OpenCodeAppManagedBootstrapCandidate,
|
||||
OpenCodeBootstrapEvidenceSource,
|
||||
OpenCodeRuntimeDeliveryStatus,
|
||||
PersistedTeamLaunchMemberState,
|
||||
PersistedTeamLaunchPhase,
|
||||
|
|
@ -2160,6 +2162,28 @@ function downgradeUncommittedOpenCodeBootstrapEvidence(
|
|||
};
|
||||
}
|
||||
|
||||
function promoteCommittedOpenCodeAppManagedBootstrapEvidence(
|
||||
evidence: TeamRuntimeMemberLaunchEvidence
|
||||
): TeamRuntimeMemberLaunchEvidence {
|
||||
return {
|
||||
...evidence,
|
||||
launchState: 'confirmed_alive',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
hardFailureReason: undefined,
|
||||
livenessKind: 'confirmed_bootstrap',
|
||||
runtimeDiagnostic:
|
||||
'OpenCode app-managed bootstrap evidence was committed and read back by the desktop app.',
|
||||
runtimeDiagnosticSeverity: 'info',
|
||||
diagnostics: appendDiagnosticOnce(
|
||||
evidence.diagnostics,
|
||||
'OpenCode app-managed bootstrap evidence committed and read back.'
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeRuntimeLaunchResultMembers(
|
||||
members: Record<string, TeamRuntimeMemberLaunchEvidence>
|
||||
): TeamLaunchAggregateState {
|
||||
|
|
@ -2493,6 +2517,7 @@ const OPEN_CODE_SECRET_FLAG_PATTERN =
|
|||
/(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
|
||||
const OPEN_CODE_BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Z0-9._~+/=-]+/gi;
|
||||
const OPEN_CODE_SECRET_KEY_PATTERN = /\bsk-[A-Za-z0-9_-]{16,}\b/g;
|
||||
const OPEN_CODE_APP_MANAGED_BRIEFING_MAX_CHARS = 12_000;
|
||||
|
||||
function normalizeOpenCodePersistedFailureReason(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.replace(/\s+/g, ' ').trim();
|
||||
|
|
@ -2505,6 +2530,21 @@ function normalizeOpenCodePersistedFailureReason(value: string | undefined): str
|
|||
.replace(OPEN_CODE_SECRET_KEY_PATTERN, '[redacted-api-key]');
|
||||
}
|
||||
|
||||
function redactOpenCodeAppManagedContextText(value: string): string {
|
||||
return value
|
||||
.replace(OPEN_CODE_SECRET_FLAG_PATTERN, '$1[redacted]')
|
||||
.replace(OPEN_CODE_BEARER_TOKEN_PATTERN, 'Bearer [redacted]')
|
||||
.replace(OPEN_CODE_SECRET_KEY_PATTERN, '[redacted-api-key]');
|
||||
}
|
||||
|
||||
function boundOpenCodeAppManagedBriefingText(value: string): string {
|
||||
const normalized = redactOpenCodeAppManagedContextText(value.replace(/\r\n/g, '\n')).trim();
|
||||
if (normalized.length <= OPEN_CODE_APP_MANAGED_BRIEFING_MAX_CHARS) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, OPEN_CODE_APP_MANAGED_BRIEFING_MAX_CHARS)}\n[truncated app-managed briefing]`;
|
||||
}
|
||||
|
||||
function isGenericOpenCodePersistedFailureReason(value: string | undefined): boolean {
|
||||
const normalized = normalizeOpenCodePersistedFailureReason(value);
|
||||
return (
|
||||
|
|
@ -2616,8 +2656,20 @@ function promoteOpenCodeSecondaryMemberFromCommittedBootstrapEvidence(input: {
|
|||
hardFailureReason: undefined,
|
||||
runtimeRunId: input.session.runId ?? input.current.runtimeRunId,
|
||||
runtimeSessionId: input.session.id,
|
||||
bootstrapEvidenceSource: input.session.source,
|
||||
bootstrapMode:
|
||||
input.session.source === 'app_managed_bootstrap'
|
||||
? 'app_managed_context'
|
||||
: 'model_tool_checkin',
|
||||
appManagedBootstrapCandidate:
|
||||
input.session.source === 'app_managed_bootstrap'
|
||||
? input.session.appManagedBootstrapCandidate
|
||||
: undefined,
|
||||
livenessKind,
|
||||
runtimeDiagnostic: 'OpenCode bootstrap evidence committed.',
|
||||
runtimeDiagnostic:
|
||||
input.session.source === 'app_managed_bootstrap'
|
||||
? 'OpenCode app-managed bootstrap evidence committed.'
|
||||
: 'OpenCode bootstrap evidence committed.',
|
||||
runtimeDiagnosticSeverity: 'info',
|
||||
firstSpawnAcceptedAt:
|
||||
input.current.firstSpawnAcceptedAt ?? input.previous?.firstSpawnAcceptedAt ?? observedAt,
|
||||
|
|
@ -6687,6 +6739,13 @@ export class TeamProvisioningService {
|
|||
};
|
||||
}
|
||||
|
||||
private isOpenCodePromptAcceptedByObservation(
|
||||
observation?: NonNullable<OpenCodeTeamRuntimeMessageResult['responseObservation']>
|
||||
): boolean {
|
||||
const deliveredUserMessageId = observation?.deliveredUserMessageId;
|
||||
return typeof deliveredUserMessageId === 'string' && deliveredUserMessageId.trim().length > 0;
|
||||
}
|
||||
|
||||
private isOpenCodeDeliveryRetryablePendingResponse(input: {
|
||||
ledgerRecord: OpenCodePromptDeliveryLedgerRecord;
|
||||
visibleReply?: OpenCodeVisibleReplyProof | null;
|
||||
|
|
@ -7115,6 +7174,42 @@ export class TeamProvisioningService {
|
|||
}
|
||||
}
|
||||
|
||||
private async isStaleOpenCodePromptDeliveryWatchdogError(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
messageId: string;
|
||||
error: unknown;
|
||||
}): Promise<boolean> {
|
||||
if (!getErrorMessage(input.error).startsWith('OpenCode prompt delivery record not found:')) {
|
||||
return false;
|
||||
}
|
||||
if (!this.canDeliverToOpenCodeRuntimeForTeam(input.teamName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const inboxMessages = await this.inboxReader
|
||||
.getMessagesFor(input.teamName, input.memberName)
|
||||
.catch(() => []);
|
||||
const targetMessage = inboxMessages.find((message) => message.messageId === input.messageId);
|
||||
if (!targetMessage || targetMessage.read) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const identity = await this.resolveOpenCodeMemberDeliveryIdentity(
|
||||
input.teamName,
|
||||
input.memberName
|
||||
).catch(() => null);
|
||||
if (!identity?.ok) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const laneActive = await this.isOpenCodeRuntimeLaneIndexActive(
|
||||
input.teamName,
|
||||
identity.laneId
|
||||
).catch(() => false);
|
||||
return !laneActive;
|
||||
}
|
||||
|
||||
private scheduleOpenCodePromptDeliveryWatchdog(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
|
|
@ -7141,10 +7236,30 @@ export class TeamProvisioningService {
|
|||
this.enqueueOpenCodePromptDeliveryWatchdogJob({
|
||||
teamName: input.teamName,
|
||||
run: async () => {
|
||||
await this.relayOpenCodeMemberInboxMessages(input.teamName, input.memberName, {
|
||||
onlyMessageId: messageId,
|
||||
source: 'watchdog',
|
||||
});
|
||||
if (!this.canDeliverToOpenCodeRuntimeForTeam(input.teamName)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.relayOpenCodeMemberInboxMessages(input.teamName, input.memberName, {
|
||||
onlyMessageId: messageId,
|
||||
source: 'watchdog',
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
await this.isStaleOpenCodePromptDeliveryWatchdogError({
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
messageId,
|
||||
error,
|
||||
})
|
||||
) {
|
||||
logger.debug(
|
||||
`[${input.teamName}] Ignoring stale OpenCode prompt delivery watchdog job for ${input.memberName}/${messageId}: ${getErrorMessage(error)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
}, delayMs);
|
||||
|
|
@ -8098,16 +8213,18 @@ export class TeamProvisioningService {
|
|||
const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation(
|
||||
result.responseObservation
|
||||
);
|
||||
const promptAccepted =
|
||||
result.ok || this.isOpenCodePromptAcceptedByObservation(responseObservation);
|
||||
if (ledgerRecord && ledger) {
|
||||
ledgerRecord = await ledger.applyDeliveryResult({
|
||||
id: ledgerRecord.id,
|
||||
accepted: result.ok,
|
||||
accepted: promptAccepted,
|
||||
attempted: true,
|
||||
responseObservation,
|
||||
sessionId: result.sessionId,
|
||||
prePromptCursor: result.prePromptCursor,
|
||||
diagnostics: result.diagnostics,
|
||||
reason: result.ok ? responseObservation?.reason : result.diagnostics[0],
|
||||
reason: promptAccepted ? responseObservation?.reason : result.diagnostics[0],
|
||||
now: nowIso(),
|
||||
});
|
||||
let proof = await this.applyOpenCodeVisibleDestinationProof({
|
||||
|
|
@ -8127,7 +8244,7 @@ export class TeamProvisioningService {
|
|||
});
|
||||
ledgerRecord = proof.ledgerRecord;
|
||||
this.logOpenCodePromptDeliveryEvent(
|
||||
result.ok
|
||||
promptAccepted
|
||||
? ledgerRecord.status === 'unanswered'
|
||||
? 'opencode_prompt_delivery_unanswered'
|
||||
: ledgerRecord.status === 'responded'
|
||||
|
|
@ -8135,7 +8252,10 @@ export class TeamProvisioningService {
|
|||
: 'opencode_prompt_delivery_prompt_accepted'
|
||||
: 'opencode_prompt_delivery_retry_scheduled',
|
||||
ledgerRecord,
|
||||
{ accepted: result.ok, reason: ledgerRecord.lastReason ?? result.diagnostics[0] ?? null }
|
||||
{
|
||||
accepted: promptAccepted,
|
||||
reason: ledgerRecord.lastReason ?? result.diagnostics[0] ?? null,
|
||||
}
|
||||
);
|
||||
}
|
||||
const responseState = ledgerRecord?.responseState ?? responseObservation?.state;
|
||||
|
|
@ -8160,7 +8280,7 @@ export class TeamProvisioningService {
|
|||
visibleReply,
|
||||
ledgerRecord,
|
||||
});
|
||||
if (ledgerRecord && result.ok && !readAllowed) {
|
||||
if (ledgerRecord && promptAccepted && !readAllowed) {
|
||||
const retry = this.isOpenCodeDeliveryRetryablePendingResponse({
|
||||
ledgerRecord,
|
||||
visibleReply,
|
||||
|
|
@ -8196,7 +8316,7 @@ export class TeamProvisioningService {
|
|||
};
|
||||
}
|
||||
}
|
||||
if (ledgerRecord && !result.ok) {
|
||||
if (ledgerRecord && !promptAccepted) {
|
||||
const reason = this.isOpenCodePromptAcceptanceUnknownFailure(result.diagnostics)
|
||||
? 'opencode_prompt_acceptance_unknown_after_bridge_timeout'
|
||||
: (result.diagnostics[0] ?? 'opencode_message_delivery_failed');
|
||||
|
|
@ -8239,9 +8359,9 @@ export class TeamProvisioningService {
|
|||
ledgerRecord?.visibleReplyCorrelation ??
|
||||
responseObservation?.visibleReplyCorrelation ??
|
||||
undefined;
|
||||
const acceptanceUnknown = Boolean(ledgerRecord?.acceptanceUnknown && !result.ok);
|
||||
const acceptanceUnknown = Boolean(ledgerRecord?.acceptanceUnknown && !promptAccepted);
|
||||
const responsePending =
|
||||
acceptanceUnknown || (result.ok && Boolean(ledgerRecord || responseObservation))
|
||||
acceptanceUnknown || (promptAccepted && Boolean(ledgerRecord || responseObservation))
|
||||
? !readAllowed
|
||||
: false;
|
||||
const pendingReason =
|
||||
|
|
@ -8255,8 +8375,8 @@ export class TeamProvisioningService {
|
|||
? ledgerRecord.diagnostics
|
||||
: result.diagnostics;
|
||||
return {
|
||||
delivered: result.ok || acceptanceUnknown,
|
||||
...(ledgerRecord || responseObservation ? { accepted: result.ok } : {}),
|
||||
delivered: promptAccepted || acceptanceUnknown,
|
||||
...(ledgerRecord || responseObservation ? { accepted: promptAccepted } : {}),
|
||||
...(ledgerRecord || responseObservation ? { responsePending } : {}),
|
||||
...(acceptanceUnknown ? { acceptanceUnknown: true } : {}),
|
||||
...(ledgerRecord
|
||||
|
|
@ -8279,7 +8399,7 @@ export class TeamProvisioningService {
|
|||
: {}),
|
||||
...(pendingReason
|
||||
? { reason: pendingReason }
|
||||
: result.ok
|
||||
: promptAccepted
|
||||
? {}
|
||||
: { reason: result.diagnostics[0] ?? 'opencode_message_delivery_failed' }),
|
||||
diagnostics,
|
||||
|
|
@ -9927,6 +10047,8 @@ export class TeamProvisioningService {
|
|||
memberName: string;
|
||||
runtimeSessionId: string;
|
||||
observedAt: string;
|
||||
source?: OpenCodeBootstrapEvidenceSource;
|
||||
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
|
||||
}): Promise<void> {
|
||||
const descriptor = OPENCODE_RUNTIME_STORE_DESCRIPTORS.find(
|
||||
(candidate) => candidate.schemaName === 'opencode.sessionStore'
|
||||
|
|
@ -9944,6 +10066,7 @@ export class TeamProvisioningService {
|
|||
await fs.promises.mkdir(runtimeDirectory, { recursive: true });
|
||||
const sessionStorePath = path.join(runtimeDirectory, descriptor.relativePath);
|
||||
const existingSessions = await this.readOpenCodeRuntimeSessionStore(sessionStorePath);
|
||||
const source = input.source ?? 'runtime_bootstrap_checkin';
|
||||
const session = {
|
||||
id: input.runtimeSessionId,
|
||||
teamName: input.teamName,
|
||||
|
|
@ -9952,7 +10075,10 @@ export class TeamProvisioningService {
|
|||
laneId: input.laneId,
|
||||
providerId: 'opencode',
|
||||
observedAt: input.observedAt,
|
||||
source: 'runtime_bootstrap_checkin',
|
||||
source,
|
||||
...(source === 'app_managed_bootstrap' && input.appManagedBootstrapCandidate
|
||||
? { appManagedBootstrapCandidate: input.appManagedBootstrapCandidate }
|
||||
: {}),
|
||||
};
|
||||
const sessions = this.mergeOpenCodeRuntimeSessionRecords(existingSessions, session);
|
||||
const manifestStore = createRuntimeStoreManifestStore({
|
||||
|
|
@ -9989,6 +10115,11 @@ export class TeamProvisioningService {
|
|||
}
|
||||
throw error;
|
||||
}
|
||||
if (!(await this.hasCommittedOpenCodeRuntimeBootstrapSessionEvidence(input))) {
|
||||
throw new Error(
|
||||
`OpenCode bootstrap session evidence write did not verify for ${input.memberName}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async hasCommittedOpenCodeRuntimeBootstrapSessionEvidence(input: {
|
||||
|
|
@ -9997,6 +10128,8 @@ export class TeamProvisioningService {
|
|||
laneId: string;
|
||||
memberName: string;
|
||||
runtimeSessionId: string;
|
||||
source?: OpenCodeBootstrapEvidenceSource;
|
||||
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
|
||||
}): Promise<boolean> {
|
||||
const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
|
|
@ -10009,12 +10142,28 @@ export class TeamProvisioningService {
|
|||
if (evidence.activeRunId && evidence.activeRunId.trim() !== input.runId) {
|
||||
return false;
|
||||
}
|
||||
return evidence.sessions.some(
|
||||
(session) =>
|
||||
session.id === input.runtimeSessionId &&
|
||||
session.runId === input.runId &&
|
||||
namesMatchCaseInsensitive(session.memberName, input.memberName)
|
||||
);
|
||||
return evidence.sessions.some((session) => {
|
||||
if (
|
||||
session.id !== input.runtimeSessionId ||
|
||||
session.runId !== input.runId ||
|
||||
!namesMatchCaseInsensitive(session.memberName, input.memberName)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (input.source && session.source !== input.source) {
|
||||
return false;
|
||||
}
|
||||
if (input.source === 'app_managed_bootstrap' && input.appManagedBootstrapCandidate) {
|
||||
const candidate = session.appManagedBootstrapCandidate;
|
||||
return (
|
||||
candidate?.runtimeSessionId === input.appManagedBootstrapCandidate.runtimeSessionId &&
|
||||
candidate.messageID === input.appManagedBootstrapCandidate.messageID &&
|
||||
candidate.contextHash === input.appManagedBootstrapCandidate.contextHash &&
|
||||
candidate.briefingHash === input.appManagedBootstrapCandidate.briefingHash
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private async hasDeliverableOpenCodeRuntimeBootstrapSessionEvidence(input: {
|
||||
|
|
@ -16656,7 +16805,7 @@ export class TeamProvisioningService {
|
|||
laneId: 'primary',
|
||||
runId,
|
||||
});
|
||||
const result = await adapter.launch(launchInput);
|
||||
const launchResult = await adapter.launch(launchInput);
|
||||
if (
|
||||
this.cancelledRuntimeAdapterRunIds.delete(runId) ||
|
||||
this.provisioningRunByTeam.get(input.request.teamName) !== runId
|
||||
|
|
@ -16664,7 +16813,10 @@ export class TeamProvisioningService {
|
|||
await this.clearOpenCodeRuntimeAdapterPrimaryLaneIfOwned(input.request.teamName, runId);
|
||||
return { runId };
|
||||
}
|
||||
await this.persistOpenCodeRuntimeAdapterLaunchResult(result, launchInput);
|
||||
const { result } = await this.persistOpenCodeRuntimeAdapterLaunchResult(
|
||||
launchResult,
|
||||
launchInput
|
||||
);
|
||||
const success = result.teamLaunchState === 'clean_success';
|
||||
const pending = result.teamLaunchState === 'partial_pending';
|
||||
const failed = result.teamLaunchState === 'partial_failure';
|
||||
|
|
@ -16794,42 +16946,72 @@ export class TeamProvisioningService {
|
|||
private async persistOpenCodeRuntimeAdapterLaunchResult(
|
||||
result: TeamRuntimeLaunchResult,
|
||||
input: TeamRuntimeLaunchInput
|
||||
): Promise<PersistedTeamLaunchSnapshot> {
|
||||
await this.commitOpenCodeRuntimeAdapterLaunchSessionEvidence({
|
||||
): Promise<{
|
||||
snapshot: PersistedTeamLaunchSnapshot;
|
||||
result: TeamRuntimeLaunchResult;
|
||||
}> {
|
||||
const committedResult = await this.commitOpenCodeRuntimeAdapterLaunchSessionEvidence({
|
||||
teamName: input.teamName,
|
||||
laneId: input.laneId?.trim() || 'primary',
|
||||
result,
|
||||
});
|
||||
const members: Record<string, PersistedTeamLaunchMemberState> = {};
|
||||
for (const member of input.expectedMembers) {
|
||||
const evidence = result.members[member.name];
|
||||
members[member.name] = this.toOpenCodePersistedLaunchMember(member, evidence);
|
||||
const evidence = committedResult.members[member.name];
|
||||
members[member.name] = this.toOpenCodePersistedLaunchMember(
|
||||
member,
|
||||
evidence,
|
||||
committedResult.runId
|
||||
);
|
||||
}
|
||||
const snapshot = createPersistedLaunchSnapshot({
|
||||
teamName: input.teamName,
|
||||
expectedMembers: input.expectedMembers.map((member) => member.name),
|
||||
bootstrapExpectedMembers: input.expectedMembers.map((member) => member.name),
|
||||
leadSessionId: result.leadSessionId,
|
||||
launchPhase: result.launchPhase,
|
||||
launchPhase: committedResult.launchPhase,
|
||||
members,
|
||||
});
|
||||
return this.writeLaunchStateSnapshot(input.teamName, snapshot);
|
||||
return {
|
||||
snapshot: await this.writeLaunchStateSnapshot(input.teamName, snapshot),
|
||||
result: committedResult,
|
||||
};
|
||||
}
|
||||
|
||||
private async commitOpenCodeRuntimeAdapterLaunchSessionEvidence(params: {
|
||||
teamName: string;
|
||||
laneId: string;
|
||||
result: TeamRuntimeLaunchResult;
|
||||
}): Promise<void> {
|
||||
}): Promise<TeamRuntimeLaunchResult> {
|
||||
let changed = false;
|
||||
const members: Record<string, TeamRuntimeMemberLaunchEvidence> = { ...params.result.members };
|
||||
for (const [memberName, evidence] of Object.entries(params.result.members)) {
|
||||
const runtimeSessionId = evidence.sessionId?.trim();
|
||||
const confirmed =
|
||||
evidence.launchState === 'confirmed_alive' ||
|
||||
evidence.bootstrapConfirmed === true ||
|
||||
evidence.livenessKind === 'confirmed_bootstrap';
|
||||
if (!confirmed || !runtimeSessionId) {
|
||||
const appManagedCandidate =
|
||||
evidence.bootstrapEvidenceSource === 'app_managed_bootstrap' &&
|
||||
evidence.bootstrapMode === 'app_managed_context'
|
||||
? evidence.appManagedBootstrapCandidate
|
||||
: undefined;
|
||||
const appManagedCandidateMatches =
|
||||
appManagedCandidate?.source === 'app_managed_bootstrap' &&
|
||||
appManagedCandidate.teamName === params.teamName &&
|
||||
appManagedCandidate.memberName === memberName &&
|
||||
appManagedCandidate.runId === params.result.runId &&
|
||||
appManagedCandidate.laneId === params.laneId &&
|
||||
appManagedCandidate.runtimeSessionId === runtimeSessionId;
|
||||
if ((!confirmed && !appManagedCandidateMatches) || !runtimeSessionId) {
|
||||
continue;
|
||||
}
|
||||
// For app-managed bootstrap, promotion is intentionally two-phase:
|
||||
// write the candidate as runtime evidence, then verify it using the same
|
||||
// reader path used by later reconciliation/restart flows.
|
||||
const source: OpenCodeBootstrapEvidenceSource = appManagedCandidateMatches
|
||||
? 'app_managed_bootstrap'
|
||||
: (evidence.bootstrapEvidenceSource ?? 'runtime_bootstrap_checkin');
|
||||
await this.commitOpenCodeRuntimeBootstrapSessionEvidence({
|
||||
teamName: params.teamName,
|
||||
runId: params.result.runId,
|
||||
|
|
@ -16837,13 +17019,47 @@ export class TeamProvisioningService {
|
|||
memberName,
|
||||
runtimeSessionId,
|
||||
observedAt: nowIso(),
|
||||
source,
|
||||
appManagedBootstrapCandidate: appManagedCandidateMatches
|
||||
? appManagedCandidate
|
||||
: evidence.appManagedBootstrapCandidate,
|
||||
});
|
||||
const verified = await this.hasCommittedOpenCodeRuntimeBootstrapSessionEvidence({
|
||||
teamName: params.teamName,
|
||||
runId: params.result.runId,
|
||||
laneId: params.laneId,
|
||||
memberName,
|
||||
runtimeSessionId,
|
||||
source,
|
||||
appManagedBootstrapCandidate: appManagedCandidateMatches
|
||||
? appManagedCandidate
|
||||
: evidence.appManagedBootstrapCandidate,
|
||||
});
|
||||
if (appManagedCandidateMatches && verified && !confirmed) {
|
||||
members[memberName] = promoteCommittedOpenCodeAppManagedBootstrapEvidence(evidence);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) {
|
||||
return params.result;
|
||||
}
|
||||
const teamLaunchState = summarizeRuntimeLaunchResultMembers(members);
|
||||
return {
|
||||
...params.result,
|
||||
launchPhase: teamLaunchState === 'clean_success' ? 'finished' : params.result.launchPhase,
|
||||
teamLaunchState,
|
||||
members,
|
||||
diagnostics: appendDiagnosticOnce(
|
||||
params.result.diagnostics,
|
||||
'OpenCode app-managed bootstrap evidence was committed and read back before readiness promotion.'
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private toOpenCodePersistedLaunchMember(
|
||||
member: TeamRuntimeLaunchInput['expectedMembers'][number],
|
||||
evidence: TeamRuntimeMemberLaunchEvidence | undefined
|
||||
evidence: TeamRuntimeMemberLaunchEvidence | undefined,
|
||||
runId?: string
|
||||
): PersistedTeamLaunchMemberState {
|
||||
const now = nowIso();
|
||||
const launchState = evidence?.launchState ?? 'failed_to_start';
|
||||
|
|
@ -16869,10 +17085,24 @@ export class TeamProvisioningService {
|
|||
: undefined,
|
||||
...(evidence?.runtimePid ? { runtimePid: evidence.runtimePid } : {}),
|
||||
...(evidence?.sessionId ? { runtimeSessionId: evidence.sessionId } : {}),
|
||||
...(evidence?.sessionId
|
||||
? { runtimeRunId: evidence.appManagedBootstrapCandidate?.runId ?? runId }
|
||||
: {}),
|
||||
...(evidence?.bootstrapEvidenceSource
|
||||
? { bootstrapEvidenceSource: evidence.bootstrapEvidenceSource }
|
||||
: {}),
|
||||
...(evidence?.bootstrapMode ? { bootstrapMode: evidence.bootstrapMode } : {}),
|
||||
...(evidence?.appManagedBootstrapCandidate
|
||||
? { appManagedBootstrapCandidate: evidence.appManagedBootstrapCandidate }
|
||||
: {}),
|
||||
...(evidence?.livenessKind ? { livenessKind: evidence.livenessKind } : {}),
|
||||
...(evidence?.pidSource ? { pidSource: evidence.pidSource } : {}),
|
||||
...(evidence?.runtimeDiagnostic ? { runtimeDiagnostic: evidence.runtimeDiagnostic } : {}),
|
||||
...(evidence?.runtimeDiagnostic ? { runtimeDiagnosticSeverity: 'info' as const } : {}),
|
||||
...(evidence?.runtimeDiagnosticSeverity
|
||||
? { runtimeDiagnosticSeverity: evidence.runtimeDiagnosticSeverity }
|
||||
: evidence?.runtimeDiagnostic
|
||||
? { runtimeDiagnosticSeverity: 'info' as const }
|
||||
: {}),
|
||||
...(evidence?.runtimeAlive ? { runtimeLastSeenAt: now } : {}),
|
||||
firstSpawnAcceptedAt: evidence?.agentToolAccepted ? now : undefined,
|
||||
lastHeartbeatAt: evidence?.bootstrapConfirmed ? now : undefined,
|
||||
|
|
@ -18545,6 +18775,17 @@ export class TeamProvisioningService {
|
|||
});
|
||||
result.lastDelivery = delivery;
|
||||
if (!delivery.delivered) {
|
||||
if (delivery.accepted === true) {
|
||||
const diagnostics = delivery.diagnostics ?? [
|
||||
delivery.reason ?? 'opencode_delivery_response_pending',
|
||||
];
|
||||
result.diagnostics = [...(result.diagnostics ?? []), ...diagnostics];
|
||||
result.lastDelivery = {
|
||||
...delivery,
|
||||
diagnostics,
|
||||
};
|
||||
break;
|
||||
}
|
||||
result.failed += 1;
|
||||
result.diagnostics = [
|
||||
...(result.diagnostics ?? []),
|
||||
|
|
@ -21577,6 +21818,10 @@ export class TeamProvisioningService {
|
|||
result: TeamRuntimeLaunchResult;
|
||||
memberName: string;
|
||||
}): Promise<TeamRuntimeLaunchResult> {
|
||||
// OpenCode launch can now return an app-managed bootstrap candidate without
|
||||
// a model tool call. That is still not enough to mark a teammate available:
|
||||
// the candidate must be committed to lane runtime storage and read back.
|
||||
// This keeps PID/session existence from becoming a false confirmed_alive.
|
||||
const memberEvidence = params.result.members[params.memberName];
|
||||
if (!memberEvidence) {
|
||||
return params.result;
|
||||
|
|
@ -21586,14 +21831,28 @@ export class TeamProvisioningService {
|
|||
memberEvidence.launchState === 'confirmed_alive' ||
|
||||
memberEvidence.bootstrapConfirmed === true ||
|
||||
memberEvidence.livenessKind === 'confirmed_bootstrap';
|
||||
if (!claimsBootstrapConfirmed) {
|
||||
const runtimeSessionId = memberEvidence.sessionId?.trim();
|
||||
const appManagedCandidate =
|
||||
memberEvidence.bootstrapEvidenceSource === 'app_managed_bootstrap' &&
|
||||
memberEvidence.bootstrapMode === 'app_managed_context'
|
||||
? memberEvidence.appManagedBootstrapCandidate
|
||||
: undefined;
|
||||
const appManagedCandidateMatches =
|
||||
appManagedCandidate?.source === 'app_managed_bootstrap' &&
|
||||
appManagedCandidate.teamName === params.teamName &&
|
||||
appManagedCandidate.memberName === params.memberName &&
|
||||
appManagedCandidate.runId === params.result.runId &&
|
||||
appManagedCandidate.laneId === params.laneId &&
|
||||
appManagedCandidate.runtimeSessionId === runtimeSessionId;
|
||||
if (!claimsBootstrapConfirmed && !appManagedCandidateMatches) {
|
||||
return params.result;
|
||||
}
|
||||
await this.commitOpenCodeRuntimeAdapterLaunchSessionEvidence({
|
||||
const committedResult = await this.commitOpenCodeRuntimeAdapterLaunchSessionEvidence({
|
||||
teamName: params.teamName,
|
||||
laneId: params.laneId,
|
||||
result: params.result,
|
||||
});
|
||||
const committedMemberEvidence = committedResult.members[params.memberName] ?? memberEvidence;
|
||||
|
||||
const storage = await inspectOpenCodeRuntimeLaneStorage({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
|
|
@ -21601,14 +21860,17 @@ export class TeamProvisioningService {
|
|||
laneId: params.laneId,
|
||||
});
|
||||
if (storage.hasRuntimeEvidenceOnDisk) {
|
||||
return params.result;
|
||||
return committedResult;
|
||||
}
|
||||
if (!claimsBootstrapConfirmed) {
|
||||
return committedResult;
|
||||
}
|
||||
|
||||
const diagnostics = buildOpenCodeUncommittedBootstrapDiagnostic(storage);
|
||||
const members = {
|
||||
...params.result.members,
|
||||
...committedResult.members,
|
||||
[params.memberName]: downgradeUncommittedOpenCodeBootstrapEvidence(
|
||||
memberEvidence,
|
||||
committedMemberEvidence,
|
||||
diagnostics
|
||||
),
|
||||
};
|
||||
|
|
@ -21630,10 +21892,36 @@ export class TeamProvisioningService {
|
|||
launchPhase: teamLaunchState === 'clean_success' ? params.result.launchPhase : 'active',
|
||||
teamLaunchState,
|
||||
members,
|
||||
diagnostics: Array.from(new Set([...params.result.diagnostics, ...diagnostics])),
|
||||
diagnostics: Array.from(new Set([...committedResult.diagnostics, ...diagnostics])),
|
||||
};
|
||||
}
|
||||
|
||||
private async buildOpenCodeSecondaryAppManagedLaunchPrompt(
|
||||
run: ProvisioningRun,
|
||||
lane: MixedSecondaryRuntimeLaneState
|
||||
): Promise<string> {
|
||||
const controller = createController({
|
||||
teamName: run.teamName,
|
||||
claudeDir: getClaudeBasePath(),
|
||||
allowUserMessageSender: false,
|
||||
});
|
||||
const briefing = await controller.tasks.memberBriefing(lane.member.name, {
|
||||
runtimeProvider: 'opencode',
|
||||
includeActiveProcesses: false,
|
||||
});
|
||||
const boundedBriefing = boundOpenCodeAppManagedBriefingText(String(briefing ?? ''));
|
||||
if (!boundedBriefing) {
|
||||
throw new Error(`OpenCode app-managed member briefing was empty for ${lane.member.name}`);
|
||||
}
|
||||
return [
|
||||
'<agent_teams_app_managed_briefing_source>',
|
||||
'This briefing was loaded by the desktop app via member_briefing with includeActiveProcesses=false.',
|
||||
'Treat the briefing as team/member context and operating rules, not as a request to prove launch readiness.',
|
||||
boundedBriefing,
|
||||
'</agent_teams_app_managed_briefing_source>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private buildMixedPersistedLaunchSnapshotForRun(
|
||||
run: ProvisioningRun,
|
||||
launchPhase: PersistedTeamLaunchPhase
|
||||
|
|
@ -22029,17 +22317,24 @@ export class TeamProvisioningService {
|
|||
await finishCancelledLane();
|
||||
return;
|
||||
}
|
||||
const appManagedLaunchPrompt = await this.buildOpenCodeSecondaryAppManagedLaunchPrompt(
|
||||
run,
|
||||
lane
|
||||
);
|
||||
if (shouldAbortLaunch()) {
|
||||
await finishCancelledLane();
|
||||
return;
|
||||
}
|
||||
const rawResult = await adapter.launch({
|
||||
runId: lane.runId,
|
||||
laneId: lane.laneId,
|
||||
teamName: run.teamName,
|
||||
cwd: laneCwd,
|
||||
prompt: run.request.prompt?.trim() ?? undefined,
|
||||
prompt: appManagedLaunchPrompt,
|
||||
providerId: 'opencode',
|
||||
model: lane.member.model,
|
||||
effort: lane.member.effort,
|
||||
runtimeOnly: true,
|
||||
skipReadinessPreflight: true,
|
||||
skipPermissions: run.request.skipPermissions !== false,
|
||||
expectedMembers: [
|
||||
{
|
||||
|
|
@ -22059,6 +22354,9 @@ export class TeamProvisioningService {
|
|||
await finishCancelledLane();
|
||||
return;
|
||||
}
|
||||
// Treat the bridge result as provisional. The guard below is the single
|
||||
// promotion gate that turns app-managed OpenCode bootstrap into
|
||||
// confirmed_alive only after durable lane evidence exists on disk.
|
||||
const result = await this.guardCommittedOpenCodeSecondaryLaneEvidence({
|
||||
teamName: run.teamName,
|
||||
laneId: lane.laneId,
|
||||
|
|
@ -22439,6 +22737,9 @@ export class TeamProvisioningService {
|
|||
runtimePid?: number;
|
||||
sessionId?: string;
|
||||
runtimeSessionId?: string;
|
||||
bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource;
|
||||
bootstrapMode?: 'model_tool_checkin' | 'app_managed_context';
|
||||
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
|
||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||
pidSource?: TeamAgentRuntimePidSource;
|
||||
runtimeDiagnostic?: string;
|
||||
|
|
@ -22501,6 +22802,9 @@ export class TeamProvisioningService {
|
|||
runtimePid: runtimeEvidence.runtimePid,
|
||||
sessionId: runtimeEvidence.sessionId,
|
||||
runtimeSessionId: runtimeEvidence.sessionId,
|
||||
bootstrapEvidenceSource: runtimeEvidence.bootstrapEvidenceSource,
|
||||
bootstrapMode: runtimeEvidence.bootstrapMode,
|
||||
appManagedBootstrapCandidate: runtimeEvidence.appManagedBootstrapCandidate,
|
||||
livenessKind: runtimeEvidence.livenessKind,
|
||||
pidSource: runtimeEvidence.pidSource,
|
||||
runtimeDiagnostic: runtimeEvidence.runtimeDiagnostic,
|
||||
|
|
@ -22536,6 +22840,9 @@ export class TeamProvisioningService {
|
|||
pendingPermissionRequestIds: runtimeEvidence.pendingPermissionRequestIds,
|
||||
runtimePid: runtimeEvidence.runtimePid,
|
||||
sessionId: runtimeEvidence.sessionId,
|
||||
bootstrapEvidenceSource: runtimeEvidence.bootstrapEvidenceSource,
|
||||
bootstrapMode: runtimeEvidence.bootstrapMode,
|
||||
appManagedBootstrapCandidate: runtimeEvidence.appManagedBootstrapCandidate,
|
||||
livenessKind: runtimeEvidence.livenessKind,
|
||||
pidSource: runtimeEvidence.pidSource,
|
||||
runtimeDiagnostic: runtimeEvidence.runtimeDiagnostic,
|
||||
|
|
@ -30055,6 +30362,7 @@ export class TeamProvisioningService {
|
|||
teamName: fixture.teamName,
|
||||
memberName: fixture.memberName,
|
||||
runtimeProvider: 'opencode',
|
||||
includeActiveProcesses: false,
|
||||
},
|
||||
});
|
||||
throwIfCancelled();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
import { createHash } from 'crypto';
|
||||
|
||||
import type {
|
||||
OpenCodeAppManagedBootstrapCandidate,
|
||||
OpenCodeBootstrapEvidenceSource,
|
||||
OpenCodeBootstrapMode,
|
||||
} from '@shared/types/team';
|
||||
|
||||
export const OPEN_CODE_BRIDGE_SCHEMA_VERSION = 1 as const;
|
||||
export const OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION = 1 as const;
|
||||
export const OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION = 1 as const;
|
||||
|
||||
export type OpenCodeBridgeCommandName =
|
||||
| 'opencode.handshake'
|
||||
|
|
@ -65,6 +72,9 @@ export interface OpenCodeLaunchTeamCommandBody {
|
|||
export interface OpenCodeTeamMemberLaunchCommandData {
|
||||
sessionId: string;
|
||||
launchState: OpenCodeTeamMemberLaunchBridgeState;
|
||||
bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource;
|
||||
bootstrapMode?: OpenCodeBootstrapMode;
|
||||
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
|
||||
pendingPermissionRequestIds?: string[];
|
||||
diagnostics?: string[];
|
||||
model: string;
|
||||
|
|
@ -373,6 +383,7 @@ export interface OpenCodeBridgePeerIdentity {
|
|||
currentVersion: number;
|
||||
supportedCommands: OpenCodeBridgeCommandName[];
|
||||
opencodeTaskLedgerEvidenceContractVersion?: number;
|
||||
opencodeAppManagedBootstrapContractVersion?: number;
|
||||
};
|
||||
runtime: {
|
||||
providerId: 'opencode';
|
||||
|
|
@ -591,6 +602,26 @@ export function validateOpenCodeBridgeHandshake(input: {
|
|||
return { ok: false, reason: `Bridge server does not support command ${input.requiredCommand}` };
|
||||
}
|
||||
|
||||
if (input.requiredCommand === 'opencode.launchTeam') {
|
||||
if (!input.expectedCapabilitySnapshotId) {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
'OpenCode app-managed bootstrap launch requires a fresh capability snapshot before state-changing launch',
|
||||
};
|
||||
}
|
||||
if (
|
||||
input.handshake.server.bridgeProtocol.opencodeAppManagedBootstrapContractVersion !==
|
||||
OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
'OpenCode app-managed bootstrap is required, but the orchestrator does not advertise contract version 1. Update agent_teams_orchestrator and restart the app.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
input.expectedCapabilitySnapshotId &&
|
||||
input.handshake.server.runtime.capabilitySnapshotId !== input.expectedCapabilitySnapshotId
|
||||
|
|
@ -860,7 +891,10 @@ function isPeerIdentity(value: unknown): value is OpenCodeBridgePeerIdentity {
|
|||
!bridgeProtocol.supportedCommands.every(isOpenCodeBridgeCommandName) ||
|
||||
(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion !== undefined &&
|
||||
(!Number.isInteger(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion) ||
|
||||
(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion as number) < 1))
|
||||
(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion as number) < 1)) ||
|
||||
(bridgeProtocol.opencodeAppManagedBootstrapContractVersion !== undefined &&
|
||||
(!Number.isInteger(bridgeProtocol.opencodeAppManagedBootstrapContractVersion) ||
|
||||
(bridgeProtocol.opencodeAppManagedBootstrapContractVersion as number) < 1))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@ import type {
|
|||
OpenCodeBridgeHandshake,
|
||||
OpenCodeBridgePeerIdentity,
|
||||
} from './OpenCodeBridgeCommandContract';
|
||||
import {
|
||||
OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
|
||||
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
||||
} from './OpenCodeBridgeCommandContract';
|
||||
import type {
|
||||
OpenCodeBridgeCommandExecutor,
|
||||
OpenCodeBridgeHandshakePort,
|
||||
|
|
@ -96,6 +100,8 @@ export function createOpenCodeBridgeClientIdentity(input: {
|
|||
'opencode.recoverDeliveryJournal',
|
||||
'opencode.backfillTaskLedger',
|
||||
],
|
||||
opencodeTaskLedgerEvidenceContractVersion: OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
||||
opencodeAppManagedBootstrapContractVersion: OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
|
||||
},
|
||||
runtime: {
|
||||
providerId: 'opencode',
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ import {
|
|||
validateRuntimeStoreManifest,
|
||||
} from './RuntimeStoreManifest';
|
||||
|
||||
import type {
|
||||
OpenCodeAppManagedBootstrapCandidate,
|
||||
OpenCodeBootstrapEvidenceSource,
|
||||
} from '@shared/types/team';
|
||||
import type { RuntimeStoreManifestEvidence } from '../bridge/OpenCodeBridgeCommandContract';
|
||||
import type { RuntimeStoreManifestReader } from '../bridge/OpenCodeStateChangingBridgeCommandService';
|
||||
import type { RuntimeStoreManifestEntryState } from './RuntimeStoreManifest';
|
||||
|
|
@ -65,7 +69,8 @@ export interface OpenCodeCommittedBootstrapSessionRecord {
|
|||
laneId: string;
|
||||
runId: string | null;
|
||||
observedAt: string | null;
|
||||
source: 'runtime_bootstrap_checkin';
|
||||
source: OpenCodeBootstrapEvidenceSource;
|
||||
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
|
||||
}
|
||||
|
||||
export interface OpenCodeCommittedBootstrapSessionEvidence {
|
||||
|
|
@ -301,10 +306,20 @@ function normalizeOpenCodeBootstrapSessionRecord(
|
|||
const memberName = normalizeNonEmptyStoreString(record.memberName);
|
||||
const laneId = normalizeNonEmptyStoreString(record.laneId);
|
||||
const source = normalizeNonEmptyStoreString(record.source);
|
||||
if (!id || !teamName || !memberName || !laneId || source !== 'runtime_bootstrap_checkin') {
|
||||
if (
|
||||
!id ||
|
||||
!teamName ||
|
||||
!memberName ||
|
||||
!laneId ||
|
||||
(source !== 'runtime_bootstrap_checkin' && source !== 'app_managed_bootstrap')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const observedAt = normalizeOptionalStoreIso(record.observedAt);
|
||||
const appManagedBootstrapCandidate =
|
||||
source === 'app_managed_bootstrap'
|
||||
? normalizeAppManagedBootstrapCandidate(record.appManagedBootstrapCandidate)
|
||||
: undefined;
|
||||
return {
|
||||
id,
|
||||
teamName,
|
||||
|
|
@ -312,7 +327,62 @@ function normalizeOpenCodeBootstrapSessionRecord(
|
|||
laneId,
|
||||
runId: normalizeNonEmptyStoreString(record.runId),
|
||||
observedAt,
|
||||
source: 'runtime_bootstrap_checkin',
|
||||
source,
|
||||
...(appManagedBootstrapCandidate ? { appManagedBootstrapCandidate } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAppManagedBootstrapCandidate(
|
||||
value: unknown
|
||||
): OpenCodeAppManagedBootstrapCandidate | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.schemaVersion !== 1 || record.source !== 'app_managed_bootstrap') {
|
||||
return undefined;
|
||||
}
|
||||
const teamName = normalizeNonEmptyStoreString(record.teamName);
|
||||
const memberName = normalizeNonEmptyStoreString(record.memberName);
|
||||
const runId = normalizeNonEmptyStoreString(record.runId);
|
||||
const laneId = normalizeNonEmptyStoreString(record.laneId);
|
||||
const runtimeSessionId = normalizeNonEmptyStoreString(record.runtimeSessionId);
|
||||
const messageID = normalizeNonEmptyStoreString(record.messageID);
|
||||
const contextHash = normalizeNonEmptyStoreString(record.contextHash);
|
||||
const briefingHash = normalizeNonEmptyStoreString(record.briefingHash);
|
||||
const injectionVerifiedAt = normalizeNonEmptyStoreString(record.injectionVerifiedAt);
|
||||
const candidateAt = normalizeNonEmptyStoreString(record.candidateAt);
|
||||
if (
|
||||
!teamName ||
|
||||
!memberName ||
|
||||
!runId ||
|
||||
!laneId ||
|
||||
!runtimeSessionId ||
|
||||
!messageID ||
|
||||
!contextHash ||
|
||||
!briefingHash ||
|
||||
!injectionVerifiedAt ||
|
||||
!candidateAt
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const model = normalizeNonEmptyStoreString(record.model);
|
||||
const agent = normalizeNonEmptyStoreString(record.agent);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
source: 'app_managed_bootstrap',
|
||||
teamName,
|
||||
memberName,
|
||||
runId,
|
||||
laneId,
|
||||
runtimeSessionId,
|
||||
messageID,
|
||||
contextHash,
|
||||
briefingHash,
|
||||
injectionVerifiedAt,
|
||||
candidateAt,
|
||||
...(model ? { model } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -560,7 +630,7 @@ export async function readCommittedOpenCodeBootstrapSessionEvidence(params: {
|
|||
}
|
||||
);
|
||||
if (sessions.length === 0) {
|
||||
diagnostics.push('OpenCode session store has no committed bootstrap check-in sessions.');
|
||||
diagnostics.push('OpenCode session store has no committed bootstrap sessions.');
|
||||
}
|
||||
return {
|
||||
state: 'healthy',
|
||||
|
|
|
|||
|
|
@ -26,7 +26,12 @@ import type {
|
|||
TeamRuntimeStopInput,
|
||||
TeamRuntimeStopResult,
|
||||
} from './TeamRuntimeAdapter';
|
||||
import type { AgentActionMode, InboxMessageKind, TaskRef } from '@shared/types/team';
|
||||
import type {
|
||||
AgentActionMode,
|
||||
InboxMessageKind,
|
||||
OpenCodeAppManagedBootstrapCandidate,
|
||||
TaskRef,
|
||||
} from '@shared/types/team';
|
||||
|
||||
export interface OpenCodeTeamRuntimeBridgePort {
|
||||
checkOpenCodeTeamLaunchReadiness(input: {
|
||||
|
|
@ -169,6 +174,15 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
const runtimeSnapshot = skipReadinessPreflight
|
||||
? null
|
||||
: (this.bridge.getLastOpenCodeRuntimeSnapshot?.(input.cwd) ?? null);
|
||||
if (
|
||||
!skipReadinessPreflight &&
|
||||
this.bridge.getLastOpenCodeRuntimeSnapshot &&
|
||||
!runtimeSnapshot?.capabilitySnapshotId
|
||||
) {
|
||||
return blockedLaunchResult(input, 'opencode_capability_snapshot_missing', [
|
||||
'OpenCode app-managed launch requires a fresh capability snapshot before state-changing launch.',
|
||||
]);
|
||||
}
|
||||
this.lastProjectPathByTeamName.set(input.teamName, input.cwd);
|
||||
const data = await this.bridge.launchOpenCodeTeam({
|
||||
runId: input.runId,
|
||||
|
|
@ -457,18 +471,24 @@ function mapOpenCodeLaunchDataToRuntimeResult(
|
|||
checkpointNames.has(name)
|
||||
);
|
||||
const bridgeReady = data.teamLaunchState === 'ready';
|
||||
const isExpectedMemberConfirmed = (memberName: string): boolean => {
|
||||
const bridgeMember = data.members[memberName];
|
||||
return bridgeMember?.launchState === 'confirmed_alive';
|
||||
};
|
||||
const missingExpectedMembers = input.expectedMembers
|
||||
.map((member) => member.name)
|
||||
.filter((memberName) => data.members[memberName] == null);
|
||||
const unconfirmedExpectedMembers = input.expectedMembers
|
||||
.map((member) => member.name)
|
||||
.filter((memberName) => data.members[memberName]?.launchState !== 'confirmed_alive');
|
||||
.filter((memberName) => !isExpectedMemberConfirmed(memberName));
|
||||
const anyExpectedMemberFailed = input.expectedMembers.some(
|
||||
(member) => data.members[member.name]?.launchState === 'failed'
|
||||
);
|
||||
const allExpectedMembersConfirmed =
|
||||
input.expectedMembers.length > 0 && unconfirmedExpectedMembers.length === 0;
|
||||
const success = bridgeReady && readyCheckpointsPresent && allExpectedMembersConfirmed;
|
||||
const success =
|
||||
(bridgeReady && readyCheckpointsPresent && allExpectedMembersConfirmed) ||
|
||||
(data.teamLaunchState === 'launching' && allExpectedMembersConfirmed);
|
||||
const checkpointDiagnostic = success
|
||||
? []
|
||||
: bridgeReady && !readyCheckpointsPresent
|
||||
|
|
@ -522,6 +542,12 @@ function mapOpenCodeLaunchDataToRuntimeResult(
|
|||
bridgeMember?.pendingPermissionRequestIds,
|
||||
bridgeMember != null,
|
||||
memberDiagnostics,
|
||||
input.runId,
|
||||
input.laneId?.trim() || 'primary',
|
||||
input.teamName,
|
||||
bridgeMember?.bootstrapEvidenceSource,
|
||||
bridgeMember?.bootstrapMode,
|
||||
bridgeMember?.appManagedBootstrapCandidate,
|
||||
selectOpenCodeMemberFailureReason({
|
||||
memberDiagnostics: bridgeMember?.diagnostics ?? [],
|
||||
bridgeDiagnostics: data.diagnostics,
|
||||
|
|
@ -556,6 +582,61 @@ function mapOpenCodeLaunchDataToRuntimeResult(
|
|||
};
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function normalizeAppManagedBootstrapCandidate(
|
||||
value: OpenCodeAppManagedBootstrapCandidate | undefined,
|
||||
expected: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
runId: string;
|
||||
laneId: string;
|
||||
runtimeSessionId?: string;
|
||||
}
|
||||
): OpenCodeAppManagedBootstrapCandidate | undefined {
|
||||
if (!value || value.schemaVersion !== 1 || value.source !== 'app_managed_bootstrap') {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
value.teamName !== expected.teamName ||
|
||||
value.memberName !== expected.memberName ||
|
||||
value.runId !== expected.runId ||
|
||||
value.laneId !== expected.laneId ||
|
||||
(expected.runtimeSessionId && value.runtimeSessionId !== expected.runtimeSessionId)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
!isNonEmptyString(value.runtimeSessionId) ||
|
||||
!isNonEmptyString(value.messageID) ||
|
||||
!value.messageID.startsWith('msg') ||
|
||||
!isNonEmptyString(value.contextHash) ||
|
||||
!isNonEmptyString(value.briefingHash) ||
|
||||
!isNonEmptyString(value.injectionVerifiedAt) ||
|
||||
!isNonEmptyString(value.candidateAt)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
source: 'app_managed_bootstrap',
|
||||
teamName: value.teamName,
|
||||
memberName: value.memberName,
|
||||
runId: value.runId,
|
||||
laneId: value.laneId,
|
||||
runtimeSessionId: value.runtimeSessionId,
|
||||
messageID: value.messageID,
|
||||
contextHash: value.contextHash,
|
||||
briefingHash: value.briefingHash,
|
||||
injectionVerifiedAt: value.injectionVerifiedAt,
|
||||
candidateAt: value.candidateAt,
|
||||
...(isNonEmptyString(value.model) ? { model: value.model } : {}),
|
||||
...(isNonEmptyString(value.agent) ? { agent: value.agent } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mapBridgeMemberToRuntimeEvidence(
|
||||
memberName: string,
|
||||
launchState: OpenCodeTeamMemberLaunchBridgeState,
|
||||
|
|
@ -564,8 +645,30 @@ function mapBridgeMemberToRuntimeEvidence(
|
|||
pendingPermissionRequestIds: string[] | undefined,
|
||||
runtimeMaterialized: boolean,
|
||||
diagnostics: string[],
|
||||
runId: string,
|
||||
laneId: string,
|
||||
teamName: string,
|
||||
bootstrapEvidenceSource: TeamRuntimeMemberLaunchEvidence['bootstrapEvidenceSource'] | undefined,
|
||||
bootstrapMode: TeamRuntimeMemberLaunchEvidence['bootstrapMode'] | undefined,
|
||||
appManagedBootstrapCandidate: OpenCodeAppManagedBootstrapCandidate | undefined,
|
||||
selectedHardFailureReason: string
|
||||
): TeamRuntimeMemberLaunchEvidence {
|
||||
const normalizedAppManagedCandidate = normalizeAppManagedBootstrapCandidate(
|
||||
appManagedBootstrapCandidate,
|
||||
{
|
||||
teamName,
|
||||
memberName,
|
||||
runId,
|
||||
laneId,
|
||||
runtimeSessionId: sessionId,
|
||||
}
|
||||
);
|
||||
const appManagedCandidatePresent =
|
||||
launchState === 'created' &&
|
||||
isNonEmptyString(sessionId) &&
|
||||
bootstrapEvidenceSource === 'app_managed_bootstrap' &&
|
||||
bootstrapMode === 'app_managed_context' &&
|
||||
normalizedAppManagedCandidate != null;
|
||||
const confirmed = launchState === 'confirmed_alive';
|
||||
const failed = launchState === 'failed';
|
||||
const hasRuntimePid =
|
||||
|
|
@ -580,20 +683,24 @@ function mapBridgeMemberToRuntimeEvidence(
|
|||
: launchState === 'permission_blocked'
|
||||
? 'permission_blocked'
|
||||
: 'registered_only';
|
||||
const runtimeDiagnostic = pendingRuntimeObserved
|
||||
? hasRuntimePid
|
||||
? 'OpenCode runtime pid reported by bridge without local process verification'
|
||||
: 'OpenCode session exists without verified runtime pid'
|
||||
: launchState === 'permission_blocked'
|
||||
? 'OpenCode runtime is waiting for permission approval'
|
||||
: runtimeMaterialized
|
||||
? 'OpenCode bridge did not report a runtime session or pid for this member'
|
||||
const runtimeDiagnostic = appManagedCandidatePresent
|
||||
? 'OpenCode app-managed bootstrap context was injected and verified by the bridge; waiting for app-owned durable evidence commit.'
|
||||
: pendingRuntimeObserved
|
||||
? hasRuntimePid
|
||||
? 'OpenCode runtime pid reported by bridge without local process verification'
|
||||
: 'OpenCode session exists without verified runtime pid'
|
||||
: launchState === 'permission_blocked'
|
||||
? 'OpenCode runtime is waiting for permission approval'
|
||||
: runtimeMaterialized
|
||||
? 'OpenCode bridge did not report a runtime session or pid for this member'
|
||||
: undefined;
|
||||
const runtimeDiagnosticSeverity = appManagedCandidatePresent
|
||||
? 'info'
|
||||
: failed
|
||||
? 'error'
|
||||
: pendingRuntimeObserved || launchState === 'permission_blocked' || runtimeMaterialized
|
||||
? 'warning'
|
||||
: undefined;
|
||||
const runtimeDiagnosticSeverity = failed
|
||||
? 'error'
|
||||
: pendingRuntimeObserved || launchState === 'permission_blocked' || runtimeMaterialized
|
||||
? 'warning'
|
||||
: undefined;
|
||||
return {
|
||||
memberName,
|
||||
providerId: 'opencode',
|
||||
|
|
@ -618,6 +725,13 @@ function mapBridgeMemberToRuntimeEvidence(
|
|||
? [...new Set(pendingPermissionRequestIds)]
|
||||
: undefined,
|
||||
sessionId,
|
||||
...(appManagedCandidatePresent
|
||||
? { bootstrapEvidenceSource: 'app_managed_bootstrap' as const }
|
||||
: {}),
|
||||
...(appManagedCandidatePresent ? { bootstrapMode: 'app_managed_context' as const } : {}),
|
||||
...(normalizedAppManagedCandidate
|
||||
? { appManagedBootstrapCandidate: normalizedAppManagedCandidate }
|
||||
: {}),
|
||||
...(hasRuntimePid ? { runtimePid } : {}),
|
||||
livenessKind,
|
||||
...(hasRuntimePid ? { pidSource: 'opencode_bridge' as const } : {}),
|
||||
|
|
@ -725,24 +839,24 @@ function buildMemberBootstrapPrompt(
|
|||
const role = member.role?.trim() || member.workflow?.trim() || 'teammate';
|
||||
const workflow = member.workflow?.trim();
|
||||
return [
|
||||
'<agent_teams_app_managed_bootstrap_briefing>',
|
||||
'AGENT_TEAMS_APP_MANAGED_BOOTSTRAP_V1',
|
||||
`You are ${member.name}, a ${role} on team "${input.teamName}".`,
|
||||
teamPrompt ? `Team launch context:\n${teamPrompt}` : null,
|
||||
workflow ? `Workflow:\n${workflow}` : null,
|
||||
'',
|
||||
'This OpenCode session is already attached by the desktop app. Do NOT create local team files, run join scripts, or search the project for a fake team registry.',
|
||||
'This OpenCode session is created, attached, and launch-verified by the desktop app.',
|
||||
'Do not call runtime_bootstrap_checkin or member_briefing just to prove launch readiness.',
|
||||
'Do NOT create local team files, run join scripts, or search the project for a fake team registry.',
|
||||
'Use the app MCP tools exposed by the "agent-teams" server for team communication and task state.',
|
||||
'The desktop bridge may prepend runtime identity and bootstrap instructions. Follow those first.',
|
||||
'After runtime identity check-in, if you have not already done so, call MCP tool agent-teams_member_briefing (or mcp__agent-teams__member_briefing if that is the exposed name) with:',
|
||||
`{ "teamName": "${input.teamName}", "memberName": "${member.name}", "runtimeProvider": "opencode" }`,
|
||||
'If that tool is not available, stay idle and wait for app-delivered instructions. Do not improvise a replacement workflow.',
|
||||
'Launch bootstrap is a silent attach, not a user/team conversation turn.',
|
||||
'After runtime_bootstrap_checkin and member_briefing both succeed, stop this turn immediately and wait for app-delivered messages or actionable task assignments.',
|
||||
'Do not call task_briefing, message_send, or cross_team_send just to announce readiness, say understood, report no tasks, or ask for work.',
|
||||
'If the briefing says there are no actionable tasks, stay idle silently.',
|
||||
'',
|
||||
'When you need to message the human user, team lead, or another teammate, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send) with teamName, to, from, text, and optional summary.',
|
||||
`Always set from="${member.name}" when sending a team message from this OpenCode teammate.`,
|
||||
'Do not answer team/app messages only as plain assistant text when agent-teams_message_send is available.',
|
||||
'</agent_teams_app_managed_bootstrap_briefing>',
|
||||
]
|
||||
.filter((line): line is string => line !== null)
|
||||
.join('\n');
|
||||
|
|
@ -792,6 +906,10 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
|
|||
input.taskRefs
|
||||
?.map((ref) => ref.taskId?.trim())
|
||||
.filter((taskId): taskId is string => Boolean(taskId)) ?? [];
|
||||
// Work-sync nudges are health/reporting probes. Requiring a visible
|
||||
// message_send reply here causes false delivery failures, so accept the
|
||||
// dedicated member_work_sync_report proof path while keeping normal user
|
||||
// messages on the visible reply contract.
|
||||
const responseInstructions = isWorkSyncNudge
|
||||
? [
|
||||
'This delivered app message is a member-work-sync nudge.',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import type {
|
||||
EffortLevel,
|
||||
MemberLaunchState,
|
||||
OpenCodeAppManagedBootstrapCandidate,
|
||||
OpenCodeBootstrapEvidenceSource,
|
||||
OpenCodeBootstrapMode,
|
||||
PersistedTeamLaunchPhase,
|
||||
PersistedTeamLaunchSnapshot,
|
||||
TeamAgentRuntimeBackendType,
|
||||
|
|
@ -79,6 +82,9 @@ export interface TeamRuntimeMemberLaunchEvidence {
|
|||
hardFailureReason?: string;
|
||||
pendingPermissionRequestIds?: string[];
|
||||
sessionId?: string;
|
||||
bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource;
|
||||
bootstrapMode?: OpenCodeBootstrapMode;
|
||||
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
|
||||
backendType?: TeamAgentRuntimeBackendType;
|
||||
runtimePid?: number;
|
||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||
|
|
|
|||
|
|
@ -1005,6 +1005,27 @@ export interface PersistedTeamLaunchMemberSources {
|
|||
duplicateRespawnBlocked?: boolean;
|
||||
}
|
||||
|
||||
export interface OpenCodeAppManagedBootstrapCandidate {
|
||||
schemaVersion: 1;
|
||||
source: 'app_managed_bootstrap';
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
runId: string;
|
||||
laneId: string;
|
||||
runtimeSessionId: string;
|
||||
messageID: string;
|
||||
contextHash: string;
|
||||
briefingHash: string;
|
||||
injectionVerifiedAt: string;
|
||||
candidateAt: string;
|
||||
model?: string;
|
||||
agent?: string;
|
||||
}
|
||||
|
||||
export type OpenCodeBootstrapEvidenceSource = 'runtime_bootstrap_checkin' | 'app_managed_bootstrap';
|
||||
|
||||
export type OpenCodeBootstrapMode = 'model_tool_checkin' | 'app_managed_context';
|
||||
|
||||
export interface PersistedTeamLaunchMemberState {
|
||||
name: string;
|
||||
providerId?: TeamProviderId;
|
||||
|
|
@ -1032,6 +1053,9 @@ export interface PersistedTeamLaunchMemberState {
|
|||
/** OpenCode runtime run id that produced the current runtimeSessionId/liveness evidence. */
|
||||
runtimeRunId?: string;
|
||||
runtimeSessionId?: string;
|
||||
bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource;
|
||||
bootstrapMode?: OpenCodeBootstrapMode;
|
||||
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
|
||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||
pidSource?: TeamAgentRuntimePidSource;
|
||||
runtimeDiagnostic?: string;
|
||||
|
|
|
|||
2
src/types/agent-teams-controller.d.ts
vendored
2
src/types/agent-teams-controller.d.ts
vendored
|
|
@ -43,7 +43,7 @@ declare module 'agent-teams-controller' {
|
|||
unlinkTask(taskId: string, targetId: string, linkType: string): unknown;
|
||||
memberBriefing(
|
||||
memberName: string,
|
||||
options?: { runtimeProvider?: 'native' | 'opencode' }
|
||||
options?: { runtimeProvider?: 'native' | 'opencode'; includeActiveProcesses?: boolean }
|
||||
): Promise<string>;
|
||||
leadBriefing(): Promise<string>;
|
||||
taskBriefing(memberName: string): Promise<string>;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
286
test/main/services/team/MemberWorkSyncOpenCode.live.test.ts
Normal file
286
test/main/services/team/MemberWorkSyncOpenCode.live.test.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import { promises as fs } from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createMemberWorkSyncFeature,
|
||||
type MemberWorkSyncFeatureFacade,
|
||||
} from '../../../../src/features/member-work-sync/main';
|
||||
import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader';
|
||||
import { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
|
||||
import { TeamKanbanManager } from '../../../../src/main/services/team/TeamKanbanManager';
|
||||
import { TeamMembersMetaStore } from '../../../../src/main/services/team/TeamMembersMetaStore';
|
||||
import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader';
|
||||
import {
|
||||
getTeamsBasePath,
|
||||
setClaudeBasePathOverride,
|
||||
} from '../../../../src/main/utils/pathDecoder';
|
||||
import {
|
||||
formatMemberWorkSyncDiagnostics,
|
||||
formatProgressDump,
|
||||
readRuntimeTurnSettledProcessedMetas,
|
||||
waitUntil,
|
||||
} from './memberWorkSyncLiveHarness';
|
||||
import {
|
||||
createOpenCodeLiveHarness,
|
||||
readInboxMessages,
|
||||
waitForOpenCodeLanesStopped,
|
||||
type OpenCodeLiveHarness,
|
||||
} from './openCodeLiveTestHarness';
|
||||
|
||||
import type { TeamChangeEvent, TeamProvisioningProgress } from '../../../../src/shared/types';
|
||||
|
||||
const liveDescribe =
|
||||
process.env.OPENCODE_E2E === '1' && process.env.OPENCODE_E2E_MEMBER_WORK_SYNC === '1'
|
||||
? describe
|
||||
: describe.skip;
|
||||
|
||||
const DEFAULT_MODEL = 'opencode/gpt-5-nano';
|
||||
|
||||
liveDescribe('Member work sync OpenCode live e2e', () => {
|
||||
let tempDir: string;
|
||||
let tempClaudeRoot: string;
|
||||
let feature: MemberWorkSyncFeatureFacade | null;
|
||||
let harness: OpenCodeLiveHarness | null;
|
||||
let teamName: string | null;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'member-work-sync-opencode-live-'));
|
||||
tempClaudeRoot = path.join(tempDir, '.claude');
|
||||
await fs.mkdir(tempClaudeRoot, { recursive: true });
|
||||
setClaudeBasePathOverride(tempClaudeRoot);
|
||||
feature = null;
|
||||
harness = null;
|
||||
teamName = null;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (harness && teamName) {
|
||||
await harness.svc.stopTeam(teamName).catch(() => undefined);
|
||||
await waitForOpenCodeLanesStopped(teamName);
|
||||
}
|
||||
await feature?.dispose().catch(() => undefined);
|
||||
await harness?.dispose().catch(() => undefined);
|
||||
setClaudeBasePathOverride(null);
|
||||
if (process.env.OPENCODE_E2E_KEEP_TEMP === '1') {
|
||||
console.info(`[MemberWorkSyncOpenCode.live] preserved temp dir: ${tempDir}`);
|
||||
} else {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
'delivers a work-sync nudge to a real OpenCode member and accepts its still-working report',
|
||||
async () => {
|
||||
const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL;
|
||||
const projectPath = path.join(tempDir, 'project');
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(projectPath, 'README.md'),
|
||||
'# Member work sync OpenCode live e2e\n\nKeep this project intentionally tiny.\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
let activeService: OpenCodeLiveHarness['svc'] | null = null;
|
||||
harness = await createOpenCodeLiveHarness({
|
||||
tempDir,
|
||||
selectedModel,
|
||||
projectPath,
|
||||
configureServices: (svc) => {
|
||||
activeService = svc;
|
||||
const configReader = new TeamConfigReader();
|
||||
feature = createMemberWorkSyncFeature({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
configReader,
|
||||
taskReader: new TeamTaskReader(),
|
||||
kanbanManager: new TeamKanbanManager(),
|
||||
membersMetaStore: new TeamMembersMetaStore(),
|
||||
isTeamActive: (name) => svc.isTeamAlive(name) || svc.hasProvisioningRun(name),
|
||||
listLifecycleActiveTeamNames: async () => (teamName ? [teamName] : []),
|
||||
queueQuietWindowMs: 1,
|
||||
});
|
||||
svc.setTeamChangeEmitter((event: TeamChangeEvent) => feature!.noteTeamChange(event));
|
||||
svc.setRuntimeTurnSettledEnvironmentProvider((input) =>
|
||||
feature!.buildRuntimeTurnSettledEnvironment(input)
|
||||
);
|
||||
return { memberWorkSyncFeature: feature! };
|
||||
},
|
||||
});
|
||||
expect(activeService).toBe(harness.svc);
|
||||
|
||||
const memberName = 'bob';
|
||||
const marker = `member-work-sync-opencode-live-${Date.now()}`;
|
||||
teamName = `member-work-sync-opencode-${Date.now()}`;
|
||||
const progressEvents: TeamProvisioningProgress[] = [];
|
||||
|
||||
await harness.svc.createTeam(
|
||||
{
|
||||
teamName,
|
||||
cwd: projectPath,
|
||||
providerId: 'opencode',
|
||||
model: selectedModel,
|
||||
skipPermissions: true,
|
||||
prompt: [
|
||||
'Keep launch work minimal.',
|
||||
'If you receive a member_work_sync_nudge, call member_work_sync_status first.',
|
||||
'Then call member_work_sync_report with state "still_working", the returned agendaFingerprint/reportToken, and taskIds from the nudge.',
|
||||
'Do not complete the task and do not reply only with acknowledgement.',
|
||||
].join(' '),
|
||||
members: [
|
||||
{
|
||||
name: memberName,
|
||||
role: 'Developer',
|
||||
providerId: 'opencode',
|
||||
model: selectedModel,
|
||||
},
|
||||
],
|
||||
},
|
||||
(progress) => {
|
||||
progressEvents.push(progress);
|
||||
}
|
||||
);
|
||||
|
||||
await waitUntil(async () => {
|
||||
const last = progressEvents.at(-1);
|
||||
if (last?.state === 'failed') {
|
||||
throw new Error(formatProgressDump(progressEvents));
|
||||
}
|
||||
return progressEvents.some((progress) =>
|
||||
progress.message.includes('OpenCode team launch is ready')
|
||||
);
|
||||
}, 240_000);
|
||||
|
||||
await seedShadowReadyMetrics({ teamName, memberName });
|
||||
const task = await new TeamDataService().createTask(teamName, {
|
||||
subject: `Member work sync OpenCode live nudge ${marker}`,
|
||||
owner: memberName,
|
||||
startImmediately: false,
|
||||
prompt: [
|
||||
`This is a live member-work-sync OpenCode validation task. Marker: ${marker}.`,
|
||||
'Do not edit files and do not complete this task.',
|
||||
'Only report still_working if member-work-sync asks you to synchronize.',
|
||||
].join('\n'),
|
||||
});
|
||||
feature!.noteTeamChange({ type: 'task', teamName, taskId: task.id });
|
||||
|
||||
const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${memberName}.json`);
|
||||
await waitUntil(async () => {
|
||||
const status = await feature!.getStatus({ teamName: teamName!, memberName });
|
||||
const inbox = await readInboxMessages(inboxPath);
|
||||
return (
|
||||
status.agenda.items.some((item) => item.taskId === task.id) &&
|
||||
inbox.some(
|
||||
(message) =>
|
||||
message.messageKind === 'member_work_sync_nudge' &&
|
||||
typeof message.messageId === 'string'
|
||||
)
|
||||
);
|
||||
}, 60_000, 500, async () =>
|
||||
formatMemberWorkSyncDiagnostics({
|
||||
feature: feature!,
|
||||
teamName: teamName!,
|
||||
memberName,
|
||||
taskId: task.id,
|
||||
})
|
||||
);
|
||||
|
||||
const nudge = (await readInboxMessages(inboxPath)).find(
|
||||
(message) => message.messageKind === 'member_work_sync_nudge'
|
||||
);
|
||||
expect(nudge?.messageId).toBeTruthy();
|
||||
|
||||
let lastRelay: Awaited<
|
||||
ReturnType<OpenCodeLiveHarness['svc']['relayOpenCodeMemberInboxMessages']>
|
||||
> | null = null;
|
||||
await waitUntil(async () => {
|
||||
lastRelay = await harness!.svc.relayOpenCodeMemberInboxMessages(teamName!, memberName, {
|
||||
onlyMessageId: nudge!.messageId,
|
||||
source: 'manual',
|
||||
deliveryMetadata: {
|
||||
replyRecipient: 'user',
|
||||
},
|
||||
});
|
||||
return Boolean(lastRelay.lastDelivery?.delivered);
|
||||
}, 120_000);
|
||||
|
||||
await waitUntil(async () => {
|
||||
const status = await feature!.getStatus({ teamName: teamName!, memberName });
|
||||
return status.report?.accepted === true && status.report.state === 'still_working';
|
||||
}, 180_000, 2_000, async () =>
|
||||
[
|
||||
`Last OpenCode relay: ${JSON.stringify(lastRelay, null, 2)}`,
|
||||
await formatMemberWorkSyncDiagnostics({
|
||||
feature: feature!,
|
||||
teamName: teamName!,
|
||||
memberName,
|
||||
taskId: task.id,
|
||||
}),
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
await waitUntil(async () => {
|
||||
await feature!.drainRuntimeTurnSettledEvents();
|
||||
const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath());
|
||||
return metas.some(({ meta }) => {
|
||||
const event = meta.event as Record<string, unknown> | undefined;
|
||||
return event?.provider === 'opencode' && event.teamName === teamName;
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
await expect(feature!.dispatchDueNudges([teamName])).resolves.toMatchObject({
|
||||
claimed: 0,
|
||||
delivered: 0,
|
||||
});
|
||||
},
|
||||
420_000
|
||||
);
|
||||
});
|
||||
|
||||
async function seedShadowReadyMetrics(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
}): Promise<void> {
|
||||
const metricsPath = path.join(
|
||||
getTeamsBasePath(),
|
||||
input.teamName,
|
||||
'.member-work-sync',
|
||||
'indexes',
|
||||
'metrics.json'
|
||||
);
|
||||
const startMs = Date.now() - 2 * 60 * 60_000;
|
||||
await fs.mkdir(path.dirname(metricsPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
metricsPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 2,
|
||||
members: {
|
||||
[input.memberName]: {
|
||||
memberName: input.memberName,
|
||||
state: 'caught_up',
|
||||
agendaFingerprint: 'agenda:v1:seed',
|
||||
actionableCount: 0,
|
||||
evaluatedAt: new Date(startMs).toISOString(),
|
||||
providerId: 'opencode',
|
||||
},
|
||||
},
|
||||
recentEvents: Array.from({ length: 24 }, (_, index) => ({
|
||||
id: `seed-status-${index}`,
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
kind: 'status_evaluated',
|
||||
state: 'caught_up',
|
||||
agendaFingerprint: `agenda:v1:seed-${index}`,
|
||||
recordedAt: new Date(startMs + index * 6 * 60_000).toISOString(),
|
||||
actionableCount: 0,
|
||||
providerId: 'opencode',
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
createOpenCodeBridgeHandshakeIdentityHash,
|
||||
createOpenCodeBridgeIdempotencyKey,
|
||||
isOpenCodeBridgeCommandName,
|
||||
OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
|
||||
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
||||
parseSingleBridgeJsonResult,
|
||||
stableHash,
|
||||
|
|
@ -312,6 +313,8 @@ function peerIdentity(
|
|||
'opencode.launchTeam',
|
||||
'opencode.stopTeam',
|
||||
],
|
||||
opencodeAppManagedBootstrapContractVersion:
|
||||
OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
|
||||
},
|
||||
runtime: {
|
||||
providerId: 'opencode',
|
||||
|
|
|
|||
|
|
@ -119,7 +119,14 @@ liveDescribe('OpenCode mixed recovery live e2e', () => {
|
|||
selectedModel,
|
||||
});
|
||||
launchedLanes.push(launchInput);
|
||||
const launchResult = await adapter.launch(launchInput);
|
||||
const rawLaunchResult = await adapter.launch(launchInput);
|
||||
const launchResult = await commitMixedOpenCodeLaunchResult({
|
||||
service: svc,
|
||||
teamName,
|
||||
laneId: launchInput.laneId ?? 'secondary:opencode:bob',
|
||||
memberName: 'bob',
|
||||
result: rawLaunchResult,
|
||||
});
|
||||
expectCleanOpenCodeLaunch(launchResult);
|
||||
expect(launchResult.members.bob).toMatchObject({
|
||||
launchState: 'confirmed_alive',
|
||||
|
|
@ -226,7 +233,14 @@ liveDescribe('OpenCode mixed recovery live e2e', () => {
|
|||
selectedModel,
|
||||
});
|
||||
launchedLanes.push(launchInput);
|
||||
const launchResult = await adapter.launch(launchInput);
|
||||
const rawLaunchResult = await adapter.launch(launchInput);
|
||||
const launchResult = await commitMixedOpenCodeLaunchResult({
|
||||
service: svc,
|
||||
teamName,
|
||||
laneId: launchInput.laneId ?? `secondary:opencode:${memberName}`,
|
||||
memberName,
|
||||
result: rawLaunchResult,
|
||||
});
|
||||
expectCleanOpenCodeLaunch(launchResult);
|
||||
expect(launchResult.members[memberName]).toMatchObject({
|
||||
launchState: 'confirmed_alive',
|
||||
|
|
@ -327,6 +341,21 @@ function expectCleanOpenCodeLaunch(
|
|||
expect(launchResult.teamLaunchState).toBe('clean_success');
|
||||
}
|
||||
|
||||
async function commitMixedOpenCodeLaunchResult(input: {
|
||||
service: TeamProvisioningService;
|
||||
teamName: string;
|
||||
laneId: string;
|
||||
memberName: string;
|
||||
result: Awaited<ReturnType<OpenCodeTeamRuntimeAdapter['launch']>>;
|
||||
}): Promise<Awaited<ReturnType<OpenCodeTeamRuntimeAdapter['launch']>>> {
|
||||
return (input.service as any).guardCommittedOpenCodeSecondaryLaneEvidence({
|
||||
teamName: input.teamName,
|
||||
laneId: input.laneId,
|
||||
memberName: input.memberName,
|
||||
result: input.result,
|
||||
});
|
||||
}
|
||||
|
||||
async function writeMixedRecoveryFixtures(input: {
|
||||
teamName: string;
|
||||
projectPath: string;
|
||||
|
|
|
|||
|
|
@ -368,7 +368,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
members: [
|
||||
expect.objectContaining({
|
||||
name: 'alice',
|
||||
prompt: expect.stringContaining('agent-teams_member_briefing'),
|
||||
prompt: expect.stringContaining('AGENT_TEAMS_APP_MANAGED_BOOTSTRAP_V1'),
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
|
@ -377,6 +377,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
expect(launchArg?.members[0]?.prompt).toContain('Do NOT create local team files');
|
||||
expect(launchArg?.members[0]?.prompt).toContain('Launch bootstrap is a silent attach');
|
||||
expect(launchArg?.members[0]?.prompt).toContain('stay idle silently');
|
||||
expect(launchArg?.members[0]?.prompt).not.toContain('agent-teams_member_briefing');
|
||||
expect(launchArg?.members[0]?.prompt).not.toContain('Join team "team-a"');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -547,6 +547,31 @@ function createMemberSpawnRun(params?: {
|
|||
} as any;
|
||||
}
|
||||
|
||||
const TEST_OPENCODE_APP_MANAGED_BOOTSTRAP_PROMPT = [
|
||||
'AGENT_TEAMS_APP_MANAGED_BOOTSTRAP_V1',
|
||||
'<agent_teams_app_managed_briefing_source>',
|
||||
'Test app-managed member briefing.',
|
||||
'</agent_teams_app_managed_briefing_source>',
|
||||
].join('\n');
|
||||
|
||||
function stubOpenCodeAppManagedLaunchPrompt(svc: TeamProvisioningService) {
|
||||
return vi
|
||||
.spyOn(svc as any, 'buildOpenCodeSecondaryAppManagedLaunchPrompt')
|
||||
.mockImplementation(async (_run: unknown, lane: unknown) => {
|
||||
const memberName =
|
||||
lane &&
|
||||
typeof lane === 'object' &&
|
||||
'member' in lane &&
|
||||
lane.member &&
|
||||
typeof lane.member === 'object' &&
|
||||
'name' in lane.member &&
|
||||
typeof lane.member.name === 'string'
|
||||
? lane.member.name
|
||||
: 'unknown';
|
||||
return `${TEST_OPENCODE_APP_MANAGED_BOOTSTRAP_PROMPT}\nmember=${memberName}`;
|
||||
});
|
||||
}
|
||||
|
||||
function createClaudeLogsRun(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
runId: 'run-logs-1',
|
||||
|
|
@ -4357,25 +4382,21 @@ describe('TeamProvisioningService', () => {
|
|||
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(
|
||||
await writeCommittedOpenCodeSessionStore({
|
||||
teamName,
|
||||
laneId,
|
||||
runId,
|
||||
sessions: [
|
||||
{
|
||||
...createDefaultRuntimeStoreManifest(teamName, '2026-04-22T12:00:00.000Z'),
|
||||
activeRunId: runId,
|
||||
id: 'oc-session-bob',
|
||||
teamName,
|
||||
memberName: 'bob',
|
||||
laneId,
|
||||
runId,
|
||||
source: 'runtime_bootstrap_checkin',
|
||||
},
|
||||
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,
|
||||
|
|
@ -4408,6 +4429,7 @@ describe('TeamProvisioningService', () => {
|
|||
} as any,
|
||||
]);
|
||||
svc.setRuntimeAdapterRegistry(registry);
|
||||
stubOpenCodeAppManagedLaunchPrompt(svc);
|
||||
|
||||
(svc as any).launchStateStore = {
|
||||
read: vi.fn(async () => null),
|
||||
|
|
@ -4480,7 +4502,7 @@ describe('TeamProvisioningService', () => {
|
|||
model: 'minimax-m2.5-free',
|
||||
effort: 'medium',
|
||||
runtimeOnly: true,
|
||||
skipReadinessPreflight: true,
|
||||
prompt: expect.stringContaining('AGENT_TEAMS_APP_MANAGED_BOOTSTRAP_V1'),
|
||||
cwd: '/tmp/mixed-team',
|
||||
expectedMembers: [
|
||||
expect.objectContaining({
|
||||
|
|
@ -4493,6 +4515,7 @@ describe('TeamProvisioningService', () => {
|
|||
],
|
||||
})
|
||||
);
|
||||
expect(adapterLaunch.mock.calls[0]?.[0]).not.toHaveProperty('skipReadinessPreflight');
|
||||
});
|
||||
|
||||
it('does not trust OpenCode secondary bootstrap success without committed lane evidence', async () => {
|
||||
|
|
@ -6622,6 +6645,57 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('treats OpenCode empty assistant turns with prompt proof as pending delivery', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
ok: false,
|
||||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
prePromptCursor: 'cursor-before',
|
||||
responseObservation: {
|
||||
state: 'empty_assistant_turn' as const,
|
||||
deliveredUserMessageId: 'oc-user-empty',
|
||||
assistantMessageId: null,
|
||||
toolCallNames: [],
|
||||
visibleMessageToolCallId: null,
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyCorrelation: null,
|
||||
latestAssistantPreview: null,
|
||||
reason: 'empty_assistant_turn',
|
||||
},
|
||||
diagnostics: ['empty_assistant_turn'],
|
||||
}));
|
||||
await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember });
|
||||
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||
memberName: 'bob',
|
||||
text: 'Work sync check for #task-1.',
|
||||
messageId: 'msg-empty-assistant-pending',
|
||||
replyRecipient: 'team-lead',
|
||||
actionMode: 'do',
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
taskRefs: [
|
||||
{
|
||||
taskId: 'task-1',
|
||||
displayId: 'task-1',
|
||||
teamName: 'team-a',
|
||||
},
|
||||
],
|
||||
source: 'watcher',
|
||||
inboxTimestamp: '2026-04-25T10:00:00.000Z',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
delivered: true,
|
||||
accepted: true,
|
||||
responsePending: true,
|
||||
responseState: 'prompt_delivered_no_assistant_message',
|
||||
ledgerStatus: 'retry_scheduled',
|
||||
reason: 'prompt_delivered_no_assistant_message',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks OpenCode delivery terminal after max attempts instead of leaving it pending', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const emptyResponseObservation = {
|
||||
|
|
@ -7624,6 +7698,7 @@ describe('TeamProvisioningService', () => {
|
|||
} as any,
|
||||
]);
|
||||
svc.setRuntimeAdapterRegistry(registry);
|
||||
stubOpenCodeAppManagedLaunchPrompt(svc);
|
||||
|
||||
(svc as any).launchStateStore = {
|
||||
read: vi.fn(async () => null),
|
||||
|
|
@ -7761,6 +7836,7 @@ describe('TeamProvisioningService', () => {
|
|||
} as any,
|
||||
])
|
||||
);
|
||||
stubOpenCodeAppManagedLaunchPrompt(svc);
|
||||
(svc as any).launchStateStore = {
|
||||
read: vi.fn(async () => null),
|
||||
write: vi.fn(async () => {}),
|
||||
|
|
@ -7873,6 +7949,7 @@ describe('TeamProvisioningService', () => {
|
|||
} as any,
|
||||
])
|
||||
);
|
||||
stubOpenCodeAppManagedLaunchPrompt(svc);
|
||||
(svc as any).launchStateStore = {
|
||||
read: vi.fn(async () => null),
|
||||
write: vi.fn(async () => {}),
|
||||
|
|
@ -10828,6 +10905,7 @@ describe('TeamProvisioningService', () => {
|
|||
resolvedFastMode: null,
|
||||
fastResolutionReason: null,
|
||||
}));
|
||||
stubOpenCodeAppManagedLaunchPrompt(svc);
|
||||
|
||||
return { svc, mcpConfigBuilder, membersMetaStore, teamMetaStore };
|
||||
}
|
||||
|
|
@ -11121,25 +11199,21 @@ describe('TeamProvisioningService', () => {
|
|||
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(
|
||||
await writeCommittedOpenCodeSessionStore({
|
||||
teamName,
|
||||
laneId,
|
||||
runId,
|
||||
sessions: [
|
||||
{
|
||||
...createDefaultRuntimeStoreManifest(teamName, '2026-04-22T12:00:00.000Z'),
|
||||
activeRunId: runId,
|
||||
id: `oc-session-${memberName}`,
|
||||
teamName,
|
||||
memberName,
|
||||
laneId,
|
||||
runId,
|
||||
source: 'runtime_bootstrap_checkin',
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
await fsPromises.writeFile(
|
||||
path.join(path.dirname(manifestPath), 'opencode-sessions.json'),
|
||||
`${JSON.stringify({ sessions: [{ id: `oc-session-${memberName}` }] })}\n`,
|
||||
'utf8'
|
||||
);
|
||||
],
|
||||
});
|
||||
return {
|
||||
runId,
|
||||
teamName,
|
||||
|
|
@ -11447,9 +11521,27 @@ describe('TeamProvisioningService', () => {
|
|||
const adapterLaunch = vi.fn(async (input: Record<string, unknown>) => {
|
||||
const expectedMembers = input.expectedMembers as Array<{ name: string }>;
|
||||
const memberName = expectedMembers[0]?.name ?? 'unknown';
|
||||
const teamName = String(input.teamName);
|
||||
const laneId = String(input.laneId);
|
||||
const runId = String(input.runId);
|
||||
await writeCommittedOpenCodeSessionStore({
|
||||
teamName,
|
||||
laneId,
|
||||
runId,
|
||||
sessions: [
|
||||
{
|
||||
id: `oc-session-${memberName}`,
|
||||
teamName,
|
||||
memberName,
|
||||
laneId,
|
||||
runId,
|
||||
source: 'runtime_bootstrap_checkin',
|
||||
},
|
||||
],
|
||||
});
|
||||
return {
|
||||
runId: String(input.runId),
|
||||
teamName: String(input.teamName),
|
||||
runId,
|
||||
teamName,
|
||||
launchPhase: 'finished',
|
||||
teamLaunchState: 'clean_success',
|
||||
members: {
|
||||
|
|
|
|||
|
|
@ -1834,6 +1834,94 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
expect(rows[0].read).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps accepted OpenCode prompt rows pending without warning when response proof is terminally absent', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
hoisted.files.set(
|
||||
`/mock/teams/${teamName}/config.json`,
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
projectPath: '/tmp/my-team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
|
||||
],
|
||||
})
|
||||
);
|
||||
seedMemberInbox(teamName, 'jack', [
|
||||
{
|
||||
from: 'bob',
|
||||
to: 'jack',
|
||||
text: 'Please sync your current task.',
|
||||
timestamp: '2026-02-23T17:04:00.000Z',
|
||||
read: false,
|
||||
messageId: 'opencode-accepted-terminal-empty-1',
|
||||
actionMode: 'do',
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
},
|
||||
]);
|
||||
vi.spyOn(service, 'deliverOpenCodeMemberMessage').mockResolvedValue({
|
||||
delivered: false,
|
||||
accepted: true,
|
||||
responsePending: false,
|
||||
responseState: 'empty_assistant_turn',
|
||||
ledgerStatus: 'failed_terminal',
|
||||
ledgerRecordId: 'ledger-1',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
reason: 'empty_assistant_turn',
|
||||
diagnostics: ['empty_assistant_turn'],
|
||||
});
|
||||
|
||||
const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack');
|
||||
|
||||
expect(relay).toMatchObject({
|
||||
relayed: 0,
|
||||
attempted: 1,
|
||||
delivered: 0,
|
||||
failed: 0,
|
||||
lastDelivery: {
|
||||
delivered: false,
|
||||
accepted: true,
|
||||
responsePending: false,
|
||||
ledgerStatus: 'failed_terminal',
|
||||
reason: 'empty_assistant_turn',
|
||||
},
|
||||
});
|
||||
expect(vi.mocked(console.warn)).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('OpenCode inbox relay failed')
|
||||
);
|
||||
const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
|
||||
expect(rows[0].read).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat empty OpenCode observations as accepted without delivered prompt proof', () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const isAccepted = (
|
||||
service as unknown as {
|
||||
isOpenCodePromptAcceptedByObservation: (observation?: unknown) => boolean;
|
||||
}
|
||||
).isOpenCodePromptAcceptedByObservation.bind(service);
|
||||
|
||||
expect(
|
||||
isAccepted({
|
||||
state: 'empty_assistant_turn',
|
||||
deliveredUserMessageId: null,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
isAccepted({
|
||||
state: 'prompt_delivered_no_assistant_message',
|
||||
deliveredUserMessageId: '',
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
isAccepted({
|
||||
state: 'empty_assistant_turn',
|
||||
deliveredUserMessageId: 'opencode-user-message-1',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reuses existing OpenCode prompt ledger metadata during watchdog relay retries', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
|
|
@ -1901,6 +1989,91 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('ignores stale OpenCode watchdog jobs after the runtime lane is no longer active', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
hoisted.files.set(
|
||||
`/mock/teams/${teamName}/config.json`,
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
projectPath: '/tmp/my-team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
|
||||
],
|
||||
})
|
||||
);
|
||||
seedMemberInbox(teamName, 'jack', [
|
||||
{
|
||||
from: 'bob',
|
||||
to: 'jack',
|
||||
text: 'Please sync.',
|
||||
timestamp: '2026-02-23T17:00:00.000Z',
|
||||
read: false,
|
||||
messageId: 'opencode-stale-watchdog-1',
|
||||
},
|
||||
]);
|
||||
const deliverSpy = vi
|
||||
.spyOn(service, 'deliverOpenCodeMemberMessage')
|
||||
.mockRejectedValue(
|
||||
new Error('OpenCode prompt delivery record not found: opencode-prompt:stale')
|
||||
);
|
||||
|
||||
(service as any).scheduleOpenCodePromptDeliveryWatchdog({
|
||||
teamName,
|
||||
memberName: 'jack',
|
||||
messageId: 'opencode-stale-watchdog-1',
|
||||
delayMs: 500,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(deliverSpy).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(console.warn)).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not classify missing OpenCode watchdog ledger rows as stale while the lane is active', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
attachAliveRun(service, teamName);
|
||||
hoisted.files.set(
|
||||
`/mock/teams/${teamName}/config.json`,
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
projectPath: '/tmp/my-team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
|
||||
],
|
||||
})
|
||||
);
|
||||
seedMemberInbox(teamName, 'jack', [
|
||||
{
|
||||
from: 'bob',
|
||||
to: 'jack',
|
||||
text: 'Please sync.',
|
||||
timestamp: '2026-02-23T17:00:00.000Z',
|
||||
read: false,
|
||||
messageId: 'opencode-active-watchdog-1',
|
||||
},
|
||||
]);
|
||||
vi.spyOn(service as any, 'isOpenCodeRuntimeLaneIndexActive').mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
(service as any).isStaleOpenCodePromptDeliveryWatchdogError({
|
||||
teamName,
|
||||
memberName: 'jack',
|
||||
messageId: 'opencode-active-watchdog-1',
|
||||
error: new Error('OpenCode prompt delivery record not found: opencode-prompt:active'),
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('skips failed-terminal OpenCode rows without blocking newer unread rows', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import * as path from 'path';
|
|||
|
||||
import Fastify from 'fastify';
|
||||
|
||||
import { buildMemberWorkSyncRuntimeTurnSettledEnvironment } from '../../../../src/features/member-work-sync/main';
|
||||
import { registerTeamRoutes } from '../../../../src/main/http/teams';
|
||||
import { applyOpenCodeAutoUpdatePolicy } from '../../../../src/main/services/runtime/openCodeAutoUpdatePolicy';
|
||||
import { OpenCodeBridgeCommandClient } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient';
|
||||
|
|
@ -39,6 +40,7 @@ export interface InboxMessage {
|
|||
to?: string;
|
||||
text?: string;
|
||||
messageId?: string;
|
||||
messageKind?: string;
|
||||
read?: boolean;
|
||||
taskRefs?: TaskRef[];
|
||||
source?: string;
|
||||
|
|
@ -55,13 +57,17 @@ export async function createOpenCodeLiveHarness(input: {
|
|||
tempDir: string;
|
||||
selectedModel: string;
|
||||
projectPath?: string;
|
||||
configureServices?: (
|
||||
svc: TeamProvisioningService
|
||||
) => Partial<HttpServices> | Promise<Partial<HttpServices> | void> | void;
|
||||
}): Promise<OpenCodeLiveHarness> {
|
||||
const orchestratorCli =
|
||||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
|
||||
await assertExecutable(orchestratorCli);
|
||||
|
||||
const svc = new TeamProvisioningService();
|
||||
const controlApi = await startLiveTeamControlApi(svc);
|
||||
const extraServices = (await input.configureServices?.(svc)) ?? {};
|
||||
const controlApi = await startLiveTeamControlApi(svc, extraServices);
|
||||
svc.setControlApiBaseUrlResolver(async () => controlApi.baseUrl);
|
||||
|
||||
const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec();
|
||||
|
|
@ -75,6 +81,13 @@ export async function createOpenCodeLiveHarness(input: {
|
|||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
|
||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args),
|
||||
};
|
||||
const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
provider: 'opencode',
|
||||
});
|
||||
if (turnSettledEnv) {
|
||||
Object.assign(bridgeEnv, turnSettledEnv);
|
||||
}
|
||||
if (process.env.OPENCODE_E2E_USE_REAL_APP_CREDENTIALS !== '1') {
|
||||
bridgeEnv.XDG_DATA_HOME = path.join(input.tempDir, 'xdg-data');
|
||||
} else if (stableBridgeEnv.XDG_DATA_HOME) {
|
||||
|
|
@ -326,13 +339,17 @@ function getTranscriptDurableState(transcript: unknown): string | null {
|
|||
return typeof durableState === 'string' ? durableState : null;
|
||||
}
|
||||
|
||||
async function startLiveTeamControlApi(svc: TeamProvisioningService): Promise<{
|
||||
async function startLiveTeamControlApi(
|
||||
svc: TeamProvisioningService,
|
||||
extraServices: Partial<HttpServices> = {}
|
||||
): Promise<{
|
||||
baseUrl: string;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const app = Fastify({ logger: false });
|
||||
registerTeamRoutes(app, {
|
||||
teamProvisioningService: svc,
|
||||
...extraServices,
|
||||
} as HttpServices);
|
||||
await app.listen({ host: '127.0.0.1', port: 0 });
|
||||
const address = app.server.address();
|
||||
|
|
|
|||
Loading…
Reference in a new issue