@hoplogic/hopjit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/dist/act-body-interpreter.d.ts +24 -0
  4. package/dist/act-body-interpreter.js +159 -0
  5. package/dist/act-body-parser.d.ts +10 -0
  6. package/dist/act-body-parser.js +577 -0
  7. package/dist/act-builtins.d.ts +12 -0
  8. package/dist/act-builtins.js +104 -0
  9. package/dist/ast-helpers.d.ts +26 -0
  10. package/dist/ast-helpers.js +58 -0
  11. package/dist/ast-runtime.d.ts +49 -0
  12. package/dist/ast-runtime.js +224 -0
  13. package/dist/ast-types.d.ts +260 -0
  14. package/dist/ast-types.js +4 -0
  15. package/dist/cli-types.d.ts +190 -0
  16. package/dist/cli-types.js +4 -0
  17. package/dist/cli.d.ts +26 -0
  18. package/dist/cli.js +623 -0
  19. package/dist/dispatcher.d.ts +92 -0
  20. package/dist/dispatcher.js +692 -0
  21. package/dist/doc-ref.d.ts +41 -0
  22. package/dist/doc-ref.js +214 -0
  23. package/dist/engine-traverse.d.ts +37 -0
  24. package/dist/engine-traverse.js +385 -0
  25. package/dist/engine.d.ts +141 -0
  26. package/dist/engine.js +1792 -0
  27. package/dist/errors.d.ts +47 -0
  28. package/dist/errors.js +34 -0
  29. package/dist/hoplog.d.ts +120 -0
  30. package/dist/hoplog.js +501 -0
  31. package/dist/parser.d.ts +7 -0
  32. package/dist/parser.js +802 -0
  33. package/dist/persistence.d.ts +60 -0
  34. package/dist/persistence.js +208 -0
  35. package/dist/prompt.d.ts +130 -0
  36. package/dist/prompt.js +1014 -0
  37. package/dist/provider-types.d.ts +134 -0
  38. package/dist/provider-types.js +3 -0
  39. package/dist/runtime-types.d.ts +84 -0
  40. package/dist/runtime-types.js +4 -0
  41. package/dist/tools.d.ts +16 -0
  42. package/dist/tools.js +141 -0
  43. package/dist/validator.d.ts +23 -0
  44. package/dist/validator.js +959 -0
  45. package/driver/codex/AGENTS.md +54 -0
  46. package/driver/hopskill-build/SKILL.md +137 -0
  47. package/driver/hopskill-build/references/spec-skeleton.md +132 -0
  48. package/driver/hopskill-build/references/step-type-cheatsheet.md +56 -0
  49. package/driver/hopskill-build/references/three-focus-rules.md +148 -0
  50. package/driver/hopspec-skill.md +187 -0
  51. package/driver/references/cli-discovery.md +36 -0
  52. package/driver/references/discovery.md +45 -0
  53. package/driver/references/driver-subagent.md +50 -0
  54. package/driver/references/parallel-worker.md +50 -0
  55. package/driver/references/step-execution-rules.md +47 -0
  56. package/package.json +52 -0
