@oisincoveney/pipeline 2.7.0 → 2.8.1

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 (60) hide show
  1. package/defaults/pipeline.yaml +25 -0
  2. package/dist/argo-graph.js +7 -7
  3. package/dist/argo-submit.d.ts +2 -4
  4. package/dist/argo-submit.js +80 -80
  5. package/dist/bench/eval-report.js +27 -0
  6. package/dist/cli/program.js +19 -3
  7. package/dist/cluster-doctor.js +89 -101
  8. package/dist/commands/bench-command.js +18 -0
  9. package/dist/config/defaults.js +9 -19
  10. package/dist/config/load.js +47 -37
  11. package/dist/config/schemas.d.ts +24 -7
  12. package/dist/config/schemas.js +20 -7
  13. package/dist/context/repo-map.js +203 -0
  14. package/dist/install-commands/opencode.js +10 -1
  15. package/dist/mcp/gateway-error.js +15 -0
  16. package/dist/mcp/gateway.js +119 -220
  17. package/dist/moka-global-config.js +20 -20
  18. package/dist/moka-submit.d.ts +6 -6
  19. package/dist/pipeline-init.js +18 -12
  20. package/dist/pipeline-runtime.js +592 -372
  21. package/dist/planning/compile.d.ts +8 -3
  22. package/dist/planning/compile.js +7 -7
  23. package/dist/planning/generate.d.ts +6 -1
  24. package/dist/planning/generate.js +29 -7
  25. package/dist/run-state/git-refs.js +124 -94
  26. package/dist/runner-command-contract.d.ts +6 -1
  27. package/dist/runner-command-contract.js +6 -5
  28. package/dist/runner-event-schema.d.ts +6 -6
  29. package/dist/runner-event-sink.js +37 -68
  30. package/dist/runner.d.ts +6 -1
  31. package/dist/runner.js +3 -3
  32. package/dist/runtime/agent-node/agent-node.js +218 -159
  33. package/dist/runtime/changed-files/changed-files.js +15 -27
  34. package/dist/runtime/changed-files/index.js +2 -0
  35. package/dist/runtime/drain-merge/drain-merge.js +124 -82
  36. package/dist/runtime/gates/gates.js +45 -27
  37. package/dist/runtime/hooks/hooks.js +74 -29
  38. package/dist/runtime/local-scheduler.js +45 -0
  39. package/dist/runtime/opencode-server.js +32 -23
  40. package/dist/runtime/opencode-session-executor.js +101 -44
  41. package/dist/runtime/parallel-node/parallel-node.js +93 -75
  42. package/dist/runtime/parallel-worktrees/parallel-worktrees.js +49 -4
  43. package/dist/runtime/run-journal.js +21 -0
  44. package/dist/runtime/scheduler.js +122 -93
  45. package/dist/runtime/select-candidate/select-candidate.js +52 -24
  46. package/dist/runtime/services/agent-node-runtime-service.js +15 -0
  47. package/dist/runtime/services/command-executor-service.js +8 -0
  48. package/dist/runtime/services/config-io-service.js +42 -0
  49. package/dist/runtime/services/drain-merge-git-service.js +10 -0
  50. package/dist/runtime/services/git-porcelain-service.js +38 -0
  51. package/dist/runtime/services/kubernetes-argo-service.d.ts +13 -0
  52. package/dist/runtime/services/kubernetes-argo-service.js +81 -0
  53. package/dist/runtime/services/mcp-gateway-service.js +184 -0
  54. package/dist/runtime/services/opencode-sdk-service.js +27 -0
  55. package/dist/runtime/services/runner-event-sink-http-service.js +80 -0
  56. package/dist/runtime/services/select-candidate-service.js +13 -0
  57. package/dist/runtime/services/worktree-service.js +18 -0
  58. package/dist/schedule/passes/candidates.js +17 -8
  59. package/docs/config-architecture.md +105 -0
  60. package/package.json +7 -2
@@ -1,4 +1,6 @@
1
+ import { OpencodeSdkService, OpencodeSdkServiceLive } from "./services/opencode-sdk-service.js";
1
2
  import { opencodeAgentName } from "./opencode-agent-name.js";
3
+ import { Effect } from "effect";
2
4
  //#region src/runtime/opencode-session-executor.ts
3
5
  function createOpencodeSessionRegistry() {
4
6
  return { sessions: /* @__PURE__ */ new Map() };
@@ -18,45 +20,69 @@ const EXIT_INFRA = 70;
18
20
  */
19
21
  function createOpencodeExecutor(deps) {
20
22
  return async function execute(plan, options = {}) {
21
- if (plan.type !== "opencode") throw new Error(`opencode executor cannot drive runner type '${plan.type}'`);
22
- try {
23
- return successResult(plan, await driveSession(deps, plan, options));
24
- } catch (error) {
25
- return failureResult(plan, error);
26
- }
23
+ return await Effect.runPromise(Effect.provide(executeOpencodeEffect(deps, plan, options), OpencodeSdkServiceLive));
27
24
  };
28
25
  }
26
+ function executeOpencodeEffect(deps, plan, options) {
27
+ return Effect.gen(function* () {
28
+ yield* validateOpencodePlan(plan);
29
+ return yield* executeOpencodeSession(deps, plan, options);
30
+ });
31
+ }
32
+ function executeOpencodeSession(deps, plan, options) {
33
+ return Effect.gen(function* () {
34
+ return successResult(plan, yield* driveSession(deps, plan, options));
35
+ }).pipe(Effect.catchAll((error) => Effect.succeed(failureResult(plan, error))));
36
+ }
37
+ function validateOpencodePlan(plan) {
38
+ if (plan.type === "opencode") return Effect.void;
39
+ return Effect.fail(/* @__PURE__ */ new Error(`opencode executor cannot drive runner type '${plan.type}'`));
40
+ }
29
41
  function sessionDirectory(deps, plan) {
30
42
  return plan.cwd ?? deps.directory;
31
43
  }
32
- async function driveSession(deps, plan, options) {
33
- const sessionId = await resolveSessionId(deps, plan);
34
- deps.onSession?.(plan.nodeId, sessionId);
35
- const stream = await streamEventsToOutput(deps, sessionId, plan, options);
36
- try {
37
- const data = unwrap(await deps.client.session.prompt({
38
- body: promptBody(plan),
39
- path: { id: sessionId },
40
- query: { directory: sessionDirectory(deps, plan) }
41
- }));
44
+ function driveSession(deps, plan, options) {
45
+ return Effect.gen(function* () {
46
+ const sessionId = yield* resolveSessionId(deps, plan);
47
+ recordSession(deps, plan.nodeId, sessionId);
48
+ const stream = yield* streamEventsToOutput(deps, sessionId, plan, options);
49
+ return yield* promptSessionResult(deps, plan, sessionId).pipe(Effect.ensuring(stopStream(stream)));
50
+ });
51
+ }
52
+ function promptSessionResult(deps, plan, sessionId) {
53
+ return Effect.gen(function* () {
54
+ const data = unwrap(yield* (yield* OpencodeSdkService).promptSession(deps.client, promptRequest(deps, plan, sessionId)));
42
55
  return {
43
56
  ...data.info ? { assistant: data.info } : {},
44
57
  parts: data.parts ?? [],
45
58
  sessionId
46
59
  };
47
- } finally {
48
- await stream.stop();
49
- }
60
+ });
61
+ }
62
+ function promptRequest(deps, plan, sessionId) {
63
+ return {
64
+ body: promptBody(plan),
65
+ path: { id: sessionId },
66
+ query: { directory: sessionDirectory(deps, plan) }
67
+ };
68
+ }
69
+ function stopStream(stream) {
70
+ return Effect.tryPromise(() => stream.stop()).pipe(Effect.asVoid, Effect.catchAll(() => Effect.void));
50
71
  }
51
- async function resolveSessionId(deps, plan) {
72
+ function recordSession(deps, nodeId, sessionId) {
73
+ deps.onSession?.(nodeId, sessionId);
74
+ }
75
+ function resolveSessionId(deps, plan) {
52
76
  const existing = deps.registry.sessions.get(plan.nodeId);
53
- if (existing) return existing;
54
- const session = unwrap(await deps.client.session.create({
55
- body: { title: `moka:${plan.nodeId}` },
56
- query: { directory: plan.cwd ?? deps.directory }
57
- }));
58
- deps.registry.sessions.set(plan.nodeId, session.id);
59
- return session.id;
77
+ if (existing) return Effect.succeed(existing);
78
+ return Effect.gen(function* () {
79
+ const session = unwrap(yield* (yield* OpencodeSdkService).createSession(deps.client, {
80
+ body: { title: `moka:${plan.nodeId}` },
81
+ query: { directory: plan.cwd ?? deps.directory }
82
+ }));
83
+ deps.registry.sessions.set(plan.nodeId, session.id);
84
+ return session.id;
85
+ });
60
86
  }
61
87
  function promptBody(plan) {
62
88
  const prompt = promptText(plan);
@@ -95,24 +121,55 @@ function parseModel(model) {
95
121
  providerID: model.slice(0, slash)
96
122
  };
97
123
  }
98
- async function streamEventsToOutput(deps, sessionId, plan, options) {
99
- if (!options.onOutput) return { stop: () => Promise.resolve() };
100
- const iterator = (await deps.client.event.subscribe()).stream;
101
- const pump = (async () => {
102
- try {
103
- for await (const event of iterator) forwardEvent(event, sessionId, plan, options);
104
- } catch (error) {
105
- options.onOutput?.({
106
- chunk: `opencode event stream dropped: ${errorMessage(error)}\n`,
107
- nodeId: plan.nodeId,
108
- stream: "stderr"
109
- });
124
+ function streamEventsToOutput(deps, sessionId, plan, options) {
125
+ if (!options.onOutput) return Effect.succeed({ stop: () => Promise.resolve() });
126
+ return Effect.gen(function* () {
127
+ const iterator = (yield* (yield* OpencodeSdkService).subscribeEvents(deps.client)).stream;
128
+ const pump = Effect.runPromise(pumpEvents(iterator, sessionId, plan, options));
129
+ return { stop: () => stopIterator(iterator, pump) };
130
+ });
131
+ }
132
+ function pumpEvents(iterator, sessionId, plan, options) {
133
+ return Effect.gen(function* () {
134
+ let done = false;
135
+ while (!done) {
136
+ const next = yield* readNextEvent(iterator);
137
+ done = next.done === true;
138
+ if (!done) forwardEvent(next.value, sessionId, plan, options);
110
139
  }
111
- })();
112
- return { stop: async () => {
113
- await iterator.return?.(void 0);
114
- await pump;
115
- } };
140
+ }).pipe(Effect.catchAll((error) => reportStreamDrop(error, plan, options)));
141
+ }
142
+ function readNextEvent(iterator) {
143
+ return Effect.tryPromise({
144
+ catch: (error) => error,
145
+ try: () => iterator.next()
146
+ });
147
+ }
148
+ function reportStreamDrop(error, plan, options) {
149
+ return Effect.sync(() => {
150
+ options.onOutput?.({
151
+ chunk: `opencode event stream dropped: ${errorMessage(error)}\n`,
152
+ nodeId: plan.nodeId,
153
+ stream: "stderr"
154
+ });
155
+ });
156
+ }
157
+ function stopIterator(iterator, pump) {
158
+ return Effect.runPromise(stopIteratorEffect(iterator, pump));
159
+ }
160
+ function stopIteratorEffect(iterator, pump) {
161
+ return Effect.gen(function* () {
162
+ yield* requestIteratorReturn(iterator);
163
+ yield* Effect.tryPromise(() => pump).pipe(Effect.catchAll(() => Effect.void));
164
+ });
165
+ }
166
+ function requestIteratorReturn(iterator) {
167
+ const returnIterator = iterator.return;
168
+ if (!returnIterator) return Effect.void;
169
+ return Effect.tryPromise({
170
+ catch: (error) => error,
171
+ try: () => returnIterator.call(iterator, void 0)
172
+ }).pipe(Effect.asVoid, Effect.catchAll(() => Effect.void));
116
173
  }
117
174
  function forwardEvent(event, sessionId, plan, options) {
118
175
  const forwarded = eventChunk(event, sessionId);
@@ -1,60 +1,116 @@
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 { configUsesOpencode, leaseOpencodeRuntime } from "../opencode-runtime.js";
4
+ import { WorktreeService, WorktreeServiceLive } from "../services/worktree-service.js";
5
+ import { Effect } from "effect";
5
6
  //#region src/runtime/parallel-node/parallel-node.ts
6
- async function executeParallelNode(node, context, runtime) {
7
+ function executeParallelNode(node, context, runtime) {
8
+ return Effect.runPromise(Effect.provide(parallelNodeProgram(node, context, runtime), WorktreeServiceLive));
9
+ }
10
+ function parallelNodeProgram(node, context, runtime) {
7
11
  const children = node.children ?? [];
8
- if (children.length === 0) return {
12
+ if (children.length === 0) return Effect.succeed({
9
13
  evidence: [`parallel node '${node.id}' has no children`],
10
14
  exitCode: 1,
11
15
  output: ""
16
+ });
17
+ return Effect.gen(function* () {
18
+ yield* gcStaleWorktrees(context);
19
+ const failFast = context.plan.execution.failFast;
20
+ const linkedAbort = createLinkedAbortController(context.signal);
21
+ const childContext = createParallelChildContext(context, node.id, children, failFast ? linkedAbort.controller.signal : context.signal);
22
+ const gate = failFast ? makeFailFastGate(linkedAbort.controller) : void 0;
23
+ for (const child of children) runtime.markNodeReady(childContext, child.id);
24
+ const settled = yield* runAllChildren(children, childContext, runtime, yield* makeCategorySemaphores(childContext), gate).pipe(Effect.ensuring(Effect.sync(linkedAbort.cleanup)));
25
+ return aggregateParallelResult(node.id, children, settled);
26
+ });
27
+ }
28
+ function makeFailFastGate(controller) {
29
+ return {
30
+ abort: () => controller.abort(),
31
+ aborted: () => controller.signal.aborted
12
32
  };
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
- }
33
+ }
34
+ function aggregateParallelResult(nodeId, children, settled) {
35
+ const results = settled.filter((result) => result !== void 0);
36
+ const failed = results.filter((result) => result.status === "failed");
37
+ return {
38
+ evidence: parallelEvidence(nodeId, results, failed),
39
+ exitCode: failed.length > 0 ? 1 : 0,
40
+ output: parallelOutput(children, results)
41
+ };
42
+ }
43
+ function runAllChildren(children, context, runtime, caps, gate) {
44
+ return Effect.forEach(children, (child) => runChildCapped(child, context, runtime, caps, gate), { concurrency: context.maxParallelNodes ?? "unbounded" });
45
+ }
46
+ function runChildCapped(child, context, runtime, caps, gate) {
47
+ return Effect.gen(function* () {
48
+ if (gate?.aborted()) return;
49
+ const result = yield* withCategoryCap(caps, child.id, context, runChildInWorktree(child, context, runtime));
50
+ if (gate && result.status === "failed") gate.abort();
51
+ return result;
52
+ });
53
+ }
54
+ function withCategoryCap(caps, childId, context, effect) {
55
+ const category = childCategory(childId, context.config.token_budget?.fan_out_width);
56
+ const semaphore = category ? caps.get(category) : void 0;
57
+ return semaphore ? semaphore.withPermits(1)(effect) : effect;
58
+ }
59
+ function makeCategorySemaphores(context) {
60
+ const fanOut = context.config.token_budget?.fan_out_width;
61
+ if (!fanOut) return Effect.succeed(/* @__PURE__ */ new Map());
62
+ return Effect.gen(function* () {
63
+ const caps = /* @__PURE__ */ new Map();
64
+ for (const [category, permits] of Object.entries(fanOut.by_category)) caps.set(category, yield* Effect.makeSemaphore(permits));
65
+ return caps;
66
+ });
27
67
  }
28
68
  function gcStaleWorktrees(context) {
29
- if (context.config.parallel_worktrees?.enabled) gcParallelWorktrees(context.worktreePath);
69
+ return Effect.gen(function* () {
70
+ if (context.config.parallel_worktrees?.enabled) yield* (yield* WorktreeService).gc(context.worktreePath);
71
+ });
30
72
  }
31
73
  /**
32
74
  * 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.
75
+ * concurrent candidate edits can't collide. The worktree lease is acquired and
76
+ * released as an Effect-scoped resource (released on success, failure, or
77
+ * interruption); release retains dirty/unpushed work for downstream selection.
37
78
  */
38
79
  function runChildInWorktree(child, context, runtime) {
39
- return context.config.parallel_worktrees?.enabled ? runInLease(child, context, runtime, createChildLease(child, context)) : runtime.executeNode(child, context);
80
+ if (!context.config.parallel_worktrees?.enabled) return executeChild(child, context, runtime);
81
+ return Effect.gen(function* () {
82
+ const worktree = yield* WorktreeService;
83
+ return yield* Effect.acquireUseRelease(worktree.createChild(childLeaseOptions(child, context)), (lease) => runChildWithWorktreeLease(child, context, runtime, lease.path), (lease) => Effect.sync(() => lease.release()));
84
+ });
85
+ }
86
+ function executeChild(child, context, runtime) {
87
+ return Effect.tryPromise(() => runtime.executeNode(child, context)).pipe(Effect.orDie);
40
88
  }
41
- function createChildLease(child, context) {
42
- return createChildWorktree({
89
+ function runChildWithWorktreeLease(child, context, runtime, worktreePath) {
90
+ const childContext = {
91
+ ...context,
92
+ worktreePath
93
+ };
94
+ if (!configUsesOpencode(context.config)) return executeChild(child, childContext, runtime);
95
+ return Effect.acquireUseRelease(leaseChildOpencodeRuntime(context, worktreePath), (childRuntime) => executeChild(child, {
96
+ ...childContext,
97
+ executor: childRuntime.executor
98
+ }, runtime), (childRuntime) => Effect.tryPromise(() => childRuntime.release()).pipe(Effect.orDie));
99
+ }
100
+ function leaseChildOpencodeRuntime(context, worktreePath) {
101
+ return Effect.tryPromise(() => leaseOpencodeRuntime({
102
+ config: context.config,
103
+ ...context.signal ? { signal: context.signal } : {},
104
+ worktreePath
105
+ })).pipe(Effect.orDie);
106
+ }
107
+ function childLeaseOptions(child, context) {
108
+ return {
43
109
  childNodeId: child.id,
44
110
  parentNodeId: context.parentParallelNodeId ?? "parallel",
45
111
  repoRoot: context.worktreePath,
46
112
  ...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
- }
113
+ };
58
114
  }
59
115
  function createParallelChildContext(context, parentNodeId, children, signal) {
60
116
  return {
@@ -94,44 +150,6 @@ function createLinkedAbortController(signal) {
94
150
  function childCategory(childId, fanOut) {
95
151
  return fanOut ? Object.keys(fanOut.by_category).find((category) => childId.includes(category)) : void 0;
96
152
  }
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
153
  function parallelEvidence(nodeId, results, failed) {
136
154
  if (failed.length === 0) return [`parallel node '${nodeId}' completed ${results.length} child nodes`];
137
155
  return [`parallel node '${nodeId}' failed with ${failed.length} failed child nodes`, ...failed.flatMap((result) => result.evidence)];
@@ -1,5 +1,5 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
3
  import { execFileSync } from "node:child_process";
4
4
  //#region src/runtime/parallel-worktrees/parallel-worktrees.ts
5
5
  /**
@@ -13,6 +13,50 @@ import { execFileSync } from "node:child_process";
13
13
  const WORKTREE_ROOT = ".pipeline/worktrees";
14
14
  const REGISTRY_DIR = join(WORKTREE_ROOT, "registry");
15
15
  const OWNER = "oisin-pipeline";
16
+ const GENERATED_WORKTREE_RESOURCES = [join(".opencode", "agents"), join(".opencode", "command")];
17
+ function provisionGeneratedResources(repoRoot, worktreePath) {
18
+ for (const relativePath of GENERATED_WORKTREE_RESOURCES) {
19
+ const source = join(repoRoot, relativePath);
20
+ const target = join(worktreePath, relativePath);
21
+ if (existsSync(source) && !existsSync(target)) cpSync(source, target, { recursive: true });
22
+ }
23
+ }
24
+ function childWorktreeRelPath(runId, parentNodeId, childNodeId) {
25
+ return join(WORKTREE_ROOT, "trees", sanitize(runId ?? "local"), sanitize(parentNodeId), sanitize(childNodeId));
26
+ }
27
+ function changedWorktreeFiles(worktreePath) {
28
+ const modified = git(worktreePath, [
29
+ "diff",
30
+ "--name-only",
31
+ "HEAD"
32
+ ]);
33
+ const untracked = git(worktreePath, [
34
+ "ls-files",
35
+ "--others",
36
+ "--exclude-standard"
37
+ ]);
38
+ return [...modified.split("\n"), ...untracked.split("\n")].filter(Boolean);
39
+ }
40
+ function copyFileInto(fromRoot, toRoot, relativePath) {
41
+ const source = join(fromRoot, relativePath);
42
+ if (!existsSync(source)) return;
43
+ const dest = join(toRoot, relativePath);
44
+ mkdirSync(dirname(dest), { recursive: true });
45
+ cpSync(source, dest, { recursive: true });
46
+ }
47
+ /**
48
+ * PIPE-83.14: promote a best-of-N winner's edits from its (retained) worktree
49
+ * back into the main worktree, so downstream nodes (tests, verification) see the
50
+ * selected candidate's changes. Copies modified + untracked files; no-op if the
51
+ * worktree is gone. Returns the promoted file paths.
52
+ */
53
+ function promoteWorktreeChanges(repoRoot, runId, parentNodeId, childNodeId) {
54
+ const worktreePath = join(repoRoot, childWorktreeRelPath(runId, parentNodeId, childNodeId));
55
+ if (!existsSync(worktreePath)) return [];
56
+ const files = changedWorktreeFiles(worktreePath);
57
+ for (const relativePath of files) copyFileInto(worktreePath, repoRoot, relativePath);
58
+ return files;
59
+ }
16
60
  function git(cwd, args) {
17
61
  return execFileSync("git", args, {
18
62
  cwd,
@@ -33,7 +77,7 @@ function createChildWorktree(opts) {
33
77
  const parentSeg = sanitize(opts.parentNodeId);
34
78
  const childSeg = sanitize(opts.childNodeId);
35
79
  const baseSha = git(opts.repoRoot, ["rev-parse", "HEAD"]);
36
- const relPath = join(WORKTREE_ROOT, "trees", runSeg, parentSeg, childSeg);
80
+ const relPath = childWorktreeRelPath(opts.runId, opts.parentNodeId, opts.childNodeId);
37
81
  const absPath = join(opts.repoRoot, relPath);
38
82
  const branch = `pipeline/worktrees/${runSeg}/${parentSeg}/${childSeg}`;
39
83
  const leaseId = `${runSeg}__${parentSeg}__${childSeg}`;
@@ -61,6 +105,7 @@ function createChildWorktree(opts) {
61
105
  absPath,
62
106
  baseSha
63
107
  ]);
108
+ provisionGeneratedResources(opts.repoRoot, absPath);
64
109
  writeManifest(manifestPath, {
65
110
  ...manifest,
66
111
  state: "active"
@@ -129,4 +174,4 @@ function gcParallelWorktrees(repoRoot) {
129
174
  return results;
130
175
  }
131
176
  //#endregion
132
- export { createChildWorktree, gcParallelWorktrees };
177
+ export { createChildWorktree, gcParallelWorktrees, promoteWorktreeChanges };
@@ -0,0 +1,21 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ //#region src/runtime/run-journal.ts
4
+ function passedOnly(results) {
5
+ return results.filter((result) => result.status === "passed");
6
+ }
7
+ function readJournalFile(path) {
8
+ if (!existsSync(path)) return [];
9
+ return readFileSync(path, "utf8").split("\n").filter((line) => line.trim().length > 0).map((line) => JSON.parse(line));
10
+ }
11
+ function fileRunJournal(path) {
12
+ return {
13
+ record: (result) => {
14
+ mkdirSync(dirname(path), { recursive: true });
15
+ appendFileSync(path, `${JSON.stringify(result)}\n`);
16
+ },
17
+ resumeCompleted: () => passedOnly(readJournalFile(path))
18
+ };
19
+ }
20
+ //#endregion
21
+ export { fileRunJournal };