@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.
- package/defaults/pipeline.yaml +25 -0
- package/dist/argo-graph.js +7 -7
- package/dist/argo-submit.d.ts +2 -4
- package/dist/argo-submit.js +80 -80
- package/dist/bench/eval-report.js +27 -0
- package/dist/cli/program.js +19 -3
- package/dist/cluster-doctor.js +89 -101
- package/dist/commands/bench-command.js +18 -0
- package/dist/config/defaults.js +9 -19
- package/dist/config/load.js +47 -37
- package/dist/config/schemas.d.ts +24 -7
- package/dist/config/schemas.js +20 -7
- package/dist/context/repo-map.js +203 -0
- package/dist/install-commands/opencode.js +10 -1
- package/dist/mcp/gateway-error.js +15 -0
- package/dist/mcp/gateway.js +119 -220
- package/dist/moka-global-config.js +20 -20
- package/dist/moka-submit.d.ts +6 -6
- package/dist/pipeline-init.js +18 -12
- package/dist/pipeline-runtime.js +592 -372
- package/dist/planning/compile.d.ts +8 -3
- package/dist/planning/compile.js +7 -7
- package/dist/planning/generate.d.ts +6 -1
- package/dist/planning/generate.js +29 -7
- package/dist/run-state/git-refs.js +124 -94
- package/dist/runner-command-contract.d.ts +6 -1
- package/dist/runner-command-contract.js +6 -5
- package/dist/runner-event-schema.d.ts +6 -6
- package/dist/runner-event-sink.js +37 -68
- package/dist/runner.d.ts +6 -1
- package/dist/runner.js +3 -3
- package/dist/runtime/agent-node/agent-node.js +218 -159
- package/dist/runtime/changed-files/changed-files.js +15 -27
- package/dist/runtime/changed-files/index.js +2 -0
- package/dist/runtime/drain-merge/drain-merge.js +124 -82
- package/dist/runtime/gates/gates.js +45 -27
- package/dist/runtime/hooks/hooks.js +74 -29
- package/dist/runtime/local-scheduler.js +45 -0
- package/dist/runtime/opencode-server.js +32 -23
- package/dist/runtime/opencode-session-executor.js +101 -44
- package/dist/runtime/parallel-node/parallel-node.js +93 -75
- package/dist/runtime/parallel-worktrees/parallel-worktrees.js +49 -4
- package/dist/runtime/run-journal.js +21 -0
- package/dist/runtime/scheduler.js +122 -93
- package/dist/runtime/select-candidate/select-candidate.js +52 -24
- package/dist/runtime/services/agent-node-runtime-service.js +15 -0
- package/dist/runtime/services/command-executor-service.js +8 -0
- package/dist/runtime/services/config-io-service.js +42 -0
- package/dist/runtime/services/drain-merge-git-service.js +10 -0
- package/dist/runtime/services/git-porcelain-service.js +38 -0
- package/dist/runtime/services/kubernetes-argo-service.d.ts +13 -0
- package/dist/runtime/services/kubernetes-argo-service.js +81 -0
- package/dist/runtime/services/mcp-gateway-service.js +184 -0
- package/dist/runtime/services/opencode-sdk-service.js +27 -0
- package/dist/runtime/services/runner-event-sink-http-service.js +80 -0
- package/dist/runtime/services/select-candidate-service.js +13 -0
- package/dist/runtime/services/worktree-service.js +18 -0
- package/dist/schedule/passes/candidates.js +17 -8
- package/docs/config-architecture.md +105 -0
- 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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
}
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
99
|
-
if (!options.onOutput) return { stop: () => Promise.resolve() };
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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 {
|
|
4
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
|
|
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
|
|
42
|
-
|
|
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 =
|
|
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 };
|