@v587d/capital-generation 2.11.1 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,858 @@
1
+ /**
2
+ * One run = one fresh Agent session. The runner owns prompt admission, budgets, cancellation,
3
+ * the session subtree, and the status transition; it never reads market data or touches a
4
+ * Dataset. Session creation failing still leaves a visible synthetic failed run.
5
+ */
6
+ import { mkdir } from 'node:fs/promises'
7
+ import { CANCEL_GRACE_MS, MAX_ERRORS_PER_RUN, QUIET_SETTLE_MS } from './defaults.js'
8
+ import { sanitizeMessage } from './redact.js'
9
+
10
+ const PRESET_ID = 'capital-generation'
11
+ const POLICY_ID = 'automation-v1'
12
+ const MAX_SESSION_LINEAGE = 32
13
+ const MAX_COUNTED_EVENT_KEYS = 12_000
14
+ const MAX_SUMMARY_CHARS = 2_000
15
+ const MAX_ACTIVE_RUNS = 2
16
+ const FLUSH_TIMEOUT_MS = 5_000
17
+ const DISPOSE_TIMEOUT_MS = CANCEL_GRACE_MS + FLUSH_TIMEOUT_MS
18
+ const MODE_GUIDANCE = {
19
+ news: '资讯模式:先给当日要点,再按主题分节;每条信息标注来源与时间,旧闻不得冒充新闻。',
20
+ market: '行情模式:先给价格/量能/资金的关键读数与口径,再给异动解释;数值必须带采集时间。',
21
+ review: '复盘模式:先给一句话结论与下一交易日观察条件,再展开证据;不得用未核验数据推断。',
22
+ research: '研究模式:先给结论与风险,再给证据、假设与验证路径;不做交易指令或收益承诺。',
23
+ }
24
+
25
+ function service(ctx, name) {
26
+ try {
27
+ return ctx.get(name)
28
+ } catch {
29
+ return undefined
30
+ }
31
+ }
32
+
33
+ function iso(now) {
34
+ return new Date(now).toISOString()
35
+ }
36
+
37
+ function shortRunId(id) {
38
+ return String(id).replace(/^run_/, '').slice(-8) || 'unknown'
39
+ }
40
+
41
+ export function sessionTitleOf(task, profileName, runId) {
42
+ const raw = `[CapGen 投研自动化·${profileName}] ${task.title}·${shortRunId(runId)}`
43
+ return raw.length > 200 ? `${raw.slice(0, 197)}...` : raw
44
+ }
45
+
46
+ export function buildRunPrompt({ task, run, trigger, profileName, now }) {
47
+ const local = new Intl.DateTimeFormat('zh-CN', {
48
+ timeZone: task.trigger.timezone,
49
+ year: 'numeric',
50
+ month: '2-digit',
51
+ day: '2-digit',
52
+ hour: '2-digit',
53
+ minute: '2-digit',
54
+ hourCycle: 'h23',
55
+ }).format(new Date(now))
56
+ const reason = trigger === 'manual' ? '手动运行' : '定时触发'
57
+ return `【CapGen 投研自动化】
58
+ 运行: ${run.id}
59
+ 任务: ${task.title}(${task.id})
60
+ 触发: ${reason}
61
+ occurrence: ${run.occurrenceKey}
62
+ ${run.scheduledFor === undefined ? '' : `计划时刻: ${run.scheduledFor}\n`}当前时间: ${local}(${task.trigger.timezone})
63
+ profile: ${profileName}
64
+ 执行模式: ${task.mode}
65
+ 无人值守策略: ${POLICY_ID}
66
+
67
+ 任务说明:
68
+ ${task.description.length > 0 ? task.description : '(无补充说明)'}
69
+
70
+ 任务指令:
71
+ ${task.instruction}
72
+
73
+ 模式要求: ${MODE_GUIDANCE[task.mode] ?? MODE_GUIDANCE.research}
74
+
75
+ 执行要求:
76
+ - 这是无人值守运行:禁止 ask_user_question、禁止等待审批、禁止写文件或提权。
77
+ - 使用当前角色可见的工具面;结构化数据交 data_collector,网页材料交 web_retriever,分析交 data_junior。
78
+ - 最终答复必须自足:结论、证据与来源、局限与风险;不要写“见上”“已完成”。
79
+ - 缺少关键输入时,采用最保守的可核查口径继续,并在开头写明「需要用户确认」及具体缺口。`
80
+ }
81
+
82
+ export function createRunRunner(options) {
83
+ const { ctx, store, settings, profile, workspaces, now = () => Date.now(), logger, random } = options
84
+ const entries = new Map()
85
+ const pendingDispatches = new Set()
86
+ const bySession = new Map()
87
+ let disposed = false
88
+ let disposePromise
89
+ let listenersInstalled = false
90
+ const listenerDisposers = []
91
+
92
+ function settingsOf() {
93
+ return typeof settings === 'function' ? settings() ?? {} : settings ?? {}
94
+ }
95
+
96
+ function listDescendantAgents(entry) {
97
+ const agents = service(ctx, 'agents')
98
+ const root = entry.agent
99
+ if (!root || typeof agents?.list !== 'function') return []
100
+ return agents.list().filter((agent) => {
101
+ if (agent === root) return false
102
+ let session = agent?.session
103
+ let hops = 0
104
+ while (session?.header?.parentSession !== undefined && hops < MAX_SESSION_LINEAGE) {
105
+ if (session.id === entry.sessionId) return true
106
+ session = service(ctx, 'sessions')?.get?.(session.header.parentSession)
107
+ hops += 1
108
+ }
109
+ return session?.id === entry.sessionId
110
+ })
111
+ }
112
+
113
+ function sessionForId(sessionId) {
114
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return undefined
115
+ return service(ctx, 'sessions')?.get?.(sessionId)
116
+ }
117
+
118
+ /** Resolve a root or descendant without walking every active run on the event hot path. */
119
+ function entryForSession(session) {
120
+ let cursor = session
121
+ let sessionId = cursor?.id
122
+ const visited = new Set()
123
+ for (let hops = 0; typeof sessionId === 'string' && sessionId.length > 0 && hops < MAX_SESSION_LINEAGE; hops += 1) {
124
+ if (visited.has(sessionId)) return undefined
125
+ visited.add(sessionId)
126
+ const entry = entries.get(bySession.get(sessionId))
127
+ if (entry !== undefined) return entry
128
+ const parentSession = cursor?.header?.parentSession
129
+ if (typeof parentSession !== 'string' || parentSession.length === 0) return undefined
130
+ sessionId = parentSession
131
+ cursor = sessionForId(sessionId) ?? { id: sessionId }
132
+ }
133
+ return undefined
134
+ }
135
+
136
+ function entryForSessionId(sessionId) {
137
+ return entryForSession(sessionForId(sessionId) ?? { id: sessionId })
138
+ }
139
+
140
+ async function hasRunningDescendants(entry) {
141
+ const subagents = service(ctx, 'subagents')
142
+ if (typeof subagents?.listDescendants === 'function' && entry.sessionId !== undefined) {
143
+ try {
144
+ const descendants = await subagents.listDescendants(entry.sessionId)
145
+ return descendants.some((item) => item?.kind === 'child' && item.activity === 'running')
146
+ } catch {
147
+ // Fall back to the live registry when the catalog is unavailable.
148
+ }
149
+ }
150
+ return listDescendantAgents(entry).some((agent) => agent.status === 'running')
151
+ }
152
+
153
+ function armWall(entry) {
154
+ if (entry.wallTimer !== undefined) return
155
+ entry.wallTimer = setTimeout(() => {
156
+ if (entry.finished) return
157
+ void cancelEntry(entry, 'automation_run_timeout', 'timed_out', {
158
+ phase: 'timeout',
159
+ code: 'run_timeout',
160
+ message: `运行超过 ${entry.task.limits.runTimeoutMinutes} 分钟,已取消。`,
161
+ })
162
+ }, entry.task.limits.runTimeoutMinutes * 60_000)
163
+ entry.wallTimer.unref?.()
164
+ }
165
+
166
+ function armIdle(entry) {
167
+ if (entry.idleTimer !== undefined) clearTimeout(entry.idleTimer)
168
+ const idleMs = Math.max(60_000, entry.task.limits.idleTimeoutMinutes * 60_000)
169
+ entry.idleTimer = setTimeout(() => {
170
+ if (entry.finished) return
171
+ void cancelEntry(entry, 'automation_idle_timeout', 'failed', {
172
+ phase: 'timeout',
173
+ code: 'idle_timeout',
174
+ message: `运行连续 ${entry.task.limits.idleTimeoutMinutes} 分钟没有活动,已取消。`,
175
+ })
176
+ }, idleMs)
177
+ entry.idleTimer.unref?.()
178
+ }
179
+
180
+ function clearTimers(entry) {
181
+ for (const timer of [entry.wallTimer, entry.idleTimer, entry.cancelTimer, entry.settleTimer]) {
182
+ if (timer !== undefined) clearTimeout(timer)
183
+ }
184
+ entry.wallTimer = undefined
185
+ entry.idleTimer = undefined
186
+ entry.cancelTimer = undefined
187
+ entry.settleTimer = undefined
188
+ }
189
+
190
+ function pushError(entry, error) {
191
+ const message = sanitizeMessage(error.message ?? String(error))
192
+ if (entry.errors.some((item) => item.code === error.code && item.message === message && item.phase === error.phase)) return
193
+ entry.errors.push({
194
+ at: iso(now()),
195
+ phase: error.phase ?? 'turn',
196
+ severity: error.severity ?? 'error',
197
+ code: error.code ?? 'automation_error',
198
+ message,
199
+ ...(error.tool === undefined ? {} : { tool: error.tool }),
200
+ })
201
+ if (entry.errors.length > MAX_ERRORS_PER_RUN) entry.errors.splice(0, entry.errors.length - MAX_ERRORS_PER_RUN)
202
+ }
203
+
204
+ async function updateEntryRun(entry, patch) {
205
+ const updated = await store.updateRun(entry.run.id, patch).catch((error) => {
206
+ pushError(entry, { phase: 'storage', code: 'run_update_failed', message: String(error) })
207
+ return undefined
208
+ })
209
+ if (updated !== undefined) entry.run = updated
210
+ return updated
211
+ }
212
+
213
+ async function flushSessionBounded(agent) {
214
+ const sessions = service(ctx, 'sessions')
215
+ const flush = sessions?.flush
216
+ if (typeof flush !== 'function') return
217
+ let timer
218
+ try {
219
+ await Promise.race([
220
+ Promise.resolve().then(() => flush.call(sessions, agent.session)).catch(() => {}),
221
+ new Promise((resolve) => {
222
+ timer = setTimeout(resolve, FLUSH_TIMEOUT_MS)
223
+ timer.unref?.()
224
+ }),
225
+ ])
226
+ } finally {
227
+ if (timer !== undefined) clearTimeout(timer)
228
+ }
229
+ }
230
+
231
+ async function finalize(entry, status, options = {}) {
232
+ if (entry.finished) return
233
+ entry.finished = true
234
+ clearTimers(entry)
235
+ const finishedAt = now()
236
+ if (options.error !== undefined) pushError(entry, options.error)
237
+ const started = entry.run.startedAt === undefined ? undefined : Date.parse(entry.run.startedAt)
238
+ const agent = entry.agent
239
+ if (agent !== undefined) await flushSessionBounded(agent)
240
+ const updated = await updateEntryRun(entry, {
241
+ status,
242
+ finishedAt: iso(finishedAt),
243
+ durationMs: Math.max(0, finishedAt - (started ?? Date.parse(entry.run.queuedAt))),
244
+ steps: entry.steps,
245
+ toolCalls: entry.toolCalls,
246
+ blockedRequiresUser: entry.blocked === true,
247
+ errors: [...entry.errors],
248
+ ...(entry.summary === undefined ? {} : { summary: entry.summary }),
249
+ })
250
+ entries.delete(entry.id)
251
+ if (entry.sessionId !== undefined) bySession.delete(entry.sessionId)
252
+ await notify(entry, updated ?? { ...entry.run, status })
253
+ }
254
+
255
+ async function notify(entry, run) {
256
+ if (settingsOf().inAppNotifications !== true) return
257
+ const task = entry.task
258
+ if (task.notifications.inApp !== true) return
259
+ let kind
260
+ if (run.status === 'failed' || run.status === 'blocked_requires_user') kind = 'run_failed'
261
+ else if (run.status === 'timed_out') kind = 'run_timeout'
262
+ else if (run.status === 'succeeded') {
263
+ if (task.notifications.onSuccess !== true) return
264
+ kind = 'run_succeeded'
265
+ } else if (run.status.startsWith('skipped_') || run.status === 'missed') {
266
+ return
267
+ } else {
268
+ return
269
+ }
270
+ if ((kind === 'run_failed' || kind === 'run_timeout') && task.notifications.onFailure !== true) return
271
+ const title = (kind === 'run_succeeded'
272
+ ? `任务完成:${task.title}`
273
+ : kind === 'run_timeout'
274
+ ? `任务超时:${task.title}`
275
+ : `任务失败:${task.title}`).slice(0, 120)
276
+ const message = run.errors?.at(-1)?.message ?? (run.summary ?? '')
277
+ await store.addNotification({
278
+ kind,
279
+ title,
280
+ message: sanitizeMessage(message).slice(0, 500),
281
+ runId: run.id,
282
+ taskId: task.id,
283
+ }).catch(() => {})
284
+ }
285
+
286
+ async function cancelEntry(entry, reason, status, error) {
287
+ if (entry.finished) return
288
+ const previous = entry.cancelRequested
289
+ if (previous !== undefined) {
290
+ if (previous.status !== 'timed_out' && status === 'timed_out') previous.status = status
291
+ if (error !== undefined) pushError(entry, error)
292
+ return
293
+ }
294
+ entry.cancelRequested = { reason, status }
295
+ entry.promptController?.abort()
296
+ if (error !== undefined) pushError(entry, error)
297
+ const finalStatus = () => entry.cancelRequested?.status ?? status
298
+ entry.cancelTimer = setTimeout(() => {
299
+ if (entry.finished) return
300
+ const terminalStatus = finalStatus()
301
+ const note = terminalStatus === 'timed_out'
302
+ ? { phase: 'timeout', code: 'cancel_timeout', message: '取消宽限期已过,底层工具可能仍在运行;运行按超时收口。' }
303
+ : { phase: 'timeout', code: 'cancel_grace_expired', message: '取消宽限期已过,底层工具可能仍在运行。' }
304
+ void finalize(entry, terminalStatus, { error: note })
305
+ }, CANCEL_GRACE_MS)
306
+ entry.cancelTimer.unref?.()
307
+ const agent = entry.agent
308
+ if (agent !== undefined) {
309
+ try {
310
+ agent.cancel({ kind: 'hook', reason })
311
+ } catch {
312
+ // Agent already idle/disposed: the stored status still has to settle.
313
+ }
314
+ await drainDescendantsBounded(agent)
315
+ }
316
+ void maybeComplete(entry, true)
317
+ }
318
+
319
+ async function maybeComplete(entry, force = false) {
320
+ if (entry.finished || (entry.promptAccepted !== true && entry.cancelRequested === undefined) || entry.completing === true) return
321
+ if (!force && entry.cancelRequested === undefined) {
322
+ if (entry.lastTurnEnd === undefined) return
323
+ if (entry.agent?.status !== 'idle') return
324
+ if ((entry.agent?.inbox?.nextTurn?.length ?? 0) + (entry.agent?.inbox?.nextStep?.length ?? 0) > 0) return
325
+ }
326
+ if (entry.settleTimer !== undefined) return
327
+ entry.settleTimer = setTimeout(() => {
328
+ entry.settleTimer = undefined
329
+ entry.completing = true
330
+ void (async () => {
331
+ try {
332
+ if (entry.finished) return
333
+ const subagents = service(ctx, 'subagents')
334
+ if (subagents?.waitForChildren !== undefined && entry.agent !== undefined) {
335
+ const joined = await subagents.waitForChildren(entry.agent).catch(() => true)
336
+ if (joined && !entry.finished && entry.cancelRequested === undefined) {
337
+ armIdle(entry)
338
+ return
339
+ }
340
+ }
341
+ if (entry.cancelRequested !== undefined) {
342
+ // The owner cancels descendants first; only report the terminal status after the root
343
+ // and descendants are idle. The grace timer is the hard bound when they refuse to stop.
344
+ if (entry.agent?.status !== undefined && entry.agent.status !== 'idle') {
345
+ armIdle(entry)
346
+ return
347
+ }
348
+ if (await hasRunningDescendants(entry)) {
349
+ armIdle(entry)
350
+ return
351
+ }
352
+ await finalize(entry, entry.cancelRequested.status)
353
+ return
354
+ }
355
+ if (await hasRunningDescendants(entry)) {
356
+ armIdle(entry)
357
+ return
358
+ }
359
+ if (entry.agent?.status !== 'idle') {
360
+ armIdle(entry)
361
+ return
362
+ }
363
+ await finalize(entry, outcomeOf(entry), outcomeError(entry))
364
+ } finally {
365
+ entry.completing = false
366
+ }
367
+ })()
368
+ }, force ? 0 : QUIET_SETTLE_MS)
369
+ entry.settleTimer.unref?.()
370
+ }
371
+
372
+ function outcomeOf(entry) {
373
+ if (entry.blocked === true) return 'blocked_requires_user'
374
+ if (entry.lastTurnEnd === undefined) return 'unknown_after_restart'
375
+ const reason = entry.lastTurnEnd.reason ?? {}
376
+ if (reason.kind === 'completed') return 'succeeded'
377
+ if (reason.kind === 'blocked') return 'blocked_requires_user'
378
+ if (reason.kind === 'error' || reason.kind === 'max-tokens') return 'failed'
379
+ if (reason.kind === 'interrupted' || reason.kind === 'forked') return 'unknown_after_restart'
380
+ if (reason.kind === 'aborted') {
381
+ const cause = reason.reason ?? {}
382
+ if (cause.kind === 'hook' && String(cause.reason).includes('timeout')) return 'timed_out'
383
+ return 'canceled'
384
+ }
385
+ return 'failed'
386
+ }
387
+
388
+ function outcomeError(entry) {
389
+ if (entry.lastTurnEnd?.reason?.kind === 'error') {
390
+ return {
391
+ phase: 'llm',
392
+ code: entry.lastTurnEnd.reason.error?.code ?? 'llm_error',
393
+ message: entry.lastTurnEnd.reason.error?.message ?? '本轮模型请求失败。',
394
+ }
395
+ }
396
+ if (entry.lastTurnEnd?.reason?.kind === 'max-tokens') {
397
+ return { phase: 'turn', code: 'max_tokens', message: '本轮达到输出上限,未能形成完整结论。' }
398
+ }
399
+ return undefined
400
+ }
401
+
402
+ function countedEventKey(session, event) {
403
+ const sessionId = typeof session?.id === 'string' ? session.id : ''
404
+ if (Number.isSafeInteger(event.seq)) return `${sessionId}:seq:${event.seq}`
405
+ if ((event.type === 'tool/call' || event.type === 'tool/ptc-dispatch') && typeof event.callId === 'string') {
406
+ return `${sessionId}:${event.type}:${event.callId}`
407
+ }
408
+ if (event.type === 'step/start' && Number.isSafeInteger(event.turn) && Number.isSafeInteger(event.step)) {
409
+ return `${sessionId}:step:${event.turn}:${event.step}`
410
+ }
411
+ return undefined
412
+ }
413
+
414
+ function countEventOnce(entry, session, event) {
415
+ const key = countedEventKey(session, event)
416
+ if (key === undefined) return true
417
+ if (entry.countedEvents.has(key)) return false
418
+ entry.countedEvents.add(key)
419
+ if (entry.countedEvents.size > MAX_COUNTED_EVENT_KEYS) {
420
+ entry.countedEvents.delete(entry.countedEvents.values().next().value)
421
+ }
422
+ return true
423
+ }
424
+
425
+ function onSessionEvent(session, event) {
426
+ if (event === undefined || typeof event.type !== 'string') return
427
+ const entry = entryForSession(session)
428
+ if (entry === undefined || entry.finished) return
429
+ const rootEvent = session?.id === entry.sessionId
430
+ if (event.type === 'step/start') {
431
+ if (!countEventOnce(entry, session, event)) return
432
+ armIdle(entry)
433
+ entry.steps += 1
434
+ if (entry.steps > entry.task.limits.maxSteps) {
435
+ void cancelEntry(entry, 'automation_step_limit', 'failed', {
436
+ phase: 'timeout',
437
+ code: 'step_limit',
438
+ message: `运行步数超过上限 ${entry.task.limits.maxSteps},已取消。`,
439
+ })
440
+ }
441
+ return
442
+ }
443
+ if (event.type === 'tool/call' || event.type === 'tool/ptc-dispatch') {
444
+ if (!countEventOnce(entry, session, event)) return
445
+ armIdle(entry)
446
+ entry.toolCalls += 1
447
+ if (typeof event.callId === 'string' && typeof event.name === 'string') entry.toolNames.set(event.callId, event.name)
448
+ if (entry.toolCalls > entry.task.limits.maxToolCalls) {
449
+ void cancelEntry(entry, 'automation_tool_limit', 'failed', {
450
+ phase: 'timeout',
451
+ code: 'tool_limit',
452
+ message: `工具调用数超过上限 ${entry.task.limits.maxToolCalls},已取消。`,
453
+ })
454
+ }
455
+ return
456
+ }
457
+ if (event.type === 'tool/result') {
458
+ armIdle(entry)
459
+ if (event.message?.isError !== true) return
460
+ const tool = entry.toolNames.get(event.callId)
461
+ pushError(entry, {
462
+ phase: 'tool',
463
+ code: event.error?.code ?? 'tool_error',
464
+ message: event.error?.reason ?? '工具调用失败。',
465
+ ...(tool === undefined ? {} : { tool }),
466
+ ...(typeof event.callId === 'string' ? { callId: event.callId } : {}),
467
+ })
468
+ return
469
+ }
470
+ if (event.type === 'assistant/message') {
471
+ armIdle(entry)
472
+ if (!rootEvent) return
473
+ const text = textOfMessage(event.message)
474
+ if (text.length > 0) entry.summary = sanitizeMessage(text).slice(0, MAX_SUMMARY_CHARS)
475
+ return
476
+ }
477
+ if (event.type === 'turn/end') {
478
+ armIdle(entry)
479
+ if (!rootEvent) return
480
+ entry.lastTurnEnd = { turn: event.turn, reason: event.reason }
481
+ void maybeComplete(entry)
482
+ }
483
+ }
484
+
485
+ function onAgentStatus(payload) {
486
+ const agent = payload?.agent
487
+ if (agent === undefined) return
488
+ const entry = entryForSession(agent.session)
489
+ if (entry === undefined || entry.finished) return
490
+ if (payload.status === 'running') armIdle(entry)
491
+ else void maybeComplete(entry)
492
+ }
493
+
494
+ function onSubagentEnd(info) {
495
+ const entry = entryForSessionId(info?.id)
496
+ if (entry !== undefined && !entry.finished) void maybeComplete(entry)
497
+ }
498
+
499
+ function installListeners() {
500
+ if (listenersInstalled || typeof ctx.on !== 'function') return
501
+ listenersInstalled = true
502
+ const disposers = []
503
+ try {
504
+ for (const [event, listener] of [
505
+ ['session/event', onSessionEvent],
506
+ ['agent/status', onAgentStatus],
507
+ ['subagent/end', onSubagentEnd],
508
+ ]) {
509
+ const dispose = ctx.on(event, listener)
510
+ if (typeof dispose === 'function') disposers.push(dispose)
511
+ }
512
+ listenerDisposers.push(...disposers)
513
+ } catch (error) {
514
+ for (const dispose of disposers.reverse()) {
515
+ try { dispose() } catch { /* best effort */ }
516
+ }
517
+ listenersInstalled = false
518
+ logger?.warn?.(`capital-automation: 运行监听器安装失败:${String(error)}`)
519
+ }
520
+ }
521
+
522
+ function disposeListeners() {
523
+ for (const dispose of listenerDisposers.splice(0).reverse()) {
524
+ try { dispose() } catch { /* best effort */ }
525
+ }
526
+ listenersInstalled = false
527
+ }
528
+
529
+ async function drainDescendantsBounded(agent) {
530
+ const subagents = service(ctx, 'subagents')
531
+ const drain = subagents?.drainDescendants
532
+ if (typeof drain !== 'function') return
533
+ let timer
534
+ try {
535
+ await Promise.race([
536
+ Promise.resolve().then(() => drain.call(subagents, [agent])).catch(() => {}),
537
+ new Promise((resolve) => {
538
+ timer = setTimeout(resolve, CANCEL_GRACE_MS)
539
+ timer.unref?.()
540
+ }),
541
+ ])
542
+ } finally {
543
+ if (timer !== undefined) clearTimeout(timer)
544
+ }
545
+ }
546
+
547
+ async function stopEntryAgent(entry, controller) {
548
+ let agent = entry.agent
549
+ if (agent === undefined && entry.sessionId !== undefined && typeof controller?.resolveAgent === 'function') {
550
+ try {
551
+ agent = (await controller.resolveAgent(entry.sessionId))?.agent
552
+ if (agent !== undefined) entry.agent = agent
553
+ } catch {
554
+ return
555
+ }
556
+ }
557
+ if (agent === undefined) return
558
+ try {
559
+ agent.cancel({ kind: 'hook', reason: 'automation_disposed' })
560
+ } catch {
561
+ // The Agent may already have been disposed.
562
+ }
563
+ await drainDescendantsBounded(agent)
564
+ }
565
+
566
+ async function markDisposedRun(run) {
567
+ const current = await store.getRun(run.id).catch(() => undefined)
568
+ if (current === undefined || !store.isActiveRun(current.status)) return
569
+ const finishedAt = now()
570
+ await store.setAttemptState(run.occurrenceKey, 'unknown').catch(() => {})
571
+ await store.updateRun(run.id, {
572
+ status: 'unknown_after_restart',
573
+ finishedAt: iso(finishedAt),
574
+ durationMs: Math.max(0, finishedAt - Date.parse(current.queuedAt)),
575
+ errors: [...current.errors, {
576
+ at: iso(finishedAt),
577
+ phase: 'session',
578
+ severity: 'warning',
579
+ code: 'unknown_after_restart',
580
+ message: '自动化 runtime 在运行开始前结束;未自动重试。',
581
+ }].slice(-MAX_ERRORS_PER_RUN),
582
+ }).catch(() => {})
583
+ }
584
+
585
+ async function abandonEntry(entry, controller) {
586
+ if (entry.abandonPromise !== undefined) return entry.abandonPromise
587
+ entry.abandoned = true
588
+ entry.finished = true
589
+ clearTimers(entry)
590
+ entry.promptController?.abort()
591
+ entry.abandonPromise = (async () => {
592
+ await stopEntryAgent(entry, controller)
593
+ const at = now()
594
+ const current = await store.getRun(entry.id).catch(() => undefined)
595
+ if (current !== undefined && store.isActiveRun(current.status)) {
596
+ await store.setAttemptState(entry.run.occurrenceKey, 'unknown').catch(() => {})
597
+ await store.updateRun(entry.id, {
598
+ status: 'unknown_after_restart',
599
+ finishedAt: iso(at),
600
+ durationMs: Math.max(0, at - Date.parse(current.queuedAt)),
601
+ errors: [...current.errors, {
602
+ at: iso(at),
603
+ phase: 'session',
604
+ severity: 'warning',
605
+ code: 'unknown_after_restart',
606
+ message: '自动化 runtime 结束时无法证明本轮运行已经结束;未自动重试。',
607
+ }].slice(-MAX_ERRORS_PER_RUN),
608
+ }).catch(() => {})
609
+ }
610
+ entries.delete(entry.id)
611
+ if (entry.sessionId !== undefined && bySession.get(entry.sessionId) === entry.id) bySession.delete(entry.sessionId)
612
+ entry.toolNames.clear()
613
+ entry.countedEvents.clear()
614
+ })()
615
+ return entry.abandonPromise
616
+ }
617
+
618
+ function dispatchStopped(entry) {
619
+ return disposed || entry.abandoned || entry.finished || entry.cancelRequested !== undefined
620
+ }
621
+
622
+ function canDispatch() {
623
+ return !disposed && entries.size < MAX_ACTIVE_RUNS
624
+ }
625
+
626
+ async function dispatchInternal(run, task, trigger) {
627
+ if (disposed) {
628
+ await markDisposedRun(run)
629
+ return false
630
+ }
631
+ if (entries.has(run.id)) return false
632
+ const current = await store.getRun(run.id).catch(() => undefined)
633
+ if (current === undefined || current.status !== 'queued') return false
634
+ if (disposed) {
635
+ await markDisposedRun(run)
636
+ return false
637
+ }
638
+ if (!canDispatch()) {
639
+ const finishedAt = now()
640
+ await store.updateRun(run.id, {
641
+ status: 'skipped_overlap',
642
+ finishedAt: iso(finishedAt),
643
+ durationMs: Math.max(0, finishedAt - Date.parse(run.queuedAt)),
644
+ }).catch(() => {})
645
+ return false
646
+ }
647
+ const entry = {
648
+ id: run.id,
649
+ run,
650
+ task,
651
+ trigger,
652
+ sessionId: undefined,
653
+ agent: undefined,
654
+ steps: 0,
655
+ toolCalls: 0,
656
+ errors: [],
657
+ toolNames: new Map(),
658
+ countedEvents: new Set(),
659
+ blocked: false,
660
+ abandoned: false,
661
+ abandonPromise: undefined,
662
+ promptController: undefined,
663
+ summary: undefined,
664
+ lastTurnEnd: undefined,
665
+ promptAccepted: false,
666
+ finished: false,
667
+ completing: false,
668
+ cancelRequested: undefined,
669
+ wallTimer: undefined,
670
+ idleTimer: undefined,
671
+ cancelTimer: undefined,
672
+ settleTimer: undefined,
673
+ }
674
+ entries.set(entry.id, entry)
675
+ armWall(entry)
676
+ try {
677
+ const started = await updateEntryRun(entry, { status: 'starting', startedAt: iso(now()) })
678
+ if (started !== undefined && !store.isActiveRun(started.status)) {
679
+ entry.finished = true
680
+ clearTimers(entry)
681
+ entries.delete(entry.id)
682
+ return false
683
+ }
684
+ if (dispatchStopped(entry)) {
685
+ if (disposed || entry.abandoned) await abandonEntry(entry)
686
+ return
687
+ }
688
+ const controller = service(ctx, 'sessionController')
689
+ if (controller?.create === undefined) throw new Error('sessionController 不可用')
690
+ if (profile.runDir === undefined) throw new Error('profile run directory 不可用')
691
+ const target = workspaces === undefined
692
+ ? { workspaceId: undefined, path: profile.runDir }
693
+ : await workspaces.resolve(task.workspaceId)
694
+ if (dispatchStopped(entry)) {
695
+ if (disposed || entry.abandoned) await abandonEntry(entry, controller)
696
+ return
697
+ }
698
+ if (target.workspaceId === undefined) await mkdir(target.path ?? profile.runDir, { recursive: true, mode: 0o700 })
699
+ const created = target.workspaceId === undefined
700
+ ? await controller.create({ cwd: target.path ?? profile.runDir, agentPreset: PRESET_ID })
701
+ : await controller.create({ workspaceId: target.workspaceId, agentPreset: PRESET_ID })
702
+ entry.sessionId = created.sessionId
703
+ if (disposed || entry.abandoned) {
704
+ await stopEntryAgent(entry, controller)
705
+ return
706
+ }
707
+ bySession.set(created.sessionId, entry.id)
708
+ if (entry.cancelRequested !== undefined) {
709
+ await stopEntryAgent(entry, controller)
710
+ return
711
+ }
712
+ const title = sessionTitleOf(task, profile.displayName, run.id)
713
+ const renamed = controller.rename === undefined
714
+ ? undefined
715
+ : await controller.rename({ sessionId: created.sessionId, title }).catch(() => undefined)
716
+ const sessionTitle = renamed?.title ?? title
717
+ await store.setAttemptState(run.occurrenceKey, 'session_created')
718
+ await updateEntryRun(entry, { sessionId: created.sessionId, sessionTitle })
719
+ if (dispatchStopped(entry)) {
720
+ if (disposed || entry.abandoned) await abandonEntry(entry, controller)
721
+ else await stopEntryAgent(entry, controller)
722
+ return
723
+ }
724
+ const resolved = await controller.resolveAgent(created.sessionId)
725
+ if (resolved?.error !== undefined) throw new Error(`session resolve failed: ${resolved.error?.code ?? resolved.error}`)
726
+ entry.agent = resolved.agent
727
+ if (dispatchStopped(entry)) {
728
+ if (disposed || entry.abandoned) await abandonEntry(entry, controller)
729
+ else await stopEntryAgent(entry, controller)
730
+ return
731
+ }
732
+ if (typeof ctx.on === 'function') {
733
+ installListeners()
734
+ }
735
+ armIdle(entry)
736
+ if (dispatchStopped(entry)) {
737
+ if (disposed || entry.abandoned) await abandonEntry(entry, controller)
738
+ else await stopEntryAgent(entry, controller)
739
+ return
740
+ }
741
+ await store.setAttemptState(run.occurrenceKey, 'prompt_submitted')
742
+ if (dispatchStopped(entry)) return
743
+ entry.promptController = new AbortController()
744
+ let receipt
745
+ try {
746
+ receipt = await controller.prompt({
747
+ requestId: `automation-${run.id}`,
748
+ sessionId: created.sessionId,
749
+ mode: 'queue',
750
+ content: [{ type: 'text', text: buildRunPrompt({ task, run, trigger, profileName: profile.displayName, now: now() }) }],
751
+ clientTimeZone: task.trigger.timezone,
752
+ }, entry.promptController.signal)
753
+ } catch (error) {
754
+ if (entry.abandoned || disposed || entry.cancelRequested !== undefined || entry.finished) return
755
+ entry.promptAccepted = false
756
+ await store.setAttemptState(run.occurrenceKey, 'unknown').catch(() => {})
757
+ await finalize(entry, 'unknown_after_restart', {
758
+ error: { phase: 'session', code: 'prompt_not_accepted', message: `无法确认首条指令是否被 Agent 接受:${String(error)}` },
759
+ })
760
+ return
761
+ } finally {
762
+ entry.promptController = undefined
763
+ }
764
+ if (dispatchStopped(entry)) return
765
+ if (receipt?.accepted !== true) {
766
+ await store.setAttemptState(run.occurrenceKey, 'unknown').catch(() => {})
767
+ await finalize(entry, 'unknown_after_restart', {
768
+ error: { phase: 'session', code: 'prompt_not_accepted', message: 'Agent 未明确接受首条指令。' },
769
+ })
770
+ return
771
+ }
772
+ if (dispatchStopped(entry)) return
773
+ entry.promptAccepted = true
774
+ await store.setAttemptState(run.occurrenceKey, 'accepted')
775
+ if (dispatchStopped(entry)) return
776
+ await updateEntryRun(entry, { status: 'running' })
777
+ if (dispatchStopped(entry)) return
778
+ armIdle(entry)
779
+ void maybeComplete(entry)
780
+ } catch (error) {
781
+ if (entry.abandoned || disposed || entry.finished || entry.cancelRequested !== undefined) return
782
+ await store.setAttemptState(run.occurrenceKey, 'failed').catch(() => {})
783
+ const code = error?.code === 'workspace_not_found' || error?.code === 'workspace_unavailable'
784
+ ? error.code
785
+ : 'session_create_failed'
786
+ const message = code === 'session_create_failed' ? `创建运行会话失败:${String(error)}` : String(error.message ?? error)
787
+ await finalize(entry, 'failed', { error: { phase: 'session', code, message } })
788
+ }
789
+ }
790
+
791
+ function dispatch(run, task, trigger) {
792
+ const pending = dispatchInternal(run, task, trigger)
793
+ pendingDispatches.add(pending)
794
+ pending.finally(() => pendingDispatches.delete(pending)).catch(() => {})
795
+ return pending
796
+ }
797
+
798
+ function cancelRun(runId, status = 'canceled') {
799
+ const entry = entries.get(runId)
800
+ if (entry === undefined) return false
801
+ void cancelEntry(entry, 'automation_user_cancel', status)
802
+ return true
803
+ }
804
+
805
+ function noteGuardDenied(sessionId, tool, reason) {
806
+ const entry = entryForSessionId(sessionId)
807
+ if (entry === undefined) return
808
+ entry.blocked = true
809
+ pushError(entry, { phase: 'tool', code: 'blocked_requires_user', message: reason, tool })
810
+ }
811
+
812
+ function dispose() {
813
+ if (disposePromise !== undefined) return disposePromise
814
+ disposed = true
815
+ disposeListeners()
816
+ const pending = [...entries.values()].map((entry) => abandonEntry(entry))
817
+ const dispatches = [...pendingDispatches]
818
+ const all = Promise.allSettled([...pending, ...dispatches])
819
+ let timer
820
+ disposePromise = Promise.race([
821
+ all,
822
+ new Promise((resolve) => {
823
+ timer = setTimeout(resolve, DISPOSE_TIMEOUT_MS)
824
+ timer.unref?.()
825
+ }),
826
+ ]).finally(() => {
827
+ if (timer !== undefined) clearTimeout(timer)
828
+ }).then(() => undefined)
829
+ return disposePromise
830
+ }
831
+
832
+ return {
833
+ dispatch,
834
+ cancelRun,
835
+ noteGuardDenied,
836
+ hasAutomationSession: (sessionId) => entryForSessionId(sessionId) !== undefined,
837
+ runForSession: (sessionId) => entryForSessionId(sessionId)?.id,
838
+ activeRuns: () => [...entries.keys()],
839
+ canDispatch,
840
+ dispose,
841
+ installListeners,
842
+ }
843
+ }
844
+
845
+ function textOfMessage(message, maxChars = MAX_SUMMARY_CHARS) {
846
+ const content = message?.content
847
+ if (!Array.isArray(content) || maxChars <= 0) return ''
848
+ const chunks = []
849
+ let remaining = maxChars
850
+ for (const block of content) {
851
+ if (remaining <= 0) break
852
+ if (block?.type !== 'text' || typeof block.text !== 'string') continue
853
+ const text = block.text.slice(0, remaining)
854
+ chunks.push(text)
855
+ remaining -= text.length
856
+ }
857
+ return chunks.join('\n')
858
+ }