@v587d/capital-generation 2.12.0 → 2.13.1

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,912 @@
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, SETTLED_RUN_STATUSES } 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
+ // The 750ms settle window is not durable. Persist the turn boundary before waiting so a
214
+ // restart cannot turn a finished turn into unknown_after_restart.
215
+ function checkpointTurn(entry, phase, turn, at) {
216
+ const turnState = {
217
+ phase,
218
+ turn: Number.isSafeInteger(turn) ? turn : 0,
219
+ at: iso(Number.isFinite(at) ? at : now()),
220
+ }
221
+ const patch = { turnState }
222
+ if (phase === 'ended') {
223
+ turnState.status = outcomeOf(entry)
224
+ patch.steps = entry.steps
225
+ patch.toolCalls = entry.toolCalls
226
+ patch.blockedRequiresUser = entry.blocked === true
227
+ if (entry.summary !== undefined) patch.summary = entry.summary
228
+ }
229
+ void store.updateRun(entry.run.id, patch)
230
+ .then((updated) => { if (updated !== undefined) entry.run = updated })
231
+ .catch((error) => logger?.warn?.(`capital-automation: turn checkpoint failed: ${String(error)}`))
232
+ }
233
+
234
+ function settledStatusOf(entry) {
235
+ if (entry.turnActive === true) return undefined
236
+ if (entry.lastTurnEnd !== undefined) {
237
+ const status = outcomeOf(entry)
238
+ if (status !== 'unknown_after_restart') return status
239
+ }
240
+ const turn = entry.run.turnState
241
+ if (turn?.phase === 'ended' && SETTLED_RUN_STATUSES.includes(turn.status)) return turn.status
242
+ return undefined
243
+ }
244
+
245
+ async function flushSessionBounded(agent) {
246
+ const sessions = service(ctx, 'sessions')
247
+ const flush = sessions?.flush
248
+ if (typeof flush !== 'function') return
249
+ let timer
250
+ try {
251
+ await Promise.race([
252
+ Promise.resolve().then(() => flush.call(sessions, agent.session)).catch(() => {}),
253
+ new Promise((resolve) => {
254
+ timer = setTimeout(resolve, FLUSH_TIMEOUT_MS)
255
+ timer.unref?.()
256
+ }),
257
+ ])
258
+ } finally {
259
+ if (timer !== undefined) clearTimeout(timer)
260
+ }
261
+ }
262
+
263
+ async function finalize(entry, status, options = {}) {
264
+ if (entry.finished) return
265
+ entry.finished = true
266
+ clearTimers(entry)
267
+ const finishedAt = now()
268
+ if (options.error !== undefined) pushError(entry, options.error)
269
+ const started = entry.run.startedAt === undefined ? undefined : Date.parse(entry.run.startedAt)
270
+ const agent = entry.agent
271
+ if (agent !== undefined) await flushSessionBounded(agent)
272
+ const updated = await updateEntryRun(entry, {
273
+ status,
274
+ finishedAt: iso(finishedAt),
275
+ durationMs: Math.max(0, finishedAt - (started ?? Date.parse(entry.run.queuedAt))),
276
+ steps: entry.steps,
277
+ toolCalls: entry.toolCalls,
278
+ blockedRequiresUser: entry.blocked === true,
279
+ errors: [...entry.errors],
280
+ ...(entry.summary === undefined ? {} : { summary: entry.summary }),
281
+ })
282
+ entries.delete(entry.id)
283
+ if (entry.sessionId !== undefined) bySession.delete(entry.sessionId)
284
+ await notify(entry, updated ?? { ...entry.run, status })
285
+ }
286
+
287
+ async function notify(entry, run) {
288
+ if (settingsOf().inAppNotifications !== true) return
289
+ const task = entry.task
290
+ if (task.notifications.inApp !== true) return
291
+ let kind
292
+ if (run.status === 'failed' || run.status === 'blocked_requires_user') kind = 'run_failed'
293
+ else if (run.status === 'timed_out') kind = 'run_timeout'
294
+ else if (run.status === 'succeeded') {
295
+ if (task.notifications.onSuccess !== true) return
296
+ kind = 'run_succeeded'
297
+ } else if (run.status.startsWith('skipped_') || run.status === 'missed') {
298
+ return
299
+ } else {
300
+ return
301
+ }
302
+ if ((kind === 'run_failed' || kind === 'run_timeout') && task.notifications.onFailure !== true) return
303
+ const title = (kind === 'run_succeeded'
304
+ ? `任务完成:${task.title}`
305
+ : kind === 'run_timeout'
306
+ ? `任务超时:${task.title}`
307
+ : `任务失败:${task.title}`).slice(0, 120)
308
+ const message = run.errors?.at(-1)?.message ?? (run.summary ?? '')
309
+ await store.addNotification({
310
+ kind,
311
+ title,
312
+ message: sanitizeMessage(message).slice(0, 500),
313
+ runId: run.id,
314
+ taskId: task.id,
315
+ }).catch(() => {})
316
+ }
317
+
318
+ async function cancelEntry(entry, reason, status, error) {
319
+ if (entry.finished) return
320
+ const previous = entry.cancelRequested
321
+ if (previous !== undefined) {
322
+ if (previous.status !== 'timed_out' && status === 'timed_out') previous.status = status
323
+ if (error !== undefined) pushError(entry, error)
324
+ return
325
+ }
326
+ entry.cancelRequested = { reason, status }
327
+ entry.promptController?.abort()
328
+ if (error !== undefined) pushError(entry, error)
329
+ const finalStatus = () => entry.cancelRequested?.status ?? status
330
+ entry.cancelTimer = setTimeout(() => {
331
+ if (entry.finished) return
332
+ const terminalStatus = finalStatus()
333
+ const note = terminalStatus === 'timed_out'
334
+ ? { phase: 'timeout', code: 'cancel_timeout', message: '取消宽限期已过,底层工具可能仍在运行;运行按超时收口。' }
335
+ : { phase: 'timeout', code: 'cancel_grace_expired', message: '取消宽限期已过,底层工具可能仍在运行。' }
336
+ void finalize(entry, terminalStatus, { error: note })
337
+ }, CANCEL_GRACE_MS)
338
+ entry.cancelTimer.unref?.()
339
+ const agent = entry.agent
340
+ if (agent !== undefined) {
341
+ try {
342
+ agent.cancel({ kind: 'hook', reason })
343
+ } catch {
344
+ // Agent already idle/disposed: the stored status still has to settle.
345
+ }
346
+ await drainDescendantsBounded(agent)
347
+ }
348
+ void maybeComplete(entry, true)
349
+ }
350
+
351
+ async function maybeComplete(entry, force = false) {
352
+ if (entry.finished || (entry.promptAccepted !== true && entry.cancelRequested === undefined) || entry.completing === true) return
353
+ if (!force && entry.cancelRequested === undefined) {
354
+ if (entry.lastTurnEnd === undefined) return
355
+ if (entry.agent?.status !== 'idle') return
356
+ if ((entry.agent?.inbox?.nextTurn?.length ?? 0) + (entry.agent?.inbox?.nextStep?.length ?? 0) > 0) return
357
+ }
358
+ if (entry.settleTimer !== undefined) return
359
+ entry.settleTimer = setTimeout(() => {
360
+ entry.settleTimer = undefined
361
+ entry.completing = true
362
+ void (async () => {
363
+ try {
364
+ if (entry.finished) return
365
+ const subagents = service(ctx, 'subagents')
366
+ if (subagents?.waitForChildren !== undefined && entry.agent !== undefined) {
367
+ const joined = await subagents.waitForChildren(entry.agent).catch(() => true)
368
+ if (joined && !entry.finished && entry.cancelRequested === undefined) {
369
+ armIdle(entry)
370
+ return
371
+ }
372
+ }
373
+ if (entry.cancelRequested !== undefined) {
374
+ // The owner cancels descendants first; only report the terminal status after the root
375
+ // and descendants are idle. The grace timer is the hard bound when they refuse to stop.
376
+ if (entry.agent?.status !== undefined && entry.agent.status !== 'idle') {
377
+ armIdle(entry)
378
+ return
379
+ }
380
+ if (await hasRunningDescendants(entry)) {
381
+ armIdle(entry)
382
+ return
383
+ }
384
+ await finalize(entry, entry.cancelRequested.status)
385
+ return
386
+ }
387
+ if (await hasRunningDescendants(entry)) {
388
+ armIdle(entry)
389
+ return
390
+ }
391
+ if (entry.agent?.status !== 'idle') {
392
+ armIdle(entry)
393
+ return
394
+ }
395
+ await finalize(entry, outcomeOf(entry), outcomeError(entry))
396
+ } finally {
397
+ entry.completing = false
398
+ }
399
+ })()
400
+ }, force ? 0 : QUIET_SETTLE_MS)
401
+ entry.settleTimer.unref?.()
402
+ }
403
+
404
+ function outcomeOf(entry) {
405
+ if (entry.blocked === true) return 'blocked_requires_user'
406
+ if (entry.lastTurnEnd === undefined) return 'unknown_after_restart'
407
+ const reason = entry.lastTurnEnd.reason ?? {}
408
+ if (reason.kind === 'completed') return 'succeeded'
409
+ if (reason.kind === 'blocked') return 'blocked_requires_user'
410
+ if (reason.kind === 'error' || reason.kind === 'max-tokens') return 'failed'
411
+ if (reason.kind === 'interrupted' || reason.kind === 'forked') return 'unknown_after_restart'
412
+ if (reason.kind === 'aborted') {
413
+ const cause = reason.reason ?? {}
414
+ if (cause.kind === 'hook' && String(cause.reason).includes('timeout')) return 'timed_out'
415
+ return 'canceled'
416
+ }
417
+ return 'failed'
418
+ }
419
+
420
+ function outcomeError(entry) {
421
+ if (entry.lastTurnEnd?.reason?.kind === 'error') {
422
+ return {
423
+ phase: 'llm',
424
+ code: entry.lastTurnEnd.reason.error?.code ?? 'llm_error',
425
+ message: entry.lastTurnEnd.reason.error?.message ?? '本轮模型请求失败。',
426
+ }
427
+ }
428
+ if (entry.lastTurnEnd?.reason?.kind === 'max-tokens') {
429
+ return { phase: 'turn', code: 'max_tokens', message: '本轮达到输出上限,未能形成完整结论。' }
430
+ }
431
+ return undefined
432
+ }
433
+
434
+ function countedEventKey(session, event) {
435
+ const sessionId = typeof session?.id === 'string' ? session.id : ''
436
+ const data = event.data ?? event
437
+ if (Number.isSafeInteger(event.seq)) return `${sessionId}:seq:${event.seq}`
438
+ if ((event.type === 'tool/call' || event.type === 'tool/ptc-dispatch') && typeof data.callId === 'string') {
439
+ return `${sessionId}:${event.type}:${data.callId}`
440
+ }
441
+ if (event.type === 'step/start' && Number.isSafeInteger(data.turn) && Number.isSafeInteger(data.step)) {
442
+ return `${sessionId}:step:${data.turn}:${data.step}`
443
+ }
444
+ return undefined
445
+ }
446
+
447
+ function countEventOnce(entry, session, event) {
448
+ const key = countedEventKey(session, event)
449
+ if (key === undefined) return true
450
+ if (entry.countedEvents.has(key)) return false
451
+ entry.countedEvents.add(key)
452
+ if (entry.countedEvents.size > MAX_COUNTED_EVENT_KEYS) {
453
+ entry.countedEvents.delete(entry.countedEvents.values().next().value)
454
+ }
455
+ return true
456
+ }
457
+
458
+ function onSessionEvent(session, event) {
459
+ if (event === undefined || typeof event.type !== 'string') return
460
+ const entry = entryForSession(session)
461
+ if (entry === undefined || entry.finished) return
462
+ const data = event.data ?? event
463
+ const rootEvent = session?.id === entry.sessionId
464
+ if (event.type === 'turn/start') {
465
+ armIdle(entry)
466
+ if (!rootEvent) return
467
+ entry.lastTurnEnd = undefined
468
+ entry.turnActive = true
469
+ checkpointTurn(entry, 'running', data.turn, event.time)
470
+ return
471
+ }
472
+ if (event.type === 'step/start') {
473
+ if (!countEventOnce(entry, session, event)) return
474
+ armIdle(entry)
475
+ entry.steps += 1
476
+ if (entry.steps > entry.task.limits.maxSteps) {
477
+ void cancelEntry(entry, 'automation_step_limit', 'failed', {
478
+ phase: 'timeout',
479
+ code: 'step_limit',
480
+ message: `运行步数超过上限 ${entry.task.limits.maxSteps},已取消。`,
481
+ })
482
+ }
483
+ return
484
+ }
485
+ if (event.type === 'tool/call' || event.type === 'tool/ptc-dispatch') {
486
+ if (!countEventOnce(entry, session, event)) return
487
+ armIdle(entry)
488
+ entry.toolCalls += 1
489
+ if (typeof data.callId === 'string' && typeof data.name === 'string') entry.toolNames.set(data.callId, data.name)
490
+ if (entry.toolCalls > entry.task.limits.maxToolCalls) {
491
+ void cancelEntry(entry, 'automation_tool_limit', 'failed', {
492
+ phase: 'timeout',
493
+ code: 'tool_limit',
494
+ message: `工具调用数超过上限 ${entry.task.limits.maxToolCalls},已取消。`,
495
+ })
496
+ }
497
+ return
498
+ }
499
+ if (event.type === 'tool/result') {
500
+ armIdle(entry)
501
+ if (data.message?.isError !== true) return
502
+ const tool = entry.toolNames.get(data.callId)
503
+ pushError(entry, {
504
+ phase: 'tool',
505
+ code: data.error?.code ?? 'tool_error',
506
+ message: textOfMessage(data.message) || '工具调用失败。',
507
+ ...(tool === undefined ? {} : { tool }),
508
+ ...(typeof data.callId === 'string' ? { callId: data.callId } : {}),
509
+ })
510
+ return
511
+ }
512
+ if (event.type === 'assistant/message') {
513
+ armIdle(entry)
514
+ if (!rootEvent) return
515
+ const text = textOfMessage(data.message)
516
+ if (text.length > 0) entry.summary = sanitizeMessage(text).slice(0, MAX_SUMMARY_CHARS)
517
+ return
518
+ }
519
+ if (event.type === 'turn/end') {
520
+ armIdle(entry)
521
+ if (!rootEvent) return
522
+ entry.lastTurnEnd = { turn: data.turn, reason: data.reason }
523
+ entry.turnActive = false
524
+ checkpointTurn(entry, 'ended', data.turn, event.time)
525
+ void maybeComplete(entry)
526
+ }
527
+ }
528
+
529
+ function onAgentStatus(payload) {
530
+ const agent = payload?.agent
531
+ if (agent === undefined) return
532
+ const entry = entryForSession(agent.session)
533
+ if (entry === undefined || entry.finished) return
534
+ if (payload.status === 'running') armIdle(entry)
535
+ else void maybeComplete(entry)
536
+ }
537
+
538
+ function onSubagentEnd(info) {
539
+ const entry = entryForSessionId(info?.id)
540
+ if (entry !== undefined && !entry.finished) void maybeComplete(entry)
541
+ }
542
+
543
+ function installListeners() {
544
+ if (listenersInstalled || typeof ctx.on !== 'function') return
545
+ listenersInstalled = true
546
+ const disposers = []
547
+ try {
548
+ for (const [event, listener] of [
549
+ ['session/event', onSessionEvent],
550
+ ['agent/status', onAgentStatus],
551
+ ['subagent/end', onSubagentEnd],
552
+ ]) {
553
+ const dispose = ctx.on(event, listener)
554
+ if (typeof dispose === 'function') disposers.push(dispose)
555
+ }
556
+ listenerDisposers.push(...disposers)
557
+ } catch (error) {
558
+ for (const dispose of disposers.reverse()) {
559
+ try { dispose() } catch { /* best effort */ }
560
+ }
561
+ listenersInstalled = false
562
+ logger?.warn?.(`capital-automation: 运行监听器安装失败:${String(error)}`)
563
+ }
564
+ }
565
+
566
+ function disposeListeners() {
567
+ for (const dispose of listenerDisposers.splice(0).reverse()) {
568
+ try { dispose() } catch { /* best effort */ }
569
+ }
570
+ listenersInstalled = false
571
+ }
572
+
573
+ async function drainDescendantsBounded(agent) {
574
+ const subagents = service(ctx, 'subagents')
575
+ const drain = subagents?.drainDescendants
576
+ if (typeof drain !== 'function') return
577
+ let timer
578
+ try {
579
+ await Promise.race([
580
+ Promise.resolve().then(() => drain.call(subagents, [agent])).catch(() => {}),
581
+ new Promise((resolve) => {
582
+ timer = setTimeout(resolve, CANCEL_GRACE_MS)
583
+ timer.unref?.()
584
+ }),
585
+ ])
586
+ } finally {
587
+ if (timer !== undefined) clearTimeout(timer)
588
+ }
589
+ }
590
+
591
+ async function stopEntryAgent(entry, controller) {
592
+ let agent = entry.agent
593
+ if (agent === undefined && entry.sessionId !== undefined && typeof controller?.resolveAgent === 'function') {
594
+ try {
595
+ agent = (await controller.resolveAgent(entry.sessionId))?.agent
596
+ if (agent !== undefined) entry.agent = agent
597
+ } catch {
598
+ return
599
+ }
600
+ }
601
+ if (agent === undefined) return
602
+ try {
603
+ agent.cancel({ kind: 'hook', reason: 'automation_disposed' })
604
+ } catch {
605
+ // The Agent may already have been disposed.
606
+ }
607
+ await drainDescendantsBounded(agent)
608
+ }
609
+
610
+ async function markDisposedRun(run) {
611
+ const current = await store.getRun(run.id).catch(() => undefined)
612
+ if (current === undefined || !store.isActiveRun(current.status)) return
613
+ const finishedAt = now()
614
+ await store.setAttemptState(run.occurrenceKey, 'unknown').catch(() => {})
615
+ await store.updateRun(run.id, {
616
+ status: 'unknown_after_restart',
617
+ finishedAt: iso(finishedAt),
618
+ durationMs: Math.max(0, finishedAt - Date.parse(current.queuedAt)),
619
+ errors: [...current.errors, {
620
+ at: iso(finishedAt),
621
+ phase: 'session',
622
+ severity: 'warning',
623
+ code: 'unknown_after_restart',
624
+ message: '自动化 runtime 在运行开始前结束;未自动重试。',
625
+ }].slice(-MAX_ERRORS_PER_RUN),
626
+ }).catch(() => {})
627
+ }
628
+
629
+ async function abandonEntry(entry, controller) {
630
+ if (entry.abandonPromise !== undefined) return entry.abandonPromise
631
+ const settled = entry.finished ? undefined : settledStatusOf(entry)
632
+ if (settled !== undefined) {
633
+ entry.abandoned = true
634
+ entry.abandonPromise = (async () => {
635
+ await finalize(entry, settled)
636
+ await stopEntryAgent(entry, controller)
637
+ })()
638
+ return entry.abandonPromise
639
+ }
640
+ entry.abandoned = true
641
+ entry.finished = true
642
+ clearTimers(entry)
643
+ entry.promptController?.abort()
644
+ entry.abandonPromise = (async () => {
645
+ await stopEntryAgent(entry, controller)
646
+ const at = now()
647
+ const current = await store.getRun(entry.id).catch(() => undefined)
648
+ if (current !== undefined && store.isActiveRun(current.status)) {
649
+ await store.setAttemptState(entry.run.occurrenceKey, 'unknown').catch(() => {})
650
+ await store.updateRun(entry.id, {
651
+ status: 'unknown_after_restart',
652
+ finishedAt: iso(at),
653
+ durationMs: Math.max(0, at - Date.parse(current.queuedAt)),
654
+ errors: [...current.errors, {
655
+ at: iso(at),
656
+ phase: 'session',
657
+ severity: 'warning',
658
+ code: 'unknown_after_restart',
659
+ message: '自动化 runtime 结束时无法证明本轮运行已经结束,结果未知;为避免重复执行,未自动重试。',
660
+ }].slice(-MAX_ERRORS_PER_RUN),
661
+ }).catch(() => {})
662
+ }
663
+ entries.delete(entry.id)
664
+ if (entry.sessionId !== undefined && bySession.get(entry.sessionId) === entry.id) bySession.delete(entry.sessionId)
665
+ entry.toolNames.clear()
666
+ entry.countedEvents.clear()
667
+ })()
668
+ return entry.abandonPromise
669
+ }
670
+
671
+ function dispatchStopped(entry) {
672
+ return disposed || entry.abandoned || entry.finished || entry.cancelRequested !== undefined
673
+ }
674
+
675
+ function canDispatch() {
676
+ return !disposed && entries.size < MAX_ACTIVE_RUNS
677
+ }
678
+
679
+ async function dispatchInternal(run, task, trigger) {
680
+ if (disposed) {
681
+ await markDisposedRun(run)
682
+ return false
683
+ }
684
+ if (entries.has(run.id)) return false
685
+ const current = await store.getRun(run.id).catch(() => undefined)
686
+ if (current === undefined || current.status !== 'queued') return false
687
+ if (disposed) {
688
+ await markDisposedRun(run)
689
+ return false
690
+ }
691
+ if (!canDispatch()) {
692
+ const finishedAt = now()
693
+ await store.updateRun(run.id, {
694
+ status: 'skipped_overlap',
695
+ finishedAt: iso(finishedAt),
696
+ durationMs: Math.max(0, finishedAt - Date.parse(run.queuedAt)),
697
+ }).catch(() => {})
698
+ return false
699
+ }
700
+ const entry = {
701
+ id: run.id,
702
+ run,
703
+ task,
704
+ trigger,
705
+ sessionId: undefined,
706
+ agent: undefined,
707
+ steps: 0,
708
+ toolCalls: 0,
709
+ errors: [],
710
+ toolNames: new Map(),
711
+ countedEvents: new Set(),
712
+ blocked: false,
713
+ abandoned: false,
714
+ abandonPromise: undefined,
715
+ promptController: undefined,
716
+ summary: undefined,
717
+ lastTurnEnd: undefined,
718
+ turnActive: false,
719
+ promptAccepted: false,
720
+ finished: false,
721
+ completing: false,
722
+ cancelRequested: undefined,
723
+ wallTimer: undefined,
724
+ idleTimer: undefined,
725
+ cancelTimer: undefined,
726
+ settleTimer: undefined,
727
+ }
728
+ entries.set(entry.id, entry)
729
+ armWall(entry)
730
+ try {
731
+ const started = await updateEntryRun(entry, { status: 'starting', startedAt: iso(now()) })
732
+ if (started !== undefined && !store.isActiveRun(started.status)) {
733
+ entry.finished = true
734
+ clearTimers(entry)
735
+ entries.delete(entry.id)
736
+ return false
737
+ }
738
+ if (dispatchStopped(entry)) {
739
+ if (disposed || entry.abandoned) await abandonEntry(entry)
740
+ return
741
+ }
742
+ const controller = service(ctx, 'sessionController')
743
+ if (controller?.create === undefined) throw new Error('sessionController 不可用')
744
+ if (profile.runDir === undefined) throw new Error('profile run directory 不可用')
745
+ const target = workspaces === undefined
746
+ ? { workspaceId: undefined, path: profile.runDir }
747
+ : await workspaces.resolve(task.workspaceId)
748
+ if (dispatchStopped(entry)) {
749
+ if (disposed || entry.abandoned) await abandonEntry(entry, controller)
750
+ return
751
+ }
752
+ if (target.workspaceId === undefined) await mkdir(target.path ?? profile.runDir, { recursive: true, mode: 0o700 })
753
+ const created = target.workspaceId === undefined
754
+ ? await controller.create({ cwd: target.path ?? profile.runDir, agentPreset: PRESET_ID })
755
+ : await controller.create({ workspaceId: target.workspaceId, agentPreset: PRESET_ID })
756
+ entry.sessionId = created.sessionId
757
+ if (disposed || entry.abandoned) {
758
+ await stopEntryAgent(entry, controller)
759
+ return
760
+ }
761
+ bySession.set(created.sessionId, entry.id)
762
+ if (entry.cancelRequested !== undefined) {
763
+ await stopEntryAgent(entry, controller)
764
+ return
765
+ }
766
+ const title = sessionTitleOf(task, profile.displayName, run.id)
767
+ const renamed = controller.rename === undefined
768
+ ? undefined
769
+ : await controller.rename({ sessionId: created.sessionId, title }).catch(() => undefined)
770
+ const sessionTitle = renamed?.title ?? title
771
+ await store.setAttemptState(run.occurrenceKey, 'session_created')
772
+ await updateEntryRun(entry, { sessionId: created.sessionId, sessionTitle })
773
+ if (dispatchStopped(entry)) {
774
+ if (disposed || entry.abandoned) await abandonEntry(entry, controller)
775
+ else await stopEntryAgent(entry, controller)
776
+ return
777
+ }
778
+ const resolved = await controller.resolveAgent(created.sessionId)
779
+ if (resolved?.error !== undefined) throw new Error(`session resolve failed: ${resolved.error?.code ?? resolved.error}`)
780
+ entry.agent = resolved.agent
781
+ if (dispatchStopped(entry)) {
782
+ if (disposed || entry.abandoned) await abandonEntry(entry, controller)
783
+ else await stopEntryAgent(entry, controller)
784
+ return
785
+ }
786
+ if (typeof ctx.on === 'function') {
787
+ installListeners()
788
+ }
789
+ armIdle(entry)
790
+ if (dispatchStopped(entry)) {
791
+ if (disposed || entry.abandoned) await abandonEntry(entry, controller)
792
+ else await stopEntryAgent(entry, controller)
793
+ return
794
+ }
795
+ await store.setAttemptState(run.occurrenceKey, 'prompt_submitted')
796
+ if (dispatchStopped(entry)) return
797
+ entry.promptController = new AbortController()
798
+ let receipt
799
+ try {
800
+ receipt = await controller.prompt({
801
+ requestId: `automation-${run.id}`,
802
+ sessionId: created.sessionId,
803
+ mode: 'queue',
804
+ content: [{ type: 'text', text: buildRunPrompt({ task, run, trigger, profileName: profile.displayName, now: now() }) }],
805
+ clientTimeZone: task.trigger.timezone,
806
+ }, entry.promptController.signal)
807
+ } catch (error) {
808
+ if (entry.abandoned || disposed || entry.cancelRequested !== undefined || entry.finished) return
809
+ entry.promptAccepted = false
810
+ await store.setAttemptState(run.occurrenceKey, 'unknown').catch(() => {})
811
+ await finalize(entry, 'unknown_after_restart', {
812
+ error: { phase: 'session', code: 'prompt_not_accepted', message: `无法确认首条指令是否被 Agent 接受:${String(error)}` },
813
+ })
814
+ return
815
+ } finally {
816
+ entry.promptController = undefined
817
+ }
818
+ if (dispatchStopped(entry)) return
819
+ if (receipt?.accepted !== true) {
820
+ await store.setAttemptState(run.occurrenceKey, 'unknown').catch(() => {})
821
+ await finalize(entry, 'unknown_after_restart', {
822
+ error: { phase: 'session', code: 'prompt_not_accepted', message: 'Agent 未明确接受首条指令。' },
823
+ })
824
+ return
825
+ }
826
+ if (dispatchStopped(entry)) return
827
+ entry.promptAccepted = true
828
+ await store.setAttemptState(run.occurrenceKey, 'accepted')
829
+ if (dispatchStopped(entry)) return
830
+ await updateEntryRun(entry, { status: 'running' })
831
+ if (dispatchStopped(entry)) return
832
+ armIdle(entry)
833
+ void maybeComplete(entry)
834
+ } catch (error) {
835
+ if (entry.abandoned || disposed || entry.finished || entry.cancelRequested !== undefined) return
836
+ await store.setAttemptState(run.occurrenceKey, 'failed').catch(() => {})
837
+ const code = error?.code === 'workspace_not_found' || error?.code === 'workspace_unavailable'
838
+ ? error.code
839
+ : 'session_create_failed'
840
+ const message = code === 'session_create_failed' ? `创建运行会话失败:${String(error)}` : String(error.message ?? error)
841
+ await finalize(entry, 'failed', { error: { phase: 'session', code, message } })
842
+ }
843
+ }
844
+
845
+ function dispatch(run, task, trigger) {
846
+ const pending = dispatchInternal(run, task, trigger)
847
+ pendingDispatches.add(pending)
848
+ pending.finally(() => pendingDispatches.delete(pending)).catch(() => {})
849
+ return pending
850
+ }
851
+
852
+ function cancelRun(runId, status = 'canceled') {
853
+ const entry = entries.get(runId)
854
+ if (entry === undefined) return false
855
+ void cancelEntry(entry, 'automation_user_cancel', status)
856
+ return true
857
+ }
858
+
859
+ function noteGuardDenied(sessionId, tool, reason) {
860
+ const entry = entryForSessionId(sessionId)
861
+ if (entry === undefined) return
862
+ entry.blocked = true
863
+ pushError(entry, { phase: 'tool', code: 'blocked_requires_user', message: reason, tool })
864
+ }
865
+
866
+ function dispose() {
867
+ if (disposePromise !== undefined) return disposePromise
868
+ disposed = true
869
+ disposeListeners()
870
+ const pending = [...entries.values()].map((entry) => abandonEntry(entry))
871
+ const dispatches = [...pendingDispatches]
872
+ const all = Promise.allSettled([...pending, ...dispatches])
873
+ let timer
874
+ disposePromise = Promise.race([
875
+ all,
876
+ new Promise((resolve) => {
877
+ timer = setTimeout(resolve, DISPOSE_TIMEOUT_MS)
878
+ timer.unref?.()
879
+ }),
880
+ ]).finally(() => {
881
+ if (timer !== undefined) clearTimeout(timer)
882
+ }).then(() => undefined)
883
+ return disposePromise
884
+ }
885
+
886
+ return {
887
+ dispatch,
888
+ cancelRun,
889
+ noteGuardDenied,
890
+ hasAutomationSession: (sessionId) => entryForSessionId(sessionId) !== undefined,
891
+ runForSession: (sessionId) => entryForSessionId(sessionId)?.id,
892
+ activeRuns: () => [...entries.keys()],
893
+ canDispatch,
894
+ dispose,
895
+ installListeners,
896
+ }
897
+ }
898
+
899
+ function textOfMessage(message, maxChars = MAX_SUMMARY_CHARS) {
900
+ const content = message?.content
901
+ if (!Array.isArray(content) || maxChars <= 0) return ''
902
+ const chunks = []
903
+ let remaining = maxChars
904
+ for (const block of content) {
905
+ if (remaining <= 0) break
906
+ if (block?.type !== 'text' || typeof block.text !== 'string') continue
907
+ const text = block.text.slice(0, remaining)
908
+ chunks.push(text)
909
+ remaining -= text.length
910
+ }
911
+ return chunks.join('\n')
912
+ }