dsh-knj-workflow 2026.9.70 → 2026.9.73

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/client.js CHANGED
@@ -503,6 +503,20 @@ table.knj-tbl{width:100%;border-collapse:collapse;table-layout:fixed}
503
503
  .knj-archive-list{flex:1;padding:14px 16px;overflow-y:auto}
504
504
  .knj-arch-row{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:10px;padding:12px 14px;display:flex;align-items:center;gap:12px;margin-bottom:10px}
505
505
  .knj-arch-meta{margin-left:auto;font-size:12px;color:var(--dsw-alias-label-tertiary)}
506
+ /* ===== 流程运行面板:状态对比增强 + 运行中呼吸脉冲 =====
507
+ 只对「正在运行」这一主信号加动画(其余状态保持静态),脉冲柔和克制、契合 DSH 原生风格。
508
+ 色值为既有语义色(RUN_STATUS/STAGE_META),未新增调色板。 */
509
+ @keyframes knjRunPulse{0%,100%{opacity:.9}50%{opacity:.25}}
510
+ @keyframes knjRunBreath{0%,100%{box-shadow:0 0 0 3px rgba(47,107,255,.22)}50%{box-shadow:0 0 0 9px rgba(47,107,255,.05)}}
511
+ @keyframes knjBarStrip{to{background-position:14px 0}}
512
+ .knj-run-glow{animation:knjRunPulse 2s ease-in-out infinite}
513
+ .knj-node-dot.running{animation:knjRunBreath 1.6s ease-in-out infinite}
514
+ .knj-node-card.running{border-color:#2f6bff;background:var(--dsw-alias-bg-base)}
515
+ .knj-node-card.done{border-color:var(--dsw-alias-state-success-primary)}
516
+ .knj-node-card.pending{border-color:var(--dsw-alias-border-l2);opacity:.85}
517
+ .knj-node-card.failed{border-color:var(--dsw-alias-state-error-primary)}
518
+ .knj-node-card.skipped{opacity:.55}
519
+ .knj-progress.animated>div{background-image:linear-gradient(90deg,var(--dsw-alias-state-business-primary) 0 62%,rgba(47,107,255,.5) 62% 100%);background-size:14px 100%;animation:knjBarStrip .9s linear infinite}
506
520
  `;
507
521
 
508
522
  function toast(msg) {
@@ -1141,7 +1155,7 @@ table.knj-tbl{width:100%;border-collapse:collapse;table-layout:fixed}
1141
1155
  ]),
1142
1156
  sec('行为 prompt(task 节点)', [
1143
1157
  '本节点让 AI 做什么,写清楚目标和约束。',
1144
- '可引用:${inputDescription}(任务需求描述)、${inputTitle}(标题)、${上游编码.字段}(上游输出,如 ${analyze.description})、${inputs.参数名}(任务输入)。',
1158
+ '可引用:${inputDescription}(任务需求描述)、${inputTitle}(标题)、${taskId}(任务 id,流程实例唯一标识;同任务续跑/重跑 id 不变,可拼产出路径隔离产物)、${上游编码.字段}(上游输出,如 ${analyze.description})、${inputs.参数名}(任务输入)。',
1145
1159
  '执行方式:single 一个 AI 完成;parallel 派多个 AI 并行(结果合并为数组)。',
1146
1160
  '最大执行次数:本节点在一轮任务里最多执行几次(默认 3)。驳回重做 / 循环连线会重复执行节点,超过上限本轮失败——自动防死循环,无需其他配置。',
1147
1161
  ]),
@@ -1195,7 +1209,7 @@ table.knj-tbl{width:100%;border-collapse:collapse;table-layout:fixed}
1195
1209
  ]),
1196
1210
  sec('行为 prompt(task 节点)', [
1197
1211
  '本节点让 AI 做什么,写清目标与约束。',
1198
- '可引用:${inputDescription}(任务需求描述)、${inputTitle}(标题)、${storyCode}(用户故事编码)、${cwd}(任务工作目录)、${inputs.参数名}(任务输入)。',
1212
+ '可引用:${inputDescription}(任务需求描述)、${inputTitle}(标题)、${storyCode}(用户故事编码)、${cwd}(任务工作目录)、${taskId}(任务 id,同任务续跑/重跑 id 不变,产出路径用它拼接可隔离各实例产物)、${inputs.参数名}(任务输入)。',
1199
1213
  '运行时每个 task 节点会自动声明任务工作目录(所有文件/命令操作都在其中)。',
1200
1214
  '需要 skill 时在 prompt 里直接写「先调用 skill 工具加载 skill「xxx」」。',
1201
1215
  '上游输出引用(${上游编码.字段}):',
@@ -1702,7 +1716,7 @@ table.knj-tbl{width:100%;border-collapse:collapse;table-layout:fixed}
1702
1716
  fieldLabel('节点编码(id)', '节点唯一标识:下游用 ${编码.字段} 引用(如 ${analyze.description}),网关用字段值做分支判断。修改后 prompt 里旧的 ${旧编码.字段} 引用需手动更新。', '下游用 ${编码.字段} 引用,如 ${analyze.description}'),
1703
1717
  react.createElement('input', { className: 'knj-input', style: { marginBottom: 8 }, placeholder: '如:analyze', defaultValue: node.id || '', onBlur: (e) => this.patchNodeId(node.id, e.target.value.trim()) }),
1704
1718
  isTask ? react.createElement('div', null,
1705
- fieldLabel('行为 prompt(主体)', '本节点让 AI 做什么,写清目标与约束。\n可引用:\n${inputDescription}(任务需求描述)\n${inputTitle}(标题)\n${storyCode}(用户故事编码,可选)\n${cwd}(任务工作目录;运行时还会自动向 subagent 声明该目录)\n${inputs.参数名}(任务输入)\n\n上游输出引用(${上游编码.字段}):\n' + this.buildRefsText(node) + '\n\n注意:XOR 分支未执行的节点引用为空;整节点输出用 ${节点id};${ctx.前缀写法仍兼容。', '让 AI 做什么;可引用 ${inputDescription}、${storyCode}、${cwd}、${上游编码.字段}(ⓘ 看完整清单)'),
1719
+ fieldLabel('行为 prompt(主体)', '本节点让 AI 做什么,写清目标与约束。\n可引用:\n${inputDescription}(任务需求描述)\n${inputTitle}(标题)\n${storyCode}(用户故事编码,可选)\n${cwd}(任务工作目录;运行时还会自动向 subagent 声明该目录)\n${taskId}(任务 id,流程实例唯一标识;同任务续跑/重跑 id 不变——产出路径可写 ${cwd}/out/${taskId}/ 按实例隔离产物)\n${inputs.参数名}(任务输入)\n\n上游输出引用(${上游编码.字段}):\n' + this.buildRefsText(node) + '\n\n注意:XOR 分支未执行的节点引用为空;整节点输出用 ${节点id};${ctx.前缀写法仍兼容。', '让 AI 做什么;可引用 ${inputDescription}、${storyCode}、${cwd}、${上游编码.字段}(ⓘ 看完整清单)'),
1706
1720
  react.createElement('textarea', { className: 'knj-textarea', style: { minHeight: 140 }, placeholder: '如:根据需求 ${inputDescription} 输出功能描述与验收标准', value: node.body?.prompt || '', onChange: (e) => this.patchNode(node.id, { body: { ...node.body, prompt: e.target.value } }) }),
1707
1721
  fieldLabel('执行方式', 'single:一个 AI 完成本阶段,输出单个对象。\nparallel:派多个 AI 并行做同一件事,结果合并为数组(配合「最大执行次数」外的 parallelItems 使用,见输出说明)。', 'single:单个 AI 完成;parallel:多个 AI 并行,结果合并为数组'),
1708
1722
  react.createElement('select', { className: 'knj-input', value: node.body?.mode || 'single', onChange: (e) => this.patchNode(node.id, { body: { ...node.body, mode: e.target.value } }) },
@@ -2893,7 +2907,7 @@ table.knj-tbl{width:100%;border-collapse:collapse;table-layout:fixed}
2893
2907
  kids.push(react.createElement('rect', { key: 'b', x: n.x - 31, y: n.y - 18, width: 62, height: 36, rx: 18, fill: 'var(--dsw-alias-bg-base)', stroke: isSkipped ? '#b6bdc7' : (status === 'pending' ? '#8b95a5' : c), strokeWidth: 1.4, strokeDasharray: isSkipped ? '4 3' : undefined, opacity: dim }));
2894
2908
  kids.push(react.createElement('text', { key: 't', x: n.x, y: n.y + 4, fontSize: 12, textAnchor: 'middle', fill: 'var(--dsw-alias-label-secondary)' }, n.type === 'start' ? '开始' : '结束'));
2895
2909
  } else {
2896
- kids.push(react.createElement('rect', { key: 'b', x: n.x - w / 2, y: n.y - hh / 2, width: w, height: hh, rx: 9, fill: n.type === 'human' ? '#f3efff' : 'var(--dsw-alias-bg-base)', stroke: 'var(--dsw-alias-border-l2)', strokeWidth: 1, opacity: dim }));
2910
+ kids.push(react.createElement('rect', { key: 'b', x: n.x - w / 2, y: n.y - hh / 2, width: w, height: hh, rx: 9, fill: n.type === 'human' ? '#f3efff' : (status === 'running' ? '#e7efff' : status === 'done' ? '#f0faf3' : 'var(--dsw-alias-bg-base)'), stroke: isSkipped ? '#b6bdc7' : (status === 'pending' ? 'var(--dsw-alias-border-l2)' : c), strokeWidth: status === 'running' ? 2.1 : 1, opacity: dim }));
2897
2911
  kids.push(react.createElement('rect', { key: 'bar', x: n.x - w / 2 + 1, y: n.y - hh / 2 + 1, width: 3.5, height: hh - 2, rx: 1.75, fill: isSkipped ? '#9aa3af' : c, strokeDasharray: isSkipped ? '3 2' : undefined }));
2898
2912
  kids.push(react.createElement('text', { key: 'tt', x: n.x + 5, y: n.y - 1, fontSize: 13, fontWeight: 600, textAnchor: 'middle', fill: 'var(--dsw-alias-label-primary)', style: { userSelect: 'none' } }, (n.title || n.id).length > 11 ? (n.title || n.id).slice(0, 11) + '…' : (n.title || n.id)));
2899
2913
  const sub = status === 'running' ? '运行中' : status === 'waiting' ? '等待人工审批' : (status === 'passed' ? '已过' : meta.label);
@@ -2907,8 +2921,8 @@ table.knj-tbl{width:100%;border-collapse:collapse;table-layout:fixed}
2907
2921
  else if (status === 'skipped') kids.push(react.createElement('path', { key: 'ic2', d: `M ${ix - 3.2} ${iy} h 6.4`, stroke: '#fff', strokeWidth: 1.6, strokeLinecap: 'round' }));
2908
2922
  else if (status === 'waiting') kids.push(react.createElement('circle', { key: 'ic2', cx: ix, cy: iy - 1.5, r: 2.2, fill: 'none', stroke: '#fff', strokeWidth: 1.2 }), react.createElement('path', { key: 'ic3', d: `M ${ix - 3.6} ${iy + 4.6} c 0 -2.1 1.7 -3.1 3.6 -3.1 s 3.6 1 3.6 3.1`, stroke: '#fff', strokeWidth: 1.2, fill: 'none', strokeLinecap: 'round' }));
2909
2923
  }
2910
- // running 光环(静态两层,替代动画,避免依赖注入 keyframes)
2911
- if (status === 'running') kids.push(react.createElement('circle', { key: 'halo', cx: n.x, cy: n.y, r: 33, fill: 'none', stroke: '#2f6bff', strokeWidth: 1.6, opacity: 0.35 }));
2924
+ // running 光环:贴合节点矩形的呼吸光边(非圆环;仅运行节点=当前主信号上动画)
2925
+ if (status === 'running') kids.push(react.createElement('rect', { key: 'halo', className: 'knj-run-glow', x: n.x - w / 2 - 5, y: n.y - hh / 2 - 5, width: w + 10, height: hh + 10, rx: 13, fill: 'rgba(47,107,255,.1)', stroke: '#2f6bff', strokeWidth: 2.2 }));
2912
2926
  }
2913
2927
  const isSel = sel;
2914
2928
  const hit = react.createElement('rect', { x: n.x - 70, y: n.y - 38, width: 140, height: 76, rx: 12, fill: 'transparent', onClick: (e) => { e.stopPropagation(); this.setState({ graphSel: n.id }); const stage = (this.props.task.stageStates || []).find((x) => x.id === n.id); if (stage && stage.status === 'done' && this.resultFor(n.id) == null) this.loadStageResult(n.id); }, style: { cursor: 'pointer' } });
@@ -2935,9 +2949,9 @@ table.knj-tbl{width:100%;border-collapse:collapse;table-layout:fixed}
2935
2949
  : s.status === 'skipped' ? '–'
2936
2950
  : s.status === 'failed' ? icon('x')
2937
2951
  : (i + 1);
2938
- return react.createElement('div', { key: s.id, className: 'knj-node-card' },
2952
+ return react.createElement('div', { key: s.id, className: 'knj-node-card' + (isRunning ? ' running' : s.status === 'done' ? ' done' : s.status === 'pending' ? ' pending' : s.status === 'failed' ? ' failed' : s.status === 'skipped' ? ' skipped' : '') },
2939
2953
  react.createElement('div', { className: 'knj-node-head', onClick: () => hasDetail && this.toggle(s.id) },
2940
- react.createElement('div', { className: 'knj-node-dot', style: { background: meta.color, ...(isRunning ? { boxShadow: '0 0 0 4px rgba(37,99,235,.2)' } : {}) } }, statusIcon),
2954
+ react.createElement('div', { className: 'knj-node-dot' + (isRunning ? ' running' : ''), style: { background: meta.color } }, statusIcon),
2941
2955
  react.createElement('div', { style: { flex: 1, minWidth: 0 } },
2942
2956
  react.createElement('div', { className: 'knj-node-title' }, s.title),
2943
2957
  react.createElement('div', { className: 'knj-node-sub' }, dur ? `${dur} · ${meta.label}` : meta.label),
@@ -3233,7 +3247,7 @@ table.knj-tbl{width:100%;border-collapse:collapse;table-layout:fixed}
3233
3247
  task.model ? react.createElement('span', { className: 'knj-badge', style: { background: '#5b6472' }, title: '该任务固定的执行模型(创建时指定)' }, task.model) : null,
3234
3248
  ),
3235
3249
  // 整体进度条 + 摘要
3236
- react.createElement('div', { className: 'knj-progress', style: { width: '100%', marginTop: 12 } },
3250
+ react.createElement('div', { className: 'knj-progress' + (task.status === 'running' ? ' animated' : ''), style: { width: '100%', marginTop: 12 } },
3237
3251
  react.createElement('div', { style: { width: pct + '%' } }),
3238
3252
  ),
3239
3253
  react.createElement('div', { className: 'sub', style: { marginTop: 4 } }, `进度 ${pct}% · 完成 ${doneCount}/${relevant.length} 阶段${steps.length - relevant.length ? `(跳过 ${steps.length - relevant.length})` : ''}`),
package/lib/index.js CHANGED
@@ -627,6 +627,18 @@ export class WorkflowBridge {
627
627
  // sessionIds 无限增长(每次续跑 +N 条重复 id)
628
628
  const ids = s.sessionIds || [];
629
629
  if (!ids.includes(childId)) s.sessionIds = [...ids, childId];
630
+ // 真实执行信号:主体 subagent 启动 = 本节点正在跑。多轮复用同一节点时
631
+ // (如人工驳回 → fix 重做 → 再驳回 → fix 再重做),上一轮 done 会挡死状态:
632
+ // onPhase 只把 pending/skipped→running,onAgentEnd 只把 running→done,
633
+ // done 阶段被再次执行时永远停留在旧 done/旧 finishedAt(task-mtu18nd3 实测:
634
+ // 显示 fix 已完成 12:06,实际 checkpoint 12:31 才落盘)。这里以 agent-start
635
+ // 为唯一事实源强制置回 running:清 finishedAt、刷新 startedAt 为本轮纪元。
636
+ if (s.status !== 'running') {
637
+ s.status = 'running';
638
+ s.startedAt = nowIso();
639
+ s.finishedAt = undefined;
640
+ s.error = undefined;
641
+ }
630
642
  }
631
643
  }
632
644
  }).catch(() => {});
@@ -36,6 +36,12 @@ let humanFeedback = null; // 人工意见:注入去向目标节点(仅一
36
36
  // 直接走其最后去向(目标已在 initialResults 则被 skipSet 跳过;否则正常执行)。
37
37
  // 没有它,两个顺序 human 的任务会在 H1/H2 之间无限振荡(H2 的决策被 H1 的重新暂停吞掉)。
38
38
  const decidedMap = args.decided || {};
39
+ // 本轮已放行过一次去向的 human(消费当前决策 / 重放历史决策都算放行)。
40
+ // 去向把流程带回同一个 human(如驳回 → fix → submit → 同一 human 的回环)时,再次到达
41
+ // = 新一轮审批,必须暂停等人工,不能再用 decidedMap 里的旧决策放行第二次——
42
+ // 否则 fix/submit 被反复重执行直到 maxRuns 超限报错,人工节点永远无法重新触发
43
+ // (task-mttp8q71/task-mttz5ok7 实测:驳回后 fix/submit 空转 → 任务卡 running/失败)。
44
+ const humanReleased = new Set();
39
45
 
40
46
  /** 节点最大执行次数:显式配置 maxRuns 优先,默认 3(首次 + 最多 2 次重跑)。 */
41
47
  function maxRunsOf(node) {
@@ -182,7 +188,11 @@ const BUILTIN_ACTIONS = {
182
188
  function getCtx() {
183
189
  // 任务级变量:inputDescription(新建任务需求描述)、inputTitle(标题)、storyCode(用户故事编码)、
184
190
  // cwd(工作目录)、inputs(参数映射)并列,工作流任意 prompt/hook 参数可引用。
191
+ // taskId = task.id(流程实例唯一标识;同任务 resume/rerun 不变)——节点产出文件路径可用它拼接,
192
+ // 让不同任务实例的产物互相隔离(等价写法 ${task.id})。
185
193
  return {
194
+ task: { id: task.id },
195
+ taskId: task.id,
186
196
  inputs: args.inputs || {},
187
197
  inputDescription: task.description || '',
188
198
  inputTitle: task.title || '',
@@ -351,12 +361,16 @@ async function executeUntil(nodeId, joinId) {
351
361
  const route = matchRoute(node, decision.value);
352
362
  if (decision.feedback) humanFeedback = decision.feedback;
353
363
  skipSet.delete(route.to); // 去向目标应重新执行(接受人工意见),避免被断点跳过
364
+ humanReleased.add(node.id); // 本 walk 已放行过一次 → 回访即暂停(新一轮审批)
354
365
  current = route.to;
355
- } else if (decidedMap[node.id]) {
366
+ } else if (decidedMap[node.id] && !humanReleased.has(node.id)) {
356
367
  // 历史决策重放:这个 human 已审批过(如顺序两个 human,后一个决策触发 resume 重走图)。
357
368
  // 直接走最后去向:目标已在 initialResults → skipSet 跳过;未执行过 → 正常执行。
358
369
  // 不注入意见(当初决策后的执行已带过意见)。
370
+ // 每个 human 每轮 walk 只重放一次:去向若把流程带回同一 human(驳回回环),
371
+ // 第二次到达意味着要重新审批,直接暂停而不是再次放行旧决策(见 humanReleased 注释)。
359
372
  const route = matchRoute(node, decidedMap[node.id]);
373
+ humanReleased.add(node.id);
360
374
  current = route.to;
361
375
  } else {
362
376
  return { ok: true, current: null, paused: true, pausedAt: node.id };
@@ -694,6 +694,27 @@ test('orchestrator: 用户故事编码 storyCode 可引用(${storyCode})', a
694
694
  assert.ok(!capturedPrompt.includes('${storyCode}'), capturedPrompt);
695
695
  });
696
696
 
697
+ test('orchestrator: task.id 可引用(${taskId} 与 ${task.id}),可拼产出路径隔离实例产物', async () => {
698
+ const wf = {
699
+ id: 'wf-taskid', nodes: [
700
+ { id: 'start', type: 'start' },
701
+ { id: 'a', type: 'task', title: 'A', inputs: [], body: { prompt: '产物目录:${cwd}/out/${taskId}/,别名:${task.id}', mode: 'single', output: {} } },
702
+ { id: 'end', type: 'end' },
703
+ ],
704
+ edges: [{ from: 'start', to: 'a' }, { from: 'a', to: 'end' }],
705
+ };
706
+ let capturedPrompt = null;
707
+ const { result } = await runOrchestrator(wf, (label, prompt) => {
708
+ if (label === 'A') capturedPrompt = prompt;
709
+ return { ok: true };
710
+ }, { task: { id: 'task-mt1uc64i', title: 'T', taskDir: '.tasks/task-mt1uc64i', cwd: 'D:/ws/proj' } });
711
+ // 两种写法都解析为 task.id,拼接出的产出路径按实例隔离
712
+ assert.ok(capturedPrompt.includes('产物目录:D:/ws/proj/out/task-mt1uc64i/'), capturedPrompt);
713
+ assert.ok(capturedPrompt.includes('别名:task-mt1uc64i'), capturedPrompt);
714
+ assert.ok(!capturedPrompt.includes('${task'), capturedPrompt);
715
+ assert.equal(result.current, 'end');
716
+ });
717
+
697
718
  test('orchestrator: human reject 带 feedback → rejectTo 节点 prompt 含人工意见', async () => {
698
719
  let capturedPrompt = null;
699
720
  const { result, agentCalls } = await runOrchestrator(humanWorkflow(), (label, prompt) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-knj-workflow",
3
- "version": "2026.9.70",
3
+ "version": "2026.9.73",
4
4
  "description": "KNJ 开发任务编排插件:配置驱动的工作流 + 开发任务管理 + 可视化阶段进度(DeepSeek Harness)",
5
5
  "author": "knj",
6
6
  "license": "MIT",
@@ -37,12 +37,13 @@
37
37
  ],
38
38
  "peerDependencies": {
39
39
  "@deepseek-ai/cordis": "^4.0.1",
40
- "@deepseek-ai/dsh-agent": "0.1.2-rc.1",
41
- "@deepseek-ai/dsh-commands": "0.1.2-rc.1",
42
- "@deepseek-ai/dsh-host-webserver": "0.1.2-rc.1",
43
- "@deepseek-ai/dsh-session": "0.1.2-rc.1",
44
- "@deepseek-ai/dsh-tools": "0.1.2-rc.1",
45
- "@deepseek-ai/dsh-workflow": "0.1.2-rc.1"
40
+ "@deepseek-ai/dsh-agent": ">=0.1.1-rc.2 <0.1.2-0 || >=0.1.2-rc.1 <0.1.3-0",
41
+ "@deepseek-ai/dsh-commands": ">=0.1.1-rc.2 <0.1.2-0 || >=0.1.2-rc.1 <0.1.3-0",
42
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.1-rc.2 <0.1.2-0 || >=0.1.2-rc.1 <0.1.3-0",
43
+ "@deepseek-ai/dsh-session": ">=0.1.1-rc.2 <0.1.2-0 || >=0.1.2-rc.1 <0.1.3-0",
44
+ "@deepseek-ai/dsh-tools": ">=0.1.1-rc.2 <0.1.2-0 || >=0.1.2-rc.1 <0.1.3-0",
45
+ "@deepseek-ai/dsh-workflow": ">=0.1.1-rc.2 <0.1.2-0 || >=0.1.2-rc.1 <0.1.3-0",
46
+ "@deepseek-ai/schemastery": ">=3.18.0 <4.0.0"
46
47
  },
47
48
  "devDependencies": {
48
49
  "@deepseek-ai/cordis": "4.0.1",