@oisincoveney/pipeline 2.7.0 → 2.8.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 (35) hide show
  1. package/defaults/pipeline.yaml +25 -0
  2. package/dist/argo-graph.js +7 -7
  3. package/dist/bench/eval-report.js +27 -0
  4. package/dist/cli/program.js +19 -3
  5. package/dist/commands/bench-command.js +18 -0
  6. package/dist/config/load.js +17 -0
  7. package/dist/config/schemas.d.ts +21 -4
  8. package/dist/config/schemas.js +20 -7
  9. package/dist/context/repo-map.js +203 -0
  10. package/dist/install-commands/opencode.js +10 -1
  11. package/dist/mcp/gateway.js +3 -3
  12. package/dist/moka-submit.d.ts +1 -1
  13. package/dist/pipeline-init.js +18 -12
  14. package/dist/pipeline-runtime.js +12 -1
  15. package/dist/planning/compile.d.ts +8 -3
  16. package/dist/planning/compile.js +7 -7
  17. package/dist/planning/generate.d.ts +6 -1
  18. package/dist/planning/generate.js +29 -7
  19. package/dist/runner-command-contract.d.ts +8 -3
  20. package/dist/runner-command-contract.js +6 -5
  21. package/dist/runner-event-sink.js +6 -5
  22. package/dist/runner.d.ts +6 -1
  23. package/dist/runner.js +3 -3
  24. package/dist/runtime/agent-node/agent-node.js +22 -4
  25. package/dist/runtime/local-scheduler.js +45 -0
  26. package/dist/runtime/opencode-server.js +6 -3
  27. package/dist/runtime/parallel-node/parallel-node.js +74 -75
  28. package/dist/runtime/parallel-worktrees/parallel-worktrees.js +49 -4
  29. package/dist/runtime/run-journal.js +21 -0
  30. package/dist/runtime/scheduler.js +122 -93
  31. package/dist/runtime/select-candidate/select-candidate.js +13 -1
  32. package/dist/runtime/services/worktree-service.js +18 -0
  33. package/dist/schedule/passes/candidates.js +17 -8
  34. package/docs/config-architecture.md +105 -0
  35. package/package.json +7 -2
@@ -18,12 +18,14 @@ import "./runtime/builtins/index.js";
18
18
  import { diffChangedFiles, snapshotChangedFiles } from "./runtime/changed-files/changed-files.js";
19
19
  import { evaluateNodeGates } from "./runtime/gates/gates.js";
20
20
  import "./runtime/gates/index.js";
21
+ import { LocalScheduler } from "./runtime/local-scheduler.js";
21
22
  import { NodeStateTracker } from "./runtime/node-state-tracker.js";
22
23
  import { configUsesOpencode, leaseOpencodeRuntime } from "./runtime/opencode-runtime.js";
23
24
  import { executeParallelNode } from "./runtime/parallel-node/parallel-node.js";
24
25
  import "./runtime/parallel-node/index.js";
25
26
  import { decideNodeRetry, nodeRetryPolicy } from "./runtime/retry.js";
26
- import { LocalScheduler } from "./runtime/scheduler.js";
27
+ import { fileRunJournal } from "./runtime/run-journal.js";
28
+ import { join } from "node:path";
27
29
  //#region src/pipeline-runtime.ts
28
30
  function runPipelineFromConfig(options) {
29
31
  return withOpencodeRuntime(options, (resolved) => runPipelineWithContext(createRuntimeContext(resolved)));
@@ -78,6 +80,14 @@ async function runWithLeasedOpencode(options, config, worktreePath, run) {
78
80
  await lease.release();
79
81
  }
80
82
  }
