fix: support twenty-member deterministic bootstrap
This commit is contained in:
parent
bf3011624d
commit
e22589f6c4
6 changed files with 180 additions and 53 deletions
|
|
@ -95,6 +95,9 @@ export CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH=/Users/belief/dev/projects/claud
|
|||
|
||||
The source launcher executes `src/entrypoints/cli.tsx` through Bun. It is the right default for local
|
||||
debug loops, live model/provider checks, and cross-repo runtime fixes.
|
||||
Source-mode teammate startup can be slower than bundled startup, so live smoke harnesses may set
|
||||
`CLAUDE_TEAM_PROCESS_RUNTIME_READY_TIMEOUT_MS` to a larger value when they are validating source
|
||||
behavior instead of watchdog latency.
|
||||
|
||||
Release or production-like smoke checks must validate the built wrapper:
|
||||
|
||||
|
|
|
|||
|
|
@ -1840,7 +1840,7 @@ function mergeProvisioningWarnings(
|
|||
}
|
||||
|
||||
const DETERMINISTIC_BOOTSTRAP_LARGE_TEAM_WARNING_THRESHOLD = 8;
|
||||
const DETERMINISTIC_BOOTSTRAP_MAX_PRIMARY_MEMBERS = 16;
|
||||
const DETERMINISTIC_BOOTSTRAP_MAX_PRIMARY_MEMBERS = 20;
|
||||
|
||||
function buildLargeDeterministicBootstrapWarning(memberCount: number): string | null {
|
||||
if (memberCount <= DETERMINISTIC_BOOTSTRAP_LARGE_TEAM_WARNING_THRESHOLD) {
|
||||
|
|
@ -4552,7 +4552,7 @@ interface RuntimeBootstrapSpec {
|
|||
|
||||
const DETERMINISTIC_BOOTSTRAP_MIN_TIMEOUT_MS = 120_000;
|
||||
const DETERMINISTIC_BOOTSTRAP_TIMEOUT_PER_MEMBER_MS = 60_000;
|
||||
const DETERMINISTIC_BOOTSTRAP_MAX_TIMEOUT_MS = 300_000;
|
||||
const DETERMINISTIC_BOOTSTRAP_MAX_TIMEOUT_MS = 600_000;
|
||||
const DETERMINISTIC_BOOTSTRAP_OUTER_TIMEOUT_GRACE_MS = 30_000;
|
||||
|
||||
function getDeterministicBootstrapTimeoutMs(memberCount: number): number {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ export interface NativeAppManagedBootstrapBuildResult {
|
|||
const MAX_NATIVE_BOOTSTRAP_BRIEFING_CHARS = 18_000;
|
||||
const MAX_NATIVE_BOOTSTRAP_CONTEXT_CHARS = 24_000;
|
||||
export const MAX_NATIVE_BOOTSTRAP_TOTAL_CONTEXT_CHARS = 256_000;
|
||||
const MIN_NATIVE_BOOTSTRAP_CONTEXT_CHARS = 6_000;
|
||||
const NATIVE_BOOTSTRAP_COMPACT_ROSTER_MEMBER_COUNT = 10;
|
||||
const NATIVE_BOOTSTRAP_SPEC_JSON_OVERHEAD_MIN_CHARS = 64_000;
|
||||
const NATIVE_BOOTSTRAP_SPEC_JSON_OVERHEAD_PER_MEMBER_CHARS = 4_000;
|
||||
const NATIVE_BOOTSTRAP_LARGE_ROSTER_MEMBER_COUNT = 7;
|
||||
const NATIVE_BOOTSTRAP_NEAR_LIMIT_RATIO = 0.85;
|
||||
|
||||
|
|
@ -72,6 +76,7 @@ function buildContextText(params: {
|
|||
providerId?: TeamProviderId;
|
||||
cwd: string;
|
||||
briefing: string;
|
||||
maxContextChars?: number;
|
||||
}): string {
|
||||
const briefing = boundText(
|
||||
redactNativeBootstrapContextText(params.briefing),
|
||||
|
|
@ -90,7 +95,39 @@ function buildContextText(params: {
|
|||
'</member_briefing_context_data>',
|
||||
'</agent_teams_native_bootstrap_context>',
|
||||
].join('\n'),
|
||||
MAX_NATIVE_BOOTSTRAP_CONTEXT_CHARS
|
||||
params.maxContextChars ?? MAX_NATIVE_BOOTSTRAP_CONTEXT_CHARS
|
||||
);
|
||||
}
|
||||
|
||||
function shouldUseCompactNativeBootstrapContext(nativeMemberCount: number): boolean {
|
||||
if (nativeMemberCount >= NATIVE_BOOTSTRAP_COMPACT_ROSTER_MEMBER_COUNT) {
|
||||
return true;
|
||||
}
|
||||
const reservedSpecOverhead = Math.max(
|
||||
NATIVE_BOOTSTRAP_SPEC_JSON_OVERHEAD_MIN_CHARS,
|
||||
nativeMemberCount * NATIVE_BOOTSTRAP_SPEC_JSON_OVERHEAD_PER_MEMBER_CHARS
|
||||
);
|
||||
return (
|
||||
nativeMemberCount * MAX_NATIVE_BOOTSTRAP_CONTEXT_CHARS >
|
||||
MAX_NATIVE_BOOTSTRAP_TOTAL_CONTEXT_CHARS - reservedSpecOverhead
|
||||
);
|
||||
}
|
||||
|
||||
function getNativeBootstrapContextCharLimit(nativeMemberCount: number): number {
|
||||
if (!shouldUseCompactNativeBootstrapContext(nativeMemberCount)) {
|
||||
return MAX_NATIVE_BOOTSTRAP_CONTEXT_CHARS;
|
||||
}
|
||||
const reservedSpecOverhead = Math.max(
|
||||
NATIVE_BOOTSTRAP_SPEC_JSON_OVERHEAD_MIN_CHARS,
|
||||
nativeMemberCount * NATIVE_BOOTSTRAP_SPEC_JSON_OVERHEAD_PER_MEMBER_CHARS
|
||||
);
|
||||
const aggregateContextBudget = Math.max(
|
||||
MIN_NATIVE_BOOTSTRAP_CONTEXT_CHARS * nativeMemberCount,
|
||||
MAX_NATIVE_BOOTSTRAP_TOTAL_CONTEXT_CHARS - reservedSpecOverhead
|
||||
);
|
||||
return Math.min(
|
||||
MAX_NATIVE_BOOTSTRAP_CONTEXT_CHARS,
|
||||
Math.floor(aggregateContextBudget / Math.max(1, nativeMemberCount))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -162,6 +199,63 @@ function buildLocalNativeMemberBriefing(params: {
|
|||
.join('\n');
|
||||
}
|
||||
|
||||
function formatCompactField(label: string, value: string | undefined, maxChars: number): string {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
return `${label}: ${boundText(redactNativeBootstrapContextText(trimmed), maxChars)}`;
|
||||
}
|
||||
|
||||
async function readCompactTaskBriefing(params: {
|
||||
controller: ReturnType<typeof createController>;
|
||||
memberName: string;
|
||||
}): Promise<string> {
|
||||
try {
|
||||
return String(await params.controller.tasks.taskBriefing(params.memberName));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return `Task briefing unavailable during startup context generation: ${message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function buildCompactNativeMemberBriefing(params: {
|
||||
teamName: string;
|
||||
cwd: string;
|
||||
providerId?: TeamProviderId;
|
||||
member: TeamCreateRequest['members'][number];
|
||||
taskBriefing: string;
|
||||
maxContextChars: number;
|
||||
}): string {
|
||||
const member = params.member;
|
||||
const taskBriefingLimit = Math.max(1_200, Math.floor(params.maxContextChars * 0.55));
|
||||
return [
|
||||
`You are ${member.name}, a teammate in team ${params.teamName}.`,
|
||||
`Provider: ${params.providerId ?? 'anthropic'}`,
|
||||
`Project: ${member.cwd?.trim() || params.cwd}`,
|
||||
formatCompactField('Role', member.role, 700),
|
||||
formatCompactField('Workflow', member.workflow, 1_200),
|
||||
formatCompactField('Model', member.model, 300),
|
||||
formatCompactField('Effort', member.effort, 100),
|
||||
'',
|
||||
'The app loaded compact startup context for a large native team.',
|
||||
'',
|
||||
'Startup rules:',
|
||||
'- This bootstrap turn is private. Reply locally once, then stop and wait for real work.',
|
||||
'- Do not call member_briefing for launch readiness in this flow.',
|
||||
'- Do not send messages to the user, lead, or teammates during bootstrap.',
|
||||
'- Before real task work, use task_briefing as your working queue.',
|
||||
'- Start real work only from an assigned task or a direct app-delivered instruction.',
|
||||
'- Post durable task results as task comments before completing tasks.',
|
||||
'- Ask the lead when blocked instead of guessing.',
|
||||
'',
|
||||
'Current task briefing:',
|
||||
boundText(redactNativeBootstrapContextText(params.taskBriefing), taskBriefingLimit),
|
||||
]
|
||||
.filter((line) => line.length > 0)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export async function buildNativeAppManagedBootstrapSpecs(params: {
|
||||
teamName: string;
|
||||
cwd: string;
|
||||
|
|
@ -182,39 +276,52 @@ export async function buildNativeAppManagedBootstrapSpecsWithDiagnostics(params:
|
|||
});
|
||||
const result = new Map<string, NativeAppManagedBootstrapSpec>();
|
||||
let totalContextChars = 0;
|
||||
let nativeMemberCount = 0;
|
||||
|
||||
for (const member of params.members) {
|
||||
const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? 'anthropic';
|
||||
if (!isNativeAppManagedBootstrapProvider(providerId)) {
|
||||
continue;
|
||||
}
|
||||
nativeMemberCount += 1;
|
||||
const nativeMembers = params.members
|
||||
.map((member) => ({
|
||||
member,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId) ?? 'anthropic',
|
||||
}))
|
||||
.filter(({ providerId }) => isNativeAppManagedBootstrapProvider(providerId));
|
||||
const nativeMemberCount = nativeMembers.length;
|
||||
const compactContext = shouldUseCompactNativeBootstrapContext(nativeMemberCount);
|
||||
const maxContextChars = getNativeBootstrapContextCharLimit(nativeMemberCount);
|
||||
|
||||
for (const { member, providerId } of nativeMembers) {
|
||||
let briefing: string;
|
||||
try {
|
||||
briefing = String(
|
||||
await controller.tasks.memberBriefing(member.name, {
|
||||
runtimeProvider: 'native',
|
||||
includeActiveProcesses: false,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes('Member not found in team metadata or inboxes')) {
|
||||
throw error;
|
||||
}
|
||||
// In createTeam, the orchestrator's canonical config/inboxes may not
|
||||
// exist until after the lead process runs. Fail-closed would break team
|
||||
// creation, so use bounded request metadata while keeping readiness tied
|
||||
// to the private bootstrap proof, never to this context load.
|
||||
briefing = buildLocalNativeMemberBriefing({
|
||||
if (compactContext) {
|
||||
briefing = buildCompactNativeMemberBriefing({
|
||||
teamName: params.teamName,
|
||||
cwd: params.cwd,
|
||||
providerId,
|
||||
member,
|
||||
unavailableReason: message,
|
||||
taskBriefing: await readCompactTaskBriefing({ controller, memberName: member.name }),
|
||||
maxContextChars,
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
briefing = String(
|
||||
await controller.tasks.memberBriefing(member.name, {
|
||||
runtimeProvider: 'native',
|
||||
includeActiveProcesses: false,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes('Member not found in team metadata or inboxes')) {
|
||||
throw error;
|
||||
}
|
||||
// In createTeam, the orchestrator's canonical config/inboxes may not
|
||||
// exist until after the lead process runs. Fail-closed would break team
|
||||
// creation, so use bounded request metadata while keeping readiness tied
|
||||
// to the private bootstrap proof, never to this context load.
|
||||
briefing = buildLocalNativeMemberBriefing({
|
||||
teamName: params.teamName,
|
||||
cwd: params.cwd,
|
||||
providerId,
|
||||
member,
|
||||
unavailableReason: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
const boundedBriefing = boundText(
|
||||
redactNativeBootstrapContextText(briefing),
|
||||
|
|
@ -229,6 +336,7 @@ export async function buildNativeAppManagedBootstrapSpecsWithDiagnostics(params:
|
|||
providerId,
|
||||
cwd: member.cwd?.trim() || params.cwd,
|
||||
briefing: boundedBriefing,
|
||||
maxContextChars,
|
||||
});
|
||||
totalContextChars += contextText.length;
|
||||
if (totalContextChars > MAX_NATIVE_BOOTSTRAP_TOTAL_CONTEXT_CHARS) {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ liveDescribe('Anthropic launch selection live e2e', () => {
|
|||
let previousAnthropicAuthToken: string | undefined;
|
||||
let previousDisableAppBootstrap: string | undefined;
|
||||
let previousDisableRuntimeBootstrap: string | undefined;
|
||||
let previousRuntimeReadyTimeout: string | undefined;
|
||||
let previousClaudeJsonConfig: string | null | undefined;
|
||||
let svc: TeamProvisioningService | null;
|
||||
let teamName: string | null;
|
||||
|
|
@ -98,10 +99,13 @@ liveDescribe('Anthropic launch selection live e2e', () => {
|
|||
previousAnthropicAuthToken = process.env.ANTHROPIC_AUTH_TOKEN;
|
||||
previousDisableAppBootstrap = process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
|
||||
previousDisableRuntimeBootstrap = process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
|
||||
previousRuntimeReadyTimeout = process.env.CLAUDE_TEAM_PROCESS_RUNTIME_READY_TIMEOUT_MS;
|
||||
|
||||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH =
|
||||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
|
||||
process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator';
|
||||
process.env.CLAUDE_TEAM_PROCESS_RUNTIME_READY_TIMEOUT_MS =
|
||||
process.env.CLAUDE_TEAM_PROCESS_RUNTIME_READY_TIMEOUT_MS?.trim() || '90000';
|
||||
process.env.HOME = subscriptionAuth ? os.userInfo().homedir : tempHome;
|
||||
process.env.USERPROFILE = subscriptionAuth ? os.userInfo().homedir : tempHome;
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
|
@ -117,6 +121,7 @@ liveDescribe('Anthropic launch selection live e2e', () => {
|
|||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const preserveArtifacts = process.env.ANTHROPIC_LAUNCH_SELECTION_KEEP_TEMP === '1';
|
||||
const beforeStopSnapshot = svc && teamName ? await safeRuntimeSnapshot(svc, teamName) : null;
|
||||
if (svc && teamName) {
|
||||
await svc.stopTeam(teamName).catch(() => undefined);
|
||||
|
|
@ -125,10 +130,10 @@ liveDescribe('Anthropic launch selection live e2e', () => {
|
|||
const afterStopSnapshot = svc && teamName ? await safeRuntimeSnapshot(svc, teamName) : null;
|
||||
await terminateSmokeOwnedProcessBackends(afterStopSnapshot);
|
||||
|
||||
if (subscriptionAuth && projectPath) {
|
||||
if (!preserveArtifacts && subscriptionAuth && projectPath) {
|
||||
await removeClaudeProjectArtifacts(tempClaudeRoot, projectPath);
|
||||
}
|
||||
if (subscriptionAuth && teamName) {
|
||||
if (!preserveArtifacts && subscriptionAuth && teamName) {
|
||||
await removeTeamArtifacts(teamName);
|
||||
}
|
||||
if (subscriptionAuth && previousClaudeJsonConfig !== undefined) {
|
||||
|
|
@ -145,25 +150,26 @@ liveDescribe('Anthropic launch selection live e2e', () => {
|
|||
restoreEnv('ANTHROPIC_AUTH_TOKEN', previousAnthropicAuthToken);
|
||||
restoreEnv('CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableAppBootstrap);
|
||||
restoreEnv('CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableRuntimeBootstrap);
|
||||
restoreEnv('CLAUDE_TEAM_PROCESS_RUNTIME_READY_TIMEOUT_MS', previousRuntimeReadyTimeout);
|
||||
|
||||
if (process.env.ANTHROPIC_LAUNCH_SELECTION_KEEP_TEMP === '1') {
|
||||
if (preserveArtifacts) {
|
||||
process.stderr.write(`[AnthropicLaunchSelection.live] preserved temp dir: ${tempDir}\n`);
|
||||
} else {
|
||||
await removeTempDirWithRetries(tempDir);
|
||||
}
|
||||
if (subscriptionAuth && projectPath) {
|
||||
if (!preserveArtifacts && subscriptionAuth && projectPath) {
|
||||
await removeClaudeProjectArtifacts(tempClaudeRoot, projectPath);
|
||||
}
|
||||
if (subscriptionAuth && teamName) {
|
||||
if (!preserveArtifacts && subscriptionAuth && teamName) {
|
||||
await removeTeamArtifacts(teamName);
|
||||
}
|
||||
if (subscriptionAuth && (projectPath || teamName)) {
|
||||
if (!preserveArtifacts && subscriptionAuth && (projectPath || teamName)) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10_000));
|
||||
}
|
||||
if (subscriptionAuth && projectPath) {
|
||||
if (!preserveArtifacts && subscriptionAuth && projectPath) {
|
||||
await removeClaudeProjectArtifacts(tempClaudeRoot, projectPath);
|
||||
}
|
||||
if (subscriptionAuth && teamName) {
|
||||
if (!preserveArtifacts && subscriptionAuth && teamName) {
|
||||
await removeTeamArtifacts(teamName);
|
||||
}
|
||||
discardKnownAnthropicLaunchSelectionWarnings();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { mkdtemp, rm } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
|
|
@ -135,7 +134,7 @@ describe('NativeAppManagedBootstrapContextBuilder', () => {
|
|||
expect(result.diagnostics.warning).toMatch(/Large native team startup context/);
|
||||
});
|
||||
|
||||
it('fails closed when aggregate native context budget is exceeded', async () => {
|
||||
it('compacts twenty large native contexts within the aggregate budget', async () => {
|
||||
const hugeRole = 'x'.repeat(40_000);
|
||||
await new TeamMetaStore().writeMeta('large-native-team', {
|
||||
cwd: '/tmp/workspace',
|
||||
|
|
@ -145,23 +144,34 @@ describe('NativeAppManagedBootstrapContextBuilder', () => {
|
|||
});
|
||||
await new TeamMembersMetaStore().writeMembers(
|
||||
'large-native-team',
|
||||
Array.from({ length: 16 }, (_, index) => ({
|
||||
Array.from({ length: 20 }, (_, index) => ({
|
||||
name: `member-${index}`,
|
||||
providerId: 'anthropic' as const,
|
||||
role: hugeRole,
|
||||
}))
|
||||
);
|
||||
|
||||
await expect(
|
||||
buildNativeAppManagedBootstrapSpecs({
|
||||
teamName: 'large-native-team',
|
||||
cwd: '/tmp/workspace',
|
||||
members: Array.from({ length: 16 }, (_, index) => ({
|
||||
name: `member-${index}`,
|
||||
providerId: 'anthropic' as const,
|
||||
role: hugeRole,
|
||||
})),
|
||||
})
|
||||
).rejects.toThrow(/aggregate size budget/);
|
||||
const result = await buildNativeAppManagedBootstrapSpecsWithDiagnostics({
|
||||
teamName: 'large-native-team',
|
||||
cwd: '/tmp/workspace',
|
||||
members: Array.from({ length: 20 }, (_, index) => ({
|
||||
name: `member-${index}`,
|
||||
providerId: 'anthropic' as const,
|
||||
role: hugeRole,
|
||||
})),
|
||||
});
|
||||
const totalContextChars = [...result.specs.values()].reduce(
|
||||
(sum, spec) => sum + spec.contextText.length,
|
||||
0
|
||||
);
|
||||
const firstContext = result.specs.get('member-0')?.contextText ?? '';
|
||||
|
||||
expect(result.specs.size).toBe(20);
|
||||
expect(totalContextChars).toBeLessThanOrEqual(MAX_NATIVE_BOOTSTRAP_TOTAL_CONTEXT_CHARS);
|
||||
expect(firstContext).toContain('The app loaded compact startup context');
|
||||
expect(firstContext).toContain('Startup rules:');
|
||||
expect(firstContext).toContain('Current task briefing:');
|
||||
expect(firstContext).toContain('[truncated native bootstrap context]');
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18279,14 +18279,14 @@ describe('TeamProvisioningService', () => {
|
|||
|
||||
it('fails before spawning when deterministic launch exceeds the current primary teammate cap', async () => {
|
||||
allowConsoleLogs();
|
||||
const members = Array.from({ length: 17 }, (_, index) => `member-${index + 1}`);
|
||||
const members = Array.from({ length: 21 }, (_, index) => `member-${index + 1}`);
|
||||
|
||||
await expect(
|
||||
startDeterministicLaunchCloseHarness({
|
||||
teamName: 'launch-too-many-primary-members',
|
||||
members,
|
||||
})
|
||||
).rejects.toThrow(/up to 16 primary teammates/);
|
||||
).rejects.toThrow(/up to 20 primary teammates/);
|
||||
expect(spawnCli).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue