diff --git a/docs/team-management/debugging-agent-teams.md b/docs/team-management/debugging-agent-teams.md index 11dcde51..2015feac 100644 --- a/docs/team-management/debugging-agent-teams.md +++ b/docs/team-management/debugging-agent-teams.md @@ -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: diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 5ff7d9da..60491a3d 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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 { diff --git a/src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder.ts b/src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder.ts index 3ffa0367..3d71e714 100644 --- a/src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder.ts +++ b/src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder.ts @@ -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: { '', '', ].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; + memberName: string; +}): Promise { + 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(); 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) { diff --git a/test/main/services/team/AnthropicLaunchSelection.live.test.ts b/test/main/services/team/AnthropicLaunchSelection.live.test.ts index dc4fa756..876b10ac 100644 --- a/test/main/services/team/AnthropicLaunchSelection.live.test.ts +++ b/test/main/services/team/AnthropicLaunchSelection.live.test.ts @@ -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(); diff --git a/test/main/services/team/NativeAppManagedBootstrapContextBuilder.test.ts b/test/main/services/team/NativeAppManagedBootstrapContextBuilder.test.ts index 51901da6..3c2da998 100644 --- a/test/main/services/team/NativeAppManagedBootstrapContextBuilder.test.ts +++ b/test/main/services/team/NativeAppManagedBootstrapContextBuilder.test.ts @@ -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]'); }); + }); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 2a4fbbd5..5659a1a7 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -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(); });