@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.
- package/engine/flow-exec.js +23 -0
- package/lib/board.mjs +28 -14
- 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 +511 -428
- package/lib/storage.mjs +13 -4
- package/lib/store.mjs +28 -10
- package/lib/template.mjs +7 -6
- package/package.json +6 -2
- package/preset/rs-workflow/agent.cordis.yml +181 -0
- package/preset/rs-workflow/preset.yml +5 -0
- package/src/client.js +0 -1
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// 单批次执行器脚本:宿主 workflowEngine 裸 vm body,globals 仅 agent/parallel/pipeline/phase/log/args,无模块语法
|
|
2
|
+
const BATCH_PHASE = '若水批次'
|
|
3
|
+
const NULL_OUTPUT_ERROR = '子代理未提交结构化产出'
|
|
4
|
+
|
|
5
|
+
const errorText = (error) => String((error && error.message) || error)
|
|
6
|
+
const settle = (callId, outputs) => outputs === null
|
|
7
|
+
? { callId, ok: false, error: NULL_OUTPUT_ERROR }
|
|
8
|
+
: { callId, ok: true, outputs }
|
|
9
|
+
|
|
10
|
+
const { calls } = args
|
|
11
|
+
// v5 引擎 parallel 契约:零参 thunk 数组(每个 thunk 失败→null),不再是 promise 数组;
|
|
12
|
+
// agent() options 键存在则值必须是 JSON(undefined 不行),缺省键须整键省略
|
|
13
|
+
const dispatches = calls.map((call) => () => agent(call.prompt, {
|
|
14
|
+
label: call.label,
|
|
15
|
+
phase: BATCH_PHASE,
|
|
16
|
+
...call.schema !== undefined ? { schema: call.schema } : {},
|
|
17
|
+
...call.provider !== undefined ? { provider: call.provider } : {},
|
|
18
|
+
...call.model !== undefined ? { model: call.model } : {},
|
|
19
|
+
}).then((outputs) => settle(call.callId, outputs))
|
|
20
|
+
.catch((error) => ({ callId: call.callId, ok: false, error: errorText(error) })))
|
|
21
|
+
|
|
22
|
+
const results = await parallel(dispatches)
|
|
23
|
+
return { results }
|
package/lib/board.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
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'
|
|
@@ -67,21 +69,26 @@ guardedRoute.post = (handler) => async (req, res) => {
|
|
|
67
69
|
return guardedRoute(handler)(req, res)
|
|
68
70
|
}
|
|
69
71
|
|
|
70
|
-
function readJsonBody(req) {
|
|
72
|
+
function readJsonBody(req, res) {
|
|
71
73
|
return new Promise((resolve, reject) => {
|
|
72
74
|
let size = 0
|
|
73
75
|
const chunks = []
|
|
76
|
+
let over = false
|
|
74
77
|
req.on('data', (chunk) => {
|
|
75
78
|
size += chunk.length
|
|
76
79
|
if (size > BODY_MAX_BYTES) {
|
|
80
|
+
// 只暂停流入并标记超限,不销毁 socket:连接毁了 guardedRoute 的 400 就送不出去
|
|
81
|
+
over = true
|
|
82
|
+
req.pause()
|
|
77
83
|
reject(new Error('请求体超过上限'))
|
|
78
|
-
req.destroy()
|
|
79
84
|
return
|
|
80
85
|
}
|
|
81
|
-
chunks.push(chunk)
|
|
86
|
+
if (!over) chunks.push(chunk)
|
|
82
87
|
})
|
|
83
|
-
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
84
|
-
req.on('error', reject)
|
|
88
|
+
req.on('end', () => { if (!over) resolve(Buffer.concat(chunks).toString('utf8')) })
|
|
89
|
+
req.on('error', () => { if (!over) reject(new Error('请求体读取失败')) })
|
|
90
|
+
// 超限后 end 不再流到(已暂停):挂 res 收尾销毁,防 socket 悬挂
|
|
91
|
+
res.on('finish', () => req.destroy())
|
|
85
92
|
})
|
|
86
93
|
}
|
|
87
94
|
|
|
@@ -238,11 +245,11 @@ export function registerBoardRoutes(ctx) {
|
|
|
238
245
|
sendJson(res, 200, run)
|
|
239
246
|
}), 'rsww run detail route')
|
|
240
247
|
route('/api/rsww/control', guardedRoute.post(async (req, res) => {
|
|
241
|
-
const body = JSON.parse(await readJsonBody(req))
|
|
248
|
+
const body = JSON.parse(await readJsonBody(req, res))
|
|
242
249
|
sendJson(res, 200, handleControl(body ?? {}))
|
|
243
250
|
}), 'rsww control route')
|
|
244
251
|
route('/api/rsww/resume-from', guardedRoute.post(async (req, res) => {
|
|
245
|
-
const body = JSON.parse(await readJsonBody(req))
|
|
252
|
+
const body = JSON.parse(await readJsonBody(req, res))
|
|
246
253
|
const runId = typeof body.runId === 'string' ? body.runId : ''
|
|
247
254
|
const record = store.get(runId)
|
|
248
255
|
if (!record) throw new Error('运行记录不存在:' + runId)
|
|
@@ -255,14 +262,21 @@ export function registerBoardRoutes(ctx) {
|
|
|
255
262
|
if (fromStepId !== undefined && record.plan?.steps?.some((p) => p.ref === fromStepId) !== true) {
|
|
256
263
|
throw new Error('fromStepId 不在剧本中:' + fromStepId)
|
|
257
264
|
}
|
|
258
|
-
|
|
265
|
+
// inputs 须为字符串值对象:数组/嵌套值在种子展开中失真
|
|
266
|
+
let inputs
|
|
267
|
+
if (body.inputs !== undefined && body.inputs !== null) {
|
|
268
|
+
if (typeof body.inputs !== 'object' || Array.isArray(body.inputs) || Object.values(body.inputs).some((v) => typeof v !== 'string')) {
|
|
269
|
+
throw new Error('inputs 必须为字符串值对象(键→字符串)')
|
|
270
|
+
}
|
|
271
|
+
inputs = body.inputs
|
|
272
|
+
}
|
|
259
273
|
const outcome = start(record, fromStepId, inputs)
|
|
260
274
|
if (!outcome.ok) throw new Error(outcome.error)
|
|
261
275
|
// 纠偏消息不跨 run:旧 run 受理未消费的纠偏不带入种子(controls 属旧 run 审计)
|
|
262
276
|
sendJson(res, 200, { ok: true, runId: outcome.runId, hint: '旧 run 未消费的纠偏消息不带入新 run' })
|
|
263
277
|
}), 'rsww resume-from route')
|
|
264
278
|
route('/api/rsww/run-remove', guardedRoute.post(async (req, res) => {
|
|
265
|
-
const body = JSON.parse(await readJsonBody(req))
|
|
279
|
+
const body = JSON.parse(await readJsonBody(req, res))
|
|
266
280
|
const runId = typeof body.runId === 'string' ? body.runId : ''
|
|
267
281
|
const record = store.has(runId) ? store.get(runId) : undefined
|
|
268
282
|
if (record && isRunActive(runId, record)) throw new Error('运行进行中,不可删除')
|
|
@@ -292,7 +306,7 @@ export function registerBoardRoutes(ctx) {
|
|
|
292
306
|
sendJson(res, 200, { spec: SPEC_TEXT })
|
|
293
307
|
}), 'rsww spec route')
|
|
294
308
|
route('/api/rsww/template-save', guardedRoute.post(async (req, res) => {
|
|
295
|
-
const body = JSON.parse(await readJsonBody(req))
|
|
309
|
+
const body = JSON.parse(await readJsonBody(req, res))
|
|
296
310
|
const id = typeof body.id === 'string' ? body.id.trim() : ''
|
|
297
311
|
const { parsed, json } = parseTemplateEntry(id, body)
|
|
298
312
|
if (body.dryRun === true) {
|
|
@@ -311,14 +325,14 @@ export function registerBoardRoutes(ctx) {
|
|
|
311
325
|
sendJson(res, 200, { ok: true })
|
|
312
326
|
}), 'rsww template-save route')
|
|
313
327
|
route('/api/rsww/template-remove', guardedRoute.post(async (req, res) => {
|
|
314
|
-
const body = JSON.parse(await readJsonBody(req))
|
|
328
|
+
const body = JSON.parse(await readJsonBody(req, res))
|
|
315
329
|
const id = typeof body.id === 'string' ? body.id.trim() : ''
|
|
316
330
|
const outcome = removeTemplateById(id)
|
|
317
331
|
if (!outcome.ok) throw new Error(outcome.error)
|
|
318
332
|
sendJson(res, 200, { ok: true })
|
|
319
333
|
}), 'rsww template-remove route')
|
|
320
334
|
route('/api/rsww/release', guardedRoute.post(async (req, res) => {
|
|
321
|
-
const body = JSON.parse(await readJsonBody(req))
|
|
335
|
+
const body = JSON.parse(await readJsonBody(req, res))
|
|
322
336
|
const id = typeof body.id === 'string' ? body.id.trim() : ''
|
|
323
337
|
const entries = readTemplates()
|
|
324
338
|
const entry = entries.find((t) => t.id === id)
|
|
@@ -334,7 +348,7 @@ export function registerBoardRoutes(ctx) {
|
|
|
334
348
|
sendJson(res, 200, { ok: true, outcome })
|
|
335
349
|
}), 'rsww release route')
|
|
336
350
|
route('/api/rsww/unrelease', guardedRoute.post(async (req, res) => {
|
|
337
|
-
const body = JSON.parse(await readJsonBody(req))
|
|
351
|
+
const body = JSON.parse(await readJsonBody(req, res))
|
|
338
352
|
const id = typeof body.id === 'string' ? body.id.trim() : ''
|
|
339
353
|
const outcome = unreleaseFlowTemplate(id)
|
|
340
354
|
sendJson(res, 200, { ok: true, outcome })
|
|
@@ -343,7 +357,7 @@ export function registerBoardRoutes(ctx) {
|
|
|
343
357
|
sendJson(res, 200, { config: normalizeConfig(loadJson('config.json', undefined)) })
|
|
344
358
|
}), 'rsww config route')
|
|
345
359
|
route('/api/rsww/config-save', guardedRoute.post(async (req, res) => {
|
|
346
|
-
const body = JSON.parse(await readJsonBody(req))
|
|
360
|
+
const body = JSON.parse(await readJsonBody(req, res))
|
|
347
361
|
const patch = configSavePatch(body ?? {})
|
|
348
362
|
const current = normalizeConfig(loadJson('config.json', undefined))
|
|
349
363
|
// 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
|
+
}
|