@mzzsfy/dsh-rs-workflow 1.0.0 → 1.0.2

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.
@@ -1,428 +1,511 @@
1
- // orchestrator — 主循环工具行:rs_workflow_start/status/resume/cancel/message/resume_from/verdict 七工具
2
- // 编排以分段 continuable job 推进:每段 = jobs.start 包装 driver.runSegment,settle 负载经 tool-jobs
3
- // 完成通知唤醒主循环;推进责任唯一在 rs_workflow_resume(页签 control 仅清 paused/裁决)
4
- import { defineTool } from '@deepseek-ai/dsh-tools'
5
- import { gate } from './planner-gate.mjs'
6
- import { validateTemplate } from './template.mjs'
7
- import { startRun, buildSeed } from './driver/index.mjs'
8
- import { registry, registerInitiator, unregisterInitiator, registerResumer } from './driver/control.mjs'
9
- import { reportStore } from './store.mjs'
10
- import { loadJson } from './storage.mjs'
11
- import { normalizeConfig } from './settings-schema.mjs'
12
-
13
- const MAX_REJECTS = 3
14
- const SEGMENT_KIND = 'rsww-segment'
15
- const SETTLE_OUTPUT_LIMIT = 64 * 1024
16
-
17
- // 已启用模板表(自有存储 → {entry, parsed};gate/驱动共用此形态)
18
- export function enabledTemplates() {
19
- const out = []
20
- for (const entry of loadJson('templates.json', [])) {
21
- if (entry.enabled === false || typeof entry.json !== 'string') continue
22
- try {
23
- const parsed = JSON.parse(entry.json)
24
- if (validateTemplate(parsed).length === 0) out.push({ entry, parsed })
25
- } catch { /* 损坏模板不进候选 */ }
26
- }
27
- return out
28
- }
29
-
30
- /** 模式行激活入口(config: { role:'orchestrator', templateId });宿主服务缺一目工具即报错 */
31
- export function registerOrchestrator(ctx, config) {
32
- const expectedTemplateId = config.templateId
33
- // 连续拒单计数与现役 run:行实例内存态,进程重启清零
34
- const rejectCounts = new Map()
35
- const activeRuns = new Map()
36
-
37
- const registered = ctx.inject(['tools', 'workflowEngine', 'jobs'], (tctx) => {
38
- const engine = tctx.workflowEngine
39
- const jobs = tctx.jobs
40
-
41
- const configOf = () => normalizeConfig(loadJson('config.json', undefined))
42
-
43
- const agentIdOf = (agent) => String(agent?.id ?? '')
44
-
45
- // 会话现役 run:同会话同一时刻至多一个未终态 run
46
- const currentRunId = (agent) => {
47
- const runId = activeRuns.get(agentIdOf(agent))
48
- if (runId === undefined) return undefined
49
- // run 可能已被页签删除:get 抛异常须自愈清位,不能让编排入口变砖
50
- let record
51
- try { record = reportStore().get(runId) } catch { record = undefined }
52
- if (record === undefined || record.finishedAt !== undefined) {
53
- activeRuns.delete(agentIdOf(agent))
54
- return undefined
55
- }
56
- return runId
57
- }
58
-
59
- // 段 job:jobs.start 包装 runSegment;settle 负载进 output,经 tool-jobs 完成通知唤醒主循环
60
- function startSegmentJob(agent, driver) {
61
- const promise = driver.runSegment()
62
- return jobs.start({
63
- kind: SEGMENT_KIND,
64
- label: `若水编排段推进 ${driver.runId}`,
65
- outputLimitBytes: SETTLE_OUTPUT_LIMIT,
66
- owner: agent,
67
- run: () => ({
68
- cancel: () => driver.cancel(),
69
- done: promise.then((payload) => ({ status: 'completed', output: JSON.stringify(payload) })),
70
- }),
71
- })
72
- }
73
-
74
- // 断点续跑挂靠:board resume-from 经此回调在原会话重建种子 run(engine/parent 闭包自本域)
75
- function startSeedRun(agent, record, fromStepId, inputs) {
76
- const templates = enabledTemplates()
77
- const template = templates.find((t) => t.entry.id === record.templateId)
78
- if (template === undefined) return { ok: false, error: `模板不存在或已禁用: ${record.templateId}` }
79
- // 单活跃约束:种子 run 与 start 工具同守卫;主循环成为其推进者(activeRuns 注册,补拉/守卫生效)
80
- const current = currentRunId(agent)
81
- if (current !== undefined) return { ok: false, error: `本会话已有进行中的编排 ${current},不可续跑` }
82
- const seed = buildSeed(record, record.plan, fromStepId, inputs)
83
- const driver = startRun({
84
- template: template.parsed, templateSet: templates.map((t) => t.parsed),
85
- plan: record.plan, warnings: record.warnings ?? [],
86
- request: record.request, inputs: seed.inputs, state: seed,
87
- sessionId: agentIdOf(agent), workspace: agent.session?.header?.cwd ?? process.cwd(),
88
- engine, slots: configOf().slots ?? {}, budgets: configOf().budgets ?? {},
89
- parent: agent,
90
- })
91
- activeRuns.set(agentIdOf(agent), driver.runId)
92
- // 种子 run 首段拉起:与 start 工具同责,缺此则续跑 run 停在 running 无活跃段
93
- startSegmentJob(agent, driver)
94
- return { ok: true, runId: driver.runId }
95
- }
96
-
97
- function finishRun(agent, runId) {
98
- if (currentRunId(agent) === runId) activeRuns.delete(agentIdOf(agent))
99
- }
100
-
101
- // 段拉起挂靠:页签裁决受理后 board 经此回到本域拉下一段(幂等:活跃段不重拉);点位与 initiator 挂靠一致
102
- function ensureResumer(agent) {
103
- registerResumer(agentIdOf(agent), (runId) => {
104
- const driver = registry.drivers.get(runId)
105
- if (driver === undefined || driver.active) return false
106
- startSegmentJob(agent, driver)
107
- return true
108
- })
109
- }
110
-
111
- const tools = [
112
- defineTool({
113
- name: 'rs_workflow_start',
114
- description: [
115
- '启动若水工作流编排:提交用户请求原文与结构化规划,受理后引擎分段推进。',
116
- '受理返回 { ok, runId, status };规划被拒返回 { ok:false, errors }(按 errors 修正重交;连续 3 次被拒须回退直接答复并说明)。',
117
- 'templateId 必须等于本组合锚定的模板 id;plan 缺省 = 保底全序(不推荐,多步骤任务应提交 steps/brief)。',
118
- ].join(''),
119
- parameters: {
120
- request: { type: 'string', required: true, description: '用户请求原文(逐字,不转写)' },
121
- templateId: { type: 'string', required: true, description: '本组合锚定的模板 id' },
122
- inputs: { type: 'object', additionalProperties: true, description: '模板顶层 inputs 的键值,值必须为字符串' },
123
- plan: {
124
- type: 'object',
125
- additionalProperties: true,
126
- description: '结构化规划 { steps: [{ref, note?, done?}], brief? }:steps 各步要点(note)与验收口径(done);缺省 = 保底全序',
127
- },
128
- },
129
- output: {
130
- schema: {
131
- type: 'object',
132
- additionalProperties: false,
133
- properties: {
134
- ok: { type: 'boolean', required: true },
135
- runId: { type: 'string' },
136
- status: { type: 'string' },
137
- errors: { type: 'array', items: { type: 'object', additionalProperties: true } },
138
- hint: { type: 'string' },
139
- },
140
- },
141
- render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
142
- },
143
- async execute(args, exec) {
144
- const agent = exec.agent
145
- // 工具参数扁平:gate 入参 = 顶层三字段 + plan(steps/brief)组装(见 feat/orchestrator.md)
146
- const gatePlan = {
147
- request: args.request, templateId: args.templateId, inputs: args.inputs,
148
- brief: args.plan?.brief, steps: args.plan?.steps,
149
- }
150
- const rejects = (id) => {
151
- const next = (rejectCounts.get(id) ?? 0) + 1
152
- rejectCounts.set(id, next)
153
- return next
154
- }
155
- const rejected = (errors) => {
156
- const count = rejects(agentIdOf(agent))
157
- if (count >= MAX_REJECTS) return { ok: false, errors, hint: `已连续 ${MAX_REJECTS} 次规划被拒,本会话编排入口关闭:回退直接答复并向用户说明` }
158
- return { ok: false, errors, hint: '修正 plan 后重新调用;连续 3 次失败将回退直接答复' }
159
- }
160
- // 连续拒单达上限:本会话工具恒失败
161
- if ((rejectCounts.get(agentIdOf(agent)) ?? 0) >= MAX_REJECTS) {
162
- return { ok: false, errors: [], hint: `已连续 ${MAX_REJECTS} 次规划被拒,本会话编排入口关闭:回退直接答复并向用户说明` }
163
- }
164
- if (currentRunId(agent) !== undefined) {
165
- return { ok: false, errors: [], hint: `本会话已有进行中的编排 ${currentRunId(agent)},先等待终态或取消` }
166
- }
167
- const templates = enabledTemplates()
168
- const template = templates.find((t) => t.entry.id === args.templateId)
169
- if (template === undefined) {
170
- // 组合/环境错误而非规划错误:不计连续拒单,hint 指向组合而非 plan
171
- return { ok: false, errors: [{ target: 'templateId', message: `模板不存在或未启用: ${args.templateId}` }], hint: '本组合未启用该模板:检查组合锚定与模板启用状态,勿修改 plan' }
172
- }
173
- const outcome = gate(gatePlan, template, { expectedTemplateId })
174
- if (!outcome.ok) return rejected(outcome.errors)
175
- const driver = startRun({
176
- template: template.parsed, templateSet: templates.map((t) => t.parsed),
177
- plan: outcome.planScript, warnings: outcome.warnings ?? [],
178
- request: args.request, inputs: args.inputs ?? {},
179
- engine, slots: configOf().slots ?? {}, budgets: configOf().budgets ?? {},
180
- // 会话工作区取自会话 header(子代理继承同源);宿主 cwd 仅兜底
181
- sessionId: agentIdOf(agent), workspace: agent.session?.header?.cwd ?? process.cwd(),
182
- parent: agent,
183
- })
184
- rejectCounts.delete(agentIdOf(agent))
185
- activeRuns.set(agentIdOf(agent), driver.runId)
186
- // 断点续跑挂靠:本会话 agent 成为推进器,board resume-from 据此重建种子 run
187
- registerInitiator(agentIdOf(agent), (record, fromStepId, inputs) => startSeedRun(agent, record, fromStepId, inputs))
188
- ensureResumer(agent)
189
- startSegmentJob(agent, driver)
190
- return { ok: true, runId: driver.runId, status: driver.state.status }
191
- },
192
- }),
193
- defineTool({
194
- name: 'rs_workflow_status',
195
- description: '查询若水编排状态:runId 缺省 = 本会话现役 run。返回 status/awaitingResume(补拉判定信号)/steps 账目/waiting 待裁决摘要。',
196
- parameters: {
197
- runId: { type: 'string', description: '缺省 = 本会话现役 run' },
198
- },
199
- output: {
200
- schema: { type: 'object', additionalProperties: true },
201
- render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
202
- },
203
- async execute(args, exec) {
204
- const store = reportStore()
205
- const runId = typeof args.runId === 'string' && args.runId !== '' ? args.runId : currentRunId(exec.agent)
206
- if (runId === undefined) return { ok: false, error: '本会话无现役 run(编排未启动)' }
207
- let record
208
- try { record = store.get(runId) } catch { record = undefined }
209
- if (record === undefined) return { ok: false, error: '运行记录不存在:' + runId }
210
- // 唤醒轮挂靠:主循环任一唤醒轮必先 status,借机注册断点续跑推进器(进程重启后表空,
211
- // 页签 resume-from 恰是重启中断的唯一恢复路径,不能依赖 start/resume 才有挂靠)
212
- registerInitiator(agentIdOf(exec.agent), (record2, fromStepId, inputs) => startSeedRun(exec.agent, record2, fromStepId, inputs))
213
- ensureResumer(exec.agent)
214
- const driver = registry.drivers.get(runId)
215
- const steps = {}
216
- for (const [id, s] of Object.entries(record.state?.steps ?? {})) {
217
- steps[id] = { status: s.status, failCount: s.failCount ?? 0 }
218
- }
219
- const waiting = []
220
- if (record.status === 'waiting_approval') {
221
- const stepId = record.state?.waitingApproval
222
- const planStep = (record.plan?.steps ?? []).find((p) => p.ref === stepId)
223
- waiting.push({ stepId, note: planStep?.note ?? '', done: planStep?.done ?? '' })
224
- }
225
- // paused+已入队裁决:主循环据 pendingVerdicts 可知 resume 拉段后裁决将生效
226
- const pendingVerdicts = (record.state?.pendingApprovals ?? []).length
227
- return {
228
- ok: true, runId, status: record.status,
229
- awaitingResume: record.status === 'paused' && driver?.awaitingResume === true,
230
- steps,
231
- // 宿主校验工具输出须为纯 JSON:undefined 值键会被判无效输出
232
- ...(waiting.length > 0 ? { waiting } : {}),
233
- ...(pendingVerdicts > 0 ? { pendingVerdicts } : {}),
234
- summary: record.summary ?? '',
235
- }
236
- },
237
- }),
238
- defineTool({
239
- name: 'rs_workflow_resume',
240
- description: [
241
- '拉起若水编排下一段(推进责任唯一在此):paused 拉起时同步置 running;已推进/终态返回 skipped。',
242
- '裁决回写后、页签恢复后、running 无活跃段(待拉起)时调用。',
243
- ].join(''),
244
- parameters: {
245
- runId: { type: 'string', required: true, description: '要推进的 run' },
246
- },
247
- output: {
248
- schema: { type: 'object', additionalProperties: true },
249
- render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
250
- },
251
- async execute(args, exec) {
252
- const store = reportStore()
253
- let record
254
- try { record = store.get(args.runId) } catch { record = undefined }
255
- if (record === undefined) return { ok: false, error: '运行记录不存在:' + args.runId }
256
- if (record.finishedAt !== undefined) {
257
- finishRun(exec.agent, args.runId)
258
- return { ok: true, runId: args.runId, status: record.status, summary: record.summary }
259
- }
260
- const driver = registry.drivers.get(args.runId)
261
- if (driver === undefined) {
262
- return { ok: false, error: '编排驱动器未注册(进程重启后 run 已收敛为终态,请 rs_workflow_status 确认)' }
263
- }
264
- // 页签已推进(有活跃段):不重复拉起
265
- if (driver.active) return { ok: true, runId: args.runId, status: record.status, skipped: true }
266
- if (record.status === 'paused') {
267
- driver.state.status = 'running'
268
- driver.awaitingResume = false
269
- driver.persistState()
270
- }
271
- if (record.status === 'waiting_approval') {
272
- return { ok: true, runId: args.runId, status: record.status, skipped: true, hint: '先裁决(control approve/reject 或经会话页签),再 resume 拉起下一段' }
273
- }
274
- startSegmentJob(exec.agent, driver)
275
- registerInitiator(agentIdOf(exec.agent), (record2, fromStepId, inputs) => startSeedRun(exec.agent, record2, fromStepId, inputs))
276
- ensureResumer(exec.agent)
277
- return { ok: true, runId: args.runId, status: record.status }
278
- },
279
- }),
280
- defineTool({
281
- name: 'rs_workflow_cancel',
282
- description: '取消若水编排(幂等):running 有活跃段则 abort 收敛;waiting_approval/paused 即时终态;已终态返回摘要无副作用。',
283
- parameters: {
284
- runId: { type: 'string', required: true, description: '要取消的 run' },
285
- },
286
- output: {
287
- schema: { type: 'object', additionalProperties: true },
288
- render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
289
- },
290
- async execute(args, exec) {
291
- const store = reportStore()
292
- let record
293
- try { record = store.get(args.runId) } catch { record = undefined }
294
- if (record === undefined) return { ok: false, error: '运行记录不存在:' + args.runId }
295
- if (record.finishedAt !== undefined) {
296
- finishRun(exec.agent, args.runId)
297
- return { ok: true, runId: args.runId, status: record.status, summary: record.summary }
298
- }
299
- const driver = registry.drivers.get(args.runId)
300
- if (driver === undefined) return { ok: false, error: '编排驱动器未注册' }
301
- driver.cancel()
302
- const hint = '已完成步骤保留,可在会话页签断点续跑'
303
- if (driver.active) {
304
- await driver.runSegment().catch(() => {})
305
- let after
306
- try { after = store.get(args.runId) } catch { after = undefined }
307
- finishRun(exec.agent, args.runId)
308
- return { ok: true, runId: args.runId, status: after?.status ?? 'cancelled', summary: after?.summary ?? '', hint }
309
- }
310
- let after
311
- try { after = store.get(args.runId) } catch { after = undefined }
312
- finishRun(exec.agent, args.runId)
313
- return { ok: true, runId: args.runId, status: after?.status ?? 'cancelled', hint }
314
- },
315
- }),
316
- defineTool({
317
- name: 'rs_workflow_message',
318
- description: '向进行中的若水编排转达用户纠偏:下一步骤边界进入编排(纠偏类经主循环转译后 inject 注入后续指令)。',
319
- parameters: {
320
- runId: { type: 'string', required: true, description: '目标 run' },
321
- text: { type: 'string', required: true, description: '用户纠偏内容(转译后)' },
322
- inject: { type: 'boolean', description: 'true = 注入后续指令;缺省 false 排队' },
323
- },
324
- output: {
325
- schema: { type: 'object', additionalProperties: true },
326
- render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
327
- },
328
- async execute(args) {
329
- const driver = registry.drivers.get(args.runId)
330
- if (driver === undefined) return { ok: false, error: '编排驱动器未注册(run 未终态但不在内存,或已终态)' }
331
- const accepted = driver.handlePost({ kind: 'message', text: args.text, inject: args.inject === true })
332
- return accepted ? { ok: true } : { ok: false, error: 'run 已终态,消息不予受理' }
333
- },
334
- }),
335
- defineTool({
336
- name: 'rs_workflow_resume_from',
337
- description: [
338
- '断点续跑/从头重跑(主循环通道,与页签 ResumePicker 同能力):目标 run 须终态或已收敛。',
339
- '经挂靠机制在原会话重建种子 run:done 步骤产出继承,fromStepId 及其后代闭包重置(缺省 = 从第一个未完成步续跑);',
340
- 'inputs 可选覆盖模板入参。旧 run 记录保留作审计,旧 run 未消费的纠偏消息不带入新 run。',
341
- ].join(''),
342
- parameters: {
343
- runId: { type: 'string', required: true, description: '要续跑的 run(终态)' },
344
- fromStepId: { type: 'string', description: '从该步骤重来(其后代全部重置);缺省 = 断点续跑' },
345
- inputs: { type: 'object', additionalProperties: true, description: '覆盖模板顶层 inputs,值必须为字符串' },
346
- },
347
- output: {
348
- schema: { type: 'object', additionalProperties: true },
349
- render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
350
- },
351
- async execute(args, exec) {
352
- const store = reportStore()
353
- let record
354
- try { record = store.get(args.runId) } catch { record = undefined }
355
- if (record === undefined) return { ok: false, error: '运行记录不存在:' + args.runId }
356
- // 活跃守卫:未终态(或 driver 仍在内存)不可续跑,与页签 resume-from 同口径
357
- if (registry.drivers.has(args.runId) || record.finishedAt === undefined) {
358
- return { ok: false, error: '运行进行中,不可续跑' }
359
- }
360
- // 会话归属守卫:仅原 run 所属会话的主循环可续跑,防跨会话接管他人 run
361
- if (record.sessionId !== agentIdOf(exec.agent)) {
362
- return { ok: false, error: '该 run 属于其他会话,本会话不可续跑' }
363
- }
364
- if (args.fromStepId !== undefined && (record.plan?.steps?.some((p) => p.ref === args.fromStepId)) !== true) {
365
- return { ok: false, error: 'fromStepId 不在剧本中:' + args.fromStepId }
366
- }
367
- if (currentRunId(exec.agent) !== undefined) {
368
- return { ok: false, error: `本会话已有进行中的编排 ${currentRunId(exec.agent)},不可续跑` }
369
- }
370
- // 唤醒轮挂靠兜底:重启后 initiator 表空,续跑前借本调用重注册(status/resume 同款)
371
- registerInitiator(agentIdOf(exec.agent), (record2, fromStepId, inputs) => startSeedRun(exec.agent, record2, fromStepId, inputs))
372
- ensureResumer(exec.agent)
373
- const outcome = startSeedRun(exec.agent, record, args.fromStepId, args.inputs)
374
- if (!outcome.ok) return { ok: false, error: outcome.error }
375
- return { ok: true, runId: outcome.runId, hint: '旧 run 未消费的纠偏消息不带入新 run;rs_workflow_status 跟踪推进' }
376
- },
377
- }),
378
- defineTool({
379
- name: 'rs_workflow_verdict',
380
- description: [
381
- '回写若水编排裁决(主循环裁决通道,与页签先到先得):run 处于 waiting_approval 时受理。',
382
- 'autoApprove=true 代审:by=main-agent + 代审意见;ask_user 转呈真人:by=user + 用户意见。裁决后 rs_workflow_resume 拉起下一段。',
383
- ].join(''),
384
- parameters: {
385
- runId: { type: 'string', description: '要裁决的 run(缺省 = 本会话现役 run)' },
386
- verdict: { type: 'string', required: true, description: 'approve = 通过;reject = 驳回(重做)' },
387
- reason: { type: 'string', required: true, description: '裁决意见(审计必填):驳回=重做口径,通过=放行依据' },
388
- by: { type: 'string', description: '裁决来源:main-agent=代审(缺省);user=真人转呈后的用户裁决' },
389
- },
390
- output: {
391
- schema: { type: 'object', additionalProperties: true },
392
- render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
393
- },
394
- async execute(args, exec) {
395
- if (args.verdict !== 'approve' && args.verdict !== 'reject') {
396
- return { ok: false, error: 'verdict 须为 approve|reject' }
397
- }
398
- if (typeof args.reason !== 'string' || args.reason.trim() === '') {
399
- return { ok: false, error: 'reason 必填(审计口径与页签一致)' }
400
- }
401
- const by = args.by === 'user' ? 'user' : 'main-agent'
402
- const runId = typeof args.runId === 'string' && args.runId !== '' ? args.runId : currentRunId(exec.agent)
403
- if (runId === undefined) return { ok: false, error: '本会话无现役 run(编排未启动)' }
404
- const driver = registry.drivers.get(runId)
405
- if (driver === undefined) return { ok: false, error: '编排驱动器未注册(run 未终态但不在内存,或已终态)' }
406
- const accepted = driver.handlePost({ kind: args.verdict, by, reason: args.reason })
407
- const record = reportStore().get(runId)
408
- if (accepted) {
409
- return { ok: true, runId, status: record?.status, hint: '裁决已受理;rs_workflow_resume 拉起下一段' }
410
- }
411
- // 竞态口径:页签先裁时后到方不受理,但 run 可能已翻 running 且无活跃段(等 resume 拉起),
412
- // 主循环须继续承担推进责任,否则 run 挂死在 running 无段状态
413
- if (!driver.active && record?.status === 'running') {
414
- startSegmentJob(exec.agent, driver)
415
- return { ok: true, runId, status: record.status, hint: '裁决已被页签先裁(先到先得);已代为拉起下一段' }
416
- }
417
- return { ok: false, error: '裁决未受理(状态不符或已被页签先裁)' }
418
- },
419
- }),
420
- ]
421
- for (const tool of tools) tctx.effect(() => tctx.tools.register(tool), 'rs-workflow orchestrator: ' + tool.name)
422
- // 行上下文(tool context)无 agent 属性,effect 回调在此返回 undefined 触发宿主 dispose 契约异常,
423
- // 导致整个 inject 回调失败、七工具全部未注册(43267fc 引入,实机定位);清理由既有路径承担:
424
- // run 终态 finishRun/unregisterInitiator 与 cancel 的 finishRun 清 activeRuns;行销毁后的
425
- // stale initiator 表项在下次 start 受理时被覆盖,不产生跨会话续跑。
426
- })
427
- return { rejectCounts, activeRuns }
428
- }
1
+ // orchestrator — 主循环工具行:rs_workflow_start/status/resume/cancel/message/resume_from/verdict 七工具
2
+ // 编排以分段 continuable job 推进:每段 = jobs.start 包装 driver.runSegment,settle 负载经 tool-jobs
3
+ // 完成通知唤醒主循环;推进责任唯一在 rs_workflow_resume(页签 control 仅清 paused/裁决)
4
+ // 另有会话守门:轮次将停时(agent/turn-stopping)判定欠动作并 steer 拉回,防弱模型提前退出
5
+ import { defineTool } from '@deepseek-ai/dsh-tools'
6
+ import { gate } from './planner-gate.mjs'
7
+ import { validateTemplate } from './template.mjs'
8
+ import { startRun, buildSeed } from './driver/index.mjs'
9
+ import { registry, registerInitiator, registerResumer } from './driver/control.mjs'
10
+ import { reportStore } from './store.mjs'
11
+ import { loadJson } from './storage.mjs'
12
+ import { normalizeConfig } from './settings-schema.mjs'
13
+ import { pendingOf, createNudgeGate, nudgeText } from './guard.mjs'
14
+
15
+ const MAX_REJECTS = 3
16
+ const SEGMENT_KIND = 'rsww-segment'
17
+ const SETTLE_OUTPUT_LIMIT = 64 * 1024
18
+
19
+ // 已启用模板表(自有存储 → {entry, parsed};gate/驱动共用此形态)
20
+ export function enabledTemplates() {
21
+ const out = []
22
+ for (const entry of loadJson('templates.json', [])) {
23
+ if (entry.enabled === false || typeof entry.json !== 'string') continue
24
+ try {
25
+ const parsed = JSON.parse(entry.json)
26
+ if (validateTemplate(parsed).length === 0) out.push({ entry, parsed })
27
+ } catch { /* 损坏模板不进候选 */ }
28
+ }
29
+ return out
30
+ }
31
+
32
+ /** 模式行激活入口(config: { role:'orchestrator', templateId });宿主服务缺一目工具即报错 */
33
+ export function registerOrchestrator(ctx, config) {
34
+ const expectedTemplateId = config.templateId
35
+ // 连续拒单计数与现役 run:行实例内存态,进程重启清零
36
+ const rejectCounts = new Map()
37
+ const activeRuns = new Map()
38
+
39
+ // 会话现役 run:同会话同一时刻至多一个未终态 run(守门与工具同源,故在 inject 外共享)
40
+ const agentIdOf = (agent) => String(agent?.id ?? '')
41
+ const currentRunId = (agent) => {
42
+ const runId = activeRuns.get(agentIdOf(agent))
43
+ if (runId === undefined) return undefined
44
+ // run 可能已被页签删除:get 抛异常须自愈清位,不能让编排入口变砖;
45
+ // driver 不在内存(行重建/模块重载)的僵尸 running 同样清位:resume/cancel 对它均无力,只会永久阻塞
46
+ let record
47
+ try { record = reportStore().get(runId) } catch { record = undefined }
48
+ if (record === undefined || record.finishedAt !== undefined || !registry.drivers.has(runId)) {
49
+ activeRuns.delete(agentIdOf(agent))
50
+ return undefined
51
+ }
52
+ return runId
53
+ }
54
+
55
+ const registered = ctx.inject(['tools', 'workflowEngine', 'jobs'], (tctx) => {
56
+ const engine = tctx.workflowEngine
57
+ const jobs = tctx.jobs
58
+
59
+ const configOf = () => normalizeConfig(loadJson('config.json', undefined))
60
+
61
+ // 段 job:jobs.start 包装 runSegment;settle 负载进 output,经 tool-jobs 完成通知唤醒主循环。
62
+ // jobs.start 同步抛错(无控制器/owner 超上限/stale owner)时 run 已落盘而段未起,必须收敛为 failed,
63
+ // 否则本会话被未终态 run 永久阻塞(start 与页签 resume-from 均拒)
64
+ function startSegmentJob(agent, driver) {
65
+ const promise = driver.runSegment()
66
+ return jobs.start({
67
+ kind: SEGMENT_KIND,
68
+ label: `若水编排段推进 ${driver.runId}`,
69
+ outputLimitBytes: SETTLE_OUTPUT_LIMIT,
70
+ owner: agent,
71
+ run: () => ({
72
+ cancel: () => driver.cancel(),
73
+ done: promise.then((payload) => ({ status: 'completed', output: JSON.stringify(payload) })),
74
+ }),
75
+ })
76
+ }
77
+ // 包装失败收敛:jobs.start 抛错 → run 收敛 failed + 注销驱动器,保持记录终态一致
78
+ function startSegmentJobSafe(agent, driver) {
79
+ try {
80
+ return startSegmentJob(agent, driver)
81
+ } catch (e) {
82
+ driver.finish('failed', `段任务启动失败:${e?.message ?? e}`)
83
+ try { registry.drivers.delete(driver.runId) } catch { /* 内存表清理,不影响收敛结果 */ }
84
+ throw e
85
+ }
86
+ }
87
+
88
+ // 断点续跑挂靠:board resume-from 经此回调在原会话重建种子 run(engine/parent 闭包自本域)
89
+ function startSeedRun(agent, record, fromStepId, inputs) {
90
+ const templates = enabledTemplates()
91
+ const template = templates.find((t) => t.entry.id === record.templateId)
92
+ if (template === undefined) return { ok: false, error: `模板不存在或已禁用: ${record.templateId}` }
93
+ // 单活跃约束:种子 run 与 start 工具同守卫;主循环成为其推进者(activeRuns 注册,补拉/守卫生效)
94
+ const current = currentRunId(agent)
95
+ if (current !== undefined) return { ok: false, error: `本会话已有进行中的编排 ${current},不可续跑` }
96
+ const seed = buildSeed(record, record.plan, fromStepId, inputs)
97
+ const driver = startRun({
98
+ template: template.parsed, templateSet: templates.map((t) => t.parsed),
99
+ plan: record.plan, warnings: record.warnings ?? [],
100
+ request: record.request, inputs: seed.inputs, state: seed,
101
+ sessionId: agentIdOf(agent), workspace: agent.session?.header?.cwd ?? process.cwd(),
102
+ engine, slots: configOf().slots ?? {}, budgets: configOf().budgets ?? {},
103
+ parent: agent,
104
+ })
105
+ // 先拉段后注册:jobs.start 抛错时 activeRuns 不占位(段收敛 failed 已终态,不阻塞后续 start);
106
+ // 拉段成功但未注册的窗口内 resume 幂等(skipped),无漂移
107
+ startSegmentJobSafe(agent, driver)
108
+ activeRuns.set(agentIdOf(agent), driver.runId)
109
+ return { ok: true, runId: driver.runId }
110
+ }
111
+
112
+ function finishRun(agent, runId) {
113
+ if (currentRunId(agent) === runId) activeRuns.delete(agentIdOf(agent))
114
+ }
115
+
116
+ // 段拉起挂靠:页签裁决受理后 board 经此回到本域拉下一段(幂等:活跃段不重拉);点位与 initiator 挂靠一致
117
+ function ensureResumer(agent) {
118
+ registerResumer(agentIdOf(agent), (runId) => {
119
+ const driver = registry.drivers.get(runId)
120
+ if (driver === undefined || driver.active) return false
121
+ startSegmentJobSafe(agent, driver)
122
+ return true
123
+ })
124
+ }
125
+
126
+ const tools = [
127
+ defineTool({
128
+ name: 'rs_workflow_start',
129
+ description: [
130
+ '启动若水工作流编排:提交用户请求原文与结构化规划,受理后引擎分段推进。',
131
+ '受理返回 { ok, runId, status };规划被拒返回 { ok:false, errors }(按 errors 修正重交;连续 3 次被拒须回退直接答复并说明)。',
132
+ 'templateId 必须等于本组合锚定的模板 id;plan 缺省 = 保底全序(不推荐,多步骤任务应提交 steps/brief)。',
133
+ ].join(''),
134
+ parameters: {
135
+ request: { type: 'string', required: true, description: '用户请求原文(逐字,不转写)' },
136
+ templateId: { type: 'string', required: true, description: '本组合锚定的模板 id' },
137
+ inputs: { type: 'object', additionalProperties: true, description: '模板顶层 inputs 的键值,值必须为字符串' },
138
+ plan: {
139
+ type: 'object',
140
+ additionalProperties: true,
141
+ description: '结构化规划 { steps: [{ref, note?, done?}], brief? }:steps 各步要点(note)与验收口径(done);缺省 = 保底全序',
142
+ },
143
+ },
144
+ output: {
145
+ schema: {
146
+ type: 'object',
147
+ additionalProperties: false,
148
+ properties: {
149
+ ok: { type: 'boolean', required: true },
150
+ runId: { type: 'string' },
151
+ status: { type: 'string' },
152
+ errors: { type: 'array', items: { type: 'object', additionalProperties: true } },
153
+ hint: { type: 'string' },
154
+ },
155
+ },
156
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
157
+ },
158
+ async execute(args, exec) {
159
+ const agent = exec.agent
160
+ // 工具参数扁平:gate 入参 = 顶层三字段 + plan(steps/brief)组装(见 feat/orchestrator.md)
161
+ const gatePlan = {
162
+ request: args.request, templateId: args.templateId, inputs: args.inputs,
163
+ brief: args.plan?.brief, steps: args.plan?.steps,
164
+ }
165
+ const rejects = (id) => {
166
+ const next = (rejectCounts.get(id) ?? 0) + 1
167
+ rejectCounts.set(id, next)
168
+ return next
169
+ }
170
+ const rejected = (errors) => {
171
+ const count = rejects(agentIdOf(agent))
172
+ if (count >= MAX_REJECTS) return { ok: false, errors, hint: `已连续 ${MAX_REJECTS} 次规划被拒,本会话编排入口关闭:回退直接答复并向用户说明` }
173
+ return { ok: false, errors, hint: `修正 plan 后重新调用;连续 ${MAX_REJECTS} 次失败将回退直接答复` }
174
+ }
175
+ // 连续拒单达上限:本会话工具恒失败
176
+ if ((rejectCounts.get(agentIdOf(agent)) ?? 0) >= MAX_REJECTS) {
177
+ return { ok: false, errors: [], hint: `已连续 ${MAX_REJECTS} 次规划被拒,本会话编排入口关闭:回退直接答复并向用户说明` }
178
+ }
179
+ if (currentRunId(agent) !== undefined) {
180
+ return { ok: false, errors: [], hint: `本会话已有进行中的编排 ${currentRunId(agent)},先等待终态或取消` }
181
+ }
182
+ const templates = enabledTemplates()
183
+ const template = templates.find((t) => t.entry.id === args.templateId)
184
+ if (template === undefined) {
185
+ // 组合/环境错误而非规划错误:不计连续拒单,hint 指向组合而非 plan
186
+ return { ok: false, errors: [{ target: 'templateId', message: `模板不存在或未启用: ${args.templateId}` }], hint: '本组合未启用该模板:检查组合锚定与模板启用状态,勿修改 plan' }
187
+ }
188
+ const outcome = gate(gatePlan, template, { expectedTemplateId })
189
+ if (!outcome.ok) {
190
+ // 锚定不符与模板缺失同为组合/环境错误:模板集是环境给定,模型改 plan 无济于事,不计拒单
191
+ const anchoring = outcome.errors.some((e) => e.target === 'templateId' && String(e.message).includes('与本组合锚定模板不符'))
192
+ if (anchoring) {
193
+ return { ok: false, errors: outcome.errors, hint: '提交的 templateId 与本组合锚定不符:改用组合锚定模板,勿修改 plan' }
194
+ }
195
+ return rejected(outcome.errors)
196
+ }
197
+ const driver = startRun({
198
+ template: template.parsed, templateSet: templates.map((t) => t.parsed),
199
+ plan: outcome.planScript, warnings: outcome.warnings ?? [],
200
+ request: args.request, inputs: args.inputs ?? {},
201
+ engine, slots: configOf().slots ?? {}, budgets: configOf().budgets ?? {},
202
+ // 会话工作区取自会话 header(子代理继承同源);宿主 cwd 仅兜底
203
+ sessionId: agentIdOf(agent), workspace: agent.session?.header?.cwd ?? process.cwd(),
204
+ parent: agent,
205
+ })
206
+ rejectCounts.delete(agentIdOf(agent))
207
+ // 断点续跑挂靠:本会话 agent 成为推进器,board resume-from 据此重建种子 run
208
+ registerInitiator(agentIdOf(agent), (record, fromStepId, inputs) => startSeedRun(agent, record, fromStepId, inputs))
209
+ ensureResumer(agent)
210
+ // 先拉段后注册现役:jobs.start 抛错时 run 已收敛 failed,不占 activeRuns 阻塞本会话
211
+ startSegmentJobSafe(agent, driver)
212
+ activeRuns.set(agentIdOf(agent), driver.runId)
213
+ return { ok: true, runId: driver.runId, status: driver.state.status }
214
+ },
215
+ }),
216
+ defineTool({
217
+ name: 'rs_workflow_status',
218
+ description: '查询若水编排状态:runId 缺省 = 本会话现役 run。返回 status/awaitingResume(补拉判定信号)/steps 账目/waiting 待裁决摘要。',
219
+ parameters: {
220
+ runId: { type: 'string', description: '缺省 = 本会话现役 run' },
221
+ },
222
+ output: {
223
+ schema: { type: 'object', additionalProperties: true },
224
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
225
+ },
226
+ async execute(args, exec) {
227
+ const store = reportStore()
228
+ const runId = typeof args.runId === 'string' && args.runId !== '' ? args.runId : currentRunId(exec.agent)
229
+ if (runId === undefined) return { ok: false, error: '本会话无现役 run(编排未启动)' }
230
+ let record
231
+ try { record = store.get(runId) } catch { record = undefined }
232
+ if (record === undefined) return { ok: false, error: '运行记录不存在:' + runId }
233
+ // 唤醒轮挂靠:主循环任一唤醒轮必先 status,借机注册断点续跑推进器(进程重启后表空,
234
+ // 页签 resume-from 恰是重启中断的唯一恢复路径,不能依赖 start/resume 才有挂靠)
235
+ registerInitiator(agentIdOf(exec.agent), (record2, fromStepId, inputs) => startSeedRun(exec.agent, record2, fromStepId, inputs))
236
+ ensureResumer(exec.agent)
237
+ const driver = registry.drivers.get(runId)
238
+ const steps = {}
239
+ for (const [id, s] of Object.entries(record.state?.steps ?? {})) {
240
+ steps[id] = { status: s.status, failCount: s.failCount ?? 0 }
241
+ }
242
+ const waiting = []
243
+ if (record.status === 'waiting_approval') {
244
+ const stepId = record.state?.waitingApproval
245
+ const planStep = (record.plan?.steps ?? []).find((p) => p.ref === stepId)
246
+ waiting.push({ stepId, note: planStep?.note ?? '', done: planStep?.done ?? '', autoApprove: record.state?.waitingAutoApprove === true })
247
+ }
248
+ // paused+已入队裁决:主循环据 pendingVerdicts 可知 resume 拉段后裁决将生效
249
+ const pendingVerdicts = (record.state?.pendingApprovals ?? []).length
250
+ return {
251
+ ok: true, runId, status: record.status,
252
+ awaitingResume: record.status === 'paused' && driver?.awaitingResume === true,
253
+ // 段在飞信号:模型据此区分"running 且段在飞(勿拉)"与"running 无段(须 resume 补拉)"
254
+ active: driver?.active === true,
255
+ steps,
256
+ // 宿主校验工具输出须为纯 JSON:undefined 值键会被判无效输出
257
+ ...(waiting.length > 0 ? { waiting } : {}),
258
+ ...(pendingVerdicts > 0 ? { pendingVerdicts } : {}),
259
+ summary: record.summary ?? '',
260
+ }
261
+ },
262
+ }),
263
+ defineTool({
264
+ name: 'rs_workflow_resume',
265
+ description: [
266
+ '拉起若水编排下一段(推进责任唯一在此):paused 拉起时同步置 running;已推进/终态返回 skipped。',
267
+ '裁决回写后、页签恢复后、running 无活跃段(待拉起)时调用。',
268
+ ].join(''),
269
+ parameters: {
270
+ runId: { type: 'string', required: true, description: '要推进的 run' },
271
+ },
272
+ output: {
273
+ schema: { type: 'object', additionalProperties: true },
274
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
275
+ },
276
+ async execute(args, exec) {
277
+ const store = reportStore()
278
+ let record
279
+ try { record = store.get(args.runId) } catch { record = undefined }
280
+ if (record === undefined) return { ok: false, error: '运行记录不存在:' + args.runId }
281
+ if (record.sessionId !== undefined && record.sessionId !== agentIdOf(exec.agent)) {
282
+ return { ok: false, error: '拒绝操作:该 run 属于其他会话' }
283
+ }
284
+ if (record.finishedAt !== undefined) {
285
+ finishRun(exec.agent, args.runId)
286
+ return { ok: true, runId: args.runId, status: record.status, summary: record.summary }
287
+ }
288
+ const driver = registry.drivers.get(args.runId)
289
+ if (driver === undefined) {
290
+ return { ok: false, error: '编排驱动器未注册(进程重启后 run 已收敛为终态,请 rs_workflow_status 确认)' }
291
+ }
292
+ // 页签已推进(有活跃段):不重复拉起
293
+ if (driver.active) return { ok: true, runId: args.runId, status: record.status, skipped: true }
294
+ if (record.status === 'paused') {
295
+ driver.state.status = 'running'
296
+ driver.awaitingResume = false
297
+ driver.persistState()
298
+ }
299
+ if (record.status === 'waiting_approval') {
300
+ return { ok: true, runId: args.runId, status: record.status, skipped: true, hint: '先裁决(control approve/reject 或经会话页签),再 resume 拉起下一段' }
301
+ }
302
+ startSegmentJobSafe(exec.agent, driver)
303
+ registerInitiator(agentIdOf(exec.agent), (record2, fromStepId, inputs) => startSeedRun(exec.agent, record2, fromStepId, inputs))
304
+ ensureResumer(exec.agent)
305
+ return { ok: true, runId: args.runId, status: record.status }
306
+ },
307
+ }),
308
+ defineTool({
309
+ name: 'rs_workflow_cancel',
310
+ description: '取消若水编排(幂等):running 有活跃段则 abort 收敛;waiting_approval/paused 即时终态;已终态返回摘要无副作用。',
311
+ parameters: {
312
+ runId: { type: 'string', required: true, description: '要取消的 run' },
313
+ },
314
+ output: {
315
+ schema: { type: 'object', additionalProperties: true },
316
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
317
+ },
318
+ async execute(args, exec) {
319
+ const store = reportStore()
320
+ let record
321
+ try { record = store.get(args.runId) } catch { record = undefined }
322
+ if (record === undefined) return { ok: false, error: '运行记录不存在:' + args.runId }
323
+ if (record.sessionId !== undefined && record.sessionId !== agentIdOf(exec.agent)) {
324
+ return { ok: false, error: '拒绝操作:该 run 属于其他会话' }
325
+ }
326
+ if (record.finishedAt !== undefined) {
327
+ finishRun(exec.agent, args.runId)
328
+ return { ok: true, runId: args.runId, status: record.status, summary: record.summary }
329
+ }
330
+ const driver = registry.drivers.get(args.runId)
331
+ if (driver === undefined) return { ok: false, error: '编排驱动器未注册' }
332
+ await driver.cancelAndSettle()
333
+ let after
334
+ try { after = store.get(args.runId) } catch { after = undefined }
335
+ finishRun(exec.agent, args.runId)
336
+ const hint = '已完成步骤保留,可在会话页签断点续跑'
337
+ return { ok: true, runId: args.runId, status: after?.status ?? 'cancelled', hint }
338
+ },
339
+ }),
340
+ defineTool({
341
+ name: 'rs_workflow_message',
342
+ description: '向进行中的若水编排转达用户纠偏:下一步骤边界进入编排(纠偏类经主循环转译后 inject 注入后续指令)。',
343
+ parameters: {
344
+ runId: { type: 'string', required: true, description: '目标 run' },
345
+ text: { type: 'string', required: true, description: '用户纠偏内容(转译后)' },
346
+ inject: { type: 'boolean', description: 'true = 注入后续指令;缺省 false 排队' },
347
+ },
348
+ output: {
349
+ schema: { type: 'object', additionalProperties: true },
350
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
351
+ },
352
+ async execute(args, exec) {
353
+ const driver = registry.drivers.get(args.runId)
354
+ if (driver === undefined) return { ok: false, error: '编排驱动器未注册(run 未终态但不在内存,或已终态)' }
355
+ // 会话归属守卫:driver 与 record 同 runId,直接以 driver.sessionId 校验
356
+ if (driver.sessionId !== undefined && driver.sessionId !== agentIdOf(exec.agent)) {
357
+ return { ok: false, error: '拒绝操作:该 run 属于其他会话' }
358
+ }
359
+ if (typeof args.text !== 'string' || args.text.trim() === '') {
360
+ return { ok: false, error: 'text 必须为非空字符串' }
361
+ }
362
+ const accepted = driver.handlePost({ kind: 'message', text: args.text, inject: args.inject === true })
363
+ return accepted ? { ok: true } : { ok: false, error: '消息未被受理(run 已终态或内容为空)' }
364
+ },
365
+ }),
366
+ defineTool({
367
+ name: 'rs_workflow_resume_from',
368
+ description: [
369
+ '断点续跑/从头重跑(主循环通道,与页签 ResumePicker 同能力):目标 run 须终态或已收敛。',
370
+ '经挂靠机制在原会话重建种子 run:done 步骤产出继承,fromStepId 及其后代闭包重置(缺省 = 从第一个未完成步续跑);',
371
+ 'inputs 可选覆盖模板入参。旧 run 记录保留作审计,旧 run 未消费的纠偏消息不带入新 run。',
372
+ ].join(''),
373
+ parameters: {
374
+ runId: { type: 'string', required: true, description: '要续跑的 run(终态)' },
375
+ fromStepId: { type: 'string', description: '从该步骤重来(其后代全部重置);缺省 = 断点续跑' },
376
+ inputs: { type: 'object', additionalProperties: true, description: '覆盖模板顶层 inputs,值必须为字符串' },
377
+ },
378
+ output: {
379
+ schema: { type: 'object', additionalProperties: true },
380
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
381
+ },
382
+ async execute(args, exec) {
383
+ const store = reportStore()
384
+ let record
385
+ try { record = store.get(args.runId) } catch { record = undefined }
386
+ if (record === undefined) return { ok: false, error: '运行记录不存在:' + args.runId }
387
+ // 活跃守卫:未终态(或 driver 仍在内存)不可续跑,与页签 resume-from 同口径
388
+ if (registry.drivers.has(args.runId) || record.finishedAt === undefined) {
389
+ return { ok: false, error: '运行进行中,不可续跑' }
390
+ }
391
+ // 会话归属守卫:仅原 run 所属会话的主循环可续跑,防跨会话接管他人 run
392
+ if (record.sessionId !== agentIdOf(exec.agent)) {
393
+ return { ok: false, error: '该 run 属于其他会话,本会话不可续跑' }
394
+ }
395
+ if (args.fromStepId !== undefined && (record.plan?.steps?.some((p) => p.ref === args.fromStepId)) !== true) {
396
+ return { ok: false, error: 'fromStepId 不在剧本中:' + args.fromStepId }
397
+ }
398
+ // 与模板入参契约同口径:值必须为字符串(数组/对象在种子展开中失真)
399
+ if (args.inputs !== undefined && (typeof args.inputs !== 'object' || args.inputs === null || Array.isArray(args.inputs)
400
+ || Object.values(args.inputs).some((v) => typeof v !== 'string'))) {
401
+ return { ok: false, error: 'inputs 必须为字符串值对象(键→字符串)' }
402
+ }
403
+ if (currentRunId(exec.agent) !== undefined) {
404
+ return { ok: false, error: `本会话已有进行中的编排 ${currentRunId(exec.agent)},不可续跑` }
405
+ }
406
+ // 唤醒轮挂靠兜底:重启后 initiator 表空,续跑前借本调用重注册(status/resume 同款)
407
+ registerInitiator(agentIdOf(exec.agent), (record2, fromStepId, inputs) => startSeedRun(exec.agent, record2, fromStepId, inputs))
408
+ ensureResumer(exec.agent)
409
+ const outcome = startSeedRun(exec.agent, record, args.fromStepId, args.inputs)
410
+ if (!outcome.ok) return { ok: false, error: outcome.error }
411
+ return { ok: true, runId: outcome.runId, hint: '旧 run 未消费的纠偏消息不带入新 run;rs_workflow_status 跟踪推进' }
412
+ },
413
+ }),
414
+ defineTool({
415
+ name: 'rs_workflow_verdict',
416
+ description: [
417
+ '回写若水编排裁决(主循环裁决通道,与页签先到先得):run 处于 waiting_approval 时受理。',
418
+ 'autoApprove=true 代审:by=main-agent + 代审意见;ask_user 转呈真人:by=user + 用户意见。裁决后 rs_workflow_resume 拉起下一段。',
419
+ ].join(''),
420
+ parameters: {
421
+ runId: { type: 'string', description: '要裁决的 run(缺省 = 本会话现役 run)' },
422
+ verdict: { type: 'string', required: true, description: 'approve = 通过;reject = 驳回(重做)' },
423
+ reason: { type: 'string', required: true, description: '裁决意见(审计必填):驳回=重做口径,通过=放行依据' },
424
+ by: { type: 'string', description: '裁决来源:main-agent=代审(缺省);user=真人转呈后的用户裁决' },
425
+ },
426
+ output: {
427
+ schema: { type: 'object', additionalProperties: true },
428
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
429
+ },
430
+ async execute(args, exec) {
431
+ if (args.verdict !== 'approve' && args.verdict !== 'reject') {
432
+ return { ok: false, error: 'verdict 须为 approve|reject' }
433
+ }
434
+ if (typeof args.reason !== 'string' || args.reason.trim() === '') {
435
+ return { ok: false, error: 'reason 必填(审计口径与页签一致)' }
436
+ }
437
+ const by = args.by === 'user' ? 'user' : 'main-agent'
438
+ const runId = typeof args.runId === 'string' && args.runId !== '' ? args.runId : currentRunId(exec.agent)
439
+ if (runId === undefined) return { ok: false, error: '本会话无现役 run(编排未启动)' }
440
+ const driver = registry.drivers.get(runId)
441
+ if (driver === undefined) return { ok: false, error: '编排驱动器未注册(run 未终态但不在内存,或已终态)' }
442
+ // 会话归属守卫:显式 runId 可指向任意会话的 run,凭 runId 不可代裁
443
+ if (driver.sessionId !== undefined && driver.sessionId !== agentIdOf(exec.agent)) {
444
+ return { ok: false, error: '拒绝操作:该 run 属于其他会话' }
445
+ }
446
+ const accepted = driver.handlePost({ kind: args.verdict, by, reason: args.reason })
447
+ const record = reportStore().get(runId)
448
+ if (accepted) {
449
+ return { ok: true, runId, status: record?.status, hint: '裁决已受理;rs_workflow_resume 拉起下一段' }
450
+ }
451
+ // 竞态口径:页签先裁时后到方不受理,但 run 可能已翻 running 且无活跃段(等 resume 拉起),
452
+ // 主循环须继续承担推进责任,否则 run 挂死在 running 无段状态
453
+ if (!driver.active && record?.status === 'running') {
454
+ startSegmentJobSafe(exec.agent, driver)
455
+ return { ok: true, runId, status: record.status, hint: '裁决已被页签先裁(先到先得);已代为拉起下一段' }
456
+ }
457
+ return { ok: false, error: '裁决未受理(状态不符或已被页签先裁)' }
458
+ },
459
+ }),
460
+ ]
461
+ for (const tool of tools) tctx.effect(() => tctx.tools.register(tool), 'rs-workflow orchestrator: ' + tool.name)
462
+ // 行上下文(tool context)无 agent 属性,此处若直接 return undefined,register 的 disposer 不登记
463
+ // (cordis 静默接受 undefined):行销毁时七工具残留,重载后重复注册抛错致全量回滚(43267fc 实机定位)。
464
+ // 现行为返回注册 disposer,行销毁即注销;挂靠表(initiator/resumer)以 agentId 为键,行销毁后的
465
+ // stale 表项由下次 status/start 受理覆盖;run 终态经 finishRun 清 activeRuns。
466
+ })
467
+ registerTurnGuard(ctx, { currentRunId })
468
+ return { rejectCounts, activeRuns }
469
+ }
470
+
471
+ // 会话守门注册:轮次将停时判定欠动作并 steer 拉回(宿主机器重读 inbox,有 steering 即续跑一步)。
472
+ // 消息构造依赖宿主 llm 包,缺失即降级不守门(规约条款 1:编排能力不得被增强功能拖垮)。
473
+ function registerTurnGuard(ctx, { currentRunId }) {
474
+ // 事件面缺失的上下文(旧宿主 / 精简组合)不注册守门——编排本身照常可用
475
+ if (typeof ctx.on !== 'function') return
476
+ const nudgeGate = createNudgeGate()
477
+ let createUserMessage
478
+ ctx.on('agent/turn-stopping', async ({ agent }) => {
479
+ const runId = currentRunId(agent)
480
+ if (runId === undefined) { return }
481
+ let record
482
+ try { record = reportStore().get(runId) } catch { return }
483
+ const pending = pendingOf(record, registry.drivers.get(runId))
484
+ if (pending === undefined) {
485
+ // 无欠动作(段在飞/终态):清计数并回收,防跨 run 累积
486
+ nudgeGate.clear(runId)
487
+ return
488
+ }
489
+ if (createUserMessage === undefined) {
490
+ // 消息构造器来自宿主 llm 包:解析失败即降级不守门,但须留痕(否则守门静默消失无从诊断)
491
+ try { ({ createUserMessage } = await import('@deepseek-ai/dsh-llm')) } catch (e) {
492
+ ctx.logger?.warn?.(`[rsww-guard] 消息构造器不可用,守门降级:${e?.message ?? e}`)
493
+ return
494
+ }
495
+ }
496
+ if (!nudgeGate.take(runId)) {
497
+ // 提醒额度耗尽仍未处置:留痕供诊断,不再 steer(防烧 token 死循环)
498
+ if (nudgeGate.exhausted(runId)) ctx.logger?.warn?.(`[rsww-guard] ${runId} 提醒已达上限仍未处置(欠${pending.need}),停止守门`)
499
+ return
500
+ }
501
+ try {
502
+ agent.steer(createUserMessage({
503
+ content: [{ type: 'text', text: nudgeText(runId, pending) }],
504
+ source: { kind: 'plugin', plugin: 'rs-workflow' },
505
+ }))
506
+ } catch (e) {
507
+ // 轮次已中止 / agent 已停:守门无从施加,不影响编排本体
508
+ ctx.logger?.warn?.(`[rsww-guard] ${runId} 守门提醒投递失败:${e?.message ?? e}`)
509
+ }
510
+ })
511
+ }