@mzzsfy/dsh-rs-workflow 1.0.1 → 1.1.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/README.md +1 -1
- package/lib/board.mjs +79 -24
- package/lib/driver/approve.mjs +3 -1
- package/lib/driver/index.mjs +47 -15
- package/lib/driver/runner.mjs +11 -5
- package/lib/driver/scheduler.mjs +2 -1
- package/lib/guard.mjs +67 -0
- package/lib/orchestrator.mjs +513 -428
- package/lib/release-registry.mjs +134 -0
- package/lib/release.mjs +190 -183
- package/lib/skeleton.mjs +136 -0
- package/lib/storage.mjs +13 -4
- package/lib/store.mjs +28 -10
- package/lib/template-tool.mjs +172 -161
- package/lib/template.mjs +7 -6
- package/package.json +4 -2
- package/preset/rs-workflow/agent.cordis.yml +5 -64
- package/src/client.js +0 -1
package/README.md
CHANGED
|
@@ -25,4 +25,4 @@ node --test "test/*.test.mjs" # 包内测试(在 packages/dsh-rs-workflow
|
|
|
25
25
|
|
|
26
26
|
## dsh 版本兼容
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
三版本(0.1.2-rc.1 / 0.1.5-rc.3 / 0.1.7-rc.1)全部通过:preset 释放、Agent 预设页若水工作流卡(0.1.7-rc.1 预设页换形「模式」卡,属宿主演进)、rs_workflow_ 工具可列出、配置页六工作位、移除删除联动、激活 live。
|
package/lib/board.mjs
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
// board — /api/rsww/* 路由薄分发:数据权威态在 store 单例/自有文件存储(v5/{templates,config}.json)/driver 控制队列单例,路由无业务状态
|
|
2
2
|
// 运行时路由 v5 恢复:runs/run/control(approve|reject 增 by/reason)/resume-from(种子续跑,不拉段)/run-remove/release/unrelease/released
|
|
3
3
|
// 规划受理不经 HTTP:rs_workflow_start 是 orchestrator 工具行(见 feat/orchestrator.md)
|
|
4
|
+
// 安全边界(全仓库约定,AGENTS.md):不做 host 认证,无 Host 白名单/rebinding 防线/token 校验;
|
|
5
|
+
// 认证由宿主原生 cookie/startup-auth 承担,写路由跨源防护以 Origin 同源比对为上限
|
|
4
6
|
import { reportStore, ACTIVE_STATES } from './store.mjs'
|
|
5
7
|
import { registry, post, initiatorOf, resumerOf } from './driver/control.mjs'
|
|
6
8
|
import { validateTemplate, validateTemplateSet } from './template.mjs'
|
|
7
|
-
import {
|
|
9
|
+
import { releasedTemplateIds } from './release.mjs'
|
|
10
|
+
import {
|
|
11
|
+
currentAgentPresets,
|
|
12
|
+
disposeAllRegistered,
|
|
13
|
+
markReleased,
|
|
14
|
+
registryFormAvailable,
|
|
15
|
+
replayReleased,
|
|
16
|
+
setAgentPresets,
|
|
17
|
+
unreleaseTemplateVia,
|
|
18
|
+
releaseTemplateVia,
|
|
19
|
+
} from './release-registry.mjs'
|
|
8
20
|
import { SPEC_TEXT } from './spec.mjs'
|
|
9
21
|
import { normalizeConfig, BUDGET_KEYS, SLOT_KEYS } from './settings-schema.mjs'
|
|
10
22
|
import { loadJson, saveJson } from './storage.mjs'
|
|
@@ -67,21 +79,26 @@ guardedRoute.post = (handler) => async (req, res) => {
|
|
|
67
79
|
return guardedRoute(handler)(req, res)
|
|
68
80
|
}
|
|
69
81
|
|
|
70
|
-
function readJsonBody(req) {
|
|
82
|
+
function readJsonBody(req, res) {
|
|
71
83
|
return new Promise((resolve, reject) => {
|
|
72
84
|
let size = 0
|
|
73
85
|
const chunks = []
|
|
86
|
+
let over = false
|
|
74
87
|
req.on('data', (chunk) => {
|
|
75
88
|
size += chunk.length
|
|
76
89
|
if (size > BODY_MAX_BYTES) {
|
|
90
|
+
// 只暂停流入并标记超限,不销毁 socket:连接毁了 guardedRoute 的 400 就送不出去
|
|
91
|
+
over = true
|
|
92
|
+
req.pause()
|
|
77
93
|
reject(new Error('请求体超过上限'))
|
|
78
|
-
req.destroy()
|
|
79
94
|
return
|
|
80
95
|
}
|
|
81
|
-
chunks.push(chunk)
|
|
96
|
+
if (!over) chunks.push(chunk)
|
|
82
97
|
})
|
|
83
|
-
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
84
|
-
req.on('error', reject)
|
|
98
|
+
req.on('end', () => { if (!over) resolve(Buffer.concat(chunks).toString('utf8')) })
|
|
99
|
+
req.on('error', () => { if (!over) reject(new Error('请求体读取失败')) })
|
|
100
|
+
// 超限后 end 不再流到(已暂停):挂 res 收尾销毁,防 socket 悬挂
|
|
101
|
+
res.on('finish', () => req.destroy())
|
|
85
102
|
})
|
|
86
103
|
}
|
|
87
104
|
|
|
@@ -98,13 +115,25 @@ function writeTemplates(templates) {
|
|
|
98
115
|
saveJson('templates.json', templates)
|
|
99
116
|
}
|
|
100
117
|
|
|
101
|
-
|
|
118
|
+
// ── 释放分派(注册形态 rc.1+ / 目录形态 0.1.2-0.1.5,见 计划-rs-workflow-preset-rc1.md)──
|
|
119
|
+
// released 事实源随形态:注册形态 = templates.json released 标志(markReleased 落盘);
|
|
120
|
+
// 目录形态 = 目录本身。服务当值经 release-registry 模块单例,template-tool 行同源消费
|
|
121
|
+
const releaseDispatch = (entry) => releaseTemplateVia(currentAgentPresets(), entry)
|
|
122
|
+
|
|
123
|
+
const unreleaseDispatch = (id) => unreleaseTemplateVia(currentAgentPresets(), id)
|
|
124
|
+
|
|
125
|
+
function releasedIdsVia() {
|
|
126
|
+
if (!registryFormAvailable(currentAgentPresets())) return releasedTemplateIds()
|
|
127
|
+
return rawTemplates().filter((t) => t.released === true).map((t) => t.id)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function removeTemplateById(id) {
|
|
102
131
|
const raw = rawTemplates()
|
|
103
132
|
const next = raw.filter((t) => t.id !== id)
|
|
104
133
|
if (next.length === raw.length) return { ok: false, error: '模板不存在: ' + id }
|
|
105
134
|
writeTemplates(next)
|
|
106
|
-
//
|
|
107
|
-
const outcome =
|
|
135
|
+
// 撤下释放物:注册形态注销预设,目录形态移除释放目录(外来目录返回 foreign 不动)
|
|
136
|
+
const outcome = await unreleaseDispatch(id)
|
|
108
137
|
return { ok: true, outcome }
|
|
109
138
|
}
|
|
110
139
|
|
|
@@ -225,6 +254,19 @@ export function handleControl(body) {
|
|
|
225
254
|
|
|
226
255
|
export function registerBoardRoutes(ctx) {
|
|
227
256
|
const store = reportStore()
|
|
257
|
+
// 注册形态运行态:服务注入即设当值 + 重放已释放模板;行卸载清当值并注销全部
|
|
258
|
+
// 注册(热重载后随重放重建)。目录形态宿主(无 register)保持当值空缺
|
|
259
|
+
ctx.inject(['agentPresets'], (actx) => {
|
|
260
|
+
const service = actx.agentPresets
|
|
261
|
+
if (!registryFormAvailable(service)) return
|
|
262
|
+
setAgentPresets(service)
|
|
263
|
+
const released = rawTemplates().filter((t) => t.released === true && t.enabled !== false)
|
|
264
|
+
replayReleased(service, released).catch(() => {})
|
|
265
|
+
actx.effect(() => async () => {
|
|
266
|
+
setAgentPresets(undefined)
|
|
267
|
+
await disposeAllRegistered()
|
|
268
|
+
}, 'rs-workflow preset registrations')
|
|
269
|
+
})
|
|
228
270
|
ctx.inject(['webServer'], (wctx) => {
|
|
229
271
|
const route = (path, handler, name) => wctx.effect(() => wctx.webServer.register({ kind: 'exact', path, handler }), name)
|
|
230
272
|
route('/api/rsww/runs', guardedRoute(async (req, res) => {
|
|
@@ -238,11 +280,11 @@ export function registerBoardRoutes(ctx) {
|
|
|
238
280
|
sendJson(res, 200, run)
|
|
239
281
|
}), 'rsww run detail route')
|
|
240
282
|
route('/api/rsww/control', guardedRoute.post(async (req, res) => {
|
|
241
|
-
const body = JSON.parse(await readJsonBody(req))
|
|
283
|
+
const body = JSON.parse(await readJsonBody(req, res))
|
|
242
284
|
sendJson(res, 200, handleControl(body ?? {}))
|
|
243
285
|
}), 'rsww control route')
|
|
244
286
|
route('/api/rsww/resume-from', guardedRoute.post(async (req, res) => {
|
|
245
|
-
const body = JSON.parse(await readJsonBody(req))
|
|
287
|
+
const body = JSON.parse(await readJsonBody(req, res))
|
|
246
288
|
const runId = typeof body.runId === 'string' ? body.runId : ''
|
|
247
289
|
const record = store.get(runId)
|
|
248
290
|
if (!record) throw new Error('运行记录不存在:' + runId)
|
|
@@ -255,14 +297,21 @@ export function registerBoardRoutes(ctx) {
|
|
|
255
297
|
if (fromStepId !== undefined && record.plan?.steps?.some((p) => p.ref === fromStepId) !== true) {
|
|
256
298
|
throw new Error('fromStepId 不在剧本中:' + fromStepId)
|
|
257
299
|
}
|
|
258
|
-
|
|
300
|
+
// inputs 须为字符串值对象:数组/嵌套值在种子展开中失真
|
|
301
|
+
let inputs
|
|
302
|
+
if (body.inputs !== undefined && body.inputs !== null) {
|
|
303
|
+
if (typeof body.inputs !== 'object' || Array.isArray(body.inputs) || Object.values(body.inputs).some((v) => typeof v !== 'string')) {
|
|
304
|
+
throw new Error('inputs 必须为字符串值对象(键→字符串)')
|
|
305
|
+
}
|
|
306
|
+
inputs = body.inputs
|
|
307
|
+
}
|
|
259
308
|
const outcome = start(record, fromStepId, inputs)
|
|
260
309
|
if (!outcome.ok) throw new Error(outcome.error)
|
|
261
310
|
// 纠偏消息不跨 run:旧 run 受理未消费的纠偏不带入种子(controls 属旧 run 审计)
|
|
262
311
|
sendJson(res, 200, { ok: true, runId: outcome.runId, hint: '旧 run 未消费的纠偏消息不带入新 run' })
|
|
263
312
|
}), 'rsww resume-from route')
|
|
264
313
|
route('/api/rsww/run-remove', guardedRoute.post(async (req, res) => {
|
|
265
|
-
const body = JSON.parse(await readJsonBody(req))
|
|
314
|
+
const body = JSON.parse(await readJsonBody(req, res))
|
|
266
315
|
const runId = typeof body.runId === 'string' ? body.runId : ''
|
|
267
316
|
const record = store.has(runId) ? store.get(runId) : undefined
|
|
268
317
|
if (record && isRunActive(runId, record)) throw new Error('运行进行中,不可删除')
|
|
@@ -286,19 +335,20 @@ export function registerBoardRoutes(ctx) {
|
|
|
286
335
|
sendJson(res, 200, { entry, parsed })
|
|
287
336
|
}), 'rsww template detail route')
|
|
288
337
|
route('/api/rsww/released', guardedRoute(async (req, res) => {
|
|
289
|
-
sendJson(res, 200, { ids:
|
|
338
|
+
sendJson(res, 200, { ids: releasedIdsVia() })
|
|
290
339
|
}), 'rsww released route')
|
|
291
340
|
route('/api/rsww/spec', guardedRoute(async (req, res) => {
|
|
292
341
|
sendJson(res, 200, { spec: SPEC_TEXT })
|
|
293
342
|
}), 'rsww spec route')
|
|
294
343
|
route('/api/rsww/template-save', guardedRoute.post(async (req, res) => {
|
|
295
|
-
const body = JSON.parse(await readJsonBody(req))
|
|
344
|
+
const body = JSON.parse(await readJsonBody(req, res))
|
|
296
345
|
const id = typeof body.id === 'string' ? body.id.trim() : ''
|
|
297
346
|
const { parsed, json } = parseTemplateEntry(id, body)
|
|
298
347
|
if (body.dryRun === true) {
|
|
299
348
|
sendJson(res, 200, { ok: true, dryRun: true })
|
|
300
349
|
return
|
|
301
350
|
}
|
|
351
|
+
const previous = rawTemplates().find((t) => t.id === id)
|
|
302
352
|
const templates = rawTemplates().filter((t) => t.id !== id)
|
|
303
353
|
templates.push({
|
|
304
354
|
id,
|
|
@@ -306,19 +356,21 @@ export function registerBoardRoutes(ctx) {
|
|
|
306
356
|
description: typeof body.description === 'string' && body.description.trim() !== '' ? body.description.trim() : parsed.description || '',
|
|
307
357
|
enabled: body.enabled !== false,
|
|
308
358
|
json,
|
|
359
|
+
// 已释放模板的编辑性重存不改变释放态(注销走 unrelease 显式动作)
|
|
360
|
+
...(previous?.released === true ? { released: true } : {}),
|
|
309
361
|
})
|
|
310
362
|
writeTemplates(templates)
|
|
311
363
|
sendJson(res, 200, { ok: true })
|
|
312
364
|
}), 'rsww template-save route')
|
|
313
365
|
route('/api/rsww/template-remove', guardedRoute.post(async (req, res) => {
|
|
314
|
-
const body = JSON.parse(await readJsonBody(req))
|
|
366
|
+
const body = JSON.parse(await readJsonBody(req, res))
|
|
315
367
|
const id = typeof body.id === 'string' ? body.id.trim() : ''
|
|
316
|
-
const outcome = removeTemplateById(id)
|
|
368
|
+
const outcome = await removeTemplateById(id)
|
|
317
369
|
if (!outcome.ok) throw new Error(outcome.error)
|
|
318
370
|
sendJson(res, 200, { ok: true })
|
|
319
371
|
}), 'rsww template-remove route')
|
|
320
372
|
route('/api/rsww/release', guardedRoute.post(async (req, res) => {
|
|
321
|
-
const body = JSON.parse(await readJsonBody(req))
|
|
373
|
+
const body = JSON.parse(await readJsonBody(req, res))
|
|
322
374
|
const id = typeof body.id === 'string' ? body.id.trim() : ''
|
|
323
375
|
const entries = readTemplates()
|
|
324
376
|
const entry = entries.find((t) => t.id === id)
|
|
@@ -330,20 +382,23 @@ export function registerBoardRoutes(ctx) {
|
|
|
330
382
|
}).filter((t) => t !== null)
|
|
331
383
|
const setErrors = validateTemplateSet(parsedSet).filter((e) => e.target === 'top:id' && e.message.startsWith(id + ' '))
|
|
332
384
|
if (setErrors.length > 0) throw new Error('模板集合校验失败:' + setErrors.map((e) => e.message).join(';'))
|
|
333
|
-
const
|
|
334
|
-
|
|
385
|
+
const result = await releaseDispatch(entry)
|
|
386
|
+
if (result.outcome === 'failed') throw new Error(result.broken ?? '释放失败')
|
|
387
|
+
markReleased(id, true)
|
|
388
|
+
sendJson(res, 200, { ok: true, outcome: result.outcome, ...(result.broken === undefined ? {} : { broken: result.broken }) })
|
|
335
389
|
}), 'rsww release route')
|
|
336
390
|
route('/api/rsww/unrelease', guardedRoute.post(async (req, res) => {
|
|
337
|
-
const body = JSON.parse(await readJsonBody(req))
|
|
391
|
+
const body = JSON.parse(await readJsonBody(req, res))
|
|
338
392
|
const id = typeof body.id === 'string' ? body.id.trim() : ''
|
|
339
|
-
const
|
|
340
|
-
|
|
393
|
+
const result = await unreleaseDispatch(id)
|
|
394
|
+
markReleased(id, false)
|
|
395
|
+
sendJson(res, 200, { ok: true, outcome: result.outcome })
|
|
341
396
|
}), 'rsww unrelease route')
|
|
342
397
|
route('/api/rsww/config', guardedRoute(async (req, res) => {
|
|
343
398
|
sendJson(res, 200, { config: normalizeConfig(loadJson('config.json', undefined)) })
|
|
344
399
|
}), 'rsww config route')
|
|
345
400
|
route('/api/rsww/config-save', guardedRoute.post(async (req, res) => {
|
|
346
|
-
const body = JSON.parse(await readJsonBody(req))
|
|
401
|
+
const body = JSON.parse(await readJsonBody(req, res))
|
|
347
402
|
const patch = configSavePatch(body ?? {})
|
|
348
403
|
const current = normalizeConfig(loadJson('config.json', undefined))
|
|
349
404
|
// config.json 权威节集 = slots/budgets(data-design);templates 属 templates.json 独立存储
|
package/lib/driver/approve.mjs
CHANGED
|
@@ -106,7 +106,7 @@ export function applyExternalVerdict(state, script, approveStep, { verdict, comm
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
// 段 settle 负载(waiting):待裁决摘要 + 口径上下文(feat/approve-bridge.md 字段集)
|
|
109
|
-
export function waitingPayload({ runId, state, script, planStepOf, approveStep }) {
|
|
109
|
+
export function waitingPayload({ runId, state, script, planStepOf, approveStep, autoApprove = false }) {
|
|
110
110
|
const target = state.steps[approveStep.target]
|
|
111
111
|
const planStep = planStepOf.get(approveStep.id)
|
|
112
112
|
const brief = planStep?.done || planStep?.note || approveStep.prompt
|
|
@@ -121,6 +121,8 @@ export function waitingPayload({ runId, state, script, planStepOf, approveStep }
|
|
|
121
121
|
brief,
|
|
122
122
|
comments: ledger?.lastComments ?? '',
|
|
123
123
|
rounds: ledger?.rounds ?? 0,
|
|
124
|
+
// 模板代审口径:主循环 persona 据此决定代审(by 缺省)或转呈真人(by=user)
|
|
125
|
+
autoApprove,
|
|
124
126
|
},
|
|
125
127
|
}
|
|
126
128
|
}
|
package/lib/driver/index.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// RunDriver 聚合(v5):剧本驱动;runSegment 分段推进(审批到达/暂停/终态即返回 settle 负载);
|
|
2
2
|
// 外部裁决回写(waiting_approval 即时应用,paused 入队 resume 生效);取消优先;推进责任在主循环 resume
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { isAbsolute, join, resolve, sep } from 'node:path'
|
|
3
5
|
import { reportStore } from '../store.mjs'
|
|
4
6
|
import { templateDeps } from '../planner-gate.mjs'
|
|
5
7
|
import { nextBatch, scriptViewOf } from './scheduler.mjs'
|
|
@@ -88,11 +90,13 @@ export function buildSeed(record, plan, fromStepId, inputs) {
|
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
state.pendingApprovals = []
|
|
91
|
-
// 终态污染标志清零:续跑后旧账不得再次收口(blocked)
|
|
93
|
+
// 终态污染标志清零:续跑后旧账不得再次收口(blocked)或无耗尽即派发升级步;
|
|
94
|
+
// escalateReady 是步级标记(scheduler 消费),须逐步清零
|
|
92
95
|
state.terminalBlocked = false
|
|
93
|
-
state.escalateReady = []
|
|
94
96
|
state.escalateLimitReached = false
|
|
95
|
-
state.
|
|
97
|
+
for (const s of Object.values(state.steps)) s.escalateReady = false
|
|
98
|
+
// 控制序号对齐新记录:续跑生成全新 store 记录(controls 从空起),沿用旧计数会吞掉新记录前缀消息
|
|
99
|
+
state.controlSeq = 0
|
|
96
100
|
return state
|
|
97
101
|
}
|
|
98
102
|
|
|
@@ -123,27 +127,46 @@ export class RunDriver {
|
|
|
123
127
|
this.subordinate = subordinate
|
|
124
128
|
// 编排子代理的挂载父 agent(与 workflow 工具链对齐;undefined 时引擎派发行为未定义)
|
|
125
129
|
this.parent = parent
|
|
130
|
+
// spec §8 doc:<相对路径>:读会话工作区文件,缺失不阻断(空串);resolve 限定在 workspace 内防路径逃逸
|
|
131
|
+
const base = resolve(workspace || process.cwd())
|
|
132
|
+
this.readDoc = (rel) => {
|
|
133
|
+
const target = resolve(base, rel)
|
|
134
|
+
if (!target.startsWith(base + sep) && target !== base) return ''
|
|
135
|
+
try { return readFileSync(target, 'utf8') } catch { return '' }
|
|
136
|
+
}
|
|
126
137
|
}
|
|
127
138
|
|
|
128
139
|
// 段推进唯一入口:跑到下一个段边界(审批到达/暂停/终态)返回 settle 负载;orchestrator 包装为 continuable job
|
|
129
140
|
async runSegment() {
|
|
141
|
+
// 重入防护:在飞段由 settlePromise 承载(取消方 await 它收敛,而非重入第二个 loop)
|
|
142
|
+
if (this.active) return this.settlePromise ?? this.terminalPayload()
|
|
130
143
|
if (this.finished || TERMINAL_STATES.has(this.state.status)) {
|
|
131
144
|
return this.terminalPayload()
|
|
132
145
|
}
|
|
133
146
|
this.active = true
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
this.
|
|
147
|
+
this.settlePromise = (async () => {
|
|
148
|
+
try {
|
|
149
|
+
this.drainPendingApprovals()
|
|
150
|
+
return await this.loop()
|
|
151
|
+
} catch (e) {
|
|
152
|
+
if (this.signal.aborted) {
|
|
153
|
+
this.finish('cancelled', CANCELLED_SUMMARY)
|
|
154
|
+
return this.terminalPayload()
|
|
155
|
+
}
|
|
156
|
+
this.finish('failed', `驱动器异常:${e?.message ?? e}`)
|
|
140
157
|
return this.terminalPayload()
|
|
158
|
+
} finally {
|
|
159
|
+
this.active = false
|
|
160
|
+
this.settlePromise = undefined
|
|
141
161
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
162
|
+
})()
|
|
163
|
+
return this.settlePromise
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 取消并等待在飞段收敛(无在飞段时即时收敛) */
|
|
167
|
+
async cancelAndSettle() {
|
|
168
|
+
this.cancel()
|
|
169
|
+
if (this.settlePromise !== undefined) await this.settlePromise.catch(() => {})
|
|
147
170
|
}
|
|
148
171
|
|
|
149
172
|
startPersist() {
|
|
@@ -159,6 +182,8 @@ export class RunDriver {
|
|
|
159
182
|
if (this.finished || TERMINAL_STATES.has(this.state.status)) return
|
|
160
183
|
// 统一转 paused(与 waiting_approval 互斥,paused 优先);活跃段在飞批次收敛后于段顶 settle
|
|
161
184
|
this.state.status = 'paused'
|
|
185
|
+
// 暂停即撤销先前的恢复意图,否则陈旧标记会让守门持续催 resume
|
|
186
|
+
this.awaitingResume = false
|
|
162
187
|
this.persistState()
|
|
163
188
|
}
|
|
164
189
|
|
|
@@ -243,6 +268,8 @@ export class RunDriver {
|
|
|
243
268
|
this.state.redoInfo = { [route.redoTarget]: { comments: route.comments, prevOutputs: route.prevOutputs } }
|
|
244
269
|
}
|
|
245
270
|
}
|
|
271
|
+
// 与 waiting 直裁路径同款收口:残留 waitingApproval 会让后续 pause 期裁决对非等待步幽灵生效
|
|
272
|
+
this.state.waitingApproval = undefined
|
|
246
273
|
this.store.update({ runId: this.runId, waiting: null })
|
|
247
274
|
}
|
|
248
275
|
|
|
@@ -266,6 +293,9 @@ export class RunDriver {
|
|
|
266
293
|
}
|
|
267
294
|
|
|
268
295
|
persistState() {
|
|
296
|
+
// 嵌套子流程与父共享同 runId 记录:子状态落盘会覆盖父的全局 state,崩溃后续跑种子即失真;
|
|
297
|
+
// 子流程中间态不落盘,flow 步结束后由父 persistState 兜底
|
|
298
|
+
if (this.subordinate) return
|
|
269
299
|
this.store.update({ runId: this.runId, state: this.state, status: this.state.status })
|
|
270
300
|
}
|
|
271
301
|
|
|
@@ -343,8 +373,10 @@ export class RunDriver {
|
|
|
343
373
|
const step = batch.step
|
|
344
374
|
this.state.status = 'waiting_approval'
|
|
345
375
|
this.state.waitingApproval = step.id
|
|
376
|
+
// 模板代审口径落 state:进程重启后 status 工具仍可透出
|
|
377
|
+
this.state.waitingAutoApprove = this.template?.autoApprove === true
|
|
346
378
|
this.persistState()
|
|
347
|
-
const payload = waitingPayload({ runId: this.runId, state: this.state, script: this.script, planStepOf: this.planStepOf, approveStep: step })
|
|
379
|
+
const payload = waitingPayload({ runId: this.runId, state: this.state, script: this.script, planStepOf: this.planStepOf, approveStep: step, autoApprove: this.template?.autoApprove === true })
|
|
348
380
|
// 待裁决摘要落盘:页签 /run 路由据此渲染审批上下文
|
|
349
381
|
this.store.update({ runId: this.runId, waiting: payload.waiting })
|
|
350
382
|
return payload
|
package/lib/driver/runner.mjs
CHANGED
|
@@ -48,7 +48,7 @@ function rotateCursor(state, slotKey) {
|
|
|
48
48
|
state.slotCursor[slotKey] = (state.slotCursor[slotKey] ?? 0) + 1
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
// 单批次:组 Batch → engine.start → 收敛逐 call 记账。返回 {
|
|
51
|
+
// 单批次:组 Batch → engine.start → 收敛逐 call 记账。返回 {settled, cancelled}
|
|
52
52
|
export async function runBatch({ state, script, template, batch, ctx }) {
|
|
53
53
|
const { store, runId, engine, parent, signal } = ctx
|
|
54
54
|
const calls = []
|
|
@@ -87,8 +87,9 @@ export async function runBatch({ state, script, template, batch, ctx }) {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
let outcome
|
|
90
|
+
let run
|
|
90
91
|
try {
|
|
91
|
-
|
|
92
|
+
run = engine.start({
|
|
92
93
|
script: FLOW_EXEC_SOURCE,
|
|
93
94
|
args: { calls },
|
|
94
95
|
// 宿主 meta 契约:description 必填非空;phases 必须为 {title} 对象数组(dsh-workflow-worker-thread meta 校验)
|
|
@@ -104,15 +105,20 @@ export async function runBatch({ state, script, template, batch, ctx }) {
|
|
|
104
105
|
} catch (e) {
|
|
105
106
|
if (signal?.aborted) return { cancelled: true }
|
|
106
107
|
outcome = { results: calls.map((c) => ({ callId: c.callId, ok: false, error: String(e?.message ?? e) })) }
|
|
108
|
+
} finally {
|
|
109
|
+
// 宿主契约:caller must dispose every run(否则 worker 线程泄漏);engine.start 同步抛时 run 为空
|
|
110
|
+
await run?.dispose?.().catch?.(() => {})
|
|
107
111
|
}
|
|
108
112
|
if (signal?.aborted) return { cancelled: true }
|
|
109
113
|
// 引擎 run.result 契约:{ value(脚本返回值), stopReason, error?, agentsStarted }
|
|
110
|
-
// stopReason 非 completed(如
|
|
114
|
+
// stopReason 非 completed(如 error)时 value 不可信,全部调用统一按失败记账
|
|
115
|
+
let results
|
|
111
116
|
if (outcome?.stopReason !== undefined && outcome?.stopReason !== 'completed') {
|
|
112
117
|
const reason = String(outcome?.error ?? outcome?.stopReason)
|
|
113
|
-
|
|
118
|
+
results = calls.map((c) => ({ callId: c.callId, ok: false, error: reason }))
|
|
119
|
+
} else {
|
|
120
|
+
results = outcome?.value?.results ?? outcome?.results ?? []
|
|
114
121
|
}
|
|
115
|
-
const results = outcome?.value?.results ?? outcome?.results ?? []
|
|
116
122
|
const settled = []
|
|
117
123
|
for (const result of results) {
|
|
118
124
|
const meta = callMeta.get(result.callId)
|
package/lib/driver/scheduler.mjs
CHANGED
|
@@ -45,7 +45,8 @@ function expandForEach(state, step) {
|
|
|
45
45
|
const list = sourceList(state, step)
|
|
46
46
|
if (list === null) return
|
|
47
47
|
s.instances = list.map((item, index) => ({
|
|
48
|
-
|
|
48
|
+
// index 1 基(与 key #N 及 spec {item.index} 序号口径一致)
|
|
49
|
+
key: `#${index + 1}`, index: index + 1, item, status: 'pending', outputs: null, failCount: 0, carry: null,
|
|
49
50
|
}))
|
|
50
51
|
}
|
|
51
52
|
|
package/lib/guard.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// 会话守门:轮次将停时判定本会话编排是否欠动作(persona 判据 3 的机器化执行)
|
|
2
|
+
// 判据来源:running 且无活跃段 job → 欠 resume;paused 且 awaitingResume → 欠 resume;
|
|
3
|
+
// waiting_approval → 欠裁决。终态与无现役 run 不拦。
|
|
4
|
+
// 拦截手段由调用方承担(agent.steer 投喂提醒消息,机器重读 inbox 后继续跑)。
|
|
5
|
+
|
|
6
|
+
// 单个欠动作周期的提醒上限:弱模型可能忽略首次提醒,但无限提醒会烧 token 死循环。
|
|
7
|
+
// 计数在轮次不欠动作时清零,故多步编排的每个欠动作周期各自享有上限,而非全 run 共享。
|
|
8
|
+
export const MAX_NUDGE = 2
|
|
9
|
+
|
|
10
|
+
const ACTION_OF_STATUS = {
|
|
11
|
+
waiting_approval: { need: '裁决', text: '等待裁决' },
|
|
12
|
+
paused: { need: 'resume', text: '已暂停' },
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 判据:record 状态 + driver 活跃度 → 是否欠动作;无现役 run / 终态 / 段在飞均返回 undefined */
|
|
16
|
+
export function pendingOf(record, driver) {
|
|
17
|
+
if (record === undefined) return undefined
|
|
18
|
+
if (record.status === 'running') {
|
|
19
|
+
// 段在飞即正常等待:段 job settle 会经完成通知唤醒主循环,此时轮次停是合理行为
|
|
20
|
+
return driver?.active === true ? undefined : { need: 'resume', text: '运行中待拉起' }
|
|
21
|
+
}
|
|
22
|
+
const known = ACTION_OF_STATUS[record.status]
|
|
23
|
+
if (known === undefined) return undefined
|
|
24
|
+
if (record.status === 'paused' && driver?.awaitingResume !== true) {
|
|
25
|
+
// paused 且页签未发恢复:裁决已入队的 resume 拉段仍欠——段首生效前欠动作不变
|
|
26
|
+
if ((record.state?.pendingApprovals?.length ?? 0) > 0) return { need: 'resume', text: '已暂停(待生效裁决)' }
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
29
|
+
return known
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 提醒计数闸:按欠动作周期独立计数,超上限返回 false(调用方改记告警,不再 steer) */
|
|
33
|
+
export function createNudgeGate(maxNudge = MAX_NUDGE) {
|
|
34
|
+
const counts = new Map()
|
|
35
|
+
const warned = new Set()
|
|
36
|
+
return {
|
|
37
|
+
take(runId) {
|
|
38
|
+
const used = counts.get(runId) ?? 0
|
|
39
|
+
if (used >= maxNudge) return false
|
|
40
|
+
counts.set(runId, used + 1)
|
|
41
|
+
return true
|
|
42
|
+
},
|
|
43
|
+
clear(runId) {
|
|
44
|
+
counts.delete(runId)
|
|
45
|
+
warned.delete(runId)
|
|
46
|
+
},
|
|
47
|
+
used(runId) {
|
|
48
|
+
return counts.get(runId) ?? 0
|
|
49
|
+
},
|
|
50
|
+
/** 首次触顶返回 true,此后false(告警只一次;clear 重置) */
|
|
51
|
+
exhausted(runId) {
|
|
52
|
+
if ((counts.get(runId) ?? 0) < maxNudge) return false
|
|
53
|
+
if (warned.has(runId)) return false
|
|
54
|
+
warned.add(runId)
|
|
55
|
+
return true
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 提醒文案:给出 runId / 状态 / 欠动作,并指向应调用的工具 */
|
|
61
|
+
export function nudgeText(runId, pending) {
|
|
62
|
+
// waiting_approval 有合法终止路径(转呈真人后等输入),文案带豁免以免误伤已处置的轮次
|
|
63
|
+
const dispense = pending.need === '裁决'
|
|
64
|
+
? '裁决按模板 autoApprove 决定代审或转呈真人;若已转呈真人并等待其输入,则本轮无需再动作,可直接结束轮次。'
|
|
65
|
+
: '欠 resume 调 rs_workflow_resume 补拉。'
|
|
66
|
+
return `[若水守门] 本会话编排 ${runId} 未终态(状态=${pending.text},欠动作=${pending.need})。请立即调用 rs_workflow_status 查看并按流程纪律处置:${dispense}处置完成后才可结束轮次。`
|
|
67
|
+
}
|