@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 CHANGED
@@ -25,4 +25,4 @@ node --test "test/*.test.mjs" # 包内测试(在 packages/dsh-rs-workflow
25
25
 
26
26
  ## dsh 版本兼容
27
27
 
28
- 三版本全部通过:preset 释放、Agent 预设页若水工作流卡、rs_workflow_ 工具可列出、激活 live。
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 { releaseFlowTemplate, unreleaseFlowTemplate, releasedTemplateIds } from './release.mjs'
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
- function removeTemplateById(id) {
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
- // 撤下释放物:删除模板即移除对应释放目录(外来目录返回 foreign 不动)
107
- const outcome = unreleaseFlowTemplate(id)
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
- const inputs = body.inputs && typeof body.inputs === 'object' ? body.inputs : undefined
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: releasedTemplateIds() })
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 outcome = releaseFlowTemplate(entry)
334
- sendJson(res, 200, { ok: true, outcome })
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 outcome = unreleaseFlowTemplate(id)
340
- sendJson(res, 200, { ok: true, outcome })
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 独立存储
@@ -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
  }
@@ -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.controlSeq = record.controls?.length ?? 0
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
- try {
135
- this.drainPendingApprovals()
136
- return await this.loop()
137
- } catch (e) {
138
- if (this.signal.aborted) {
139
- this.finish('cancelled', CANCELLED_SUMMARY)
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
- this.finish('failed', `驱动器异常:${e?.message ?? e}`)
143
- return this.terminalPayload()
144
- } finally {
145
- this.active = false
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
@@ -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 记账。返回 {results, cancelled}
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
- const run = engine.start({
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(如 cancelled/error)时 value 不可信,全部调用按失败记账
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
- return { results: calls.map((c) => ({ callId: c.callId, ok: false, error: reason })) }
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)
@@ -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
- key: `#${index + 1}`, index, item, status: 'pending', outputs: null, failCount: 0, carry: null,
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
+ }