dsh-knj-workflow 2026.9.21 → 2026.9.22

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/lib/graph.js CHANGED
@@ -212,14 +212,19 @@ function matchCondition(upstream, when, ctx) {
212
212
  }
213
213
  }
214
214
 
215
- /** 宽松相等:真值优先严格比较;兼容旧配置存的字符串值('true'↔true、'5'↔5)。 */
215
+ /** 宽松相等:真值优先严格比较;兼容旧配置存的字符串值('true'↔true、'5'↔5)。
216
+ * 注意双向:subagent 输出可能是字符串化的数字/布尔("5"/"true"),
217
+ * 条件值也可能是数字/布尔(新版 UI 保存时智能转换)——任意一侧为字符串都要能宽松匹配。 */
216
218
  function looseEq(a, b) {
217
219
  if (a === b) return true;
218
- if (typeof b === 'string') {
219
- if (a === true && b === 'true') return true;
220
- if (a === false && b === 'false') return true;
221
- if (typeof a === 'number' && b !== '' && !isNaN(Number(b)) && Number(b) === a) return true;
222
- }
220
+ // 布尔 'true'/'false'(任意一侧字符串)
221
+ if (a === true && b === 'true') return true;
222
+ if (a === false && b === 'false') return true;
223
+ if (b === true && a === 'true') return true;
224
+ if (b === false && a === 'false') return true;
225
+ // 数字 ↔ 数字字符串("5"↔5,任意一侧字符串)
226
+ if (typeof a === 'number' && typeof b === 'string') return b !== '' && !isNaN(Number(b)) && Number(b) === a;
227
+ if (typeof b === 'number' && typeof a === 'string') return a !== '' && !isNaN(Number(a)) && Number(a) === b;
223
228
  return false;
224
229
  }
225
230
 
@@ -306,3 +311,52 @@ export function prepareWorkflowForSave(workflow, existing) {
306
311
  workflow.revision = (existing?.revision || 0) + 1;
307
312
  return { ok: true, workflow };
308
313
  }
314
+
315
+ /**
316
+ * 把 task 节点按图的拓扑执行顺序排列(从 start 出发沿边 BFS)。
317
+ * 用途:任务 stageStates 的展示顺序应反映流程走向(如 task1 → task11 → task2 → task3),
318
+ * 而不是 nodes 数组的声明顺序(节点在画布上的添加顺序可能与连线顺序不一致)。
319
+ * 规则:
320
+ * - 只排 type === 'task' 的节点;start/end/gateway/human 不进入列表但参与连线拓扑。
321
+ * - human 节点的去向 routes(或旧 approveTo/rejectTo)视为虚拟出边参与排序——
322
+ * 否则 human 后续的 task 会因「无真实出边」排到列表尾部(与执行顺序不符)。
323
+ * - XOR/AND 网关多出边按声明顺序稳定排列(同一网关后的分支保持 edges 顺序)。
324
+ * - 有环(驳回重跑等)时以 BFS 先到为准,环内节点不会导致死循环。
325
+ * 返回:task 节点 id 数组(按拓扑序)。
326
+ */
327
+ export function orderTaskNodesByFlow(workflow) {
328
+ const nodes = Array.isArray(workflow?.nodes) ? workflow.nodes : [];
329
+ const edges = Array.isArray(workflow?.edges) ? workflow.edges : [];
330
+ const byId = new Map(nodes.map((n) => [n.id, n]));
331
+ // 邻接表:真实边 + human 虚拟出边(routes / approveTo / rejectTo)
332
+ const adj = new Map(nodes.map((n) => [n.id, []]));
333
+ for (const e of edges) {
334
+ if (e?.from && byId.has(e.from) && e.to && byId.has(e.to)) adj.get(e.from).push(e.to);
335
+ }
336
+ for (const n of nodes) {
337
+ if (n.type !== 'human') continue;
338
+ for (const r of humanRoutes(n)) {
339
+ if (r?.to && byId.has(r.to)) adj.get(n.id).push(r.to);
340
+ }
341
+ }
342
+ // BFS 从 start 出发,记录 task 节点首次出现顺序
343
+ const ordered = [];
344
+ const seen = new Set();
345
+ const queue = ['start'];
346
+ seen.add('start');
347
+ while (queue.length) {
348
+ const id = queue.shift();
349
+ const node = byId.get(id);
350
+ if (node && node.type === 'task') ordered.push(id);
351
+ for (const to of adj.get(id) || []) {
352
+ if (seen.has(to)) continue;
353
+ seen.add(to);
354
+ queue.push(to);
355
+ }
356
+ }
357
+ // 未被 start 可达(孤立/异常图)的 task 按声明顺序补尾,避免丢失
358
+ for (const n of nodes) {
359
+ if (n.type === 'task' && !seen.has(n.id)) ordered.push(n.id);
360
+ }
361
+ return ordered;
362
+ }
package/lib/graph.test.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { test } from 'node:test';
7
7
  import assert from 'node:assert/strict';
8
- import { validateWorkflow, nextNode, resolveInputs, prepareWorkflowForSave } from './graph.js';
8
+ import { validateWorkflow, nextNode, resolveInputs, prepareWorkflowForSave, orderTaskNodesByFlow } from './graph.js';
9
9
 
10
10
  // 辅助:构造串行图
