@tea-agent/loop-agent 0.20.1-beta.0 → 0.21.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 (71) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/application/dag/args.js +29 -0
  3. package/dist/application/dag/run-dag.js +3 -1
  4. package/dist/cli/command-definitions.js +15 -1
  5. package/dist/cli/program.js +11 -1
  6. package/dist/commands/dag-rerun-task.js +19 -0
  7. package/dist/commands/dag-rerun.js +111 -0
  8. package/dist/commands/init.js +7 -0
  9. package/dist/executors/dag-pi-executor.js +24 -0
  10. package/dist/executors/pi-executor.js +111 -36
  11. package/dist/executors/pi-sdk-executor.js +105 -29
  12. package/dist/executors/shell-executor.js +54 -11
  13. package/dist/shared/operator/capabilities.js +54 -0
  14. package/dist/worker/console/index.js +1 -1
  15. package/dist/worker/console/inspect-split.js +82 -0
  16. package/dist/worker/console/operation-runner.js +3 -1
  17. package/dist/worker/console/operation-store.js +1 -0
  18. package/dist/worker/console/operator-actions.js +153 -2
  19. package/dist/worker/console/operator-user-error.js +10 -0
  20. package/dist/worker/console/pi-readiness.js +4 -0
  21. package/dist/worker/console/recovery-cta.js +116 -5
  22. package/dist/worker/console/recovery-selection.js +107 -0
  23. package/dist/worker/console/resolve-dag-run-for-task.js +50 -11
  24. package/dist/worker/console/routes.js +20 -0
  25. package/dist/worker/console/sibling-controller.js +12 -7
  26. package/dist/worker/console/static/assets/index-CUDke82y.js +18 -0
  27. package/dist/worker/console/static/assets/index-wSEksVSO.css +1 -0
  28. package/dist/worker/console/static/index.html +2 -2
  29. package/dist/worker/loop-agent/loop-agent-client.js +43 -9
  30. package/dist/worker/observability/read-model.js +67 -1
  31. package/dist/worker/observe/static/constants.js +5 -0
  32. package/dist/worker/observe/static/format-pool.js +22 -3
  33. package/dist/worker/observe/static/index.html +1 -1
  34. package/dist/worker/observe/static/styles.css +32 -3
  35. package/dist/worker/observe/static/views/dag-inspector.js +2 -2
  36. package/dist/worker/run-task/run-task.js +23 -6
  37. package/dist/workflows/dag/backend-test-markdown-workflow.js +291 -97
  38. package/dist/workflows/dag/backend-test-result-contract.js +10 -4
  39. package/dist/workflows/dag/frontend-test-result-contract.js +64 -0
  40. package/dist/workflows/dag/init-hybrid.js +117 -64
  41. package/dist/workflows/dag/lifecycle.js +60 -4
  42. package/dist/workflows/dag/liveness-policy.js +250 -0
  43. package/dist/workflows/dag/node-execution.js +89 -6
  44. package/dist/workflows/dag/output-protocol.js +76 -0
  45. package/dist/workflows/dag/rerun-plan.js +611 -0
  46. package/dist/workflows/dag/rerun-run.js +497 -0
  47. package/dist/workflows/dag/rerun-task.js +284 -0
  48. package/dist/workflows/dag/retry-policy.js +20 -1
  49. package/dist/workflows/dag/runner.js +71 -1
  50. package/dist/workflows/dag/skill-snapshot.js +22 -3
  51. package/dist/workflows/dag/types.js +12 -0
  52. package/dist/workflows/dag/validate.js +11 -0
  53. package/dist/workflows/dag/workspace-checkpoint.js +163 -0
  54. package/docs/README.md +5 -5
  55. package/docs/architecture/dag-execution.md +11 -0
  56. package/docs/architecture/facts-and-state.md +1 -0
  57. package/docs/architecture/worker-and-feature.md +10 -0
  58. package/docs/templates/agent-dag.schema.json +17 -2
  59. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -2
  60. package/docs/templates/backend-test-dag.json +15 -15
  61. package/docs/templates/frontend-test-case-checklist.md +16 -1
  62. package/docs/templates/frontend-test-dag.generate-cases.prompt.md +26 -2
  63. package/docs/templates/frontend-test-dag.json +65 -6
  64. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +4 -1
  65. package/harness.json +1 -1
  66. package/package.json +1 -1
  67. package/skills/loop-agent/references/command-reference.md +5 -0
  68. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  69. package/skills/playwright-cli-case-generator/SKILL.md +35 -7
  70. package/dist/worker/console/static/assets/index-3vsjZJHq.js +0 -16
  71. package/dist/worker/console/static/assets/index-i1wV4LrY.css +0 -1
