@meiyukichan/dsh-sandore 0.1.0-rc.6
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/LICENSE +201 -0
- package/cordis.patch.yml +39 -0
- package/dsh.plugin.json +16 -0
- package/lib/client/CommandEditor.d.ts +19 -0
- package/lib/client/DagEditor.d.ts +22 -0
- package/lib/client/DefinitionDetail.d.ts +13 -0
- package/lib/client/DefinitionList.d.ts +19 -0
- package/lib/client/DiscussionInput.d.ts +63 -0
- package/lib/client/DragHandle.d.ts +16 -0
- package/lib/client/EditorPane.d.ts +13 -0
- package/lib/client/FlowEditor.d.ts +31 -0
- package/lib/client/InstanceList.d.ts +24 -0
- package/lib/client/NewInstanceDialog.d.ts +19 -0
- package/lib/client/PreviewPanel.d.ts +13 -0
- package/lib/client/RunView.d.ts +79 -0
- package/lib/client/StepChatPanel.d.ts +34 -0
- package/lib/client/TreeNav.d.ts +27 -0
- package/lib/client/UnifiedTimeline.d.ts +11 -0
- package/lib/client/WorkflowButton.d.ts +11 -0
- package/lib/client/WorkflowList.d.ts +18 -0
- package/lib/client/WorkflowOverlay.d.ts +6 -0
- package/lib/client/chat/AssistantMarkdown.d.ts +22 -0
- package/lib/client/chat/CompactionItem.d.ts +24 -0
- package/lib/client/chat/ContextBody.d.ts +136 -0
- package/lib/client/chat/ContextInjectionRow.d.ts +25 -0
- package/lib/client/chat/GenericCommandCard.d.ts +10 -0
- package/lib/client/chat/MessageIconActions.d.ts +41 -0
- package/lib/client/chat/MessageItem.d.ts +65 -0
- package/lib/client/chat/ReasoningRow.d.ts +13 -0
- package/lib/client/chat/TurnTailNodeView.d.ts +29 -0
- package/lib/client/chat/UserMessageRow.d.ts +18 -0
- package/lib/client/chat/attachment/ImageLightbox.d.ts +26 -0
- package/lib/client/chat/attachment/MessageImage.d.ts +56 -0
- package/lib/client/chat/attachment/image-attachment.d.ts +41 -0
- package/lib/client/chat/attachment/image-labels.d.ts +21 -0
- package/lib/client/chat/message-chrome.d.ts +51 -0
- package/lib/client/chat/reference/ReferenceIcon.d.ts +20 -0
- package/lib/client/chat/turn-assistant.d.ts +12 -0
- package/lib/client/chat/turn-metrics.d.ts +42 -0
- package/lib/client/chat/use-calendar-day.d.ts +5 -0
- package/lib/client/chat/use-throttled-visual-update.d.ts +7 -0
- package/lib/client/chat-flow.d.ts +110 -0
- package/lib/client/composer-selects.d.ts +17 -0
- package/lib/client/dag-editor-model.d.ts +141 -0
- package/lib/client/describe-error.d.ts +9 -0
- package/lib/client/index.d.ts +6 -0
- package/lib/client/locales.d.ts +777 -0
- package/lib/client/model-faces.d.ts +156 -0
- package/lib/client/open-discussion.d.ts +24 -0
- package/lib/client/panel-model.d.ts +34 -0
- package/lib/client/remote.d.ts +34 -0
- package/lib/client/run-view-model.d.ts +304 -0
- package/lib/client/sandore-save.d.ts +41 -0
- package/lib/client/sandore-sse.d.ts +15 -0
- package/lib/client/step-chat-retry.d.ts +44 -0
- package/lib/client/tool/GenericToolCard.d.ts +28 -0
- package/lib/client/tool/ToolRow.d.ts +95 -0
- package/lib/client/tool/diff-card-model.d.ts +63 -0
- package/lib/client/tool/read-card-model.d.ts +67 -0
- package/lib/client/tool/search-card-model.d.ts +94 -0
- package/lib/client/tool/terminal-card-model.d.ts +86 -0
- package/lib/client/tool/tool-call-model.d.ts +74 -0
- package/lib/client/tool/web-card-model.d.ts +44 -0
- package/lib/client/use-focus-refresh.d.ts +1 -0
- package/lib/client/workflow-face.d.ts +68 -0
- package/lib/client/workflow-store.d.ts +80 -0
- package/lib/client/workspace-path.d.ts +15 -0
- package/lib/client.js +38389 -0
- package/lib/index.js +8854 -0
- package/lib/microflow/branch-evaluator.d.ts +290 -0
- package/lib/microflow/concurrency-limiter.d.ts +39 -0
- package/lib/microflow/errors.d.ts +39 -0
- package/lib/microflow/file-utils.d.ts +210 -0
- package/lib/microflow/finalize-manager.d.ts +414 -0
- package/lib/microflow/fork-session.d.ts +151 -0
- package/lib/microflow/index.d.ts +17 -0
- package/lib/microflow/internal.d.ts +56 -0
- package/lib/microflow/loop-manager.d.ts +127 -0
- package/lib/microflow/rules-resolver.d.ts +28 -0
- package/lib/microflow/stage-executor.d.ts +362 -0
- package/lib/microflow/step-navigator.d.ts +108 -0
- package/lib/microflow/step-runner.d.ts +190 -0
- package/lib/microflow/subagents.d.ts +380 -0
- package/lib/tool/index.d.ts +65 -0
- package/lib/typert.host.d.ts +16 -0
- package/lib/typert.host.js +25 -0
- package/lib/typert.remote-client.d.ts +114 -0
- package/lib/typert.remote-client.js +399 -0
- package/lib/workflow/ask-tool.d.ts +78 -0
- package/lib/workflow/dag-navigator.d.ts +136 -0
- package/lib/workflow/domain.d.ts +304 -0
- package/lib/workflow/engine-surface.d.ts +70 -0
- package/lib/workflow/errors.d.ts +13 -0
- package/lib/workflow/host-agent.d.ts +53 -0
- package/lib/workflow/index.d.ts +23 -0
- package/lib/workflow/interaction-manager.d.ts +170 -0
- package/lib/workflow/lifecycle-manager.d.ts +111 -0
- package/lib/workflow/persistence.d.ts +35 -0
- package/lib/workflow/plugin.d.ts +27 -0
- package/lib/workflow/reactive-state.d.ts +27 -0
- package/lib/workflow/recovery-manager.d.ts +158 -0
- package/lib/workflow/remote.d.ts +139 -0
- package/lib/workflow/schema.d.ts +1917 -0
- package/lib/workflow/seam.d.ts +254 -0
- package/lib/workflow/shell.d.ts +127 -0
- package/lib/workflow/sse.d.ts +16 -0
- package/lib/workflow/typert/typert.host.d.ts +16 -0
- package/lib/workflow/typert/typert.remote-client.d.ts +114 -0
- package/lib/workflow/types.d.ts +187 -0
- package/lib/workflow/wire.d.ts +256 -0
- package/lib/workflow/workflow-engine.d.ts +373 -0
- package/lib/workflow/yaml-error.d.ts +22 -0
- package/lib/workflow/yaml-parser.d.ts +23 -0
- package/package.json +140 -0
- package/packages/client/CommandEditor.tsx +73 -0
- package/packages/client/DagEditor.module.css +711 -0
- package/packages/client/DagEditor.tsx +1203 -0
- package/packages/client/DefinitionDetail.module.css +110 -0
- package/packages/client/DefinitionDetail.tsx +126 -0
- package/packages/client/DefinitionList.tsx +173 -0
- package/packages/client/DiscussionInput.tsx +763 -0
- package/packages/client/DragHandle.tsx +92 -0
- package/packages/client/EditorPane.tsx +70 -0
- package/packages/client/FlowEditor.tsx +227 -0
- package/packages/client/InstanceList.tsx +250 -0
- package/packages/client/NewInstanceDialog.module.css +244 -0
- package/packages/client/NewInstanceDialog.tsx +478 -0
- package/packages/client/PreviewPanel.tsx +55 -0
- package/packages/client/RunView.module.css +246 -0
- package/packages/client/RunView.tsx +314 -0
- package/packages/client/StepChatPanel.tsx +486 -0
- package/packages/client/TreeNav.tsx +294 -0
- package/packages/client/UnifiedTimeline.tsx +484 -0
- package/packages/client/WorkflowButton.module.css +72 -0
- package/packages/client/WorkflowButton.tsx +50 -0
- package/packages/client/WorkflowList.tsx +158 -0
- package/packages/client/WorkflowOverlay.module.css +390 -0
- package/packages/client/WorkflowOverlay.tsx +136 -0
- package/packages/client/WorkflowPanel.module.css +247 -0
- package/packages/client/chat/AssistantMarkdown.module.css +38 -0
- package/packages/client/chat/AssistantMarkdown.tsx +120 -0
- package/packages/client/chat/ChatView.module.css +198 -0
- package/packages/client/chat/CompactionItem.tsx +85 -0
- package/packages/client/chat/ContextBody.module.css +162 -0
- package/packages/client/chat/ContextBody.tsx +598 -0
- package/packages/client/chat/ContextInjectionRow.module.css +63 -0
- package/packages/client/chat/ContextInjectionRow.tsx +78 -0
- package/packages/client/chat/GenericCommandCard.module.css +87 -0
- package/packages/client/chat/GenericCommandCard.tsx +76 -0
- package/packages/client/chat/MessageIconActions.module.css +84 -0
- package/packages/client/chat/MessageIconActions.tsx +147 -0
- package/packages/client/chat/MessageItem.module.css +293 -0
- package/packages/client/chat/MessageItem.tsx +384 -0
- package/packages/client/chat/ReasoningRow.module.css +82 -0
- package/packages/client/chat/ReasoningRow.tsx +70 -0
- package/packages/client/chat/TurnTailNodeView.module.css +10 -0
- package/packages/client/chat/TurnTailNodeView.tsx +63 -0
- package/packages/client/chat/UserMessageRow.module.css +75 -0
- package/packages/client/chat/UserMessageRow.tsx +92 -0
- package/packages/client/chat/accessibility.module.css +9 -0
- package/packages/client/chat/attachment/ImageLightbox.module.css +45 -0
- package/packages/client/chat/attachment/ImageLightbox.tsx +70 -0
- package/packages/client/chat/attachment/MessageImage.module.css +70 -0
- package/packages/client/chat/attachment/MessageImage.tsx +136 -0
- package/packages/client/chat/attachment/image-attachment.ts +98 -0
- package/packages/client/chat/attachment/image-labels.ts +34 -0
- package/packages/client/chat/message-chrome.ts +96 -0
- package/packages/client/chat/reference/ReferenceIcon.tsx +40 -0
- package/packages/client/chat/turn-assistant.ts +15 -0
- package/packages/client/chat/turn-metrics.ts +102 -0
- package/packages/client/chat/use-calendar-day.ts +27 -0
- package/packages/client/chat/use-throttled-visual-update.ts +44 -0
- package/packages/client/chat-flow.spec.ts +357 -0
- package/packages/client/chat-flow.ts +278 -0
- package/packages/client/composer-selects.module.css +376 -0
- package/packages/client/composer-selects.tsx +531 -0
- package/packages/client/dag-editor-model.spec.ts +173 -0
- package/packages/client/dag-editor-model.ts +359 -0
- package/packages/client/describe-error.spec.ts +18 -0
- package/packages/client/describe-error.ts +12 -0
- package/packages/client/index.ts +144 -0
- package/packages/client/input.module.css +854 -0
- package/packages/client/is-conditional-check.spec.ts +34 -0
- package/packages/client/locales.ts +789 -0
- package/packages/client/model-faces.ts +225 -0
- package/packages/client/open-discussion.spec.ts +39 -0
- package/packages/client/open-discussion.ts +32 -0
- package/packages/client/panel-model.spec.ts +152 -0
- package/packages/client/panel-model.ts +72 -0
- package/packages/client/preview.module.css +95 -0
- package/packages/client/react-dom.d.ts +11 -0
- package/packages/client/reactflow-theme.css +91 -0
- package/packages/client/remote.spec.ts +41 -0
- package/packages/client/remote.ts +46 -0
- package/packages/client/run-view-model.spec.ts +641 -0
- package/packages/client/run-view-model.ts +636 -0
- package/packages/client/sandore-save.spec.ts +146 -0
- package/packages/client/sandore-save.ts +97 -0
- package/packages/client/sandore-sse.ts +59 -0
- package/packages/client/step-chat-retry.spec.ts +70 -0
- package/packages/client/step-chat-retry.ts +65 -0
- package/packages/client/timeline.module.css +503 -0
- package/packages/client/tool/GenericToolCard.tsx +99 -0
- package/packages/client/tool/ToolRow.module.css +308 -0
- package/packages/client/tool/ToolRow.tsx +328 -0
- package/packages/client/tool/diff-card-model.ts +103 -0
- package/packages/client/tool/read-card-model.ts +86 -0
- package/packages/client/tool/search-card-model.ts +166 -0
- package/packages/client/tool/terminal-card-model.ts +225 -0
- package/packages/client/tool/tool-call-model.ts +267 -0
- package/packages/client/tool/web-card-model.ts +80 -0
- package/packages/client/tree-nav.module.css +282 -0
- package/packages/client/use-focus-refresh.ts +23 -0
- package/packages/client/workflow-face.ts +72 -0
- package/packages/client/workflow-store.spec.ts +49 -0
- package/packages/client/workflow-store.ts +161 -0
- package/packages/client/workspace-path.ts +27 -0
- package/packages/css-modules.d.ts +6 -0
- package/packages/microflow/branch-evaluator.spec.ts +745 -0
- package/packages/microflow/branch-evaluator.ts +1037 -0
- package/packages/microflow/concurrency-limiter.ts +70 -0
- package/packages/microflow/errors.ts +64 -0
- package/packages/microflow/file-utils.spec.ts +604 -0
- package/packages/microflow/file-utils.ts +521 -0
- package/packages/microflow/finalize-manager.spec.ts +1977 -0
- package/packages/microflow/finalize-manager.ts +1464 -0
- package/packages/microflow/fork-session.spec.ts +184 -0
- package/packages/microflow/fork-session.ts +316 -0
- package/packages/microflow/index.ts +17 -0
- package/packages/microflow/internal.ts +166 -0
- package/packages/microflow/loop-manager.spec.ts +294 -0
- package/packages/microflow/loop-manager.ts +255 -0
- package/packages/microflow/rules-resolver.spec.ts +147 -0
- package/packages/microflow/rules-resolver.ts +58 -0
- package/packages/microflow/stage-executor.spec.ts +1577 -0
- package/packages/microflow/stage-executor.ts +1478 -0
- package/packages/microflow/step-navigator.spec.ts +301 -0
- package/packages/microflow/step-navigator.ts +300 -0
- package/packages/microflow/step-runner.spec.ts +1132 -0
- package/packages/microflow/step-runner.ts +752 -0
- package/packages/microflow/subagents.spec.ts +215 -0
- package/packages/microflow/subagents.ts +422 -0
- package/packages/tests/fixtures/test-workflow.yaml +128 -0
- package/packages/tests/mocks/agents.ts +206 -0
- package/packages/tests/mocks/llm.ts +46 -0
- package/packages/tests/mocks/session-title.ts +31 -0
- package/packages/tests/mocks/sessions.ts +131 -0
- package/packages/tests/mocks/shell.ts +60 -0
- package/packages/tests/mocks/storage-domain.ts +101 -0
- package/packages/tests/mocks/subagents.ts +197 -0
- package/packages/tests/mocks/subprocess.ts +59 -0
- package/packages/tests/mocks/user-questions.ts +33 -0
- package/packages/tests/mocks/workspace.ts +52 -0
- package/packages/tool/index.spec.ts +287 -0
- package/packages/tool/index.ts +348 -0
- package/packages/workflow/ask-tool.ts +173 -0
- package/packages/workflow/dag-navigator.spec.ts +236 -0
- package/packages/workflow/dag-navigator.ts +425 -0
- package/packages/workflow/domain.ts +26 -0
- package/packages/workflow/engine-surface.ts +71 -0
- package/packages/workflow/errors.ts +13 -0
- package/packages/workflow/host-agent.spec.ts +92 -0
- package/packages/workflow/host-agent.ts +62 -0
- package/packages/workflow/index.ts +25 -0
- package/packages/workflow/interaction-manager.spec.ts +390 -0
- package/packages/workflow/interaction-manager.ts +397 -0
- package/packages/workflow/lifecycle-manager.spec.ts +683 -0
- package/packages/workflow/lifecycle-manager.ts +288 -0
- package/packages/workflow/persistence.spec.ts +109 -0
- package/packages/workflow/persistence.ts +64 -0
- package/packages/workflow/plugin.ts +39 -0
- package/packages/workflow/reactive-state.spec.ts +69 -0
- package/packages/workflow/reactive-state.ts +52 -0
- package/packages/workflow/recovery-manager.spec.ts +606 -0
- package/packages/workflow/recovery-manager.ts +471 -0
- package/packages/workflow/remote.spec.ts +1045 -0
- package/packages/workflow/remote.ts +479 -0
- package/packages/workflow/schema.spec.ts +303 -0
- package/packages/workflow/schema.ts +562 -0
- package/packages/workflow/seam.ts +293 -0
- package/packages/workflow/shell.ts +142 -0
- package/packages/workflow/sse.ts +110 -0
- package/packages/workflow/typert/typert.host.d.ts +16 -0
- package/packages/workflow/typert/typert.host.js +25 -0
- package/packages/workflow/typert/typert.remote-client.d.ts +114 -0
- package/packages/workflow/typert/typert.remote-client.js +399 -0
- package/packages/workflow/types.ts +258 -0
- package/packages/workflow/wire.ts +267 -0
- package/packages/workflow/workflow-engine-e2e.spec.ts +262 -0
- package/packages/workflow/workflow-engine-real-e2e.spec.ts +922 -0
- package/packages/workflow/workflow-engine.spec.ts +4389 -0
- package/packages/workflow/workflow-engine.ts +2133 -0
- package/packages/workflow/yaml-error.ts +29 -0
- package/packages/workflow/yaml-parser.spec.ts +757 -0
- package/packages/workflow/yaml-parser.ts +287 -0
|
@@ -0,0 +1,1577 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stage-executor.ts(StageExecutor)的单元测试:段模型核心不变量。
|
|
3
|
+
*
|
|
4
|
+
* 覆盖 §3.3–§3.5 定夺的段创建规则:
|
|
5
|
+
* - executedSteps 条目的 `id` 为纯 UUID,且每次执行段唯一(同 Stage 内两个 Step
|
|
6
|
+
* 各自新 id)
|
|
7
|
+
* - `sessionMeta.childId`(子代理占位/真实会话 id)与段 `id` **解耦**(互不相等、
|
|
8
|
+
* 语义分离)
|
|
9
|
+
* - 命令节点段携带 `createdAt` 与 `attemptIndex: 0`(时间轴排序键)
|
|
10
|
+
* - pause 续跑(resumePendingPaused)基于旧段**新建段对象**:新 id + attemptIndex+1
|
|
11
|
+
* + createdAt,旧 paused 段保持定格快照
|
|
12
|
+
* @module dsh-sandore/microflow/src/stage-executor.spec
|
|
13
|
+
*/
|
|
14
|
+
import { Context } from '@deepseek-ai/cordis';
|
|
15
|
+
import type { ContentBlock, LlmRuntime } from '@deepseek-ai/dsh-llm';
|
|
16
|
+
import type { SessionStore } from '@deepseek-ai/dsh-session';
|
|
17
|
+
import { describe, expect, it } from 'vitest';
|
|
18
|
+
|
|
19
|
+
import type { WorkflowDefinition, WorkflowStage, WorkflowStep, WorkflowTaskState } from '../workflow/schema';
|
|
20
|
+
import { assistantMessageEvent, FakeAgent, FakeAgentRegistry } from '../tests/mocks/agents.ts';
|
|
21
|
+
import { FakeLlm, textChunks } from '../tests/mocks/llm.ts';
|
|
22
|
+
import { FakeShell } from '../tests/mocks/shell.ts';
|
|
23
|
+
import { FakeSubagents } from '../tests/mocks/subagents.ts';
|
|
24
|
+
import { FakeSubprocess } from '../tests/mocks/subprocess.ts';
|
|
25
|
+
import type { ConcurrencyLimiter } from './concurrency-limiter.ts';
|
|
26
|
+
import { FinalizeManager } from './finalize-manager.ts';
|
|
27
|
+
import type { FinalizeManagerConfig } from './finalize-manager.ts';
|
|
28
|
+
import type { FileSystem, FsDirEntry, FsTarget, FsWriteOutcome } from './file-utils.ts';
|
|
29
|
+
import type { LoopState } from './loop-manager.ts';
|
|
30
|
+
import { StageExecutor } from './stage-executor.ts';
|
|
31
|
+
import type { StageExecutorOptions, StageExecutorResume } from './stage-executor.ts';
|
|
32
|
+
import { StepNavigator } from './step-navigator.ts';
|
|
33
|
+
import type { StepRunnerConfig } from './step-runner.ts';
|
|
34
|
+
import type { SubagentSettlement, SubagentStopReason } from './subagents.ts';
|
|
35
|
+
|
|
36
|
+
/** 纯 UUID 形状(8-4-4-4-12 十六进制)。 */
|
|
37
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
38
|
+
|
|
39
|
+
/** 内存文件系统(target 导向两段式协议):与 file-utils.spec / finalize-manager.spec 一致。 */
|
|
40
|
+
class FakeFs implements FileSystem {
|
|
41
|
+
readonly files = new Map<string, string>();
|
|
42
|
+
readonly dirs = new Set<string>();
|
|
43
|
+
|
|
44
|
+
seedFile(path: string, content = ''): void {
|
|
45
|
+
this.files.set(path, content);
|
|
46
|
+
const segments = path.split('/');
|
|
47
|
+
for (let i = 1; i < segments.length; i++) {
|
|
48
|
+
this.dirs.add(segments.slice(0, i).join('/'));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async resolve(path: string): Promise<FsTarget> {
|
|
53
|
+
const normalized = path.replace(/\/+$/, '') || '/';
|
|
54
|
+
return { targetKey: normalized, displayPath: normalized };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
processPath(target: FsTarget): string {
|
|
58
|
+
return target.displayPath;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async listDir(target: FsTarget): Promise<FsDirEntry[]> {
|
|
62
|
+
const prefix = target.targetKey === '/' ? '' : `${target.targetKey}/`;
|
|
63
|
+
const names = new Set<string>();
|
|
64
|
+
for (const dir of this.dirs) {
|
|
65
|
+
if (dir.startsWith(prefix)) {
|
|
66
|
+
const name = dir.slice(prefix.length).split('/')[0]!;
|
|
67
|
+
if (name !== '') names.add(name);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const file of this.files.keys()) {
|
|
71
|
+
if (file.startsWith(prefix)) {
|
|
72
|
+
const name = file.slice(prefix.length).split('/')[0]!;
|
|
73
|
+
if (name !== '') names.add(name);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return [...names].sort().map(name => {
|
|
77
|
+
const full = prefix + name;
|
|
78
|
+
const isDir = this.dirs.has(full);
|
|
79
|
+
return { name, type: isDir ? 'directory' : 'file', target: { targetKey: full, displayPath: full } };
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async readText(target: FsTarget): Promise<string> {
|
|
84
|
+
const content = this.files.get(target.targetKey);
|
|
85
|
+
if (content === undefined) throw new Error(`FS_NOT_FOUND: ${target.displayPath}`);
|
|
86
|
+
return content;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async writeText(target: FsTarget, content: string): Promise<FsWriteOutcome> {
|
|
90
|
+
const before = this.files.get(target.targetKey) ?? null;
|
|
91
|
+
this.seedFile(target.targetKey, content);
|
|
92
|
+
return { operation: before === null ? 'create' : 'update', version: 'v2', before, after: content };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 组装一个单 Stage 工作流定义(shell Step,无门禁/无透传变量 → finalize 直接 done)。 */
|
|
97
|
+
function singleShellDefinition(): { definition: WorkflowDefinition; stage: WorkflowStage; step: WorkflowStep } {
|
|
98
|
+
const step: WorkflowStep = {
|
|
99
|
+
id: 'step-sh',
|
|
100
|
+
name: 'echo',
|
|
101
|
+
command: { type: 'shell', value: { command: 'echo hi' } },
|
|
102
|
+
};
|
|
103
|
+
const stage: WorkflowStage = {
|
|
104
|
+
id: 'stg-1',
|
|
105
|
+
name: 'Stage 1',
|
|
106
|
+
steps: [{ id: 'step-sh' }],
|
|
107
|
+
};
|
|
108
|
+
const definition: WorkflowDefinition = {
|
|
109
|
+
id: 'wfl-1',
|
|
110
|
+
name: 'wfl-1',
|
|
111
|
+
version: '1',
|
|
112
|
+
stages: [stage],
|
|
113
|
+
steps: [step],
|
|
114
|
+
settings: {},
|
|
115
|
+
};
|
|
116
|
+
return { definition, stage, step };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 组装 StageExecutor 测试环境。 */
|
|
120
|
+
function harness(): {
|
|
121
|
+
ctx: Context;
|
|
122
|
+
llm: FakeLlm;
|
|
123
|
+
shell: FakeShell;
|
|
124
|
+
subagents: FakeSubagents;
|
|
125
|
+
subprocess: FakeSubprocess;
|
|
126
|
+
fs: FakeFs;
|
|
127
|
+
agents: FakeAgentRegistry;
|
|
128
|
+
makeExecutor: (options?: Partial<StageExecutorOptions>, finalizeConfig?: FinalizeManagerConfig) => StageExecutor;
|
|
129
|
+
} {
|
|
130
|
+
const ctx = new Context();
|
|
131
|
+
const llm = new FakeLlm();
|
|
132
|
+
const shell = new FakeShell();
|
|
133
|
+
const subagents = new FakeSubagents();
|
|
134
|
+
const subprocess = new FakeSubprocess();
|
|
135
|
+
const fs = new FakeFs();
|
|
136
|
+
const agents = new FakeAgentRegistry();
|
|
137
|
+
ctx.provide('llm', llm as unknown as LlmRuntime);
|
|
138
|
+
ctx.provide('shell', shell);
|
|
139
|
+
ctx.provide('subagents', subagents);
|
|
140
|
+
ctx.provide('subprocess', subprocess);
|
|
141
|
+
ctx.provide('sessions', {} as unknown as SessionStore);
|
|
142
|
+
ctx.provide('agents', agents);
|
|
143
|
+
ctx.provide('fs', fs as unknown as FileSystem);
|
|
144
|
+
|
|
145
|
+
const makeExecutor = (options?: Partial<StageExecutorOptions>, finalizeConfig?: FinalizeManagerConfig): StageExecutor => {
|
|
146
|
+
const { definition, stage } = singleShellDefinition();
|
|
147
|
+
const stageDef = options?.stageDef ?? stage;
|
|
148
|
+
const workflowDef = options?.workflowDef ?? definition;
|
|
149
|
+
const loopState: LoopState = { activeLoopStack: [], activeLoopIterationStack: [] };
|
|
150
|
+
// Step 级路由已归 microflow:用真实 StepNavigator(持当前 Stage Step 节点图 + 循环管理器),
|
|
151
|
+
// 不再经 options 注入 resolveNextNodes。
|
|
152
|
+
const stepNavigator = new StepNavigator(ctx, { defaultTimeoutMs: 60000 }, loopState, stageDef, workflowDef);
|
|
153
|
+
const loopManager = stepNavigator.loopManagerInstance;
|
|
154
|
+
const config: FinalizeManagerConfig = {
|
|
155
|
+
defaultTimeoutMs: 60000,
|
|
156
|
+
defaultProvider: 'p',
|
|
157
|
+
defaultModel: 'm',
|
|
158
|
+
...(finalizeConfig ?? {}),
|
|
159
|
+
};
|
|
160
|
+
const finalizeManager = new FinalizeManager(ctx, config);
|
|
161
|
+
const runnerConfig: StepRunnerConfig = {
|
|
162
|
+
defaultTimeoutMs: 60000,
|
|
163
|
+
...(options?.runnerConfig === undefined ? {} : options.runnerConfig),
|
|
164
|
+
};
|
|
165
|
+
return new StageExecutor({
|
|
166
|
+
ctx,
|
|
167
|
+
stageDef,
|
|
168
|
+
workflowDef,
|
|
169
|
+
input: { originalInput: 'do it', contractArtifacts: {}, passThroughVars: {} },
|
|
170
|
+
sharedState: { cwd: '/tmp', taskId: 'task-1' },
|
|
171
|
+
runnerConfig,
|
|
172
|
+
loopManager,
|
|
173
|
+
stepNavigator,
|
|
174
|
+
finalizeManager,
|
|
175
|
+
...(options?.hooks === undefined ? {} : { hooks: options.hooks }),
|
|
176
|
+
...(options?.concurrencyLimiter === undefined ? {} : { concurrencyLimiter: options.concurrencyLimiter }),
|
|
177
|
+
visitCounts: {},
|
|
178
|
+
...(options?.resumeFrom === undefined ? {} : { resumeFrom: options.resumeFrom }),
|
|
179
|
+
// visitCounts 注入透传(崩溃恢复测试需要播种快照计数;缺省空对象)。
|
|
180
|
+
...(options?.visitCounts === undefined ? {} : { visitCounts: options.visitCounts }),
|
|
181
|
+
onPersist: options?.onPersist ?? (async () => {}),
|
|
182
|
+
...(options?.commitState === undefined ? {} : { commitState: options.commitState }),
|
|
183
|
+
});
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
return { ctx, llm, shell, subagents, subprocess, fs, agents, makeExecutor };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
describe('StageExecutor 段模型不变量', () => {
|
|
190
|
+
it('shell Step 执行产生的段 id 为纯 UUID', async () => {
|
|
191
|
+
const { shell, makeExecutor } = harness();
|
|
192
|
+
shell.runHandler = async spec => ({ exitCode: 0, stdout: 'hi', stderr: '' });
|
|
193
|
+
|
|
194
|
+
const executor = makeExecutor();
|
|
195
|
+
const result = await executor.execute();
|
|
196
|
+
|
|
197
|
+
expect(result.status).toBe('completed');
|
|
198
|
+
expect(result.executedSteps).toHaveLength(1);
|
|
199
|
+
const segment = result.executedSteps[0]!;
|
|
200
|
+
expect(segment.id).toMatch(UUID_RE);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('同 Stage 两个 Step 各自生成唯一段 id(每次执行段唯一)', async () => {
|
|
204
|
+
const { definition, stage } = singleShellDefinition();
|
|
205
|
+
// 扩展为两个 shell Step。
|
|
206
|
+
const secondStep: WorkflowStep = {
|
|
207
|
+
id: 'step-sh2',
|
|
208
|
+
name: 'echo2',
|
|
209
|
+
command: { type: 'shell', value: { command: 'echo again' } },
|
|
210
|
+
};
|
|
211
|
+
const multiStage: WorkflowStage = {
|
|
212
|
+
...stage,
|
|
213
|
+
steps: [{ id: 'step-sh' }, { id: 'step-sh2' }],
|
|
214
|
+
};
|
|
215
|
+
const multiDefinition: WorkflowDefinition = {
|
|
216
|
+
...definition,
|
|
217
|
+
stages: [multiStage],
|
|
218
|
+
steps: [...(definition.steps ?? []), secondStep],
|
|
219
|
+
};
|
|
220
|
+
const { shell, makeExecutor } = harness();
|
|
221
|
+
shell.runHandler = async spec => ({ exitCode: 0, stdout: spec.command, stderr: '' });
|
|
222
|
+
|
|
223
|
+
// 真实 StepNavigator 路由:两 Step 均无 branches → 数组顺序兜底,
|
|
224
|
+
// step-sh 解析下一批 → step-sh2,step-sh2 无后续 → 停止。
|
|
225
|
+
const executor = makeExecutor({
|
|
226
|
+
stageDef: multiStage,
|
|
227
|
+
workflowDef: multiDefinition,
|
|
228
|
+
});
|
|
229
|
+
const result = await executor.execute();
|
|
230
|
+
|
|
231
|
+
expect(result.executedSteps).toHaveLength(2);
|
|
232
|
+
const ids = result.executedSteps.map(segment => segment.id);
|
|
233
|
+
expect(ids[0]).toMatch(UUID_RE);
|
|
234
|
+
expect(ids[1]).toMatch(UUID_RE);
|
|
235
|
+
expect(ids[0]).not.toBe(ids[1]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('sessionMeta.childId 与段 id 解耦(合成占位 ≠ 执行段 id)', async () => {
|
|
239
|
+
const { shell, makeExecutor } = harness();
|
|
240
|
+
shell.runHandler = async spec => ({ exitCode: 0, stdout: 'hi', stderr: '' });
|
|
241
|
+
|
|
242
|
+
const executor = makeExecutor();
|
|
243
|
+
const result = await executor.execute();
|
|
244
|
+
|
|
245
|
+
const segment = result.executedSteps[0]!;
|
|
246
|
+
// shell 为合成会话:childId 是 step-{stepId}-{hex} 占位,与段 id(UUID)不同。
|
|
247
|
+
expect(segment.sessionMeta?.synthetic).toBe(true);
|
|
248
|
+
expect(segment.sessionMeta?.childId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
|
249
|
+
expect(segment.sessionMeta?.childId).not.toBe(segment.id);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('命令节点段携带 createdAt 与 attemptIndex: 0(时间轴排序键)', async () => {
|
|
253
|
+
const { shell, makeExecutor } = harness();
|
|
254
|
+
shell.runHandler = async spec => ({ exitCode: 0, stdout: 'hi', stderr: '' });
|
|
255
|
+
|
|
256
|
+
const executor = makeExecutor();
|
|
257
|
+
const result = await executor.execute();
|
|
258
|
+
|
|
259
|
+
const segment = result.executedSteps[0]!;
|
|
260
|
+
expect(segment.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
261
|
+
expect(segment.attemptIndex).toBe(0);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('崩溃恢复/重试续跑(resumeFrom)从指定子代理 Step 播种,跳过已完成 Step', async () => {
|
|
265
|
+
const { definition, stage } = singleShellDefinition();
|
|
266
|
+
const agentStep: WorkflowStep = {
|
|
267
|
+
id: 'step-sh',
|
|
268
|
+
name: 'agent',
|
|
269
|
+
command: { type: 'agent', value: { promptText: 'do work' } },
|
|
270
|
+
};
|
|
271
|
+
const completed: WorkflowTaskState['executedSteps'][number] = {
|
|
272
|
+
id: 'exec-1',
|
|
273
|
+
stepId: 'step-sh',
|
|
274
|
+
stageId: 'stg-1',
|
|
275
|
+
name: 'agent',
|
|
276
|
+
status: 'completed',
|
|
277
|
+
command: { type: 'agent', value: { promptText: 'do work' } },
|
|
278
|
+
sessionMeta: { childId: 'child-1', synthetic: false, command: 'agent' },
|
|
279
|
+
visitCount: 1,
|
|
280
|
+
createdAt: '2026-08-16T00:00:00.000Z',
|
|
281
|
+
attemptIndex: 0,
|
|
282
|
+
output: [{ type: 'text', text: 'first' }],
|
|
283
|
+
};
|
|
284
|
+
const resumeFrom: StageExecutorResume = {
|
|
285
|
+
completedSteps: [completed],
|
|
286
|
+
resumeSubagents: [{
|
|
287
|
+
step: agentStep,
|
|
288
|
+
executed: { ...completed, id: 'exec-2', status: 'running', attemptIndex: 1, createdAt: '2026-08-16T00:00:01.000Z' },
|
|
289
|
+
childId: 'child-1',
|
|
290
|
+
}],
|
|
291
|
+
};
|
|
292
|
+
const { agents, makeExecutor } = harness();
|
|
293
|
+
// 注册续跑的子代理(whenIdle 后取 final output)。
|
|
294
|
+
const child = new FakeAgent('child-1');
|
|
295
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'resumed output' }]));
|
|
296
|
+
agents.register(child);
|
|
297
|
+
|
|
298
|
+
const executor = makeExecutor({
|
|
299
|
+
stageDef: stage,
|
|
300
|
+
workflowDef: definition,
|
|
301
|
+
resumeFrom,
|
|
302
|
+
});
|
|
303
|
+
const result = await executor.execute();
|
|
304
|
+
|
|
305
|
+
// 播种:completed 段进 executedSteps 快照(不重跑),续跑段完成后并入。
|
|
306
|
+
expect(result.status).toBe('completed');
|
|
307
|
+
expect(result.executedSteps.some(segment => segment.id === 'exec-1')).toBe(true);
|
|
308
|
+
expect(result.executedSteps.some(segment => segment.id === 'exec-2')).toBe(true);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('between-steps 崩溃续跑(resumeBetweenSteps)从最后完成 Step 反推 DAG next,不重跑已完成 Step(缺陷 9 G)', async () => {
|
|
312
|
+
const { definition, stage } = singleShellDefinition();
|
|
313
|
+
// 两步线性 shell Stage:step-a → step-b(无 branches,数组顺序兜底 next)。
|
|
314
|
+
const stepA: WorkflowStep = { id: 'step-a', name: 'a', command: { type: 'shell', value: { command: 'echo a' } } };
|
|
315
|
+
const stepB: WorkflowStep = { id: 'step-b', name: 'b', command: { type: 'shell', value: { command: 'echo b' } } };
|
|
316
|
+
const twoStepStage: WorkflowStage = { ...stage, steps: [{ id: 'step-a' }, { id: 'step-b' }] };
|
|
317
|
+
const twoStepDef: WorkflowDefinition = { ...definition, stages: [twoStepStage], steps: [stepA, stepB] };
|
|
318
|
+
// 崩溃前 step-a 已完成落盘、step-b 未执行(between-steps 窗口):无活跃段。
|
|
319
|
+
const completedA: WorkflowTaskState['executedSteps'][number] = {
|
|
320
|
+
id: 'exec-a',
|
|
321
|
+
stepId: 'step-a',
|
|
322
|
+
stageId: 'stg-1',
|
|
323
|
+
name: 'a',
|
|
324
|
+
status: 'completed',
|
|
325
|
+
command: { type: 'shell', value: { command: 'echo a' } },
|
|
326
|
+
sessionMeta: { childId: 'step-a-synthetic', synthetic: true, command: 'shell' },
|
|
327
|
+
visitCount: 1,
|
|
328
|
+
createdAt: '2026-08-16T00:00:00.000Z',
|
|
329
|
+
attemptIndex: 0,
|
|
330
|
+
output: [{ type: 'text', text: 'a' }],
|
|
331
|
+
};
|
|
332
|
+
const resumeFrom: StageExecutorResume = {
|
|
333
|
+
completedSteps: [completedA],
|
|
334
|
+
resumeBetweenSteps: true,
|
|
335
|
+
};
|
|
336
|
+
const { shell, makeExecutor } = harness();
|
|
337
|
+
shell.runHandler = async spec => ({ exitCode: 0, stdout: spec.command, stderr: '' });
|
|
338
|
+
|
|
339
|
+
const executor = makeExecutor({ stageDef: twoStepStage, workflowDef: twoStepDef, resumeFrom });
|
|
340
|
+
const result = await executor.execute();
|
|
341
|
+
|
|
342
|
+
expect(result.status).toBe('completed');
|
|
343
|
+
// step-a 不重跑:仅 'exec-a' 一段(无新增 step-a 段 → 非恰好一次语义成立)。
|
|
344
|
+
const stepASegments = result.executedSteps.filter(segment => segment.stepId === 'step-a');
|
|
345
|
+
expect(stepASegments).toHaveLength(1);
|
|
346
|
+
expect(stepASegments[0]?.id).toBe('exec-a');
|
|
347
|
+
// step-b 续跑一次(从 step-a 反推 DAG next 播种 batch,新增段完成)。
|
|
348
|
+
const stepBSegments = result.executedSteps.filter(segment => segment.stepId === 'step-b');
|
|
349
|
+
expect(stepBSegments).toHaveLength(1);
|
|
350
|
+
expect(stepBSegments[0]?.status).toBe('completed');
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
/** 提取文本 ContentBlock 的纯文本(非文本块返回空串)。 */
|
|
355
|
+
function textOf(block: ContentBlock | undefined): string {
|
|
356
|
+
return block !== undefined && block.type === 'text' ? block.text : '';
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
describe('StageExecutor 缺陷 7:解析并注入任务规则', () => {
|
|
360
|
+
/** 组装带三级 rules 的 prompt Step 工作流定义。 */
|
|
361
|
+
function rulesDefinition(): { definition: WorkflowDefinition; stage: WorkflowStage } {
|
|
362
|
+
const step: WorkflowStep = {
|
|
363
|
+
id: 'step-p',
|
|
364
|
+
name: 'review',
|
|
365
|
+
command: { type: 'prompt', value: { promptText: '生成报告' } },
|
|
366
|
+
rules: ['chinese-output'],
|
|
367
|
+
};
|
|
368
|
+
const stage: WorkflowStage = {
|
|
369
|
+
id: 'stg-1',
|
|
370
|
+
name: 'Stage 1',
|
|
371
|
+
steps: [{ id: 'step-p' }],
|
|
372
|
+
rules: ['no-direct-commit'],
|
|
373
|
+
};
|
|
374
|
+
const definition: WorkflowDefinition = {
|
|
375
|
+
id: 'wfl-1',
|
|
376
|
+
name: 'wfl-1',
|
|
377
|
+
version: '1',
|
|
378
|
+
settings: {},
|
|
379
|
+
stages: [stage],
|
|
380
|
+
steps: [step],
|
|
381
|
+
rules: {
|
|
382
|
+
'chinese-output': '所有输出必须使用中文',
|
|
383
|
+
'no-direct-commit': '禁止直接执行 git commit',
|
|
384
|
+
'unused-rule': '未引用的规则',
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
return { definition, stage };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
it('processCommandNode 解析 3 级合并 rules 并经 StepRunRequest 注入提示词 ## 任务规则', async () => {
|
|
391
|
+
const { definition, stage } = rulesDefinition();
|
|
392
|
+
const { llm, subagents, agents, makeExecutor } = harness();
|
|
393
|
+
// prompt Step 走可续接子代理(决策 D3):provider/model 走 llm 目录(L3)兜底,
|
|
394
|
+
// 注册真实 childId 并回填最终输出。
|
|
395
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
396
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
397
|
+
const child = new FakeAgent('child-1');
|
|
398
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'report content' }]));
|
|
399
|
+
agents.register(child);
|
|
400
|
+
|
|
401
|
+
const executor = makeExecutor({ stageDef: stage, workflowDef: definition });
|
|
402
|
+
const result = await executor.execute();
|
|
403
|
+
|
|
404
|
+
expect(result.status).toBe('completed');
|
|
405
|
+
// startContinuable 的请求提示词应含 ## 任务规则 节,且内容为 step.rules ∪
|
|
406
|
+
// stage.rules 去重后查 workflow.rules。
|
|
407
|
+
expect(subagents.continuableStarts).toHaveLength(1);
|
|
408
|
+
const promptText = textOf(subagents.continuableStarts[0]?.request.prompt[0]);
|
|
409
|
+
expect(promptText).toContain('## 任务规则');
|
|
410
|
+
expect(promptText).toContain('- **chinese-output**: 所有输出必须使用中文');
|
|
411
|
+
expect(promptText).toContain('- **no-direct-commit**: 禁止直接执行 git commit');
|
|
412
|
+
// 未引用的规则键不出现在提示词中
|
|
413
|
+
expect(promptText).not.toContain('unused-rule');
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('workflow/stage/step 全无 rules 时不注入 ## 任务规则 节(向后兼容)', async () => {
|
|
417
|
+
const { definition, stage } = singleShellDefinition();
|
|
418
|
+
// singleShellDefinition 是 shell Step(无 AI 提示词),改造成 prompt Step 验证不注入。
|
|
419
|
+
const promptStep: WorkflowStep = {
|
|
420
|
+
id: 'step-sh',
|
|
421
|
+
name: 'echo',
|
|
422
|
+
command: { type: 'prompt', value: { promptText: 'do work' } },
|
|
423
|
+
};
|
|
424
|
+
const promptDef: WorkflowDefinition = {
|
|
425
|
+
...definition,
|
|
426
|
+
steps: [promptStep],
|
|
427
|
+
};
|
|
428
|
+
const { llm, subagents, agents, makeExecutor } = harness();
|
|
429
|
+
// prompt Step 走可续接子代理:provider/model 走 llm 目录(L3)兜底,注册真实 childId。
|
|
430
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
431
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
432
|
+
const child = new FakeAgent('child-1');
|
|
433
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'done' }]));
|
|
434
|
+
agents.register(child);
|
|
435
|
+
|
|
436
|
+
const executor = makeExecutor({ stageDef: stage, workflowDef: promptDef });
|
|
437
|
+
const result = await executor.execute();
|
|
438
|
+
|
|
439
|
+
expect(result.status).toBe('completed');
|
|
440
|
+
const promptText = textOf(subagents.continuableStarts[0]?.request.prompt[0]);
|
|
441
|
+
// 头部三点列表含 `` `## 任务规则` `` 引用(行内反引号),此处断言正文节不注入。
|
|
442
|
+
expect(promptText).not.toContain('\n## 任务规则\n');
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// ── 缺陷 16:collectStepArtifacts 产物收集(四处断裂修复) ──────────────────────
|
|
447
|
+
|
|
448
|
+
describe('StageExecutor 缺陷 16:collectStepArtifacts 产物收集', () => {
|
|
449
|
+
/** 组装带 outputTo(glob + 非 glob + regex 三项)的 shell Step 工作流定义。 */
|
|
450
|
+
function outputToDefinition(): { definition: WorkflowDefinition; stage: WorkflowStage; step: WorkflowStep } {
|
|
451
|
+
const step: WorkflowStep = {
|
|
452
|
+
id: 'step-sh',
|
|
453
|
+
name: 'echo',
|
|
454
|
+
command: { type: 'shell', value: { command: 'echo hi' } },
|
|
455
|
+
outputTo: [
|
|
456
|
+
{ id: 'literal', path: 'out/report.md' },
|
|
457
|
+
{ id: 'glob', path: 'out/*.md' },
|
|
458
|
+
{ id: 'regex', regex: 'out/.+\\.md$' },
|
|
459
|
+
],
|
|
460
|
+
};
|
|
461
|
+
const stage: WorkflowStage = { id: 'stg-1', name: 'Stage 1', steps: [{ id: 'step-sh' }] };
|
|
462
|
+
const definition: WorkflowDefinition = {
|
|
463
|
+
id: 'wfl-1', name: 'wfl-1', version: '1', settings: {}, stages: [stage], steps: [step],
|
|
464
|
+
};
|
|
465
|
+
return { definition, stage, step };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// 功能点 ①:StageContract.artifacts[].files 与 contractArtifacts 非空(收集生效)
|
|
469
|
+
it('finalizeStageContract 收集产物填 artifacts[].files 并聚合 contractArtifacts(缺陷 16)', async () => {
|
|
470
|
+
const { definition, stage } = outputToDefinition();
|
|
471
|
+
const { fs, shell, makeExecutor } = harness();
|
|
472
|
+
shell.runHandler = async () => ({ exitCode: 0, stdout: 'hi', stderr: '' });
|
|
473
|
+
fs.seedFile('/tmp/out/report.md');
|
|
474
|
+
fs.seedFile('/tmp/out/notes.md');
|
|
475
|
+
|
|
476
|
+
const executor = makeExecutor({ stageDef: stage, workflowDef: definition });
|
|
477
|
+
const result = await executor.execute();
|
|
478
|
+
|
|
479
|
+
expect(result.status).toBe('completed');
|
|
480
|
+
const contract = result.stageContract;
|
|
481
|
+
// 三个 outputTo 项各一个 artifacts 条目(files 非硬编码 [])
|
|
482
|
+
expect(contract.artifacts).toHaveLength(3);
|
|
483
|
+
const filesById = new Map(contract.artifacts.map(a => [a.outputToId, a.files] as const));
|
|
484
|
+
// 非 glob 字面路径:fileExists 精确命中 report.md
|
|
485
|
+
expect(filesById.get('literal')).toEqual(['/tmp/out/report.md']);
|
|
486
|
+
// glob:out/*.md 命中 notes.md + report.md
|
|
487
|
+
expect(filesById.get('glob')!.sort()).toEqual(['/tmp/out/notes.md', '/tmp/out/report.md']);
|
|
488
|
+
// regex:out/.+\.md$ 命中 notes.md + report.md
|
|
489
|
+
expect(filesById.get('regex')!.sort()).toEqual(['/tmp/out/notes.md', '/tmp/out/report.md']);
|
|
490
|
+
// contractArtifacts 按 outputTo.id 聚合(非硬编码 {})
|
|
491
|
+
expect(contract.contractArtifacts['literal']).toEqual(['/tmp/out/report.md']);
|
|
492
|
+
expect(contract.contractArtifacts['glob']!.sort()).toEqual(['/tmp/out/notes.md', '/tmp/out/report.md']);
|
|
493
|
+
expect(contract.contractArtifacts['regex']!.sort()).toEqual(['/tmp/out/notes.md', '/tmp/out/report.md']);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// 功能点 ① 边界:无 outputTo 的 Step 不产生 artifacts 条目
|
|
497
|
+
it('finalizeStageContract 跳过无 outputTo 的 Step(artifacts 为空,contractArtifacts 为空)', async () => {
|
|
498
|
+
const { definition, stage } = singleShellDefinition();
|
|
499
|
+
const { shell, makeExecutor } = harness();
|
|
500
|
+
shell.runHandler = async () => ({ exitCode: 0, stdout: 'hi', stderr: '' });
|
|
501
|
+
|
|
502
|
+
const executor = makeExecutor({ stageDef: stage, workflowDef: definition });
|
|
503
|
+
const result = await executor.execute();
|
|
504
|
+
|
|
505
|
+
expect(result.stageContract.artifacts).toEqual([]);
|
|
506
|
+
expect(result.stageContract.contractArtifacts).toEqual({});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// 功能点 ②:finalize 评估提示词 <artifacts> 块含当前 Step 产物(context.artifacts 填充)
|
|
510
|
+
it('finalize 评估提示词 <artifacts> 块含当前 Step 产物(缺陷 16;primary 门禁零 AI)', async () => {
|
|
511
|
+
// Round 20:shell 步骤跳过 Phase A/B 收尾评估(无 assess 提示词)——本用例改用
|
|
512
|
+
// prompt Step(非 shell)验证 Phase B 提示词仍含 <artifacts> 块。
|
|
513
|
+
const { definition, stage } = singleShellDefinition();
|
|
514
|
+
const stepWithGates: WorkflowStep = {
|
|
515
|
+
id: 'step-sh',
|
|
516
|
+
name: 'agent',
|
|
517
|
+
command: { type: 'prompt', value: { promptText: 'do work' } },
|
|
518
|
+
outputTo: [{ id: 'report', path: 'out/report.md' }],
|
|
519
|
+
passThroughVars: [{ name: 'v', value: '${v}', description: 'd' }],
|
|
520
|
+
gates: { primary: { enabled: true } },
|
|
521
|
+
};
|
|
522
|
+
const def: WorkflowDefinition = { ...definition, steps: [stepWithGates] };
|
|
523
|
+
const { llm, fs, agents, makeExecutor } = harness();
|
|
524
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
525
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
526
|
+
llm.streamHandler = () => textChunks('{"completionType":"completed","passThroughVars":{}}');
|
|
527
|
+
// prompt Step 走可续接子代理:注册真实 childId 并回填最终输出。
|
|
528
|
+
const child = new FakeAgent('child-1');
|
|
529
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'work output' }]));
|
|
530
|
+
agents.register(child);
|
|
531
|
+
fs.seedFile('/tmp/out/report.md');
|
|
532
|
+
|
|
533
|
+
const executor = makeExecutor({ stageDef: stage, workflowDef: def });
|
|
534
|
+
const result = await executor.execute();
|
|
535
|
+
|
|
536
|
+
// prompt 无 prompt stream → calls = [Phase A assess, Phase B 重新 assess(无 fork
|
|
537
|
+
// 配置降级临时会话)];primary 门禁为纯代码产物校验(零 AI)——不再有第 3 次门禁
|
|
538
|
+
// AI 调用,产物存在即通过。
|
|
539
|
+
expect(result.status).toBe('completed');
|
|
540
|
+
expect(llm.calls).toHaveLength(2);
|
|
541
|
+
// Phase B 重新评估提示词同样含 <Artifacts> 块(缺陷 16:收尾评估上下文带产物)。
|
|
542
|
+
const phaseBAssessPrompt = textOf(llm.calls[1]?.messages[0]?.content[0]);
|
|
543
|
+
expect(phaseBAssessPrompt).toContain('<Artifacts>');
|
|
544
|
+
expect(phaseBAssessPrompt).toContain('/tmp/out/report.md');
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
// 功能点 ③:toPreviousOutput files 非空(前置产物 <artifacts> 块)
|
|
548
|
+
it('toPreviousOutput 收集产物填前置 Step 提示词 <artifacts> 块(缺陷 16)', async () => {
|
|
549
|
+
const { definition, stage } = singleShellDefinition();
|
|
550
|
+
const stepA: WorkflowStep = {
|
|
551
|
+
id: 'step-a', name: 'produce',
|
|
552
|
+
command: { type: 'shell', value: { command: 'echo done' } },
|
|
553
|
+
outputTo: [{ id: 'report', path: 'out/report.md' }],
|
|
554
|
+
};
|
|
555
|
+
const stepB: WorkflowStep = {
|
|
556
|
+
id: 'step-b', name: 'consume',
|
|
557
|
+
command: { type: 'prompt', value: { promptText: 'review previous' } },
|
|
558
|
+
};
|
|
559
|
+
const twoStepStage: WorkflowStage = { ...stage, steps: [{ id: 'step-a' }, { id: 'step-b' }] };
|
|
560
|
+
const twoStepDef: WorkflowDefinition = { ...definition, stages: [twoStepStage], steps: [stepA, stepB] };
|
|
561
|
+
const { llm, subagents, shell, fs, agents, makeExecutor } = harness();
|
|
562
|
+
// prompt Step(step-b)走可续接子代理:provider/model 走 llm 目录(L3)兜底,
|
|
563
|
+
// 注册真实 childId 并回填最终输出。
|
|
564
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
565
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
566
|
+
const child = new FakeAgent('child-1');
|
|
567
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'ok' }]));
|
|
568
|
+
agents.register(child);
|
|
569
|
+
shell.runHandler = async () => ({ exitCode: 0, stdout: 'done', stderr: '' });
|
|
570
|
+
fs.seedFile('/tmp/out/report.md');
|
|
571
|
+
|
|
572
|
+
const executor = makeExecutor({ stageDef: twoStepStage, workflowDef: twoStepDef });
|
|
573
|
+
const result = await executor.execute();
|
|
574
|
+
|
|
575
|
+
expect(result.status).toBe('completed');
|
|
576
|
+
// step-a 是 shell(无 AI 调用);step-b 是 prompt(startContinuable 提示词含前置产物)。
|
|
577
|
+
// step-b 无 gates 无未解析透传变量 → finalize 跳过 → 无 assess/gate stream。
|
|
578
|
+
expect(subagents.continuableStarts).toHaveLength(1);
|
|
579
|
+
const promptText = textOf(subagents.continuableStarts[0]?.request.prompt[0]);
|
|
580
|
+
expect(promptText).toContain('的输出和产物');
|
|
581
|
+
expect(promptText).toContain('<Artifacts>');
|
|
582
|
+
expect(promptText).toContain('/tmp/out/report.md');
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
// 功能点 ③ 续跑路径:resumeFrom 的 completedSteps 重建 previousOutputs 时收集产物
|
|
586
|
+
it('resumeFrom 续跑路径 initState 收集 completedSteps 产物 + StageContract 收集续跑 executedSteps 产物(缺陷 16)', async () => {
|
|
587
|
+
const { definition, stage } = singleShellDefinition();
|
|
588
|
+
const agentStep: WorkflowStep = {
|
|
589
|
+
id: 'step-sh', name: 'agent',
|
|
590
|
+
command: { type: 'agent', value: { promptText: 'do work' } },
|
|
591
|
+
outputTo: [{ id: 'report', path: 'out/report.md' }],
|
|
592
|
+
};
|
|
593
|
+
const completed: WorkflowTaskState['executedSteps'][number] = {
|
|
594
|
+
id: 'exec-1', stepId: 'step-sh', stageId: 'stg-1', name: 'agent', status: 'completed',
|
|
595
|
+
command: { type: 'agent', value: { promptText: 'do work' } },
|
|
596
|
+
sessionMeta: { childId: 'child-1', synthetic: false, command: 'agent' },
|
|
597
|
+
outputTo: [{ id: 'report', path: 'out/report.md' }],
|
|
598
|
+
visitCount: 1, createdAt: '2026-08-16T00:00:00.000Z', attemptIndex: 0,
|
|
599
|
+
output: [{ type: 'text', text: 'first' }],
|
|
600
|
+
};
|
|
601
|
+
const resumeFrom: StageExecutorResume = {
|
|
602
|
+
completedSteps: [completed],
|
|
603
|
+
resumeSubagents: [{
|
|
604
|
+
step: agentStep,
|
|
605
|
+
executed: { ...completed, id: 'exec-2', status: 'running', attemptIndex: 1, createdAt: '2026-08-16T00:00:01.000Z' },
|
|
606
|
+
childId: 'child-1',
|
|
607
|
+
}],
|
|
608
|
+
};
|
|
609
|
+
const { llm, fs, agents, makeExecutor } = harness();
|
|
610
|
+
// 注册续跑的子代理(whenIdle 后取 final output)。
|
|
611
|
+
const child = new FakeAgent('child-1');
|
|
612
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'resumed output' }]));
|
|
613
|
+
agents.register(child);
|
|
614
|
+
fs.seedFile('/tmp/out/report.md');
|
|
615
|
+
|
|
616
|
+
const executor = makeExecutor({ stageDef: stage, workflowDef: definition, resumeFrom });
|
|
617
|
+
const result = await executor.execute();
|
|
618
|
+
|
|
619
|
+
expect(result.status).toBe('completed');
|
|
620
|
+
// initState resumeFrom 分支收集 completedSteps 产物填 previousOutputs.files(不崩),
|
|
621
|
+
// 续跑段(exec-2)完成后并入 executedSteps;finalizeStageContract 对 executedSteps
|
|
622
|
+
// 收集产物:exec-1 与 exec-2 都声明同一 outputTo(report),各产生一个 artifacts 条目。
|
|
623
|
+
const contract = result.stageContract;
|
|
624
|
+
expect(contract.artifacts).toHaveLength(2);
|
|
625
|
+
expect(contract.artifacts[0]!.stepId).toBe('step-sh');
|
|
626
|
+
expect(contract.artifacts[0]!.outputToId).toBe('report');
|
|
627
|
+
expect(contract.artifacts[0]!.files).toEqual(['/tmp/out/report.md']);
|
|
628
|
+
expect(contract.artifacts[1]!.stepId).toBe('step-sh');
|
|
629
|
+
expect(contract.artifacts[1]!.outputToId).toBe('report');
|
|
630
|
+
expect(contract.artifacts[1]!.files).toEqual(['/tmp/out/report.md']);
|
|
631
|
+
// contractArtifacts 按 outputTo.id 聚合两个 executedStep 的产物(合并)。
|
|
632
|
+
expect(contract.contractArtifacts).toEqual({ report: ['/tmp/out/report.md', '/tmp/out/report.md'] });
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// ── 缺陷 10:队列驱动真并发改造(runLoop 并发 + maxConcurrentSteps 生效) ──────
|
|
637
|
+
|
|
638
|
+
/** 并行分支定义:step-router 有两个并行 branches(无 condition),step-a + step-b 并行后汇聚到 step-end。 */
|
|
639
|
+
function parallelBranchDefinition(): { definition: WorkflowDefinition; stage: WorkflowStage } {
|
|
640
|
+
const stepRouter: WorkflowStep = { id: 'step-router', name: 'Router' };
|
|
641
|
+
const stepA: WorkflowStep = { id: 'step-a', name: 'A', command: { type: 'shell', value: { command: 'echo a' } } };
|
|
642
|
+
const stepB: WorkflowStep = { id: 'step-b', name: 'B', command: { type: 'shell', value: { command: 'echo b' } } };
|
|
643
|
+
const stepEnd: WorkflowStep = { id: 'step-end', name: 'End', command: { type: 'shell', value: { command: 'echo end' } } };
|
|
644
|
+
const stage: WorkflowStage = {
|
|
645
|
+
id: 'stg-1',
|
|
646
|
+
name: 'Parallel',
|
|
647
|
+
// step-a/step-b 各自显式 branches 指向 step-end(汇聚),避免 arrayNextStep 隐式
|
|
648
|
+
// 边导致 step-a → step-b → step-a 无限循环(并行场景 arrayNext 不适用)。
|
|
649
|
+
steps: [
|
|
650
|
+
{ id: 'step-router', branches: [{ next: 'step-a' }, { next: 'step-b' }] },
|
|
651
|
+
{ id: 'step-a', branches: [{ next: 'step-end' }] },
|
|
652
|
+
{ id: 'step-b', branches: [{ next: 'step-end' }] },
|
|
653
|
+
{ id: 'step-end' },
|
|
654
|
+
],
|
|
655
|
+
};
|
|
656
|
+
const definition: WorkflowDefinition = {
|
|
657
|
+
id: 'wfl-parallel', name: 'wfl-parallel', version: '1', settings: {}, stages: [stage],
|
|
658
|
+
steps: [stepRouter, stepA, stepB, stepEnd],
|
|
659
|
+
};
|
|
660
|
+
return { definition, stage };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** 跟踪并发上限的测试用 limiter(记录 acquire 时 max active 数)。 */
|
|
664
|
+
class TrackingLimiter implements ConcurrencyLimiter {
|
|
665
|
+
private active = 0;
|
|
666
|
+
private readonly waiters: Array<() => void> = [];
|
|
667
|
+
maxObserved = 0;
|
|
668
|
+
constructor(private readonly max: number) {}
|
|
669
|
+
|
|
670
|
+
async acquire(): Promise<void> {
|
|
671
|
+
if (this.active < this.max) {
|
|
672
|
+
this.active += 1;
|
|
673
|
+
this.maxObserved = Math.max(this.maxObserved, this.active);
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
// hand-off:release() 调用 resolve 转移槽位(active 不变,不需要 await 后递增)。
|
|
677
|
+
await new Promise<void>(resolve => { this.waiters.push(resolve); });
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
release(): void {
|
|
681
|
+
if (this.waiters.length > 0) {
|
|
682
|
+
const next = this.waiters.shift()!;
|
|
683
|
+
next();
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
this.active -= 1;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
describe('StageExecutor 缺陷 10:队列驱动真并发改造', () => {
|
|
691
|
+
// ① 并行分支并发执行(Promise.all,不再逐个串行)
|
|
692
|
+
it('parallel branches execute concurrently (Promise.all, not serial)', async () => {
|
|
693
|
+
const { definition, stage } = parallelBranchDefinition();
|
|
694
|
+
const { shell, makeExecutor } = harness();
|
|
695
|
+
// shell handler 带 yield 让并发可观测:active > 1 时说明并发执行。
|
|
696
|
+
let active = 0;
|
|
697
|
+
let maxActive = 0;
|
|
698
|
+
shell.runHandler = async () => {
|
|
699
|
+
active += 1;
|
|
700
|
+
maxActive = Math.max(maxActive, active);
|
|
701
|
+
await Promise.resolve(); // yield to event loop → 允许其他并发 shell 启动
|
|
702
|
+
active -= 1;
|
|
703
|
+
return { exitCode: 0, stdout: 'done', stderr: '' };
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
const executor = makeExecutor({ stageDef: stage, workflowDef: definition });
|
|
707
|
+
const result = await executor.execute();
|
|
708
|
+
|
|
709
|
+
expect(result.status).toBe('completed');
|
|
710
|
+
expect(result.executedSteps).toHaveLength(4); // router + step-a + step-b + step-end
|
|
711
|
+
// 无 limiter(unlimited)→ 两 step-a/step-b shell handler 并发执行 → maxActive ≥ 2。
|
|
712
|
+
expect(maxActive).toBeGreaterThanOrEqual(2);
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
// ② maxConcurrentSteps 上限生效(并发数 ≤ maxConcurrentSteps)
|
|
716
|
+
it('maxConcurrentSteps limits concurrency (max active ≤ limit)', async () => {
|
|
717
|
+
const { definition, stage } = parallelBranchDefinition();
|
|
718
|
+
const { shell, makeExecutor } = harness();
|
|
719
|
+
let active = 0;
|
|
720
|
+
let maxActive = 0;
|
|
721
|
+
shell.runHandler = async () => {
|
|
722
|
+
active += 1;
|
|
723
|
+
maxActive = Math.max(maxActive, active);
|
|
724
|
+
await Promise.resolve();
|
|
725
|
+
active -= 1;
|
|
726
|
+
return { exitCode: 0, stdout: 'done', stderr: '' };
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
// limiter max=1 → 串行(不并发)
|
|
730
|
+
const limiter = new TrackingLimiter(1);
|
|
731
|
+
const executor = makeExecutor({ stageDef: stage, workflowDef: definition, concurrencyLimiter: limiter });
|
|
732
|
+
const result = await executor.execute();
|
|
733
|
+
|
|
734
|
+
expect(result.status).toBe('completed');
|
|
735
|
+
// limiter maxObserved 应 ≤ 1(step-a/step-b 不并发)。
|
|
736
|
+
expect(limiter.maxObserved).toBe(1);
|
|
737
|
+
// shell handler 也应观察到 maxActive = 1(串行执行)。
|
|
738
|
+
expect(maxActive).toBe(1);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
// ⑥ 串行模型回归(maxConcurrentSteps=1 时行为与改造前一致)
|
|
742
|
+
it('serial regression: maxConcurrentSteps=1 executes steps in order (like before)', async () => {
|
|
743
|
+
const { definition, stage } = parallelBranchDefinition();
|
|
744
|
+
const { shell, makeExecutor } = harness();
|
|
745
|
+
const executionOrder: string[] = [];
|
|
746
|
+
let active = 0;
|
|
747
|
+
let maxActive = 0;
|
|
748
|
+
shell.runHandler = async spec => {
|
|
749
|
+
active += 1;
|
|
750
|
+
maxActive = Math.max(maxActive, active);
|
|
751
|
+
executionOrder.push(spec.command);
|
|
752
|
+
await Promise.resolve();
|
|
753
|
+
active -= 1;
|
|
754
|
+
return { exitCode: 0, stdout: spec.command, stderr: '' };
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
const limiter = new TrackingLimiter(1);
|
|
758
|
+
const executor = makeExecutor({ stageDef: stage, workflowDef: definition, concurrencyLimiter: limiter });
|
|
759
|
+
const result = await executor.execute();
|
|
760
|
+
|
|
761
|
+
expect(result.status).toBe('completed');
|
|
762
|
+
expect(result.executedSteps).toHaveLength(4); // router + step-a + step-b + step-end
|
|
763
|
+
// 串行执行:maxActive = 1(无并发)
|
|
764
|
+
expect(maxActive).toBe(1);
|
|
765
|
+
expect(limiter.maxObserved).toBe(1);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
// ④ 嵌套循环计数并发安全(外层重入内层时内层 visitCount 从 0 起)
|
|
769
|
+
it('nested loop visitCount resets on outer re-entry (counting model safe under concurrency)', async () => {
|
|
770
|
+
// 构建 2 层嵌套循环:outer(3) → inner(3) → body → (body 回边 inner) → inner 跳出 → outer → 重复
|
|
771
|
+
// outer maxIterations=3(第 3 次 visit 跳出 → 跑 2 轮 inner,每轮 body 2 次 = 4 次)。
|
|
772
|
+
// inner maxIterations=3(第 3 次 visit 跳出 → 跑 2 次 body)。
|
|
773
|
+
const stepOuter: WorkflowStep = { id: 'step-outer-loop', name: 'Outer' };
|
|
774
|
+
const stepInner: WorkflowStep = { id: 'step-inner-loop', name: 'Inner' };
|
|
775
|
+
const stepBody: WorkflowStep = { id: 'step-body', name: 'Body', command: { type: 'shell', value: { command: 'echo body' } } };
|
|
776
|
+
const stage: WorkflowStage = {
|
|
777
|
+
id: 'stg-1', name: 'Nested',
|
|
778
|
+
steps: [
|
|
779
|
+
{ id: 'step-outer-loop', branches: [{ loop: { next: 'step-inner-loop', exitTo: '', maxIterations: 3 } }] },
|
|
780
|
+
{ id: 'step-inner-loop', branches: [{ loop: { next: 'step-body', exitTo: 'step-outer-loop', maxIterations: 3 } }] },
|
|
781
|
+
// step-body 显式回边 step-inner-loop(否则 arrayNextStep 为空 → 不回边 → inner 跑 1 次即停)。
|
|
782
|
+
{ id: 'step-body', branches: [{ next: 'step-inner-loop' }] },
|
|
783
|
+
],
|
|
784
|
+
};
|
|
785
|
+
const definition: WorkflowDefinition = {
|
|
786
|
+
id: 'wfl-nested', name: 'wfl-nested', version: '1', settings: {}, stages: [stage],
|
|
787
|
+
steps: [stepOuter, stepInner, stepBody],
|
|
788
|
+
};
|
|
789
|
+
const { shell, makeExecutor } = harness();
|
|
790
|
+
shell.runHandler = async () => ({ exitCode: 0, stdout: 'body', stderr: '' });
|
|
791
|
+
|
|
792
|
+
const executor = makeExecutor({ stageDef: stage, workflowDef: definition });
|
|
793
|
+
const result = await executor.execute();
|
|
794
|
+
|
|
795
|
+
expect(result.status).toBe('completed');
|
|
796
|
+
// outer 2 轮 × inner 2 次 = body 执行 4 次。
|
|
797
|
+
const bodySteps = result.executedSteps.filter(s => s.stepId === 'step-body');
|
|
798
|
+
expect(bodySteps).toHaveLength(4);
|
|
799
|
+
// 循环控制节点(纯 loop 分支无 command)平时不产生路由条目(仅 breakCondition
|
|
800
|
+
// 评估跳出时回写 loopMeta),因此 executedSteps 可能不含 step-outer-loop/inner-loop。
|
|
801
|
+
// 但 visitCount 反映循环计数正确(body 4 次证明 outer 2 轮 × inner 2 次)。
|
|
802
|
+
});
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
// ── 补充 37:SessionIdleWaiter 多 child 并发(Promise.all(whenIdle)) ──────────
|
|
806
|
+
|
|
807
|
+
/** 并行分支定义(agent 子代理版):两个并行 agent Step 汇聚到 step-end。 */
|
|
808
|
+
function parallelAgentDefinition(): { definition: WorkflowDefinition; stage: WorkflowStage } {
|
|
809
|
+
const stepRouter: WorkflowStep = { id: 'step-router', name: 'Router' };
|
|
810
|
+
const stepA: WorkflowStep = { id: 'step-a', name: 'A', command: { type: 'agent', value: { promptText: 'do A' } } };
|
|
811
|
+
const stepB: WorkflowStep = { id: 'step-b', name: 'B', command: { type: 'agent', value: { promptText: 'do B' } } };
|
|
812
|
+
const stepEnd: WorkflowStep = { id: 'step-end', name: 'End', command: { type: 'shell', value: { command: 'echo end' } } };
|
|
813
|
+
const stage: WorkflowStage = {
|
|
814
|
+
id: 'stg-1',
|
|
815
|
+
name: 'ParallelAgent',
|
|
816
|
+
steps: [
|
|
817
|
+
{ id: 'step-router', branches: [{ next: 'step-a' }, { next: 'step-b' }] },
|
|
818
|
+
{ id: 'step-a', branches: [{ next: 'step-end' }] },
|
|
819
|
+
{ id: 'step-b', branches: [{ next: 'step-end' }] },
|
|
820
|
+
{ id: 'step-end' },
|
|
821
|
+
],
|
|
822
|
+
};
|
|
823
|
+
const definition: WorkflowDefinition = {
|
|
824
|
+
id: 'wfl-parallel-agent', name: 'wfl-parallel-agent', version: '1', settings: {}, stages: [stage],
|
|
825
|
+
steps: [stepRouter, stepA, stepB, stepEnd],
|
|
826
|
+
};
|
|
827
|
+
return { definition, stage };
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
describe('StageExecutor 补充 37:SessionIdleWaiter 多 child 并发', () => {
|
|
831
|
+
it('multiple agent subagents run concurrently (Promise.all(whenIdle) aggregated at runLoop)', async () => {
|
|
832
|
+
const { definition, stage } = parallelAgentDefinition();
|
|
833
|
+
const { llm, subagents, shell, agents, makeExecutor } = harness();
|
|
834
|
+
// agent step 的 runAgentStep 需 resolveProviderModel → 配置 llm providers/models。
|
|
835
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
836
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
837
|
+
|
|
838
|
+
// 注册两个子代理,各自携带 assistant 输出(startContinuable 按 index 返回 child-1/child-2)。
|
|
839
|
+
const child1 = new FakeAgent('child-1');
|
|
840
|
+
child1.session.append(assistantMessageEvent([{ type: 'text', text: 'output A' }]));
|
|
841
|
+
const child2 = new FakeAgent('child-2');
|
|
842
|
+
child2.session.append(assistantMessageEvent([{ type: 'text', text: 'output B' }]));
|
|
843
|
+
agents.register(child1);
|
|
844
|
+
agents.register(child2);
|
|
845
|
+
|
|
846
|
+
// step-end 的 shell handler(默认 FakeShell 无 handler → 抛错;需设置)。
|
|
847
|
+
shell.runHandler = async () => ({ exitCode: 0, stdout: 'end', stderr: '' });
|
|
848
|
+
|
|
849
|
+
const executor = makeExecutor({ stageDef: stage, workflowDef: definition });
|
|
850
|
+
const result = await executor.execute();
|
|
851
|
+
|
|
852
|
+
expect(result.status).toBe('completed');
|
|
853
|
+
// router + step-a + step-b + step-end = 4 段(两个 agent step 并发执行)。
|
|
854
|
+
expect(result.executedSteps).toHaveLength(4);
|
|
855
|
+
// 两个 agent step 均完成(各自 whenIdle 被 Promise.all 聚合)。
|
|
856
|
+
const agentSteps = result.executedSteps.filter(s => s.stepId === 'step-a' || s.stepId === 'step-b');
|
|
857
|
+
expect(agentSteps).toHaveLength(2);
|
|
858
|
+
expect(agentSteps.every(s => s.status === 'completed')).toBe(true);
|
|
859
|
+
// startContinuable 被调用 2 次(两个 agent step 各启动一个子代理)。
|
|
860
|
+
expect(subagents.continuableStarts).toHaveLength(2);
|
|
861
|
+
});
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
describe('StageExecutor 5 阶段收尾(4.6/4.7:Phase A ask 保底 → Phase B/C → resume 状态流转)', () => {
|
|
865
|
+
it('Phase A ask 保底置 interacting(finalizeMeta currentPhase=ask-detect),resume 后 Phase B/C 完成', async () => {
|
|
866
|
+
const { llm, agents, makeExecutor } = harness();
|
|
867
|
+
let call = 0;
|
|
868
|
+
llm.streamHandler = () => {
|
|
869
|
+
// 首次评估判定需提问;回答后续跑重评估判定完成。
|
|
870
|
+
const askJson = '{"hasAsk":true,"passThroughVars":{},"askQuestion":{"questions":[{"id":"q1","question":"确认继续?","options":[{"label":"A"}]}]}}';
|
|
871
|
+
return textChunks(call++ === 0 ? askJson : '{"hasAsk":false,"passThroughVars":{}}');
|
|
872
|
+
};
|
|
873
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
874
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
875
|
+
// Round 20:shell 步骤跳过 Phase A(无 ask 通道)——本用例改用 prompt Step 验证
|
|
876
|
+
// ask 保底链路(prompt Step 声明未解析透传变量 → needsAssess(Phase A 跑评估))。
|
|
877
|
+
const step: WorkflowStep = {
|
|
878
|
+
id: 'step-sh',
|
|
879
|
+
name: 'echo',
|
|
880
|
+
command: { type: 'prompt', value: { promptText: 'do work' } },
|
|
881
|
+
passThroughVars: [{ name: 'v', value: '${v}', description: 'd' }],
|
|
882
|
+
};
|
|
883
|
+
const stage: WorkflowStage = { id: 'stg-1', name: 'Stage 1', steps: [{ id: 'step-sh' }] };
|
|
884
|
+
const definition: WorkflowDefinition = {
|
|
885
|
+
id: 'wfl-1', name: 'wfl-1', version: '1', stages: [stage], steps: [step], settings: {},
|
|
886
|
+
};
|
|
887
|
+
// prompt Step 走可续接子代理:注册真实 childId 并回填最终输出(resume 续跑复用)。
|
|
888
|
+
const child = new FakeAgent('child-1');
|
|
889
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'work done' }]));
|
|
890
|
+
agents.register(child);
|
|
891
|
+
const executor = makeExecutor({ stageDef: stage, workflowDef: definition });
|
|
892
|
+
|
|
893
|
+
// 首次 execute:Phase A ask 保底 → 实例 interacting(引擎 ask 闭环);Step 段
|
|
894
|
+
// 保持 running(偏离 8:interacting 只设 state.status)。
|
|
895
|
+
const first = await executor.execute();
|
|
896
|
+
expect(first.status).toBe('interacting');
|
|
897
|
+
expect(first.executedSteps[0]?.status).toBe('running');
|
|
898
|
+
// Phase A 中断快照:currentPhase='ask-detect',顶层 assessSessionId 不记录
|
|
899
|
+
// (临时会话用完即删不持久化;Phase B 收尾才落 assessSessionId)。
|
|
900
|
+
expect(first.executedSteps[0]?.finalizeMeta?.currentPhase).toBe('ask-detect');
|
|
901
|
+
expect(first.executedSteps[0]?.finalizeMeta?.assessSessionId).toBeUndefined();
|
|
902
|
+
expect(first.executedSteps[0]?.finalizeMeta?.askResult?.hasAsk).toBe(true);
|
|
903
|
+
|
|
904
|
+
// 回答后再次 execute:resumePendingAsk(prompt 真实子代理重收集输出)→ Phase B 真 fork
|
|
905
|
+
// 子代理重新评估(finalizeMeta currentPhase='finalizing')→ Phase C(无门禁 ok)→ 完成。
|
|
906
|
+
const second = await executor.execute();
|
|
907
|
+
expect(second.status).toBe('completed');
|
|
908
|
+
expect(second.executedSteps[0]?.status).toBe('completed');
|
|
909
|
+
expect(second.executedSteps[0]?.finalizeMeta?.currentPhase).toBe('completed');
|
|
910
|
+
// Phase B 起持久化 assessSessionId(评估 fork id;无 fork 配置降级 eval- 句柄)。
|
|
911
|
+
// Round 17:顶层 assessSessionId 已删除,sessionId 从 assessResult.forkSessionId 内读
|
|
912
|
+
expect(second.executedSteps[0]?.finalizeMeta?.assessResult?.forkSessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
it('shell Step 跳过 Phase A/B 收尾评估(Round 20:直接 Phase C 门禁 → completed)', async () => {
|
|
916
|
+
// shell 步骤声明未解析透传变量 + 门禁——修复前会触发 Phase A assess(ask 通道)+
|
|
917
|
+
// Phase B assess(compressedOutput);Round 20 后 shell 跳过两者,仅 Phase C
|
|
918
|
+
// primary 纯代码门禁(零 AI)运行。
|
|
919
|
+
const { definition, stage } = singleShellDefinition();
|
|
920
|
+
const shellStep: WorkflowStep = {
|
|
921
|
+
id: 'step-sh',
|
|
922
|
+
name: 'echo',
|
|
923
|
+
command: { type: 'shell', value: { command: 'echo hi' } },
|
|
924
|
+
passThroughVars: [{ name: 'v', value: '${v}', description: 'd' }],
|
|
925
|
+
gates: { primary: { enabled: true } },
|
|
926
|
+
};
|
|
927
|
+
const def: WorkflowDefinition = { ...definition, steps: [shellStep] };
|
|
928
|
+
const { llm, shell, makeExecutor } = harness();
|
|
929
|
+
llm.streamHandler = () => textChunks('should not be called');
|
|
930
|
+
shell.runHandler = async () => ({ exitCode: 0, stdout: 'hi', stderr: '' });
|
|
931
|
+
|
|
932
|
+
const executor = makeExecutor({ stageDef: stage, workflowDef: def });
|
|
933
|
+
const result = await executor.execute();
|
|
934
|
+
|
|
935
|
+
expect(result.status).toBe('completed');
|
|
936
|
+
const segment = result.executedSteps[0]!;
|
|
937
|
+
// shell 跳过 Phase A/B:零 llm 调用、无 assessResult(Phase B 不跑、无压缩输出)。
|
|
938
|
+
expect(llm.calls).toHaveLength(0);
|
|
939
|
+
expect(segment.finalizeMeta?.assessResult).toBeUndefined();
|
|
940
|
+
// Phase C primary 门禁仍运行(纯代码产物校验;无 outputTo → 直接通过)。
|
|
941
|
+
expect(segment.finalizeMeta?.gateResult?.gatesPassed).toBe(true);
|
|
942
|
+
// 命令输出直接展示(completed 段保留原始 output 供 UI shell 分支显示)。
|
|
943
|
+
expect(segment.output).toHaveLength(1);
|
|
944
|
+
expect(segment.output[0]?.type).toBe('text');
|
|
945
|
+
});
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
// ── 收尾真 fork(Phase A 临时会话 / Phase B agents.create 重建宿主子代理) ─────────
|
|
949
|
+
|
|
950
|
+
describe('StageExecutor 收尾真 fork(5 阶段收尾重构)', () => {
|
|
951
|
+
/** 组装单 agent Step(未解析透传变量 → Phase A + Phase B 均跑评估)。 */
|
|
952
|
+
function agentFinalizeDefinition(): { definition: WorkflowDefinition; stage: WorkflowStage; step: WorkflowStep } {
|
|
953
|
+
const step: WorkflowStep = {
|
|
954
|
+
id: 'step-a',
|
|
955
|
+
name: 'agent',
|
|
956
|
+
command: { type: 'agent', value: { promptText: 'do work' } },
|
|
957
|
+
passThroughVars: [{ name: 'projectName', value: '${projectName}', description: 'project' }],
|
|
958
|
+
};
|
|
959
|
+
const stage: WorkflowStage = { id: 'stg-1', name: 'Stage 1', steps: [{ id: 'step-a' }] };
|
|
960
|
+
const definition: WorkflowDefinition = {
|
|
961
|
+
id: 'wfl-agent-finalize', name: 'wfl-agent-finalize', version: '1', stages: [stage], steps: [step], settings: {},
|
|
962
|
+
};
|
|
963
|
+
return { definition, stage, step };
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** 源子代理完整事件(turn 0 闭合:start/user/assistant/end)。 */
|
|
967
|
+
function childEvents(): Array<{ type: string; seq: number; time: number; data: unknown }> {
|
|
968
|
+
return [
|
|
969
|
+
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 0 } },
|
|
970
|
+
{ type: 'user/message', seq: 1, time: 0, data: { id: 'u1', content: [{ type: 'text', text: 'do work' }] } },
|
|
971
|
+
{ type: 'assistant/message', seq: 2, time: 0, data: { turn: 0, step: 0, message: { id: 'm2', role: 'assistant', content: [{ type: 'text', text: 'agent work output' }], source: { kind: 'model', provider: 'p', model: 'm' } } } },
|
|
972
|
+
{ type: 'turn/end', seq: 3, time: 0, data: { turn: 0, reason: { kind: 'completed' } } },
|
|
973
|
+
];
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
it('Phase B 真 fork:agents.create 收到 seed=完整事件 + meta.parentSession=宿主主代理 + origin=subagent;透传变量在 Phase B 回写;compressedOutput 落 previousOutputs;Phase D routing 落盘', async () => {
|
|
977
|
+
// 两步工作流:step-a(agent + 未解析透传变量,Phase B 真 fork)→ step-b(prompt,
|
|
978
|
+
// 引用前置输出——验证 compressedOutput 经 previousOutputs 进入 step-b 提示词)。
|
|
979
|
+
const stepA: WorkflowStep = {
|
|
980
|
+
id: 'step-a',
|
|
981
|
+
name: 'agent',
|
|
982
|
+
command: { type: 'agent', value: { promptText: 'do work' } },
|
|
983
|
+
passThroughVars: [{ name: 'projectName', value: '${projectName}', description: 'project' }],
|
|
984
|
+
};
|
|
985
|
+
const stepB: WorkflowStep = {
|
|
986
|
+
id: 'step-b',
|
|
987
|
+
name: 'consume',
|
|
988
|
+
command: { type: 'prompt', value: { promptText: 'review previous' } },
|
|
989
|
+
};
|
|
990
|
+
const stage: WorkflowStage = { id: 'stg-1', name: 'Stage 1', steps: [{ id: 'step-a' }, { id: 'step-b' }] };
|
|
991
|
+
const definition: WorkflowDefinition = {
|
|
992
|
+
id: 'wfl-fork-b2', name: 'wfl-fork-b2', version: '1', stages: [stage], steps: [stepA, stepB], settings: {},
|
|
993
|
+
};
|
|
994
|
+
const { llm, subagents, agents, makeExecutor } = harness();
|
|
995
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
996
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
997
|
+
// Phase A 评估临时会话(llm.stream:completed JSON;Phase A 不写回透传变量)。
|
|
998
|
+
llm.streamHandler = () => textChunks('{"hasAsk":false,"passThroughVars":{}}');
|
|
999
|
+
// agent Step(step-a)的执行子代理(child-1,输出 'agent work output')。
|
|
1000
|
+
const child = new FakeAgent('child-1');
|
|
1001
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'agent work output' }]));
|
|
1002
|
+
agents.register(child);
|
|
1003
|
+
// step-b 的执行子代理(child-2)。
|
|
1004
|
+
const childB = new FakeAgent('child-2');
|
|
1005
|
+
childB.session.append(assistantMessageEvent([{ type: 'text', text: 'review done' }]));
|
|
1006
|
+
agents.register(childB);
|
|
1007
|
+
// Phase B 真 fork 的评估回复(一个 LLM 调用返回合并 JSON:passThroughVars + compressedOutput)。
|
|
1008
|
+
agents.createFollowupHandler = () => [{ type: 'text', text: '{"passThroughVars":{"projectName":"sandore"},"compressedOutput":"agent 压缩摘要"}' }];
|
|
1009
|
+
// 记录收尾阶段流转(Phase B finalizing → Phase D routing → completed)。
|
|
1010
|
+
const phases: Array<{ stepId: string; phase: string }> = [];
|
|
1011
|
+
|
|
1012
|
+
const executor = makeExecutor(
|
|
1013
|
+
{
|
|
1014
|
+
stageDef: stage,
|
|
1015
|
+
workflowDef: definition,
|
|
1016
|
+
hooks: {
|
|
1017
|
+
onStepStart: () => {},
|
|
1018
|
+
onStepCompleted: () => {},
|
|
1019
|
+
onStepFailed: () => {},
|
|
1020
|
+
onPhaseChange: (step, _childId, phase) => phases.push({ stepId: step.id, phase }),
|
|
1021
|
+
},
|
|
1022
|
+
},
|
|
1023
|
+
{
|
|
1024
|
+
defaultTimeoutMs: 60000, defaultProvider: 'p', defaultModel: 'm',
|
|
1025
|
+
hostAgentId: 'host-1',
|
|
1026
|
+
createAgent: options => agents.create(options),
|
|
1027
|
+
getStepSessionEvents: async childId => (childId === 'child-1' ? childEvents() : undefined),
|
|
1028
|
+
},
|
|
1029
|
+
);
|
|
1030
|
+
const result = await executor.execute();
|
|
1031
|
+
|
|
1032
|
+
expect(result.status).toBe('completed');
|
|
1033
|
+
// Phase B 真 fork:agents.create 收到 seed = 完整事件 + meta(parentSession=宿主主代理、
|
|
1034
|
+
// origin='subagent'、seedLength=4、cwd=/tmp)。
|
|
1035
|
+
expect(agents.createSeeds).toHaveLength(1);
|
|
1036
|
+
expect(agents.createSeeds[0]).toEqual(childEvents());
|
|
1037
|
+
expect(agents.createMetas[0]).toEqual({ cwd: '/tmp', parentSession: 'host-1', seedLength: 4, origin: 'subagent' });
|
|
1038
|
+
// 合成 fork 会话 id:纯 UUID(globalThis.crypto.randomUUID())。
|
|
1039
|
+
const segment = result.executedSteps.find(s => s.stepId === 'step-a')!;
|
|
1040
|
+
// Round 17:顶层 assessSessionId 已删除,sessionId 从 assessResult.forkSessionId 内读
|
|
1041
|
+
expect(segment.finalizeMeta?.assessResult?.forkSessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
|
1042
|
+
// 驱动一轮:followup 收到收尾处理提示词(含 compressedOutput 格式)、whenIdle 被 await。
|
|
1043
|
+
const forkedAgent = agents.createdAgents[0]!;
|
|
1044
|
+
expect(forkedAgent.followups).toHaveLength(1);
|
|
1045
|
+
expect(forkedAgent.whenIdleCount).toBe(1);
|
|
1046
|
+
// Phase B 真 fork 子代理不消费 llm.stream → 仅 Phase A 临时会话 1 次调用。
|
|
1047
|
+
expect(llm.calls).toHaveLength(1);
|
|
1048
|
+
// 评估完 dispose:live 移除。
|
|
1049
|
+
expect(agents.get(segment.finalizeMeta!.assessResult!.forkSessionId)).toBeUndefined();
|
|
1050
|
+
// 透传变量回写(保留 description)——阶段契约与累加器均落账。
|
|
1051
|
+
expect(result.passThroughVars['projectName']).toEqual({ value: 'sandore', description: 'project' });
|
|
1052
|
+
expect(result.stageContract.passThroughVars['projectName']).toEqual({ value: 'sandore', description: 'project' });
|
|
1053
|
+
// Phase B JSON 的 compressedOutput 经 previousOutputs 落入 step-b 的提示词
|
|
1054
|
+
// (「前置Step: ... 的输出和产物」Reference 块的 Output,替代原 compressStepOutput)。
|
|
1055
|
+
// step-a(agent)为第 1 个 startContinuable(child-1),step-b(prompt)为第 2 个。
|
|
1056
|
+
const stepBPrompt = textOf(subagents.continuableStarts[1]?.request.prompt[0]);
|
|
1057
|
+
expect(stepBPrompt).toContain('的输出和产物');
|
|
1058
|
+
expect(stepBPrompt).toContain('agent 压缩摘要');
|
|
1059
|
+
// 收尾阶段流转:step-a finalizing(进入 Phase B 先 commit 快照)→ finalizing(Phase B 完成后 commit)
|
|
1060
|
+
// → routing(Phase D)→ completed;step-b 无变量无门禁跳过收尾。
|
|
1061
|
+
expect(phases).toEqual([
|
|
1062
|
+
{ stepId: 'step-a', phase: 'finalizing' },
|
|
1063
|
+
{ stepId: 'step-a', phase: 'finalizing' },
|
|
1064
|
+
{ stepId: 'step-a', phase: 'routing' },
|
|
1065
|
+
{ stepId: 'step-a', phase: 'completed' },
|
|
1066
|
+
]);
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
it('Phase B 事件源缺失(shell 合成会话)→ 降级临时会话并记 errorDetail(不失败)', async () => {
|
|
1070
|
+
const { definition, stage } = agentFinalizeDefinition();
|
|
1071
|
+
const { llm, agents, makeExecutor } = harness();
|
|
1072
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
1073
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
1074
|
+
// Phase A + Phase B 降级均走 llm.stream(两段 completed JSON)。
|
|
1075
|
+
llm.streamHandler = () => textChunks('{"hasAsk":false,"passThroughVars":{}}');
|
|
1076
|
+
const child = new FakeAgent('child-1');
|
|
1077
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'agent work output' }]));
|
|
1078
|
+
agents.register(child);
|
|
1079
|
+
|
|
1080
|
+
const executor = makeExecutor(
|
|
1081
|
+
{ stageDef: stage, workflowDef: definition },
|
|
1082
|
+
{
|
|
1083
|
+
defaultTimeoutMs: 60000, defaultProvider: 'p', defaultModel: 'm',
|
|
1084
|
+
hostAgentId: 'host-1',
|
|
1085
|
+
createAgent: options => agents.create(options),
|
|
1086
|
+
// 无事件源(child-1 未登记/合成会话)→ assessForked 降级临时会话。
|
|
1087
|
+
getStepSessionEvents: async () => undefined,
|
|
1088
|
+
},
|
|
1089
|
+
);
|
|
1090
|
+
const result = await executor.execute();
|
|
1091
|
+
|
|
1092
|
+
expect(result.status).toBe('completed');
|
|
1093
|
+
// 无 agents.create(forkStepSubagent 在取事件处即降级)。
|
|
1094
|
+
expect(agents.createSeeds).toHaveLength(0);
|
|
1095
|
+
const segment = result.executedSteps[0]!;
|
|
1096
|
+
// 降级后 forkSessionId 为纯 UUID 句柄,assessResult.errorDetail 记降级原因。
|
|
1097
|
+
// Round 17:顶层 assessSessionId 已删除,sessionId 从 assessResult.forkSessionId 内读
|
|
1098
|
+
expect(segment.finalizeMeta?.assessResult?.forkSessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
|
1099
|
+
expect(segment.finalizeMeta?.assessResult?.errorDetail).toContain('no event source');
|
|
1100
|
+
// Phase A + Phase B 降级 = 2 次 llm.stream。
|
|
1101
|
+
expect(llm.calls).toHaveLength(2);
|
|
1102
|
+
});
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
// ── bugfix round22:runLoop pendingQueue+Promise.race 重构(新并发模型专项) ────────
|
|
1106
|
+
|
|
1107
|
+
/** settle 控制器(对齐 step-runner.spec):注入 awaitSubagentSettlement + 测试手动 settle。 */
|
|
1108
|
+
function settleController(): {
|
|
1109
|
+
awaitSubagentSettlement: (childId: string, signal: AbortSignal) => Promise<SubagentSettlement>;
|
|
1110
|
+
settle: (childId: string, stopReason: SubagentStopReason, output?: readonly ContentBlock[]) => void;
|
|
1111
|
+
} {
|
|
1112
|
+
const pending = new Map<string, (settlement: SubagentSettlement) => void>();
|
|
1113
|
+
const buffered = new Map<string, SubagentSettlement>();
|
|
1114
|
+
return {
|
|
1115
|
+
awaitSubagentSettlement: async childId => {
|
|
1116
|
+
const existing = buffered.get(childId);
|
|
1117
|
+
if (existing !== undefined) {
|
|
1118
|
+
buffered.delete(childId);
|
|
1119
|
+
return existing;
|
|
1120
|
+
}
|
|
1121
|
+
return await new Promise<SubagentSettlement>(resolve => {
|
|
1122
|
+
pending.set(childId, resolve);
|
|
1123
|
+
});
|
|
1124
|
+
},
|
|
1125
|
+
settle: (childId, stopReason, output) => {
|
|
1126
|
+
const settlement: SubagentSettlement = {
|
|
1127
|
+
childId,
|
|
1128
|
+
stopReason,
|
|
1129
|
+
...(output === undefined ? {} : { output }),
|
|
1130
|
+
};
|
|
1131
|
+
const resolve = pending.get(childId);
|
|
1132
|
+
if (resolve !== undefined) {
|
|
1133
|
+
pending.delete(childId);
|
|
1134
|
+
resolve(settlement);
|
|
1135
|
+
} else {
|
|
1136
|
+
buffered.set(childId, settlement);
|
|
1137
|
+
}
|
|
1138
|
+
},
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/** 组装失败/成功 shell 结果(减少重复样板)。 */
|
|
1143
|
+
function shellResult(spec: { timeoutMs?: number }, stdout: string, exitCode = 0) {
|
|
1144
|
+
return {
|
|
1145
|
+
exitCode,
|
|
1146
|
+
signal: null,
|
|
1147
|
+
timedOut: false,
|
|
1148
|
+
aborted: false,
|
|
1149
|
+
timeoutMs: spec.timeoutMs,
|
|
1150
|
+
stdout: { text: stdout, truncated: false },
|
|
1151
|
+
stderr: { text: exitCode === 0 ? '' : 'boom', truncated: false },
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
describe('StageExecutor bugfix round22:runLoop pendingQueue+Promise.race 新模型', () => {
|
|
1156
|
+
// 新增单测 ①:并发完成/失败 status 竞态——条件 completed 写被终态拦截,最终 failed。
|
|
1157
|
+
it('concurrent completed + failed branches settle to failed (conditional completed write)', async () => {
|
|
1158
|
+
const { definition, stage } = parallelBranchDefinition();
|
|
1159
|
+
const { shell, makeExecutor } = harness();
|
|
1160
|
+
// step-a(echo a)成功但延迟完成(gate 放行);step-b(echo b)立即失败。
|
|
1161
|
+
let release!: () => void;
|
|
1162
|
+
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
1163
|
+
shell.runHandler = async spec => {
|
|
1164
|
+
if (spec.command === 'echo b') {
|
|
1165
|
+
return shellResult(spec, '', 1);
|
|
1166
|
+
}
|
|
1167
|
+
if (spec.command === 'echo a') {
|
|
1168
|
+
await gate; // 先让 step-b 失败落终态,再放行 step-a(条件 completed 写被 failed 拦截)
|
|
1169
|
+
}
|
|
1170
|
+
return shellResult(spec, 'done');
|
|
1171
|
+
};
|
|
1172
|
+
const commits: Array<{ status?: string; errorDetail?: string }> = [];
|
|
1173
|
+
const executor = makeExecutor({
|
|
1174
|
+
stageDef: stage,
|
|
1175
|
+
workflowDef: definition,
|
|
1176
|
+
commitState: patch => commits.push(patch),
|
|
1177
|
+
});
|
|
1178
|
+
const resultP = executor.execute();
|
|
1179
|
+
await new Promise<void>(resolve => setTimeout(resolve, 10));
|
|
1180
|
+
release();
|
|
1181
|
+
const result = await resultP;
|
|
1182
|
+
|
|
1183
|
+
expect(result.status).toBe('failed');
|
|
1184
|
+
// step-a 段仍 completed(P3 persist 无条件、段快照完整);step-b 段 failed。
|
|
1185
|
+
expect(result.executedSteps.find(s => s.stepId === 'step-a')?.status).toBe('completed');
|
|
1186
|
+
expect(result.executedSteps.find(s => s.stepId === 'step-b')?.status).toBe('failed');
|
|
1187
|
+
// 终态后不再有 completed commit(条件写被拦);最后一次 status commit 为 failed。
|
|
1188
|
+
const lastStatusCommit = [...commits].reverse().find(p => p.status !== undefined);
|
|
1189
|
+
expect(lastStatusCommit?.status).toBe('failed');
|
|
1190
|
+
// 终止态跳过剩余节点:step-end 不执行。
|
|
1191
|
+
expect(result.executedSteps.some(s => s.stepId === 'step-end')).toBe(false);
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
// 新增单测 ②:循环门 quiescent barrier——并行循环体汇聚回循环门不提前路由(G10)。
|
|
1195
|
+
it('loop gate routes only when quiescent (parallel body branches converge, iteration count correct)', async () => {
|
|
1196
|
+
const stepLoop: WorkflowStep = { id: 'step-loop', name: 'Loop' };
|
|
1197
|
+
const stepBodyRouter: WorkflowStep = { id: 'step-body-router', name: 'BodyRouter' };
|
|
1198
|
+
const stepB1: WorkflowStep = { id: 'step-b1', name: 'B1', command: { type: 'shell', value: { command: 'echo b1' } } };
|
|
1199
|
+
const stepB2: WorkflowStep = { id: 'step-b2', name: 'B2', command: { type: 'shell', value: { command: 'echo b2' } } };
|
|
1200
|
+
const stepEnd: WorkflowStep = { id: 'step-end', name: 'End', command: { type: 'shell', value: { command: 'echo end' } } };
|
|
1201
|
+
const stage: WorkflowStage = {
|
|
1202
|
+
id: 'stg-1', name: 'LoopBarrier',
|
|
1203
|
+
steps: [
|
|
1204
|
+
{ id: 'step-loop', branches: [{ loop: { next: 'step-body-router', exitTo: 'step-end', maxIterations: 3 } }] },
|
|
1205
|
+
{ id: 'step-body-router', branches: [{ next: 'step-b1' }, { next: 'step-b2' }] },
|
|
1206
|
+
{ id: 'step-b1', branches: [{ next: 'step-loop' }] },
|
|
1207
|
+
{ id: 'step-b2', branches: [{ next: 'step-loop' }] },
|
|
1208
|
+
{ id: 'step-end' },
|
|
1209
|
+
],
|
|
1210
|
+
};
|
|
1211
|
+
const definition: WorkflowDefinition = {
|
|
1212
|
+
id: 'wfl-loop-barrier', name: 'wfl-loop-barrier', version: '1', settings: {}, stages: [stage],
|
|
1213
|
+
steps: [stepLoop, stepBodyRouter, stepB1, stepB2, stepEnd],
|
|
1214
|
+
};
|
|
1215
|
+
const { shell, makeExecutor } = harness();
|
|
1216
|
+
// b2 延迟完成:b1 完成后循环门入队,barrier 必须等同迭代 b2 落地才路由;若提前路由
|
|
1217
|
+
// (无 barrier)→ 迭代语义破坏(visitCount 超前 → 提前 break → 循环发散/计数错误)。
|
|
1218
|
+
shell.runHandler = async spec => {
|
|
1219
|
+
if (spec.command === 'echo b2') {
|
|
1220
|
+
await new Promise<void>(resolve => setTimeout(resolve, 20));
|
|
1221
|
+
}
|
|
1222
|
+
return shellResult(spec, spec.command);
|
|
1223
|
+
};
|
|
1224
|
+
|
|
1225
|
+
const executor = makeExecutor({ stageDef: stage, workflowDef: definition });
|
|
1226
|
+
const result = await executor.execute();
|
|
1227
|
+
|
|
1228
|
+
expect(result.status).toBe('completed');
|
|
1229
|
+
// maxIterations=3 → 2 轮迭代 × 2 并行分支 = body 恰好执行 4 次(barrier 保证不提前 break)。
|
|
1230
|
+
const bodySegs = result.executedSteps.filter(s => s.stepId === 'step-b1' || s.stepId === 'step-b2');
|
|
1231
|
+
expect(bodySegs).toHaveLength(4);
|
|
1232
|
+
expect(bodySegs.every(s => s.status === 'completed')).toBe(true);
|
|
1233
|
+
// 循环跳出 exitTo 推进 step-end 恰好一次。
|
|
1234
|
+
const endSegs = result.executedSteps.filter(s => s.stepId === 'step-end');
|
|
1235
|
+
expect(endSegs).toHaveLength(1);
|
|
1236
|
+
expect(endSegs[0]?.status).toBe('completed');
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
// 新增单测 ③:persist 顺序——完成节点 P3 持久化快照已含其 nexts(enqueue 先于 P3)。
|
|
1240
|
+
it('completed step persist snapshot already contains its nexts (enqueue before P3)', async () => {
|
|
1241
|
+
const { definition, stage } = singleShellDefinition();
|
|
1242
|
+
const stepA: WorkflowStep = { id: 'step-a', name: 'a', command: { type: 'shell', value: { command: 'echo a' } } };
|
|
1243
|
+
const stepB: WorkflowStep = { id: 'step-b', name: 'b', command: { type: 'shell', value: { command: 'echo b' } } };
|
|
1244
|
+
const twoStepStage: WorkflowStage = { ...stage, steps: [{ id: 'step-a' }, { id: 'step-b' }] };
|
|
1245
|
+
const twoStepDef: WorkflowDefinition = { ...definition, stages: [twoStepStage], steps: [stepA, stepB] };
|
|
1246
|
+
const { shell, makeExecutor } = harness();
|
|
1247
|
+
shell.runHandler = async spec => shellResult(spec, spec.command);
|
|
1248
|
+
const persists: Array<{ stepId: string | undefined; status: string; pendingQueue: readonly string[] | undefined }> = [];
|
|
1249
|
+
const executor = makeExecutor({
|
|
1250
|
+
stageDef: twoStepStage,
|
|
1251
|
+
workflowDef: twoStepDef,
|
|
1252
|
+
onPersist: async (executed, pendingQueue) => {
|
|
1253
|
+
persists.push({ stepId: executed.stepId, status: executed.status, pendingQueue });
|
|
1254
|
+
},
|
|
1255
|
+
});
|
|
1256
|
+
const result = await executor.execute();
|
|
1257
|
+
|
|
1258
|
+
expect(result.status).toBe('completed');
|
|
1259
|
+
// step-a 的 P3 completed 快照必须已含 next(step-b)——enqueue 先于 persist(崩溃窗口闭合)。
|
|
1260
|
+
const aCompleted = persists.find(p => p.stepId === 'step-a' && p.status === 'completed');
|
|
1261
|
+
expect(aCompleted?.pendingQueue).toContain('step-b');
|
|
1262
|
+
// step-a 的 P1 running 快照在路由前:不含 step-b。
|
|
1263
|
+
const aRunning = persists.find(p => p.stepId === 'step-a' && p.status === 'running');
|
|
1264
|
+
expect(aRunning?.pendingQueue).not.toContain('step-b');
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
// 新增单测 ④:resume 混合播种——resumeSubagents 走 resumeSegments 分流、快照节点正常拉起、去重生效。
|
|
1268
|
+
it('resume mixed seeding: segments go through resumeSegments, snapshot nodes pull up, dedupe works', async () => {
|
|
1269
|
+
const stepRouter: WorkflowStep = { id: 'step-router', name: 'Router' };
|
|
1270
|
+
const stepB: WorkflowStep = { id: 'step-b', name: 'B', command: { type: 'agent', value: { promptText: 'do B' } } };
|
|
1271
|
+
const stepC: WorkflowStep = { id: 'step-c', name: 'C', command: { type: 'shell', value: { command: 'echo c' } } };
|
|
1272
|
+
const stepEnd: WorkflowStep = { id: 'step-end', name: 'End', command: { type: 'shell', value: { command: 'echo end' } } };
|
|
1273
|
+
const stage: WorkflowStage = {
|
|
1274
|
+
id: 'stg-1', name: 'MixedResume',
|
|
1275
|
+
steps: [
|
|
1276
|
+
{ id: 'step-router', branches: [{ next: 'step-b' }, { next: 'step-c' }] },
|
|
1277
|
+
{ id: 'step-b', branches: [{ next: 'step-end' }] },
|
|
1278
|
+
{ id: 'step-c', branches: [{ next: 'step-end' }] },
|
|
1279
|
+
{ id: 'step-end' },
|
|
1280
|
+
],
|
|
1281
|
+
};
|
|
1282
|
+
const definition: WorkflowDefinition = {
|
|
1283
|
+
id: 'wfl-mixed-resume', name: 'wfl-mixed-resume', version: '1', settings: {}, stages: [stage],
|
|
1284
|
+
steps: [stepRouter, stepB, stepC, stepEnd],
|
|
1285
|
+
};
|
|
1286
|
+
const routerSeg: WorkflowTaskState['executedSteps'][number] = {
|
|
1287
|
+
id: 'exec-router', stepId: 'step-router', stageId: 'stg-1', name: 'Router', status: 'completed',
|
|
1288
|
+
visitCount: 1, createdAt: '2026-08-16T00:00:00.000Z', attemptIndex: 0,
|
|
1289
|
+
};
|
|
1290
|
+
const runningB: WorkflowTaskState['executedSteps'][number] = {
|
|
1291
|
+
id: 'exec-b', stepId: 'step-b', stageId: 'stg-1', name: 'B', status: 'running',
|
|
1292
|
+
command: { type: 'agent', value: { promptText: 'do B' } },
|
|
1293
|
+
sessionMeta: { childId: 'child-1', synthetic: false, command: 'agent' },
|
|
1294
|
+
visitCount: 1, createdAt: '2026-08-16T00:00:00.000Z', attemptIndex: 0,
|
|
1295
|
+
};
|
|
1296
|
+
const resumeFrom: StageExecutorResume = {
|
|
1297
|
+
completedSteps: [routerSeg],
|
|
1298
|
+
resumeSubagents: [{ step: stepB, executed: runningB, childId: 'child-1' }],
|
|
1299
|
+
// 快照 pendingQueue:step-c 正常拉起;step-b 命中 resumeSegments → initState 去重跳过。
|
|
1300
|
+
pendingQueue: ['step-c', 'step-b'],
|
|
1301
|
+
};
|
|
1302
|
+
const { shell, agents, makeExecutor } = harness();
|
|
1303
|
+
// resume 段续跑:子代理 child-1 已有输出(whenIdle + finalAssistantOutput 回退路径)。
|
|
1304
|
+
const child = new FakeAgent('child-1');
|
|
1305
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'resumed B' }]));
|
|
1306
|
+
agents.register(child);
|
|
1307
|
+
// step-end 延迟完成:确保两个分支先后路由到同一后继时 running 去重生效(不二次执行)。
|
|
1308
|
+
shell.runHandler = async spec => {
|
|
1309
|
+
if (spec.command === 'echo end') {
|
|
1310
|
+
await new Promise<void>(resolve => setTimeout(resolve, 20));
|
|
1311
|
+
}
|
|
1312
|
+
return shellResult(spec, 'done');
|
|
1313
|
+
};
|
|
1314
|
+
|
|
1315
|
+
const executor = makeExecutor({ stageDef: stage, workflowDef: definition, resumeFrom });
|
|
1316
|
+
const result = await executor.execute();
|
|
1317
|
+
|
|
1318
|
+
expect(result.status).toBe('completed');
|
|
1319
|
+
// resume 段走 resumeSegments 分流:沿用原段 id(exec-b),不新建执行段(非重新执行)。
|
|
1320
|
+
const bSegs = result.executedSteps.filter(s => s.stepId === 'step-b');
|
|
1321
|
+
expect(bSegs).toHaveLength(1);
|
|
1322
|
+
expect(bSegs[0]?.id).toBe('exec-b');
|
|
1323
|
+
expect(bSegs[0]?.status).toBe('completed');
|
|
1324
|
+
// 快照节点 step-c 正常拉起执行(新段)。
|
|
1325
|
+
const cSegs = result.executedSteps.filter(s => s.stepId === 'step-c');
|
|
1326
|
+
expect(cSegs).toHaveLength(1);
|
|
1327
|
+
expect(cSegs[0]?.status).toBe('completed');
|
|
1328
|
+
// 汇聚 step-end 恰好一次(两分支先后路由到同一后继 → enqueue/running 去重生效)。
|
|
1329
|
+
const endSegs = result.executedSteps.filter(s => s.stepId === 'step-end');
|
|
1330
|
+
expect(endSegs).toHaveLength(1);
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
// 新增单测 ⑤:终态优先级——并发 failed + paused → 最终 failed(runLoop 末尾 re-assert,G11)。
|
|
1334
|
+
it('concurrent failed + paused settle to failed (terminal priority re-assert)', async () => {
|
|
1335
|
+
const stepRouter: WorkflowStep = { id: 'step-router', name: 'Router' };
|
|
1336
|
+
const stepA: WorkflowStep = { id: 'step-a', name: 'A', command: { type: 'agent', value: { promptText: 'do A' } } };
|
|
1337
|
+
const stepB: WorkflowStep = { id: 'step-b', name: 'B', command: { type: 'shell', value: { command: 'echo fail' } } };
|
|
1338
|
+
const stepEnd: WorkflowStep = { id: 'step-end', name: 'End', command: { type: 'shell', value: { command: 'echo end' } } };
|
|
1339
|
+
const stage: WorkflowStage = {
|
|
1340
|
+
id: 'stg-1', name: 'Priority',
|
|
1341
|
+
steps: [
|
|
1342
|
+
{ id: 'step-router', branches: [{ next: 'step-a' }, { next: 'step-b' }] },
|
|
1343
|
+
{ id: 'step-a', branches: [{ next: 'step-end' }] },
|
|
1344
|
+
{ id: 'step-b', branches: [{ next: 'step-end' }] },
|
|
1345
|
+
{ id: 'step-end' },
|
|
1346
|
+
],
|
|
1347
|
+
};
|
|
1348
|
+
const definition: WorkflowDefinition = {
|
|
1349
|
+
id: 'wfl-priority', name: 'wfl-priority', version: '1', settings: {}, stages: [stage],
|
|
1350
|
+
steps: [stepRouter, stepA, stepB, stepEnd],
|
|
1351
|
+
};
|
|
1352
|
+
const ctrl = settleController();
|
|
1353
|
+
const { llm, shell, agents, makeExecutor } = harness();
|
|
1354
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
1355
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
1356
|
+
const child = new FakeAgent('child-1');
|
|
1357
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'A output' }]));
|
|
1358
|
+
agents.register(child);
|
|
1359
|
+
// step-b 立即失败;step-a(agent)等 settle(aborted → 暂停)。
|
|
1360
|
+
shell.runHandler = async spec => shellResult(spec, '', 1);
|
|
1361
|
+
const commits: Array<{ status?: string }> = [];
|
|
1362
|
+
const executor = makeExecutor({
|
|
1363
|
+
stageDef: stage,
|
|
1364
|
+
workflowDef: definition,
|
|
1365
|
+
runnerConfig: { defaultTimeoutMs: 60000, awaitSubagentSettlement: ctrl.awaitSubagentSettlement },
|
|
1366
|
+
commitState: patch => commits.push(patch),
|
|
1367
|
+
});
|
|
1368
|
+
const resultP = executor.execute();
|
|
1369
|
+
// 等 step-b 失败先落终态(failed),再让 step-a 暂停(paused 的 last-write-wins 覆盖
|
|
1370
|
+
// failed —— 由 runLoop 末尾 terminalPriority re-assert 纠正回 failed,G11)。
|
|
1371
|
+
await new Promise<void>(resolve => setTimeout(resolve, 10));
|
|
1372
|
+
ctrl.settle('child-1', 'aborted');
|
|
1373
|
+
const result = await resultP;
|
|
1374
|
+
|
|
1375
|
+
expect(result.status).toBe('failed');
|
|
1376
|
+
// step-a 段 paused(暂停落段)、step-b 段 failed。
|
|
1377
|
+
expect(result.executedSteps.find(s => s.stepId === 'step-a')?.status).toBe('paused');
|
|
1378
|
+
expect(result.executedSteps.find(s => s.stepId === 'step-b')?.status).toBe('failed');
|
|
1379
|
+
// 终止态跳过剩余节点:step-end 不执行。
|
|
1380
|
+
expect(result.executedSteps.some(s => s.stepId === 'step-end')).toBe(false);
|
|
1381
|
+
// re-assert 兜底:最后一次 status commit 为 failed(paused 覆盖 failed 被纠正)。
|
|
1382
|
+
const lastStatusCommit = [...commits].reverse().find(p => p.status !== undefined);
|
|
1383
|
+
expect(lastStatusCommit?.status).toBe('failed');
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
// 新增单测 ⑥:resume 空队列——resumeFrom 仅 completedSteps(反推空 next)→ completed(循环末兜底)。
|
|
1387
|
+
it('resumeFrom with only completedSteps and empty reverse-derived nexts → status completed', async () => {
|
|
1388
|
+
const { definition, stage } = singleShellDefinition();
|
|
1389
|
+
const completed: WorkflowTaskState['executedSteps'][number] = {
|
|
1390
|
+
id: 'exec-sh', stepId: 'step-sh', stageId: 'stg-1', name: 'echo', status: 'completed',
|
|
1391
|
+
command: { type: 'shell', value: { command: 'echo hi' } },
|
|
1392
|
+
sessionMeta: { childId: 'step-sh-synthetic', synthetic: true, command: 'shell' },
|
|
1393
|
+
visitCount: 1, createdAt: '2026-08-16T00:00:00.000Z', attemptIndex: 0,
|
|
1394
|
+
output: [{ type: 'text', text: 'hi' }],
|
|
1395
|
+
};
|
|
1396
|
+
const resumeFrom: StageExecutorResume = {
|
|
1397
|
+
completedSteps: [completed],
|
|
1398
|
+
resumeBetweenSteps: true,
|
|
1399
|
+
};
|
|
1400
|
+
const { shell, makeExecutor } = harness();
|
|
1401
|
+
shell.runHandler = async spec => shellResult(spec, 'hi');
|
|
1402
|
+
|
|
1403
|
+
const executor = makeExecutor({ stageDef: stage, workflowDef: definition, resumeFrom });
|
|
1404
|
+
const result = await executor.execute();
|
|
1405
|
+
|
|
1406
|
+
// 反推空 next → pendingQueue 空 → 循环末兜底 completed(不重跑已完成 Step)。
|
|
1407
|
+
expect(result.status).toBe('completed');
|
|
1408
|
+
expect(result.executedSteps).toHaveLength(1);
|
|
1409
|
+
expect(result.executedSteps[0]?.id).toBe('exec-sh');
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1412
|
+
// 新增单测 ⑦:limiter 等待窗口终态跳过——acquire 期间另一节点失败 → acquire 后返回 skipped 不执行。
|
|
1413
|
+
it('limiter waiting window: terminal state before acquire → startNode returns skipped without executing', async () => {
|
|
1414
|
+
const { definition, stage } = parallelBranchDefinition();
|
|
1415
|
+
const { shell, makeExecutor } = harness();
|
|
1416
|
+
// step-a(echo a)持有槽位并失败;step-b(echo b)排队等待 acquire。
|
|
1417
|
+
const startedCommands: string[] = [];
|
|
1418
|
+
shell.runHandler = async spec => {
|
|
1419
|
+
startedCommands.push(spec.command);
|
|
1420
|
+
return spec.command === 'echo a' ? shellResult(spec, '', 1) : shellResult(spec, 'done');
|
|
1421
|
+
};
|
|
1422
|
+
const limiter = new TrackingLimiter(1);
|
|
1423
|
+
const executor = makeExecutor({ stageDef: stage, workflowDef: definition, concurrencyLimiter: limiter });
|
|
1424
|
+
const result = await executor.execute();
|
|
1425
|
+
|
|
1426
|
+
expect(result.status).toBe('failed');
|
|
1427
|
+
// step-a 段 failed 落盘;step-b 未执行(acquire 后终态检查返回 skipped:不产段、不跑 shell)。
|
|
1428
|
+
expect(result.executedSteps.find(s => s.stepId === 'step-a')?.status).toBe('failed');
|
|
1429
|
+
expect(result.executedSteps.some(s => s.stepId === 'step-b')).toBe(false);
|
|
1430
|
+
expect(startedCommands).not.toContain('echo b');
|
|
1431
|
+
});
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
// ── 端到端复现:崩溃恢复后 loop maxIterations 计数不虚高(resume 节点出队不重复 +1) ──
|
|
1435
|
+
//
|
|
1436
|
+
// 2026-08-25 实测根因:实例(1cf77eea)内层循环配置 maxIterations=3 只跑了 2 次——
|
|
1437
|
+
// resumeInfo 把崩溃活跃段放入 resumeSubagents,initState 又将其重新入队(initialQueue),
|
|
1438
|
+
// 恢复后 runLoop 出队时 visitCount 再次 +1(虚高,如 sce1 执行 1 段却计数 2)→
|
|
1439
|
+
// checkLoopBreak 的 `visitCount >= maxIterations` 提前命中 → 循环体少跑一次。
|
|
1440
|
+
// 修复:resumeSegments 命中的节点出队不 +1(续跑同一执行段,非新访问)。
|
|
1441
|
+
describe('StageExecutor e2e:崩溃恢复 loop 计数(resume 节点不重复计 visitCount)', () => {
|
|
1442
|
+
it('resume 续跑后 maxIterations=3 循环体仍执行满 3 次', async () => {
|
|
1443
|
+
const sce2: WorkflowStep = { id: 'sce2', name: 'sce2', command: { type: 'shell', value: { command: 'echo sce2' } } };
|
|
1444
|
+
const fix: WorkflowStep = {
|
|
1445
|
+
id: 'fix', name: 'fix',
|
|
1446
|
+
command: { type: 'agent', value: { promptText: 'fix counts' } },
|
|
1447
|
+
branches: [{
|
|
1448
|
+
next: 'sce2',
|
|
1449
|
+
loop: { next: 'sce2', maxIterations: 3, exitTo: 'done' },
|
|
1450
|
+
}],
|
|
1451
|
+
};
|
|
1452
|
+
const done: WorkflowStep = { id: 'done', name: 'done', command: { type: 'shell', value: { command: 'echo done' } } };
|
|
1453
|
+
const stage: WorkflowStage = {
|
|
1454
|
+
id: 'stg-1', name: 'demo1',
|
|
1455
|
+
steps: [{ id: 'sce2' }, { id: 'fix', branches: [{ next: 'sce2', loop: { next: 'sce2', maxIterations: 3, exitTo: 'done' } }] }, { id: 'done' }],
|
|
1456
|
+
};
|
|
1457
|
+
const definition: WorkflowDefinition = {
|
|
1458
|
+
id: 'wfl-loop-resume', name: 'wfl-loop-resume', version: '1', stages: [stage],
|
|
1459
|
+
steps: [sce2, fix, done], settings: {},
|
|
1460
|
+
};
|
|
1461
|
+
const { agents, llm, shell, makeExecutor } = harness();
|
|
1462
|
+
shell.runHandler = async spec => shellResult(spec, 'done');
|
|
1463
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
1464
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
1465
|
+
// fix 的子代理(resume 续跑与正常执行共用;同一 child 复用)。
|
|
1466
|
+
const child = new FakeAgent('child-1');
|
|
1467
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'fix done' }]));
|
|
1468
|
+
agents.register(child);
|
|
1469
|
+
// 第 3 次 fix 正常执行的 startContinuable 会生成 child-2(FakeSubagents 递增 index)。
|
|
1470
|
+
const child2 = new FakeAgent('child-2');
|
|
1471
|
+
child2.session.append(assistantMessageEvent([{ type: 'text', text: 'fix done 2' }]));
|
|
1472
|
+
agents.register(child2);
|
|
1473
|
+
|
|
1474
|
+
// 崩溃快照(循环体顺序 sce2 → fix):sce2(0)、sce2(1)、fix(0) 已 completed;
|
|
1475
|
+
// fix(1) 正在跑(活跃段,第 2 次循环的 fix);pendingQueue=[fix]。
|
|
1476
|
+
const completedSteps = [
|
|
1477
|
+
{ id: 'exec-1', stepId: 'sce2', stageId: 'stg-1', name: 'sce2', status: 'completed', visitCount: 1 },
|
|
1478
|
+
{ id: 'exec-1b', stepId: 'sce2', stageId: 'stg-1', name: 'sce2', status: 'completed', visitCount: 2 },
|
|
1479
|
+
{ id: 'exec-2', stepId: 'fix', stageId: 'stg-1', name: 'fix', status: 'completed', visitCount: 1 },
|
|
1480
|
+
];
|
|
1481
|
+
const resumeFrom: StageExecutorResume = {
|
|
1482
|
+
completedSteps,
|
|
1483
|
+
resumeSubagents: [{
|
|
1484
|
+
step: fix,
|
|
1485
|
+
executed: { id: 'exec-3', stepId: 'fix', stageId: 'stg-1', name: 'fix', status: 'running', visitCount: 2, sessionMeta: { childId: 'child-1', synthetic: false, command: 'agent' } },
|
|
1486
|
+
childId: 'child-1',
|
|
1487
|
+
}],
|
|
1488
|
+
pendingQueue: ['fix'],
|
|
1489
|
+
};
|
|
1490
|
+
|
|
1491
|
+
const visitCounts = { sce2: 2, fix: 2 }; // 崩溃前出队计数:sce2(0,1)、fix(0,1)
|
|
1492
|
+
const executor = makeExecutor({
|
|
1493
|
+
stageDef: stage,
|
|
1494
|
+
workflowDef: definition,
|
|
1495
|
+
resumeFrom,
|
|
1496
|
+
visitCounts,
|
|
1497
|
+
});
|
|
1498
|
+
const result = await executor.execute();
|
|
1499
|
+
|
|
1500
|
+
expect(result.status).toBe('completed');
|
|
1501
|
+
// 循环体执行满 3 次(maxIterations=3):sce2 段 3 个(exec-1/exec-1b + 1 个新)、
|
|
1502
|
+
// fix 段 3 个(exec-2 + exec-3 续跑 + 1 个新)。
|
|
1503
|
+
const sce2Segs = result.executedSteps.filter(s => s.stepId === 'sce2');
|
|
1504
|
+
const fixSegs = result.executedSteps.filter(s => s.stepId === 'fix');
|
|
1505
|
+
expect(sce2Segs).toHaveLength(3);
|
|
1506
|
+
expect(fixSegs).toHaveLength(3);
|
|
1507
|
+
expect(fixSegs.map(s => s.status)).toEqual(['completed', 'completed', 'completed']);
|
|
1508
|
+
// done 恰好一次(exitTo)。
|
|
1509
|
+
expect(result.executedSteps.filter(s => s.stepId === 'done')).toHaveLength(1);
|
|
1510
|
+
});
|
|
1511
|
+
});
|
|
1512
|
+
//
|
|
1513
|
+
// 2026-08-25 实测根因:outputTo 路径引用本 step 提取的透传变量(`artifacts/${change-name}/...`)
|
|
1514
|
+
// 时,「buildFinalizeContext 早期收集」用 pre-finalize vars(变量未写回)→ 收集空 → 段快照
|
|
1515
|
+
// artifacts 不写 → 导航无产物;而门禁用「累加器 + 当次提取」合并 vars → 能解析 → 门禁通过
|
|
1516
|
+
// (「门禁过但导航无产物」)。修复:buildPhaseBPreviousOutput(Phase B 后、变量已写回)的
|
|
1517
|
+
// 收集结果写回 executed.artifacts,保证最终产物与门禁一致。
|
|
1518
|
+
describe('StageExecutor e2e:自引用透传变量产物(导航产物缺失根因复现)', () => {
|
|
1519
|
+
it('Phase B 写回后收集产物 → executed.artifacts 与 stage 契约一致', async () => {
|
|
1520
|
+
const stepA: WorkflowStep = {
|
|
1521
|
+
id: 'step-a', name: 'sce1',
|
|
1522
|
+
command: { type: 'agent', value: { promptText: 'generate proposal' } },
|
|
1523
|
+
passThroughVars: [{ name: 'change-name', value: '', description: '当前变更名称' }],
|
|
1524
|
+
outputTo: [{ id: 'opt-1', path: 'artifacts/${change-name}/proposal.md' }],
|
|
1525
|
+
// 门禁启用 → runPhaseA/B 的跳过谓词(无未解析变量且无门禁)不命中 → 评估提取执行
|
|
1526
|
+
// (与真实工作流一致:有门禁的 step 才会跑收尾评估提取透传变量)。
|
|
1527
|
+
gates: { primary: { enabled: true } },
|
|
1528
|
+
};
|
|
1529
|
+
const stage: WorkflowStage = { id: 'stg-1', name: 'demo1', steps: [{ id: 'step-a' }] };
|
|
1530
|
+
const definition: WorkflowDefinition = {
|
|
1531
|
+
id: 'wfl-self-var', name: 'wfl-self-var', version: '1', stages: [stage], steps: [stepA], settings: {},
|
|
1532
|
+
};
|
|
1533
|
+
const { llm, agents, fs, makeExecutor } = harness();
|
|
1534
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
1535
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
1536
|
+
// Phase A 临时会话:提取为空(ask 判定 false)。
|
|
1537
|
+
llm.streamHandler = () => textChunks('{"hasAsk":false,"passThroughVars":{}}');
|
|
1538
|
+
const child = new FakeAgent('child-1');
|
|
1539
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'proposal generated' }]));
|
|
1540
|
+
agents.register(child);
|
|
1541
|
+
// Phase B 真 fork 评估回复:提取 change-name=book-management(LLM 从输出提取)。
|
|
1542
|
+
agents.createFollowupHandler = () => [{ type: 'text', text: '{"passThroughVars":{"change-name":"book-management"},"compressedOutput":"proposal 摘要"}' }];
|
|
1543
|
+
// 产物文件已由子代理生成(outputTo 命中路径)。
|
|
1544
|
+
fs.seedFile('/tmp/artifacts/book-management/proposal.md');
|
|
1545
|
+
// 源子代理完整事件(turn 0 闭合:start/user/assistant/end,供 forkStepSubagent 重建)。
|
|
1546
|
+
const childEvents = (): Array<{ type: string; seq: number; time: number; data: unknown }> => [
|
|
1547
|
+
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 0 } },
|
|
1548
|
+
{ type: 'user/message', seq: 1, time: 0, data: { id: 'u1', content: [{ type: 'text', text: 'do work' }] } },
|
|
1549
|
+
{ type: 'assistant/message', seq: 2, time: 0, data: { turn: 0, step: 0, message: { id: 'm2', role: 'assistant', content: [{ type: 'text', text: 'proposal generated' }], source: { kind: 'model', provider: 'p', model: 'm' } } } },
|
|
1550
|
+
{ type: 'turn/end', seq: 3, time: 0, data: { turn: 0, reason: { kind: 'completed' } } },
|
|
1551
|
+
];
|
|
1552
|
+
|
|
1553
|
+
const executor = makeExecutor(
|
|
1554
|
+
{
|
|
1555
|
+
stageDef: stage,
|
|
1556
|
+
workflowDef: definition,
|
|
1557
|
+
hooks: { onStepStart: () => {}, onStepCompleted: () => {}, onStepFailed: () => {} },
|
|
1558
|
+
},
|
|
1559
|
+
{
|
|
1560
|
+
defaultTimeoutMs: 60000, defaultProvider: 'p', defaultModel: 'm',
|
|
1561
|
+
hostAgentId: 'host-1',
|
|
1562
|
+
createAgent: options => agents.create(options),
|
|
1563
|
+
getStepSessionEvents: async childId => (childId === 'child-1' ? childEvents() : undefined),
|
|
1564
|
+
},
|
|
1565
|
+
);
|
|
1566
|
+
const result = await executor.execute();
|
|
1567
|
+
|
|
1568
|
+
expect(result.status).toBe('completed');
|
|
1569
|
+
// ① 写回:引擎累加器含提取值(Phase B commitFinalize('finalizing'))。
|
|
1570
|
+
expect(result.passThroughVars['change-name']).toEqual({ value: 'book-management', description: '当前变更名称' });
|
|
1571
|
+
// ② 段快照产物:自引用变量解析后收集成功(修复前为 undefined)。
|
|
1572
|
+
const segment = result.executedSteps.find(s => s.stepId === 'step-a')!;
|
|
1573
|
+
expect(segment.artifacts).toEqual(['/tmp/artifacts/book-management/proposal.md']);
|
|
1574
|
+
// ③ stage 契约产物一致(stage 级权威汇总)。
|
|
1575
|
+
expect(result.stageContract.artifacts[0]?.files).toEqual(['/tmp/artifacts/book-management/proposal.md']);
|
|
1576
|
+
});
|
|
1577
|
+
});
|