@tea-agent/loop-agent 0.16.1-beta.2 → 0.16.2

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 (113) hide show
  1. package/AGENTS.md +4 -8
  2. package/CHANGELOG.md +76 -18
  3. package/README.md +76 -299
  4. package/dist/application/evaluation/alias.js +184 -0
  5. package/dist/application/evaluation/budget.js +192 -0
  6. package/dist/application/evaluation/campaign-hash.js +47 -0
  7. package/dist/application/evaluation/campaign-matrix.js +372 -0
  8. package/dist/application/evaluation/campaign-scorecard.js +135 -0
  9. package/dist/application/evaluation/campaign.js +370 -0
  10. package/dist/application/evaluation/candidate.js +23 -6
  11. package/dist/application/evaluation/corpus-hash.js +38 -0
  12. package/dist/application/evaluation/corpus.js +56 -0
  13. package/dist/application/evaluation/experiment.js +294 -0
  14. package/dist/application/evaluation/ignition.js +198 -0
  15. package/dist/application/evaluation/integrity-audit.js +162 -0
  16. package/dist/application/evaluation/outer-loop.js +132 -0
  17. package/dist/application/evaluation/pi-cell-executor.js +39 -0
  18. package/dist/application/evaluation/private-verifier.js +46 -0
  19. package/dist/application/evaluation/promotion-policy.js +151 -0
  20. package/dist/application/evaluation/proposer.js +98 -0
  21. package/dist/application/evaluation/types.js +522 -0
  22. package/dist/cli/command-definitions.js +19 -3
  23. package/dist/commands/dag-reconcile-run.js +3 -116
  24. package/dist/commands/eval.js +1176 -13
  25. package/dist/commands/init.js +7 -1
  26. package/dist/executors/dag-pi-executor.js +8 -46
  27. package/dist/executors/pi-sdk-executor.js +66 -3
  28. package/dist/executors/shell-executor.js +213 -30
  29. package/dist/executors/shell-presets.js +12 -2
  30. package/dist/executors/shell-write-guard.js +20 -1
  31. package/dist/infrastructure/evaluation/alias-store.js +199 -0
  32. package/dist/infrastructure/evaluation/campaign-store.js +154 -0
  33. package/dist/infrastructure/evaluation/corpus-store.js +181 -0
  34. package/dist/infrastructure/evaluation/experiment-store.js +124 -0
  35. package/dist/infrastructure/evaluation/ignition-store.js +82 -0
  36. package/dist/infrastructure/evaluation/private-verifier-store.js +145 -0
  37. package/dist/infrastructure/evaluation/proposer-store.js +78 -0
  38. package/dist/records/promotion.js +3 -1
  39. package/dist/shared/git-progress.js +9 -2
  40. package/dist/worker/cli.js +83 -0
  41. package/dist/worker/delivery/git-transaction.js +75 -0
  42. package/dist/worker/delivery/verification-bundle.js +13 -2
  43. package/dist/worker/feature/review.js +3 -2
  44. package/dist/worker/observability/read-model.js +56 -0
  45. package/dist/worker/observe/server.js +6 -3
  46. package/dist/worker/observe/static/dag-helpers.js +0 -62
  47. package/dist/worker/observe/static/styles.css +18 -55
  48. package/dist/worker/observe/static/views/dag.js +13 -5
  49. package/dist/worker/outcomes/adapters.js +4 -1
  50. package/dist/worker/outcomes/declared-artifacts.js +103 -0
  51. package/dist/worker/outcomes/evidence-tokens.js +29 -0
  52. package/dist/worker/outcomes/gate.js +10 -11
  53. package/dist/worker/outcomes/projector.js +30 -4
  54. package/dist/worker/outcomes/types.js +3 -0
  55. package/dist/worker/pool/reconcile.js +285 -0
  56. package/dist/worker/run-task/run-task.js +81 -4
  57. package/dist/worker/runner/run-ready.js +25 -2
  58. package/dist/worker/task-graph/ready-planner.js +14 -8
  59. package/dist/worker/task-graph/task-graph-schema.js +5 -3
  60. package/dist/workflows/dag/backend-test-analysis-contract.js +87 -30
  61. package/dist/workflows/dag/backend-test-case-manifest.js +71 -8
  62. package/dist/workflows/dag/backend-test-execution-contract.js +63 -11
  63. package/dist/workflows/dag/backend-test-repair-contract.js +94 -0
  64. package/dist/workflows/dag/backend-test-result-contract.js +6 -4
  65. package/dist/workflows/dag/backend-test-semantic-review-contract.js +36 -0
  66. package/dist/workflows/dag/budget-enforcement.js +67 -0
  67. package/dist/workflows/dag/context-policy.js +137 -0
  68. package/dist/workflows/dag/dynamic-runtime/condition.js +1 -1
  69. package/dist/workflows/dag/dynamic-runtime/shared.js +42 -0
  70. package/dist/workflows/dag/failure-routing.js +8 -1
  71. package/dist/workflows/dag/frontend-implementation-contract.js +32 -93
  72. package/dist/workflows/dag/init-hybrid.js +624 -172
  73. package/dist/workflows/dag/knowledge-curator.js +3 -0
  74. package/dist/workflows/dag/lifecycle.js +33 -2
  75. package/dist/workflows/dag/node-execution.js +11 -4
  76. package/dist/workflows/dag/prompt.js +1 -1
  77. package/dist/workflows/dag/reconcile-run.js +121 -0
  78. package/dist/workflows/dag/report.js +12 -0
  79. package/dist/workflows/dag/runner.js +43 -16
  80. package/dist/workflows/dag/scheduler.js +87 -17
  81. package/dist/workflows/dag/skill-snapshot.js +11 -7
  82. package/dist/workflows/dag/types.js +49 -1
  83. package/dist/workflows/dag/validate.js +35 -15
  84. package/docs/README.md +3 -1
  85. package/docs/architecture/runtime-boundaries.md +3 -2
  86. package/docs/init-surface.manifest.json +4 -0
  87. package/docs/local-development-environment.md +52 -0
  88. package/docs/templates/agent-dag.schema.json +25 -7
  89. package/docs/templates/agent-dag.supervised-implementation.json +23 -4
  90. package/docs/templates/backend-test-analysis.schema.json +9 -16
  91. package/docs/templates/backend-test-dag.json +493 -197
  92. package/docs/templates/backend-test-dag.review-cases.prompt.md +10 -4
  93. package/docs/templates/backend-test-execution.schema.json +6 -1
  94. package/docs/templates/branch-merge-report.md +14 -0
  95. package/docs/templates/evaluation/campaign-budget-v1.json +12 -0
  96. package/docs/templates/evaluation/campaign-dogfood-v0.json +24 -0
  97. package/docs/templates/evaluation/campaign-evidence-v1.json +44 -0
  98. package/docs/templates/evaluation/context-policy-baseline-v1.json +17 -0
  99. package/docs/templates/evaluation/context-policy-role-specialized-v1.json +28 -0
  100. package/docs/templates/evaluation/corpus-dogfood-v0.manifest.json +118 -0
  101. package/docs/templates/evaluation/matrix-dag-dry-run-v1.json +21 -0
  102. package/docs/templates/evaluation/matrix-fixture-v1.json +10 -0
  103. package/docs/templates/evaluation/private-verifier-dogfood-v0.json +16 -0
  104. package/docs/templates/product-line/AGENTS.md +1 -0
  105. package/docs/templates/product-line/README.md +17 -0
  106. package/docs/templates/product-line/acceptance.yaml +9 -0
  107. package/docs/templates/product-line/feature.yaml +11 -0
  108. package/docs/templates/product-line/task-graph.yaml +8 -0
  109. package/docs/templates/product-line/task.yaml +4 -0
  110. package/package.json +2 -1
  111. package/skills/frontend-implementation/references/node-contracts.md +3 -3
  112. package/skills/loop-agent/references/command-reference.md +5 -0
  113. package/skills/loop-agent/references/hybrid-dag.md +7 -4