11
11
  function serialWorkflow(over = {}) {
@@ -389,3 +389,57 @@ test('validateWorkflow: human 多去向 routes 校验(目标无效 / 标签重
389
389
  assert.ok(r.errors.some((e) => e.includes('去向标签重复: 驳回')), JSON.stringify(r.errors));
390
390
  assert.ok(r.errors.some((e) => e.includes('缺少标签')), JSON.stringify(r.errors));
391
391
  });
392
+
393
+ test('orderTaskNodesByFlow: 节点按拓扑执行顺序而非声明顺序(task11 声明在尾部但连线在中间)', () => {
394
+ // 复现 wf-gate 实测:画布上先加了 task-2/task-3、后加 task11(nodes 声明序 task-1, task-2, task-3, task11),
395
+ // 但连线是 start→task-1→task11→gw→(task-2|task-3)。stageStates 若按声明序生成会把 task11 排到最底,
396
+ // 与真实执行顺序(task1→task11→task2/task3)不符。
397
+ const wf = {
398
+ id: 'wf-gate', schemaVersion: 2, revision: 2,
399
+ nodes: [
400
+ { id: 'start', type: 'start' },
401
+ { id: 'end', type: 'end' },
402
+ { id: 'task-1', type: 'task', title: 'task-1' },
403
+ { id: 'task-2', type: 'task', title: 'task-2' },
404
+ { id: 'task-3', type: 'task', title: 'task-3' },
405
+ { id: 'task11', type: 'task', title: 'task11' },
406
+ { id: 'gw', type: 'gateway-xor' },
407
+ ],
408
+ edges: [
409
+ { from: 'start', to: 'task-1' },
410
+ { from: 'task-2', to: 'end' },
411
+ { from: 'task-3', to: 'end' },
412
+ { from: 'task-1', to: 'task11' },
413
+ { from: 'task11', to: 'gw' },
414
+ { from: 'gw', to: 'task-2' },
415
+ { from: 'gw', to: 'task-3' },
416
+ ],
417
+ };
418
+ const ordered = orderTaskNodesByFlow(wf);
419
+ assert.deepEqual(ordered, ['task-1', 'task11', 'task-2', 'task-3'],
420
+ `应按执行顺序排列,实际: ${JSON.stringify(ordered)}`);
421
+ });
422
+
423
+ test('orderTaskNodesByFlow: human 节点去向(routes)后的 task 参与拓扑排序', () => {
424
+ const wf = {
425
+ id: 'wf-human', schemaVersion: 2, revision: 1,
426
+ nodes: [
427
+ { id: 'start', type: 'start' },
428
+ { id: 'task-1', type: 'task', title: 'task-1' },
429
+ { id: 'review', type: 'human', routes: [{ label: '通过', to: 'task-2' }, { label: '驳回', to: 'fix' }] },
430
+ { id: 'task-2', type: 'task', title: 'task-2' },
431
+ { id: 'fix', type: 'task', title: 'fix' },
432
+ { id: 'end', type: 'end' },
433
+ ],
434
+ edges: [
435
+ { from: 'start', to: 'task-1' },
436
+ { from: 'task-1', to: 'review' },
437
+ { from: 'task-2', to: 'end' },
438
+ { from: 'fix', to: 'task-2' }, // 驳回→fix→task-2(与 review 同层,BFS 先到为准)
439
+ ],
440
+ };
441
+ const ordered = orderTaskNodesByFlow(wf);
442
+ // start → task-1 → review(human) → task-2 与 fix;BFS 队列按邻接声明序先 task-2 后 fix
443
+ assert.deepEqual(ordered, ['task-1', 'task-2', 'fix'],
444
+ `human 后的 task 应继续排在拓扑序中,实际: ${JSON.stringify(ordered)}`);
445
+ });
package/lib/index.js CHANGED
@@ -12,10 +12,23 @@
12
12
  import { homedir } from 'node:os';
13
13
  import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
14
14
  import { mkdir, readFile, writeFile, readdir, rm, rename, stat, realpath } from 'node:fs/promises';
15
+ import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
15
16
  import { randomUUID } from 'node:crypto';
16
17
  import z from '@deepseek-ai/schemastery';
17
- import { readFileSync } from 'node:fs';
18
- import { validateWorkflow, prepareWorkflowForSave } from './graph.js';
18
+ import { validateWorkflow, prepareWorkflowForSave, orderTaskNodesByFlow } from './graph.js';
19
+
20
+ // ---- 运行时诊断日志(排查 runs 生命周期:human 决策后任务卡 running)----
21
+ // 只记录关键状态转换,避免刷屏;写入独立文件便于事后查看,不依赖宿主 logger 落点。
22
+ const DEBUG_LOG_DIR = join(homedir(), '.dsh', 'dev-orchestrator');
23
+ const DEBUG_LOG = join(DEBUG_LOG_DIR, 'knj-host-debug.log');
24
+ let _dbgInited = false;
25
+ function dbg(event, data) {
26
+ try {
27
+ if (!_dbgInited) { mkdirSync(DEBUG_LOG_DIR, { recursive: true }); _dbgInited = true; }
28
+ const line = JSON.stringify({ ts: new Date().toISOString(), event, ...data });
29
+ appendFileSync(DEBUG_LOG, line + '\n', 'utf8');
30
+ } catch { /* 日志失败不影响主流程 */ }
31
+ }
19
32
 
