Merge remote-tracking branch 'origin/feat/server-refactor' into feat/cloud-question

This commit is contained in:
林凯90331 2026-05-09 18:18:55 +08:00
commit 59d3460554
8 changed files with 125 additions and 70 deletions

View File

@ -1116,6 +1116,7 @@ export class QueryEngine {
duration_ms: Date.now() - startTime, duration_ms: Date.now() - startTime,
duration_api_ms: getTotalAPIDuration(), duration_api_ms: getTotalAPIDuration(),
is_error: true, is_error: true,
is_interrupted: this.abortController.signal.aborted,
num_turns: turnCount, num_turns: turnCount,
stop_reason: lastStopReason, stop_reason: lastStopReason,
session_id: getSessionId(), session_id: getSessionId(),
@ -1128,11 +1129,6 @@ export class QueryEngine {
initialAppState.fastMode, initialAppState.fastMode,
), ),
uuid: randomUUID(), uuid: randomUUID(),
// Diagnostic prefix: these are what isResultSuccessful() checks — if
// the result type isn't assistant-with-text/thinking or user-with-
// tool_result, and stop_reason isn't end_turn, that's why this fired.
// errors[] is turn-scoped via the watermark; previously it dumped the
// entire process's logError buffer (ripgrep timeouts, ENOENT, etc).
errors: (() => { errors: (() => {
const all = getInMemoryErrors() const all = getInMemoryErrors()
const start = errorLogWatermark const start = errorLogWatermark

View File

@ -45,8 +45,17 @@ export function createInfoRoutes(sessionManager: SessionManager): Hono {
}) })
.get('/agent', c => { .get('/agent', c => {
const allAgents = getBuiltInAgents() const allAgents = getBuiltInAgents()
return c.json( const defaultMode = {
allAgents.map(a => ({ name: 'build',
description: 'Default mode for software engineering tasks: writing code, editing files, running commands, and building projects.',
mode: 'primary' as const,
hidden: false,
options: {},
permission: [],
}
return c.json([
defaultMode,
...allAgents.map(a => ({
name: a.agentType, name: a.agentType,
description: a.whenToUse, description: a.whenToUse,
mode: a.isMainThread ? ('primary' as const) : ('subagent' as const), mode: a.isMainThread ? ('primary' as const) : ('subagent' as const),
@ -54,7 +63,7 @@ export function createInfoRoutes(sessionManager: SessionManager): Hono {
options: {}, options: {},
permission: [], permission: [],
})), })),
) ])
}) })
.get('/mcp', async c => { .get('/mcp', async c => {
for (const handle of sessionManager.getAllSessions()) { for (const handle of sessionManager.getAllSessions()) {

View File

@ -20,7 +20,7 @@ export function createMessageRoutes(sessionManager: SessionManager): Hono {
const { messages, nextCursor } = await readSessionMessages({ const { messages, nextCursor } = await readSessionMessages({
sessionId: id, sessionId: id,
cwd: handle?.cwd, cwd: handle?.spawnCwd ?? handle?.cwd,
limit, limit,
before, before,
includeSystem, includeSystem,
@ -39,7 +39,7 @@ export function createMessageRoutes(sessionManager: SessionManager): Hono {
const todos = await readSessionTodos({ const todos = await readSessionTodos({
sessionId: id, sessionId: id,
cwd: handle?.cwd, cwd: handle?.spawnCwd ?? handle?.cwd,
}) })
return c.json({ todos }) return c.json({ todos })
@ -53,7 +53,7 @@ export function createMessageRoutes(sessionManager: SessionManager): Hono {
const { diffs } = await readSessionDiff({ const { diffs } = await readSessionDiff({
sessionId: id, sessionId: id,
cwd: handle?.cwd, cwd: handle?.spawnCwd ?? handle?.cwd,
messageID, messageID,
}) })

View File

@ -11,30 +11,13 @@ import {
} from '../errors.js' } from '../errors.js'
import { listSessionsImpl } from '../../utils/listSessionsImpl.js' import { listSessionsImpl } from '../../utils/listSessionsImpl.js'
async function getOrCreateSession( /** 从磁盘历史记录查找 session 的原始 cwd用于 resume 时恢复正确的工作目录 */
sessionManager: SessionManager, async function getHistoryCwd(sessionId: string): Promise<string | undefined> {
id: string,
): Promise<import('../sessionHandle.js').SessionHandle> {
const existing = sessionManager.getSession(id)
if (existing) return existing
try { try {
const handle = await sessionManager.createSession({ const list = await listSessionsImpl({ limit: 1000 })
resumeSessionId: id, return list.find(s => s.sessionId === sessionId)?.cwd
execPath: process.execPath, } catch {
scriptArgs: getScriptArgsForChild(), return undefined
})
await handle.start()
return handle
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to create session'
if (msg.includes('Maximum concurrent')) {
throw tooManySessions(msg)
}
if (msg.includes('Working directory does not exist')) {
throw sessionError(msg)
}
throw notFound('session not found')
} }
} }
@ -178,6 +161,9 @@ export function createSessionRoutes(
scriptArgs: getScriptArgsForChild(), scriptArgs: getScriptArgsForChild(),
}) })
// 等待子进程初始化完成,确保 ready 后再返回,避免立即发 prompt 时子进程还未就绪
await handle.waitReady(30000)
const info = handle.getInfo() const info = handle.getInfo()
return c.json( return c.json(
{ {
@ -214,10 +200,7 @@ export function createSessionRoutes(
let historySessions: Awaited<ReturnType<typeof listSessionsImpl>> = [] let historySessions: Awaited<ReturnType<typeof listSessionsImpl>> = []
try { try {
historySessions = await listSessionsImpl({ dir, limit: limit + offset }) historySessions = await listSessionsImpl({ dir, limit: limit + offset })
process.stderr.write(`[server:session] listSessionsImpl dir=${dir ?? 'all'} found=${historySessions.length}\n`) } catch {}
} catch (err) {
process.stderr.write(`[server:session] listSessionsImpl error: ${err}\n`)
}
// 内存中活跃的 handle用于覆盖运行时状态 // 内存中活跃的 handle用于覆盖运行时状态
const handleMap = new Map( const handleMap = new Map(
@ -272,7 +255,6 @@ export function createSessionRoutes(
filtered.sort((a, b) => (b.last_active_at ?? 0) - (a.last_active_at ?? 0)) filtered.sort((a, b) => (b.last_active_at ?? 0) - (a.last_active_at ?? 0))
const sessions = filtered.slice(offset, offset + limit) const sessions = filtered.slice(offset, offset + limit)
process.stderr.write(`[server:session] GET /session -> history=${historySessions.length} active=${handleMap.size} merged=${merged.length} returned=${sessions.length}\n`)
return c.json({ sessions }) return c.json({ sessions })
}) })
.get('/session/status', async c => { .get('/session/status', async c => {
@ -309,7 +291,7 @@ export function createSessionRoutes(
status: st.status, status: st.status,
state: st.status, state: st.status,
has_pending_permission: st.has_pending_permission, has_pending_permission: st.has_pending_permission,
type: st.status === 'running' ? 'busy' : 'idle', type: st.prompting ? 'busy' : 'idle',
} }
} }
@ -382,7 +364,22 @@ export function createSessionRoutes(
}) })
.post('/session/:sessionID/prompt', async c => { .post('/session/:sessionID/prompt', async c => {
const id = c.req.param('sessionID') const id = c.req.param('sessionID')
const handle = await getOrCreateSession(sessionManager, id) let handle = sessionManager.getSession(id)
if (!handle) {
const cwd = await getHistoryCwd(id)
try {
handle = await sessionManager.createSession({
cwd,
sessionId: id,
resumeSessionId: id,
execPath: process.execPath,
scriptArgs: getScriptArgsForChild(),
})
await handle.waitReady(30000)
} catch (err) {
throw sessionError(err instanceof Error ? err.message : 'Failed to resume session')
}
}
const body = await c.req.json<{ const body = await c.req.json<{
content?: string content?: string
@ -408,7 +405,24 @@ export function createSessionRoutes(
.post('/session/:sessionID/prompt_async', async c => { .post('/session/:sessionID/prompt_async', async c => {
const id = c.req.param('sessionID') const id = c.req.param('sessionID')
const handle = await getOrCreateSession(sessionManager, id) let handle = sessionManager.getSession(id)
// 历史 session 不在内存中,自动恢复
if (!handle) {
const cwd = await getHistoryCwd(id)
try {
handle = await sessionManager.createSession({
cwd,
sessionId: id,
resumeSessionId: id,
execPath: process.execPath,
scriptArgs: getScriptArgsForChild(),
})
await handle.waitReady(30000)
} catch (err) {
throw sessionError(err instanceof Error ? err.message : 'Failed to resume session')
}
}
const body = await c.req.json<{ const body = await c.req.json<{
content?: string content?: string
@ -445,13 +459,15 @@ export function createSessionRoutes(
}) })
.post('/session/:sessionID/abort', async c => { .post('/session/:sessionID/abort', async c => {
const id = c.req.param('sessionID') const id = c.req.param('sessionID')
const handle = await getOrCreateSession(sessionManager, id) const handle = sessionManager.getSession(id)
if (!handle) throw notFound('session not found')
await handle.abort() await handle.abort()
return c.json({ aborted: true }) return c.json({ aborted: true })
}) })
.post('/session/:sessionID/shell', async c => { .post('/session/:sessionID/shell', async c => {
const id = c.req.param('sessionID') const id = c.req.param('sessionID')
const handle = await getOrCreateSession(sessionManager, id) const handle = sessionManager.getSession(id)
if (!handle) throw notFound('session not found')
const body = await c.req.json<{ command: string }>() const body = await c.req.json<{ command: string }>()
if (!body.command) throw badRequest('command is required') if (!body.command) throw badRequest('command is required')
@ -459,7 +475,8 @@ export function createSessionRoutes(
}) })
.post('/session/:sessionID/command', async c => { .post('/session/:sessionID/command', async c => {
const id = c.req.param('sessionID') const id = c.req.param('sessionID')
const handle = await getOrCreateSession(sessionManager, id) const handle = sessionManager.getSession(id)
if (!handle) throw notFound('session not found')
const body = await c.req.json<{ command: string }>() const body = await c.req.json<{ command: string }>()
if (!body.command) throw badRequest('command is required') if (!body.command) throw badRequest('command is required')
@ -467,7 +484,8 @@ export function createSessionRoutes(
}) })
.post('/session/:sessionID/command_async', async c => { .post('/session/:sessionID/command_async', async c => {
const id = c.req.param('sessionID') const id = c.req.param('sessionID')
const handle = await getOrCreateSession(sessionManager, id) const handle = sessionManager.getSession(id)
if (!handle) throw notFound('session not found')
const body = await c.req.json<{ command: string }>() const body = await c.req.json<{ command: string }>()
if (!body.command) throw badRequest('command is required') if (!body.command) throw badRequest('command is required')

View File

@ -115,6 +115,8 @@ function loadChildSpawnPrefix(): { execPath: string; scriptArgs: string[]; defin
export class SessionHandle { export class SessionHandle {
readonly sessionId: string readonly sessionId: string
readonly cwd: string readonly cwd: string
/** 子进程实际的工作目录dev 模式下与 cwd 不同csc 根目录),用于定位 transcript 文件 */
private _spawnCwd: string | undefined
private child: ChildProcess | null = null private child: ChildProcess | null = null
private _status: SessionState = 'starting' private _status: SessionState = 'starting'
private _model?: string private _model?: string
@ -189,6 +191,10 @@ export class SessionHandle {
return this.opts.silent ?? false return this.opts.silent ?? false
} }
get spawnCwd(): string {
return this._spawnCwd ?? this.cwd
}
async waitReady(timeoutMs = 30000): Promise<void> { async waitReady(timeoutMs = 30000): Promise<void> {
if (this._status === 'running') return if (this._status === 'running') return
if (this._status === 'stopped') { if (this._status === 'stopped') {
@ -199,11 +205,17 @@ export class SessionHandle {
reject(new Error(`Session ${this.sessionId} init timed out`)) reject(new Error(`Session ${this.sessionId} init timed out`))
}, timeoutMs) }, timeoutMs)
const origResolve = this.initResolve const origResolve = this.initResolve
const origReject = this.initReject
this.initResolve = (data: InitData) => { this.initResolve = (data: InitData) => {
clearTimeout(timer) clearTimeout(timer)
origResolve?.(data) origResolve?.(data)
resolve() resolve()
} }
this.initReject = (err: unknown) => {
clearTimeout(timer)
origReject?.(err)
reject(err)
}
}) })
} }
@ -233,8 +245,8 @@ export class SessionHandle {
'stream-json', 'stream-json',
'--output-format', '--output-format',
'stream-json', 'stream-json',
'--session-id', // resume 时不传 --session-idcsc 会继承历史 session 的 id
this.sessionId, ...(this.opts.resumeSessionId ? [] : ['--session-id', this.sessionId]),
...(this.opts.model ? ['--model', this.opts.model] : []), ...(this.opts.model ? ['--model', this.opts.model] : []),
'--permission-mode', '--permission-mode',
this._permissionMode, this._permissionMode,
@ -264,6 +276,7 @@ export class SessionHandle {
const featureArgs = saved?.featureArgs ?? [] const featureArgs = saved?.featureArgs ?? []
const spawnArgs = [...defineArgs, ...featureArgs, ...this.opts.scriptArgs, ...printArgs] const spawnArgs = [...defineArgs, ...featureArgs, ...this.opts.scriptArgs, ...printArgs]
this._spawnCwd = this.opts.cwd
this.child = spawn(this.opts.execPath, spawnArgs, { this.child = spawn(this.opts.execPath, spawnArgs, {
cwd: this.opts.cwd, cwd: this.opts.cwd,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
@ -283,8 +296,12 @@ export class SessionHandle {
signal: signal ?? null, signal: signal ?? null,
}) })
} }
if (this.initResolve) { if (this.initReject) {
const reject = this.initReject
this.initResolve = null this.initResolve = null
this.initReject = null
process.stderr.write(`[serve:${this.sessionId}] exit_code=${code} cwd=${this.cwd} stderr:\n${this.lastStderr.slice(-5).join('\n')}\n`)
reject(new Error(`Process exited with code ${code}`))
} }
if (this.promptReject) { if (this.promptReject) {
this.promptReject(new Error(`Process exited with code ${code}`)) this.promptReject(new Error(`Process exited with code ${code}`))
@ -294,8 +311,11 @@ export class SessionHandle {
this.child.on('error', err => { this.child.on('error', err => {
logError(err) logError(err)
this._status = 'stopped' this._status = 'stopped'
if (this.initResolve) { if (this.initReject) {
const reject = this.initReject
this.initResolve = null this.initResolve = null
this.initReject = null
reject(err)
} }
if (this.promptReject) { if (this.promptReject) {
this.promptReject(err) this.promptReject(err)
@ -306,13 +326,17 @@ export class SessionHandle {
this.sendInitialize() this.sendInitialize()
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
this.initResolve = null if (this.initReject) {
this.initReject = null const reject = this.initReject
this._status = 'stopped' this.initResolve = null
this.emitEvent('deleted', { this.initReject = null
status: 'stopped', this._status = 'stopped'
reason: 'init_timeout', this.emitEvent('deleted', {
}) status: 'stopped',
reason: 'init_timeout',
})
reject(new Error(`Session ${this.sessionId} init timed out`))
}
}, 30000) }, 30000)
this.initResolve = (data: InitData) => { this.initResolve = (data: InitData) => {
@ -685,18 +709,19 @@ export class SessionHandle {
}) })
this.writeStdin(interrupt) this.writeStdin(interrupt)
if (!this.promptResolve) return
await new Promise<void>(resolve => { await new Promise<void>(resolve => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
this.kill() this.kill()
resolve() resolve()
}, 5000) }, 2000)
const check = setInterval(() => { const originalResolve = this.promptResolve
if (!this.child || this.child.killed) { this.promptResolve = (value) => {
clearTimeout(timeout) clearTimeout(timeout)
clearInterval(check) originalResolve?.(value)
resolve() resolve()
} }
}, 200)
}) })
} }

View File

@ -128,16 +128,17 @@ export class SessionManager {
getSessionStatuses(): Record< getSessionStatuses(): Record<
string, string,
{ status: SessionState; has_pending_permission: boolean } { status: SessionState; has_pending_permission: boolean; prompting: boolean }
> { > {
const result: Record< const result: Record<
string, string,
{ status: SessionState; has_pending_permission: boolean } { status: SessionState; has_pending_permission: boolean; prompting: boolean }
> = {} > = {}
for (const [id, handle] of this.sessions) { for (const [id, handle] of this.sessions) {
result[id] = { result[id] = {
status: handle.status, status: handle.status,
has_pending_permission: handle.getPendingPermissions().length > 0, has_pending_permission: handle.getPendingPermissions().length > 0,
prompting: handle.prompting,
} }
} }
return result return result
@ -154,6 +155,7 @@ export class SessionManager {
execPath: string execPath: string
scriptArgs: string[] scriptArgs: string[]
silent?: boolean silent?: boolean
sessionId?: string
}): Promise<SessionHandle> { }): Promise<SessionHandle> {
if (this.maxSessions > 0 && this.sessions.size >= this.maxSessions) { if (this.maxSessions > 0 && this.sessions.size >= this.maxSessions) {
throw new Error( throw new Error(
@ -161,7 +163,7 @@ export class SessionManager {
) )
} }
const sessionId = crypto.randomUUID() const sessionId = opts.sessionId ?? crypto.randomUUID()
let cwd = opts.cwd || this.defaultWorkspace || process.cwd() let cwd = opts.cwd || this.defaultWorkspace || process.cwd()
if (!isAbsolute(cwd)) { if (!isAbsolute(cwd)) {
cwd = resolve(process.cwd(), cwd) cwd = resolve(process.cwd(), cwd)

View File

@ -63,10 +63,13 @@ async function resolveTranscriptPath(
): Promise<string | null> { ): Promise<string | null> {
const cacheKey = `${sessionId}:${cwd ?? ''}` const cacheKey = `${sessionId}:${cwd ?? ''}`
const cached = pathCache.get(cacheKey) const cached = pathCache.get(cacheKey)
if (cached !== undefined) return cached // 只缓存找到的路径,不缓存 null文件可能还未写入
if (cached !== undefined && cached !== null) return cached
const result = await resolveTranscriptPathUncached(sessionId, cwd) const result = await resolveTranscriptPathUncached(sessionId, cwd)
pathCache.set(cacheKey, result) if (result !== null) {
pathCache.set(cacheKey, result)
}
return result return result
} }

View File

@ -1,6 +1,8 @@
{ {
"extends": "./tsconfig.base.json", "extends": "./tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"baseUrl": ".",
"ignoreDeprecations": "6.0",
"paths": { "paths": {
"src/*": ["./src/*"], "src/*": ["./src/*"],
"@claude-code-best/builtin-tools/*": ["./packages/builtin-tools/src/*"], "@claude-code-best/builtin-tools/*": ["./packages/builtin-tools/src/*"],