package/dist/engine.js ADDED
@@ -0,0 +1,1792 @@
1
+ // @module: exec-engine ^anc-struct-exec-engine
2
+ import { randomUUID } from 'node:crypto';
3
+ import { writeFileSync, mkdirSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { parseSpec } from './parser.js';
6
+ import { validateSpec, validateOutputValues, formatSchemaMismatch } from './validator.js';
7
+ import { VariableStore, getWriteScope, isTerminalStatus } from './ast-runtime.js';
8
+ import { dfsNextStep, collectParallelBatch, propagateCompletion, collectDescendants, } from './engine-traverse.js';
9
+ import { FilePersistence, MemoryPersistence, writeChildParams, readChildParams, stateExists, readState } from './persistence.js';
10
+ import { HopLog, timestamp } from './hoplog.js';
11
+ import { resolveDocRefs, formatDocRefContext, DocRefError } from './doc-ref.js';
12
+ import { getParentStepId } from './ast-helpers.js';
13
+ import { PromptAssembler, formatPromptText, formatParallelPromptText, formatHumanContext } from './prompt.js';
14
+ import { hasChildren, getChildren, DEFLATE_THRESHOLD, isUpdateModeOutput } from './ast-helpers.js';
15
+ import { ErrorCode } from './errors.js';
16
+ import { bodyHasToolCall } from './act-body-parser.js';
17
+ import { BodyInterpreter } from './act-body-interpreter.js';
18
+ // confirm 拒绝类决策值(小写匹配)→ failStep。见 ^anc-exec-confirm-answer
19
+ const CONFIRM_REJECT_VALUES = new Set(['reject', 'rejected', 'deny', 'denied', 'no']);
20
+ // 哑 ToolProvider:引擎消化纯计算 body 时用——无工具 body 不会调 execute(bodyHasToolCall 已保证);
21
+ // 若误调说明 isCallerActionPoint 判定漏了工具,抛错暴露 bug。见 ^anc-exec-advance-to-caller
22
+ const ENGINE_NOOP_TOOL_PROVIDER = {
23
+ list: () => [],
24
+ execute: async (name) => {
25
+ throw new Error(`TOOL_EXEC_ERROR: 引擎消化纯计算 body 不应调工具 "${name}"——isCallerActionPoint 判定遗漏`);
26
+ },
27
+ };
28
+ /** HopJIT 执行状态机——驱动 HopSpec 从初始化到终态,管理步骤状态机/树状变量作用域/retry-adaptive 配额/None 传播。见 [[exec-engine#^anc-struct-exec-engine]] */
29
+ export class ExecutionEngine {
30
+ instanceId = '';
31
+ spec = null;
32
+ hostConfig = null;
33
+ stepStates = new Map();
34
+ variables = new VariableStore();
35
+ loopCounters = new Map();
36
+ retryCounters = new Map();
37
+ retryHistory = new Map();
38
+ replanCounters = new Map();
39
+ lastReplanChildren = new Map();
40
+ adaptiveNeededSubtask = null;
41
+ execEvents = [];
42
+ stepFailReasons = new Map();
43
+ stateDir = null;
44
+ instanceDir = null;
45
+ persistence = null;
46
+ logDir = null;
47
+ hoplog = null;
48
+ canFanout = false; // @a: anc-exec-parallel-canfanout — 顶层 true / worker 子实例 false。持久化进 StateFile
49
+ subtreeRoot = null; // @a: anc-exec-parallel-subinstance — worker 子实例执行 scope 收窄到的 child 子树根
50
+ specPath = null; // spec 文件路径(CLI 传入,parallel prompt 组装用)
51
+ cliAbsPath = null; // CLI 入口绝对路径
52
+ // fan-out CLI 调度顾问:parallel_step_id → 已派发 child_step_id 列表。CLI 派发即原子记入(防重复派发)。
53
+ // 见 design/parallel-execution.md ^anc-exec-parallel-fanout-advisor。// @a: anc-exec-parallel-fanout-advisor
54
+ dispatched = new Map();
55
+ joinStartedAt = null; // join 单进程内瞬态:进入 joinParallel 的时刻,join 块 started_at(不持久化)
56
+ // fan-out 窗口上限,跨进程持久化(host_context 不含 resource_limits,load 后 hostConfig 丢失窗口值)。
57
+ maxConcurrent = null;
58
+ forEachWorkerIdx = null; // for-each worker 在列表中的序号(itemVar 自播种用)
59
+ contextMode = 'full'; // @a: anc-exec-context-mode — context 精简档;run 时定、持久化,env 可覆盖
60
+ initExecution(specMarkdown, hostConfig, options) {
61
+ const { ast, errors: parseErrors } = parseSpec(specMarkdown);
62
+ if (parseErrors.length > 0) {
63
+ return { status: 'error', errors: parseErrors };
64
+ }
65
+ // P15 doc-ref 静态校验需 workspace + sandbox(hostConfig 提供)。// @a: anc-rule-p15
66
+ const validationErrors = validateSpec(ast, hostConfig.workspace_dir ? { workspace_dir: hostConfig.workspace_dir, sandbox: hostConfig.sandbox } : undefined);
67
+ const fatalErrors = validationErrors.filter((e) => e.severity === 'error');
68
+ if (fatalErrors.length > 0) {
69
+ return { status: 'error', errors: fatalErrors };
70
+ }
71
+ // warn 级不阻断,透传到 InitSuccess.warnings(警告被吞掉等于不存在)
72
+ const warnings = validationErrors.filter((e) => e.severity === 'warn');
73
+ this.spec = ast;
74
+ this.hostConfig = hostConfig;
75
+ this.canFanout = options?.canFanout ?? false;
76
+ this.subtreeRoot = options?.subtreeRoot ?? null;
77
+ this.specPath = options?.specPath ?? null;
78
+ this.cliAbsPath = options?.cliAbsPath ?? null;
79
+ this.contextMode = options?.contextMode ?? 'full';
80
+ // call 子实例用 callStepId 作 instanceId(落 .hopstate/<parent>/calls/<callStepId>/);否则随机 UUID
81
+ this.instanceId = options?.callStepId ?? randomUUID();
82
+ this.setSubtreePending(ast.steps ?? []);
83
+ // parallel worker 子实例:scope 收窄到 child 子树(子树外预标 skipped)
84
+ if (this.subtreeRoot) {
85
+ this.executeSubtreeOnly(this.subtreeRoot, ast.steps ?? []);
86
+ }
87
+ const inputs = {};
88
+ if (ast.header.inputs) {
89
+ for (const decl of ast.header.inputs) {
90
+ inputs[decl.name] = decl.default ?? undefined;
91
+ }
92
+ }
93
+ // params 实参覆盖 Inputs 声明的 default(在此注入,确保随下方 persist 一并落盘)
94
+ if (options?.params) {
95
+ for (const [k, v] of Object.entries(options.params)) {
96
+ inputs[k] = v;
97
+ }
98
+ }
99
+ // for-each worker itemVar:引擎单一权威,fan-out 落盘 + worker init 按 cid 回填(driver 零参与)。
100
+ // 若 itemVar 不在 --params(显式覆盖,优先),从本 worker instance 目录 params.json 回填引擎备好的真值。
101
+ // 回填只读自己那份(cid 定位),不翻父/兄弟 vars。回填源缺失 → 响亮失败,而非静默错值。
102
+ // 见 design/parallel-execution.md §9e ^anc-exec-parallel-foreach-worker。// @a: anc-exec-parallel-foreach-worker
103
+ if (this.subtreeRoot) {
104
+ const parentId = getParentStepId(this.subtreeRoot);
105
+ const parentParallel = parentId ? this.findStepById(parentId, ast.steps ?? []) : null;
106
+ const forEach = parentParallel?.forEach;
107
+ if (forEach && !(forEach.itemVar in inputs)) {
108
+ // 按 cid 回填:worker instance 目录 = <stateDir>/<cid>(cli 已把 stateDir 解析到 <parent>/parallel)。
109
+ // 该目录由父引擎 fan-out 时 writeChildParams 创建并写入 params.json。
110
+ const childParams = options?.stateDir
111
+ ? readChildParams(join(options.stateDir, this.instanceId)) : null;
112
+ if (childParams) {
113
+ for (const [k, v] of Object.entries(childParams)) {
114
+ if (!(k in inputs))
115
+ inputs[k] = v; // 显式 --params 键优先,不覆盖
116
+ }
117
+ }
118
+ if (!(forEach.itemVar in inputs)) {
119
+ return { status: 'error', errors: [{
120
+ kind: 'validate', rule: 'MISSING_INPUT', severity: 'error',
121
+ message: `for-each worker '${this.subtreeRoot}' 缺 itemVar '${forEach.itemVar}'——引擎未按 cid 备料 params_for_child(fan-out 落盘缺失或 worker 被手工错启动,见 ^anc-exec-parallel-foreach-worker)`,
122
+ }] };
123
+ }
124
+ }
125
+ }
126
+ inputs['instance_id'] = this.instanceId;
127
+ inputs['parent_instance_id'] = options?.parentInstanceId;
128
+ this.variables = new VariableStore(inputs);
129
+ if (options?.logDir) {
130
+ this.logDir = options.logDir;
131
+ this.hoplog = new HopLog({
132
+ specId: ast.header.id ?? 'unnamed',
133
+ logDir: options.logDir,
134
+ level: options.logLevel,
135
+ title: ast.header.title,
136
+ goal: ast.header.goal ?? '',
137
+ inputs: inputs,
138
+ traceId: options.traceId ?? this.instanceId, // 顶层用 instanceId,worker 继承父的 instanceId → 父子 trace_id 一致
139
+ });
140
+ }
141
+ // Persistence: explicit provider > stateDir→FilePersistence > MemoryPersistence (standalone default)
142
+ if (options?.persistence) {
143
+ this.persistence = options.persistence;
144
+ }
145
+ else if (options?.stateDir) {
146
+ this.stateDir = options.stateDir;
147
+ this.persistence = new FilePersistence(options.stateDir);
148
+ }
149
+ else {
150
+ this.persistence = new MemoryPersistence();
151
+ }
152
+ this.persistence.init(this.instanceId, ast);
153
+ if (this.persistence instanceof FilePersistence) {
154
+ this.instanceDir = this.persistence.getInstanceDir();
155
+ }
156
+ this.persist();
157
+ return {
158
+ status: 'ok',
159
+ instance_id: this.instanceId,
160
+ ...(warnings.length > 0 ? { warnings } : {}),
161
+ };
162
+ }
163
+ getSpec() { return this.spec; }
164
+ getInstanceId() { return this.instanceId; }
165
+ getStepStates() { return this.stepStates; }
166
+ getVariableStore() { return this.variables; }
167
+ getExecEvents() { return this.execEvents; }
168
+ getLoopCounters() { return this.loopCounters; }
169
+ getHopLog() { return this.hoplog; }
170
+ setCanFanout(on) { this.canFanout = on; }
171
+ getCanFanout() { return this.canFanout; }
172
+ getSubtreeRoot() { return this.subtreeRoot; }
173
+ setContextMode(mode) { this.contextMode = mode; } // env 覆盖用
174
+ getContextMode() { return this.contextMode; } // @a: anc-exec-context-mode
175
+ // minimal 且非首步(已有 step done/failed)→ 砍 L1 静态段 + L2 spec 级。见 ^anc-exec-context-mode
176
+ isMinimalNonFirst() {
177
+ if (this.contextMode !== 'minimal')
178
+ return false;
179
+ return [...this.stepStates.values()].some(s => s === 'done' || s === 'failed');
180
+ }
181
+ getInstanceDir() { return this.instanceDir; }
182
+ getHopLogRunDir() { return this.hoplog?.getRunDir() ?? null; }
183
+ /** 顶层步骤是否全部终态(done/failed/skipped)。终态判据单点复用 isTerminalStatus。 */
184
+ allTopLevelTerminal(steps) {
185
+ return steps.every(s => isTerminalStatus(this.stepStates.get(s.step_id)));
186
+ }
187
+ nextStep() {
188
+ if (!this.spec) {
189
+ return {
190
+ status: 'failed',
191
+ instance_id: this.instanceId,
192
+ failed_step_id: '',
193
+ failure_reason: 'Engine not initialized',
194
+ partial_outputs: {},
195
+ work_zone: this.getWorkZone(),
196
+ };
197
+ }
198
+ const steps = this.spec.steps ?? [];
199
+ const allTerminal = this.allTopLevelTerminal(steps);
200
+ if (allTerminal) {
201
+ const hasFailed = this.findFirstFailedStep(steps);
202
+ if (hasFailed) {
203
+ this.hoplog?.close('failed');
204
+ return {
205
+ status: 'failed',
206
+ instance_id: this.instanceId,
207
+ failed_step_id: hasFailed.step_id,
208
+ failure_reason: this.getFailureReason(hasFailed.step_id),
209
+ partial_outputs: this.deflateValues(this.collectOutputs()),
210
+ work_zone: this.getWorkZone(),
211
+ };
212
+ }
213
+ this.hoplog?.close('completed');
214
+ return {
215
+ status: 'completed',
216
+ instance_id: this.instanceId,
217
+ outputs: this.deflateValues(this.collectOutputs()),
218
+ work_zone: this.getWorkZone(),
219
+ };
220
+ }
221
+ if (this.adaptiveNeededSubtask) {
222
+ const subtaskId = this.adaptiveNeededSubtask;
223
+ const subtask = this.findStepById(subtaskId);
224
+ const history = this.retryHistory.get(subtaskId) ?? [];
225
+ const retryMax = subtask.retry ?? 1;
226
+ const remaining = this.retryCounters.get(subtaskId) ?? 0;
227
+ return {
228
+ status: 'adaptive_needed',
229
+ instance_id: this.instanceId,
230
+ spec_id: this.spec?.header.id ?? 'unnamed',
231
+ subtask_id: subtaskId,
232
+ failure: {
233
+ step_id: history.length > 0 ? history[history.length - 1].steps_tried[0]?.step_id ?? '' : '',
234
+ reason: history.length > 0 ? history[history.length - 1].failure_reason : '',
235
+ attempt: retryMax - remaining,
236
+ max_retries: retryMax,
237
+ },
238
+ subtask_contract: {
239
+ outputs: subtask.outputs ?? [],
240
+ constraints: [],
241
+ },
242
+ original_children: subtask.children.map(c => this.toStepSummary(c)),
243
+ retry_history: history,
244
+ };
245
+ }
246
+ const newlyRunning = [];
247
+ const state = {
248
+ stepStates: this.stepStates,
249
+ variables: this.variables,
250
+ loopCounters: this.loopCounters,
251
+ newlyRunning,
252
+ };
253
+ const result = dfsNextStep(steps, state, this.spec);
254
+ // 容器进入 running 时记录骨架(无 context——容器不是执行步骤) // @a: anc-obs-step-start-timing
255
+ for (const container of newlyRunning) {
256
+ this.hoplog?.recordStepStart(container.step_id, container.step_type, container.summary);
257
+ }
258
+ if (result.kind === 'retry') {
259
+ return this.nextStep();
260
+ }
261
+ if (result.kind === 'exit') {
262
+ this.hoplog?.close('completed');
263
+ return {
264
+ status: 'completed',
265
+ instance_id: this.instanceId,
266
+ outputs: this.deflateValues(this.collectOutputs()),
267
+ work_zone: this.getWorkZone(),
268
+ };
269
+ }
270
+ if (result.kind === 'none') {
271
+ const terminalNow = this.allTopLevelTerminal(steps);
272
+ if (terminalNow)
273
+ return this.nextStep();
274
+ this.hoplog?.close('failed');
275
+ return {
276
+ status: 'failed',
277
+ instance_id: this.instanceId,
278
+ failed_step_id: '',
279
+ failure_reason: 'No executable step found',
280
+ partial_outputs: this.deflateValues(this.collectOutputs()),
281
+ work_zone: this.getWorkZone(),
282
+ };
283
+ }
284
+ const target = result.step;
285
+ this.stepStates.set(target.step_id, 'running');
286
+ this.recordEvent(target.step_id, 'step_start');
287
+ // running 状态必须落盘——复用模式下 next 与 done 是独立进程,
288
+ // done 进程 resume 后需读到 running 才能 completeStep(否则误判 pending)
289
+ this.persist();
290
+ let context;
291
+ try {
292
+ context = this.assembleBasicContext(target);
293
+ }
294
+ catch (err) {
295
+ // doc-ref 找不到文件/章节 → 硬报错(确定性引用不静默降级)。// @a: anc-exec-doc-ref-resolve
296
+ const reason = err instanceof DocRefError
297
+ ? err.message
298
+ : `doc-ref 解析失败: ${err instanceof Error ? err.message : String(err)}`;
299
+ if (target.outputs) {
300
+ const scopeId = getWriteScope(target.step_id, this.spec);
301
+ this.variables.setOutputsToNull(target.outputs, scopeId);
302
+ }
303
+ this.stepStates.set(target.step_id, 'failed');
304
+ this.recordStepFailure(target.step_id, reason);
305
+ this.handleFailStepRetry(target.step_id, reason);
306
+ return this.nextStep();
307
+ }
308
+ // None propagation: auto-fail if any input resolves to null (from failed step)
309
+ if (target.inputs && target.inputs.length > 0) {
310
+ const hasNullInput = target.inputs.some(b => context.inputs[b.name] === null);
311
+ if (hasNullInput) {
312
+ if (target.outputs) {
313
+ const scopeId = getWriteScope(target.step_id, this.spec);
314
+ this.variables.setOutputsToNull(target.outputs, scopeId);
315
+ }
316
+ this.stepStates.set(target.step_id, 'failed');
317
+ this.recordStepFailure(target.step_id, 'MISSING_INPUT');
318
+ this.handleFailStepRetry(target.step_id, 'MISSING_INPUT');
319
+ return this.nextStep();
320
+ }
321
+ }
322
+ // confirm 是 CITL 审批闸门:返回 paused(approve/reject)。// @a: anc-cli-execution-paused, anc-exec-hitl-presentation
323
+ if (target.step_type === 'confirm') {
324
+ const confirmNode = target;
325
+ const options = confirmNode.response_options ?? [
326
+ { value: 'approve', label: '批准' },
327
+ { value: 'reject', label: '拒绝' },
328
+ ];
329
+ const response = {
330
+ status: 'paused',
331
+ instance_id: this.instanceId,
332
+ step_id: target.step_id,
333
+ pause_reason: (confirmNode.require_human ? 'waiting_human' : 'confirm'),
334
+ presented_data: {
335
+ summary: target.summary,
336
+ question: `审批:${target.summary}。${context.instruction}`,
337
+ instruction: context.instruction,
338
+ // 人通道:直读原值 + formatHumanContext(>5K 走 {$file, preview}),user 看到 preview 完整可读。
339
+ // 不用 context.inputs(已被 PromptAssembler 处理给 LLM 用)。见 ^anc-exec-audience-routing。
340
+ context: formatHumanContext(this.resolveRawInputs(target), this.getWorkZone()),
341
+ },
342
+ response_options: options,
343
+ work_zone: this.getWorkZone(),
344
+ };
345
+ this.hoplog?.recordStepStart(target.step_id, target.step_type, target.summary, context.inputs, formatPromptText(context, target.step_type));
346
+ this.flushDocRefMeta(target.step_id);
347
+ return response;
348
+ }
349
+ // ask 是 CITL 数据收集:返回自包含介入请求(question + output_schema + default + options)。
350
+ // 见概念 ^anc-step-ask / ^anc-exec-hitl-presentation。// @a: anc-step-ask, anc-exec-hitl-presentation
351
+ if (target.step_type === 'ask') {
352
+ // default_value:取第一个声明输出在 inputs 中的同名推断值(前序产出),无则 undefined
353
+ const firstOut = target.outputs?.[0]?.name;
354
+ const defaultValue = firstOut ? context.inputs[firstOut] : undefined;
355
+ // present_inputs:透传 AskStep 声明,driver 据此完整 dump 这些 context 字段(禁缩略)。
356
+ // 见 design/step-dispatcher.md ^anc-exec-hitl-presentation。// @a: anc-rule-p14, anc-exec-hitl-presentation
357
+ const askNode = target;
358
+ // options:从 inputs 里的列表型候选构造(可选,对齐 CC AskUserQuestion)
359
+ const response = {
360
+ status: 'paused',
361
+ instance_id: this.instanceId,
362
+ step_id: target.step_id,
363
+ pause_reason: 'ask',
364
+ presented_data: {
365
+ summary: target.summary,
366
+ question: `请提供:${target.summary}。${context.instruction}`,
367
+ instruction: context.instruction,
368
+ // 人通道:直读原值 + formatHumanContext(>5K 走 {$file, preview})。
369
+ // 见 ^anc-exec-audience-routing。
370
+ context: formatHumanContext(this.resolveRawInputs(target), this.getWorkZone()),
371
+ output_schema: target.outputs ?? [],
372
+ ...(defaultValue !== undefined ? { default_value: defaultValue } : {}),
373
+ ...(askNode.present_inputs && askNode.present_inputs.length > 0 ? { present_inputs: askNode.present_inputs } : {}),
374
+ },
375
+ response_options: [],
376
+ work_zone: this.getWorkZone(),
377
+ };
378
+ this.hoplog?.recordStepStart(target.step_id, target.step_type, target.summary, context.inputs, formatPromptText(context, target.step_type));
379
+ this.flushDocRefMeta(target.step_id);
380
+ return response;
381
+ }
382
+ const workZone = this.getWorkZone();
383
+ const response = {
384
+ status: 'step_ready',
385
+ instance_id: this.instanceId,
386
+ step_id: target.step_id,
387
+ step_type: target.step_type,
388
+ summary: target.summary,
389
+ // context.inputs 由 PromptAssembler 已截断到 2000 字符(LLM prompt 渲染用),
390
+ // 不做 deflate(deflate 用于原始数据通道:paused.context / outputs / params_for_child)。
391
+ context,
392
+ work_zone: workZone,
393
+ // 本步 output 文件的确切路径——driver/worker 照此写、`--output @<output_path>` 提交,零拼名自由度。
394
+ // 消除"parallel 内层 step_id 恒为模板 id、拼名易撞"的串台。见 design ^anc-exec-work-zone。
395
+ output_path: join(workZone, `out_${target.step_id}.json`),
396
+ };
397
+ // debug 级记录完整 prompt 文本(角色说明 + 6 层格式化)+ info 级记录 inputs // @a: anc-obs-debug-context
398
+ this.hoplog?.recordStepStart(target.step_id, target.step_type, target.summary, context.inputs, formatPromptText(context, target.step_type));
399
+ this.flushDocRefMeta(target.step_id);
400
+ return response;
401
+ }
402
+ completeStep(stepId, outputs) {
403
+ const state = this.stepStates.get(stepId);
404
+ if (state === 'done') {
405
+ return { status: 'ok', code: ErrorCode.ALREADY_DONE, message: 'Step already completed' }; // @a: anc-cli-idempotency
406
+ }
407
+ if (state !== 'running') {
408
+ return { status: 'error', code: ErrorCode.INVALID_STATE, message: `Step '${stepId}' is ${state ?? 'unknown'}, expected running` };
409
+ }
410
+ // 输出 schema 校验(值层面,写 vars 前):不匹配 → 不写不标 done,返回 SCHEMA_MISMATCH。
411
+ // 触发算子级重试(独立模式引擎内重做 / 复用模式 done 打回 CC)。
412
+ // confirm/ask 豁免:其输出是 caller 注入的 answer(审批信号/数据值),规范化在 mapConfirm/mapAskOutputs 做。
413
+ // @a: anc-exec-output-schema-check
414
+ const declNode = this.findStepById(stepId);
415
+ if (declNode && declNode.step_type !== 'confirm' && declNode.step_type !== 'ask' && declNode.outputs?.length) {
416
+ const mismatches = validateOutputValues(declNode.outputs, outputs ?? {});
417
+ if (mismatches.length > 0) {
418
+ return { status: 'error', code: ErrorCode.SCHEMA_MISMATCH, message: formatSchemaMismatch(mismatches) };
419
+ }
420
+ }
421
+ // check 判定:固定双槽签名,引擎按类型定位 bool 槽判通过/失败。false → failStep
422
+ // 接升级阶梯(首次带反馈重跑、再 adaptive);text 槽作失败说明。
423
+ // 模式无关(复用 CLI done / 独立 dispatcher 共用此处)。// @a: anc-exec-check-verdict
424
+ if (declNode?.step_type === 'check') {
425
+ const boolDecl = declNode.outputs?.find(o => o.type === 'bool');
426
+ const verdict = boolDecl ? outputs?.[boolDecl.name] : undefined;
427
+ if (verdict === false || verdict === 'false') {
428
+ const textDecl = declNode.outputs?.find(o => o.type === 'text');
429
+ const explanation = textDecl ? outputs?.[textDecl.name] : undefined;
430
+ // check 失败前先写它的**更新模式输出**(← X 且 → X,回填的 last_err 等)——否则显式反馈值
431
+ // 丢失(failStep 直接 return 不经 writeOutputs)。只写更新模式输出,保持"check 失败不落新产出"
432
+ // 语义(bool 判定槽/独立 text 槽仍不落盘)。与概念 ^anc-step-check「text 槽是失败说明」一致。
433
+ // 见 design/exec-engine.md ^anc-exec-failstep-skip-update(DEBT-13)。// @a: anc-exec-failstep-skip-update
434
+ if (outputs) {
435
+ const updateOuts = declNode.outputs?.filter(o => isUpdateModeOutput(declNode, o.name)) ?? [];
436
+ if (updateOuts.length) {
437
+ const scopeId = getWriteScope(stepId, this.spec);
438
+ for (const o of updateOuts) {
439
+ if (o.name in outputs)
440
+ this.variables.write(o.name, outputs[o.name], scopeId);
441
+ }
442
+ }
443
+ }
444
+ return this.failStep(stepId, `CHECK_FAILED: ${explanation ?? '(no explanation)'}`);
445
+ }
446
+ }
447
+ // confirm answer 规范化:caller 注入的 answer(原始 key 如 value)不直接写,
448
+ // 而是提取决策值 → reject 走 failStep、approve 按声明输出类型槽映射(bool→true,
449
+ // 非 bool→原值)落到声明的变量名。模式无关(CLI done / dispatcher.resume 共用)。
450
+ // 见概念 ^anc-step-confirm / 设计 step-dispatcher ^anc-exec-confirm-answer。// @a: anc-exec-confirm-answer
451
+ if (declNode?.step_type === 'confirm') {
452
+ const decision = this.extractDecision(outputs);
453
+ if (decision !== undefined && CONFIRM_REJECT_VALUES.has(String(decision).toLowerCase())) {
454
+ // reject = 授权否决 → 全局中止(非局部 None 传播):fail 本步 + 跳过所有未终态步骤。
455
+ // 见概念 ^anc-step-confirm reject 全局中止 / 设计 ^anc-exec-confirm-answer。
456
+ this.stepStates.set(stepId, 'failed');
457
+ this.recordStepFailure(stepId, `confirm rejected by caller: ${decision}`);
458
+ this.hoplog?.recordStepFailed(stepId, `confirm rejected by caller: ${decision}`);
459
+ for (const [id, status] of this.stepStates) {
460
+ if (id !== stepId && (status === 'pending' || status === 'running')) {
461
+ this.stepStates.set(id, 'skipped');
462
+ }
463
+ }
464
+ this.persist();
465
+ return { status: 'ok' };
466
+ }
467
+ outputs = this.mapConfirmOutputs(declNode, outputs, decision);
468
+ }
469
+ // ask answer 处理:caller 提供的数据值落到 +→ 声明的变量名。
470
+ // 与 confirm 正交——无 reject 中止,不做 bool 映射。answer 可能是 {声明名:值}、
471
+ // {value:值}(包装)、或 approve(采用 default_value 快捷)。见 ^anc-step-ask / ^anc-exec-hitl-presentation。
472
+ // @a: anc-step-ask, anc-exec-hitl-presentation
473
+ if (declNode?.step_type === 'ask') {
474
+ outputs = this.mapAskOutputs(declNode, outputs, stepId);
475
+ }
476
+ if (outputs) {
477
+ const scopeId = getWriteScope(stepId, this.spec);
478
+ this.variables.writeOutputs(outputs, scopeId);
479
+ }
480
+ this.stepStates.set(stepId, 'done');
481
+ this.recordEvent(stepId, 'step_done');
482
+ // confirm 决策是 CITL 审计点:决策值记成 hitl 审计字段(无视日志级别),
483
+ // 而非随 recordStepDone 被 info 级脱敏成 null。见概念层 ^anc-obs-hoplog 安全审计。
484
+ if (declNode?.step_type === 'confirm' && outputs) {
485
+ const vals = Object.values(outputs);
486
+ const decision = vals.length === 1
487
+ ? String(vals[0])
488
+ : JSON.stringify(outputs);
489
+ this.hoplog?.recordStepMeta(stepId, {
490
+ hitl: { response: decision, responder: 'caller', at: new Date().toISOString() },
491
+ });
492
+ }
493
+ this.hoplog?.recordStepDone(stepId, outputs);
494
+ // propagate + 容器 done 记录(branch/case/subtask/loop 级联标 done 时补 recordStepDone)
495
+ this.propagateAndRecord(stepId);
496
+ this.persist();
497
+ return { status: 'ok' };
498
+ }
499
+ // 完成并推进到 caller 介入点:driver 交活 + 领取下一指令的一次原子往返。
500
+ // completeStep 成功 → 推进、消化中间步(纯计算 body)→ 停在下一个 caller 介入点或终态,
501
+ // 返回 NextResponse。completeStep 出错(SCHEMA_MISMATCH 等)→ 原样返回错误码、不推进。
502
+ // 执行节奏归引擎(概念不变量6)。见设计 ^anc-exec-advance-to-caller。// @a: anc-exec-advance-to-caller
503
+ async completeAndAdvance(stepId, outputs) {
504
+ const resp = this.completeStep(stepId, outputs);
505
+ if (resp.status !== 'ok')
506
+ return resp; // 错误不推进(重做本步也是"下一步指令")
507
+ return this.advanceToCaller();
508
+ }
509
+ // 从当前状态推进到下一个 caller 介入点:消化纯计算 body(BodyInterpreter + 哑 ToolProvider),
510
+ // 遇 caller 介入点/终态停。async 因 BodyInterpreter.run 是 async(纯计算 body 实际无 IO)。
511
+ // can_fanout 时每轮推进前先探最外层 parallel——命中则返回 ParallelReady 交 driver fan-out。
512
+ // @a: anc-exec-advance-to-caller, anc-exec-parallel-batch, anc-exec-parallel-foreach-barrier
513
+ async advanceToCaller() {
514
+ // fan-out 优先:探最外层 parallel batch 必须先于 nextStep 树遍历。
515
+ // 否则 dfsNextStep 越过未 join 的 forEach parallel(标 running 后 continue),落到同级
516
+ // 后续步并当作下一个 executable 返回 → nextStep 把它误标 running 并持久化;join 完成后
517
+ // 再遍历时该步既非终态也非 pending → dfsNextStep 跳过 → "No executable step found"。
518
+ // collectParallelBatch 能直接命中 pending 的最外层 parallel 并标 running,无需 nextStep 先行。
519
+ // worker(canFanout=false)不进此分支,照常 nextStep 下钻子树。
520
+ // 见 design/parallel-execution.md §9b' ^anc-exec-parallel-foreach-barrier。
521
+ if (this.canFanout) {
522
+ const batch = this.nextParallelBatch();
523
+ if (batch)
524
+ return batch;
525
+ }
526
+ let result = this.nextStep();
527
+ while (result.status === 'step_ready' && !this.isCallerActionPoint(result.step_id)) {
528
+ const node = this.findStepById(result.step_id);
529
+ if (!node?.body || bodyHasToolCall(node.body))
530
+ break; // 防御性二次确认
531
+ const interp = new BodyInterpreter({
532
+ inputs: result.context?.inputs ?? {},
533
+ toolProvider: ENGINE_NOOP_TOOL_PROVIDER,
534
+ allowCommit: result.step_type === 'commit',
535
+ toolCallLog: [],
536
+ });
537
+ const outputs = await interp.run(node.body, node.outputs ?? []);
538
+ const r = this.completeStep(result.step_id, outputs);
539
+ if (r.status !== 'ok')
540
+ return result; // schema 校验失败等 → 停在该步交 caller(不静默吞)
541
+ // 消化一步后,parallelMode 下再探 parallel(被消化步骤可能正好打开了一个 parallel 容器)
542
+ if (this.canFanout) {
543
+ const batch = this.nextParallelBatch();
544
+ if (batch)
545
+ return batch;
546
+ }
547
+ result = this.nextStep();
548
+ }
549
+ return result;
550
+ }
551
+ // 复用模式真并行:探测最外层 running/pending parallel,命中则把其 pending children 标 running、
552
+ // 解析各 child 入参、构造 ParallelReady 交 driver fan-out。未命中返回 null(回退普通 advance)。
553
+ // @a: anc-exec-parallel-batch, anc-exec-parallel-canfanout
554
+ nextParallelBatch() {
555
+ if (!this.spec || !this.canFanout)
556
+ return null;
557
+ const state = {
558
+ stepStates: this.stepStates,
559
+ variables: this.variables,
560
+ loopCounters: this.loopCounters,
561
+ };
562
+ const batch = collectParallelBatch(this.spec.steps ?? [], state, this.spec);
563
+ if (!batch)
564
+ return null;
565
+ // 父 parallel 标 running(若尚未),开作用域 // @a: anc-obs-step-start-timing
566
+ if (this.stepStates.get(batch.parallelStepId) !== 'running') {
567
+ this.stepStates.set(batch.parallelStepId, 'running');
568
+ if (!this.variables.hasScope(batch.parallelStepId)) {
569
+ this.variables.createScope(batch.parallelStepId, getWriteScope(batch.parallelStepId, this.spec));
570
+ }
571
+ }
572
+ const parentScope = batch.parallelStepId; // child 入参从 parallel scope 自底向上解析
573
+ // for-each 动态展开:模板 child × 列表长度 → N 个 ParallelChildSpec
574
+ const parallelNode = this.findStepById(batch.parallelStepId);
575
+ const forEach = parallelNode?.forEach;
576
+ let expandedChildren;
577
+ if (forEach && batch.children.length === 1) { // @a: anc-exec-parallel-foreach
578
+ const listVal = this.variables.read(forEach.listVar, getWriteScope(batch.parallelStepId, this.spec));
579
+ const items = Array.isArray(listVal) ? listVal : [];
580
+ const template = batch.children[0];
581
+ expandedChildren = items.map((item, idx) => {
582
+ const childId = `${batch.parallelStepId}.${idx + 1}`;
583
+ this.stepStates.set(childId, 'running');
584
+ this.recordEvent(childId, 'step_start');
585
+ const baseParams = this.resolveChildParams(template, parentScope);
586
+ return {
587
+ child_step_id: childId,
588
+ summary: `${template.summary} [${idx + 1}/${items.length}]`,
589
+ subinstance_dir: this.instanceDir ? `${this.instanceDir}/parallel/${childId}` : `parallel/${childId}`,
590
+ params_for_child: { ...baseParams, [forEach.itemVar]: item },
591
+ };
592
+ });
593
+ }
594
+ else {
595
+ // 静态 parallel:原有逻辑
596
+ expandedChildren = batch.children.map(child => {
597
+ this.stepStates.set(child.step_id, 'running');
598
+ this.recordEvent(child.step_id, 'step_start');
599
+ return {
600
+ child_step_id: child.step_id,
601
+ summary: child.summary,
602
+ subinstance_dir: this.instanceDir ? `${this.instanceDir}/parallel/${child.step_id}` : `parallel/${child.step_id}`,
603
+ params_for_child: this.resolveChildParams(child, parentScope),
604
+ };
605
+ });
606
+ }
607
+ // parallel 容器 recordStepStart:debug 级记录完整自包含 fan-out 指令文本
608
+ const header = this.spec.header;
609
+ const maxConcurrent = this.hostConfig?.resource_limits?.max_concurrent_workers ?? 5;
610
+ const cliPath = this.cliAbsPath ?? 'PJ/AgentHOP/code/hopstep/dist/cli.js';
611
+ const parallelPrompt = formatParallelPromptText({
612
+ goal: header.goal ?? '',
613
+ outputs: header.outputs?.map(o => `${o.name}: ${o.type}`).join(', ') ?? '',
614
+ parallelStepId: batch.parallelStepId,
615
+ children: expandedChildren.map(c => ({ child_step_id: c.child_step_id, summary: c.summary, params_for_child: c.params_for_child })),
616
+ maxConcurrent,
617
+ instanceId: this.instanceId,
618
+ specPath: this.specPath ?? '<spec.md>',
619
+ cliPath,
620
+ parentLogDir: this.hoplog?.getRunDir() ?? '.hoplog',
621
+ stateDir: this.stateDir ?? '.hopstate',
622
+ });
623
+ this.hoplog?.recordStepStart(batch.parallelStepId, 'parallel', parallelNode?.summary ?? '', undefined, parallelPrompt);
624
+ // child 生命周期归子 hoplog(方案 A ^anc-obs-parallel-child-satellite):父 hoplog 不预写 child start
625
+ // (child start/done 各在其子 hoplog parallel/<cid>/log/);父只在下方 fanout 记 child_ids。
626
+ // 这消除"child start 在 fan-out 时写、done 在 join 时写"的撕裂——曾致流式 append 缩进交错、YAML 非法。
627
+ this.hoplog?.recordParallelFanout(batch.parallelStepId, expandedChildren.map(c => c.child_step_id), maxConcurrent);
628
+ // 落盘每个 child 的 params_for_child(内部真值,不 deflate)到其 subinstance 目录,供 worker init 按 cid 回填 itemVar。
629
+ // 仅复用模式(FilePersistence 有 instanceDir);独立模式 dispatcher 不起 worker 子实例、走内存 scope。
630
+ // 见 design/parallel-execution.md ^anc-exec-parallel-foreach-worker。// @a: anc-exec-parallel-foreach-worker
631
+ if (this.instanceDir) {
632
+ for (const child of expandedChildren) {
633
+ writeChildParams(this.instanceDir, child.child_step_id, child.params_for_child);
634
+ }
635
+ }
636
+ this.persist();
637
+ return {
638
+ status: 'parallel_ready',
639
+ instance_id: this.instanceId,
640
+ parallel_step_id: batch.parallelStepId,
641
+ // params_for_child 内大值经 deflate;subinstance_dir/summary 不变。// @a: anc-exec-deflate
642
+ // 按 child_step_id 命名空间隔离——for-each N child 共享父 work_zone/vars 且同名(itemVar),
643
+ // 不隔离会互相覆盖(实证 bug:exec-engine 批被 spec-parser 批覆盖)。见 ^anc-exec-deflate 多 child 隔离。
644
+ children: expandedChildren.map(c => ({
645
+ ...c,
646
+ params_for_child: this.deflateValues(c.params_for_child, c.child_step_id),
647
+ })),
648
+ max_concurrent: maxConcurrent,
649
+ ...(this.hoplog ? { parent_log_dir: this.hoplog.getRunDir() } : {}),
650
+ work_zone: this.getWorkZone(),
651
+ };
652
+ }
653
+ // 解析一个 parallel child 容器的入参:收集 child 子树(含自身)所有 ← 输入绑定,
654
+ // 从父 scope 自底向上读 source 值。driver 据此启动 worker 子实例 --params。
655
+ // @a: anc-exec-parallel-child-params
656
+ resolveChildParams(child, parentScope) {
657
+ const params = {};
658
+ const declaredInSubtree = new Set(); // 子树内部产出的名字不算外部入参
659
+ for (const node of [child, ...collectDescendants(child)]) {
660
+ for (const o of node.outputs ?? [])
661
+ declaredInSubtree.add(o.name);
662
+ }
663
+ for (const node of [child, ...collectDescendants(child)]) {
664
+ for (const b of node.inputs ?? []) {
665
+ if (declaredInSubtree.has(b.source))
666
+ continue; // 来自子树内部步骤,非外部入参
667
+ const val = this.variables.read(b.source, parentScope);
668
+ if (val !== undefined)
669
+ params[b.source] = val;
670
+ }
671
+ }
672
+ return params;
673
+ }
674
+ // fan-out CLI 调度顾问:无状态复原窗口,算下一步调度动作(补位 launch / join / wait)。
675
+ // fanout-plan(初始批)与 fanout-next(补位)共用此单一计算。派发即原子标 dispatched 落盘(防重复派发)。
676
+ // 见 design/parallel-execution.md ^anc-exec-parallel-fanout-advisor / ^anc-exec-parallel-fanout-concurrency。
677
+ // @a: anc-exec-parallel-fanout-advisor
678
+ fanoutSchedule(parallelStepId) {
679
+ const parallel = this.findStepById(parallelStepId);
680
+ if (!parallel || parallel.step_type !== 'parallel') {
681
+ return { action: 'error', message: `Step '${parallelStepId}' is not a parallel step` };
682
+ }
683
+ // 窗口上限:优先 hostConfig(顶层进程),退持久化值(load 后 hostConfig 丢 resource_limits),再缺省 5。
684
+ const maxConcurrent = this.hostConfig?.resource_limits?.max_concurrent_workers ?? this.maxConcurrent ?? 5;
685
+ // ① child 全集:fan-out 时 nextParallelBatch 已把 N 个 child 展开并标 running(for-each 合成 ID
686
+ // 或静态 child),故全集 = step_states 里以 parallelStepId 为直接父的所有 id。
687
+ const allChildren = [];
688
+ for (const id of this.stepStates.keys()) {
689
+ if (getParentStepId(id) === parallelStepId)
690
+ allChildren.push(id);
691
+ }
692
+ allChildren.sort((a, b) => {
693
+ const na = Number(a.slice(a.lastIndexOf('.') + 1));
694
+ const nb = Number(b.slice(b.lastIndexOf('.') + 1));
695
+ return na - nb;
696
+ });
697
+ // ② 已派发 / 已终态 / 在跑
698
+ const dispatched = new Set(this.dispatched.get(parallelStepId) ?? []);
699
+ const terminal = new Set();
700
+ for (const cid of allChildren) {
701
+ if (this.isChildTerminal(cid))
702
+ terminal.add(cid);
703
+ }
704
+ const running = [...dispatched].filter(c => !terminal.has(c));
705
+ const pending = allChildren.filter(c => !dispatched.has(c));
706
+ // ③ 全部终态 → join
707
+ if (terminal.size === allChildren.length && allChildren.length > 0) {
708
+ return {
709
+ action: 'join',
710
+ command: this.buildJoinCommand(parallelStepId),
711
+ };
712
+ }
713
+ // ④ 窗口有空位 + 有待起 → 补位(原子标 dispatched 落盘)
714
+ const slots = maxConcurrent - running.length;
715
+ if (slots > 0 && pending.length > 0) {
716
+ const toLaunch = pending.slice(0, slots);
717
+ const list = this.dispatched.get(parallelStepId) ?? [];
718
+ for (const cid of toLaunch)
719
+ list.push(cid);
720
+ this.dispatched.set(parallelStepId, list);
721
+ // 每派发一个 child 流式记一条 dispatch 事件到父 hoplog(dispatched_at = 此刻,引擎动作时刻)。
722
+ // 顾问进程 load 已 HopLog.resume 重建父句柄。log = 该 child 子日志目录(与 launch_command 里 --log-dir 同源)。
723
+ // 见 design ^anc-obs-parallel-dispatch。// @a: anc-obs-parallel-dispatch
724
+ for (const cid of toLaunch) {
725
+ this.hoplog?.recordParallelDispatch(parallelStepId, cid, this.childLogDir(cid));
726
+ }
727
+ this.persist(); // 派发即原子落盘,下次复原不重发
728
+ return {
729
+ action: 'launch',
730
+ max_concurrent: maxConcurrent,
731
+ workers: toLaunch.map(cid => ({ child_step_id: cid, launch_command: this.buildWorkerLaunchCommand(cid) })),
732
+ remaining: pending.filter(c => !toLaunch.includes(c)),
733
+ };
734
+ }
735
+ // ⑤ 无空位或队列已空但仍有在跑 → 等
736
+ return { action: 'wait', running };
737
+ }
738
+ // child 子实例是否终态:子实例存在 且 无任何 running 步骤(与 join_parallel 判据一致,cli.ts)。
739
+ // ⚠️ 不能用 step_states[childStepId] 判——for-each worker 经 scope 掩码把合成 ID {P}.{N} 解析回
740
+ // 模板 ID {P}.1 执行,子实例 state 全按模板 ID 记,合成 cid 键根本不存在(曾致 join 永不触发)。
741
+ // 目录名(合成 cid)才是 child 身份,目录内按"整体无 running"判终态。// @a: anc-exec-parallel-fanout-advisor
742
+ isChildTerminal(childStepId) {
743
+ if (!this.instanceDir)
744
+ return false;
745
+ const childDir = join(this.instanceDir, 'parallel', childStepId);
746
+ if (!stateExists(childDir))
747
+ return false; // 未起或刚派发未建目录 → 非终态
748
+ try {
749
+ const st = readState(childDir);
750
+ // 子实例无 running 步 = 该 worker 跑完(done 或 failed 都算终态,join 时再区分成败)
751
+ return !Object.values(st.step_states).some(s => s === 'running');
752
+ }
753
+ catch {
754
+ return false;
755
+ }
756
+ }
757
+ // 拼单个 worker 的完整启动命令(cd 锁 cwd + run --parallel-parent/child + log-dir;不含 --params,itemVar 引擎回填)。
758
+ // 路径全由引擎持有的 specPath/cliAbsPath/workspace_dir/stateDir 生成——消除 driver 手拼漏参。// @a: anc-exec-parallel-fanout-advisor
759
+ buildWorkerLaunchCommand(childStepId) {
760
+ const cli = this.cliAbsPath ?? 'PJ/AgentHOP/code/hopstep/dist/cli.js';
761
+ const spec = this.specPath ?? '<spec.md>';
762
+ const stateDir = this.stateDir ?? '.hopstate';
763
+ const vault = this.hostConfig?.workspace_dir;
764
+ const logDir = this.childLogDir(childStepId);
765
+ // 路径全双引号包裹——工作区绝对路径常含空格(iCloud "Mobile Documents"),裸插值会被 shell 拆参。
766
+ // 见 design/parallel-execution.md §10e 路径含空格拆裂参数。
767
+ const cmd = `node "${cli}" run "${spec}" --parallel-parent ${this.instanceId} --parallel-child ${childStepId} --state-dir "${stateDir}" --log-dir "${logDir}" --log-level debug`;
768
+ return vault ? `cd "${vault}" && ${cmd}` : cmd;
769
+ }
770
+ // 单个 child 子日志目录:与 launch_command 的 --log-dir 同源(dispatch 事件的 log 字段复用,保证一致)。
771
+ childLogDir(childStepId) {
772
+ const stateDir = this.stateDir ?? '.hopstate';
773
+ return `${this.hoplog?.getRunDir() ?? stateDir}/parallel/${childStepId}/log`;
774
+ }
775
+ buildJoinCommand(parallelStepId) {
776
+ const cli = this.cliAbsPath ?? 'PJ/AgentHOP/code/hopstep/dist/cli.js';
777
+ const stateDir = this.stateDir ?? '.hopstate';
778
+ // 路径双引号包裹(同 buildWorkerLaunchCommand,防含空格路径拆参)。
779
+ return `node "${cli}" join_parallel ${parallelStepId} --state-dir "${stateDir}" --instance ${this.instanceId}`;
780
+ }
781
+ // join barrier:N 个 worker 子实例终态后,单进程收集各子实例输出,按 parallel 容器声明 +→ 同名
782
+ // merge 回 parallel scope,标 children done/failed → propagateCompletion 标 parallel done + 提升。
783
+ // childResults: child_step_id → { vars, failed, log? }。log = 该 child 子实例日志路径(cli.ts 收集时带出,
784
+ // 供 join 块记录子日志定位线索)。见 design/exec-engine.md ^anc-exec-parallel-join-merge。
785
+ // @a: anc-exec-parallel-join-merge
786
+ joinParallel(parallelStepId, childResults) {
787
+ const step = this.findStepById(parallelStepId);
788
+ if (!step || step.step_type !== 'parallel') {
789
+ return { status: 'error', code: ErrorCode.INVALID_STEP_ID, message: `Step '${parallelStepId}' is not a parallel step` };
790
+ }
791
+ const parallel = step;
792
+ const parallelScope = parallelStepId;
793
+ this.joinStartedAt = timestamp(); // 进入 join 的时刻,join 块 started_at(ended_at 在 recordParallelJoin 内)
794
+ // for-each parallel:遍历 childResults 全部合成 ID(spec 只有模板 child),按列表输出收集。
795
+ // 见 design/parallel-execution.md §9c ^anc-exec-parallel-foreach-join。// @a: anc-exec-parallel-foreach-join
796
+ if (parallel.forEach) {
797
+ const template = getChildren(parallel)[0];
798
+ const templateOutputs = template?.outputs ?? [];
799
+ // 按合成 ID 序号排序,保证列表顺序与 fan-out 一致
800
+ const childIds = Object.keys(childResults).sort((a, b) => {
801
+ const na = Number(a.slice(a.lastIndexOf('.') + 1));
802
+ const nb = Number(b.slice(b.lastIndexOf('.') + 1));
803
+ return na - nb;
804
+ });
805
+ // 各 child 同名输出收集成列表(按 child 声明的 +→ 名)
806
+ const collected = {};
807
+ for (const decl of templateOutputs)
808
+ collected[decl.name] = [];
809
+ for (const childId of childIds) {
810
+ const res = childResults[childId];
811
+ for (const decl of templateOutputs) {
812
+ collected[decl.name].push(res.failed ? null : (res.vars[decl.name] ?? null));
813
+ }
814
+ this.stepStates.set(childId, res.failed ? 'failed' : 'done');
815
+ // child 生命周期归子 hoplog(方案 A ^anc-obs-parallel-child-satellite):父 hoplog 不写 child start/done,
816
+ // 只在 recordParallelJoin 记各 child 终态。父这里仅更新引擎状态 + 失败记录(供 join 聚合),不碰父 hoplog。
817
+ if (res.failed) {
818
+ this.recordStepFailure(childId, `parallel child '${childId}' failed`, 'error');
819
+ }
820
+ }
821
+ // parallel 容器声明 +→ X: [T] → 写收集的列表;child 同名 X: T → 用 collected[X]
822
+ for (const pDecl of parallel.outputs ?? []) {
823
+ const list = collected[pDecl.name];
824
+ if (list !== undefined) {
825
+ this.variables.write(pDecl.name, list, parallelScope);
826
+ }
827
+ }
828
+ this.stepStates.set(parallelStepId, 'done');
829
+ // 提升 parallel 聚合输出到父 scope
830
+ const parentScopeId = getWriteScope(parallelStepId, this.spec);
831
+ for (const pDecl of parallel.outputs ?? []) {
832
+ const val = this.variables.read(pDecl.name, parallelScope);
833
+ if (parentScopeId !== parallelScope)
834
+ this.variables.write(pDecl.name, val, parentScopeId);
835
+ }
836
+ const parallelOutputs = {};
837
+ for (const decl of parallel.outputs ?? []) {
838
+ parallelOutputs[decl.name] = this.variables.read(decl.name, parentScopeId);
839
+ }
840
+ return this.finalizeParallelJoin(parallelStepId, parallelOutputs, childResults);
841
+ }
842
+ // 每个 child 容器:按其声明 +→ 输出从子实例 vars 同名取值 merge 到 parallel scope;标终态
843
+ for (const child of getChildren(parallel)) {
844
+ const res = childResults[child.step_id];
845
+ if (!res)
846
+ continue; // 未提交结果的 child(理论上 driver 应交齐)保持原状
847
+ if (res.failed) {
848
+ // child 失败:其声明输出写 None,不阻塞其余(沿用底层 fail 语义)
849
+ for (const decl of child.outputs ?? []) {
850
+ this.variables.write(decl.name, null, parallelScope);
851
+ }
852
+ this.stepStates.set(child.step_id, 'failed');
853
+ this.recordStepFailure(child.step_id, `parallel child '${child.step_id}' failed`, 'error');
854
+ // child 生命周期归子 hoplog(方案 A):父 hoplog 不写 child done/failed,join 记终态即可。
855
+ }
856
+ else {
857
+ for (const decl of child.outputs ?? []) {
858
+ this.variables.write(decl.name, res.vars[decl.name] ?? null, parallelScope);
859
+ }
860
+ this.stepStates.set(child.step_id, 'done');
861
+ }
862
+ }
863
+ // children 全终态 → propagateCompletion 标 parallel done + 容器声明 +→ 提升到父 scope
864
+ propagateCompletion(getChildren(parallel)[0]?.step_id ?? parallelStepId, {
865
+ stepStates: this.stepStates,
866
+ variables: this.variables,
867
+ loopCounters: this.loopCounters,
868
+ }, this.spec);
869
+ // parallel 容器自身完成记录
870
+ const parallelOutputs = {};
871
+ for (const decl of parallel.outputs ?? []) {
872
+ parallelOutputs[decl.name] = this.variables.read(decl.name, getWriteScope(parallelStepId, this.spec));
873
+ }
874
+ return this.finalizeParallelJoin(parallelStepId, parallelOutputs, childResults);
875
+ }
876
+ /** parallel join 两分支(for-each / static)共同尾段:记 parallel 完成 + join 审计 + 落盘 + 返回 ok。 */
877
+ finalizeParallelJoin(parallelStepId, parallelOutputs, childResults) {
878
+ // 顺序:先 join 事件(4.3 的 body 字段),再 recordStepDone(4.3 收尾 status/completed_at)——
879
+ // 保证 status 是 parallel 步骤 mapping 的最后字段,join 作为执行事件在其前。见 ^anc-obs-parallel-child-satellite。
880
+ // join 块带 started_at(joinParallel 入口捕获)+ 每 child 的 log(子日志路径)+ ended_at(record 内 timestamp)。
881
+ this.hoplog?.recordParallelJoin(parallelStepId, Object.fromEntries(Object.entries(childResults).map(([id, r]) => [id, { failed: r.failed, log: r.log }])), this.joinStartedAt ?? undefined);
882
+ this.hoplog?.recordStepDone(parallelStepId, parallelOutputs);
883
+ this.persist();
884
+ return { status: 'ok' };
885
+ }
886
+ // caller 介入点判定(引擎语义,唯一权威):reason/check 需推理、confirm 需决策、
887
+ // 含工具调用的 act/commit 需 caller 工具。其余(纯计算 body、容器推进)引擎自消化。
888
+ // 见设计 ^anc-exec-advance-to-caller。
889
+ isCallerActionPoint(stepId) {
890
+ const node = this.findStepById(stepId);
891
+ if (!node)
892
+ return true; // 未知步骤交 caller 兜底
893
+ if (node.step_type === 'reason' || node.step_type === 'check' || node.step_type === 'confirm' || node.step_type === 'ask') {
894
+ return true;
895
+ }
896
+ if (node.step_type === 'act' || node.step_type === 'commit') {
897
+ const body = node.body;
898
+ // 无 body(自然语言指令,caller 执行)或含工具调用(工具是 caller 的)→ 交 caller
899
+ return !body || bodyHasToolCall(body);
900
+ }
901
+ return false; // 其余(控制流/容器)非 caller 介入点
902
+ }
903
+ // call 步骤完成:读子实例输出,按 output_mapping(子output→父var)回填父 call 步骤的 +→ // @a: anc-step-call
904
+ completeCallStep(callStepId, childVars) {
905
+ const step = this.findStepById(callStepId);
906
+ if (!step || step.step_type !== 'call') {
907
+ return { status: 'error', code: ErrorCode.INVALID_STEP_ID, message: `Step '${callStepId}' is not a call step` };
908
+ }
909
+ const call = step;
910
+ // 按 output_mapping 把子实例输出搬到父变量;无映射则同名透传子 spec 声明的输出
911
+ const mapped = {};
912
+ if (call.output_mapping && call.output_mapping.length > 0) {
913
+ for (const m of call.output_mapping) {
914
+ mapped[m.to] = childVars[m.from]; // 父变量 m.to ← 子输出 m.from
915
+ }
916
+ }
917
+ else {
918
+ // 无显式映射:把 call 步骤声明的 +→ 输出按同名从子变量取
919
+ for (const decl of step.outputs ?? []) {
920
+ mapped[decl.name] = childVars[decl.name];
921
+ }
922
+ }
923
+ return this.completeStep(callStepId, mapped);
924
+ }
925
+ failStep(stepId, reason, failKind = 'error') {
926
+ const state = this.stepStates.get(stepId);
927
+ if (state === 'failed') {
928
+ return { status: 'ok', code: ErrorCode.ALREADY_FAILED, message: 'Step already failed' }; // @a: anc-cli-idempotency
929
+ }
930
+ if (state !== 'running') {
931
+ return { status: 'error', code: ErrorCode.INVALID_STATE, message: `Step '${stepId}' is ${state ?? 'unknown'}, expected running` };
932
+ }
933
+ const step = this.findStepById(stepId);
934
+ if (step?.outputs) {
935
+ const scopeId = getWriteScope(stepId, this.spec);
936
+ // 更新模式输出(← X 且 → X,X 是本步在更新的既有变量)不置 None——判据同 V3 更新模式豁免。
937
+ // 置 None 会破坏跨步/跨重试的显式反馈流(如 check finally 回填祖先 last_err 供重跑步定向修正)。
938
+ // 见 design/exec-engine.md ^anc-exec-failstep-skip-update(DEBT-13)。// @a: anc-exec-failstep-skip-update
939
+ const toNull = step.outputs.filter(o => !isUpdateModeOutput(step, o.name));
940
+ this.variables.setOutputsToNull(toNull, scopeId);
941
+ }
942
+ this.stepStates.set(stepId, 'failed');
943
+ this.recordStepFailure(stepId, reason, failKind);
944
+ this.hoplog?.recordStepFailed(stepId, reason, failKind);
945
+ this.handleFailStepRetry(stepId, reason);
946
+ this.persist();
947
+ return { status: 'ok' };
948
+ }
949
+ submitReplan(subtaskId, stepsMd) {
950
+ if (this.adaptiveNeededSubtask !== subtaskId) {
951
+ return {
952
+ status: 'error',
953
+ code: ErrorCode.INVALID_STATE,
954
+ errors: [{ kind: 'validate', rule: 'replan', severity: 'error', message: `Subtask '${subtaskId}' is not awaiting replan` }],
955
+ };
956
+ }
957
+ const subtask = this.findStepById(subtaskId);
958
+ if (!subtask) {
959
+ return {
960
+ status: 'error',
961
+ code: ErrorCode.INVALID_STEP_ID,
962
+ errors: [{ kind: 'validate', rule: 'replan', severity: 'error', message: `Subtask '${subtaskId}' not found` }],
963
+ };
964
+ }
965
+ // --- P0-2: Circuit breaker — replan attempt limit ---
966
+ const currentCount = this.replanCounters.get(subtaskId) ?? 0;
967
+ if (currentCount >= 3) {
968
+ return {
969
+ status: 'error',
970
+ code: ErrorCode.REPLAN_LIMIT_EXCEEDED,
971
+ errors: [{ kind: 'validate', rule: 'replan-limit', severity: 'error', message: `Subtask '${subtaskId}' has exceeded maximum replan attempts (3)` }],
972
+ };
973
+ }
974
+ const wrappedMd = `# Replan\nGoal: replan\n\n## Steps\n${stepsMd}`;
975
+ const { ast, errors: parseErrors } = parseSpec(wrappedMd);
976
+ if (parseErrors.length > 0) {
977
+ return { status: 'error', code: ErrorCode.PARSE_ERROR, errors: parseErrors };
978
+ }
979
+ const newSteps = ast.steps ?? [];
980
+ if (newSteps.length === 0) {
981
+ return {
982
+ status: 'error',
983
+ code: ErrorCode.VALIDATION_ERROR,
984
+ errors: [{ kind: 'validate', rule: 'replan', severity: 'error', message: 'Replan must have at least 1 step' }],
985
+ };
986
+ }
987
+ // --- P0-2: Similarity detection (duplicate replan) ---
988
+ const newSummaries = newSteps.map(s => this.toStepSummary(s));
989
+ const lastChildren = this.lastReplanChildren.get(subtaskId);
990
+ if (lastChildren && this.isReplanDuplicate(lastChildren, newSummaries)) {
991
+ this.adaptiveNeededSubtask = null;
992
+ this.markSubtaskFailed(subtask);
993
+ this.persist();
994
+ return {
995
+ status: 'error',
996
+ code: ErrorCode.REPLAN_DUPLICATE,
997
+ errors: [{ kind: 'validate', rule: 'replan-duplicate', severity: 'error', message: `Replan for '${subtaskId}' is too similar to previous attempt (duplicate detected)` }],
998
+ };
999
+ }
1000
+ const requiredOutputs = new Set((subtask.outputs ?? []).map(o => o.name));
1001
+ const providedOutputs = new Set();
1002
+ const collectOutputs = (steps) => {
1003
+ for (const s of steps) {
1004
+ if (s.outputs)
1005
+ for (const o of s.outputs)
1006
+ providedOutputs.add(o.name);
1007
+ if (hasChildren(s))
1008
+ collectOutputs(getChildren(s));
1009
+ }
1010
+ };
1011
+ collectOutputs(newSteps);
1012
+ const missing = [...requiredOutputs].filter(n => !providedOutputs.has(n));
1013
+ if (missing.length > 0) {
1014
+ return {
1015
+ status: 'error',
1016
+ code: ErrorCode.VALIDATION_ERROR,
1017
+ errors: [{ kind: 'validate', rule: 'replan-outputs', severity: 'error', message: `Replan missing required outputs: ${missing.join(', ')}` }],
1018
+ };
1019
+ }
1020
+ // --- P0-1: check-finally preservation validation ---
1021
+ const checkValidationError = this.validateCheckPreservation(subtask.children, newSteps);
1022
+ if (checkValidationError) {
1023
+ return {
1024
+ status: 'error',
1025
+ code: ErrorCode.VALIDATION_ERROR,
1026
+ errors: [checkValidationError],
1027
+ };
1028
+ }
1029
+ // --- P0-2: Increment replan counter and store last children ---
1030
+ this.replanCounters.set(subtaskId, currentCount + 1);
1031
+ this.lastReplanChildren.set(subtaskId, newSummaries);
1032
+ this.removeChildStates(subtask);
1033
+ const reIdChildren = this.reIdSteps(newSteps, subtaskId);
1034
+ subtask.children = reIdChildren;
1035
+ this.setSubtreePending(reIdChildren);
1036
+ this.stepStates.set(subtaskId, 'running');
1037
+ const scopeId = getWriteScope(subtaskId, this.spec);
1038
+ this.variables.clearScope(scopeId === subtaskId ? subtaskId : scopeId);
1039
+ this.adaptiveNeededSubtask = null;
1040
+ this.recordEvent(subtaskId, 'replan');
1041
+ // 提报沉淀(降级阶梯运行时生成必沉淀):绑定四元组发 HopLog replan_audit 审计事件,
1042
+ // 供运维离线提取→审核→晋升为预声明备用链路(渐进固化)。
1043
+ // 见 ^anc-exec-retry-adaptive 提报沉淀 / spec-observability ^anc-obs-replan-audit。
1044
+ // @a: anc-obs-replan-audit
1045
+ const newChildrenSummary = reIdChildren.map(c => this.toStepSummary(c));
1046
+ const lastFail = (this.retryHistory.get(subtaskId) ?? []).slice(-1)[0];
1047
+ this.hoplog?.recordReplanAudit({
1048
+ spec_id: this.spec?.header.id ?? 'unnamed',
1049
+ step_id: subtaskId,
1050
+ error_reason: lastFail?.failure_reason ?? '',
1051
+ generated_children: newChildrenSummary,
1052
+ base: 'scratch', // 档A:运行时从零生成(第4档)。档B 备用链路库就绪后,基于备用改的标 'fallback'
1053
+ at: new Date().toISOString(),
1054
+ });
1055
+ this.persist();
1056
+ return {
1057
+ status: 'ok',
1058
+ new_children: newChildrenSummary,
1059
+ };
1060
+ }
1061
+ selectBranch(stepId, selectedCaseId, reason) {
1062
+ const branch = this.findStepById(stepId);
1063
+ if (!branch || branch.step_type !== 'branch') {
1064
+ return { status: 'error', code: ErrorCode.INVALID_STEP_ID, message: `Step '${stepId}' is not a branch` };
1065
+ }
1066
+ const state = this.stepStates.get(stepId);
1067
+ if (state !== 'pending' && state !== 'running') {
1068
+ return { status: 'error', code: ErrorCode.INVALID_STATE, message: `Branch '${stepId}' is ${state}, expected pending or running` };
1069
+ }
1070
+ const caseStep = branch.children.find(c => c.step_id === selectedCaseId);
1071
+ if (!caseStep) {
1072
+ return { status: 'error', code: ErrorCode.INVALID_STEP_ID, message: `Case '${selectedCaseId}' not found in branch '${stepId}'` };
1073
+ }
1074
+ this.stepStates.set(stepId, 'running');
1075
+ this.stepStates.set(caseStep.step_id, 'running');
1076
+ for (const c of branch.children) {
1077
+ if (c.step_id !== selectedCaseId) {
1078
+ this.stepStates.set(c.step_id, 'skipped');
1079
+ this.skipAllDescendants(c);
1080
+ }
1081
+ }
1082
+ this.recordEvent(stepId, 'branch_select', reason ?? selectedCaseId);
1083
+ this.persist();
1084
+ return { status: 'ok' };
1085
+ }
1086
+ getStatus() {
1087
+ let completed = 0, failed = 0, pending = 0;
1088
+ let currentStep;
1089
+ for (const [id, state] of this.stepStates) {
1090
+ if (state === 'done')
1091
+ completed++;
1092
+ else if (state === 'failed')
1093
+ failed++;
1094
+ else if (state === 'pending')
1095
+ pending++;
1096
+ else if (state === 'running')
1097
+ currentStep = id;
1098
+ }
1099
+ const executionStatus = this.determineExecutionStatus();
1100
+ return {
1101
+ status: 'ok',
1102
+ instance_id: this.instanceId,
1103
+ execution_status: executionStatus,
1104
+ total_steps: this.stepStates.size,
1105
+ completed,
1106
+ failed,
1107
+ pending,
1108
+ current_step: currentStep,
1109
+ };
1110
+ }
1111
+ getVars() {
1112
+ const executionStatus = this.determineExecutionStatus();
1113
+ const allVars = this.variables.getAllVariables();
1114
+ const declaredOutputs = this.spec?.header.outputs ?? [];
1115
+ const pendingOutputs = this.variables.getPendingOutputs(declaredOutputs);
1116
+ return {
1117
+ status: 'ok',
1118
+ instance_id: this.instanceId,
1119
+ execution_status: executionStatus,
1120
+ variables: allVars,
1121
+ pending_outputs: pendingOutputs,
1122
+ };
1123
+ }
1124
+ /** 递归把子树所有步骤置 pending(init 时初始化 / retry 时重置——同一操作,单点定义)。 */
1125
+ setSubtreePending(steps) {
1126
+ for (const step of steps) {
1127
+ this.stepStates.set(step.step_id, 'pending');
1128
+ if (hasChildren(step)) {
1129
+ this.setSubtreePending(getChildren(step));
1130
+ }
1131
+ }
1132
+ }
1133
+ // parallel worker 子实例 scope 掩码:目标 child 子树保留 pending,其余全标 skipped。
1134
+ // 祖先链标 running(容器已进入),子树内保持 pending(等引擎正常推进)。
1135
+ // 见 design/parallel-execution.md §2b executeSubtreeOnly + §9e for-each worker 启动。
1136
+ // @a: anc-exec-parallel-subinstance, anc-exec-parallel-foreach-worker
1137
+ executeSubtreeOnly(childStepId, steps) {
1138
+ // 收集子树 step_id 集合(child 自身 + 全部后代)
1139
+ const subtreeIds = new Set();
1140
+ let target = this.findStepById(childStepId);
1141
+ let effectiveRoot = childStepId;
1142
+ // for-each 合成 ID 解析:findStepById 命中失败时,检测父 parallel 是否 forEach,
1143
+ // 解析回模板 child {P}.1(子树结构相同,只是 step_id 前缀不同)。见 §9e
1144
+ if (!target) {
1145
+ const parentId = getParentStepId(childStepId);
1146
+ const parentStep = parentId ? this.findStepById(parentId) : null;
1147
+ if (parentStep?.step_type === 'parallel' && parentStep.forEach) {
1148
+ const templateId = `${parentId}.1`;
1149
+ target = this.findStepById(templateId);
1150
+ effectiveRoot = templateId;
1151
+ // 记录 for-each worker 序号(用于 itemVar 自播种)
1152
+ const idxMatch = childStepId.match(/\.(\d+)$/);
1153
+ this.forEachWorkerIdx = idxMatch ? Number(idxMatch[1]) - 1 : 0;
1154
+ }
1155
+ }
1156
+ if (!target)
1157
+ return;
1158
+ this.subtreeRoot = effectiveRoot; // 修正为模板 ID(worker 内部执行用模板 step_id)
1159
+ subtreeIds.add(effectiveRoot);
1160
+ for (const d of collectDescendants(target))
1161
+ subtreeIds.add(d.step_id);
1162
+ // 收集祖先链 step_id(从 child 到根的容器路径)
1163
+ const ancestorIds = new Set();
1164
+ let pid = getParentStepId(effectiveRoot);
1165
+ while (pid) {
1166
+ ancestorIds.add(pid);
1167
+ pid = getParentStepId(pid);
1168
+ }
1169
+ // 遍历全部步骤:子树保留 pending,祖先标 running(容器已进入态),其余标 skipped
1170
+ for (const [stepId] of this.stepStates) {
1171
+ if (subtreeIds.has(stepId))
1172
+ continue; // 子树保持 pending
1173
+ if (ancestorIds.has(stepId)) {
1174
+ this.stepStates.set(stepId, 'running'); // 祖先容器:running 态让 dfsNextStep 递归进入
1175
+ }
1176
+ else {
1177
+ this.stepStates.set(stepId, 'skipped');
1178
+ }
1179
+ }
1180
+ }
1181
+ findFirstFailedStep(steps) {
1182
+ for (const step of steps) {
1183
+ if (this.stepStates.get(step.step_id) === 'failed')
1184
+ return step;
1185
+ }
1186
+ return null;
1187
+ }
1188
+ // propagateCompletion + 容器 done 记录:统一调用点,通过 newlyDone 通道捕获级联标 done 的容器,
1189
+ // 逐个 recordStepDone(含容器声明的聚合输出,从父 scope 读)/Failed。
1190
+ // 见 design/spec-observability.md ^anc-obs-step-done-timing。// @a: anc-obs-step-done-timing
1191
+ propagateAndRecord(stepId) {
1192
+ const newlyDone = [];
1193
+ propagateCompletion(stepId, {
1194
+ stepStates: this.stepStates,
1195
+ variables: this.variables,
1196
+ loopCounters: this.loopCounters,
1197
+ newlyDone,
1198
+ }, this.spec);
1199
+ if (!this.hoplog)
1200
+ return;
1201
+ for (const { node, failed } of newlyDone) {
1202
+ if (failed) {
1203
+ this.hoplog.recordStepFailed(node.step_id, `container '${node.step_id}' failed`);
1204
+ }
1205
+ else {
1206
+ const outputs = {};
1207
+ if (node.outputs && node.outputs.length > 0) {
1208
+ const parentScope = getWriteScope(node.step_id, this.spec);
1209
+ for (const decl of node.outputs) {
1210
+ outputs[decl.name] = this.variables.read(decl.name, parentScope);
1211
+ }
1212
+ }
1213
+ this.hoplog.recordStepDone(node.step_id, Object.keys(outputs).length > 0 ? outputs : undefined);
1214
+ }
1215
+ }
1216
+ }
1217
+ // 返回 work_zone 工作区绝对路径,从 persistence 透传给 NextResponse 和 PromptAssembler。// @a: anc-exec-work-zone
1218
+ getWorkZone() {
1219
+ return this.persistence?.getWorkZone() ?? '';
1220
+ }
1221
+ // 直读 step 的 ← inputs 取原始值——绕过 PromptAssembler 截断/deflate,
1222
+ // 用于 paused.context 等需要原始数据的人通道场景。// @a: anc-exec-audience-routing
1223
+ resolveRawInputs(step) {
1224
+ const result = {};
1225
+ if (!step.inputs || !this.spec)
1226
+ return result;
1227
+ const scopeId = getWriteScope(step.step_id, this.spec);
1228
+ for (const binding of step.inputs) {
1229
+ result[binding.name] = this.variables.read(binding.source, scopeId);
1230
+ }
1231
+ return result;
1232
+ }
1233
+ // 大内容阈值传递:遍历 record,单个值 JSON.stringify().length > DEFLATE_THRESHOLD 时
1234
+ // 写入 work_zone/vars/<key>.json,响应里替换为 {$file: abs_path} 指针。
1235
+ // driver 遇 $file 时 Read 取真实值。见 design/shared-types.md ^anc-exec-deflate。
1236
+ // @a: anc-exec-deflate
1237
+ // namespace:可选子目录名,用于隔离共享 work_zone/vars 的多写者(见 ^anc-exec-deflate 多 child 命名空间隔离)。
1238
+ // for-each 的 N 个 child 共享父 work_zone/vars 且 params_for_child 同名(itemVar),必须按 child_step_id 隔离,
1239
+ // 否则超阈值的多个 child 写同一 vars/<name>.json 互相覆盖、$file 指针张冠李戴。
1240
+ deflateValues(record, namespace) {
1241
+ const workZone = this.getWorkZone();
1242
+ if (!workZone)
1243
+ return record; // 独立模式 work_zone 空,不做 deflate
1244
+ const varsDir = namespace ? join(workZone, 'vars', namespace) : join(workZone, 'vars');
1245
+ const result = {};
1246
+ for (const [key, value] of Object.entries(record)) {
1247
+ if (value === null || value === undefined) {
1248
+ result[key] = value;
1249
+ continue;
1250
+ }
1251
+ const serialized = JSON.stringify(value);
1252
+ if (serialized.length > DEFLATE_THRESHOLD) {
1253
+ const filePath = join(varsDir, `${key}.json`);
1254
+ mkdirSync(varsDir, { recursive: true }); // namespace 子目录按需创建(vars/ 已由 initExecution 建)
1255
+ writeFileSync(filePath, serialized, { mode: 0o600 });
1256
+ result[key] = { $file: filePath };
1257
+ }
1258
+ else {
1259
+ result[key] = value;
1260
+ }
1261
+ }
1262
+ return result;
1263
+ }
1264
+ // steps 缺省用 this.spec(运行期);init 期 this.spec 未设,可显式传 ast.steps。
1265
+ findStepById(stepId, steps) {
1266
+ const search = (nodes) => {
1267
+ for (const step of nodes) {
1268
+ if (step.step_id === stepId)
1269
+ return step;
1270
+ if (hasChildren(step)) {
1271
+ const found = search(getChildren(step));
1272
+ if (found)
1273
+ return found;
1274
+ }
1275
+ }
1276
+ return null;
1277
+ };
1278
+ return search(steps ?? this.spec?.steps ?? []);
1279
+ }
1280
+ collectOutputs() {
1281
+ const declared = this.spec?.header.outputs ?? [];
1282
+ const result = {};
1283
+ for (const decl of declared) {
1284
+ const value = this.variables.read(decl.name, 'root');
1285
+ result[decl.name] = value;
1286
+ // worker 子实例(subtreeRoot)只负责子树,不检查父 spec 的顶层 Outputs(它们由父实例 join 后检查)
1287
+ if ((value === null || value === undefined) && !this.subtreeRoot) {
1288
+ this.recordEvent('', 'step_failed', `Output "${decl.name}" was never assigned (remains None)`);
1289
+ this.hoplog?.recordWarn('', `Output "${decl.name}" declared in Outputs but never assigned`);
1290
+ }
1291
+ }
1292
+ return result;
1293
+ }
1294
+ assembleBasicContext(step) {
1295
+ const hasKnowledge = !!this.hostConfig?.knowledge_provider;
1296
+ const assembler = new PromptAssembler(this, hasKnowledge);
1297
+ const context = assembler.assembleReasonContext(step);
1298
+ this.resolveStepDocRefs(step, context);
1299
+ return context;
1300
+ }
1301
+ // doc-ref [[doc#章节]] 解析——两模式共享基座(assembleBasicContext),复用模式也生效。
1302
+ // 找不到文件/章节即抛 DocRefError(caller 在 nextStep 兜底 failStep)。// @a: anc-exec-doc-ref-resolve
1303
+ resolveStepDocRefs(step, context) {
1304
+ const host = this.hostConfig;
1305
+ if (!host?.workspace_dir)
1306
+ return; // 无 workspace(如纯 AST 测试)跳过
1307
+ // spec 级(Constraints,对所有步骤可见)+ 步骤级,合并去重。
1308
+ // minimal 非首步:跳过 spec 级 doc-ref(跨步不变,首步已给),仅保步骤级。见 ^anc-exec-context-mode。
1309
+ // @a: anc-exec-context-mode
1310
+ const lean = this.isMinimalNonFirst();
1311
+ const specRefs = lean ? [] : (this.spec?.header.doc_refs ?? []);
1312
+ const refs = [...specRefs, ...(step.doc_refs ?? [])];
1313
+ if (refs.length === 0)
1314
+ return;
1315
+ const seen = new Set();
1316
+ const uniq = refs.filter(r => {
1317
+ const k = `${r.doc} ${r.section}`;
1318
+ if (seen.has(k))
1319
+ return false;
1320
+ seen.add(k);
1321
+ return true;
1322
+ });
1323
+ const fragments = resolveDocRefs(uniq, host.workspace_dir, host.sandbox, this.getWorkZone());
1324
+ const text = formatDocRefContext(fragments);
1325
+ if (text)
1326
+ context.doc_ref_context = text;
1327
+ // HopLog 流控字段延后记录——此刻 step 尚未 recordStepStart(在本方法之后),
1328
+ // 立即 recordStepMeta 会被 guardOrphan 拦截。暂存,recordStepStart 后由 flushDocRefMeta 落账。
1329
+ this.pendingDocRefSources = fragments.map(f => `${f.doc}#${f.section}`);
1330
+ }
1331
+ pendingDocRefSources = [];
1332
+ // 在 recordStepStart 之后落 doc-ref 流控字段(绕开 guardOrphan 时序问题)。// @a: anc-exec-doc-ref-resolve
1333
+ flushDocRefMeta(stepId) {
1334
+ if (this.pendingDocRefSources.length === 0)
1335
+ return;
1336
+ const at = new Date().toISOString();
1337
+ this.hoplog?.recordStepMeta(stepId, {
1338
+ doc_refs: this.pendingDocRefSources.map(source_id => ({ source_id, at })),
1339
+ });
1340
+ this.pendingDocRefSources = [];
1341
+ }
1342
+ determineExecutionStatus() {
1343
+ const steps = this.spec?.steps ?? [];
1344
+ if (steps.length === 0)
1345
+ return 'completed';
1346
+ const allTerminal = this.allTopLevelTerminal(steps);
1347
+ if (!allTerminal)
1348
+ return 'running';
1349
+ if (steps.some(s => this.stepStates.get(s.step_id) === 'failed'))
1350
+ return 'failed';
1351
+ return 'completed';
1352
+ }
1353
+ getFailureReason(stepId) {
1354
+ return this.stepFailReasons.get(stepId)?.reason ?? 'Unknown failure';
1355
+ }
1356
+ handleFailStepRetry(stepId, reason) {
1357
+ const subtask = this.findNearestSubtaskAncestor(stepId);
1358
+ if (!subtask) {
1359
+ this.propagateAndRecord(stepId);
1360
+ return;
1361
+ }
1362
+ const retryMax = subtask.retry ?? 1;
1363
+ if (!this.retryCounters.has(subtask.step_id)) {
1364
+ this.retryCounters.set(subtask.step_id, retryMax);
1365
+ }
1366
+ const remaining = this.retryCounters.get(subtask.step_id);
1367
+ if (remaining <= 0) {
1368
+ this.markSubtaskFailed(subtask);
1369
+ return;
1370
+ }
1371
+ this.retryCounters.set(subtask.step_id, remaining - 1);
1372
+ // 已用失败次数(含本次):第 1 次失败 attemptsUsed==1。升级阶梯按此判级。
1373
+ const attemptsUsed = retryMax - remaining + 1;
1374
+ const history = this.retryHistory.get(subtask.step_id) ?? [];
1375
+ history.push({
1376
+ attempt: attemptsUsed,
1377
+ failure_reason: reason,
1378
+ steps_tried: this.summarizeChildren(subtask),
1379
+ });
1380
+ this.retryHistory.set(subtask.step_id, history);
1381
+ this.recordEvent(subtask.step_id, 'retry', `attempt ${attemptsUsed}/${retryMax}`);
1382
+ // 升级阶梯(见概念 ^anc-exec-retry-adaptive / 设计 exec-engine fail_step 3.2/3.3):
1383
+ // retry=N 总失败预算。第 1 次失败 → 带反馈重跑(相同结构,失败说明经 L2c 注入,
1384
+ // 见 prompt ^anc-exec-l2c-retry-feedback);adaptive 且第 2 次起 → 重规划结构。
1385
+ // 无 adaptive → 每次都机械重跑。 // @a: anc-exec-retry-adaptive
1386
+ if (subtask.adaptive && attemptsUsed >= 2) {
1387
+ this.adaptiveNeededSubtask = subtask.step_id;
1388
+ return;
1389
+ }
1390
+ this.resetSubtaskForRetry(subtask);
1391
+ }
1392
+ // L2c 重试反馈源:返回 stepId 所在重试容器的最近一条失败说明(供 prompt 带反馈重跑)。
1393
+ // 无重试容器或无重试历史 → undefined(首跑不渲染)。// @a: anc-exec-l2c-retry-feedback
1394
+ getActiveRetryFeedback(stepId) {
1395
+ const subtask = this.findNearestSubtaskAncestor(stepId);
1396
+ if (!subtask)
1397
+ return undefined;
1398
+ const history = this.retryHistory.get(subtask.step_id);
1399
+ if (!history || history.length === 0)
1400
+ return undefined;
1401
+ const last = history[history.length - 1];
1402
+ return { attempt: last.attempt, reason: last.failure_reason };
1403
+ }
1404
+ // confirm answer 决策值提取:从 caller 注入的 answer 取 decision/value/answer 键或单值。
1405
+ // 见 ^anc-exec-confirm-answer。
1406
+ extractDecision(answer) {
1407
+ if (!answer)
1408
+ return undefined;
1409
+ if ('decision' in answer)
1410
+ return answer['decision'];
1411
+ if ('value' in answer)
1412
+ return answer['value'];
1413
+ if ('answer' in answer)
1414
+ return answer['answer'];
1415
+ const vals = Object.values(answer);
1416
+ return vals.length === 1 ? vals[0] : undefined;
1417
+ }
1418
+ // confirm approve → bool 槽写 true(纯审批闸门,+→ 仅 bool)。reject 已在调用处全局中止,到此必是 approve。
1419
+ // 见 ^anc-exec-confirm-answer。
1420
+ mapConfirmOutputs(node, answer, decision) {
1421
+ const decls = node.outputs ?? [];
1422
+ if (decls.length === 0)
1423
+ return answer ?? {};
1424
+ const mapped = {};
1425
+ for (const d of decls) {
1426
+ mapped[d.name] = true; // approve → bool true(confirm 收窄为纯审批,所有声明输出都是 bool 审批槽)
1427
+ }
1428
+ return mapped;
1429
+ }
1430
+ // ask answer → 数据值落到 +→ 声明变量名。caller answer 三种形态:
1431
+ // ①{声明名:值} 直接采用 ②{value:值}/单值包装 → 落到首个声明名 ③approve/空 → 采用 default_value(前序推断)
1432
+ // 见 ^anc-step-ask / ^anc-exec-hitl-presentation。// @a: anc-step-ask, anc-exec-hitl-presentation
1433
+ mapAskOutputs(node, answer, stepId) {
1434
+ const decls = node.outputs ?? [];
1435
+ if (decls.length === 0)
1436
+ return answer ?? {};
1437
+ // ① answer 的 key 已匹配声明名 → 直接采用
1438
+ if (answer && decls.some(d => d.name in answer))
1439
+ return answer;
1440
+ const raw = this.extractDecision(answer);
1441
+ const mapped = {};
1442
+ // ③ approve 快捷:采用 default_value(首个声明在前序产出中的同名推断值)
1443
+ const isApproveShortcut = raw !== undefined && String(raw).toLowerCase() === 'approve';
1444
+ for (const d of decls) {
1445
+ if (isApproveShortcut) {
1446
+ // 采用前序推断默认值(从当前 scope 自底向上读同名)
1447
+ const scopeId = getWriteScope(stepId, this.spec);
1448
+ mapped[d.name] = this.variables.read(d.name, scopeId);
1449
+ }
1450
+ else {
1451
+ // ② 用 caller 提供的值
1452
+ mapped[d.name] = raw;
1453
+ }
1454
+ }
1455
+ return mapped;
1456
+ }
1457
+ // 重试容器祖先查找:subtask 或 case(case 就是 branch 下的 subtask,retry 语义相同)
1458
+ findNearestSubtaskAncestor(stepId) {
1459
+ let current = getParentStepId(stepId);
1460
+ while (current) {
1461
+ const step = this.findStepById(current);
1462
+ if (step && (step.step_type === 'subtask' || step.step_type === 'case')) {
1463
+ return step;
1464
+ }
1465
+ current = getParentStepId(current);
1466
+ }
1467
+ return null;
1468
+ }
1469
+ resetSubtaskForRetry(subtask) {
1470
+ // Python 语义:仅重置 children 步骤状态为 pending;child 输出变量**不清空**(自然保留)。
1471
+ // 重跑步骤用新产出覆盖旧值;需重跑前清空的量由作者在子步骤显式 `+ → x = Null`。
1472
+ // 原"清 subtask scope 所有 child 输出"逻辑已删(与 loop 二分同源,属已废除的引擎隐式清空)。
1473
+ // 见 [[exec-engine]] 变量作用域规则 6 / ^anc-exec-loop-var-scope。// @a: anc-exec-loop-var-scope
1474
+ this.setSubtreePending(subtask.children);
1475
+ this.stepStates.set(subtask.step_id, 'running');
1476
+ }
1477
+ markSubtaskFailed(subtask) {
1478
+ if (subtask.outputs) {
1479
+ const scopeId = getWriteScope(subtask.step_id, this.spec);
1480
+ this.variables.setOutputsToNull(subtask.outputs, scopeId);
1481
+ }
1482
+ this.stepStates.set(subtask.step_id, 'failed');
1483
+ this.recordStepFailure(subtask.step_id, 'retry exhausted');
1484
+ this.propagateAndRecord(subtask.step_id);
1485
+ }
1486
+ removeChildStates(parent) {
1487
+ if (!hasChildren(parent))
1488
+ return;
1489
+ for (const child of getChildren(parent)) {
1490
+ this.stepStates.delete(child.step_id);
1491
+ this.removeChildStates(child);
1492
+ }
1493
+ }
1494
+ reIdSteps(steps, parentId) {
1495
+ return steps.map((step, i) => {
1496
+ const newId = `${parentId}.${i + 1}`;
1497
+ const updated = { ...step, step_id: newId };
1498
+ if (hasChildren(updated)) {
1499
+ updated.children = this.reIdSteps(getChildren(updated), newId);
1500
+ }
1501
+ return updated;
1502
+ });
1503
+ }
1504
+ skipAllDescendants(step) {
1505
+ if (!hasChildren(step))
1506
+ return;
1507
+ for (const child of getChildren(step)) {
1508
+ this.stepStates.set(child.step_id, 'skipped');
1509
+ this.skipAllDescendants(child);
1510
+ }
1511
+ }
1512
+ /** 把步骤节点投影为 StepSummary(step_id/type/summary/输出名)。单点定义——四处(adaptive
1513
+ * original_children、replan 两处、summarizeChildren)复用,字段增减只改这里。 */
1514
+ toStepSummary(node) {
1515
+ return {
1516
+ step_id: node.step_id,
1517
+ step_type: node.step_type,
1518
+ summary: node.summary,
1519
+ outputs: (node.outputs ?? []).map(o => o.name),
1520
+ };
1521
+ }
1522
+ summarizeChildren(subtask) {
1523
+ return subtask.children.map(c => this.toStepSummary(c));
1524
+ }
1525
+ // 执行事件流——执行历史原料(L3 上下文重建用)。持久化到 state.json 的 exec_events,
1526
+ // 跨进程续上 loop 迭代时序与子步状态。HopLog 另由 recordStepStart/Done/Failed 独立写入,不互为数据源。
1527
+ recordEvent(stepId, event, detail) {
1528
+ this.execEvents.push({ at: new Date().toISOString(), step_id: stepId, event, detail });
1529
+ }
1530
+ // 步骤失败:记事件流 + 存结构化失败原因(getFailureReason 的唯一来源,跨进程稳定)。
1531
+ recordStepFailure(stepId, reason, failKind = 'error') {
1532
+ this.recordEvent(stepId, 'step_failed', reason);
1533
+ this.stepFailReasons.set(stepId, { reason, fail_kind: failKind });
1534
+ }
1535
+ persist() {
1536
+ if (!this.persistence || !this.spec)
1537
+ return;
1538
+ this.persistence.saveSnapshot({
1539
+ spec: this.spec,
1540
+ state: this.buildStateFile(),
1541
+ vars: { format_version: 2, scopes: this.variables.toJSON() },
1542
+ });
1543
+ this.hoplog?.flush();
1544
+ }
1545
+ buildStateFile() {
1546
+ const step_states = {};
1547
+ for (const [k, v] of this.stepStates)
1548
+ step_states[k] = v;
1549
+ const retry_counters = {};
1550
+ for (const [k, v] of this.retryCounters)
1551
+ retry_counters[k] = v;
1552
+ const loop_counters = {};
1553
+ for (const [k, v] of this.loopCounters)
1554
+ loop_counters[k] = v;
1555
+ const retry_history = {};
1556
+ for (const [k, v] of this.retryHistory)
1557
+ retry_history[k] = v;
1558
+ const step_fail_reasons = {};
1559
+ for (const [k, v] of this.stepFailReasons)
1560
+ step_fail_reasons[k] = v;
1561
+ return {
1562
+ format_version: 1,
1563
+ step_states,
1564
+ retry_counters,
1565
+ loop_counters,
1566
+ ...(Object.keys(retry_history).length ? { retry_history } : {}),
1567
+ ...(Object.keys(step_fail_reasons).length ? { step_fail_reasons } : {}),
1568
+ ...(this.execEvents.length ? { exec_events: this.execEvents } : {}),
1569
+ ...(this.logDir ? { log_dir: this.logDir } : {}),
1570
+ ...(this.hoplog ? { hoplog_run_dir: this.hoplog.getRunDir() } : {}),
1571
+ ...(this.adaptiveNeededSubtask ? { adaptive_needed_subtask: this.adaptiveNeededSubtask } : {}),
1572
+ ...(this.canFanout ? { can_fanout: true } : {}),
1573
+ ...(this.dispatched.size ? { dispatched: Object.fromEntries(this.dispatched) } : {}),
1574
+ ...(this.specPath ? { spec_path: this.specPath } : {}),
1575
+ ...(this.cliAbsPath ? { cli_abs_path: this.cliAbsPath } : {}),
1576
+ ...(this.hostConfig?.resource_limits?.max_concurrent_workers != null
1577
+ ? { max_concurrent: this.hostConfig.resource_limits.max_concurrent_workers }
1578
+ : this.maxConcurrent != null ? { max_concurrent: this.maxConcurrent } : {}),
1579
+ ...(this.contextMode === 'minimal' ? { context_mode: 'minimal' } : {}),
1580
+ ...(this.subtreeRoot ? { subtree_root: this.subtreeRoot } : {}),
1581
+ // doc-ref/P15 跨进程 resume 需 workspace+sandbox(不落凭证)。// @a: anc-exec-doc-ref-resolve
1582
+ ...(this.hostConfig?.workspace_dir
1583
+ ? { host_context: { workspace_dir: this.hostConfig.workspace_dir, sandbox: this.hostConfig.sandbox } }
1584
+ : {}),
1585
+ };
1586
+ }
1587
+ // 加载续执行:从快照重建引擎,保留 running 状态(已交付 caller、正等回写的合法持久态)。
1588
+ // 复用模式日常命令(next/done/fail/status/vars/branch/replan)的入口。
1589
+ // 概念见 [[HopSpec V3配套HopJIT运行时能力#^anc-exec-durable-resume]]。
1590
+ static load(instanceDir) {
1591
+ const persistence = new FilePersistence(instanceDir, true);
1592
+ const snapshot = persistence.loadSnapshot();
1593
+ const specData = snapshot.spec;
1594
+ const varsData = snapshot.vars;
1595
+ const stateData = snapshot.state;
1596
+ const engine = new ExecutionEngine();
1597
+ engine.spec = specData;
1598
+ engine.persistence = persistence;
1599
+ // vars.json v2:scope 树重建;v1(或无版本):扁平变量塞 root(向后兼容)。
1600
+ // 见 design/exec-engine.md ^anc-exec-vars-scope-persist。// @a: anc-exec-vars-scope-persist
1601
+ engine.variables = varsData.format_version === 2 && varsData.scopes
1602
+ ? VariableStore.fromScopes(varsData.scopes)
1603
+ : VariableStore.fromJSON(varsData.variables ?? {});
1604
+ // 原样重建 step_states——load 不重置 running(区别于 recover)
1605
+ for (const [stepId, status] of Object.entries(stateData.step_states)) {
1606
+ engine.stepStates.set(stepId, status);
1607
+ }
1608
+ for (const [k, v] of Object.entries(stateData.retry_counters)) {
1609
+ engine.retryCounters.set(k, v);
1610
+ }
1611
+ for (const [k, v] of Object.entries(stateData.loop_counters)) {
1612
+ engine.loopCounters.set(k, v);
1613
+ }
1614
+ if (stateData.retry_history) {
1615
+ for (const [k, v] of Object.entries(stateData.retry_history)) {
1616
+ engine.retryHistory.set(k, v);
1617
+ }
1618
+ }
1619
+ if (stateData.step_fail_reasons) {
1620
+ for (const [k, v] of Object.entries(stateData.step_fail_reasons)) {
1621
+ // 兼容旧格式(string)和新格式(StepFailRecord)
1622
+ engine.stepFailReasons.set(k, typeof v === 'string' ? { reason: v, fail_kind: 'error' } : v);
1623
+ }
1624
+ }
1625
+ if (stateData.exec_events) {
1626
+ engine.execEvents = [...stateData.exec_events];
1627
+ }
1628
+ if (stateData.adaptive_needed_subtask) {
1629
+ engine.adaptiveNeededSubtask = stateData.adaptive_needed_subtask;
1630
+ }
1631
+ if (stateData.can_fanout) {
1632
+ engine.canFanout = true;
1633
+ }
1634
+ if (stateData.dispatched) {
1635
+ for (const [k, v] of Object.entries(stateData.dispatched))
1636
+ engine.dispatched.set(k, [...v]);
1637
+ }
1638
+ if (stateData.spec_path)
1639
+ engine.specPath = stateData.spec_path;
1640
+ if (stateData.cli_abs_path)
1641
+ engine.cliAbsPath = stateData.cli_abs_path;
1642
+ if (stateData.max_concurrent != null)
1643
+ engine.maxConcurrent = stateData.max_concurrent;
1644
+ if (stateData.context_mode === 'minimal') {
1645
+ engine.contextMode = 'minimal';
1646
+ }
1647
+ // env 覆盖持久值:任一 load 命令进程可临时调档(精度 env > state)。见 ^anc-exec-context-mode
1648
+ const envMode = process.env['HOPJIT_CONTEXT_MODE']?.toLowerCase();
1649
+ if (envMode === 'minimal' || envMode === 'full')
1650
+ engine.contextMode = envMode;
1651
+ if (stateData.subtree_root) {
1652
+ engine.subtreeRoot = stateData.subtree_root;
1653
+ }
1654
+ // 恢复 host_context(workspace+sandbox),doc-ref/P15 跨进程必需。// @a: anc-exec-doc-ref-resolve
1655
+ if (stateData.host_context) {
1656
+ engine.hostConfig = {
1657
+ workspace_dir: stateData.host_context.workspace_dir,
1658
+ sandbox: stateData.host_context.sandbox,
1659
+ };
1660
+ }
1661
+ // Rebuild HopLog if persisted // @a: anc-obs-hoplog-resume
1662
+ if (stateData.hoplog_run_dir) {
1663
+ engine.hoplog = HopLog.resume(stateData.hoplog_run_dir);
1664
+ engine.logDir = stateData.log_dir ?? null;
1665
+ }
1666
+ const parts = instanceDir.split('/');
1667
+ engine.instanceId = parts[parts.length - 1] || parts[parts.length - 2];
1668
+ const parentDir = instanceDir.substring(0, instanceDir.lastIndexOf('/'));
1669
+ engine.stateDir = parentDir;
1670
+ engine.instanceDir = instanceDir;
1671
+ return engine;
1672
+ }
1673
+ // 崩溃恢复:load + 将悬空 running 重置为 pending 幂等重跑。仅 `hopjit resume` 命令使用。
1674
+ // 崩溃时的 running 无回写记录,是悬空的;branch 已选 case 的特例保留 running。
1675
+ static recover(instanceDir) {
1676
+ const engine = ExecutionEngine.load(instanceDir);
1677
+ const snapshotStates = {};
1678
+ for (const [k, v] of engine.stepStates)
1679
+ snapshotStates[k] = v;
1680
+ for (const [stepId, status] of Object.entries(snapshotStates)) {
1681
+ if (status !== 'running')
1682
+ continue;
1683
+ // branch 已完成条件评估(有 skipped case)→ 保留 running,避免重新评估冲突
1684
+ const step = engine.findStepById(stepId, engine.spec?.steps ?? []);
1685
+ if (step && step.step_type === 'branch' && engine.hasBranchSelection(stepId, snapshotStates)) {
1686
+ continue;
1687
+ }
1688
+ engine.stepStates.set(stepId, 'pending');
1689
+ }
1690
+ engine.persist();
1691
+ return engine;
1692
+ }
1693
+ hasBranchSelection(branchId, states) {
1694
+ // A branch has an active selection if any of its direct children are 'skipped'
1695
+ const branch = this.findStepById(branchId, this.spec?.steps ?? []);
1696
+ if (!branch || !hasChildren(branch))
1697
+ return false;
1698
+ return getChildren(branch).some(c => states[c.step_id] === 'skipped');
1699
+ }
1700
+ // --- P0-1: Check-finally preservation validation ---
1701
+ validateCheckPreservation(originalChildren, newChildren) {
1702
+ const originalHasCheck = this.hasCheckStep(originalChildren);
1703
+ const originalFinallySteps = this.getCheckFinallySteps(originalChildren);
1704
+ // Rule 1: If original has check steps, new must also have at least one check step
1705
+ if (originalHasCheck && !this.hasCheckStep(newChildren)) {
1706
+ return {
1707
+ kind: 'validate',
1708
+ rule: 'replan-check-required',
1709
+ severity: 'error',
1710
+ message: 'Replan must include at least one [check] step (original plan contained check steps)',
1711
+ };
1712
+ }
1713
+ // Rule 2: If original has check-finally steps, new must preserve them at the end
1714
+ if (originalFinallySteps.length > 0) {
1715
+ const newTopLevel = newChildren;
1716
+ if (newTopLevel.length === 0) {
1717
+ return {
1718
+ kind: 'validate',
1719
+ rule: 'replan-check-finally',
1720
+ severity: 'error',
1721
+ message: 'Replan must preserve [check finally] step at the end (check finally cannot be removed)',
1722
+ };
1723
+ }
1724
+ // Check that the last step(s) are check-finally
1725
+ const lastStep = newTopLevel[newTopLevel.length - 1];
1726
+ if (lastStep.step_type !== 'check' || !lastStep.is_finally) {
1727
+ return {
1728
+ kind: 'validate',
1729
+ rule: 'replan-check-finally',
1730
+ severity: 'error',
1731
+ message: 'Replan must preserve [check finally] step at the end (check finally must be the last step)',
1732
+ };
1733
+ }
1734
+ }
1735
+ return null;
1736
+ }
1737
+ hasCheckStep(steps) {
1738
+ for (const step of steps) {
1739
+ if (step.step_type === 'check')
1740
+ return true;
1741
+ if (hasChildren(step)) {
1742
+ if (this.hasCheckStep(getChildren(step)))
1743
+ return true;
1744
+ }
1745
+ }
1746
+ return false;
1747
+ }
1748
+ getCheckFinallySteps(steps) {
1749
+ const result = [];
1750
+ for (const step of steps) {
1751
+ if (step.step_type === 'check' && step.is_finally) {
1752
+ result.push(step);
1753
+ }
1754
+ if (hasChildren(step)) {
1755
+ result.push(...this.getCheckFinallySteps(getChildren(step)));
1756
+ }
1757
+ }
1758
+ return result;
1759
+ }
1760
+ // --- P0-2: Replan similarity detection ---
1761
+ isReplanDuplicate(previous, current) {
1762
+ // Condition 1: Same number of steps
1763
+ if (previous.length !== current.length)
1764
+ return false;
1765
+ // Condition 2: Same step type distribution
1766
+ const prevTypes = previous.map(s => s.step_type).sort().join(',');
1767
+ const currTypes = current.map(s => s.step_type).sort().join(',');
1768
+ if (prevTypes !== currTypes)
1769
+ return false;
1770
+ // Condition 3: Summary text similarity > 80% (Jaccard on words)
1771
+ const prevText = previous.map(s => s.summary).join(' ');
1772
+ const currText = current.map(s => s.summary).join(' ');
1773
+ const similarity = this.jaccardSimilarity(prevText, currText);
1774
+ return similarity > 0.8;
1775
+ }
1776
+ jaccardSimilarity(a, b) {
1777
+ const tokenize = (text) => new Set(text.toLowerCase().split(/\s+/).filter(w => w.length > 0));
1778
+ const setA = tokenize(a);
1779
+ const setB = tokenize(b);
1780
+ if (setA.size === 0 && setB.size === 0)
1781
+ return 1;
1782
+ let intersection = 0;
1783
+ for (const word of setA) {
1784
+ if (setB.has(word))
1785
+ intersection++;
1786
+ }
1787
+ const union = setA.size + setB.size - intersection;
1788
+ if (union === 0)
1789
+ return 1;
1790
+ return intersection / union;
1791
+ }
1792
+ }