@@ -0,0 +1,611 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { getTaskPaths } from "../../task/runtime.js";
5
+ import { assertTaskContractBindingFresh } from "./task-contract-binding.js";
6
+ import { isSafeReadOnlyPiRetryCandidate } from "./retry-policy.js";
7
+ const REVIEW_VERDICT_RECOVERY_NODE_ID = "review-verdict-recovery-pi";
8
+ const SAFE_SHELL_GATE_KEYS = [
9
+ "verdictGate",
10
+ "jsonArtifactGate",
11
+ "requirementCoverageGate",
12
+ "repairArtifactGate",
13
+ ];
14
+ function taskById(spec) {
15
+ return new Map(spec.tasks.map((task) => [task.id, task]));
16
+ }
17
+ function buildChildrenByParent(spec) {
18
+ const childrenByParent = new Map();
19
+ for (const task of spec.tasks) {
20
+ for (const parent of task.depends_on) {
21
+ const children = childrenByParent.get(parent) ?? [];
22
+ children.push(task.id);
23
+ childrenByParent.set(parent, children);
24
+ }
25
+ }
26
+ return childrenByParent;
27
+ }
28
+ function collectReachableDescendants(spec, rootNodeId) {
29
+ const childrenByParent = buildChildrenByParent(spec);
30
+ const descendants = [];
31
+ const queue = [...(childrenByParent.get(rootNodeId) ?? [])];
32
+ const seen = new Set();
33
+ while (queue.length > 0) {
34
+ const nodeId = queue.shift();
35
+ if (seen.has(nodeId))
36
+ continue;
37
+ seen.add(nodeId);
38
+ descendants.push(nodeId);
39
+ queue.push(...(childrenByParent.get(nodeId) ?? []));
40
+ }
41
+ return descendants.sort();
42
+ }
43
+ function isUpstreamOf(spec, ancestorId, nodeId) {
44
+ if (ancestorId === nodeId)
45
+ return true;
46
+ const tasks = taskById(spec);
47
+ const visited = new Set();
48
+ let frontier = [nodeId];
49
+ while (frontier.length > 0) {
50
+ const next = [];
51
+ for (const id of frontier) {
52
+ const task = tasks.get(id);
53
+ if (!task)
54
+ continue;
55
+ for (const dep of task.depends_on) {
56
+ if (dep === ancestorId)
57
+ return true;
58
+ if (!visited.has(dep)) {
59
+ visited.add(dep);
60
+ next.push(dep);
61
+ }
62
+ }
63
+ }
64
+ frontier = next;
65
+ }
66
+ return false;
67
+ }
68
+ function preferVerdictGateEffectiveNode(spec, fromNodeId, selectedNodeId) {
69
+ const ids = new Set(spec.tasks.map((task) => task.id));
70
+ if (!ids.has(REVIEW_VERDICT_RECOVERY_NODE_ID)) {
71
+ return fromNodeId;
72
+ }
73
+ const recoveryId = REVIEW_VERDICT_RECOVERY_NODE_ID;
74
+ if (isUpstreamOf(spec, recoveryId, selectedNodeId) &&
75
+ (recoveryId === fromNodeId || isUpstreamOf(spec, fromNodeId, recoveryId))) {
76
+ return recoveryId;
77
+ }
78
+ return fromNodeId;
79
+ }
80
+ function findNearestUpstreamErrorNodeIds(spec, state, nodeId) {
81
+ const tasks = taskById(spec);
82
+ const visited = new Set();
83
+ let frontier = [nodeId];
84
+ while (frontier.length > 0) {
85
+ const nextFrontier = [];
86
+ const errorsAtLevel = [];
87
+ for (const id of frontier) {
88
+ const task = tasks.get(id);
89
+ if (!task)
90
+ continue;
91
+ for (const dep of task.depends_on) {
92
+ if (visited.has(dep))
93
+ continue;
94
+ visited.add(dep);
95
+ const status = state?.nodes[dep]?.status;
96
+ if (status === "ERROR") {
97
+ errorsAtLevel.push(dep);
98
+ }
99
+ else {
100
+ nextFrontier.push(dep);
101
+ }
102
+ }
103
+ }
104
+ if (errorsAtLevel.length > 0) {
105
+ return [...new Set(errorsAtLevel)].sort();
106
+ }
107
+ frontier = nextFrontier;
108
+ }
109
+ return [];
110
+ }
111
+ function resolveWritePolicy(task, spec) {
112
+ return task.writePolicy ?? spec.defaults?.writePolicy;
113
+ }
114
+ function isWriterTask(task, spec) {
115
+ if (task.toolProfile === "write")
116
+ return true;
117
+ const writePolicy = resolveWritePolicy(task, spec);
118
+ return writePolicy === "exclusive" || writePolicy === "docs-only";
119
+ }
120
+ function hasDynamicRuntime(task) {
121
+ return Boolean(task.dynamicExpansion ||
122
+ task.dynamicReduction ||
123
+ task.dynamicCondition ||
124
+ task.dynamicLoopUntil);
125
+ }
126
+ function shellHasDisallowedConfig(shell) {
127
+ if ((shell.commands?.length ?? 0) > 0)
128
+ return true;
129
+ if (shell.preset)
130
+ return true;
131
+ if (shell.projectGovernanceGate)
132
+ return true;
133
+ if (shell.frontendPrewriteGate)
134
+ return true;
135
+ if (shell.frontendVerificationBundle)
136
+ return true;
137
+ if (shell.frontendReviewContext)
138
+ return true;
139
+ if (shell.backendTestPipeline)
140
+ return true;
141
+ if (shell.verifyEvidence)
142
+ return true;
143
+ if (shell.nonZeroExitPolicy)
144
+ return true;
145
+ if (shell.envAllowlist?.length)
146
+ return true;
147
+ if (shell.timeoutMs !== undefined)
148
+ return true;
149
+ if (shell.cwd)
150
+ return true;
151
+ return false;
152
+ }
153
+ function isSafeGateOnlyShell(task) {
154
+ if (task.executor !== "shell")
155
+ return false;
156
+ const shell = task.shell;
157
+ if (!shell)
158
+ return false;
159
+ if (shellHasDisallowedConfig(shell))
160
+ return false;
161
+ return SAFE_SHELL_GATE_KEYS.some((key) => shell[key] !== undefined);
162
+ }
163
+ function isStaticTask(task) {
164
+ return task.executor === "static";
165
+ }
166
+ function isConditionSkip(node, skippedReason) {
167
+ if (node !== "SKIPPED")
168
+ return false;
169
+ if (!skippedReason)
170
+ return false;
171
+ return (skippedReason.includes("condition") ||
172
+ skippedReason.includes("runIf") ||
173
+ skippedReason.includes("run-if"));
174
+ }
175
+ function sortKeysDeep(value) {
176
+ if (Array.isArray(value)) {
177
+ return value.map((item) => sortKeysDeep(item));
178
+ }
179
+ if (value !== null && typeof value === "object") {
180
+ const record = value;
181
+ const sorted = {};
182
+ for (const key of Object.keys(record).sort()) {
183
+ sorted[key] = sortKeysDeep(record[key]);
184
+ }
185
+ return sorted;
186
+ }
187
+ return value;
188
+ }
189
+ export function rewriteEffectiveFromNode(spec, selectedNodeId, state) {
190
+ const tasks = taskById(spec);
191
+ const selected = tasks.get(selectedNodeId);
192
+ if (!selected) {
193
+ return {
194
+ rewriteApplied: false,
195
+ reasonCodes: ["selected-node-missing"],
196
+ };
197
+ }
198
+ if (selected.executor === "shell") {
199
+ const shell = selected.shell;
200
+ if (shell?.verdictGate?.fromNodeId) {
201
+ const effective = preferVerdictGateEffectiveNode(spec, shell.verdictGate.fromNodeId, selectedNodeId);
202
+ return {
203
+ effectiveFromNodeId: effective,
204
+ rewriteApplied: effective !== selectedNodeId,
205
+ reasonCodes: [],
206
+ };
207
+ }
208
+ if (shell?.repairArtifactGate?.fromNodeId) {
209
+ const effective = shell.repairArtifactGate.fromNodeId;
210
+ return {
211
+ effectiveFromNodeId: effective,
212
+ rewriteApplied: effective !== selectedNodeId,
213
+ reasonCodes: [],
214
+ };
215
+ }
216
+ if (shell?.jsonArtifactGate?.fromNodeId) {
217
+ const effective = shell.jsonArtifactGate.fromNodeId;
218
+ return {
219
+ effectiveFromNodeId: effective,
220
+ rewriteApplied: effective !== selectedNodeId,
221
+ reasonCodes: [],
222
+ };
223
+ }
224
+ }
225
+ const selectedStatus = state?.nodes[selectedNodeId]?.status;
226
+ if (selectedStatus === "SKIPPED") {
227
+ const upstreamErrors = findNearestUpstreamErrorNodeIds(spec, state, selectedNodeId);
228
+ if (upstreamErrors.length === 1) {
229
+ const effective = upstreamErrors[0];
230
+ return {
231
+ effectiveFromNodeId: effective,
232
+ rewriteApplied: effective !== selectedNodeId,
233
+ reasonCodes: [],
234
+ };
235
+ }
236
+ if (upstreamErrors.length > 1) {
237
+ return {
238
+ rewriteApplied: false,
239
+ reasonCodes: ["ambiguous-skipped-rewrite"],
240
+ };
241
+ }
242
+ return {
243
+ rewriteApplied: false,
244
+ reasonCodes: ["skipped-without-upstream-error"],
245
+ };
246
+ }
247
+ return {
248
+ effectiveFromNodeId: selectedNodeId,
249
+ rewriteApplied: false,
250
+ reasonCodes: [],
251
+ };
252
+ }
253
+ export function computeResetClosure(spec, effectiveFromNodeId) {
254
+ const tasks = taskById(spec);
255
+ if (!tasks.has(effectiveFromNodeId)) {
256
+ return [];
257
+ }
258
+ return [effectiveFromNodeId, ...collectReachableDescendants(spec, effectiveFromNodeId)].sort();
259
+ }
260
+ export function assessResetClosureSafety(spec, resetNodeIds) {
261
+ const tasks = taskById(spec);
262
+ const blockingNodes = [];
263
+ const reasonCodes = [];
264
+ for (const nodeId of resetNodeIds) {
265
+ const task = tasks.get(nodeId);
266
+ if (!task)
267
+ continue;
268
+ if (isWriterTask(task, spec)) {
269
+ blockingNodes.push({
270
+ nodeId,
271
+ reasonCode: "restart-subgraph-contains-writer",
272
+ });
273
+ reasonCodes.push("restart-subgraph-contains-writer");
274
+ continue;
275
+ }
276
+ if (task.decisionGate?.enabled) {
277
+ blockingNodes.push({
278
+ nodeId,
279
+ reasonCode: "restart-subgraph-contains-decision-gate",
280
+ });
281
+ reasonCodes.push("restart-subgraph-contains-decision-gate");
282
+ continue;
283
+ }
284
+ if (hasDynamicRuntime(task)) {
285
+ blockingNodes.push({
286
+ nodeId,
287
+ reasonCode: "dynamic-rerun-unsupported",
288
+ });
289
+ reasonCodes.push("dynamic-rerun-unsupported");
290
+ continue;
291
+ }
292
+ if (task.executor === "pi") {
293
+ if (!isSafeReadOnlyPiRetryCandidate(task)) {
294
+ blockingNodes.push({
295
+ nodeId,
296
+ reasonCode: "restart-subgraph-contains-writer",
297
+ });
298
+ reasonCodes.push("restart-subgraph-contains-writer");
299
+ }
300
+ continue;
301
+ }
302
+ if (isStaticTask(task)) {
303
+ continue;
304
+ }
305
+ if (task.executor === "shell") {
306
+ if (!isSafeGateOnlyShell(task)) {
307
+ blockingNodes.push({
308
+ nodeId,
309
+ reasonCode: "restart-subgraph-contains-unsafe-shell",
310
+ });
311
+ reasonCodes.push("restart-subgraph-contains-unsafe-shell");
312
+ }
313
+ }
314
+ }
315
+ return {
316
+ safe: blockingNodes.length === 0,
317
+ blockingNodes,
318
+ reasonCodes: [...new Set(reasonCodes)],
319
+ };
320
+ }
321
+ export function computePlanHash(planWithoutHash) {
322
+ const canonical = JSON.stringify(sortKeysDeep(planWithoutHash));
323
+ return createHash("sha256").update(canonical, "utf8").digest("hex");
324
+ }
325
+ function countExecutorNodes(spec, nodeIds, executor) {
326
+ const tasks = taskById(spec);
327
+ return nodeIds.filter((nodeId) => tasks.get(nodeId)?.executor === executor)
328
+ .length;
329
+ }
330
+ function computeVerificationPolicy(spec, parentRunId, resetNodeIds, importedNodeIds) {
331
+ const tasks = taskById(spec);
332
+ const shellNodeIds = [...resetNodeIds, ...importedNodeIds].filter((nodeId) => tasks.get(nodeId)?.executor === "shell");
333
+ if (shellNodeIds.length === 0)
334
+ return undefined;
335
+ const rerunShellIds = resetNodeIds.filter((nodeId) => tasks.get(nodeId)?.executor === "shell");
336
+ const importedShellIds = importedNodeIds.filter((nodeId) => tasks.get(nodeId)?.executor === "shell");
337
+ if (rerunShellIds.length > 0 && importedShellIds.length > 0) {
338
+ return {
339
+ mode: "rerun",
340
+ nodeIds: shellNodeIds.sort(),
341
+ inheritedFromRunId: parentRunId,
342
+ };
343
+ }
344
+ if (rerunShellIds.length > 0) {
345
+ return {
346
+ mode: "rerun",
347
+ nodeIds: rerunShellIds.sort(),
348
+ };
349
+ }
350
+ return {
351
+ mode: "inherited",
352
+ nodeIds: importedShellIds.sort(),
353
+ inheritedFromRunId: parentRunId,
354
+ };
355
+ }
356
+ function deriveSuggestedAction(input) {
357
+ if (input.eligible)
358
+ return "dag-rerun";
359
+ if (input.workerManaged || input.reasonCodes.includes("worker-managed-rerun-unsupported")) {
360
+ return "worker-task-retry";
361
+ }
362
+ if (input.parentLifecycle === "paused")
363
+ return "resume";
364
+ if (input.reasonCodes.includes("binding-drift") ||
365
+ input.reasonCodes.includes("controller-drift") ||
366
+ input.reasonCodes.includes("skill-snapshot-invalid")) {
367
+ return "regenerate";
368
+ }
369
+ if (input.reasonCodes.includes("restart-subgraph-contains-writer") ||
370
+ input.reasonCodes.includes("restart-subgraph-contains-unsafe-shell") ||
371
+ input.reasonCodes.includes("workspace-drift") ||
372
+ input.reasonCodes.includes("workspace-checkpoint-missing") ||
373
+ input.reasonCodes.includes("unresolved-errors-outside-reset-closure") ||
374
+ input.reasonCodes.includes("convergence-rerun-unsupported") ||
375
+ input.reasonCodes.includes("evaluation-rerun-unsupported") ||
376
+ input.reasonCodes.includes("budget-rerun-unsupported")) {
377
+ return "standalone-task-rerun";
378
+ }
379
+ if (input.reasonCodes.includes("restart-subgraph-contains-decision-gate")) {
380
+ return "resume";
381
+ }
382
+ if (input.reasonCodes.includes("parent-lifecycle-ineligible")) {
383
+ if (input.parentLifecycle === "active")
384
+ return "reconcile";
385
+ return "manual";
386
+ }
387
+ return "manual";
388
+ }
389
+ function deriveRisk(reasonCodes, resetCount) {
390
+ if (reasonCodes.includes("restart-subgraph-contains-writer") ||
391
+ reasonCodes.includes("restart-subgraph-contains-unsafe-shell") ||
392
+ reasonCodes.includes("dynamic-rerun-unsupported")) {
393
+ return "high";
394
+ }
395
+ if (resetCount <= 2)
396
+ return "low";
397
+ if (resetCount <= 5)
398
+ return "medium";
399
+ return "high";
400
+ }
401
+ export async function evaluateLiveDagRerunBindings(input) {
402
+ const sourceBinding = input.parentSpec.sourceBinding;
403
+ let sourceBindingMatched = true;
404
+ if (sourceBinding) {
405
+ try {
406
+ const taskPaths = getTaskPaths(input.cwd, sourceBinding.taskId);
407
+ for (const source of sourceBinding.sources) {
408
+ const sourcePath = path.resolve(taskPaths.taskDir, source.path);
409
+ const sourceRoot = path.resolve(taskPaths.sourceDir);
410
+ if (sourcePath !== sourceRoot &&
411
+ !sourcePath.startsWith(`${sourceRoot}${path.sep}`)) {
412
+ sourceBindingMatched = false;
413
+ break;
414
+ }
415
+ const bytes = await readFile(sourcePath);
416
+ if (createHash("sha256").update(bytes).digest("hex") !== source.sha256) {
417
+ sourceBindingMatched = false;
418
+ break;
419
+ }
420
+ }
421
+ }
422
+ catch {
423
+ sourceBindingMatched = false;
424
+ }
425
+ }
426
+ const taskContract = await assertTaskContractBindingFresh({
427
+ repoRoot: input.cwd,
428
+ spec: input.parentSpec,
429
+ action: "dag rerun",
430
+ });
431
+ return {
432
+ sourceBindingMatched,
433
+ taskContractBindingMatched: taskContract.ok,
434
+ };
435
+ }
436
+ export async function evaluateDagRerunPlan(input) {
437
+ void input.cwd;
438
+ void input.parentRunDir;
439
+ const reasonCodes = [];
440
+ const blockedReasons = [];
441
+ const blockingNodes = [];
442
+ const allNodeIds = input.parentSpec.tasks.map((task) => task.id).sort();
443
+ const rewrite = rewriteEffectiveFromNode(input.parentSpec, input.selectedNodeId, input.parentState);
444
+ reasonCodes.push(...rewrite.reasonCodes);
445
+ const effectiveFromNodeId = rewrite.effectiveFromNodeId;
446
+ const resetNodeIds = effectiveFromNodeId !== undefined
447
+ ? computeResetClosure(input.parentSpec, effectiveFromNodeId)
448
+ : [];
449
+ const resetSet = new Set(resetNodeIds);
450
+ const importedNodeIds = allNodeIds.filter((nodeId) => !resetSet.has(nodeId));
451
+ const controllerIdentityMatched = input.currentControllerFingerprint === undefined ||
452
+ input.parentControllerFingerprint === undefined ||
453
+ input.currentControllerFingerprint === input.parentControllerFingerprint;
454
+ const workspaceMatched = input.parentTerminalWorkspace?.fingerprint !== undefined &&
455
+ input.currentWorkspace?.fingerprint !== undefined &&
456
+ input.parentTerminalWorkspace.fingerprint === input.currentWorkspace.fingerprint;
457
+ const skillSnapshotVerified = input.skillSnapshotOk !== false;
458
+ const sourceBindingMatched = input.bindingStatus?.sourceBindingMatched ?? true;
459
+ const taskContractBindingMatched = input.bindingStatus?.taskContractBindingMatched ?? true;
460
+ const bindings = {
461
+ sourceBindingMatched,
462
+ taskContractBindingMatched,
463
+ controllerIdentityMatched,
464
+ skillSnapshotVerified,
465
+ workspaceMatched,
466
+ };
467
+ if (!sourceBindingMatched || !taskContractBindingMatched) {
468
+ reasonCodes.push("binding-drift");
469
+ blockedReasons.push("binding-drift");
470
+ }
471
+ if (input.workerManaged) {
472
+ reasonCodes.push("worker-managed-rerun-unsupported");
473
+ blockedReasons.push("worker-managed-rerun-unsupported");
474
+ }
475
+ if (input.parentLifecycle !== "completed") {
476
+ reasonCodes.push("parent-lifecycle-ineligible");
477
+ blockedReasons.push("parent-lifecycle-ineligible");
478
+ }
479
+ else if (input.parentState.status !== "failed" &&
480
+ input.parentState.status !== "partial_failed") {
481
+ reasonCodes.push("parent-lifecycle-ineligible");
482
+ blockedReasons.push("parent-lifecycle-ineligible");
483
+ }
484
+ if (input.parentSpec.convergence?.enabled) {
485
+ reasonCodes.push("convergence-rerun-unsupported");
486
+ blockedReasons.push("convergence-rerun-unsupported");
487
+ }
488
+ if (input.parentState.convergence?.enabled) {
489
+ reasonCodes.push("convergence-rerun-unsupported");
490
+ blockedReasons.push("convergence-rerun-unsupported");
491
+ }
492
+ if (input.parentSpec.evaluation || input.parentState.evaluation) {
493
+ reasonCodes.push("evaluation-rerun-unsupported");
494
+ blockedReasons.push("evaluation-rerun-unsupported");
495
+ }
496
+ const budgetMode = input.parentSpec.budget?.mode ?? input.parentState.budget?.mode;
497
+ if (budgetMode === "hard") {
498
+ reasonCodes.push("budget-rerun-unsupported");
499
+ blockedReasons.push("budget-rerun-unsupported");
500
+ }
501
+ if (!input.parentTerminalWorkspace?.fingerprint) {
502
+ reasonCodes.push("workspace-checkpoint-missing");
503
+ blockedReasons.push("workspace-checkpoint-missing");
504
+ }
505
+ else if (input.currentWorkspace?.fingerprint &&
506
+ input.parentTerminalWorkspace.fingerprint !== input.currentWorkspace.fingerprint) {
507
+ reasonCodes.push("workspace-drift");
508
+ blockedReasons.push("workspace-drift");
509
+ }
510
+ else if (!input.currentWorkspace?.fingerprint) {
511
+ reasonCodes.push("workspace-checkpoint-missing");
512
+ blockedReasons.push("workspace-checkpoint-missing");
513
+ }
514
+ if (!controllerIdentityMatched) {
515
+ reasonCodes.push("controller-drift");
516
+ blockedReasons.push("controller-drift");
517
+ }
518
+ if (input.skillSnapshotOk === false) {
519
+ reasonCodes.push("skill-snapshot-invalid");
520
+ blockedReasons.push("skill-snapshot-invalid");
521
+ }
522
+ if (effectiveFromNodeId === undefined || resetNodeIds.length === 0) {
523
+ if (!reasonCodes.includes("ambiguous-skipped-rewrite")) {
524
+ reasonCodes.push("selected-node-missing");
525
+ blockedReasons.push("selected-node-missing");
526
+ }
527
+ else {
528
+ blockedReasons.push("ambiguous-skipped-rewrite");
529
+ }
530
+ }
531
+ const closureSafety = assessResetClosureSafety(input.parentSpec, resetNodeIds);
532
+ blockingNodes.push(...closureSafety.blockingNodes);
533
+ for (const code of closureSafety.reasonCodes) {
534
+ reasonCodes.push(code);
535
+ blockedReasons.push(code);
536
+ }
537
+ for (const [nodeId, record] of Object.entries(input.parentState.nodes)) {
538
+ if (record.status === "ERROR" && !resetSet.has(nodeId)) {
539
+ reasonCodes.push("unresolved-errors-outside-reset-closure");
540
+ blockedReasons.push("unresolved-errors-outside-reset-closure");
541
+ blockingNodes.push({
542
+ nodeId,
543
+ reasonCode: "unresolved-errors-outside-reset-closure",
544
+ });
545
+ }
546
+ }
547
+ for (const nodeId of importedNodeIds) {
548
+ const record = input.parentState.nodes[nodeId];
549
+ if (!record)
550
+ continue;
551
+ if (record.status === "ERROR") {
552
+ reasonCodes.push("parent-facts-invalid");
553
+ blockedReasons.push("parent-facts-invalid");
554
+ blockingNodes.push({ nodeId, reasonCode: "parent-facts-invalid" });
555
+ continue;
556
+ }
557
+ if (record.status === "SKIPPED" &&
558
+ !isConditionSkip(record.status, record.skippedReason)) {
559
+ reasonCodes.push("parent-facts-invalid");
560
+ blockedReasons.push("parent-facts-invalid");
561
+ blockingNodes.push({ nodeId, reasonCode: "parent-facts-invalid" });
562
+ }
563
+ }
564
+ const uniqueReasonCodes = [...new Set(reasonCodes)];
565
+ const uniqueBlockedReasons = [...new Set(blockedReasons)];
566
+ const eligible = uniqueBlockedReasons.length === 0 &&
567
+ effectiveFromNodeId !== undefined &&
568
+ resetNodeIds.length > 0 &&
569
+ closureSafety.safe;
570
+ const totalPi = countExecutorNodes(input.parentSpec, allNodeIds, "pi");
571
+ const resetPi = countExecutorNodes(input.parentSpec, resetNodeIds, "pi");
572
+ const resetShell = countExecutorNodes(input.parentSpec, resetNodeIds, "shell");
573
+ const workerAssociation = input.workerAssociation ?? (input.workerManaged
574
+ ? { kind: "worker-managed" }
575
+ : { kind: "standalone" });
576
+ const planWithoutHash = {
577
+ schemaVersion: 1,
578
+ eligible,
579
+ parentRunId: input.parentRunId,
580
+ parentLifecycle: "completed",
581
+ selectedNodeId: input.selectedNodeId,
582
+ effectiveFromNodeId,
583
+ rewriteApplied: rewrite.rewriteApplied,
584
+ resetNodeIds,
585
+ importedNodeIds,
586
+ blockingNodes,
587
+ reasonCodes: uniqueReasonCodes,
588
+ blockedReasons: uniqueBlockedReasons,
589
+ risk: deriveRisk(uniqueReasonCodes, resetNodeIds.length),
590
+ bindings,
591
+ verificationPolicy: computeVerificationPolicy(input.parentSpec, input.parentRunId, resetNodeIds, importedNodeIds),
592
+ workerAssociation,
593
+ estimatedExecution: {
594
+ nodeCount: resetNodeIds.length,
595
+ piCallCount: resetPi,
596
+ shellNodeCount: resetShell,
597
+ avoidedNodeCount: allNodeIds.length - resetNodeIds.length,
598
+ avoidedPiCallCount: totalPi - resetPi,
599
+ },
600
+ suggestedAction: deriveSuggestedAction({
601
+ eligible,
602
+ reasonCodes: uniqueReasonCodes,
603
+ parentLifecycle: input.parentLifecycle,
604
+ workerManaged: input.workerManaged,
605
+ }),
606
+ };
607
+ return {
608
+ ...planWithoutHash,
609
+ planHash: computePlanHash(planWithoutHash),
610
+ };
611
+ }