fix(team): refine bootstrap and provider diagnostics

This commit is contained in:
777genius 2026-04-18 18:32:21 +03:00
parent fb3d1ceb27
commit 452948b260
8 changed files with 198 additions and 19 deletions

View file

@ -85,6 +85,7 @@ import { buildActionModeProtocol } from './actionModeInstructions';
import { atomicWriteAsync } from './atomicWrite';
import { peekAutoResumeService } from './AutoResumeService';
import { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
import { getConfiguredCliCommandLabel } from './cliFlavor';
import { withFileLock } from './fileLock';
import {
type ClassifiedMainProcessIdle,
@ -1345,6 +1346,8 @@ ${buildCanonicalSendMessageExample({ to: leadName, summary: 'short update', mess
After member_briefing succeeds:
- Do NOT send a "ready", "online", "status accepted", or other acknowledgement-only message just to confirm you started successfully.
- If bootstrap succeeded and you have no task yet, stay silent and wait for task assignments.
- If bootstrap succeeded and you have no task, produce ZERO assistant text for that turn and end it immediately after the successful tool result.
- Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after bootstrap.
- Only SendMessage the lead after bootstrap when there is a real blocker, a failed bootstrap, an explicit question, an urgent coordination need, or a completed task result to report.
- Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence.
- When you later receive work or reconnect after a restart, use task_briefing as your compact queue view. Use task_get when you need the full task context before starting a pending/needsFix task or when the in_progress briefing details are not enough.
@ -1415,6 +1418,8 @@ ${actionModeProtocol}
After member_briefing succeeds:
- Do NOT send a "ready", "online", "status accepted", or other acknowledgement-only message just to confirm you reconnected successfully.
- If reconnect bootstrap succeeded and you have no immediate blocker or question, stay silent and continue with your queue.
- If reconnect bootstrap succeeded and you have no immediate blocker, question, or task, produce ZERO assistant text for that turn and end it immediately.
- Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after reconnect bootstrap.
- Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence.
- Use task_briefing as your compact queue view.
- If task_briefing shows any in_progress task, resume/finish those first. Call task_get only if you need more context than task_briefing already gave you.
@ -12441,6 +12446,7 @@ export class TeamProvisioningService {
providerId: TeamProviderId | undefined = 'anthropic'
): Promise<{ warning?: string }> {
const resolvedProviderId = resolveTeamProviderId(providerId);
const cliCommandLabel = getConfiguredCliCommandLabel();
try {
const versionProbe = await this.spawnProbe(
claudePath,
@ -12452,9 +12458,9 @@ export class TeamProvisioningService {
if (versionProbe.exitCode !== 0) {
const errorText =
buildCombinedLogs(versionProbe.stdout, versionProbe.stderr) ||
`Claude CLI exited with code ${versionProbe.exitCode ?? 'unknown'} during warm-up`;
`${cliCommandLabel} exited with code ${versionProbe.exitCode ?? 'unknown'} during warm-up`;
return {
warning: `Claude CLI binary failed to start correctly. Details: ${errorText}`,
warning: `${cliCommandLabel} binary failed to start correctly. Details: ${errorText}`,
};
}
} catch (error) {
@ -12465,7 +12471,7 @@ export class TeamProvisioningService {
};
}
return {
warning: `Claude CLI binary failed to start. Details: ${message}`,
warning: `${cliCommandLabel} binary failed to start. Details: ${message}`,
};
}
@ -12527,7 +12533,7 @@ export class TeamProvisioningService {
}
return {
warning:
'Preflight check for `claude -p` did not complete. ' +
`Preflight check for \`${cliCommandLabel} -p\` did not complete. ` +
`Proceeding anyway. Details: ${message}`,
};
}
@ -12548,13 +12554,15 @@ export class TeamProvisioningService {
const hint = isAuthFailure
? resolvedProviderId === 'codex'
? 'Codex provider is not authenticated for `-p` mode. ' +
'Run `claude-multimodel auth login --provider codex` and retry.' +
`Authenticate Codex in ${cliCommandLabel} and retry.` +
(attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
: 'Claude CLI `-p` mode is not authenticated. ' +
'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' +
: `${cliCommandLabel} \`-p\` mode is not authenticated. ` +
(cliCommandLabel === 'claude'
? 'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. '
: `Authenticate Anthropic in ${cliCommandLabel} and retry. `) +
'For automation/headless use, set ANTHROPIC_API_KEY.' +
(attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
: `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`;
: `${cliCommandLabel} preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`;
return { warning: hint };
}
@ -12595,7 +12603,7 @@ export class TeamProvisioningService {
const targetCwd = cwd ?? process.cwd();
const probeResult = await this.getCachedOrProbeResult(targetCwd, 'anthropic');
if (!probeResult?.claudePath) {
throw new Error('Claude CLI not found');
throw new Error(`${getConfiguredCliCommandLabel()} not found`);
}
const { env } = await this.buildProvisioningEnv();
const result = await this.spawnProbe(
@ -12608,7 +12616,7 @@ export class TeamProvisioningService {
const output = (result.stdout + '\n' + result.stderr).trim();
if (!output) {
throw new Error(
`claude --help returned empty output (exit code: ${String(result.exitCode)})`
`${getConfiguredCliCommandLabel()} --help returned empty output (exit code: ${String(result.exitCode)})`
);
}
this.helpOutputCache = output;
@ -12966,7 +12974,7 @@ export class TeamProvisioningService {
const timeoutHandle = setTimeout(() => {
settled = true;
killProcessTree(child);
reject(new Error(`Timeout running: claude ${args.join(' ')}`));
reject(new Error(`Timeout running: ${getConfiguredCliCommandLabel()} ${args.join(' ')}`));
}, timeoutMs);
const maybeResolveEarly = (): void => {

View file

@ -41,3 +41,17 @@ export function getCliFlavorUiOptions(flavor: CliFlavor): CliFlavorUiOptions {
};
}
}
export function getCliFlavorCommandLabel(flavor: CliFlavor): string {
switch (flavor) {
case 'agent_teams_orchestrator':
return 'orchestrator-cli';
case 'claude':
default:
return 'claude';
}
}
export function getConfiguredCliCommandLabel(): string {
return getCliFlavorCommandLabel(getConfiguredCliFlavor());
}

View file

@ -135,7 +135,7 @@ function summarizeDetail(
) {
return 'CLI binary could not be started';
}
if (lower.includes('preflight check for `claude -p` did not complete')) {
if (lower.includes('preflight check for `') && lower.includes('-p` did not complete')) {
return 'CLI preflight did not complete';
}
if (lower.includes('not authenticated') || lower.includes('not logged in')) {

View file

@ -232,6 +232,50 @@ function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string
return [...(result.details ?? []), ...(result.warnings ?? [])];
}
function extractTimedOutPreflightProbeModelId(detail: string): string | null {
const trimmed = detail.trim();
if (!trimmed) {
return null;
}
if (
!trimmed.toLowerCase().includes('preflight check for `') ||
!trimmed.toLowerCase().includes('-p` did not complete')
) {
return null;
}
const match = /--model\s+([^\s]+)/i.exec(trimmed);
return match?.[1]?.trim() || null;
}
function suppressSupersededRuntimeWarnings(params: {
runtimeDetailLines: string[];
runtimeWarnings: string[];
modelResultsById: Map<string, ProviderPrepareDiagnosticsModelResult>;
}): {
runtimeDetailLines: string[];
runtimeWarnings: string[];
} {
const suppressedEntries = new Set<string>();
for (const warning of params.runtimeWarnings) {
const probedModelId = extractTimedOutPreflightProbeModelId(warning);
if (!probedModelId) {
continue;
}
if (params.modelResultsById.get(probedModelId)?.status !== 'ready') {
continue;
}
suppressedEntries.add(warning);
}
return {
runtimeDetailLines: params.runtimeDetailLines.filter(
(detail) => !suppressedEntries.has(detail)
),
runtimeWarnings: params.runtimeWarnings.filter((warning) => !suppressedEntries.has(warning)),
};
}
function resolveModelResultFromBatch(
providerId: TeamProviderId,
modelId: string,
@ -351,7 +395,7 @@ export async function runProviderPrepareDiagnostics({
const modelLines = new Map<string, string>();
let completedCount = 0;
let hasFailure = false;
let hasNotes = runtimeWarnings.length > 0;
let hasNotes = false;
const modelWarnings: string[] = [];
for (const modelId of orderedModelIds) {
@ -436,7 +480,14 @@ export async function runProviderPrepareDiagnostics({
}
}
const dedupedWarnings = Array.from(new Set([...runtimeWarnings, ...modelWarnings]));
const filteredRuntime = suppressSupersededRuntimeWarnings({
runtimeDetailLines,
runtimeWarnings,
modelResultsById,
});
const dedupedWarnings = Array.from(
new Set([...filteredRuntime.runtimeWarnings, ...modelWarnings])
);
const selectedModelResultsById = Object.fromEntries(
orderedModelIds
.map((modelId) => [modelId, modelResultsById.get(modelId)] as const)
@ -446,9 +497,9 @@ export async function runProviderPrepareDiagnostics({
);
return {
status: hasFailure ? 'failed' : hasNotes ? 'notes' : 'ready',
status: hasFailure ? 'failed' : hasNotes || dedupedWarnings.length > 0 ? 'notes' : 'ready',
details: [
...runtimeDetailLines,
...filteredRuntime.runtimeDetailLines,
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
],
warnings: dedupedWarnings,

View file

@ -401,7 +401,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
});
vi.spyOn(svc as any, 'spawnProbe').mockRejectedValue(
new Error(
'Timeout running: claude -p Output only the single word PONG. --output-format text --model gpt-5.3-codex --max-turns 1 --no-session-persistence'
'Timeout running: orchestrator-cli -p Output only the single word PONG. --output-format text --model gpt-5.3-codex --max-turns 1 --no-session-persistence'
)
);
@ -417,6 +417,26 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
);
});
it('surfaces preflight timeouts with the orchestrator-cli label', async () => {
const svc = new TeamProvisioningService();
vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({
claudePath: '/fake/claude',
authSource: 'codex_runtime',
warning:
'Preflight check for `orchestrator-cli -p` did not complete. Proceeding anyway. Details: Timeout running: orchestrator-cli -p Output only the single word PONG. --output-format text --model gpt-5.4-mini --max-turns 1 --no-session-persistence',
});
const result = await svc.prepareForProvisioning(tempRoot, {
forceFresh: true,
providerId: 'codex',
});
expect(result.ready).toBe(true);
expect(result.warnings).toContain(
'Preflight check for `orchestrator-cli -p` did not complete. Proceeding anyway. Details: Timeout running: orchestrator-cli -p Output only the single word PONG. --output-format text --model gpt-5.4-mini --max-turns 1 --no-session-persistence'
);
});
it('maps ANTHROPIC_AUTH_TOKEN into ANTHROPIC_API_KEY for headless preflight', async () => {
const svc = new TeamProvisioningService();
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({

View file

@ -365,6 +365,20 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
);
});
it('add-member spawn prompt explicitly forbids no-task bootstrap chatter', () => {
const prompt = buildAddMemberSpawnMessage('my-team', 'My Team', 'team-lead', {
name: 'alice',
role: 'developer',
});
expect(prompt).toContain(
'If bootstrap succeeded and you have no task, produce ZERO assistant text for that turn and end it immediately after the successful tool result.'
);
expect(prompt).toContain(
'Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after bootstrap.'
);
});
it('launchTeam hydration prompt includes task-comment handling guidance by default', async () => {
const teamName = 'forward-live-team';
const teamDir = path.join(tempTeamsBase, teamName);
@ -502,7 +516,6 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(prompt).toContain(
'Correct flow: finish implementation on #X -> task_complete #X -> review_request #X -> reviewer runs review_start #X -> reviewer runs review_approve or review_request_changes on #X.'
);
await svc.cancelProvisioning(runId);
});

View file

@ -128,4 +128,36 @@ describe('ProvisioningProviderStatusList', () => {
await Promise.resolve();
});
});
it('normalizes generic preflight timeout notes without depending on a hardcoded CLI name', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(ProvisioningProviderStatusList, {
checks: [
{
providerId: 'codex',
status: 'notes',
backendSummary: 'Default adapter',
details: [
'Preflight check for `orchestrator-cli -p` did not complete. Proceeding anyway. Details: Timeout running: orchestrator-cli -p Output only the single word PONG. --output-format text --model gpt-5.4-mini --max-turns 1 --no-session-persistence',
],
},
],
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('Codex (Default adapter): CLI preflight did not complete');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -196,7 +196,7 @@ describe('runProviderPrepareDiagnostics', () => {
ready: true,
message: 'CLI is warmed up and ready to launch',
warnings: [
'Selected model gpt-5.3-codex could not be verified. Timeout running: claude -p Output only the single word PONG. --output-format text --model gpt-5.3-codex --max-turns 1 --no-session-persistence',
'Selected model gpt-5.3-codex could not be verified. Timeout running: orchestrator-cli -p Output only the single word PONG. --output-format text --model gpt-5.3-codex --max-turns 1 --no-session-persistence',
],
});
});
@ -372,4 +372,45 @@ describe('runProviderPrepareDiagnostics', () => {
'gpt-5.2-codex',
], undefined);
});
it('suppresses a timed out runtime preflight note when that same model later verifies', async () => {
const prepareProvisioning = vi.fn<
(
cwd?: string,
providerId?: 'anthropic' | 'codex' | 'gemini',
providerIds?: ('anthropic' | 'codex' | 'gemini')[],
selectedModels?: string[]
) => Promise<TeamProvisioningPrepareResult>
>((_, __, ___, selectedModels) => {
if (!selectedModels || selectedModels.length === 0) {
return Promise.resolve({
ready: true,
message: 'CLI is ready to launch (see notes)',
warnings: [
'Preflight check for `orchestrator-cli -p` did not complete. Proceeding anyway. Details: Timeout running: orchestrator-cli -p Output only the single word PONG. --output-format text --model gpt-5.4-mini --max-turns 1 --no-session-persistence',
],
});
}
return Promise.resolve({
ready: true,
message: 'CLI is warmed up and ready to launch',
details: [
'Selected model gpt-5.4-mini verified for launch.',
'Selected model gpt-5.4 verified for launch.',
],
});
});
const result = await runProviderPrepareDiagnostics({
cwd: '/tmp/project',
providerId: 'codex',
selectedModelIds: ['gpt-5.4-mini', 'gpt-5.4'],
prepareProvisioning,
});
expect(result.status).toBe('ready');
expect(result.warnings).toEqual([]);
expect(result.details).toEqual(['5.4 Mini - verified', '5.4 - verified']);
});
});