@@ -103,6 +103,7 @@ export async function curateKnowledgePatterns(input) {
103
103
  ok: true,
104
104
  patternsPath,
105
105
  patternCount: 0,
106
+ patterns: [],
106
107
  safetyFindings: [],
107
108
  message: "no patterns.jsonl found; no proposal generated",
108
109
  };
@@ -139,6 +140,7 @@ export async function curateKnowledgePatterns(input) {
139
140
  patternsPath,
140
141
  outputPath,
141
142
  patternCount: patterns.length,
143
+ patterns,
142
144
  proposalMarkdown,
143
145
  safetyFindings,
144
146
  message: "proposal failed skill safety audit",
@@ -153,6 +155,7 @@ export async function curateKnowledgePatterns(input) {
153
155
  patternsPath,
154
156
  outputPath,
155
157
  patternCount: patterns.length,
158
+ patterns,
156
159
  proposalMarkdown,
157
160
  safetyFindings,
158
161
  message: patterns.length === 0
@@ -4,7 +4,7 @@ import { hostname as localHostname } from "node:os";
4
4
  import { writeJsonAtomic, } from "../../infrastructure/harness/atomic-write.js";
5
5
  import { parseDagSpec } from "./types.js";
6
6
  import { normalizeDagFailureCategory, } from "./failure-category.js";
7
- import { routeDagFailure } from "./failure-routing.js";
7
+ import { dagProductLineFailureCategoryValues, routeDagFailure, } from "./failure-routing.js";
8
8
  const DAG_LIFECYCLE_SCAN_ORDER = [
9
9
  "paused",
10
10
  "active",
@@ -663,6 +663,36 @@ function findDoctorFailureNode(state) {
663
663
  rawFailureCategory: selected[1].failureCategory,
664
664
  };
665
665
  }
666
+ async function readRunOwnedBackendTestClassification(runDir) {
667
+ try {
668
+ const raw = JSON.parse(await readFile(path.join(runDir, "classify-backend-test-result-pi.json"), "utf-8"));
669
+ const text = raw.assistantText ?? raw.stdout;
670
+ if (typeof text !== "string")
671
+ return undefined;
672
+ const trimmed = text.trim();
673
+ const fenced = trimmed.match(/^```json\s*([\s\S]*?)\s*```$/i);
674
+ const classification = JSON.parse(fenced?.[1] ?? trimmed);
675
+ return typeof classification.category === "string" &&
676
+ dagProductLineFailureCategoryValues.includes(classification.category)
677
+ ? classification.category
678
+ : undefined;
679
+ }
680
+ catch {
681
+ return undefined;
682
+ }
683
+ }
684
+ async function resolveDoctorFailureRouting(input) {
685
+ const classifiedCategory = await readRunOwnedBackendTestClassification(input.runDir);
686
+ if (classifiedCategory) {
687
+ return routeDagFailure({
688
+ rawFailureCategory: classifiedCategory,
689
+ normalizedFailureCategory: "unknown",
690
+ nodeId: input.nodeId,
691
+ productLineFailureCategory: classifiedCategory,
692
+ });
693
+ }
694
+ return routeDagFailure(input);
695
+ }
666
696
  async function formatDagDoctorMarkdown(repoRoot, runId) {
667
697
  const located = await locateDagRun(repoRoot, runId);
668
698
  if (!located) {
@@ -675,7 +705,8 @@ async function formatDagDoctorMarkdown(repoRoot, runId) {
675
705
  (state.status === "paused" ? "human-required" : state.failureCategory);
676
706
  const failureStatus = state.status === "paused" ? "paused" : failure.status;
677
707
  const normalizedCategory = normalizeDagFailureCategory(rawFailureCategory, failureStatus ?? state.status);
678
- const routing = routeDagFailure({
708
+ const routing = await resolveDoctorFailureRouting({
709
+ runDir: located.runDir,
679
710
  rawFailureCategory,
680
711
  normalizedFailureCategory: normalizedCategory,
681
712
  nodeId: failure.nodeId,
@@ -3,29 +3,35 @@ import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { recordDecisionEnvelopeForNode, shouldPauseOnHumanEscalation, writeHumanEscalationArtifacts, } from "./decision-envelope.js";
5
5
  import { writeNodeRecord, writeNodeSkillArtifacts } from "./run-store.js";
6
+ import { resolveContextPolicy } from "./context-policy.js";
6
7
  import { buildDagNodePromptEnvelope } from "./prompt.js";
7
8
  import { persistLongNodeOutputArtifacts } from "./upstream-artifacts.js";
8
9
  import { computeBackoffDelayMs, isRetryablePiFailureCategory, isSafeReadOnlyPiRetryCandidate, } from "./retry-policy.js";
9
10
  import { writeDagNodeJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
10
11
  import { assertSkillSnapshotCoversSpec, buildNodePromptFromSnapshot, isDagSkillSnapshotIntegrityError, readSkillSnapshot, } from "./skill-snapshot.js";
11
12
  import { resolveDagSkillInstructions, skillInstructionMetadata, } from "./skill-instructions.js";
12
- import { resolveDagNodeSkills } from "./skills.js";
13
13
  import { parseRepairArtifactFromText, resolveRepairTaskForGate, validateRepairArtifactScope, } from "./repair-artifact.js";
14
14
  import { resolveModelForTask, } from "./types.js";
15
15
  export function buildNodePrompt(spec, task, upstream) {
16
+ const policy = resolveContextPolicy(spec);
16
17
  return buildDagNodePromptEnvelope({
17
18
  spec,
18
19
  task,
19
20
  upstream,
20
- resolvedSkills: resolveDagNodeSkills(spec, task),
21
+ resolvedSkills: policy.resolveSkills(spec, task),
22
+ maxUpstreamChars: policy.resolveMaxUpstreamChars(task),
21
23
  });
22
24
  }
23
25
  export async function buildNodePromptWithResolvedSkillInstructions(spec, task, upstream, cwd) {
24
- const skillNames = resolveDagNodeSkills(spec, task);
26
+ const policy = resolveContextPolicy(spec);
27
+ const skillNames = policy.resolveSkills(spec, task);
28
+ const budget = policy.resolveSkillInstructionBudget(task);
25
29
  const resolvedSkillInstructions = task.executor === "pi"
26
30
  ? await resolveDagSkillInstructions(skillNames, {
27
31
  cwd,
28
- includeLearnedPatterns: task.role === "implementer",
32
+ includeLearnedPatterns: budget.includeLearnedPatterns,
33
+ perSkillMaxChars: budget.perSkillMaxChars,
34
+ totalMaxChars: budget.totalMaxChars,
29
35
  })
30
36
  : [];
31
37
  return {
@@ -35,6 +41,7 @@ export async function buildNodePromptWithResolvedSkillInstructions(spec, task, u
35
41
  upstream,
36
42
  resolvedSkills: skillNames,
37
43
  resolvedSkillInstructions,
44
+ maxUpstreamChars: policy.resolveMaxUpstreamChars(task),
38
45
  }),
39
46
  resolvedSkills: skillInstructionMetadata(resolvedSkillInstructions),
40
47
  };
@@ -6,7 +6,7 @@ export const DAG_AUTHORING_GUIDANCE = [
6
6
  "Prefer same-rank parallel read-only scouts over unnecessary serial depends_on chains.",
7
7
  "Add depends_on only when a child truly needs upstream output; default to independent ranks.",
8
8
  "Every task must explicitly declare executor; defaults.executor is schema-only, not a runtime fallback.",
9
- "Cursor remains a first-class executor for scouts and exclusive implementers when it fits the task.",
9
+ "Pi is the only governed Agent DAG writer; cursor-prompt is an explicit manual one-shot sidecar and must not enter Loop auto-execute or Delegate writers.",
10
10
  "exclusive nodes require narrow, concrete, disjoint writeSet paths; never use ** or repo root.",
11
11
  "Read-only nodes must not write repository files, including root artifacts/**; return findings in node output only.",
12
12
  "If the DAG is a single linear chain, challenge whether read-only work can run in parallel ranks.",
@@ -0,0 +1,121 @@
1
+ import path from "node:path";
2
+ import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
3
+ import { assessDagRunLiveness, assessDagRunRecoveryEligibility, assertDagRunTransferTargetAvailable, getDagRunDir, locateDagRun, readDagRunState, transferDagRunDir, writeDagRunState, } from "./lifecycle.js";
4
+ export function parseDagReconcileRunArgs(args) {
5
+ let runId;
6
+ let action;
7
+ let reason;
8
+ for (let index = 0; index < args.length; index += 1) {
9
+ const arg = args[index];
10
+ if (arg === "--run-id")
11
+ runId = args[++index];
12
+ else if (arg.startsWith("--run-id="))
13
+ runId = arg.slice("--run-id=".length);
14
+ else if (arg === "--action")
15
+ action = parseAction(args[++index]);
16
+ else if (arg.startsWith("--action="))
17
+ action = parseAction(arg.slice("--action=".length));
18
+ else if (arg === "--reason")
19
+ reason = args[++index];
20
+ else if (arg.startsWith("--reason="))
21
+ reason = arg.slice("--reason=".length);
22
+ else if (arg.startsWith("-"))
23
+ throw new Error(`unknown dag reconcile-run flag: ${arg}`);
24
+ else
25
+ throw new Error(`unexpected positional argument: ${arg}`);
26
+ }
27
+ if (!runId)
28
+ throw new Error("dag reconcile-run requires --run-id <id>");
29
+ if (action && !reason?.trim()) {
30
+ throw new Error("dag reconcile-run mutation requires --reason <text>");
31
+ }
32
+ return { runId, action, ...(reason?.trim() ? { reason: reason.trim() } : {}) };
33
+ }
34
+ function parseAction(value) {
35
+ if (value === "supersede" || value === "abandon")
36
+ return value;
37
+ throw new Error("dag reconcile-run --action must be supersede or abandon");
38
+ }
39
+ /**
40
+ * DAG-kernel reconcile for a single run. Lives in workflows so Worker outer-loop
41
+ * recovery can call it without importing the CLI/commands layer.
42
+ */
43
+ export async function executeDagReconcileRun(repoRoot, rawArgs) {
44
+ const parsed = parseDagReconcileRunArgs(rawArgs);
45
+ const located = await locateDagRun(repoRoot, parsed.runId);
46
+ if (!located)
47
+ throw new Error(`dag run not found: ${parsed.runId}`);
48
+ const state = await readDagRunState(located.runDir);
49
+ const liveness = assessDagRunLiveness({ state });
50
+ const eligibility = assessDagRunRecoveryEligibility({
51
+ lifecycle: located.lifecycle,
52
+ state,
53
+ liveness: liveness.status,
54
+ });
55
+ if (!parsed.action) {
56
+ return {
57
+ action: "inspect",
58
+ runId: state.runId,
59
+ lifecycle: located.lifecycle,
60
+ status: state.status,
61
+ liveness: liveness.status,
62
+ eligibility,
63
+ nextRecommendedAction: eligibility.canReconcile
64
+ ? "Re-run with --action supersede|abandon and --reason <text>."
65
+ : "Do not reconcile this run; inspect runner and lifecycle evidence first.",
66
+ };
67
+ }
68
+ if (!eligibility.canReconcile || !eligibility.allowedActions.includes(parsed.action)) {
69
+ throw new Error(`dag run ${state.runId} is not eligible for reconciliation: ${eligibility.reasons.join(", ") || "unknown reason"}`);
70
+ }
71
+ const reconciledAt = new Date().toISOString();
72
+ const completedRunDir = getDagRunDir(repoRoot, "completed", state.runId);
73
+ if (located.lifecycle !== "completed") {
74
+ await assertDagRunTransferTargetAvailable(completedRunDir);
75
+ }
76
+ const artifactRelativePath = path.join(".harness", "dag-runs", "completed", state.runId, "reconciliation.json").replace(/\\/g, "/");
77
+ await writeJsonAtomic(path.join(located.runDir, "reconciliation.json"), {
78
+ schemaVersion: 1,
79
+ runId: state.runId,
80
+ action: parsed.action,
81
+ reason: parsed.reason,
82
+ reconciledAt,
83
+ previousLifecycle: located.lifecycle,
84
+ previousStatus: state.status,
85
+ liveness: liveness.status,
86
+ originalState: state,
87
+ });
88
+ const previousStatus = state.status;
89
+ for (const node of Object.values(state.nodes)) {
90
+ if (node.status !== "RUNNING")
91
+ continue;
92
+ node.status = "ERROR";
93
+ node.finishedAt = reconciledAt;
94
+ node.failureCategory = `operator-${parsed.action}`;
95
+ }
96
+ state.status = parsed.action === "supersede" ? "superseded" : "abandoned";
97
+ state.finishedAt = reconciledAt;
98
+ state.failureCategory = `operator-${parsed.action}`;
99
+ state.reconciliation = {
100
+ action: parsed.action,
101
+ reason: parsed.reason,
102
+ reconciledAt,
103
+ previousStatus,
104
+ previousLifecycle: located.lifecycle,
105
+ liveness: liveness.status,
106
+ artifactPath: artifactRelativePath,
107
+ };
108
+ await writeDagRunState(located.runDir, state);
109
+ const runDir = located.lifecycle === "completed"
110
+ ? located.runDir
111
+ : await transferDagRunDir(located.runDir, completedRunDir);
112
+ return {
113
+ action: parsed.action,
114
+ runId: state.runId,
115
+ status: state.status,
116
+ lifecycle: "completed",
117
+ reason: parsed.reason,
118
+ reconciliationArtifactPath: artifactRelativePath,
119
+ runDir,
120
+ };
121
+ }
@@ -148,6 +148,9 @@ const dagNodeReportRowSchema = z
148
148
  finishedAt: z.string().optional(),
149
149
  decisionEnvelope: dagNodeDecisionEnvelopeSchema.optional(),
150
150
  artifacts: dagNodeArtifactsReportSchema,
151
+ structuredArtifactPath: z.string().min(1).optional(),
152
+ structuredArtifactSha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
153
+ structuredArtifactSchemaId: z.string().min(1).optional(),
151
154
  })
152
155
  .strict();
153
156
  const dagRunReportEntrySchema = z
@@ -460,6 +463,15 @@ export async function buildDagRunReportEntry(input) {
460
463
  finishedAt: node.finishedAt,
461
464
  decisionEnvelope: node.decisionEnvelope,
462
465
  artifacts: await buildNodeArtifactsReport(input.runDir, nodeId),
466
+ ...(node.structuredArtifactPath
467
+ ? { structuredArtifactPath: node.structuredArtifactPath }
468
+ : {}),
469
+ ...(node.structuredArtifactSha256
470
+ ? { structuredArtifactSha256: node.structuredArtifactSha256 }
471
+ : {}),
472
+ ...(node.structuredArtifactSchemaId
473
+ ? { structuredArtifactSchemaId: node.structuredArtifactSchemaId }
474
+ : {}),
463
475
  });
464
476
  }
465
477
  }
@@ -1,8 +1,10 @@
1
1
  import { readdir, readFile } from "node:fs/promises";
2
2
  import { hostname } from "node:os";
3
3
  import path from "node:path";
4
+ import { isHardBudgetBreached, resolveEffectiveMaxConcurrent, } from "../../application/evaluation/budget.js";
4
5
  import { readCandidateRecord } from "../../infrastructure/evaluation/candidate-store.js";
5
6
  import { CANONICAL_TASK_ID_PATTERN, formatLocalCompactDate, } from "../../task/runtime.js";
7
+ import { assertFrozenBudget, initRunBudgetLedger, preflightBudgetOrBreach, recordFinishedNodeBudget, writeBudgetLedgerArtifacts, } from "./budget-enforcement.js";
6
8
  import { getDagRunDir, isTerminalDagRunStatus, locateDagRun, readHumanApprovalArtifact, requireActiveDagRun, } from "./lifecycle.js";
7
9
  import { moveToCompletedRunDir, moveToPausedRunDir, prepareActiveRunDir, writeRunSpec, writeRunState, } from "./run-store.js";
8
10
  import { createDagNodeExecutor } from "./executor-registry.js";
@@ -120,7 +122,7 @@ export function createInitialRunState(spec, opts, ranks, runId = opts.runId ?? "
120
122
  : {}),
121
123
  };
122
124
  }
123
- return {
125
+ const state = {
124
126
  version: 1,
125
127
  title: spec.title,
126
128
  runId,
@@ -147,6 +149,8 @@ export function createInitialRunState(spec, opts, ranks, runId = opts.runId ?? "
147
149
  }
148
150
  : {}),
149
151
  };
152
+ initRunBudgetLedger(state, spec.budget);
153
+ return state;
150
154
  }
151
155
  export function assertFrozenEvaluationBinding(spec, state) {
152
156
  const declared = spec.evaluation;
@@ -183,7 +187,7 @@ export async function runDag(spec, opts) {
183
187
  controllerVersion: runningIdentity.packageVersion,
184
188
  });
185
189
  const { ranks } = topoSortToRanks(spec);
186
- const maxConcurrent = Math.max(1, opts.maxConcurrent ?? 4);
190
+ const { maxConcurrent } = resolveEffectiveMaxConcurrent(Math.max(1, opts.maxConcurrent ?? 4), spec.budget);
187
191
  let runId = opts.runId;
188
192
  if (runId) {
189
193
  for (const warning of validateDagRunIdFormat(runId).warnings) {
@@ -268,6 +272,7 @@ export async function resumeDagRun(opts) {
268
272
  throw new Error(`dag run ${opts.runId} is already terminal (status=${state.status})`);
269
273
  }
270
274
  assertFrozenEvaluationBinding(spec, state);
275
+ assertFrozenBudget(spec.budget, state.budget, state.runId);
271
276
  // Runtime contract + controller identity must be re-verified before executing
272
277
  // any remaining node on resume; drift fails closed.
273
278
  const runningIdentity = resolveRunningControllerIdentity();
@@ -311,7 +316,7 @@ export async function resumeDagRun(opts) {
311
316
  catch (error) {
312
317
  throw new Error(`skill snapshot validation failed on resume: ${error instanceof Error ? error.message : String(error)}`);
313
318
  }
314
- const maxConcurrent = Math.max(1, opts.maxConcurrent ?? 4);
319
+ const { maxConcurrent } = resolveEffectiveMaxConcurrent(Math.max(1, opts.maxConcurrent ?? 4), spec.budget);
315
320
  state.status = "running";
316
321
  const resumedAt = new Date().toISOString();
317
322
  state.runner = {
@@ -410,23 +415,43 @@ async function executeDagCheckpoint(input) {
410
415
  tasksById,
411
416
  meta: { runDir, runId: state.runId, spec },
412
417
  }),
413
- executeScheduledNode: (nodeId, executeNode, onPause) => executeDagNode({
414
- nodeId,
415
- tasksById,
416
- state,
417
- spec,
418
- cwd,
419
- runDir,
420
- executeNode,
421
- executeDynamicNode,
422
- observer: input.observer,
423
- persistState,
424
- onPause,
425
- }),
418
+ executeScheduledNode: async (nodeId, executeNode, onPause) => {
419
+ if (isHardBudgetBreached(state.budgetLedger))
420
+ return;
421
+ const preBreach = preflightBudgetOrBreach(state);
422
+ if (preBreach && isHardBudgetBreached(state.budgetLedger)) {
423
+ await writeBudgetLedgerArtifacts(runDir, state.budgetLedger);
424
+ await persistState();
425
+ return;
426
+ }
427
+ await executeDagNode({
428
+ nodeId,
429
+ tasksById,
430
+ state,
431
+ spec,
432
+ cwd,
433
+ runDir,
434
+ executeNode,
435
+ executeDynamicNode,
436
+ observer: input.observer,
437
+ persistState,
438
+ onPause,
439
+ });
440
+ const node = state.nodes[nodeId];
441
+ if (node &&
442
+ (node.status === "FINISHED" || node.status === "ERROR")) {
443
+ recordFinishedNodeBudget(state, node);
444
+ await writeBudgetLedgerArtifacts(runDir, state.budgetLedger);
445
+ await persistState();
446
+ }
447
+ },
426
448
  });
427
449
  if (pausedByNodeId) {
428
450
  break;
429
451
  }
452
+ if (isHardBudgetBreached(state.budgetLedger)) {
453
+ break;
454
+ }
430
455
  const convergenceDecision = await runConvergencePassController({
431
456
  spec,
432
457
  state,
@@ -446,6 +471,7 @@ async function executeDagCheckpoint(input) {
446
471
  }
447
472
  state.finishedAt = new Date().toISOString();
448
473
  const runDirBeforeTransfer = runDir;
474
+ await writeBudgetLedgerArtifacts(runDir, state.budgetLedger);
449
475
  if (pausedByNodeId) {
450
476
  state.status = "paused";
451
477
  await persistState();
@@ -466,6 +492,7 @@ async function executeDagCheckpoint(input) {
466
492
  if (state.convergence) {
467
493
  relocateConvergenceArtifactPaths(state.convergence, runDirBeforeTransfer, runDir);
468
494
  }
495
+ await writeBudgetLedgerArtifacts(runDir, state.budgetLedger);
469
496
  await persistState({ allowCompletedFactsWrite: true });
470
497
  return {
471
498
  title: spec.title,
@@ -1,14 +1,58 @@
1
1
  import { isPauseOnHumanDecisionGate } from "./decision-envelope.js";
2
+ import { evaluateConditionExpression } from "./dynamic-runtime/condition.js";
2
3
  export function isConditionSkippedReason(reason) {
3
4
  return Boolean(reason?.startsWith("condition "));
4
5
  }
5
- function shouldSkipNode(node, task, nodes) {
6
- if (!node || node.status !== "PENDING")
7
- return false;
8
- return task.depends_on.some((depId) => {
6
+ /**
7
+ * Dependency readiness for PENDING nodes.
8
+ *
9
+ * Default (`dependsPolicy: all`): any ERROR/SKIPPED dependency → skip (cascades
10
+ * exclusive condition branches). Unsettled → wait. All FINISHED → run.
11
+ *
12
+ * `all-or-condition-skip`: exclusive-branch tips that were condition-SKIPPED are
13
+ * soft; the node runs when every dep is FINISHED or soft condition-skip and at
14
+ * least one is FINISHED (OR-join after condition). Opt-in only — never default.
15
+ */
16
+ function dependencyReadiness(task, nodes) {
17
+ if (task.depends_on.length === 0)
18
+ return "run";
19
+ const softConditionJoin = task.dependsPolicy === "all-or-condition-skip";
20
+ let hasFinished = false;
21
+ let hasPendingUpstream = false;
22
+ let hasHardBlock = false;
23
+ for (const depId of task.depends_on) {
9
24
  const dep = nodes[depId];
10
- return !dep || dep.status === "ERROR" || dep.status === "SKIPPED";
11
- });
25
+ if (!dep) {
26
+ hasHardBlock = true;
27
+ continue;
28
+ }
29
+ if (dep.status === "FINISHED") {
30
+ hasFinished = true;
31
+ continue;
32
+ }
33
+ if (dep.status === "PENDING" || dep.status === "RUNNING") {
34
+ hasPendingUpstream = true;
35
+ continue;
36
+ }
37
+ if (dep.status === "ERROR") {
38
+ hasHardBlock = true;
39
+ continue;
40
+ }
41
+ if (dep.status === "SKIPPED") {
42
+ if (softConditionJoin &&
43
+ isConditionSkippedReason(dep.skippedReason)) {
44
+ continue;
45
+ }
46
+ hasHardBlock = true;
47
+ }
48
+ }
49
+ if (hasPendingUpstream)
50
+ return "wait";
51
+ if (hasHardBlock)
52
+ return "skip";
53
+ if (hasFinished)
54
+ return "run";
55
+ return "skip";
12
56
  }
13
57
  function conditionSkippedByAncestor(task, nodes) {
14
58
  return task.depends_on.some((depId) => isConditionSkippedReason(nodes[depId]?.skippedReason));
@@ -27,18 +71,44 @@ async function mapConcurrent(items, limit, fn) {
27
71
  export async function executeDagRanksOnce(input) {
28
72
  let pausedByNodeId;
29
73
  for (const rank of input.ranks) {
30
- const runnable = rank.filter((id) => {
74
+ const pending = rank.filter((id) => {
31
75
  const node = input.state.nodes[id];
76
+ return node?.status === "PENDING";
77
+ });
78
+ const runnable = pending.filter((id) => {
32
79
  const task = input.tasksById.get(id);
33
- if (!node || node.status !== "PENDING")
34
- return false;
35
- return !shouldSkipNode(node, task, input.state.nodes);
80
+ return dependencyReadiness(task, input.state.nodes) === "run";
36
81
  });
37
- const blocked = rank.filter((id) => {
38
- const node = input.state.nodes[id];
82
+ const conditionSettled = [];
83
+ for (const id of runnable) {
84
+ const task = input.tasksById.get(id);
85
+ if (!task.runIf)
86
+ continue;
87
+ try {
88
+ if (!evaluateConditionExpression(task.runIf, input.state)) {
89
+ const node = input.state.nodes[id];
90
+ node.status = "SKIPPED";
91
+ node.skippedReason = `condition runIf did not match: ${task.runIf}`;
92
+ node.finishedAt = new Date().toISOString();
93
+ conditionSettled.push(id);
94
+ }
95
+ }
96
+ catch (error) {
97
+ const node = input.state.nodes[id];
98
+ node.status = "ERROR";
99
+ node.failureCategory = "invalid-output";
100
+ node.stderr = error instanceof Error ? error.message : String(error);
101
+ node.finishedAt = new Date().toISOString();
102
+ conditionSettled.push(id);
103
+ }
104
+ }
105
+ if (conditionSettled.length > 0)
106
+ await input.persistState();
107
+ const conditionSkippedSet = new Set(conditionSettled);
108
+ const actuallyRunnable = runnable.filter((id) => !conditionSkippedSet.has(id));
109
+ const blocked = pending.filter((id) => {
39
110
  const task = input.tasksById.get(id);
40
- return (node?.status === "PENDING" &&
41
- shouldSkipNode(node, task, input.state.nodes));
111
+ return dependencyReadiness(task, input.state.nodes) === "skip";
42
112
  });
43
113
  for (const id of blocked) {
44
114
  const node = input.state.nodes[id];
@@ -50,12 +120,12 @@ export async function executeDagRanksOnce(input) {
50
120
  if (blocked.length > 0) {
51
121
  await input.persistState();
52
122
  }
53
- const pauseGateRunnable = runnable.filter((id) => {
123
+ const pauseGateRunnable = actuallyRunnable.filter((id) => {
54
124
  const task = input.tasksById.get(id);
55
125
  return isPauseOnHumanDecisionGate(task);
56
126
  });
57
- const regularRunnable = runnable.filter((id) => !pauseGateRunnable.includes(id));
58
- const rankWriterNodeIds = runnable.filter((id) => {
127
+ const regularRunnable = actuallyRunnable.filter((id) => !pauseGateRunnable.includes(id));
128
+ const rankWriterNodeIds = actuallyRunnable.filter((id) => {
59
129
  const task = input.tasksById.get(id);
60
130
  return (task?.executor === "pi" &&
61
131
  task.toolProfile === "write" &&
@@ -3,9 +3,9 @@ import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
5
  import { writeTextAtomic } from "../../infrastructure/harness/atomic-write.js";
6
+ import { resolveContextPolicy } from "./context-policy.js";
6
7
  import { buildDagNodePromptEnvelope } from "./prompt.js";
7
- import { DEFAULT_SKILL_INSTRUCTION_MAX_CHARS, DEFAULT_SKILL_INSTRUCTION_TOTAL_MAX_CHARS, resolveDagSkillInstructions, } from "./skill-instructions.js";
8
- import { resolveDagNodeSkills } from "./skills.js";
8
+ import { resolveDagSkillInstructions, } from "./skill-instructions.js";
9
9
  export const SKILL_SNAPSHOT_SCHEMA_VERSION = 1;
10
10
  export const SKILL_SNAPSHOT_RESOLVER_VERSION = 1;
11
11
  export const SKILL_SNAPSHOT_REL_PATH = ".runtime/skill-snapshot.json";
@@ -117,11 +117,13 @@ function profileId(input) {
117
117
  export function buildDagSkillSnapshotProfileRequest(spec, task) {
118
118
  if (task.executor !== "pi")
119
119
  return undefined;
120
+ const policy = resolveContextPolicy(spec);
121
+ const budget = policy.resolveSkillInstructionBudget(task);
120
122
  const request = {
121
- skills: [...resolveDagNodeSkills(spec, task)],
122
- includeLearnedPatterns: task.role === "implementer",
123
- perSkillMaxChars: DEFAULT_SKILL_INSTRUCTION_MAX_CHARS,
124
- totalMaxChars: DEFAULT_SKILL_INSTRUCTION_TOTAL_MAX_CHARS,
123
+ skills: [...policy.resolveSkills(spec, task)],
124
+ includeLearnedPatterns: budget.includeLearnedPatterns,
125
+ perSkillMaxChars: budget.perSkillMaxChars,
126
+ totalMaxChars: budget.totalMaxChars,
125
127
  };
126
128
  return { id: profileId(request), ...request };
127
129
  }
@@ -511,7 +513,8 @@ function stripPromptText(instruction) {
511
513
  return metadata;
512
514
  }
513
515
  export function buildNodePromptFromSnapshot(input) {
514
- const skillNames = resolveDagNodeSkills(input.spec, input.task);
516
+ const policy = resolveContextPolicy(input.spec);
517
+ const skillNames = policy.resolveSkills(input.spec, input.task);
515
518
  const resolvedSkillInstructions = resolveNodeSkillsFromSnapshot(input.snapshot, input.spec, input.task);
516
519
  return {
517
520
  prompt: buildDagNodePromptEnvelope({
@@ -520,6 +523,7 @@ export function buildNodePromptFromSnapshot(input) {
520
523
  upstream: input.upstream,
521
524
  resolvedSkills: skillNames,
522
525
  resolvedSkillInstructions,
526
+ maxUpstreamChars: policy.resolveMaxUpstreamChars(input.task),
523
527
  }),
524
528
  resolvedSkills: resolvedSkillInstructions.map(stripPromptText),
525
529
  };