mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
fix(server): start a disconnected team lead on approval and explain Windows Bun startup crashes (#1479)
After an app restart the lead's CLI is gone, and approving a reviewed team plan failed with "Team leader must be connected before launching" on every retry, leaving a new session as the only way out. Approval now starts the lead first; if it cannot start, the plan stays reviewable and nothing launches. A Windows Bun that crashes before running any code (JSC init failing to commit memory) now reports that the system commit limit is likely used up, with the original crash output kept. Fixes #1475
This commit is contained in:
@@ -17,6 +17,8 @@ describe('ConversationService startup output', () => {
|
|||||||
'CLAUDE_CONFIG_DIR',
|
'CLAUDE_CONFIG_DIR',
|
||||||
'CC_HAHA_DISABLE_TERMINAL_SHELL_ENV',
|
'CC_HAHA_DISABLE_TERMINAL_SHELL_ENV',
|
||||||
'MOCK_SDK_STARTUP_STDOUT',
|
'MOCK_SDK_STARTUP_STDOUT',
|
||||||
|
'MOCK_SDK_STARTUP_STDERR',
|
||||||
|
'MOCK_SDK_STARTUP_EXIT_CODE',
|
||||||
]
|
]
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -64,4 +66,55 @@ describe('ConversationService startup output', () => {
|
|||||||
'CLI exited during startup (code 1): provider rejected request: invalid model id',
|
'CLI exited during startup (code 1): provider rejected request: invalid model id',
|
||||||
)
|
)
|
||||||
}, 10_000)
|
}, 10_000)
|
||||||
|
|
||||||
|
// Issue #1475: a team member's Bun died in JSC initialization because the
|
||||||
|
// Windows commit limit was used up; only the raw crash report was shown.
|
||||||
|
const windowsBunCrash = [
|
||||||
|
'============================================================',
|
||||||
|
'Bun v1.3.14 (0d9b296a) Windows x64 (baseline)',
|
||||||
|
'Windows v.win11_dt',
|
||||||
|
'CPU: sse42 avx avx2',
|
||||||
|
'Features: jsc',
|
||||||
|
'Elapsed: 689ms | User: 0ms | Sys: 46ms',
|
||||||
|
'RSS: 59.50MB | Peak: 59.50MB | Commit: 52.00MB | Faults: 14677',
|
||||||
|
'panic(main thread): Illegal instruction at address 0x7FF7A70604F0',
|
||||||
|
'oh no: Bun has crashed. This indicates a bug in Bun, not your code.',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const startupFailure = async (): Promise<Error> => {
|
||||||
|
try {
|
||||||
|
await service.startSession(
|
||||||
|
`startup-output-${crypto.randomUUID()}`,
|
||||||
|
tmpDir,
|
||||||
|
'ws://127.0.0.1:1/sdk/startup-output?token=test-token',
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
return error as Error
|
||||||
|
}
|
||||||
|
throw new Error('startup unexpectedly succeeded')
|
||||||
|
}
|
||||||
|
|
||||||
|
test('explains a Windows Bun startup crash as exhausted commit memory and keeps the report', async () => {
|
||||||
|
delete process.env.MOCK_SDK_STARTUP_STDOUT
|
||||||
|
process.env.MOCK_SDK_STARTUP_STDERR = windowsBunCrash
|
||||||
|
process.env.MOCK_SDK_STARTUP_EXIT_CODE = '3'
|
||||||
|
|
||||||
|
const error = await startupFailure()
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(ConversationStartupError)
|
||||||
|
expect(error).toMatchObject({ code: 'CLI_START_FAILED' })
|
||||||
|
expect(error.message).toStartWith('CLI runtime crashed during startup (code 3). On Windows this usually means the system has run out of commit memory (RAM + page file).')
|
||||||
|
expect(error.message).toContain('Illegal instruction at address 0x7FF7A70604F0')
|
||||||
|
}, 10_000)
|
||||||
|
|
||||||
|
test('leaves a Bun crash on other platforms as a plain startup exit', async () => {
|
||||||
|
delete process.env.MOCK_SDK_STARTUP_STDOUT
|
||||||
|
process.env.MOCK_SDK_STARTUP_STDERR = windowsBunCrash.replace(/Windows[^\n]*/g, 'macOS arm64')
|
||||||
|
process.env.MOCK_SDK_STARTUP_EXIT_CODE = '3'
|
||||||
|
|
||||||
|
const error = await startupFailure()
|
||||||
|
|
||||||
|
expect(error.message).toStartWith('CLI exited during startup (code 3): ')
|
||||||
|
expect(error.message).not.toContain('commit memory')
|
||||||
|
}, 10_000)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
const startupStdout = process.env.MOCK_SDK_STARTUP_STDOUT || ''
|
const startupStdout = process.env.MOCK_SDK_STARTUP_STDOUT || ''
|
||||||
|
const startupStderr = process.env.MOCK_SDK_STARTUP_STDERR || ''
|
||||||
|
|
||||||
if (startupStdout) {
|
function write(stream: NodeJS.WriteStream, text: string): Promise<void> {
|
||||||
await new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
process.stdout.write(`${startupStdout}\n`, (error) => {
|
stream.write(`${text}\n`, (error) => {
|
||||||
if (error) reject(error)
|
if (error) reject(error)
|
||||||
else resolve()
|
else resolve()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
process.exitCode = 1
|
if (startupStdout) await write(process.stdout, startupStdout)
|
||||||
|
if (startupStderr) await write(process.stderr, startupStderr)
|
||||||
|
|
||||||
|
process.exitCode = Number(process.env.MOCK_SDK_STARTUP_EXIT_CODE || 1)
|
||||||
|
|||||||
@@ -25,10 +25,16 @@ test('HTTP plan review edits only allocation, handles stale clients and resumes
|
|||||||
process.env.CLAUDE_CONFIG_DIR = root
|
process.env.CLAUDE_CONFIG_DIR = root
|
||||||
let release!: () => void
|
let release!: () => void
|
||||||
const barrier = new Promise<void>(resolve => { release = resolve })
|
const barrier = new Promise<void>(resolve => { release = resolve })
|
||||||
|
const startedLeaders: string[] = []
|
||||||
const runtime = new TeamPlanService({ validate: async plan => plan, launch: async () => { await barrier; return { memberIds: { worker: 'session-child' } } }, stop: async () => {} })
|
const runtime = new TeamPlanService({ validate: async plan => plan, launch: async () => { await barrier; return { memberIds: { worker: 'session-child' } } }, stop: async () => {} })
|
||||||
const spies = [
|
const spies = [
|
||||||
spyOn(teamPlanService, 'getForSession').mockImplementation(id => runtime.getForSession(id)),
|
spyOn(teamPlanService, 'getForSession').mockImplementation(id => runtime.getForSession(id)),
|
||||||
spyOn(teamPlanService, 'approve').mockImplementation((name, action) => runtime.approve(name, action)),
|
// The route must hand approval a way to start a disconnected lead; the
|
||||||
|
// fixture records the start instead of spawning a CLI.
|
||||||
|
spyOn(teamPlanService, 'approve').mockImplementation((name, action, options) => {
|
||||||
|
expect(typeof options?.startLeader).toBe('function')
|
||||||
|
return runtime.approve(name, action, { startLeader: async id => { startedLeaders.push(id) } })
|
||||||
|
}),
|
||||||
spyOn(teamPlanService, 'action').mockImplementation((name, kind, action) => runtime.action(name, kind, action)),
|
spyOn(teamPlanService, 'action').mockImplementation((name, kind, action) => runtime.action(name, kind, action)),
|
||||||
]
|
]
|
||||||
const request = async (method: string, pathname: string, body?: unknown) => {
|
const request = async (method: string, pathname: string, body?: unknown) => {
|
||||||
@@ -57,6 +63,7 @@ test('HTTP plan review edits only allocation, handles stale clients and resumes
|
|||||||
const approveBody = { ...identity(pending), requestId: 'approve' }
|
const approveBody = { ...identity(pending), requestId: 'approve' }
|
||||||
expect((await request('POST', '/api/teams/review/plan/approve', approveBody)).status).toBe(200)
|
expect((await request('POST', '/api/teams/review/plan/approve', approveBody)).status).toBe(200)
|
||||||
expect((await request('POST', '/api/teams/review/plan/approve', approveBody)).status).toBe(200)
|
expect((await request('POST', '/api/teams/review/plan/approve', approveBody)).status).toBe(200)
|
||||||
|
expect(startedLeaders).toEqual(['leader'])
|
||||||
release()
|
release()
|
||||||
for (let i = 0; i < 100 && (await readTeamPlan('review'))?.state !== 'running'; i++) await Bun.sleep(5)
|
for (let i = 0; i < 100 && (await readTeamPlan('review'))?.state !== 'running'; i++) await Bun.sleep(5)
|
||||||
const resumed = await request('GET', '/api/teams/session/leader/plan')
|
const resumed = await request('GET', '/api/teams/session/leader/plan')
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ import { teamPlanActionSchema, teamPlanPatchRequestSchema, teamPlanService } fro
|
|||||||
import { TeamPlanError } from '../../utils/swarm/teamPlanStore.js'
|
import { TeamPlanError } from '../../utils/swarm/teamPlanStore.js'
|
||||||
import { teamService } from '../services/teamService.js'
|
import { teamService } from '../services/teamService.js'
|
||||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||||
|
import { ensureCliSessionStartedForControl } from '../ws/handler.js'
|
||||||
|
|
||||||
export async function handleTeamsApi(
|
export async function handleTeamsApi(
|
||||||
req: Request,
|
req: Request,
|
||||||
_url: URL,
|
requestUrl: URL,
|
||||||
segments: string[],
|
segments: string[],
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
@@ -42,7 +43,11 @@ export async function handleTeamsApi(
|
|||||||
const parsed = teamPlanActionSchema.safeParse(raw)
|
const parsed = teamPlanActionSchema.safeParse(raw)
|
||||||
if (!parsed.success) throw ApiError.badRequest('Invalid team plan action')
|
if (!parsed.success) throw ApiError.badRequest('Invalid team plan action')
|
||||||
const action = segments[4]
|
const action = segments[4]
|
||||||
if (action === 'approve') return Response.json({ plan: await teamPlanService.approve(teamName, parsed.data) })
|
if (action === 'approve') {
|
||||||
|
return Response.json({ plan: await teamPlanService.approve(teamName, parsed.data, {
|
||||||
|
startLeader: sessionId => ensureCliSessionStartedForControl(sessionId, requestUrl),
|
||||||
|
}) })
|
||||||
|
}
|
||||||
if (action === 'return' || action === 'cancel' || action === 'retry') return Response.json({ plan: await teamPlanService.action(teamName, action, parsed.data) })
|
if (action === 'return' || action === 'cancel' || action === 'retry') return Response.json({ plan: await teamPlanService.action(teamName, action, parsed.data) })
|
||||||
throw ApiError.badRequest('Unknown team plan action')
|
throw ApiError.badRequest('Unknown team plan action')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2533,6 +2533,17 @@ export class ConversationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalizedDetail = detail.trim()
|
const normalizedDetail = detail.trim()
|
||||||
|
// A Windows Bun that crashes before running any code almost always failed
|
||||||
|
// to commit memory for JavaScriptCore: the machine's commit limit (RAM +
|
||||||
|
// page file) is used up, which every extra CLI process (e.g. each team
|
||||||
|
// member) brings closer. The raw crash report alone points at Bun instead.
|
||||||
|
if (/Bun has crashed/i.test(normalizedDetail) && /\bWindows\b/.test(normalizedDetail)) {
|
||||||
|
return new ConversationStartupError(
|
||||||
|
`CLI runtime crashed during startup (code ${exitCode}). On Windows this usually means the system has run out of commit memory (RAM + page file). Close other programs, set the page file (virtual memory) to system managed, or start fewer team members, then retry. Crash output: ${normalizedDetail}`,
|
||||||
|
'CLI_START_FAILED',
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
return new ConversationStartupError(
|
return new ConversationStartupError(
|
||||||
normalizedDetail
|
normalizedDetail
|
||||||
? `CLI exited during startup (code ${exitCode}): ${normalizedDetail}`
|
? `CLI exited during startup (code ${exitCode}): ${normalizedDetail}`
|
||||||
|
|||||||
@@ -91,6 +91,37 @@ test('validation failure starts no process; launch failure requires explicit rev
|
|||||||
expect(failed.launch?.error).toBe('fixture launch failed')
|
expect(failed.launch?.error).toBe('fixture launch failed')
|
||||||
expect((await service.action('review', 'retry', identity(failed))).state).toBe('review_pending')
|
expect((await service.action('review', 'retry', identity(failed))).state).toBe('review_pending')
|
||||||
})
|
})
|
||||||
|
test('approving after an app restart starts the disconnected lead before launching', async () => {
|
||||||
|
// The real launch refuses a lead without a process ("Team leader must be
|
||||||
|
// connected before launching"); after a restart nothing had started it.
|
||||||
|
let leaderConnected = false
|
||||||
|
const started: string[] = []
|
||||||
|
const service = new TeamPlanService({ validate: async item => item, launch: async () => {
|
||||||
|
if (!leaderConnected) throw new Error('Team leader must be connected before launching')
|
||||||
|
return { memberIds: { worker: 'child-1' } }
|
||||||
|
}, stop: async () => {} })
|
||||||
|
const startLeader = async (sessionId: string) => { started.push(sessionId); leaderConnected = true }
|
||||||
|
const plan = await ready()
|
||||||
|
expect((await service.approve('review', identity(plan), { startLeader })).state).toBe('launching')
|
||||||
|
expect((await settle('running')).launch?.memberIds).toEqual({ worker: 'child-1' })
|
||||||
|
// A replayed approval is already launching: it starts no second lead.
|
||||||
|
await service.approve('review', identity(plan), { startLeader })
|
||||||
|
expect(started).toEqual(['session'])
|
||||||
|
})
|
||||||
|
test('a lead that cannot start leaves the plan reviewable and launches nothing', async () => {
|
||||||
|
let launches = 0
|
||||||
|
const service = new TeamPlanService({ validate: async item => item, launch: async () => { launches++; return { memberIds: {} } }, stop: async () => {} })
|
||||||
|
const plan = await ready()
|
||||||
|
await expect(service.approve('review', identity(plan), { startLeader: async () => { throw new Error('CLI runtime crashed during startup (code 3)') } }))
|
||||||
|
.rejects.toThrow('Team leader could not start: CLI runtime crashed during startup (code 3)')
|
||||||
|
expect(launches).toBe(0)
|
||||||
|
const pending = await readTeamPlan('review')
|
||||||
|
expect(pending?.state).toBe('review_pending')
|
||||||
|
// Once the lead can start, the same reviewed plan approves without a retry step.
|
||||||
|
expect((await service.approve('review', identity(pending!), { startLeader: async () => {} })).state).toBe('launching')
|
||||||
|
await settle('running')
|
||||||
|
expect(launches).toBe(1)
|
||||||
|
})
|
||||||
test('cold server observes durable pending launch as interrupted instead of replaying', async () => {
|
test('cold server observes durable pending launch as interrupted instead of replaying', async () => {
|
||||||
const plan = await ready()
|
const plan = await ready()
|
||||||
await approveTeamPlan('review', identity(plan), 'request', plan)
|
await approveTeamPlan('review', identity(plan), 'request', plan)
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export class TeamPlanService {
|
|||||||
this.launches.set(plan.planId, operation)
|
this.launches.set(plan.planId, operation)
|
||||||
void operation.catch(() => {})
|
void operation.catch(() => {})
|
||||||
}
|
}
|
||||||
async approve(teamName: string, action: TeamPlanAction): Promise<TeamPlanRecord> {
|
async approve(teamName: string, action: TeamPlanAction, options: { startLeader?: (sessionId: string) => Promise<void> } = {}): Promise<TeamPlanRecord> {
|
||||||
const current = await readTeamPlan(teamName)
|
const current = await readTeamPlan(teamName)
|
||||||
if (!current) throw new TeamPlanError('Plan not found', 404)
|
if (!current) throw new TeamPlanError('Plan not found', 404)
|
||||||
// Idempotent replay still goes through store identity/incarnation checks.
|
// Idempotent replay still goes through store identity/incarnation checks.
|
||||||
@@ -95,6 +95,13 @@ export class TeamPlanService {
|
|||||||
if (current.planId !== action.planId || current.sessionId !== action.sessionId || current.incarnationId !== action.incarnationId || current.revision !== action.expectedRevision) throw new TeamPlanError('Plan changed; refresh before continuing')
|
if (current.planId !== action.planId || current.sessionId !== action.sessionId || current.incarnationId !== action.incarnationId || current.revision !== action.expectedRevision) throw new TeamPlanError('Plan changed; refresh before continuing')
|
||||||
try { validated = await this.runtime.validate(current) }
|
try { validated = await this.runtime.validate(current) }
|
||||||
catch (error) { throw new TeamPlanError(error instanceof Error ? error.message : 'Team configuration is unavailable', 400) }
|
catch (error) { throw new TeamPlanError(error instanceof Error ? error.message : 'Team configuration is unavailable', 400) }
|
||||||
|
// The launch needs a connected lead. After an app restart the lead's
|
||||||
|
// process is gone until something starts it, and approving a reviewed
|
||||||
|
// plan is that something: refusing left the plan unlaunchable.
|
||||||
|
if (current.state === 'review_pending' && options.startLeader) {
|
||||||
|
try { await options.startLeader(current.sessionId) }
|
||||||
|
catch (error) { throw new TeamPlanError(`Team leader could not start: ${error instanceof Error ? error.message : String(error)}`) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// The approval commits `launching` before this call can register the
|
// The approval commits `launching` before this call can register the
|
||||||
// launch; a plan read in between must not take it for an orphaned launch.
|
// launch; a plan read in between must not take it for an orphaned launch.
|
||||||
|
|||||||
Reference in New Issue
Block a user