fix(server): repair remaining 16 server-checks CI failures

Group F (PR regression): drop the dead sessionGenerationBusy gate in
  scheduleRestartSessionWithRuntimeConfig. It only cleared on a status
  'idle' event that a normal turn never emits, so any runtime restart
  after the first turn was parked forever and the 2nd startSession never
  fired. The new active-turn deferral (deferredRuntimeRestarts, drained by
  the turn's 'result' callback) now correctly guards mid-turn restarts.
  Removes now-dead pendingDeferredRuntimeRestarts / drainPendingRuntimeRestart.
Group A: install ripgrep in the server-checks job so project-scoped
  markdown config discovery (output-styles, slash-commands, agents) works.
Group B: clear inherited NVM_DIR in shell-env tests so the CI runner's real
  /home/runner/.nvm can't shadow the fake .zshrc value under test.
Group C: pin/restore ProviderService.serverPort (process-wide static) in
  conversation-service and cron-scheduler tests so leaked random ports from
  other suites don't break the hardcoded 3456 ANTHROPIC_BASE_URL assertions.
Group D: assert process.platform instead of hardcoded 'darwin' in full-flow.
Group E: assert response shape (key present) not an exact empty servers
  array for /api/mcp + /api/plugins H5 auth checks; they run at repo root
  which has a tracked .mcp.json.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
你的姓名 2026-06-11 12:17:07 +08:00
parent acb288aa51
commit c661aae438
7 changed files with 63 additions and 59 deletions

View File

@ -96,6 +96,9 @@ jobs:
with:
bun-version: latest
- name: Install ripgrep
run: sudo apt-get update && sudo apt-get install -y ripgrep
- name: Install root dependencies
run: bun install

View File

@ -31,10 +31,19 @@ describe('ConversationService', () => {
let originalPath: string | undefined
let originalShell: string | undefined
let originalZdotdir: string | undefined
let originalNvmDir: string | undefined
let originalDisableTerminalShellEnv: string | undefined
let originalServerPort: number | undefined
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-conversation-service-'))
// ProviderService.serverPort is a process-wide static; other server tests
// (e.g. conversations.test.ts starting a server on an OS-assigned port) can
// leave it set to a random value. Pin it to the default so the managed
// ANTHROPIC_BASE_URL assertions below are deterministic, and restore it
// afterwards.
originalServerPort = ProviderService.getServerPort()
ProviderService.setServerPort(3456)
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
originalApiKey = process.env.ANTHROPIC_API_KEY
originalAuthToken = process.env.ANTHROPIC_AUTH_TOKEN
@ -54,6 +63,7 @@ describe('ConversationService', () => {
originalPath = process.env.PATH
originalShell = process.env.SHELL
originalZdotdir = process.env.ZDOTDIR
originalNvmDir = process.env.NVM_DIR
originalDisableTerminalShellEnv = process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
process.env.CLAUDE_CONFIG_DIR = tmpDir
@ -74,6 +84,11 @@ describe('ConversationService', () => {
delete process.env.CC_HAHA_TRACE_PROVIDER_NAME
delete process.env.CC_HAHA_TRACE_PROVIDER_FORMAT
process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = '1'
// The CI runner exports a real NVM_DIR (/home/runner/.nvm). buildChildEnv's
// shell-env merge lets inherited process env win over shell-captured values,
// so a leaked NVM_DIR would shadow the fake .zshrc value the shell-capture
// tests assert. Clear it; the per-test fake shell is the only intended source.
delete process.env.NVM_DIR
resetTerminalShellEnvironmentCacheForTests()
})
@ -135,10 +150,14 @@ describe('ConversationService', () => {
if (originalZdotdir === undefined) delete process.env.ZDOTDIR
else process.env.ZDOTDIR = originalZdotdir
if (originalNvmDir === undefined) delete process.env.NVM_DIR
else process.env.NVM_DIR = originalNvmDir
if (originalDisableTerminalShellEnv === undefined) delete process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
else process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = originalDisableTerminalShellEnv
resetTerminalShellEnvironmentCacheForTests()
if (originalServerPort !== undefined) ProviderService.setServerPort(originalServerPort)
await fs.rm(tmpDir, { recursive: true, force: true })
})

View File

@ -22,6 +22,7 @@ const originalClaudeCodeEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT
const originalHome = process.env.HOME
const originalShell = process.env.SHELL
const originalZdotdir = process.env.ZDOTDIR
const originalNvmDir = process.env.NVM_DIR
const originalDisableTerminalShellEnv = process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
const isWindows = process.platform === 'win32'
@ -101,6 +102,11 @@ function restoreEnv(): void {
} else {
delete process.env.ZDOTDIR
}
if (originalNvmDir) {
process.env.NVM_DIR = originalNvmDir
} else {
delete process.env.NVM_DIR
}
if (originalDisableTerminalShellEnv) {
process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = originalDisableTerminalShellEnv
} else {
@ -111,16 +117,28 @@ function restoreEnv(): void {
describe('cron scheduler launcher resolution', () => {
let tmpDir: string
let originalServerPort: number | undefined
beforeEach(async () => {
tmpDir = await createTmpDir()
process.env.CLAUDE_CONFIG_DIR = path.join(tmpDir, 'config')
process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = '1'
// The CI runner exports a real NVM_DIR (/home/runner/.nvm); the shell-env
// merge lets inherited process env shadow shell-captured values, which would
// break the shell-capture assertions. Clear it so the fake shell is the only
// source.
delete process.env.NVM_DIR
// ProviderService.serverPort is a process-wide static other tests may leave
// at a random value; pin it so the managed ANTHROPIC_BASE_URL assertions are
// deterministic, restore afterwards.
originalServerPort = ProviderService.getServerPort()
ProviderService.setServerPort(3456)
resetTerminalShellEnvironmentCacheForTests()
})
afterEach(async () => {
restoreEnv()
if (originalServerPort !== undefined) ProviderService.setServerPort(originalServerPort)
await cleanupTmpDir(tmpDir)
})

View File

@ -112,7 +112,7 @@ describe('E2E: Full Flow', () => {
it('should return diagnostics', async () => {
const { data } = await api('GET', '/api/status/diagnostics')
expect(data.platform).toBe('darwin')
expect(data.platform).toBe(process.platform)
expect(data.configDir).toBe(tmpDir)
})

View File

@ -191,8 +191,13 @@ function expectWebSocketUpgradeThenClose(url: string): Promise<void> {
}
const settingsSurfaceEndpoints = [
{ path: '/api/mcp', expected: { servers: [] } },
{ path: '/api/plugins', expected: { plugins: [] } },
// Assert the response *shape* (key present), not an exact empty array: the
// server reads MCP/plugin config from the current working directory, so when
// the suite runs at the repo root (which has a tracked .mcp.json) `servers`
// is non-empty. These cases exist to verify H5-token auth (401/401/200), not
// the discovered config contents.
{ path: '/api/mcp', expectedKey: 'servers' },
{ path: '/api/plugins', expectedKey: 'plugins' },
{ path: '/api/agents', expectedKey: 'activeAgents' },
] as const
@ -713,11 +718,7 @@ describe('remote H5 auth and CORS integration', () => {
expect(validTokenResponse.status).toBe(200)
expect(validTokenResponse.headers.get('Access-Control-Allow-Origin')).toBe(PHONE_ORIGIN)
const body = await validTokenResponse.json()
if ('expected' in endpoint) {
expect(body).toMatchObject(endpoint.expected)
} else {
expect(body).toHaveProperty(endpoint.expectedKey)
}
expect(body).toHaveProperty(endpoint.expectedKey)
}
})

View File

@ -119,19 +119,6 @@ const prewarmIdleTimers = new Map<string, ReturnType<typeof setTimeout>>()
const DEFAULT_PREWARM_IDLE_TIMEOUT_MS = 5 * 60_000
const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max'])
// Track whether a session is currently mid-turn (anything other than 'idle').
// Updated inside sendMessage by observing outbound `status` events. Used to
// avoid yanking the CLI subprocess out from under an in-progress generation
// when the user changes runtime config (model / effort / thinking) mid-stream.
const sessionGenerationBusy = new Set<string>()
// When a runtime config change arrives while the session is busy, we record
// the WS that should drive the eventual restart and apply it on the next
// transition to 'idle'. The actual override values are read from
// `runtimeOverrides` at restart time, so a later set_runtime_config naturally
// supersedes an earlier deferred one.
const pendingDeferredRuntimeRestarts = new Map<string, ServerWebSocket<WebSocketData>>()
async function sendRepositoryStartupStatus(
ws: ServerWebSocket<WebSocketData>,
sessionId: string,
@ -1331,8 +1318,6 @@ function cleanupSessionRuntimeState(sessionId: string) {
runtimeTransitionPromises.delete(sessionId)
sessionStartupPromises.delete(sessionId)
lastResolvedStartupWorkDirs.delete(sessionId)
sessionGenerationBusy.delete(sessionId)
pendingDeferredRuntimeRestarts.delete(sessionId)
clearPrewarmState(sessionId)
}
@ -2133,49 +2118,21 @@ function toApiRetryServerMessage(cliMsg: any): ServerMessage | null {
}
function sendMessage(ws: ServerWebSocket<WebSocketData>, message: ServerMessage) {
// Track per-session generation busy state by observing outbound `status`
// events. This lets handleSetRuntimeConfig defer a restart that would
// otherwise kill the CLI subprocess in the middle of a streaming response.
const sessionId = ws.data.sessionId
if (sessionId && message.type === 'status') {
if (message.state === 'idle') {
const wasBusy = sessionGenerationBusy.delete(sessionId)
if (wasBusy) drainPendingRuntimeRestart(sessionId)
} else {
sessionGenerationBusy.add(sessionId)
}
}
ws.send(JSON.stringify(message))
}
// Apply a pending deferred runtime restart that was queued while the session
// was busy. Reads the latest override from `runtimeOverrides` at restart time,
// so multiple toggles during streaming naturally collapse into a single
// restart with the most recent config.
function drainPendingRuntimeRestart(sessionId: string) {
const ws = pendingDeferredRuntimeRestarts.get(sessionId)
if (!ws) return
pendingDeferredRuntimeRestarts.delete(sessionId)
if (!conversationService.hasSession(sessionId)) return
void enqueueRuntimeTransition(sessionId, async () => {
if (!conversationService.hasSession(sessionId)) return
await restartSessionWithRuntimeConfig(ws, sessionId)
})
}
// Schedule a runtime-config restart, but defer until the session is idle to
// avoid interrupting an in-progress generation. The override values are
// already in `runtimeOverrides[sessionId]` (and persisted) before this is
// called, so getRuntimeSettings will read them when the deferred restart
// finally runs.
// Restart the CLI subprocess to apply a runtime-config change. The override
// values are already in `runtimeOverrides[sessionId]` (and persisted) before
// this is called, so getRuntimeSettings will read them at restart time.
//
// Mid-turn protection is handled upstream by the active-turn deferral
// (`shouldDeferRuntimeRestartForActiveTurn` + `deferredRuntimeRestarts`, drained
// by the turn's `result` callback in `bindActiveUserTurnCompletion`), which
// gates on the real turn lifecycle rather than on outbound status events.
async function scheduleRestartSessionWithRuntimeConfig(
ws: ServerWebSocket<WebSocketData>,
sessionId: string,
): Promise<void> {
if (sessionGenerationBusy.has(sessionId)) {
pendingDeferredRuntimeRestarts.set(sessionId, ws)
return
}
await restartSessionWithRuntimeConfig(ws, sessionId)
}

View File

@ -51,10 +51,16 @@ describe('MCP stdio environment', () => {
PATH: process.env.PATH,
SHELL: process.env.SHELL,
ZDOTDIR: process.env.ZDOTDIR,
NVM_DIR: process.env.NVM_DIR,
CC_HAHA_DISABLE_TERMINAL_SHELL_ENV:
process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV,
}
delete process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
// The CI runner exports a real NVM_DIR (/home/runner/.nvm). The env merge
// lets inherited process env win over shell-captured values, so a leaked
// NVM_DIR would shadow the fake .zshrc value asserted below. Clear it so the
// only source of NVM_DIR is the fake shell config.
delete process.env.NVM_DIR
resetMcpStdioEnvironmentCacheForTests()
})