@oisincoveney/pipeline 2.3.1 → 2.5.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.
@@ -8,6 +8,8 @@ import { emit, emitAgentFinish, emitAgentStart } from "../events/events.js";
8
8
  import "../events/index.js";
9
9
  import { gatewayServerForProfile } from "../../mcp/gateway.js";
10
10
  import { selectNodeModel } from "../../model-resolver.js";
11
+ import { estimateTokens } from "../../token-estimator.js";
12
+ import { handoffFinalizerPrompt, parseHandoff, renderHandoff, synthesizeMinimalHandoff } from "../handoff.js";
11
13
  import { readFileSync } from "node:fs";
12
14
  //#region src/runtime/agent-node/agent-node.ts
13
15
  async function executeAgentNode(node, context, attempt) {
@@ -17,7 +19,17 @@ async function executeAgentNode(node, context, attempt) {
17
19
  output: ""
18
20
  };
19
21
  const prompt = renderAgentPrompt(node, context);
20
- const modelSelection = selectNodeModel(node);
22
+ const decision = decideNodeModel(prompt, node, context.config.token_budget);
23
+ if (decision.overBudget) return {
24
+ evidence: [
25
+ `agent boundary node=${node.id} profile=${node.profile}`,
26
+ `over token budget: ${decision.selection.reason}`,
27
+ ...decision.selection.skipped.length ? [`model fallbacks skipped: ${decision.selection.skipped.join(", ")}`] : []
28
+ ],
29
+ exitCode: 1,
30
+ output: ""
31
+ };
32
+ const modelSelection = decision.selection;
21
33
  const plan = createRunnerLaunchPlan(context.config, {
22
34
  model: modelSelection.model,
23
35
  nodeId: node.id,
@@ -52,9 +64,11 @@ async function executeAgentNode(node, context, attempt) {
52
64
  result,
53
65
  attempt
54
66
  });
55
- return {
67
+ const handoff = await maybeDeriveHandoff(context, node, finalized.output, attempt);
68
+ return withOptionalHandoff({
56
69
  evidence: [
57
70
  `agent boundary node=${node.id} profile=${node.profile} runner=${plan.runnerId}`,
71
+ `estimated context tokens: ${decision.estimatedTokens}`,
58
72
  `model selection: ${modelSelection.model ?? "profile/default"} (${modelSelection.reason})`,
59
73
  ...modelSelection.skipped.length ? [`model fallbacks skipped: ${modelSelection.skipped.join(", ")}`] : [],
60
74
  ...finalized.evidence,
@@ -64,6 +78,85 @@ async function executeAgentNode(node, context, attempt) {
64
78
  exitCode: result.exitCode,
65
79
  output: finalized.output,
66
80
  timedOut: result.timedOut
81
+ }, handoff);
82
+ }
83
+ function withOptionalHandoff(result, handoff) {
84
+ return handoff ? {
85
+ ...result,
86
+ handoff
87
+ } : result;
88
+ }
89
+ function profileRunner(context, node) {
90
+ return node.profile ? context.config.profiles[node.profile]?.runner : void 0;
91
+ }
92
+ /**
93
+ * PIPE-83.1: derive a structured NodeHandoff for this node when context_handoff
94
+ * is enabled. Fast-path reuses an already-handoff-shaped output; otherwise a
95
+ * cheap read-only finalizer (mirroring createOutputRepairPlan) summarizes the
96
+ * raw output, falling back to a synthesized minimal handoff. Returns undefined
97
+ * when disabled so behaviour is unchanged.
98
+ */
99
+ async function maybeDeriveHandoff(context, node, rawOutput, attempt) {
100
+ if (!context.config.context_handoff?.enabled) return;
101
+ return parseHandoff(rawOutput) ?? await runHandoffFinalizer(context, node, rawOutput, attempt);
102
+ }
103
+ async function runHandoffFinalizer(context, node, rawOutput, attempt) {
104
+ const runner = profileRunner(context, node);
105
+ if (!(runner && rawOutput.trim())) return synthesizeMinimalHandoff(rawOutput);
106
+ const plan = createHandoffFinalizerPlan(context, node, runner, rawOutput);
107
+ context.agentInvocations.push(plan);
108
+ emitAgentStart(context, plan, attempt);
109
+ const result = await context.executor(plan, { signal: context.signal });
110
+ emitAgentFinish(context, plan, attempt, result);
111
+ return parseHandoff(normalizeAgentOutput(plan, result.stdout).output) ?? synthesizeMinimalHandoff(rawOutput);
112
+ }
113
+ function createHandoffFinalizerPlan(context, node, runner, rawOutput) {
114
+ const finalizerProfileId = `${node.id}:handoff`;
115
+ const finalizerConfig = {
116
+ ...context.config,
117
+ profiles: {
118
+ ...context.config.profiles,
119
+ [finalizerProfileId]: {
120
+ filesystem: { mode: "read-only" },
121
+ instructions: { inline: "Summarize the agent output into a NodeHandoff JSON." },
122
+ network: { mode: "disabled" },
123
+ output: { format: "text" },
124
+ runner,
125
+ tools: []
126
+ }
127
+ }
128
+ };
129
+ const model = context.config.context_handoff?.model;
130
+ return createRunnerLaunchPlan(finalizerConfig, {
131
+ nodeId: finalizerProfileId,
132
+ profileId: finalizerProfileId,
133
+ prompt: handoffFinalizerPrompt(rawOutput),
134
+ worktreePath: context.worktreePath,
135
+ ...model ? { model } : {}
136
+ });
137
+ }
138
+ /**
139
+ * Pure model-routing decision for a node: estimate the assembled prompt size and
140
+ * pick the smallest fallback model whose window holds it within the context cap.
141
+ * A node with no fallback array keeps the legacy (size-unaware) selection. A node
142
+ * with a fallback array but no fitting model is `overBudget` — the caller fails
143
+ * it fast rather than truncating.
144
+ */
145
+ function decideNodeModel(prompt, node, budget) {
146
+ const estimatedTokens = estimateTokens(prompt);
147
+ if (!(budget && node.models?.length)) return {
148
+ estimatedTokens,
149
+ overBudget: false,
150
+ selection: selectNodeModel(node)
151
+ };
152
+ const selection = selectNodeModel(node, {
153
+ budget,
154
+ estimatedTokens
155
+ });
156
+ return {
157
+ estimatedTokens,
158
+ overBudget: !selection.model,
159
+ selection
67
160
  };
68
161
  }
69
162
  async function finalizeAgentOutput(inputs) {
@@ -238,9 +331,18 @@ function renderAgentPrompt(node, context) {
238
331
  "",
239
332
  ...inheritedOutputSections(node, context),
240
333
  "Dependency outputs:",
241
- ...node.needs.map((need) => `## ${need}\n${context.nodeStateStore.outputText(need)}`)
334
+ ...node.needs.map((need) => renderDependencySection(need, context))
242
335
  ].filter(Boolean).join("\n");
243
336
  }
337
+ /**
338
+ * PIPE-83.5: render a dependency's curated NodeHandoff when one was derived
339
+ * (PIPE-83.1), otherwise fall back to its raw output text. The fallback keeps
340
+ * behaviour identical when context_handoff is disabled (no handoffs recorded).
341
+ */
342
+ function renderDependencySection(nodeId, context) {
343
+ const handoff = context.nodeStateStore.handoff(nodeId);
344
+ return handoff ? renderHandoff(nodeId, handoff) : `## ${nodeId}\n${context.nodeStateStore.outputText(nodeId)}`;
345
+ }
244
346
  function renderGateOutputContract(node) {
245
347
  const gates = node.gates ?? [];
246
348
  const hasAcceptanceGate = gates.some((gate) => gate.kind === "acceptance" && (gate.target === void 0 || gate.target === "stdout"));
@@ -283,7 +385,7 @@ function inheritedOutputSections(node, context) {
283
385
  if (inherited.length === 0) return [];
284
386
  return [
285
387
  "Inherited dependency outputs:",
286
- ...inherited.map((id) => `## ${id}\n${context.nodeStateStore.outputText(id)}`),
388
+ ...inherited.map((id) => renderDependencySection(id, context)),
287
389
  ""
288
390
  ];
289
391
  }
@@ -1,10 +1,12 @@
1
1
  import { runFallow, runJscpd, runLint, runSemgrep, runTests, runTypecheck } from "../../gates.js";
2
2
  import { executeDrainMergeBuiltin } from "../drain-merge/drain-merge.js";
3
3
  import "../drain-merge/index.js";
4
+ import { executeSelectCandidateBuiltin } from "../select-candidate/select-candidate.js";
4
5
  //#region src/runtime/builtins/builtins.ts
5
6
  async function executeBuiltin(builtin, context, node) {
6
7
  switch (builtin) {
7
8
  case "drain-merge": return executeDrainMergeBuiltin(context, node);
9
+ case "select-candidate": return executeSelectCandidateBuiltin(context, node);
8
10
  case "test": {
9
11
  const result = await runTests(context.worktreePath, context.signal);
10
12
  return {
@@ -0,0 +1 @@
1
+ import { z } from "zod";
@@ -0,0 +1,91 @@
1
+ import { z } from "zod";
2
+ //#region src/runtime/handoff.ts
3
+ /**
4
+ * NodeHandoff (PIPE-83.1) — the curated, typed envelope a node hands to its
5
+ * dependents in place of its raw transcript. PIPE-83.5 makes renderAgentPrompt
6
+ * consume these instead of re-hydrating every upstream node's full output text;
7
+ * PIPE-83.10 persists them durably as the unit of cross-node state.
8
+ *
9
+ * Produced by DERIVING from a node's raw output via a cheap finalizer (see
10
+ * agent-node), with a synthesized minimal fallback when no structured handoff
11
+ * is available so existing consumers keep working unchanged.
12
+ */
13
+ const MARKDOWN_JSON_FENCE_RE = /^\s*```(?:json)?\s*\r?\n([\s\S]*?)\r?\n```\s*$/i;
14
+ const SUMMARY_FALLBACK_MAX_CHARS = 600;
15
+ const handoffArtifactSchema = z.object({
16
+ lineRange: z.tuple([z.number().int().nonnegative(), z.number().int().nonnegative()]).optional(),
17
+ path: z.string().min(1)
18
+ });
19
+ const nodeHandoffSchema = z.object({
20
+ artifacts: z.array(handoffArtifactSchema).default([]),
21
+ decisions: z.array(z.string()).default([]),
22
+ openQuestions: z.array(z.string()).default([]),
23
+ summary: z.string(),
24
+ testNames: z.array(z.string()).default([])
25
+ });
26
+ /**
27
+ * Parse a candidate handoff JSON string (tolerant of a Markdown ```json fence).
28
+ * Returns null when the text is not JSON or does not satisfy the schema, so the
29
+ * caller can fall back rather than throw.
30
+ */
31
+ function parseHandoff(raw) {
32
+ const source = MARKDOWN_JSON_FENCE_RE.exec(raw.trim())?.[1].trim() ?? raw.trim();
33
+ let value;
34
+ try {
35
+ value = JSON.parse(source);
36
+ } catch {
37
+ return null;
38
+ }
39
+ const result = nodeHandoffSchema.safeParse(value);
40
+ return result.success ? result.data : null;
41
+ }
42
+ /**
43
+ * Minimal handoff synthesized from a node's raw output text. Used when no
44
+ * structured handoff is derived, preserving the pre-PIPE-83 behaviour (the
45
+ * summary stands in for the raw text downstream consumers used to receive).
46
+ */
47
+ function synthesizeMinimalHandoff(outputText) {
48
+ return {
49
+ artifacts: [],
50
+ decisions: [],
51
+ openQuestions: [],
52
+ summary: outputText.trim().slice(0, SUMMARY_FALLBACK_MAX_CHARS),
53
+ testNames: []
54
+ };
55
+ }
56
+ /**
57
+ * Render a handoff into the compact text a dependent node receives (PIPE-83.5):
58
+ * the curated summary + non-empty sections, in place of the full raw transcript.
59
+ */
60
+ function renderHandoff(nodeId, handoff) {
61
+ const sections = [
62
+ ["Decisions:", handoff.decisions],
63
+ ["Artifacts:", handoff.artifacts.map((a) => a.lineRange ? `${a.path}:${a.lineRange[0]}-${a.lineRange[1]}` : a.path)],
64
+ ["Tests:", handoff.testNames],
65
+ ["Open questions:", handoff.openQuestions]
66
+ ];
67
+ const lines = [`## ${nodeId}`, handoff.summary];
68
+ for (const [heading, items] of sections) if (items.length > 0) lines.push(heading, ...items.map((item) => `- ${item}`));
69
+ return lines.join("\n");
70
+ }
71
+ /** Prompt for the cheap finalizer that derives a handoff from raw node output. */
72
+ function handoffFinalizerPrompt(rawOutput) {
73
+ return [
74
+ "You are a handoff summarizer for a pipeline node.",
75
+ "Read the agent output below and return ONLY a JSON object describing what a",
76
+ "downstream node needs to continue — no Markdown fences, no prose outside JSON.",
77
+ "",
78
+ "Fields:",
79
+ "- \"summary\": string — concise description of what this node accomplished.",
80
+ "- \"decisions\": string[] — explicit choices made (libraries, APIs, approaches).",
81
+ "- \"artifacts\": {\"path\": string, \"lineRange\"?: [number, number]}[] — files touched.",
82
+ "- \"testNames\": string[] — tests added or changed.",
83
+ "- \"openQuestions\": string[] — unresolved items the next node should know.",
84
+ "Use empty arrays where nothing applies. Preserve facts; do not invent.",
85
+ "",
86
+ "Agent output:",
87
+ rawOutput
88
+ ].join("\n");
89
+ }
90
+ //#endregion
91
+ export { handoffFinalizerPrompt, parseHandoff, renderHandoff, synthesizeMinimalHandoff };
@@ -1,11 +1,13 @@
1
1
  //#region src/runtime/node-state-store.ts
2
2
  var NodeStateStore = class NodeStateStore {
3
+ handoffByNode;
3
4
  inheritedOutputNodeIds;
4
5
  lastOutputByNode;
5
6
  nodeSnapshots;
6
7
  nodeStates;
7
8
  structuredOutputs;
8
9
  constructor(input = {}) {
10
+ this.handoffByNode = input.handoffByNode ?? /* @__PURE__ */ new Map();
9
11
  this.inheritedOutputNodeIds = input.inheritedOutputNodeIds ?? /* @__PURE__ */ new Set();
10
12
  this.lastOutputByNode = input.lastOutputByNode ?? /* @__PURE__ */ new Map();
11
13
  this.nodeSnapshots = input.nodeSnapshots ?? /* @__PURE__ */ new Map();
@@ -14,6 +16,7 @@ var NodeStateStore = class NodeStateStore {
14
16
  }
15
17
  forkForParallelChildren(children) {
16
18
  return new NodeStateStore({
19
+ handoffByNode: new Map(this.handoffByNode),
17
20
  inheritedOutputNodeIds: new Set(this.lastOutputByNode.keys()),
18
21
  lastOutputByNode: new Map(this.lastOutputByNode),
19
22
  nodeSnapshots: /* @__PURE__ */ new Map(),
@@ -34,6 +37,9 @@ var NodeStateStore = class NodeStateStore {
34
37
  getOutput(nodeId) {
35
38
  return this.lastOutputByNode.get(nodeId);
36
39
  }
40
+ handoff(nodeId) {
41
+ return this.handoffByNode.get(nodeId);
42
+ }
37
43
  outputText(nodeId) {
38
44
  return this.lastOutputByNode.get(nodeId) ?? "";
39
45
  }
@@ -47,6 +53,9 @@ var NodeStateStore = class NodeStateStore {
47
53
  markInheritedOutput(nodeId) {
48
54
  this.inheritedOutputNodeIds.add(nodeId);
49
55
  }
56
+ recordHandoff(nodeId, handoff) {
57
+ if (handoff) this.handoffByNode.set(nodeId, handoff);
58
+ }
50
59
  recordOutput(nodeId, output) {
51
60
  this.lastOutputByNode.set(nodeId, output);
52
61
  }
@@ -26,6 +26,9 @@ function createOpencodeExecutor(deps) {
26
26
  }
27
27
  };
28
28
  }
29
+ function sessionDirectory(deps, plan) {
30
+ return plan.cwd ?? deps.directory;
31
+ }
29
32
  async function driveSession(deps, plan, options) {
30
33
  const sessionId = await resolveSessionId(deps, plan);
31
34
  deps.onSession?.(plan.nodeId, sessionId);
@@ -34,7 +37,7 @@ async function driveSession(deps, plan, options) {
34
37
  const data = unwrap(await deps.client.session.prompt({
35
38
  body: promptBody(plan),
36
39
  path: { id: sessionId },
37
- query: { directory: deps.directory }
40
+ query: { directory: sessionDirectory(deps, plan) }
38
41
  }));
39
42
  return {
40
43
  ...data.info ? { assistant: data.info } : {},
@@ -50,7 +53,7 @@ async function resolveSessionId(deps, plan) {
50
53
  if (existing) return existing;
51
54
  const session = unwrap(await deps.client.session.create({
52
55
  body: { title: `moka:${plan.nodeId}` },
53
- query: { directory: deps.directory }
56
+ query: { directory: plan.cwd ?? deps.directory }
54
57
  }));
55
58
  deps.registry.sessions.set(plan.nodeId, session.id);
56
59
  return session.id;
@@ -1,5 +1,6 @@
1
1
  import { childReporter } from "../events/events.js";
2
2
  import "../events/index.js";
3
+ import { createChildWorktree, gcParallelWorktrees } from "../parallel-worktrees/parallel-worktrees.js";
3
4
  import pLimit from "p-limit";
4
5
  //#region src/runtime/parallel-node/parallel-node.ts
5
6
  async function executeParallelNode(node, context, runtime) {
@@ -9,6 +10,7 @@ async function executeParallelNode(node, context, runtime) {
9
10
  exitCode: 1,
10
11
  output: ""
11
12
  };
13
+ gcStaleWorktrees(context);
12
14
  const linkedAbort = createLinkedAbortController(context.signal);
13
15
  const childContext = createParallelChildContext(context, node.id, children, context.plan.execution.failFast ? linkedAbort.controller.signal : context.signal);
14
16
  try {
@@ -23,6 +25,37 @@ async function executeParallelNode(node, context, runtime) {
23
25
  linkedAbort.cleanup();
24
26
  }
25
27
  }
28
+ function gcStaleWorktrees(context) {
29
+ if (context.config.parallel_worktrees?.enabled) gcParallelWorktrees(context.worktreePath);
30
+ }
31
+ /**
32
+ * PIPE-83.4: run a parallel child in its own git worktree when enabled, so
33
+ * concurrent candidate edits can't collide. The lease is created inside the
34
+ * per-child callback (not before scheduling) so failFast-cleared children never
35
+ * allocate a worktree; release retains dirty/unpushed work for downstream
36
+ * selection. Default-off path is byte-identical to the prior behaviour.
37
+ */
38
+ function runChildInWorktree(child, context, runtime) {
39
+ return context.config.parallel_worktrees?.enabled ? runInLease(child, context, runtime, createChildLease(child, context)) : runtime.executeNode(child, context);
40
+ }
41
+ function createChildLease(child, context) {
42
+ return createChildWorktree({
43
+ childNodeId: child.id,
44
+ parentNodeId: context.parentParallelNodeId ?? "parallel",
45
+ repoRoot: context.worktreePath,
46
+ ...context.runId ? { runId: context.runId } : {}
47
+ });
48
+ }
49
+ async function runInLease(child, context, runtime, lease) {
50
+ try {
51
+ return await runtime.executeNode(child, {
52
+ ...context,
53
+ worktreePath: lease.path
54
+ });
55
+ } finally {
56
+ lease.release();
57
+ }
58
+ }
26
59
  function createParallelChildContext(context, parentNodeId, children, signal) {
27
60
  return {
28
61
  ...context,
@@ -60,9 +93,9 @@ function createLinkedAbortController(signal) {
60
93
  }
61
94
  function executeParallelChildren(children, context, runtime) {
62
95
  for (const child of children) runtime.markNodeReady(context, child.id);
63
- if (!context.maxParallelNodes) return Promise.all(children.map((child) => runtime.executeNode(child, context)));
96
+ if (!context.maxParallelNodes) return Promise.all(children.map((child) => runChildInWorktree(child, context, runtime)));
64
97
  const limit = pLimit(context.maxParallelNodes);
65
- return Promise.all(children.map((child) => limit(() => runtime.executeNode(child, context))));
98
+ return Promise.all(children.map((child) => limit(() => runChildInWorktree(child, context, runtime))));
66
99
  }
67
100
  async function executeFailFastParallelChildren(children, context, abortController, runtime) {
68
101
  for (const child of children) runtime.markNodeReady(context, child.id);
@@ -71,7 +104,7 @@ async function executeFailFastParallelChildren(children, context, abortControlle
71
104
  rejectOnClear: true
72
105
  });
73
106
  return (await Promise.allSettled(children.map((child) => limit(async () => {
74
- const result = await runtime.executeNode(child, context);
107
+ const result = await runChildInWorktree(child, context, runtime);
75
108
  if (result.status === "failed") {
76
109
  abortController.abort();
77
110
  limit.clearQueue();
@@ -0,0 +1,132 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { execFileSync } from "node:child_process";
4
+ //#region src/runtime/parallel-worktrees/parallel-worktrees.ts
5
+ /**
6
+ * PIPE-83.4: git-worktree isolation for parallel candidate nodes. Each parallel
7
+ * child runs in its own worktree on an auto-named branch so concurrent edits do
8
+ * not collide. Teardown is idempotent and crash-safe: a worktree with dirty or
9
+ * unpushed work is RETAINED (never deleted), and orphaned worktrees are GC'd on
10
+ * startup using the same safety guard. A worktree is NOT a sandbox — node_modules
11
+ * and build state are shared; real isolation remains k8s mode.
12
+ */
13
+ const WORKTREE_ROOT = ".pipeline/worktrees";
14
+ const REGISTRY_DIR = join(WORKTREE_ROOT, "registry");
15
+ const OWNER = "oisin-pipeline";
16
+ function git(cwd, args) {
17
+ return execFileSync("git", args, {
18
+ cwd,
19
+ encoding: "utf8"
20
+ }).trim();
21
+ }
22
+ function sanitize(id) {
23
+ return id.replace(/[^A-Za-z0-9._-]/g, "-");
24
+ }
25
+ function writeManifest(path, manifest) {
26
+ writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
27
+ }
28
+ function readManifest(path) {
29
+ return JSON.parse(readFileSync(path, "utf8"));
30
+ }
31
+ function createChildWorktree(opts) {
32
+ const runSeg = sanitize(opts.runId ?? "local");
33
+ const parentSeg = sanitize(opts.parentNodeId);
34
+ const childSeg = sanitize(opts.childNodeId);
35
+ const baseSha = git(opts.repoRoot, ["rev-parse", "HEAD"]);
36
+ const relPath = join(WORKTREE_ROOT, "trees", runSeg, parentSeg, childSeg);
37
+ const absPath = join(opts.repoRoot, relPath);
38
+ const branch = `pipeline/worktrees/${runSeg}/${parentSeg}/${childSeg}`;
39
+ const leaseId = `${runSeg}__${parentSeg}__${childSeg}`;
40
+ const registryAbs = join(opts.repoRoot, REGISTRY_DIR);
41
+ mkdirSync(registryAbs, { recursive: true });
42
+ const manifestPath = join(registryAbs, `${leaseId}.json`);
43
+ const manifest = {
44
+ baseSha,
45
+ branch,
46
+ childNodeId: opts.childNodeId,
47
+ leaseId,
48
+ owner: OWNER,
49
+ parentNodeId: opts.parentNodeId,
50
+ path: relPath,
51
+ runId: opts.runId,
52
+ schemaVersion: 1,
53
+ state: "creating"
54
+ };
55
+ writeManifest(manifestPath, manifest);
56
+ if (!existsSync(absPath)) git(opts.repoRoot, [
57
+ "worktree",
58
+ "add",
59
+ "-b",
60
+ branch,
61
+ absPath,
62
+ baseSha
63
+ ]);
64
+ writeManifest(manifestPath, {
65
+ ...manifest,
66
+ state: "active"
67
+ });
68
+ return {
69
+ baseSha,
70
+ branch,
71
+ leaseId,
72
+ path: absPath,
73
+ release: () => releaseWorktree(opts.repoRoot, manifestPath)
74
+ };
75
+ }
76
+ /** Idempotent, crash-safe teardown. Retains (never deletes) dirty/unpushed work. */
77
+ function releaseWorktree(repoRoot, manifestPath) {
78
+ if (!existsSync(manifestPath)) return "removed";
79
+ const manifest = readManifest(manifestPath);
80
+ const absPath = join(repoRoot, manifest.path);
81
+ git(repoRoot, ["worktree", "prune"]);
82
+ if (!existsSync(absPath)) {
83
+ writeManifest(manifestPath, {
84
+ ...manifest,
85
+ state: "removed"
86
+ });
87
+ return "removed";
88
+ }
89
+ const guarded = retentionState(absPath, manifest.baseSha);
90
+ if (guarded) {
91
+ writeManifest(manifestPath, {
92
+ ...manifest,
93
+ state: guarded
94
+ });
95
+ return guarded;
96
+ }
97
+ git(repoRoot, [
98
+ "worktree",
99
+ "remove",
100
+ "--force",
101
+ absPath
102
+ ]);
103
+ git(repoRoot, [
104
+ "branch",
105
+ "-D",
106
+ manifest.branch
107
+ ]);
108
+ writeManifest(manifestPath, {
109
+ ...manifest,
110
+ state: "removed"
111
+ });
112
+ return "removed";
113
+ }
114
+ /** Returns a retention reason when the worktree must be kept, else undefined. */
115
+ function retentionState(absPath, baseSha) {
116
+ if (git(absPath, [
117
+ "status",
118
+ "--porcelain",
119
+ "--untracked-files=all"
120
+ ]).length > 0) return "retained-dirty";
121
+ if (git(absPath, ["rev-parse", "HEAD"]) !== baseSha) return "retained-unpushed";
122
+ }
123
+ /** Startup GC: release every pipeline-owned lease using the same safety guard. */
124
+ function gcParallelWorktrees(repoRoot) {
125
+ const registryAbs = join(repoRoot, REGISTRY_DIR);
126
+ if (!existsSync(registryAbs)) return [];
127
+ const results = readdirSync(registryAbs).sort().filter((file) => file.endsWith(".json")).map((file) => join(registryAbs, file)).filter((manifestPath) => readManifest(manifestPath).owner === OWNER).map((manifestPath) => releaseWorktree(repoRoot, manifestPath));
128
+ git(repoRoot, ["worktree", "prune"]);
129
+ return results;
130
+ }
131
+ //#endregion
132
+ export { createChildWorktree, gcParallelWorktrees };
@@ -15,10 +15,12 @@ var LocalScheduler = class {
15
15
  emitWorkflowStarted: () => options.emitWorkflowStarted(context),
16
16
  executeWorkflow: () => runWorkflowScheduler({
17
17
  failFast: plan.execution.failFast,
18
+ fanOutWidth: context.config.token_budget?.fan_out_width,
18
19
  isCancelled: () => options.isCancelled(context),
19
20
  markNodeReady: (nodeId) => options.markNodeReady(nodeId, context),
20
21
  maxParallelNodes: context.maxParallelNodes,
21
22
  nodes: plan.topologicalOrder.map((node) => ({
23
+ category: node.category,
22
24
  dependents: node.dependents,
23
25
  id: node.id,
24
26
  index: node.index,
@@ -38,6 +40,7 @@ async function runWorkflowScheduler(input) {
38
40
  blocked: [],
39
41
  completed: [],
40
42
  failFast: input.failFast,
43
+ fanOutWidth: input.fanOutWidth,
41
44
  maxParallelNodes: input.maxParallelNodes,
42
45
  nodes: orderedNodes(input.nodes),
43
46
  running: [],
@@ -107,7 +110,7 @@ function unstartedBlockingDescendants(nodeId, context) {
107
110
  function launchReadyNodes(input, state, running) {
108
111
  const capacity = workflowNodeCapacity(state);
109
112
  if (capacity <= 0) return;
110
- for (const nodeId of readyNodeIds(state).slice(0, capacity)) {
113
+ for (const nodeId of selectLaunchableNodes(state, capacity)) {
111
114
  input.markNodeReady(nodeId);
112
115
  state.running = [...state.running, nodeId];
113
116
  running.set(nodeId, {
@@ -116,6 +119,50 @@ function launchReadyNodes(input, state, running) {
116
119
  });
117
120
  }
118
121
  }
122
+ /**
123
+ * Choose which ready nodes to launch this tick within the global capacity and
124
+ * the per-category fan-out caps. A category at its cap defers its remaining
125
+ * ready nodes to a later tick (it does not drop them). Nodes without a category
126
+ * are bounded only by the global capacity. Without a fanOutWidth (e.g. in tests
127
+ * or configs with no token_budget), this is the prior `slice(0, capacity)`.
128
+ */
129
+ function selectLaunchableNodes(state, capacity) {
130
+ const ready = readyNodeIds(state);
131
+ return state.fanOutWidth ? cappedSelection(ready, capacity, state, state.fanOutWidth) : ready.slice(0, capacity);
132
+ }
133
+ function cappedSelection(ready, capacity, state, fanOut) {
134
+ const categoryOf = new Map(state.nodes.map((node) => [node.id, node.category]));
135
+ const counts = categoryRunCounts(state.running, categoryOf);
136
+ const selected = [];
137
+ for (const nodeId of ready) {
138
+ if (selected.length >= capacity) break;
139
+ if (claimCategorySlot(categoryOf.get(nodeId), fanOut, counts)) selected.push(nodeId);
140
+ }
141
+ return selected;
142
+ }
143
+ function categoryCap(category, fanOut) {
144
+ return fanOut.by_category[category] ?? fanOut.default;
145
+ }
146
+ /**
147
+ * Whether a node of the given category may launch now, consuming a slot from
148
+ * `counts` when it can. Uncategorized nodes always may; a category at its cap
149
+ * may not.
150
+ */
151
+ function claimCategorySlot(category, fanOut, counts) {
152
+ if (!category) return true;
153
+ const current = counts.get(category) ?? 0;
154
+ if (current >= categoryCap(category, fanOut)) return false;
155
+ counts.set(category, current + 1);
156
+ return true;
157
+ }
158
+ function categoryRunCounts(running, categoryOf) {
159
+ const counts = /* @__PURE__ */ new Map();
160
+ for (const nodeId of running) {
161
+ const category = categoryOf.get(nodeId);
162
+ if (category) counts.set(category, (counts.get(category) ?? 0) + 1);
163
+ }
164
+ return counts;
165
+ }
119
166
  function dependencyPassed(nodeId, context) {
120
167
  const result = (context.completed ?? []).find((item) => item.nodeId === nodeId);
121
168
  return result ? context.shouldContinueAfterNodeResult?.(result) ?? result.status !== "failed" : false;
@@ -0,0 +1,58 @@
1
+ import { parseJsonObject } from "../json-validation/json-validation.js";
2
+ import "../json-validation/index.js";
3
+ //#region src/runtime/select-candidate/select-candidate.ts
4
+ function selectBestCandidate(candidates) {
5
+ const passing = candidates.filter((candidate) => candidate.status === "PASS");
6
+ if (passing.length === 0) return null;
7
+ return passing.reduce((best, candidate) => (candidate.judgeScore ?? 0) > (best.judgeScore ?? 0) ? candidate : best);
8
+ }
9
+ function executeSelectCandidateBuiltin(context, node) {
10
+ const candidates = readCandidates(context, node?.needs.at(0) ?? null);
11
+ const selected = selectBestCandidate(candidates);
12
+ if (!selected) return {
13
+ evidence: [`select-candidate: no passing candidate among ${candidates.length}`, ...candidates.map((candidate) => `- ${candidate.nodeId}: FAIL`)],
14
+ exitCode: 1,
15
+ output: ""
16
+ };
17
+ return {
18
+ evidence: [`select-candidate: selected '${selected.nodeId}' (judge=${selected.judgeScore ?? "n/a"}) from ${candidates.length} candidates`],
19
+ exitCode: 0,
20
+ output: selected.output
21
+ };
22
+ }
23
+ function readCandidates(context, upstreamNodeId) {
24
+ if (!upstreamNodeId) return [];
25
+ const upstream = context.plan.graph.node(upstreamNodeId);
26
+ const childrenOutput = parseJsonObject(parseJsonObject(context.nodeStateStore.getOutput(upstreamNodeId)).children);
27
+ return (upstream?.children ?? []).flatMap((child) => {
28
+ const raw = childrenOutput[child.id];
29
+ return raw === void 0 ? [] : [parseCandidate(child.id, raw)];
30
+ });
31
+ }
32
+ function parseCandidate(nodeId, raw) {
33
+ const output = typeof raw === "string" ? raw : JSON.stringify(raw);
34
+ const parsed = safeParseObject(output);
35
+ return {
36
+ judgeScore: candidateJudgeScore(parsed),
37
+ nodeId,
38
+ output,
39
+ status: candidateStatus(parsed)
40
+ };
41
+ }
42
+ function candidateStatus(parsed) {
43
+ if (!parsed) return "PASS";
44
+ return parsed.verdict === "FAIL" || parsed.status === "FAIL" ? "FAIL" : "PASS";
45
+ }
46
+ function candidateJudgeScore(parsed) {
47
+ return typeof parsed?.judge_score === "number" ? parsed.judge_score : null;
48
+ }
49
+ function safeParseObject(text) {
50
+ try {
51
+ const value = JSON.parse(text);
52
+ return value && typeof value === "object" ? value : null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+ //#endregion
58
+ export { executeSelectCandidateBuiltin };