workspaceScheduler.ts 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  1. import { randomUUID } from 'node:crypto'
  2. import { WorkspaceStore } from './workspaceStore.js'
  3. import { HttpError } from './errors.js'
  4. import { discussionOrder } from './workspaceDiscussion.js'
  5. import { botRelayIntent, relayFingerprint, relayMessageValue, REPEATED_RELAY_NOTICE } from './workspaceRelay.js'
  6. import type { WorkspaceAgent as Agent, WorkspaceConversation as Conversation, WorkspaceMessage as Message, WorkspaceRun as Run, WorkspaceInteraction } from '../shared/workspace.js'
  7. export const NO_REPLY = '[[YAOYAO_NO_REPLY_V1]]'
  8. export const RESOURCE_WAIT_CODES = new Set(['computer_busy', 'computer_quota'])
  9. export const WORKSPACE_CONCURRENCY_LIMIT = 10
  10. export const HOST_FALLBACK = '我还不能确定你希望我处理什么,请补充具体目标、范围,或明确需要我协调的机器人。'
  11. const terminal = (status: string) => ['complete', 'failed', 'interrupted'].includes(status)
  12. export interface Work {
  13. discussionIndex?: number
  14. resourceWait?:boolean
  15. id: string
  16. runId: string
  17. conversationId: string
  18. conversationTaskId?: string
  19. agentId: string
  20. messageId: string
  21. triggerSeq: number
  22. depth: number
  23. batchId: string
  24. reviewOf?: string
  25. replyMode: 'mentioned' | 'automatic'
  26. requiredReply: boolean
  27. status: Run['status']
  28. currentMessageId?: string
  29. submitted?: boolean
  30. /** Revision captured when this turn receives a team-management grant. */
  31. teamManagementRevision?: number
  32. cancelRequested?: boolean
  33. /** A locally interrupted turn still owes a remote interrupt confirmation. */
  34. cleanupPending?: boolean
  35. cleanupError?: string
  36. planned?: boolean
  37. silent?: boolean
  38. hadInteraction?: boolean
  39. /** Durable assistant-to-assistant routing decisions, scoped to this run. */
  40. relay?: {
  41. fingerprints: Record<string, string>
  42. suppressed: Record<string, 'acknowledgement' | 'repeated'>
  43. notice?: boolean
  44. }
  45. error?: string
  46. contextThroughSeq?: number
  47. turnConfiguration?: { mode: 'host' | 'free'; administratorId: string; members: Array<Pick<Agent, 'id' | 'name'>> }
  48. createdAt: number
  49. }
  50. /** Durable per-member work and cascade ledger; Hermes transport lives in WorkspaceRuntime. */
  51. export abstract class WorkspaceScheduler {
  52. protected closing = false
  53. protected executing = new Set<string>()
  54. private wakePending = false
  55. private pumping = false
  56. private retryTimer?: ReturnType<typeof setTimeout>
  57. private recoverAfter = new Map<string, number>()
  58. private attempts = new Map<string, number>()
  59. private interrupting = new Set<string>()
  60. onMessage: (owner: string, message: Message) => Promise<void> = async () => {}
  61. onNotify: (owner: string, c: Conversation, run: Run, message?: Message, interaction?: WorkspaceInteraction) => void = () => {}
  62. onRunSettled: (owner: string, run: Run) => void = () => {}
  63. constructor(readonly store: WorkspaceStore, readonly userActive: (owner: string) => boolean = () => true) {
  64. for (const owner of store.owners()) {
  65. // Forward the already-admitted v0.3.x turn without replaying its prompt.
  66. for (const root of store.list<Run & { queue?: string[]; next?: string[]; hostReturn?: boolean; round: number; currentMessageId?: string; turnConfiguration?: Work['turnConfiguration'] }>(owner, 'run')) {
  67. if (terminal(root.status) || this.works(owner, root.id).length) continue
  68. const c = store.require<Conversation>(owner, 'conversation', root.conversationId)
  69. const trigger = store.require<Message>(owner, 'message', root.messageId)
  70. const continuation = root.queue?.length === 0
  71. const ids = continuation ? root.hostReturn ? [c.administratorId] : root.next ?? [] : root.queue ?? [root.activeAgentId ?? c.administratorId]
  72. if (!ids.length && root.currentMessageId) {
  73. const agentId = store.get<Message>(owner, 'message', root.currentMessageId)?.agentId ?? root.activeAgentId
  74. if (agentId) ids.push(agentId)
  75. }
  76. if (!ids.length) { root.status = root.stopRequested ? 'interrupted' : 'complete'; store.saveRun(owner, root); continue }
  77. const last = store.messages(owner, c.id).filter(m => m.runId === root.id).at(-1)
  78. for (const [index, id] of ids.entries()) {
  79. const work = this.enqueue(owner, root, id, root.round + (continuation && !root.hostReturn ? 1 : 0), root.id, last?.seq ?? trigger.seq, 'mentioned', id === c.administratorId)
  80. if (index === 0 && root.currentMessageId) {
  81. Object.assign(work, { currentMessageId: root.currentMessageId, submitted: true, status: 'uncertain', turnConfiguration: root.turnConfiguration })
  82. store.put(owner, 'turn', work.id, work)
  83. const key = this.bindingKey(c.id, root.conversationTaskId, id)
  84. const binding = store.get<{ runId: string; messageId: string; taskId?: string }>(owner, 'binding', key)
  85. if (binding?.runId === root.id && binding.messageId === root.currentMessageId) {
  86. binding.taskId = work.id; store.put(owner, 'binding', key, binding)
  87. }
  88. }
  89. }
  90. }
  91. for (const work of this.works(owner)) {
  92. if (['running', 'waiting'].includes(work.status)) {
  93. work.status = work.submitted ? 'uncertain' : 'queued'
  94. work.error = work.submitted ? '服务已重启,正在核对原执行' : undefined
  95. store.put(owner, 'turn', work.id, work)
  96. }
  97. }
  98. }
  99. }
  100. protected notify(owner: string, c: Conversation, run: Run, message?: Message, interaction?: WorkspaceInteraction): void {
  101. try { this.onNotify(owner, c, run, message, interaction) } catch { /* Push cannot change execution. */ }
  102. }
  103. protected abstract performTurn(owner: string, c: Conversation, agent: Agent, work: Work, recovering: boolean): Promise<Message>
  104. protected abstract interruptTurn(owner: string, work: Work): Promise<void>
  105. protected bindingKey(conversationId: string, conversationTaskId: string | undefined, agentId: string): string {
  106. return conversationTaskId ? `${conversationId}:${conversationTaskId}:${agentId}` : `${conversationId}:${agentId}`
  107. }
  108. protected works(owner: string, runId?: string): Work[] {
  109. return this.store.db.prepare(`SELECT data FROM workspace_entities WHERE owner=? AND kind='turn' ${runId ? "AND json_extract(data,'$.runId')=?" : ''}`).all(...(runId ? [owner, runId] : [owner]))
  110. .map(row => JSON.parse(String(row.data)) as Work)
  111. }
  112. private pendingWorks(owner: string): Work[] {
  113. return this.store.db.prepare("SELECT data FROM workspace_entities WHERE owner=? AND kind='turn' AND (json_extract(data,'$.status') NOT IN ('complete','failed','interrupted') OR coalesce(json_extract(data,'$.planned'),0)=0 OR coalesce(json_extract(data,'$.cleanupPending'),0)=1)").all(owner)
  114. .map(row => JSON.parse(String(row.data)) as Work)
  115. }
  116. protected getWork(owner: string, id: string): Work { return this.store.require(owner, 'turn', id) }
  117. protected saveWork(owner: string, work: Work): void {
  118. this.store.atomic(() => { this.store.put(owner, 'turn', work.id, work); this.updateRoot(owner, work.runId) })
  119. }
  120. protected resolveInteractions(owner: string, taskId: string): void {
  121. for (const interaction of this.store.list<WorkspaceInteraction>(owner, 'interaction')) {
  122. const binding = this.store.get<{ taskId?: string }>(owner, 'interaction-binding', interaction.id)
  123. if (binding?.taskId === taskId && !interaction.resolved) {
  124. interaction.resolved = true
  125. this.store.put(owner, 'interaction', interaction.id, interaction)
  126. this.store.event(owner, 'interaction.changed', interaction, interaction.conversationId)
  127. }
  128. }
  129. }
  130. protected enqueue(owner: string, root: Run, agentId: string, depth: number, batchId: string, triggerSeq: number, replyMode: Work['replyMode'], requiredReply: boolean, reviewOf?: string): Work {
  131. const existing = this.works(owner, root.id).find(w => reviewOf ? w.reviewOf === reviewOf : w.agentId === agentId && w.depth === depth && !w.reviewOf)
  132. if (existing) return existing
  133. const work: Work = { id: randomUUID(), runId: root.id, conversationId: root.conversationId, conversationTaskId: root.conversationTaskId, agentId, messageId: root.messageId, depth, batchId, triggerSeq, replyMode, requiredReply, reviewOf, status: 'queued', createdAt: Date.now() }
  134. this.store.put(owner, 'turn', work.id, work)
  135. return work
  136. }
  137. protected admit(owner: string, root: Run, c: Conversation, trigger: Message): void {
  138. this.store.put(owner, 'run', root.id, root)
  139. if (root.discussion) {
  140. const first = discussionOrder(root.discussion)[0]
  141. if (first) {
  142. const work = this.enqueue(owner, root, first.agentId, 0, root.id, trigger.seq, 'mentioned', false)
  143. work.discussionIndex = 0
  144. this.store.put(owner, 'turn', work.id, work)
  145. }
  146. this.updateRoot(owner, root.id)
  147. return
  148. }
  149. const targeted = root.targetAgentId
  150. const ids = targeted ? [root.targetAgentId!] : c.kind === 'direct' || c.mode === 'host' ? [c.administratorId]
  151. : root.mentionIds.length ? root.mentionIds : [...new Set([c.administratorId, ...c.autoReplyIds])]
  152. for (const id of ids) this.enqueue(owner, root, id, 0, root.id, trigger.seq,
  153. c.kind === 'direct' || targeted ? 'mentioned' : c.mode === 'host' || !root.mentionIds.length ? 'automatic' : 'mentioned', !!targeted || c.kind === 'group' && id === c.administratorId && (c.mode === 'host' || !root.mentionIds.length))
  154. this.updateRoot(owner, root.id)
  155. }
  156. start(): void { this.wake() }
  157. /** Maintenance uses the same bounded retry loop as durable turn cleanup. */
  158. protected maintenance(): boolean { return false }
  159. wake(): void {
  160. if (this.closing || this.wakePending) return
  161. this.wakePending = true
  162. queueMicrotask(() => { this.wakePending = false; if (!this.closing) this.pump() })
  163. }
  164. private retrySoon(): void {
  165. if (this.closing || this.retryTimer) return
  166. this.retryTimer = setTimeout(() => { this.retryTimer = undefined; this.wake() }, 500)
  167. this.retryTimer.unref()
  168. }
  169. private updateRoot(owner: string, id: string): void {
  170. const root = this.store.require<Run>(owner, 'run', id), tasks = this.works(owner, id)
  171. const active = tasks.filter(t => !terminal(t.status))
  172. const done = !active.length && tasks.every(t => t.planned)
  173. const wasTerminal = terminal(root.status)
  174. root.round = Math.max(0, ...tasks.map(t => t.depth))
  175. root.activeAgentId = active.find(t => t.status !== 'queued')?.agentId ?? active[0]?.agentId
  176. if (done) {
  177. const interrupted = root.stopRequested || tasks.some(t => t.requiredReply && t.status === 'interrupted') || (tasks.length > 0 && tasks.every(t => t.status === 'interrupted'))
  178. const failed = tasks.some(t => t.status === 'failed' && (t.requiredReply || this.store.require<Conversation>(owner, 'conversation', root.conversationId).kind === 'direct'))
  179. || !!root.discussion && tasks.length > 0 && tasks.every(t => ['failed', 'interrupted'].includes(t.status))
  180. root.status = interrupted ? 'interrupted' : failed ? 'failed' : 'complete'
  181. } else {
  182. root.status = active.some(t => t.status === 'uncertain') ? 'uncertain' : active.some(t => t.status === 'running') ? 'running' : active.some(t => t.status === 'waiting') ? 'waiting' : tasks.some(t => t.status !== 'queued') ? 'running' : 'queued'
  183. }
  184. root.error = tasks.find(t => t.status === 'uncertain' || t.status === 'failed')?.error ?? active.find(t => t.error)?.error
  185. this.store.saveRun(owner, root)
  186. if (done && !wasTerminal) this.onRunSettled(owner, root)
  187. if (done && !wasTerminal && root.status !== 'interrupted') {
  188. const c = this.store.require<Conversation>(owner, 'conversation', root.conversationId)
  189. if (root.status === 'failed' && c.kind === 'direct') this.store.saveMessage(owner, { id: randomUUID(), conversationId: c.id, conversationTaskId: root.conversationTaskId, seq: 0, role: 'system', content: `执行失败:${root.error ?? '运行失败'}`, reasoning: '', status: 'failed', runId: root.id, attachments: [], tools: [], createdAt: Date.now() })
  190. this.notify(owner, c, root, this.store.messages(owner, c.id, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, false, root.conversationTaskId).filter(m => m.runId === id && m.visible !== false).at(-1))
  191. }
  192. }
  193. private plan(owner: string, id: string): void {
  194. this.store.atomic(() => {
  195. const work = this.getWork(owner, id)
  196. if (!terminal(work.status) || work.planned) return
  197. const root = this.store.require<Run>(owner, 'run', work.runId), c = this.store.require<Conversation>(owner, 'conversation', work.conversationId)
  198. work.planned = true
  199. this.store.put(owner, 'turn', work.id, work)
  200. if (root.discussion && !root.stopRequested && !c.archived) {
  201. const order = discussionOrder(root.discussion)
  202. let index = (work.discussionIndex ?? 0) + 1
  203. while (index < order.length) {
  204. const next = order[index]!
  205. const agent = this.store.get<Agent>(owner, 'agent', next.agentId)
  206. if (agent && !agent.archived && c.memberIds.includes(next.agentId)) {
  207. const through = Math.max(work.triggerSeq, ...this.works(owner, root.id).map(w => w.currentMessageId ? this.store.get<Message>(owner, 'message', w.currentMessageId)?.seq ?? 0 : 0))
  208. const queued = this.enqueue(owner, root, next.agentId, next.round, root.id, through, 'mentioned', false)
  209. queued.discussionIndex = index
  210. this.store.put(owner, 'turn', queued.id, queued)
  211. break
  212. }
  213. index++
  214. }
  215. this.updateRoot(owner, root.id)
  216. return
  217. }
  218. if (root.stopRequested || c.archived || c.kind === 'direct' || root.assignmentId || root.goalId || root.peerMessageId) { this.updateRoot(owner, root.id); return }
  219. const config = work.turnConfiguration ?? { mode: c.mode, administratorId: c.administratorId, members: c.memberIds.map(id => this.store.require<Agent>(owner, 'agent', id)) }
  220. const message = work.currentMessageId ? this.store.get<Message>(owner, 'message', work.currentMessageId) : undefined
  221. if (config.mode === 'host' && work.agentId !== config.administratorId) {
  222. const batch = this.works(owner, root.id).filter(t => t.batchId === work.batchId && !t.reviewOf)
  223. if (batch.every(t => terminal(t.status))) {
  224. const through = Math.max(work.triggerSeq, ...batch.map(t => t.currentMessageId ? this.store.get<Message>(owner, 'message', t.currentMessageId)?.seq ?? 0 : 0))
  225. this.enqueue(owner, root, c.administratorId, work.depth, `review:${work.batchId}`, through, 'automatic', true, work.batchId)
  226. }
  227. } else if (work.status === 'complete' && !work.silent && message) {
  228. const nextDepth = work.depth + 1
  229. const intent = botRelayIntent(message.content, config.members)
  230. const eligible = (id: string) => id !== work.agentId && this.store.taskMemberIds(owner, c, work.conversationTaskId).includes(id)
  231. const mentions = intent.targetIds.filter(eligible)
  232. // A filtered mention is still an explicit address. Never turn a closing
  233. // receipt back into a broadcast through the automatic participant list.
  234. const automatic = config.mode === 'free' ? c.autoReplyIds.filter(eligible) : []
  235. const targets = intent.mentionedIds.length ? mentions : intent.acknowledgementOnly ? [] : automatic
  236. work.relay = { fingerprints: {}, suppressed: {} }
  237. for (const target of intent.mentionedIds.filter(eligible)) {
  238. if (!mentions.includes(target)) work.relay.suppressed[target] = 'acknowledgement'
  239. }
  240. if (intent.acknowledgementOnly && !intent.mentionedIds.length)
  241. for (const target of automatic) work.relay.suppressed[target] = 'acknowledgement'
  242. if (targets.length && (c.maxReplyRounds === -1 || nextDepth < c.maxReplyRounds)) {
  243. const works = this.works(owner, root.id).filter(t => t.conversationId === c.id && t.conversationTaskId === work.conversationTaskId)
  244. const evidence = this.relayEvidence(owner, work, works)
  245. const previous = works.filter(t => t.id !== work.id && t.agentId === work.agentId && t.status === 'complete' && t.planned)
  246. const legacyEvidence = new Map<string, unknown>()
  247. for (const target of targets) {
  248. const fingerprint = evidence === undefined ? undefined : relayFingerprint({ target, evidence })
  249. if (fingerprint) work.relay.fingerprints[target] = fingerprint
  250. const repeated = fingerprint && previous.some(t => {
  251. if (t.relay) return t.relay.fingerprints[target] === fingerprint
  252. // Pre-upgrade turns have no receipt. Only a persisted child is
  253. // evidence that this particular edge was actually dispatched.
  254. if (!works.some(child => child.batchId === t.id && child.agentId === target && !child.reviewOf)) return false
  255. if (!legacyEvidence.has(t.id)) legacyEvidence.set(t.id, this.relayEvidence(owner, t, works))
  256. const old = legacyEvidence.get(t.id)
  257. return old !== undefined && relayFingerprint({ target, evidence: old }) === fingerprint
  258. })
  259. if (repeated) { work.relay.suppressed[target] = 'repeated'; continue }
  260. this.enqueue(owner, root, target, nextDepth, work.id, message.seq, mentions.includes(target) ? 'mentioned' : 'automatic', false)
  261. }
  262. if (Object.values(work.relay.suppressed).includes('repeated') && !works.some(t => t.relay?.notice)) {
  263. work.relay.notice = true
  264. this.store.saveMessage(owner, { id: randomUUID(), conversationId: c.id, conversationTaskId: root.conversationTaskId, seq: 0, role: 'system', content: REPEATED_RELAY_NOTICE, reasoning: '', status: 'complete', runId: root.id, attachments: [], tools: [], createdAt: Date.now() })
  265. }
  266. } else if (targets.length && !this.store.get(owner, 'limit-notice', root.id)) {
  267. this.store.put(owner, 'limit-notice', root.id, { id: root.id })
  268. this.store.saveMessage(owner, { id: randomUUID(), conversationId: c.id, conversationTaskId: root.conversationTaskId, seq: 0, role: 'system', content: '已达到自动协作轮数上限,本轮不再自动分派。', reasoning: '', status: 'complete', runId: root.id, attachments: [], tools: [], createdAt: Date.now() })
  269. }
  270. this.store.put(owner, 'turn', work.id, work)
  271. }
  272. this.updateRoot(owner, root.id)
  273. })
  274. }
  275. private relayEvidence(owner: string, work: Work, works: Work[]): unknown | undefined {
  276. const read = (id?: string) => {
  277. const message = id ? this.store.get<Message>(owner, 'message', id) : undefined
  278. return message?.conversationId === work.conversationId && message.conversationTaskId === work.conversationTaskId && message.runId === work.runId ? message : undefined
  279. }
  280. const reply = read(work.currentMessageId)
  281. if (!reply) return undefined
  282. const inputs = work.reviewOf ? works.filter(t => t.batchId === work.reviewOf && !t.reviewOf).sort((a, b) => a.agentId.localeCompare(b.agentId))
  283. : works.filter(t => t.id === work.batchId)
  284. let input: unknown
  285. if (inputs.length) {
  286. input = inputs.map(t => {
  287. const message = read(t.currentMessageId)
  288. return { agentId: t.agentId, status: t.status, error: t.error, message: message ? relayMessageValue(message) : null }
  289. })
  290. } else {
  291. // Initial turns and the legacy admission path have no parent Work.
  292. const row = this.store.db.prepare("SELECT data FROM workspace_entities WHERE owner=? AND kind='message' AND json_extract(data,'$.conversationId')=? AND json_extract(data,'$.seq')=?").get(owner, work.conversationId, work.triggerSeq)
  293. const trigger = row ? read((JSON.parse(String(row.data)) as Message).id) : undefined
  294. if (!trigger) return undefined
  295. input = relayMessageValue(trigger)
  296. }
  297. const relevant = new Set([work.id, ...inputs.map(t => t.id)])
  298. const interactions = this.store.list<WorkspaceInteraction & { answer?: string; responseState?: string }>(owner, 'interaction')
  299. .filter(i => i.runId === work.runId && i.conversationId === work.conversationId && i.conversationTaskId === work.conversationTaskId && i.answer !== undefined && i.responseState === 'sent'
  300. && relevant.has(this.store.get<{ taskId: string }>(owner, 'interaction-binding', i.id)?.taskId ?? ''))
  301. // A fresh human answer is permission to advance, even if the person
  302. // chooses the same answer again. This is not a provider event ID.
  303. .map(i => ({ id: i.id, kind: i.kind, question: i.message, answer: i.answer })).sort((a, b) => a.id.localeCompare(b.id))
  304. return { version: 1, agentId: work.agentId, mode: work.turnConfiguration?.mode, input, reply: relayMessageValue(reply), interactions }
  305. }
  306. private pump(): void {
  307. if (this.pumping || this.closing) return
  308. this.pumping = true
  309. try {
  310. if(this.maintenance())this.retrySoon()
  311. let all = this.store.owners().flatMap(owner => this.pendingWorks(owner).map(work => ({ owner, work })))
  312. for (const { owner, work } of all) {
  313. if (terminal(work.status) && work.cleanupPending) this.scheduleInterruptCleanup(owner, work)
  314. }
  315. for (const { owner, work } of all) {
  316. if (['running', 'waiting'].includes(work.status) && !this.executing.has(work.id)) {
  317. work.status = work.submitted ? 'uncertain' : 'queued'
  318. this.saveWork(owner, work)
  319. }
  320. }
  321. for (const { owner, work } of all) if (terminal(work.status) && !work.planned && !this.executing.has(work.id)) this.plan(owner, work.id)
  322. all = this.store.owners().flatMap(owner => this.pendingWorks(owner).map(work => ({ owner, work })))
  323. all.sort((a, b) => {
  324. const priority = (entry: typeof a) => {
  325. if (Date.now() - entry.work.createdAt > 60_000) return 0
  326. const run = this.store.get<Run>(entry.owner, 'run', entry.work.runId)
  327. if (run?.triggerKind === 'peer' && run.priority) return 0.5
  328. return run?.triggerKind === 'assignment' ? 2 : run?.triggerKind ? 1 : 0
  329. }
  330. const difference = priority(a) - priority(b)
  331. if (difference) return difference
  332. const am = this.store.require<Message>(a.owner, 'message', a.work.messageId), bm = this.store.require<Message>(b.owner, 'message', b.work.messageId)
  333. return am.createdAt - bm.createdAt || (a.work.conversationId === b.work.conversationId && a.work.conversationTaskId === b.work.conversationTaskId ? am.seq - bm.seq : 0) || a.work.createdAt - b.work.createdAt || a.work.id.localeCompare(b.work.id)
  334. })
  335. for (const { owner, work: candidate } of all) {
  336. const work = this.getWork(owner, candidate.id)
  337. if (work.cancelRequested && work.status === 'uncertain' && this.executing.has(work.id)) {
  338. if ((this.recoverAfter.get(work.id) ?? 0) > Date.now()) { this.retrySoon(); continue }
  339. if (!this.interrupting.has(work.id)) {
  340. this.interrupting.add(work.id)
  341. void this.interruptTurn(owner, work).catch(error => {
  342. if (this.closing) return
  343. const current = this.getWork(owner, work.id)
  344. if (terminal(current.status)) return
  345. current.status = 'uncertain'; current.error = `停止状态待确认:${error instanceof Error ? error.message : '连接中断'}`
  346. this.saveWork(owner, current)
  347. const attempt = this.attempts.get(work.id) ?? 0
  348. this.attempts.set(work.id, attempt + 1)
  349. this.recoverAfter.set(work.id, Date.now() + Math.min(30_000, 500 * 2 ** Math.min(attempt, 6)))
  350. this.retrySoon()
  351. }).finally(() => { this.interrupting.delete(work.id); this.wake() }).catch(() => this.retrySoon())
  352. }
  353. continue
  354. }
  355. if (this.executing.size >= WORKSPACE_CONCURRENCY_LIMIT || !['queued', 'uncertain'].includes(work.status) || this.executing.has(work.id)) continue
  356. if (!this.userActive(owner)) { this.retrySoon(); continue }
  357. const c = this.store.require<Conversation>(owner, 'conversation', work.conversationId), root = this.store.require<Run>(owner, 'run', work.runId)
  358. if (work.status === 'queued' && !root.targetAgentId && !root.discussion && c.mode === 'host' && work.depth === 0 && work.batchId === root.id && work.requiredReply && !work.currentMessageId) {
  359. work.agentId = c.administratorId
  360. }
  361. if (work.status === 'queued' && (c.archived || root.stopRequested || !this.store.taskMemberIds(owner,c,work.conversationTaskId).includes(work.agentId))) {
  362. work.status = 'interrupted'; work.error = '执行前成员已移除或聊天已停止'; this.saveWork(owner, work); this.wake(); continue
  363. }
  364. const occupied = all.map(entry => this.getWork(entry.owner, entry.work.id)).filter(t => (t.cleanupPending || ['running', 'waiting', 'uncertain'].includes(t.status) || this.executing.has(t.id)) && t.id !== work.id)
  365. // Only the conversation/task/Agent binding shares mutable session state.
  366. // Other sessions may run concurrently; computer leases and tool queues
  367. // continue to serialize access to their actual shared resources.
  368. if (occupied.some(t => t.conversationId === c.id && t.conversationTaskId === work.conversationTaskId && t.agentId === work.agentId)) continue
  369. const inTask = occupied.filter(t => t.conversationId === c.id && t.conversationTaskId === work.conversationTaskId)
  370. if (root.discussion && inTask.length) continue
  371. if (work.status === 'queued' && (occupied.length >= WORKSPACE_CONCURRENCY_LIMIT || inTask.length >= 3)) continue
  372. if ((this.recoverAfter.get(work.id) ?? 0) > Date.now()) { this.retrySoon(); continue }
  373. if (c.mode === 'host' || c.kind === 'direct') {
  374. if (inTask.some(t => c.kind === 'direct'
  375. || (!root.assignmentId && (t.runId !== work.runId || t.batchId !== work.batchId))
  376. || t.agentId === c.administratorId || work.agentId === c.administratorId)) continue
  377. }
  378. this.executing.add(work.id)
  379. const recovering = work.status === 'uncertain'
  380. if (!recovering) {
  381. work.status = 'running';work.resourceWait=false
  382. if (c.kind === 'group' && c.mode === 'host' && work.agentId === c.administratorId) { work.requiredReply = true; work.replyMode = 'automatic' }
  383. work.turnConfiguration = { mode: c.mode, administratorId: c.administratorId, members: this.store.taskMemberIds(owner,c,work.conversationTaskId).map(id => { const a = this.store.require<Agent>(owner, 'agent', id); return { id, name: a.name } }) }
  384. this.saveWork(owner, work)
  385. }
  386. void this.executeWork(owner, work, recovering).catch(() => this.retrySoon())
  387. }
  388. } catch { this.retrySoon() } finally { this.pumping = false }
  389. }
  390. private async executeWork(owner: string, work: Work, recovering: boolean): Promise<void> {
  391. try {
  392. if (work.cancelRequested) await this.interruptTurn(owner, work)
  393. else {
  394. const c = this.store.require<Conversation>(owner, 'conversation', work.conversationId)
  395. const agent = this.store.require<Agent>(owner, 'agent', work.agentId)
  396. const message = await this.performTurn(owner, c, agent, work, recovering)
  397. if (message.visible !== false) await this.onMessage(owner, message).catch(() => {})
  398. }
  399. } catch (error) {
  400. if (this.closing) return
  401. const current = this.getWork(owner, work.id)
  402. if(!current.submitted&&error instanceof HttpError&&RESOURCE_WAIT_CODES.has(error.code??'')&&current.status!=='interrupted'){
  403. current.status='queued';current.resourceWait=true;current.error='等待执行节点或电脑资源';this.saveWork(owner,current)
  404. }else if (!terminal(current.status) && current.status !== 'uncertain') {
  405. current.status = current.submitted ? 'uncertain' : 'failed'
  406. current.error = error instanceof Error ? error.message : '执行失败'
  407. this.saveWork(owner, current)
  408. }
  409. if (current.status === 'failed' && !current.currentMessageId) {
  410. current.currentMessageId = randomUUID()
  411. this.store.saveMessage(owner, { id: current.currentMessageId, conversationId: current.conversationId, conversationTaskId: current.conversationTaskId, seq: 0, role: 'assistant', agentId: current.agentId, agentName: this.store.get<Agent>(owner, 'agent', current.agentId)?.name, content: `执行失败:${current.error}`, reasoning: '', status: 'failed', error: current.error, taskId: current.id, runId: current.runId, attachments: [], tools: [], createdAt: Date.now() })
  412. this.saveWork(owner, current)
  413. }
  414. } finally {
  415. this.executing.delete(work.id)
  416. if (!this.closing) {
  417. const current = this.getWork(owner, work.id)
  418. if(current.status==='queued'&&current.resourceWait){this.recoverAfter.set(work.id,Date.now()+1000);this.retrySoon()}else if (current.status === 'uncertain') {
  419. const attempt = this.attempts.get(work.id) ?? 0
  420. this.attempts.set(work.id, attempt + 1)
  421. this.recoverAfter.set(work.id, Date.now() + Math.min(30_000, 500 * 2 ** Math.min(attempt, 6)))
  422. this.retrySoon()
  423. } else { this.attempts.delete(work.id); this.recoverAfter.delete(work.id) }
  424. this.wake()
  425. }
  426. }
  427. }
  428. async reconcile(owner: string, id: string): Promise<void> {
  429. this.store.require<Run>(owner, 'run', id)
  430. for (const w of this.works(owner, id)) this.recoverAfter.delete(w.id)
  431. this.wake()
  432. }
  433. async stop(owner: string, id: string): Promise<void> {
  434. const root = this.store.require<Run>(owner, 'run', id)
  435. if (terminal(root.status)) return
  436. root.stopRequested = true
  437. this.store.saveRun(owner, root)
  438. await this.cancelWorks(owner, this.works(owner, id))
  439. }
  440. async stopAgent(owner: string, conversationId: string, agentId: string, conversationTaskId?: string): Promise<void> {
  441. const c = this.store.require<Conversation>(owner, 'conversation', conversationId)
  442. const helper=this.store.get<Agent>(owner,'agent',agentId)
  443. const scopedTaskId = c.kind === 'group' ? this.store.resolveTask(owner, conversationId, conversationTaskId??helper?.temporaryGoalId)!.id : undefined
  444. if (!this.store.taskMemberIds(owner,c,scopedTaskId).includes(agentId)) throw new HttpError(400, '只能停止当前任务成员', 'invalid_members')
  445. await this.cancelWorks(owner, this.works(owner).filter(w => w.conversationId === conversationId && w.agentId === agentId && w.conversationTaskId === scopedTaskId))
  446. }
  447. async stopTask(owner: string, conversationId: string, conversationTaskId: string): Promise<void> {
  448. this.store.requireTask(owner, conversationId, conversationTaskId)
  449. const roots = this.store.list<Run>(owner, 'run').filter(r => r.conversationId === conversationId && r.conversationTaskId === conversationTaskId && !terminal(r.status))
  450. this.store.atomic(() => { for (const root of roots) { root.stopRequested = true; this.store.saveRun(owner, root) } })
  451. await this.cancelWorks(owner, this.works(owner).filter(w => w.conversationId === conversationId && w.conversationTaskId === conversationTaskId))
  452. }
  453. async stopConversation(owner: string, id: string): Promise<void> {
  454. this.store.require<Conversation>(owner, 'conversation', id)
  455. const roots = this.store.list<Run>(owner, 'run').filter(r => r.conversationId === id && !terminal(r.status))
  456. this.store.atomic(() => { for (const root of roots) { root.stopRequested = true; this.store.saveRun(owner, root) } })
  457. await this.cancelWorks(owner, this.works(owner).filter(w => roots.some(r => r.id === w.runId)))
  458. }
  459. private async cancelWorks(owner: string, works: Work[]): Promise<void> {
  460. for (const w of works) {
  461. if (terminal(w.status)) continue
  462. const current = this.getWork(owner, w.id)
  463. const wasQueued = current.status === 'queued'
  464. current.cancelRequested = true
  465. current.status = 'interrupted'
  466. current.error = '已停止'
  467. current.planned = true
  468. current.cleanupPending = !wasQueued
  469. if (current.currentMessageId) {
  470. const message = this.store.require<Message>(owner, 'message', current.currentMessageId)
  471. message.status = 'interrupted'
  472. message.error = '已停止'
  473. this.store.atomic(() => {
  474. this.store.saveMessage(owner, message)
  475. this.saveWork(owner, current)
  476. })
  477. } else this.saveWork(owner, current)
  478. this.resolveInteractions(owner, current.id)
  479. if (current.cleanupPending) this.scheduleInterruptCleanup(owner, current)
  480. }
  481. this.wake()
  482. }
  483. private scheduleInterruptCleanup(owner: string, work: Work): void {
  484. if (this.closing || this.interrupting.has(work.id)) return
  485. if ((this.recoverAfter.get(work.id) ?? 0) > Date.now()) { this.retrySoon(); return }
  486. this.interrupting.add(work.id)
  487. let succeeded = false
  488. void this.interruptTurn(owner, work)
  489. .then(() => {
  490. succeeded = true
  491. const current = this.getWork(owner, work.id)
  492. current.cleanupPending = false
  493. current.cleanupError = undefined
  494. this.saveWork(owner, current)
  495. })
  496. .catch(error => {
  497. if (this.closing) return
  498. const current = this.getWork(owner, work.id)
  499. current.cleanupPending = true
  500. current.cleanupError = error instanceof Error ? error.message : '连接中断'
  501. this.saveWork(owner, current)
  502. const attempt = this.attempts.get(work.id) ?? 0
  503. this.attempts.set(work.id, attempt + 1)
  504. this.recoverAfter.set(work.id, Date.now() + Math.min(30_000, 500 * 2 ** Math.min(attempt, 6)))
  505. this.retrySoon()
  506. })
  507. .finally(() => {
  508. this.interrupting.delete(work.id)
  509. if (succeeded) {
  510. this.attempts.delete(work.id)
  511. this.recoverAfter.delete(work.id)
  512. }
  513. this.wake()
  514. })
  515. .catch(() => this.retrySoon())
  516. }
  517. protected closeScheduler(): void {
  518. this.closing = true
  519. if (this.retryTimer) clearTimeout(this.retryTimer)
  520. this.recoverAfter.clear()
  521. this.attempts.clear()
  522. }
  523. }