20
33
  export const name = 'dsh-knj-workflow';
21
34
 
@@ -187,11 +200,14 @@ export class DevTaskStore {
187
200
  // 串行化同一任务的写入:事件回调(phase/agent-end/end)可能并发触发 saveTask,
188
201
  // Windows 上并发写同一文件会互相踩踏。
189
202
  const prev = this._queues.get(task.id) || Promise.resolve();
203
+ dbg('store.saveTask: 排队', { taskId: task.id, queueDepth: this._queues.has(task.id) ? 1 : 0 });
190
204
  const next = prev.then(async () => {
191
205
  await writeJson(file, task);
206
+ dbg('store.saveTask: 写盘完成', { taskId: task.id });
192
207
  });
193
208
  this._queues.set(task.id, next.catch(() => {})); // 队列本身吞掉错误,避免整链断掉
194
209
  await next;
210
+ dbg('store.saveTask: await 返回', { taskId: task.id });
195
211
  return task;
196
212
  }
197
213
  /**
@@ -203,15 +219,19 @@ export class DevTaskStore {
203
219
  await mkdir(join(this.tasksDir, id), { recursive: true });
204
220
  const file = this.taskFile(id);
205
221
  const prev = this._queues.get(id) || Promise.resolve();
222
+ dbg('store.mutateTask: 排队', { taskId: id, queueDepth: this._queues.has(id) ? 1 : 0 });
206
223
  const next = prev.then(async () => {
207
224
  const t = await readJson(file, null);
208
225
  if (!t) return null;
209
226
  await mutator(t);
210
227
  await writeJson(file, t);
228
+ dbg('store.mutateTask: 写盘完成', { taskId: id });
211
229
  return t;
212
230
  });
213
231
  this._queues.set(id, next.catch(() => {}));
214
- return await next;
232
+ const out = await next;
233
+ dbg('store.mutateTask: await 返回', { taskId: id });
234
+ return out;
215
235
  }
216
236
  async deleteTask(id) {
217
237
  DevTaskStore.assertSafeId(id);
@@ -377,10 +397,12 @@ export class WorkflowBridge {
377
397
  // 并发守卫:检查 + 同步占位必须在任何 await 之前原子完成,堵住 TOCTOU 竞态——
378
398
  // 否则两个并发请求会双双通过守卫、启动两份 subagent(双倍 LLM 成本 + 互踩 stage)。
379
399
  if (this.runs.has(task.id)) {
400
+ dbg('startTask: 并发守卫命中(runs 已有条目)', { taskId: task.id, optsKeys: Object.keys(opts) });
380
401
  throw new RunAlreadyActiveError(`任务 ${task.id} 已有运行中的 run,请先等待完成或取消后再启动`);
381
402
  }
382
403
  const pendingMarker = {};
383
404
  this.runs.set(task.id, pendingMarker);
405
+ dbg('startTask: 占位已登记', { taskId: task.id, optsKeys: Object.keys(opts) });
384
406
  try {
385
407
  // 任务快照优先:运行只用创建时的快照,与工作流后续修改解耦
386
408
  const workflow = task.workflowSnapshot || await this.store.getWorkflow(task.workflowId);
@@ -491,6 +513,7 @@ export class WorkflowBridge {
491
513
 
492
514
  this.listeners.set(task.id, run.id);
493
515
  this.runs.set(task.id, run);
516
+ dbg('startTask: run 已登记', { taskId: task.id, runId: run.id });
494
517
  if (parentHandle) this.parentHandles.set(run.id, parentHandle);
495
518
  this.monitorRun(task, run);
496
519
  return { runId: run.id };
@@ -498,12 +521,14 @@ export class WorkflowBridge {
498
521
  // 早期失败(workflow 缺失 / 无 parent / 引擎不可用 / engine.start 抛错):
499
522
  // 清除占位标记,让任务可再次启动。真实 run 已登记时(成功路径)不会走到这里。
500
523
  if (this.runs.get(task.id) === pendingMarker) this.runs.delete(task.id);
524
+ dbg('startTask: 启动失败(已清理占位)', { taskId: task.id, error: error instanceof Error ? error.message : String(error) });
501
525
  throw error;
502
526
  }
503
527
  }
504
528
 
505
529
  /** 监听 run 事件并回写任务状态(事件签名:(info, payload)) */
