@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.
- package/CHANGELOG.md +25 -0
- package/README.md +14 -14
- package/automation/builtins.js +147 -0
- package/automation/calendar.js +66 -0
- package/automation/client.js +68 -0
- package/automation/client.src.cjs +777 -0
- package/automation/defaults.js +71 -0
- package/automation/index.js +183 -0
- package/automation/owner.js +222 -0
- package/automation/package.json +50 -0
- package/automation/policy.js +105 -0
- package/automation/redact.js +25 -0
- package/automation/route.js +241 -0
- package/automation/runner.js +858 -0
- package/automation/scheduler.js +495 -0
- package/automation/schema.js +359 -0
- package/automation/service.js +338 -0
- package/automation/settings.js +48 -0
- package/automation/store.js +707 -0
- package/automation/time.js +220 -0
- package/automation/workspaces.js +154 -0
- package/chart-ui/package.json +1 -1
- package/config/client.js +4 -2
- package/config/client.src.cjs +90 -1
- package/config/index.js +15 -0
- package/config/package.json +1 -1
- package/cordis.patch.yml +8 -0
- package/lib/index.d.ts +38 -0
- package/lib/index.js +16 -0
- package/lib/web-retriever/local-fetch.d.ts +1 -1
- package/lib/web-retriever/local-fetch.js +1 -1
- package/lib/web-retriever/wind-client.d.ts +1 -1
- package/lib/web-retriever/wind-client.js +1 -1
- package/package.json +9 -7
- package/preset/capital-generation/README.md +1 -1
- package/watchlist/package.json +1 -1
|
@@ -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
|
+
}
|