83
+ function runJournalPath(context, dir) {
84
+ return join(context.worktreePath ?? process.cwd(), dir, `${context.runId}.jsonl`);
85
+ }
86
+ function resolveRunJournal(context) {
87
+ const durability = context.config.durability;
88
+ if (!(durability?.enabled && context.runId)) return;
89
+ return fileRunJournal(runJournalPath(context, durability.dir));
90
+ }
81
91
  async function runPipelineWithContext(context) {
82
92
  return finishRuntime(context, await new LocalScheduler({
83
93
  buildResult: (outcome, nodes, failure) => workflowRuntimeResult(context, outcome, nodes, failure),
@@ -89,6 +99,7 @@ async function runPipelineWithContext(context) {
89
99
  at: now(),
90
100
  type: "READY"
91
101
  }),
102
+ resolveJournal: (nextContext) => resolveRunJournal(nextContext),
92
103
  runWorkflowHook: (event, failure, nextContext) => dispatchHooks(nextContext, event, failure),
93
104
  shouldContinueAfterNodeResult: (result, nextContext) => shouldContinueAfterNodeResult(result, nextContext),
94
105
  skipNode: (nodeId, reason, nextContext) => recordSkippedNodeState(nextContext, nodeId, reason, now())
@@ -7,9 +7,14 @@ interface WorkflowPlannerIssue {
7
7
  message: string;
8
8
  path?: string;
9
9
  }
10
- declare class WorkflowPlannerError extends Error {
11
- code: WorkflowPlannerErrorCode;
12
- issues: WorkflowPlannerIssue[];
10
+ declare const WorkflowPlannerError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P] }>) => import("effect/Cause").YieldableError & {
11
+ readonly _tag: "WorkflowPlannerError";
12
+ } & Readonly<A>;
13
+ declare class WorkflowPlannerError extends WorkflowPlannerError_base<{
14
+ readonly code: WorkflowPlannerErrorCode;
15
+ readonly message: string;
16
+ readonly issues: WorkflowPlannerIssue[];
17
+ }> {
13
18
  constructor(code: WorkflowPlannerErrorCode, message: string, issues?: WorkflowPlannerIssue[]);
14
19
  }
15
20
  interface PlannedWorkflowNode {
@@ -1,15 +1,15 @@
1
1
  import { uniqueStrings } from "../strings.js";
2
2
  import { findDependencyCycles } from "./graph.js";
3
+ import { Data } from "effect";
3
4
  import { Graph } from "@dagrejs/graphlib";
4
5
  //#region src/planning/compile.ts
5
- var WorkflowPlannerError = class extends Error {
6
- code;
7
- issues;
6
+ var WorkflowPlannerError = class extends Data.TaggedError("WorkflowPlannerError") {
8
7
  constructor(code, message, issues = []) {
9
- super(message);
10
- this.name = "WorkflowPlannerError";
11
- this.code = code;
12
- this.issues = issues;
8
+ super({
9
+ code,
10
+ message,
11
+ issues
12
+ });
13
13
  }
14
14
  };
15
15
  function compileWorkflowPlan(config, workflowId = config.default_workflow) {
@@ -1594,7 +1594,12 @@ interface SchedulePlanningContext {
1594
1594
  workUnits: BacklogWorkUnit[];
1595
1595
  }
1596
1596
  declare function parseScheduleArtifact(source: string, sourcePath?: string): ScheduleArtifact;
1597
- declare class ScheduleArtifactError extends Error {
1597
+ declare const ScheduleArtifactError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P] }>) => import("effect/Cause").YieldableError & {
1598
+ readonly _tag: "ScheduleArtifactError";
1599
+ } & Readonly<A>;
1600
+ declare class ScheduleArtifactError extends ScheduleArtifactError_base<{
1601
+ readonly message: string;
1602
+ }> {
1598
1603
  constructor(message: string);
1599
1604
  }
1600
1605
  declare function compileScheduleArtifact(config: PipelineConfig, artifact: ScheduleArtifact, projectRoot?: string): CompiledScheduleArtifact;
@@ -17,6 +17,7 @@ import { plannerPrompt, plannerRepairPrompt } from "../schedule/prompts.js";
17
17
  import { compileWorkflowPlan } from "./compile.js";
18
18
  import { parseDocument, stringify } from "yaml";
19
19
  import { z } from "zod";
20
+ import { Data } from "effect";
20
21
  import { mkdirSync, writeFileSync } from "node:fs";
21
22
  import { join } from "node:path";
22
23
  //#region src/planning/generate.ts
@@ -30,10 +31,12 @@ const SCHEDULE_BUILTINS = [
30
31
  "duplication",
31
32
  "fallow",
32
33
  "lint",
34
+ "select-candidate",
33
35
  "semgrep",
34
36
  "test",
35
37
  "typecheck"
36
38
  ];
39
+ const PARALLEL_MERGE_BUILTINS = new Set(["drain-merge", "select-candidate"]);
37
40
  const scheduleArtifactSchema = z.object({
38
41
  generated_at: z.string().datetime(),
39
42
  kind: z.literal(SCHEDULE_KIND),
@@ -54,10 +57,9 @@ function parseScheduleArtifact(source, sourcePath = "schedule.yaml") {
54
57
  if (!parsed.success) throw new ScheduleArtifactError([`Invalid schedule artifact ${sourcePath}:`, ...parsed.error.issues.map((issue) => issue.path.length > 0 ? `- ${issue.path.join(".")}: ${issue.message}` : `- ${issue.message}`)].join("\n"));
55
58
  return parsed.data;
56
59
  }
57
- var ScheduleArtifactError = class extends Error {
60
+ var ScheduleArtifactError = class extends Data.TaggedError("ScheduleArtifactError") {
58
61
  constructor(message) {
59
- super(message);
60
- this.name = "ScheduleArtifactError";
62
+ super({ message });
61
63
  }
62
64
  };
63
65
  function compileScheduleArtifact(config, artifact, projectRoot) {
@@ -255,11 +257,28 @@ function unsafeParallelWorktreeIssues(config, artifact) {
255
257
  return [`parallel node '${workflowId}.${node.id}' has write-capable children sharing a worktree without isolated worktree roots or drain-merge integration`];
256
258
  }));
257
259
  }
260
+ function registerContainmentEdge(parent, child, index) {
261
+ const dependents = index.get(child.id) ?? [];
262
+ dependents.push(parent);
263
+ index.set(child.id, dependents);
264
+ }
265
+ function addParallelContainmentEdges(nodes, index) {
266
+ for (const node of nodes) {
267
+ if (node.kind !== "parallel") continue;
268
+ for (const child of node.nodes) registerContainmentEdge(node, child, index);
269
+ addParallelContainmentEdges(node.nodes, index);
270
+ }
271
+ }
272
+ function dependentsByNeedWithContainment(nested, flat) {
273
+ const index = dependentsByNeed(flat);
274
+ addParallelContainmentEdges(nested, index);
275
+ return index;
276
+ }
258
277
  function workflowNodeIssues(artifact, collectIssues) {
259
278
  return Object.entries(artifact.workflows).flatMap(([workflowId, workflow]) => {
260
279
  const nodes = flattenWorkflowNodes(workflow.nodes);
261
280
  return collectIssues({
262
- dependentsByNeed: dependentsByNeed(nodes),
281
+ dependentsByNeed: dependentsByNeedWithContainment(workflow.nodes, nodes),
263
282
  nodes,
264
283
  workflowId
265
284
  });
@@ -272,7 +291,7 @@ function isWriteCapableParallelChild(config, node) {
272
291
  return false;
273
292
  }
274
293
  function hasDownstreamDrainMerge(nodeId, index) {
275
- return hasReachableDependent(nodeId, index, (node) => node.kind === "builtin" && node.builtin === "drain-merge");
294
+ return hasReachableDependent(nodeId, index, (node) => node.kind === "builtin" && PARALLEL_MERGE_BUILTINS.has(node.builtin));
276
295
  }
277
296
  function generatedRootWorkflowIssues(artifact) {
278
297
  const workflowIds = Object.keys(artifact.workflows);
@@ -324,9 +343,9 @@ function workUnitDependencyIssues(config, artifact, workUnits) {
324
343
  const dependenciesByUnit = new Map(workUnits.map((unit) => [unit.id, (unit.dependencies ?? []).filter((id) => workUnitIds.has(id))]));
325
344
  return Object.entries(artifact.workflows).flatMap(([workflowId, workflow]) => {
326
345
  const nodes = flattenWorkflowNodes(workflow.nodes);
327
- const index = dependentsByNeed(nodes);
346
+ const index = dependentsByNeedWithContainment(workflow.nodes, nodes);
328
347
  const nodesByWorkUnit = nodesByAssignedWorkUnit(nodes);
329
- return nodes.filter((node) => isImplementationNode(config, node)).flatMap((node) => {
348
+ return nodes.filter((node) => isImplementationNode(config, node) || isSelectCandidateNode(node)).flatMap((node) => {
330
349
  const dependentId = node.task_context?.id;
331
350
  if (!dependentId) return [];
332
351
  return (dependenciesByUnit.get(dependentId) ?? []).flatMap((prerequisiteId) => {
@@ -336,6 +355,9 @@ function workUnitDependencyIssues(config, artifact, workUnits) {
336
355
  });
337
356
  });
338
357
  }
358
+ function isSelectCandidateNode(node) {
359
+ return node.kind === "builtin" && node.builtin === "select-candidate";
360
+ }
339
361
  function nodesByAssignedWorkUnit(nodes) {
340
362
  const grouped = /* @__PURE__ */ new Map();
341
363
  for (const node of nodes) {
@@ -43,8 +43,8 @@ declare const runnerDeliverySchema: z.ZodObject<{
43
43
  declare const mokaSubmissionSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
44
44
  kind: z.ZodLiteral<"graph">;
45
45
  mode: z.ZodEnum<{
46
- full: "full";
47
46
  quick: "quick";
47
+ full: "full";
48
48
  }>;
49
49
  }, z.core.$strict>, z.ZodObject<{
50
50
  argv: z.ZodArray<z.ZodString>;
@@ -104,8 +104,8 @@ declare const runnerCommandPayloadSchema: z.ZodObject<{
104
104
  submission: z.ZodDefault<z.ZodDiscriminatedUnion<[z.ZodObject<{
105
105
  kind: z.ZodLiteral<"graph">;
106
106
  mode: z.ZodEnum<{
107
- full: "full";
108
107
  quick: "quick";
108
+ full: "full";
109
109
  }>;
110
110
  }, z.core.$strict>, z.ZodObject<{
111
111
  argv: z.ZodArray<z.ZodString>;
@@ -140,8 +140,13 @@ interface RunnerCommandPayloadValidationIssue {
140
140
  message: string;
141
141
  path: string;
142
142
  }
143
- declare class RunnerCommandPayloadValidationError extends Error {
143
+ declare const RunnerCommandPayloadValidationError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P] }>) => import("effect/Cause").YieldableError & {
144
+ readonly _tag: "RunnerCommandPayloadValidationError";
145
+ } & Readonly<A>;
146
+ declare class RunnerCommandPayloadValidationError extends RunnerCommandPayloadValidationError_base<{
147
+ readonly message: string;
144
148
  readonly issues: RunnerCommandPayloadValidationIssue[];
149
+ }> {
145
150
  constructor(message: string, issues: RunnerCommandPayloadValidationIssue[]);
146
151
  }
147
152
  interface BuildRunnerCommandPayloadOptions {
@@ -1,5 +1,6 @@
1
1
  import { parseJson } from "./safe-json.js";
2
2
  import { z } from "zod";
3
+ import { Data } from "effect";
3
4
  import { readFileSync } from "node:fs";
4
5
  import parseGitUrl from "git-url-parse";
5
6
  //#region src/runner-command-contract.ts
@@ -71,12 +72,12 @@ const runnerCommandPayloadSchema = z.object({
71
72
  task: runnerTaskSchema,
72
73
  workflow: runnerWorkflowIdentitySchema
73
74
  }).strict();
74
- var RunnerCommandPayloadValidationError = class extends Error {
75
- issues;
75
+ var RunnerCommandPayloadValidationError = class extends Data.TaggedError("RunnerCommandPayloadValidationError") {
76
76
  constructor(message, issues) {
77
- super(message);
78
- this.name = "RunnerCommandPayloadValidationError";
79
- this.issues = issues;
77
+ super({
78
+ message,
79
+ issues
80
+ });
80
81
  }
81
82
  };
82
83
  function resolveRunnerEventSinkAuthToken(options) {
@@ -1,4 +1,5 @@
1
1
  import { mapRuntimeEventToRunnerEventRecords } from "./runner-command-contract.js";
2
+ import { Data } from "effect";
2
3
  import ky, { isHTTPError } from "ky";
3
4
  //#region src/runner-event-sink.ts
4
5
  const DEFAULT_BATCH_SIZE = 50;
@@ -20,12 +21,12 @@ const RETRYABLE_STATUS_CODES = [
20
21
  510,
21
22
  511
22
23
  ];
23
- var EventSinkHttpError = class extends Error {
24
- status;
24
+ var EventSinkHttpError = class extends Data.TaggedError("EventSinkHttpError") {
25
25
  constructor(status, message) {
26
- super(message);
27
- this.name = "EventSinkHttpError";
28
- this.status = status;
26
+ super({
27
+ status,
28
+ message
29
+ });
29
30
  }
30
31
  };
31
32
  function createRunnerEventSink(options) {
package/dist/runner.d.ts CHANGED
@@ -80,7 +80,12 @@ interface RunnerLaunchInput {
80
80
  prompt: string;
81
81
  worktreePath: string;
82
82
  }
83
- declare class RunnerCapabilityError extends Error {
83
+ declare const RunnerCapabilityError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P] }>) => import("effect/Cause").YieldableError & {
84
+ readonly _tag: "RunnerCapabilityError";
85
+ } & Readonly<A>;
86
+ declare class RunnerCapabilityError extends RunnerCapabilityError_base<{
87
+ readonly message: string;
88
+ }> {
84
89
  constructor(message: string);
85
90
  }
86
91
  /**
package/dist/runner.js CHANGED
@@ -1,11 +1,11 @@
1
+ import { Data } from "effect";
1
2
  import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { execa } from "execa";
4
5
  //#region src/runner.ts
5
- var RunnerCapabilityError = class extends Error {
6
+ var RunnerCapabilityError = class extends Data.TaggedError("RunnerCapabilityError") {
6
7
  constructor(message) {
7
- super(message);
8
- this.name = "RunnerCapabilityError";
8
+ super({ message });
9
9
  }
10
10
  };
11
11
  const OPENCODE_EXCLUDES = [
@@ -6,9 +6,10 @@ import { normalizeJsonSource, readJsonSchemaSource, validateJsonSchemaSource } f
6
6
  import "../json-validation/index.js";
7
7
  import { emit, emitAgentFinish, emitAgentStart } from "../events/events.js";
8
8
  import "../events/index.js";
9
+ import { estimateTokens } from "../../token-estimator.js";
10
+ import { buildRepoMapContext } from "../../context/repo-map.js";
9
11
  import { gatewayServerForProfile } from "../../mcp/gateway.js";
10
12
  import { selectNodeModel } from "../../model-resolver.js";
11
- import { estimateTokens } from "../../token-estimator.js";
12
13
  import { handoffFinalizerPrompt, parseHandoff, renderHandoff, synthesizeMinimalHandoff } from "../handoff.js";
13
14
  import { readFileSync } from "node:fs";
14
15
  //#region src/runtime/agent-node/agent-node.ts
@@ -18,7 +19,7 @@ async function executeAgentNode(node, context, attempt) {
18
19
  exitCode: 1,
19
20
  output: ""
20
21
  };
21
- const prompt = renderAgentPrompt(node, context);
22
+ const prompt = await renderAgentPrompt(node, context);
22
23
  const decision = decideNodeModel(prompt, node, context.config.token_budget);
23
24
  if (decision.overBudget) return {
24
25
  evidence: [
@@ -307,11 +308,28 @@ function createOutputRepairPlan(inputs) {
307
308
  function normalizeAgentOutput(plan, stdout) {
308
309
  return normalizeRunnerOutput(plan, stdout);
309
310
  }
310
- function renderAgentPrompt(node, context) {
311
+ async function repoMapSection(node, context) {
312
+ const repoMap = context.config.repo_map;
313
+ if (!repoMap?.enabled) return "";
314
+ try {
315
+ return (await buildRepoMapContext({
316
+ artifacts: node.needs.flatMap((need) => context.nodeStateStore.handoff(need)?.artifacts ?? []),
317
+ taskText: context.task,
318
+ tokenBudget: repoMap.token_budget,
319
+ worktreePath: context.worktreePath
320
+ })).context;
321
+ } catch {
322
+ return "";
323
+ }
324
+ }
325
+ async function renderAgentPrompt(node, context) {
311
326
  const profile = node.profile ? context.config.profiles[node.profile] : void 0;
327
+ const instructions = profile ? readInstructions(context.worktreePath, profile.instructions) : "";
328
+ const repoMap = await repoMapSection(node, context);
312
329
  return [
313
- (profile ? readInstructions(context.worktreePath, profile.instructions) : "").trim(),
330
+ instructions.trim(),
314
331
  "",
332
+ repoMap,
315
333
  `Task: ${context.task}`,
316
334
  `Workflow: ${context.workflowId}`,
317
335
  `Node: ${node.id}`,
@@ -0,0 +1,45 @@
1
+ import { runWorkflowLifecycle } from "./workflow-lifecycle.js";
2
+ import { runWorkflowScheduler } from "./scheduler.js";
3
+ //#region src/runtime/local-scheduler.ts
4
+ /**
5
+ * The local, in-process scheduler seam. It owns workflow lifecycle hooks and
6
+ * delegates the topological fan-out/gating loop to `runWorkflowScheduler`,
7
+ * passing the durability journal (PIPE-83.10) through to it.
8
+ */
9
+ var LocalScheduler = class {
10
+ options;
11
+ constructor(options) {
12
+ this.options = options;
13
+ }
14
+ async runWorkflow(plan, context) {
15
+ const options = this.options;
16
+ if (!options) throw new Error("LocalScheduler requires runtime options to run workflow");
17
+ return (await runWorkflowLifecycle({
18
+ buildResult: options.buildResult,
19
+ emitWorkflowPlanned: () => options.emitWorkflowPlanned(context),
20
+ emitWorkflowStarted: () => options.emitWorkflowStarted(context),
21
+ executeWorkflow: () => runWorkflowScheduler({
22
+ failFast: plan.execution.failFast,
23
+ fanOutWidth: context.config.token_budget?.fan_out_width,
24
+ isCancelled: () => options.isCancelled(context),
25
+ journal: options.resolveJournal?.(context),
26
+ markNodeReady: (nodeId) => options.markNodeReady(nodeId, context),
27
+ maxParallelNodes: context.maxParallelNodes,
28
+ nodes: plan.topologicalOrder.map((node) => ({
29
+ category: node.category,
30
+ dependents: node.dependents,
31
+ id: node.id,
32
+ index: node.index,
33
+ needs: node.needs
34
+ })),
35
+ runNode: (nodeId) => options.executeNode(nodeId, context),
36
+ shouldContinueAfterNodeResult: (result) => options.shouldContinueAfterNodeResult(result, context),
37
+ skipNode: (nodeId, reason) => options.skipNode(nodeId, reason, context)
38
+ }),
39
+ isCancelled: () => options.isCancelled(context),
40
+ runWorkflowHook: (event, failure) => options.runWorkflowHook(event, failure, context)
41
+ })).result;
42
+ }
43
+ };
44
+ //#endregion
45
+ export { LocalScheduler };
@@ -1,10 +1,13 @@
1
+ import { Data } from "effect";
1
2
  import { createOpencode, createOpencodeClient } from "@opencode-ai/sdk";
2
3
  //#region src/runtime/opencode-server.ts
3
4
  const DEFAULT_STARTUP_TIMEOUT_MS = 3e4;
4
- var OpencodeServerStartupError = class extends Error {
5
+ var OpencodeServerStartupError = class extends Data.TaggedError("OpencodeServerStartupError") {
5
6
  constructor(message, options) {
6
- super(message, options);
7
- this.name = "OpencodeServerStartupError";
7
+ super({
8
+ message,
9
+ cause: options?.cause
10
+ });
8
11
  }
9
12
  };
10
13
  async function openOpencodeServer(options) {
@@ -1,60 +1,97 @@
1
1
  import { childReporter } from "../events/events.js";
2
2
  import "../events/index.js";
3
- import { createChildWorktree, gcParallelWorktrees } from "../parallel-worktrees/parallel-worktrees.js";
4
- import pLimit from "p-limit";
3
+ import { WorktreeService, WorktreeServiceLive } from "../services/worktree-service.js";
4
+ import { Effect } from "effect";
5
5
  //#region src/runtime/parallel-node/parallel-node.ts
6
- async function executeParallelNode(node, context, runtime) {
6
+ function executeParallelNode(node, context, runtime) {
7
+ return Effect.runPromise(Effect.provide(parallelNodeProgram(node, context, runtime), WorktreeServiceLive));
8
+ }
9
+ function parallelNodeProgram(node, context, runtime) {
7
10
  const children = node.children ?? [];
8
- if (children.length === 0) return {
11
+ if (children.length === 0) return Effect.succeed({
9
12
  evidence: [`parallel node '${node.id}' has no children`],
10
13
  exitCode: 1,
11
14
  output: ""
15
+ });
16
+ return Effect.gen(function* () {
17
+ yield* gcStaleWorktrees(context);
18
+ const failFast = context.plan.execution.failFast;
19
+ const linkedAbort = createLinkedAbortController(context.signal);
20
+ const childContext = createParallelChildContext(context, node.id, children, failFast ? linkedAbort.controller.signal : context.signal);
21
+ const gate = failFast ? makeFailFastGate(linkedAbort.controller) : void 0;
22
+ for (const child of children) runtime.markNodeReady(childContext, child.id);
23
+ const settled = yield* runAllChildren(children, childContext, runtime, yield* makeCategorySemaphores(childContext), gate).pipe(Effect.ensuring(Effect.sync(linkedAbort.cleanup)));
24
+ return aggregateParallelResult(node.id, children, settled);
25
+ });
26
+ }
27
+ function makeFailFastGate(controller) {
28
+ return {
29
+ abort: () => controller.abort(),
30
+ aborted: () => controller.signal.aborted
12
31
  };
13
- gcStaleWorktrees(context);
14
- const linkedAbort = createLinkedAbortController(context.signal);
15
- const childContext = createParallelChildContext(context, node.id, children, context.plan.execution.failFast ? linkedAbort.controller.signal : context.signal);
16
- try {
17
- const results = context.plan.execution.failFast ? await executeFailFastParallelChildren(children, childContext, linkedAbort.controller, runtime) : await executeParallelChildren(children, childContext, runtime);
18
- const failed = results.filter((result) => result.status === "failed");
19
- return {
20
- evidence: parallelEvidence(node.id, results, failed),
21
- exitCode: failed.length > 0 ? 1 : 0,
22
- output: parallelOutput(children, results)
23
- };
24
- } finally {
25
- linkedAbort.cleanup();
26
- }
32
+ }
33
+ function aggregateParallelResult(nodeId, children, settled) {
34
+ const results = settled.filter((result) => result !== void 0);
35
+ const failed = results.filter((result) => result.status === "failed");
36
+ return {
37
+ evidence: parallelEvidence(nodeId, results, failed),
38
+ exitCode: failed.length > 0 ? 1 : 0,
39
+ output: parallelOutput(children, results)
40
+ };
41
+ }
42
+ function runAllChildren(children, context, runtime, caps, gate) {
43
+ return Effect.forEach(children, (child) => runChildCapped(child, context, runtime, caps, gate), { concurrency: context.maxParallelNodes ?? "unbounded" });
44
+ }
45
+ function runChildCapped(child, context, runtime, caps, gate) {
46
+ return Effect.gen(function* () {
47
+ if (gate?.aborted()) return;
48
+ const result = yield* withCategoryCap(caps, child.id, context, runChildInWorktree(child, context, runtime));
49
+ if (gate && result.status === "failed") gate.abort();
50
+ return result;
51
+ });
52
+ }
53
+ function withCategoryCap(caps, childId, context, effect) {
54
+ const category = childCategory(childId, context.config.token_budget?.fan_out_width);
55
+ const semaphore = category ? caps.get(category) : void 0;
56
+ return semaphore ? semaphore.withPermits(1)(effect) : effect;
57
+ }
58
+ function makeCategorySemaphores(context) {
59
+ const fanOut = context.config.token_budget?.fan_out_width;
60
+ if (!fanOut) return Effect.succeed(/* @__PURE__ */ new Map());
61
+ return Effect.gen(function* () {
62
+ const caps = /* @__PURE__ */ new Map();
63
+ for (const [category, permits] of Object.entries(fanOut.by_category)) caps.set(category, yield* Effect.makeSemaphore(permits));
64
+ return caps;
65
+ });
27
66
  }
28
67
  function gcStaleWorktrees(context) {
29
- if (context.config.parallel_worktrees?.enabled) gcParallelWorktrees(context.worktreePath);
68
+ return Effect.gen(function* () {
69
+ if (context.config.parallel_worktrees?.enabled) yield* (yield* WorktreeService).gc(context.worktreePath);
70
+ });
30
71
  }
31
72
  /**
32
73
  * 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.
74
+ * concurrent candidate edits can't collide. The worktree lease is acquired and
75
+ * released as an Effect-scoped resource (released on success, failure, or
76
+ * interruption); release retains dirty/unpushed work for downstream selection.
37
77
  */
38
78
  function runChildInWorktree(child, context, runtime) {
39
- return context.config.parallel_worktrees?.enabled ? runInLease(child, context, runtime, createChildLease(child, context)) : runtime.executeNode(child, context);
79
+ if (!context.config.parallel_worktrees?.enabled) return Effect.promise(() => runtime.executeNode(child, context));
80
+ return Effect.gen(function* () {
81
+ const worktree = yield* WorktreeService;
82
+ return yield* Effect.acquireUseRelease(worktree.createChild(childLeaseOptions(child, context)), (lease) => Effect.promise(() => runtime.executeNode(child, {
83
+ ...context,
84
+ worktreePath: lease.path
85
+ })), (lease) => Effect.sync(() => lease.release()));
86
+ });
40
87
  }
41
- function createChildLease(child, context) {
42
- return createChildWorktree({
88
+ function childLeaseOptions(child, context) {
89
+ return {
43
90
  childNodeId: child.id,
44
91
  parentNodeId: context.parentParallelNodeId ?? "parallel",
45
92
  repoRoot: context.worktreePath,
46
93
  ...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
- }
94
+ };
58
95
  }
59
96
  function createParallelChildContext(context, parentNodeId, children, signal) {
60
97
  return {
@@ -94,44 +131,6 @@ function createLinkedAbortController(signal) {
94
131
  function childCategory(childId, fanOut) {
95
132
  return fanOut ? Object.keys(fanOut.by_category).find((category) => childId.includes(category)) : void 0;
96
133
  }
97
- function makeCategoryGate(context) {
98
- const fanOut = context.config.token_budget?.fan_out_width;
99
- const limits = /* @__PURE__ */ new Map();
100
- return (childId, run) => {
101
- const category = childCategory(childId, fanOut);
102
- if (!(category && fanOut)) return run();
103
- let limit = limits.get(category);
104
- if (!limit) {
105
- limit = pLimit(fanOut.by_category[category]);
106
- limits.set(category, limit);
107
- }
108
- return limit(run);
109
- };
110
- }
111
- function executeParallelChildren(children, context, runtime) {
112
- for (const child of children) runtime.markNodeReady(context, child.id);
113
- const gate = makeCategoryGate(context);
114
- const runChild = (child) => gate(child.id, () => runChildInWorktree(child, context, runtime));
115
- if (!context.maxParallelNodes) return Promise.all(children.map((child) => runChild(child)));
116
- const limit = pLimit(context.maxParallelNodes);
117
- return Promise.all(children.map((child) => limit(() => runChild(child))));
118
- }
119
- async function executeFailFastParallelChildren(children, context, abortController, runtime) {
120
- for (const child of children) runtime.markNodeReady(context, child.id);
121
- const gate = makeCategoryGate(context);
122
- const limit = pLimit({
123
- concurrency: context.maxParallelNodes ?? children.length,
124
- rejectOnClear: true
125
- });
126
- return (await Promise.allSettled(children.map((child) => limit(async () => {
127
- const result = await gate(child.id, () => runChildInWorktree(child, context, runtime));
128
- if (result.status === "failed") {
129
- abortController.abort();
130
- limit.clearQueue();
131
- }
132
- return result;
133
- })))).flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
134
- }
135
134
  function parallelEvidence(nodeId, results, failed) {
136
135
  if (failed.length === 0) return [`parallel node '${nodeId}' completed ${results.length} child nodes`];
137
136
  return [`parallel node '${nodeId}' failed with ${failed.length} failed child nodes`, ...failed.flatMap((result) => result.evidence)];