506
530
  async monitorRun(task, run) {
531
+ dbg('monitorRun: 注册监听', { taskId: task.id, runId: run.id });
507
532
  const ctx = this.ctx;
508
533
  const matches = (info) => info?.id === run.id;
509
534
 
@@ -577,6 +602,14 @@ export class WorkflowBridge {
577
602
  // 不能拿它 finalizeTask,否则会用空 results 覆盖正确结果(已踩坑)。
578
603
  // run.result 契约上永不 reject,直接 await 设置终态 + 清理监听器。
579
604
  run.result.then(async (result) => {
605
+ dbg('monitorRun: result 落定', {
606
+ taskId: task.id, runId: run.id,
607
+ stopReason: result?.stopReason,
608
+ paused: !!(result?.value && result.value.paused),
609
+ runsIsRun: this.runs.get(task.id) === run,
610
+ runsHas: this.runs.has(task.id),
611
+ valueKeys: result?.value ? Object.keys(result.value) : [],
612
+ });
580
613
  try {
581
614
  // 只允许「当前活跃 run」写终态:cancel→resume 时旧 run 的 result 晚到,
582
615
  // 若不校验会覆盖新 run 状态;parent 按 runId 管理,旧 run 只 dispose 自己的 parent,
@@ -586,13 +619,19 @@ export class WorkflowBridge {
586
619
  }
587
620
  } catch {}
588
621
  // 清理放 finally 语义(即使 finalizeTask 抛错也必须 off 监听器 + dispose parent)
589
- ctx.off('workflow/phase', onPhase);
590
- ctx.off('workflow/agent-start', onAgentStart);
591
- ctx.off('workflow/agent-end', onAgentEnd);
592
- ctx.off('workflow/log', onLog);
622
+ // 注意:ctx.off 在真实环境可能抛错(监听器已随 ctx 生命周期注销等),必须各自独立 try——
623
+ // 若 ctx.off 抛错冒泡到外层 .catch,后面的 runs.delete 永不执行 → runs 残留 →
624
+ // decide/resume 全部被并发守卫 409 卡死(人工节点无法继续,实测复现)。
625
+ try { ctx.off('workflow/phase', onPhase); } catch {}
626
+ try { ctx.off('workflow/agent-start', onAgentStart); } catch {}
627
+ try { ctx.off('workflow/agent-end', onAgentEnd); } catch {}
628
+ try { ctx.off('workflow/log', onLog); } catch {}
593
629
  if (this.runs.get(task.id) === run) {
594
630
  this.listeners.delete(task.id);
595
631
  this.runs.delete(task.id);
632
+ dbg('monitorRun: runs 已清理', { taskId: task.id, runId: run.id });
633
+ } else {
634
+ dbg('monitorRun: runs 清理被跳过(runs 已被替换/删除)', { taskId: task.id, runId: run.id, runsHas: this.runs.has(task.id) });
596
635
  }
597
636
  // 关键:run 是 holder-owned,契约要求持有者在 result 落定后调用 run.dispose()
598
637
  // 释放引擎侧资源(等待子清理收敛)。不调用的话引擎 run 持续占用 parent 的
@@ -611,12 +650,15 @@ export class WorkflowBridge {
611
650
  this.parentHandles.delete(run.id);
612
651
  try { await handle.dispose?.(); } catch (e) { /* 忽略 dispose 失败,任务已终态 */ }
613
652
  }
614
- }).catch(() => {});
653
+ }).catch((error) => {
654
+ dbg('monitorRun: result.then 回调异常(清理可能被跳过 → runs 残留)', { taskId: task.id, error: error instanceof Error ? error.message : String(error) });
655
+ });
615
656
  }
616
657
 
617
658
  /** 取消后台 run(真正停止,否则 finalizeTask 会把 cancelled 覆盖成 success/failed) */
618
659
  cancelTask(taskId) {
619
660
  const run = this.runs.get(taskId);
661
+ dbg('cancelTask: 调用', { taskId, hasRun: !!run, isMarker: run !== undefined && run !== null && typeof run !== 'object' ? false : (!!run && typeof run.cancel !== 'function') });
620
662
  if (run && typeof run.cancel === 'function') run.cancel();
621
663
  }
622
664
 
