runs.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. // Run 调度:排队、并发控制、执行、中断与重启恢复(docs/design/02-architecture.md §3.2)
  2. import fsp from 'node:fs/promises'
  3. import path from 'node:path'
  4. import { eq, inArray } from 'drizzle-orm'
  5. import PQueue from 'p-queue'
  6. import { z } from 'zod'
  7. import type { AgentEventType, EventPayloads, ProviderId, RunDto, SendMessageBody, SessionDto } from '@agent-server/shared'
  8. import type { DB } from '../db'
  9. import { runs } from '../db/schema'
  10. import { conflict, notFound } from '../lib/errors'
  11. import { newId, nowIso } from '../lib/ids'
  12. import type { AgentProvider, RunInput } from '../providers/types'
  13. import { ProviderRunError } from '../providers/types'
  14. import { resolveAccess } from './access-policy'
  15. import { parse, type AuditService, type KnowledgeService, type ProfileService } from './catalog'
  16. import type { EventStore } from './event-store'
  17. import type { FileService } from './files'
  18. import type { InteractionService } from './interactions'
  19. import type { CommunitySkillService } from './community-skills'
  20. import type { PluginService } from './plugins'
  21. import type { RunTokenRegistry } from '../mcp-gateway/tokens'
  22. import { gatewayUrl } from '../config'
  23. import type { SessionRow, SessionService } from './sessions'
  24. import type { SettingsService } from './settings'
  25. import { buildSystemPrompt } from './prompt'
  26. type RunRow = typeof runs.$inferSelect
  27. const sendSchema: z.ZodType<SendMessageBody> = z.object({
  28. text: z.string().trim().min(1).max(100_000),
  29. attachments: z.array(z.string()).max(50).optional(),
  30. })
  31. const IMAGE_MIMES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])
  32. const MAX_INLINE_IMAGE = 5 * 1024 * 1024
  33. interface ActiveRun {
  34. runId: string
  35. sessionId: string
  36. controller: AbortController
  37. status: 'queued' | 'running' | 'waiting_input'
  38. }
  39. interface Logger { info(o: object, m?: string): void; warn(o: object, m?: string): void; error(o: object, m?: string): void; child(b: object): Logger }
  40. export class RunManager {
  41. private global: PQueue
  42. private perProvider: Record<ProviderId, PQueue>
  43. private active = new Map<string, ActiveRun>()
  44. constructor(
  45. private deps: {
  46. db: DB
  47. log: Logger
  48. settings: SettingsService
  49. sessions: SessionService
  50. profiles: ProfileService
  51. knowledge: KnowledgeService
  52. files: FileService
  53. events: EventStore
  54. interactions: InteractionService
  55. audit: AuditService
  56. plugins: PluginService
  57. communitySkills: CommunitySkillService
  58. gatewayTokens: RunTokenRegistry
  59. providers: Partial<Record<ProviderId, AgentProvider>>
  60. },
  61. ) {
  62. const c = deps.settings.get().limits.concurrency
  63. this.global = new PQueue({ concurrency: c.global })
  64. this.perProvider = { claude: new PQueue({ concurrency: Math.max(c.claude, 1) }), codex: new PQueue({ concurrency: Math.max(c.codex, 1) }) }
  65. deps.settings.on('changed', s => {
  66. this.global.concurrency = s.limits.concurrency.global
  67. this.perProvider.claude.concurrency = Math.max(s.limits.concurrency.claude, 1)
  68. this.perProvider.codex.concurrency = Math.max(s.limits.concurrency.codex, 1)
  69. })
  70. deps.sessions.statusOf = id => this.sessionStatus(id)
  71. this.recoverAfterRestart()
  72. }
  73. sessionStatus(sessionId: string): SessionDto['status'] {
  74. const a = this.activeForSession(sessionId)
  75. if (!a) return 'idle'
  76. return a.status === 'waiting_input' ? 'waiting_input' : 'running'
  77. }
  78. activeForSession(sessionId: string): ActiveRun | undefined {
  79. for (const a of this.active.values()) if (a.sessionId === sessionId) return a
  80. return undefined
  81. }
  82. stats() {
  83. return { running: this.global.pending, queued: this.global.size + this.perProvider.claude.size + this.perProvider.codex.size, active: this.active.size }
  84. }
  85. /** 发送消息:登记 Run 并排队,立即返回 runId */
  86. send(session: SessionRow, body: SendMessageBody): RunDto {
  87. const data = parse(sendSchema, body)
  88. if (this.activeForSession(session.id)) throw conflict('session_busy', '会话正在运行,请等待完成或先中断')
  89. const provider = this.deps.providers[session.provider]
  90. if (!provider || !this.deps.settings.get().providers[session.provider].enabled) throw conflict('provider_unavailable', `引擎 ${session.provider} 当前不可用`)
  91. this.deps.sessions.assertProfileAllowed(session)
  92. const attachments = this.deps.files.getMany(session.id, data.attachments ?? [])
  93. const runId = newId('run')
  94. this.deps.db.insert(runs).values({ id: runId, sessionId: session.id, status: 'queued' }).run()
  95. const active: ActiveRun = { runId, sessionId: session.id, controller: new AbortController(), status: 'queued' }
  96. this.active.set(runId, active)
  97. this.deps.sessions.ensureTitle(session.id, data.text)
  98. this.deps.events.append(session.id, runId, 'user.message', {
  99. text: data.text,
  100. attachments: attachments.map(f => ({ fileId: f.id, name: path.basename(f.relPath), mime: f.mime, size: f.size })),
  101. })
  102. const queue = this.perProvider[session.provider]
  103. this.deps.events.append(session.id, runId, 'run.queued', { position: queue.size + queue.pending })
  104. void queue.add(() => this.global.add(() => this.execute(runId, session.id, provider, data.text, attachments.map(f => f.id))))
  105. .catch(err => this.deps.log.error({ err, runId }, 'run task crashed'))
  106. return this.get(runId)
  107. }
  108. interrupt(runId: string, sessionId?: string) {
  109. const a = this.active.get(runId)
  110. if (!a || (sessionId && a.sessionId !== sessionId)) {
  111. const row = this.deps.db.select().from(runs).where(eq(runs.id, runId)).get()
  112. if (!row || (sessionId && row.sessionId !== sessionId)) throw notFound('Run')
  113. return // 已结束
  114. }
  115. a.controller.abort()
  116. this.deps.interactions.cancelRun(runId)
  117. if (a.status === 'queued') this.finish(a, 'interrupted', { reason: 'user' })
  118. }
  119. get(runId: string): RunDto {
  120. const r = this.deps.db.select().from(runs).where(eq(runs.id, runId)).get()
  121. if (!r) throw notFound('Run')
  122. return toRunDto(r)
  123. }
  124. private async execute(runId: string, sessionId: string, provider: AgentProvider, text: string, attachmentIds: string[]) {
  125. const a = this.active.get(runId)
  126. if (!a || a.controller.signal.aborted) return
  127. const { deps } = this
  128. const log = deps.log.child({ runId, sessionId })
  129. const emit = <T extends AgentEventType>(type: T, payload: EventPayloads[T]) => { deps.events.append(sessionId, runId, type, payload) }
  130. const startedAt = Date.now()
  131. a.status = 'running'
  132. deps.db.update(runs).set({ status: 'running', startedAt: nowIso() }).where(eq(runs.id, runId)).run()
  133. let scanTimer: NodeJS.Timeout | undefined
  134. let gatewayToken: string | null = null
  135. let session: SessionRow | undefined
  136. const scanFiles = async () => {
  137. if (!session) return
  138. for (const c of await deps.files.scan(sessionId, session.workspaceDir)) {
  139. emit('file.changed', { fileId: c.file.id, path: c.file.path, kind: c.kind, size: c.file.size, mime: c.file.mime })
  140. }
  141. }
  142. try {
  143. session = deps.sessions.getRow(sessionId)
  144. const settings = deps.settings.get()
  145. const { profile, permissionMode } = deps.sessions.effectiveProfile(session)
  146. const knowledgeRoots = deps.knowledge.list()
  147. const access = resolveAccess({
  148. policy: profile.fileAccess,
  149. knowledgeRoots,
  150. selectedKnowledgeRootIds: profile.knowledgeRootIds,
  151. workspaceDir: session.workspaceDir,
  152. settings,
  153. })
  154. emit('run.started', { provider: session.provider, model: session.model })
  155. const sessionRow = session
  156. gatewayToken = deps.gatewayTokens.issue({
  157. sessionId, runId, provider: session.provider, workspaceDir: session.workspaceDir, access, permissionMode, settings,
  158. knowledgeRoots: knowledgeRoots.filter(k => k.enabled && profile.knowledgeRootIds.includes(k.id)),
  159. audit: (kind, summary, detail) => deps.audit.log({ actor: `agent:${sessionRow.provider}`, sessionId, kind, summary, detail }),
  160. }, { stablePerSession: session.provider === 'codex' })
  161. const result = await provider.run({
  162. sessionId,
  163. runId,
  164. model: session.model,
  165. permissionMode,
  166. providerSessionId: session.providerSessionId,
  167. workspaceDir: session.workspaceDir,
  168. systemPromptAppend: buildSystemPrompt({
  169. workspaceDir: session.workspaceDir,
  170. settings,
  171. knowledgeRoots: knowledgeRoots.filter(k => k.enabled && profile.knowledgeRootIds.includes(k.id)),
  172. profilePrompt: profile.systemPrompt,
  173. sessionPrompt: session.systemPromptAppend,
  174. permissionMode,
  175. }),
  176. allowedTools: profile.allowedTools,
  177. disallowedTools: profile.disallowedTools,
  178. skills: profile.skills,
  179. // 插件只在 Claude 中加载;专有许可的插件不能提供给其他引擎(ADR-0013)。第三方技能 Codex 经 extraRoots 加载(ADR-0017)
  180. plugins: session.provider === 'claude' ? await this.claudePlugins() : [],
  181. // Codex 插件与应用连接器使用账号绑定的数据,只在后台 Playground(source = admin)中开放
  182. codexPlugins: session.provider === 'codex' && session.source === 'admin',
  183. input: await this.buildInput(session, text, attachmentIds),
  184. access,
  185. settings,
  186. signal: a.controller.signal,
  187. emit: (type, payload) => {
  188. emit(type, payload)
  189. if (type === 'tool.completed') {
  190. clearTimeout(scanTimer)
  191. scanTimer = setTimeout(() => void scanFiles().catch(() => {}), 300)
  192. }
  193. },
  194. interact: async req => {
  195. a.status = 'waiting_input'
  196. deps.db.update(runs).set({ status: 'waiting_input' }).where(eq(runs.id, runId)).run()
  197. try {
  198. return await deps.interactions.request(sessionId, runId, req, a.controller.signal)
  199. } finally {
  200. if (!deps.interactions.hasPending(runId) && !a.controller.signal.aborted) {
  201. a.status = 'running'
  202. deps.db.update(runs).set({ status: 'running' }).where(eq(runs.id, runId)).run()
  203. }
  204. }
  205. },
  206. setProviderSessionId: id => deps.sessions.setProviderSessionId(sessionId, id),
  207. audit: (kind, summary, detail) => deps.audit.log({ actor: `agent:${session!.provider}`, sessionId, kind, summary, detail }),
  208. log,
  209. gateway: { url: gatewayUrl(), token: gatewayToken },
  210. })
  211. clearTimeout(scanTimer)
  212. await scanFiles().catch(err => log.warn({ err }, 'file scan failed'))
  213. if (result.usage) {
  214. emit('usage', result.usage)
  215. deps.db.update(runs).set({
  216. inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens,
  217. cacheReadTokens: result.usage.cacheReadTokens, cacheWriteTokens: result.usage.cacheWriteTokens, costUsd: result.usage.costUsd ?? null,
  218. }).where(eq(runs.id, runId)).run()
  219. }
  220. this.finish(a, 'completed', { durationMs: Date.now() - startedAt })
  221. } catch (err) {
  222. clearTimeout(scanTimer)
  223. await scanFiles().catch(() => {})
  224. if (a.controller.signal.aborted) {
  225. this.finish(a, 'interrupted', { reason: 'user' })
  226. } else {
  227. const code = err instanceof ProviderRunError ? err.code : 'internal_error'
  228. const message = err instanceof Error ? err.message : String(err)
  229. log.error({ err }, 'run failed')
  230. this.finish(a, 'failed', { code, message })
  231. }
  232. } finally {
  233. if (gatewayToken) deps.gatewayTokens.revoke(gatewayToken)
  234. }
  235. }
  236. private finish(a: ActiveRun, status: 'completed', p: EventPayloads['run.completed']): void
  237. private finish(a: ActiveRun, status: 'failed', p: EventPayloads['run.failed']): void
  238. private finish(a: ActiveRun, status: 'interrupted', p: EventPayloads['run.interrupted']): void
  239. private finish(a: ActiveRun, status: 'completed' | 'failed' | 'interrupted', p: never) {
  240. if (!this.active.has(a.runId)) return
  241. this.active.delete(a.runId)
  242. this.deps.db.update(runs).set({
  243. status, endedAt: nowIso(), error: status === 'failed' ? (p as EventPayloads['run.failed']).message : null,
  244. }).where(eq(runs.id, a.runId)).run()
  245. this.deps.events.append(a.sessionId, a.runId, `run.${status}`, p)
  246. }
  247. /** Claude 运行时加载的插件:已启用的插件 + 第三方技能目录 */
  248. private async claudePlugins() {
  249. const community = this.deps.communitySkills.pluginRef()
  250. return [...await this.deps.plugins.loadable().catch(() => []), ...(community ? [community] : [])]
  251. }
  252. private async buildInput(session: SessionRow, text: string, attachmentIds: string[]): Promise<RunInput> {
  253. const input: RunInput = { text, images: [], attachments: [] }
  254. for (const f of this.deps.files.getMany(session.id, attachmentIds)) {
  255. const name = path.basename(f.relPath)
  256. input.attachments.push({ path: f.relPath, name, mime: f.mime, size: f.size })
  257. if (IMAGE_MIMES.has(f.mime) && f.size <= MAX_INLINE_IMAGE) {
  258. const buf = await fsp.readFile(path.join(session.workspaceDir, f.relPath))
  259. input.images.push({ mime: f.mime, base64: buf.toString('base64'), name })
  260. }
  261. }
  262. return input
  263. }
  264. private recoverAfterRestart() {
  265. const stale = this.deps.db.select().from(runs).where(inArray(runs.status, ['queued', 'running', 'waiting_input'])).all()
  266. for (const r of stale) {
  267. this.deps.db.update(runs).set({ status: 'interrupted', endedAt: nowIso(), error: '服务重启' }).where(eq(runs.id, r.id)).run()
  268. try {
  269. this.deps.events.append(r.sessionId, r.id, 'run.interrupted', { reason: 'restart' })
  270. } catch {
  271. // 会话已被删除
  272. }
  273. }
  274. if (stale.length) this.deps.log.warn({ count: stale.length }, '服务重启:未完成的 Run 已标记为 interrupted')
  275. }
  276. }
  277. function toRunDto(r: RunRow): RunDto {
  278. return {
  279. id: r.id, sessionId: r.sessionId, status: r.status, startedAt: r.startedAt, endedAt: r.endedAt, error: r.error,
  280. usage: r.inputTokens === null ? null : {
  281. inputTokens: r.inputTokens, outputTokens: r.outputTokens ?? 0, cacheReadTokens: r.cacheReadTokens ?? 0,
  282. cacheWriteTokens: r.cacheWriteTokens ?? 0, costUsd: r.costUsd ?? undefined,
  283. },
  284. }
  285. }
  286. function safe<T>(fn: () => T): T | undefined {
  287. try {
  288. return fn()
  289. } catch {
  290. return undefined
  291. }
  292. }