mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-27 15:13:37 +08:00
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:
parent
acb288aa51
commit
c661aae438
3
.github/workflows/pr-quality.yml
vendored
3
.github/workflows/pr-quality.yml
vendored
@ -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
|
||||
|
||||
|
||||
@ -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 })
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user