@@ -632,12 +674,15 @@ export class WorkflowBridge {
632
674
  const value = result?.value;
633
675
  // 人工节点暂停:标记 waiting-human + 落盘断点(result.value 是编排器 return 值)
634
676
  if (value?.paused) {
635
- await this.store.mutateTask(taskId, (t) => {
677
+ dbg('finalizeTask: paused 分支(人工暂停)', { taskId, pausedAt: value.pausedAt });
678
+ const m1 = await this.store.mutateTask(taskId, (t) => {
636
679
  t.status = 'waiting-human';
637
680
  t.currentStage = value.pausedAt;
638
681
  t.humanState = { humanId: value.pausedAt, results: value.results || {} };
639
682
  });
683
+ dbg('finalizeTask: paused mutateTask 完成', { taskId, m1Status: m1?.status });
640
684
  await this.store.saveResults(taskId, value.results || {});
685
+ dbg('finalizeTask: paused saveResults 完成', { taskId });
641
686
  return;
642
687
  }
643
688
  const stopReason = result?.stopReason;
@@ -802,7 +847,10 @@ export function registerRoutes(ctx, store, bridge, prefix) {
802
847
  workflowSnapshot: workflow,
803
848
  status: 'pending',
804
849
  currentStage: null,
805
- stageStates: workflow.nodes.filter((n) => n.type === 'task').map((n) => ({ id: n.id, title: n.title, status: 'pending' })),
850
+ stageStates: orderTaskNodesByFlow(workflow).map((id) => {
851
+ const n = workflow.nodes.find((x) => x.id === id);
852
+ return { id, title: n?.title || id, status: 'pending' };
853
+ }),
806
854
  createdAt: nowIso(),
807
855
  ...(body.notes ? { notes: body.notes } : {}),
808
856
  // 需求描述必须保存:编排器各节点 prompt 要靠它注入「用户输入的需求内容」
@@ -933,7 +981,10 @@ export function registerRoutes(ctx, store, bridge, prefix) {
933
981
  if (!t) return sendJson(c.res, 404, { error: 'task not found' });
934
982
  // 取消中守卫:cancel 异步落定(最长约 5s),期间 runs 条目仍在——改状态前先问,
935
983
  // 避免「取消后立即续跑」被旧守卫误拦后把任务误标 failed(任务永久卡死)。
936
- if (bridge.isRunning(c.params.id)) return sendJson(c.res, 409, { error: '任务正在取消/运行中,请稍候再续跑' });
984
+ if (bridge.isRunning(c.params.id)) {
985
+ dbg('resume: isRunning 预检拦截(runs 仍有条目)', { taskId: c.params.id, mode: body.mode });
986
+ return sendJson(c.res, 409, { error: '任务正在取消/运行中,请稍候再续跑' });
987
+ }
937
988
  // 断点来源:续跑(mode=resume)读 stage 文件(每节点完成时编排器 checkpoint 落盘),
938
989
  // orchestrator 跳过已完成节点从断点继续;重跑(mode=rerun)清空 initialResults 从头执行。
939
990
  // 注意:results.json 在取消时是空的(编排器被中断,中间结果不落盘),不能作为断点来源。
@@ -1013,6 +1064,10 @@ export function registerRoutes(ctx, store, bridge, prefix) {
1013
1064
  ['POST', '/tasks/:id/decide', async (c) => {
1014
1065
  const body = await readBody(c.req);
1015
1066
  const t = await store.getTask(c.params.id);
1067
+ dbg('decide: 请求进入', {
1068
+ taskId: c.params.id, decision: body?.decision, hasFeedback: typeof body?.feedback === 'string' && !!body.feedback.trim(),
1069
+ taskStatus: t?.status, hasHumanState: !!(t && t.humanState), humanId: t?.humanState?.humanId,
1070
+ });
1016
1071
  if (!t) return sendJson(c.res, 404, { error: 'task not found' });
1017
1072
  if (t.status !== 'waiting-human' || !t.humanState) return sendJson(c.res, 400, { error: 'task not waiting for human' });
1018
1073
  const { humanId, results } = t.humanState;
@@ -1030,6 +1085,10 @@ export function registerRoutes(ctx, store, bridge, prefix) {
1030
1085
  const feedback = typeof body.feedback === 'string' ? body.feedback.trim() : '';
1031
1086
  // 记录决策历史,便于事后追溯(第几轮审批、结论、意见)
1032
1087
  t.decisions = [...(t.decisions || []), { humanId, decision, feedback, at: nowIso() }];
1088
+ // 保存决策前的现场:startTask 若被并发守卫拒绝(RunAlreadyActiveError)必须回滚,
1089
+ // 否则 humanState 已清、任务已置 running,用户无法再决策、任务永久卡死(无法恢复)。
1090
+ const pendingHumanState = t.humanState;
1091
+ const pendingDecision = t.decisions[t.decisions.length - 1];
1033
1092
  t.status = 'running';
1034
1093
  t.humanState = null;
1035
1094
  t.error = undefined;
@@ -1043,8 +1102,28 @@ export function registerRoutes(ctx, store, bridge, prefix) {
1043
1102
  });
1044
1103
  sendJson(c.res, 200, { task: t, runId });
1045
1104
  } catch (e) {
1046
- if (e instanceof RunAlreadyActiveError) return sendJson(c.res, 409, { error: e.message });
1047
- // humanState 已清、决策已记录——无法回滚,但至少要标记 failed,避免任务卡 running
1105
+ dbg('decide: startTask 抛错', {
1106
+ taskId: c.params.id, decision,
1107
+ isRunAlreadyActive: e instanceof RunAlreadyActiveError,
1108
+ error: e instanceof Error ? e.message : String(e),
1109
+ });
1110
+ if (e instanceof RunAlreadyActiveError) {
1111
+ // 并发守卫拒绝 = 决策未被编排器消费(新 run 没起来)。回滚到 waiting-human,
1112
+ // 保留 humanState 与决策历史供用户重试,而不是留下一个卡死的 running 任务。
1113
+ await store.mutateTask(c.params.id, (tt) => {
1114
+ tt.status = 'waiting-human';
1115
+ tt.humanState = pendingHumanState;
1116
+ tt.currentStage = pendingHumanState?.humanId || tt.currentStage;
1117
+ tt.error = undefined;
1118
+ // 去掉刚追加但未生效的决策(避免 buildDecidedMap 重放成"已决策"跳过 human)
1119
+ if (pendingDecision) {
1120
+ const idx = (tt.decisions || []).findIndex((d) => d === pendingDecision || (d.humanId === pendingDecision.humanId && d.at === pendingDecision.at));
1121
+ if (idx >= 0) tt.decisions = (tt.decisions || []).filter((_, i) => i !== idx);
1122
+ }
1123
+ }).catch(() => {});
1124
+ return sendJson(c.res, 409, { error: e.message });
1125
+ }
1126
+ // 决策后启动失败(非并发):humanState 已清、决策已记录——无法回滚,但至少要标记 failed,避免任务卡 running
1048
1127
  await store.mutateTask(c.params.id, (tt) => {
1049
1128
  tt.status = 'failed';
1050
1129
  tt.error = `决策后继续失败:${e instanceof Error ? e.message : String(e)}`;
@@ -1179,7 +1258,10 @@ function registerCommands(ctx, store, bridge) {
1179
1258
  workflowRevision: workflow.revision || 1,
1180
1259
  workflowSnapshot: workflow,
1181
1260
  status: 'pending',
1182
- stageStates: workflow.nodes.filter((n) => n.type === 'task').map((n) => ({ id: n.id, title: n.title, status: 'pending' })),
1261
+ stageStates: orderTaskNodesByFlow(workflow).map((id) => {
1262
+ const n = workflow.nodes.find((x) => x.id === id);
1263
+ return { id, title: n?.title || id, status: 'pending' };
1264
+ }),
1183
1265
  createdAt: nowIso(),
1184
1266
  };
1185
1267
  await store.saveTask(task);
@@ -95,14 +95,19 @@ function matchCondition(upstream, when) {
95
95
  return false;
96
96
  }
97
97
 
98
- /** 宽松相等:真值优先严格比较;兼容旧配置存的字符串值('true'↔true、'5'↔5)。 */
98
+ /** 宽松相等:真值优先严格比较;兼容旧配置存的字符串值('true'↔true、'5'↔5)。
99
+ * 注意双向:subagent 输出可能是字符串化的数字/布尔("5"/"true"),
100
+ * 条件值也可能是数字/布尔(新版 UI 保存时智能转换)——任意一侧为字符串都要能宽松匹配。 */
99
101
  function looseEq(a, b) {
100
102
  if (a === b) return true;
101
- if (typeof b === 'string') {
102
- if (a === true && b === 'true') return true;
103
- if (a === false && b === 'false') return true;
104
- if (typeof a === 'number' && b !== '' && !isNaN(Number(b)) && Number(b) === a) return true;
105
- }
103
+ // 布尔 'true'/'false'(任意一侧字符串)
104
+ if (a === true && b === 'true') return true;
105
+ if (a === false && b === 'false') return true;
106
+ if (b === true && a === 'true') return true;
107
+ if (b === false && a === 'false') return true;
108
+ // 数字 ↔ 数字字符串("5"↔5,任意一侧字符串)
109
+ if (typeof a === 'number' && typeof b === 'string') return b !== '' && !isNaN(Number(b)) && Number(b) === a;
110
+ if (typeof b === 'number' && typeof a === 'string') return a !== '' && !isNaN(Number(a)) && Number(a) === b;
106
111
  return false;
107
112
  }
108
113
 
@@ -237,6 +237,31 @@ test('orchestrator: 条件值字符串宽松匹配旧配置("true" 匹配布
237
237
  assert.ok(!r.agentCalls.includes('修复'), JSON.stringify(r.agentCalls));
238
238
  });
239
239
 
240
+ test('orchestrator: 反向宽松匹配——subagent 输出字符串数字 "5"、条件值为数字 5 也应命中(实测 wf-gate 失败场景)', async () => {
241
+ const wf = {
242
+ id: 'wf', nodes: [
243
+ { id: 'start', type: 'start' },
244
+ { id: 'task11', type: 'task', title: 'task11', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
245
+ { id: 'gw', type: 'gateway-xor', title: '网关' },
246
+ { id: 'task2', type: 'task', title: 'task-2', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
247
+ { id: 'task3', type: 'task', title: 'task-3', inputs: [], body: { prompt: '', mode: 'single', output: {} } },
248
+ { id: 'end', type: 'end' },
249
+ ],
250
+ edges: [
251
+ { from: 'start', to: 'task11' },
252
+ { from: 'task11', to: 'gw' },
253
+ // 条件值是数字 5(新版 UI 保存时智能转换 '5'→5)
254
+ { from: 'gw', to: 'task2', when: { field: 'taskResult', op: 'eq', value: 5 } },
255
+ { from: 'gw', to: 'task3', default: true },
256
+ { from: 'task2', to: 'end' }, { from: 'task3', to: 'end' },
257
+ ],
258
+ };
259
+ // subagent 把 taskResult 输出成了字符串 "5"(没有声明 type:number 时 LLM 常见)
260
+ const r = await runOrchestrator(wf, (label) => (label === 'task11' ? { taskResult: '5' } : { ok: true }));
261
+ assert.ok(r.agentCalls.includes('task-2'), `应命中数字条件分支 task-2,实际: ${JSON.stringify(r.agentCalls)}`);
262
+ assert.ok(!r.agentCalls.includes('task-3'), JSON.stringify(r.agentCalls));
263
+ });
264
+
240
265
  // 构造 AND split/join 图
241
266
  function andWorkflow() {
242
267
  return {
@@ -0,0 +1,73 @@
1
+ /**
2
+ * 复现 2:decide 路由的状态破坏顺序。
3
+ * 现场:任务 waiting-human(humanState 有值)→ 用户点驳回 → decide 路由先
4
+ * t.status='running'; t.humanState=null; saveTask → 再 bridge.startTask。
5
+ * 若此刻 runs Map 仍残留旧 run(decide 前的 run 尚未清理/或并发 run 活跃),
6
+ * startTask 抛 RunAlreadyActiveError → 路由返回 409 但不恢复任务状态 →
7
+ * 任务永久卡 running + humanState 已丢 → 无法再次决策、resume 也被 isRunning 挡住。
8
+ * 运行:node --test lib/repro-decide-order.test.js
9
+ */
10
+ import { test } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import { EventEmitter } from 'node:events';
13
+ import { registerRoutes, RunAlreadyActiveError } from './index.js';
14
+
15
+ const WF = {
16
+ id: 'wf1', name: 'WF',
17
+ nodes: [
18
+ { id: 'start', type: 'start' },
19
+ { id: 'task-1', type: 'task', title: 'A', body: { prompt: 'p' } },
20
+ { id: 'human-1', type: 'human', title: '审批', routes: [{ label: '通过', to: 'task-2' }, { label: '不通过', to: 'task-3' }] },
21
+ { id: 'task-2', type: 'task', title: 'B', body: { prompt: 'p' } },
22
+ { id: 'task-3', type: 'task', title: 'C', body: { prompt: 'p' } },
23
+ { id: 'end', type: 'end' },
24
+ ],
25
+ edges: [
26
+ { from: 'start', to: 'task-1' }, { from: 'task-1', to: 'human-1' },
27
+ { from: 'task-2', to: 'end' }, { from: 'task-3', to: 'end' },
28
+ ],
29
+ };
30
+
31
+ function makeWaitingTask() {
32
+ return {
33
+ id: 'task-w', title: 'W', workflowId: 'wf1', cwd: 'D:/w', workflowSnapshot: WF,
34
+ status: 'waiting-human',
35
+ currentStage: 'human-1',
36
+ humanState: { humanId: 'human-1', results: { 'task-1': { ok: true } } },
37
+ stageStates: [
38
+ { id: 'task-1', status: 'done' }, { id: 'task-2', status: 'pending' }, { id: 'task-3', status: 'pending' },
39
+ ],
40
+ };
41
+ }
42
+
43
+ /** 路由直驱:bridge.startTask 抛 RunAlreadyActiveError(模拟 decide 前旧 run 尚未清理/有并发 run) */
44
+ test('decide:startTask 被 409 拒绝时不得破坏 waiting-human 任务状态(可重试)', async () => {
45
+ let saved = null;
46
+ const store = {
47
+ tasksDir: '.tasks',
48
+ async getTask(id) { return id === 'task-w' ? JSON.parse(JSON.stringify(saved)) : null; },
49
+ async saveTask(t) { saved = JSON.parse(JSON.stringify(t)); },
50
+ async mutateTask(id, fn) { if (saved && saved.id === id) { fn(saved); } },
51
+ async saveResults() {}, async readResults() { return {}; }, async readStage() { return null; },
52
+ };
53
+ const bridge = {
54
+ isRunning: () => true, // decide 前仍有活跃 run(旧 run 残留 / 并发)
55
+ async startTask() { throw new RunAlreadyActiveError('任务 task-w 已有运行中的 run'); },
56
+ };
57
+ const registrations = [];
58
+ registerRoutes({ effect(fn) { fn(); return () => {}; }, webServer: { register: (r) => registrations.push(r) } }, store, bridge, '');
59
+ const { handler } = registrations[0]; // register({ kind:'prefix', path, handler })
60
+
61
+ saved = makeWaitingTask();
62
+ const req = new EventEmitter();
63
+ req.method = 'POST';
64
+ req.url = '/tasks/task-w/decide';
65
+ const res = { headersSent: false, statusCode: 0, body: null, writeHead(s) { this.statusCode = s; this.headersSent = true; }, end(d) { this.body = d ? JSON.parse(d) : null; } };
66
+ process.nextTick(() => { req.emit('data', JSON.stringify({ decision: '不通过', feedback: 'x' })); req.emit('end'); });
67
+ await handler(req, res);
68
+
69
+ assert.equal(res.statusCode, 409, '并发守卫应 409');
70
+ assert.equal(saved.status, 'waiting-human',
71
+ 'BUG:startTask 被拒后任务状态被改成 running,卡死且 humanState 已丢(无法重试决策)');
72
+ assert.ok(saved.humanState, 'BUG:humanState 被清空,任务无法再次 decide');
73
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * 复现:human paused 后 runs Map 是否残留(导致 decide/resume 被并发守卫 409 卡死)。
3
+ * 现场:task-mtk40fjd 点驳回后任务卡 running,resume 返回 409「任务正在取消/运行中」,
4
+ * 且没有创建第二个 parent 会话 → decide 的 startTask 在 runs.has 处抛 RunAlreadyActiveError。
5
+ * 运行:node --test lib/repro-paused-runs.test.js
6
+ */
7
+ import { test } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { WorkflowBridge, RunAlreadyActiveError } from './index.js';
10
+
11
+ const WF_HUMAN = {
12
+ id: 'wf-human', name: 'H',
13
+ nodes: [
14
+ { id: 'start', type: 'start' },
15
+ { id: 'task-1', type: 'task', title: 'T1', body: { prompt: 'p1' } },
16
+ { id: 'human-1', type: 'human', title: '审批', routes: [{ label: '通过', to: 'task-2' }, { label: '不通过', to: 'task-3' }] },
17
+ { id: 'task-2', type: 'task', title: 'T2', body: { prompt: 'p2' } },
18
+ { id: 'task-3', type: 'task', title: 'T3', body: { prompt: 'p3' } },
19
+ { id: 'end', type: 'end' },
20
+ ],
21
+ edges: [
22
+ { from: 'start', to: 'task-1' }, { from: 'task-1', to: 'human-1' },
23
+ { from: 'task-2', to: 'end' }, { from: 'task-3', to: 'end' },
24
+ ],
25
+ };
26
+
27
+ function makeTask() {
28
+ return { id: 'task-x', title: 'X', workflowId: 'wf-human', cwd: 'D:/w', workflowSnapshot: WF_HUMAN };
29
+ }
30
+
31
+ /** 内存态 store:getTask/mutateTask/saveTask/saveResults 真实生效,供 decide 路由校验 humanState */
32
+ function makeStore() {
33
+ let task = null;
34
+ const results = {};
35
+ return {
36
+ tasksDir: '.tasks',
37
+ async getWorkflow() { return WF_HUMAN; },
38
+ async getTask(id) { return task && task.id === id ? JSON.parse(JSON.stringify(task)) : null; },
39
+ async saveTask(t) { task = JSON.parse(JSON.stringify(t)); },
40
+ async mutateTask(id, fn) { if (task && task.id === id) { fn(task); } },
41
+ async saveResults(id, r) { Object.assign(results, r); },
42
+ async readResults() { return { ...results }; },
43
+ async writeStage() {}, async readStage() { return null; },
44
+ };
45
+ }
46
+
47
+ /** ctx mock:agents.create 提供 parent,engine.start 返回可编程 result 的 run */
48
+ function makeEnv(resultProvider) {
49
+ const created = [];
50
+ const engine = {
51
+ start() {
52
+ return {
53
+ id: 'run-' + created.length,
54
+ cancel() {},
55
+ dispose() {},
56
+ result: Promise.resolve(typeof resultProvider === 'function' ? resultProvider(created.length) : resultProvider),
57
+ };
58
+ },
59
+ };
60
+ const parentAgent = { id: 'parent-1', scope: { ctx: { get: (n) => (n === 'workflowEngine' ? engine : undefined) } } };
61
+ const seed = { session: { header: { cwd: 'D:/w' } }, options: {} };
62
+ const ctx = {
63
+ on() {}, off() {},
64
+ logger: { info() {}, warn() {} },
65
+ get(name) {
66
+ if (name === 'agents') return {
67
+ currentInitiator: () => seed,
68
+ roots: () => [seed],
69
+ async create(opts) { created.push(opts); return { agent: parentAgent, dispose: async () => {} }; },
70
+ };
71
+ return undefined;
72
+ },
73
+ };
74
+ return { ctx, store: makeStore(), created, engine };
75
+ }
76
+
77
+ test('复现:第一次 run paused 结束后 runs 应清理,isRunning 应为 false', async () => {
78
+ const { ctx, store } = makeEnv({ stopReason: 'completed', value: { paused: true, pausedAt: 'human-1', results: { 'task-1': { ok: true } }, stageLog: [{ id: 'task-1' }] } });
79
+ const bridge = new WorkflowBridge(ctx, store, 'script');
80
+ const task = makeTask();
81
+ await bridge.startTask(task, {});
82
+ assert.equal(bridge.isRunning(task.id), true, '启动后应运行中');
83
+
84
+ // 等 run.result.then 的清理(monitorRun 在 result settle 后删 runs)
85
+ await new Promise((r) => setTimeout(r, 50));
86
+ assert.equal(bridge.isRunning(task.id), false,
87
+ 'BUG:paused 后 runs 未清理,decide/resume 会被并发守卫 409 卡死');
88
+ });
89
+
90
+ test('复现:paused 清理后,decide 再次 startTask 必须成功(不被 409 拒)', async () => {
91
+ let calls = 0;
92
+ const { ctx, store } = makeEnv(() => {
93
+ calls++;
94
+ // 第一次 run:跑到 human 暂停;第二次 run:正常完成(本次只验证 startTask 不被拒)
95
+ return calls === 1
96
+ ? { stopReason: 'completed', value: { paused: true, pausedAt: 'human-1', results: { 'task-1': { ok: true } }, stageLog: [{ id: 'task-1' }] } }
97
+ : { stopReason: 'completed', value: { results: { 'task-1': { ok: true }, 'task-3': { ok: true } }, stageLog: [{ id: 'task-1' }, { id: 'task-3' }] } };
98
+ });
99
+ const bridge = new WorkflowBridge(ctx, store, 'script');
100
+ const task = makeTask();
101
+ await bridge.startTask(task, {});
102
+ await new Promise((r) => setTimeout(r, 50));
103
+ assert.equal(bridge.isRunning(task.id), false, 'paused 后应可再启动');
104
+
105
+ // 模拟 decide 后的第二次启动(带决策继续)
106
+ try {
107
+ await bridge.startTask(task, { decision: { humanId: 'human-1', value: '不通过' }, initialResults: { 'task-1': { ok: true } }, decided: { 'human-1': '不通过' } });
108
+ assert.equal(calls, 2, '第二次 startTask 应成功启动新 run');
109
+ } catch (e) {
110
+ assert.fail('decide 后 startTask 被拒(' + (e instanceof RunAlreadyActiveError ? 'RunAlreadyActiveError: ' : '') + e.message + ')');
111
+ }
112
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-knj-workflow",
3
- "version": "2026.9.21",
3
+ "version": "2026.9.22",
4
4
  "description": "KNJ 开发任务编排插件:配置驱动的工作流 + 开发任务管理 + 可视化阶段进度(DeepSeek Harness)",
5
5
  "author": "knj",
6
6
  "license": "MIT",