@viraatdas/rudder 1.0.74 → 1.1.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.
- package/dist/auth.d.ts +2 -0
- package/dist/auth.js +22 -1
- package/dist/auth.js.map +1 -1
- package/dist/backends.js +88 -1
- package/dist/backends.js.map +1 -1
- package/dist/board/board.css +1 -0
- package/dist/board/board.js +2 -0
- package/dist/board/daemon.d.ts +21 -0
- package/dist/board/daemon.js +838 -0
- package/dist/board/daemon.js.map +1 -0
- package/dist/brain.d.ts +9 -0
- package/dist/brain.js +32 -5
- package/dist/brain.js.map +1 -1
- package/dist/bus.d.ts +9 -0
- package/dist/bus.js +23 -0
- package/dist/bus.js.map +1 -0
- package/dist/daemon.d.ts +21 -0
- package/dist/daemon.js +141 -0
- package/dist/daemon.js.map +1 -0
- package/dist/git.d.ts +5 -16
- package/dist/git.js +43 -50
- package/dist/git.js.map +1 -1
- package/dist/goal.d.ts +30 -0
- package/dist/goal.js +75 -0
- package/dist/goal.js.map +1 -0
- package/dist/graph.d.ts +56 -0
- package/dist/graph.js +213 -0
- package/dist/graph.js.map +1 -0
- package/dist/jj.d.ts +121 -0
- package/dist/jj.js +524 -0
- package/dist/jj.js.map +1 -0
- package/dist/main.js +171 -6
- package/dist/main.js.map +1 -1
- package/dist/native/rudder-native +0 -0
- package/dist/planner.d.ts +27 -0
- package/dist/planner.js +540 -0
- package/dist/planner.js.map +1 -0
- package/dist/run-manager.d.ts +7 -0
- package/dist/run-manager.js +98 -38
- package/dist/run-manager.js.map +1 -1
- package/dist/scheduler.d.ts +124 -0
- package/dist/scheduler.js +849 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/state.d.ts +16 -1
- package/dist/state.js +101 -0
- package/dist/state.js.map +1 -1
- package/dist/surfaces.d.ts +23 -0
- package/dist/surfaces.js +196 -0
- package/dist/surfaces.js.map +1 -0
- package/dist/task-summary.d.ts +18 -0
- package/dist/task-summary.js +132 -0
- package/dist/task-summary.js.map +1 -1
- package/dist/types.d.ts +198 -1
- package/dist/types.js +1 -1
- package/dist/types.js.map +1 -1
- package/dist/util.js +1 -0
- package/dist/util.js.map +1 -1
- package/package.json +9 -2
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
import { createEmptyChange, createNodeWorkspace, currentJjChangeId, currentOpId, exportToGit, jjDiff, listConflicts, mergeNode, } from "./jj.js";
|
|
2
|
+
import { dependents, frontier as graphFrontier, hardParents, judgeParents, newEdgeId, newNodeId, projectNodeStatus, readGraph, readyNodes, updateGraph, } from "./graph.js";
|
|
3
|
+
import { reconcile } from "./planner.js";
|
|
4
|
+
import { DEFAULT_SUCCESS, deriveGoal, formatGoalPrompt } from "./goal.js";
|
|
5
|
+
import { createRunRecord, loadConfig, loadRunRecord, runDir } from "./state.js";
|
|
6
|
+
import { spawnWorker } from "./run-manager.js";
|
|
7
|
+
import { ensureDecisionsFile, renderLiveRudderMd } from "./surfaces.js";
|
|
8
|
+
import { currentBranch } from "./git.js";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { newRunId, nowIso, writeJson } from "./util.js";
|
|
11
|
+
// ===========================================================================
|
|
12
|
+
// PURE decision functions. No IO, no spawning: these are the unit-tested core
|
|
13
|
+
// of the scheduler so tests never launch a worker.
|
|
14
|
+
// ===========================================================================
|
|
15
|
+
/**
|
|
16
|
+
* The set of nodes to launch this tick: ready nodes (planned + all hard parents
|
|
17
|
+
* merged) sorted oldest-first, sliced to the remaining parallel capacity.
|
|
18
|
+
*/
|
|
19
|
+
export function selectLaunchable(graph, runningCount, maxParallel) {
|
|
20
|
+
const capacity = Math.max(0, maxParallel - runningCount);
|
|
21
|
+
if (capacity === 0) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
return readyNodes(graph)
|
|
25
|
+
.slice()
|
|
26
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
27
|
+
.slice(0, capacity);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* When a node fails or is cancelled, the hard dependents (direct + transitive)
|
|
31
|
+
* that can no longer ever start must become blocked. Soft dependents are
|
|
32
|
+
* unaffected (they only ever received the parent's diff as context).
|
|
33
|
+
*/
|
|
34
|
+
export function computeBlocked(graph, failedOrCancelledNodeId) {
|
|
35
|
+
const blocked = new Set();
|
|
36
|
+
const queue = [failedOrCancelledNodeId];
|
|
37
|
+
while (queue.length) {
|
|
38
|
+
const current = queue.shift();
|
|
39
|
+
for (const childId of dependents(graph, current)) {
|
|
40
|
+
const child = graph.nodes[childId];
|
|
41
|
+
if (!child) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
// Only hard dependents are blocked; the edge from `current` must be hard.
|
|
45
|
+
const isHardChild = hardParents(graph, childId).includes(current);
|
|
46
|
+
if (!isHardChild) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (child.status === "merged" || blocked.has(childId)) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
blocked.add(childId);
|
|
53
|
+
queue.push(childId);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [...blocked];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Build the resolver-agent prompt. Pure: given the conflicted merge change, the
|
|
60
|
+
* two merged titles, and the conflicted files, it instructs the worker to open
|
|
61
|
+
* each file, resolve preserving BOTH intents, and remove all conflict markers.
|
|
62
|
+
*/
|
|
63
|
+
export function buildResolverPrompt(input) {
|
|
64
|
+
const files = input.conflictedFiles.length ? input.conflictedFiles.join(", ") : "(none reported)";
|
|
65
|
+
return formatGoalPrompt({
|
|
66
|
+
goal: `resolve the merge conflict between ${input.intoTitle} and ${input.nodeTitle}, preserving both intents`,
|
|
67
|
+
success: "no conflict markers remain and `jj resolve --list` is empty",
|
|
68
|
+
body: [
|
|
69
|
+
`You are resolving a merge conflict at jj change ${input.mergeChangeId}, a merge of ${input.intoTitle} and ${input.nodeTitle}.`,
|
|
70
|
+
`These files have conflict markers: ${files}.`,
|
|
71
|
+
"Open each, resolve the conflict preserving BOTH intents, and remove all conflict markers.",
|
|
72
|
+
"Do not touch unrelated files.",
|
|
73
|
+
"When done, the merge must have no remaining conflicts.",
|
|
74
|
+
].join(" "),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* PURE decision: should a completed resolver run finalize its original node?
|
|
79
|
+
* Yes when the run is a resolver (has resolverFor) and no conflicts remain in
|
|
80
|
+
* its workspace. The IO (listConflicts) is supplied by the caller so this stays
|
|
81
|
+
* unit-testable without jj.
|
|
82
|
+
*/
|
|
83
|
+
export function resolverShouldFinalize(input) {
|
|
84
|
+
return Boolean(input.resolverFor) && input.remainingConflicts.length === 0;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Soft edges whose parent has merged but whose diff has not yet been delivered
|
|
88
|
+
* to the child. The scheduler pipes each parent's diff into the child context.
|
|
89
|
+
*/
|
|
90
|
+
export function undeliveredSoftEdges(graph) {
|
|
91
|
+
return Object.values(graph.edges).filter((edge) => edge.type === "soft" && !edge.delivered && graph.nodes[edge.from]?.status === "merged");
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Judge edges (fan-out-and-judge) whose variant has REACHED review (or merged)
|
|
95
|
+
* but whose diff has not yet been delivered to the judge node. Like
|
|
96
|
+
* undeliveredSoftEdges but gated on review rather than merge, since the judge
|
|
97
|
+
* compares the variants' finished work before any of them lands.
|
|
98
|
+
*/
|
|
99
|
+
export function undeliveredJudgeEdges(graph) {
|
|
100
|
+
return Object.values(graph.edges).filter((edge) => {
|
|
101
|
+
if (edge.type !== "judge" || edge.delivered) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
const status = graph.nodes[edge.from]?.status;
|
|
105
|
+
return status === "review" || status === "merged";
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Whether a node is a fan-out variant: the `from` of at least one judge edge.
|
|
110
|
+
* Such a node is never merged on its own; the judge node it feeds produces the
|
|
111
|
+
* winning implementation and merges that. Used to skip variants in auto-merge.
|
|
112
|
+
*/
|
|
113
|
+
export function isJudgedVariant(graph, id) {
|
|
114
|
+
return Object.values(graph.edges).some((edge) => edge.type === "judge" && edge.from === id);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Sum the node-attributed token usage across the whole graph (running +
|
|
118
|
+
* terminal + merged). This is the number the budget cap compares against.
|
|
119
|
+
*/
|
|
120
|
+
export function sumNodeTokens(graph) {
|
|
121
|
+
let total = 0;
|
|
122
|
+
for (const node of Object.values(graph.nodes)) {
|
|
123
|
+
if (node.tokens) {
|
|
124
|
+
total += node.tokens.input + node.tokens.output;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return total;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Pure budget gate: true when a positive maxTokens cap is set and the spent
|
|
131
|
+
* total has reached it. The scheduler HOLDS new launches when this is true.
|
|
132
|
+
*/
|
|
133
|
+
export function isOverBudget(tokensSpent, maxTokens) {
|
|
134
|
+
return typeof maxTokens === "number" && maxTokens > 0 && tokensSpent >= maxTokens;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Whether `next` token usage is a larger total than `prev` (or prev is unset).
|
|
138
|
+
* Backends report cumulative usage, so the largest value seen is the truth; we
|
|
139
|
+
* only ever raise node.tokens, never lower it.
|
|
140
|
+
*/
|
|
141
|
+
export function tokensNewer(prev, next) {
|
|
142
|
+
if (!prev) {
|
|
143
|
+
return next.input + next.output > 0;
|
|
144
|
+
}
|
|
145
|
+
return next.input + next.output > prev.input + prev.output;
|
|
146
|
+
}
|
|
147
|
+
// ===========================================================================
|
|
148
|
+
// SIDE-EFFECTFUL orchestration. These mutate graph.json (always via updateGraph,
|
|
149
|
+
// the daemon-only writer), spawn detached workers, and call jj.
|
|
150
|
+
// ===========================================================================
|
|
151
|
+
/**
|
|
152
|
+
* Launch a ready node: create its jj workspace off the scaffolded change (or the
|
|
153
|
+
* integration trunk), create the run record, flip status to "running" INSIDE the
|
|
154
|
+
* updateGraph transaction (the double-launch guard), then spawn the detached
|
|
155
|
+
* worker and publish schedule.launched.
|
|
156
|
+
*/
|
|
157
|
+
export async function launchNode(repoRoot, graph, node, bus) {
|
|
158
|
+
// Determine the base change: the node's scaffolded empty change if present,
|
|
159
|
+
// else the integration trunk, else `@`.
|
|
160
|
+
const baseChange = node.jjChangeId || graph.integrationChangeId || (await currentJjChangeId(repoRoot)) || "@";
|
|
161
|
+
const workspace = await createNodeWorkspace({
|
|
162
|
+
repoRoot,
|
|
163
|
+
nodeId: node.id,
|
|
164
|
+
atChangeId: baseChange,
|
|
165
|
+
task: node.title,
|
|
166
|
+
});
|
|
167
|
+
// Seed the shared-knowledge surface into the workspace if absent so the agent
|
|
168
|
+
// edits it in its jj workspace (jj merges concurrent edits on fan-in).
|
|
169
|
+
await ensureDecisionsFile(workspace.path).catch(() => undefined);
|
|
170
|
+
const jjChangeId = (await currentJjChangeId(workspace.path)) || node.jjChangeId;
|
|
171
|
+
const run = await createRunRecord({
|
|
172
|
+
repoRoot,
|
|
173
|
+
task: node.prompt,
|
|
174
|
+
backend: node.backend,
|
|
175
|
+
model: node.model,
|
|
176
|
+
effort: node.effort,
|
|
177
|
+
targetBranch: baseChange,
|
|
178
|
+
baseCommit: baseChange,
|
|
179
|
+
vcs: "jj",
|
|
180
|
+
useWorktree: true,
|
|
181
|
+
worktreeWorkspaceName: workspace.workspaceName,
|
|
182
|
+
worktreeJjChangeId: jjChangeId,
|
|
183
|
+
worktreePath: workspace.path,
|
|
184
|
+
});
|
|
185
|
+
// Flip status -> running in the same transaction so a concurrent tick cannot
|
|
186
|
+
// re-select this node and double-launch it.
|
|
187
|
+
await updateGraph(repoRoot, (g) => {
|
|
188
|
+
const current = g.nodes[node.id];
|
|
189
|
+
if (!current) {
|
|
190
|
+
return g;
|
|
191
|
+
}
|
|
192
|
+
current.runId = run.id;
|
|
193
|
+
current.status = "running";
|
|
194
|
+
current.worktree = { path: workspace.path, workspaceName: workspace.workspaceName };
|
|
195
|
+
if (jjChangeId) {
|
|
196
|
+
current.jjChangeId = jjChangeId;
|
|
197
|
+
}
|
|
198
|
+
current.updatedAt = nowIso();
|
|
199
|
+
return g;
|
|
200
|
+
});
|
|
201
|
+
spawnWorker(repoRoot, run.id);
|
|
202
|
+
bus.publish({
|
|
203
|
+
ts: nowIso(),
|
|
204
|
+
runId: run.id,
|
|
205
|
+
nodeId: node.id,
|
|
206
|
+
type: "schedule.launched",
|
|
207
|
+
message: `Launched ${node.id} (${node.title})`,
|
|
208
|
+
data: { workspace: workspace.path },
|
|
209
|
+
});
|
|
210
|
+
bus.publish({ ts: nowIso(), runId: run.id, nodeId: node.id, type: "node.running" });
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Merge a node's change into the integration trunk. Captures the op id first.
|
|
214
|
+
* Clean -> node "merged" + advance graph.integrationChangeId + exportToGit +
|
|
215
|
+
* merge.merged. Conflict -> node "blocked" + record operationId/conflictedFiles
|
|
216
|
+
* + merge.conflict. The resolver-agent spawn is Phase 5; for now hold it blocked.
|
|
217
|
+
*/
|
|
218
|
+
export async function mergeNodeIntoIntegration(repoRoot, node, bus) {
|
|
219
|
+
const nodeChangeId = node.jjChangeId || (node.worktree?.path ? await currentJjChangeId(node.worktree.path) : "");
|
|
220
|
+
if (!nodeChangeId) {
|
|
221
|
+
bus.publish({
|
|
222
|
+
ts: nowIso(),
|
|
223
|
+
runId: node.runId ?? node.id,
|
|
224
|
+
nodeId: node.id,
|
|
225
|
+
type: "merge.conflict",
|
|
226
|
+
message: `Could not determine jj change id for ${node.id}.`,
|
|
227
|
+
});
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const graph = await readGraph(repoRoot);
|
|
231
|
+
const intoChange = graph.integrationChangeId || (await currentJjChangeId(repoRoot)) || "@";
|
|
232
|
+
const opIdBefore = await currentOpId(repoRoot);
|
|
233
|
+
bus.publish({
|
|
234
|
+
ts: nowIso(),
|
|
235
|
+
runId: node.runId ?? node.id,
|
|
236
|
+
nodeId: node.id,
|
|
237
|
+
type: "merge.attempt",
|
|
238
|
+
message: `Merging ${node.id} into integration`,
|
|
239
|
+
...(opIdBefore ? { data: { operationId: opIdBefore } } : {}),
|
|
240
|
+
});
|
|
241
|
+
const result = await mergeNode({
|
|
242
|
+
repoRoot,
|
|
243
|
+
nodeChangeId,
|
|
244
|
+
intoChangeId: intoChange,
|
|
245
|
+
message: `rudder: ${node.title.slice(0, 72)}`,
|
|
246
|
+
});
|
|
247
|
+
if (result.conflictedFiles.length === 0 && result.mergeChangeId) {
|
|
248
|
+
// If this is a judge node (fan-out-and-judge), its variant parents lose:
|
|
249
|
+
// the judge produced the winning implementation and merged it. The variants
|
|
250
|
+
// are never merged; they end in "review" flagged supersededBy the judge so
|
|
251
|
+
// the board can render them as superseded.
|
|
252
|
+
const supersededVariants = judgeParents(graph, node.id);
|
|
253
|
+
await updateGraph(repoRoot, (g) => {
|
|
254
|
+
const current = g.nodes[node.id];
|
|
255
|
+
if (current) {
|
|
256
|
+
current.status = "merged";
|
|
257
|
+
current.reviewState = "approved";
|
|
258
|
+
current.updatedAt = nowIso();
|
|
259
|
+
}
|
|
260
|
+
for (const variantId of supersededVariants) {
|
|
261
|
+
const variant = g.nodes[variantId];
|
|
262
|
+
if (variant && variant.status !== "merged") {
|
|
263
|
+
variant.supersededBy = node.id;
|
|
264
|
+
variant.updatedAt = nowIso();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
g.integrationChangeId = result.mergeChangeId;
|
|
268
|
+
return g;
|
|
269
|
+
});
|
|
270
|
+
await exportToGit(repoRoot);
|
|
271
|
+
bus.publish({
|
|
272
|
+
ts: nowIso(),
|
|
273
|
+
runId: node.runId ?? node.id,
|
|
274
|
+
nodeId: node.id,
|
|
275
|
+
type: "merge.merged",
|
|
276
|
+
message: `Merged ${node.id}`,
|
|
277
|
+
data: { mergeChangeId: result.mergeChangeId, operationId: result.opId },
|
|
278
|
+
});
|
|
279
|
+
bus.publish({ ts: nowIso(), runId: node.runId ?? node.id, nodeId: node.id, type: "node.merged" });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
// Conflict-as-data: the merge change exists and records the conflict. Hold the
|
|
283
|
+
// node blocked and spawn a resolver-agent node whose working copy IS the
|
|
284
|
+
// conflicted merge change, unless we have already auto-retried once (cap at 1).
|
|
285
|
+
bus.publish({
|
|
286
|
+
ts: nowIso(),
|
|
287
|
+
runId: node.runId ?? node.id,
|
|
288
|
+
nodeId: node.id,
|
|
289
|
+
type: "merge.conflict",
|
|
290
|
+
message: `Merge conflict for ${node.id}`,
|
|
291
|
+
data: { mergeChangeId: result.mergeChangeId, operationId: result.opId, conflictedFiles: result.conflictedFiles },
|
|
292
|
+
});
|
|
293
|
+
bus.publish({ ts: nowIso(), runId: node.runId ?? node.id, nodeId: node.id, type: "node.blocked" });
|
|
294
|
+
if (!result.mergeChangeId) {
|
|
295
|
+
await updateGraph(repoRoot, (g) => {
|
|
296
|
+
const current = g.nodes[node.id];
|
|
297
|
+
if (current) {
|
|
298
|
+
current.status = "blocked";
|
|
299
|
+
current.updatedAt = nowIso();
|
|
300
|
+
}
|
|
301
|
+
return g;
|
|
302
|
+
});
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
await spawnResolver(repoRoot, {
|
|
306
|
+
node,
|
|
307
|
+
mergeChangeId: result.mergeChangeId,
|
|
308
|
+
intoChangeId: intoChange,
|
|
309
|
+
intoTitle: intoTitleFor(graph),
|
|
310
|
+
conflictedFiles: result.conflictedFiles,
|
|
311
|
+
bus,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
function intoTitleFor(graph) {
|
|
315
|
+
// The integration trunk has no single owning node; name it for the surface.
|
|
316
|
+
return graph.integrationChangeId ? "integration trunk" : "the base change";
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Spawn a resolver-agent node at the conflicted merge change. The resolver's
|
|
320
|
+
* workspace is created at the merge change so `jj edit` materializes the
|
|
321
|
+
* conflict markers; its run record carries resolverFor = the blocked node id.
|
|
322
|
+
* The original node keeps status "blocked" with resolverRunId set. The resolver
|
|
323
|
+
* auto-retry is capped: a node that already has a resolverRunId is not respun.
|
|
324
|
+
*/
|
|
325
|
+
async function spawnResolver(repoRoot, input) {
|
|
326
|
+
const { node, bus } = input;
|
|
327
|
+
// Cap auto-retry at 1: if this node already spawned a resolver, do not loop.
|
|
328
|
+
if (node.resolverRunId) {
|
|
329
|
+
await updateGraph(repoRoot, (g) => {
|
|
330
|
+
const current = g.nodes[node.id];
|
|
331
|
+
if (current) {
|
|
332
|
+
current.status = "blocked";
|
|
333
|
+
current.updatedAt = nowIso();
|
|
334
|
+
}
|
|
335
|
+
return g;
|
|
336
|
+
});
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const resolverRunId = newRunId(`resolve ${node.title}`);
|
|
340
|
+
let workspace;
|
|
341
|
+
try {
|
|
342
|
+
workspace = await createNodeWorkspace({
|
|
343
|
+
repoRoot,
|
|
344
|
+
runId: resolverRunId,
|
|
345
|
+
atChangeId: input.mergeChangeId,
|
|
346
|
+
task: `resolve ${node.title}`,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
// jj could not materialize the merge change; hold blocked without a resolver.
|
|
351
|
+
await updateGraph(repoRoot, (g) => {
|
|
352
|
+
const current = g.nodes[node.id];
|
|
353
|
+
if (current) {
|
|
354
|
+
current.status = "blocked";
|
|
355
|
+
current.updatedAt = nowIso();
|
|
356
|
+
}
|
|
357
|
+
return g;
|
|
358
|
+
});
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const prompt = buildResolverPrompt({
|
|
362
|
+
mergeChangeId: input.mergeChangeId,
|
|
363
|
+
intoTitle: input.intoTitle,
|
|
364
|
+
nodeTitle: node.title,
|
|
365
|
+
conflictedFiles: input.conflictedFiles,
|
|
366
|
+
});
|
|
367
|
+
const run = await createRunRecord({
|
|
368
|
+
id: resolverRunId,
|
|
369
|
+
repoRoot,
|
|
370
|
+
task: prompt,
|
|
371
|
+
backend: node.backend,
|
|
372
|
+
model: node.model,
|
|
373
|
+
effort: node.effort,
|
|
374
|
+
targetBranch: input.mergeChangeId,
|
|
375
|
+
baseCommit: input.mergeChangeId,
|
|
376
|
+
vcs: "jj",
|
|
377
|
+
resolverFor: node.id,
|
|
378
|
+
useWorktree: true,
|
|
379
|
+
worktreeWorkspaceName: workspace.workspaceName,
|
|
380
|
+
worktreeJjChangeId: input.mergeChangeId,
|
|
381
|
+
worktreePath: workspace.path,
|
|
382
|
+
});
|
|
383
|
+
// Persist the resolver context for the worker (and any UI). Best-effort.
|
|
384
|
+
const context = {
|
|
385
|
+
mergeChangeId: input.mergeChangeId,
|
|
386
|
+
parentChangeIds: [input.intoChangeId, node.jjChangeId ?? ""].filter(Boolean),
|
|
387
|
+
conflictedFiles: input.conflictedFiles,
|
|
388
|
+
nodeTitle: node.title,
|
|
389
|
+
intoTitle: input.intoTitle,
|
|
390
|
+
workspacePath: workspace.path,
|
|
391
|
+
};
|
|
392
|
+
await writeJson(path.join(runDir(repoRoot, resolverRunId), "resolver.json"), context).catch(() => undefined);
|
|
393
|
+
// Hold the original node blocked and back-point at its resolver.
|
|
394
|
+
await updateGraph(repoRoot, (g) => {
|
|
395
|
+
const current = g.nodes[node.id];
|
|
396
|
+
if (current) {
|
|
397
|
+
current.status = "blocked";
|
|
398
|
+
current.resolverRunId = resolverRunId;
|
|
399
|
+
current.merge = {
|
|
400
|
+
...(current.merge ?? { status: "conflict" }),
|
|
401
|
+
status: "conflict",
|
|
402
|
+
mergeChangeId: input.mergeChangeId,
|
|
403
|
+
conflictedFiles: input.conflictedFiles,
|
|
404
|
+
};
|
|
405
|
+
current.updatedAt = nowIso();
|
|
406
|
+
}
|
|
407
|
+
return g;
|
|
408
|
+
});
|
|
409
|
+
spawnWorker(repoRoot, run.id);
|
|
410
|
+
bus.publish({
|
|
411
|
+
ts: nowIso(),
|
|
412
|
+
runId: run.id,
|
|
413
|
+
nodeId: node.id,
|
|
414
|
+
type: "resolver.spawned",
|
|
415
|
+
message: `Spawned resolver ${run.id} for ${node.id}`,
|
|
416
|
+
data: { resolverRunId: run.id, mergeChangeId: input.mergeChangeId, conflictedFiles: input.conflictedFiles },
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Deliver a parent's diff to its child as additional context, then mark the
|
|
421
|
+
* edge delivered. Used for both soft edges (parent merged) and judge edges (the
|
|
422
|
+
* variant reached review). Minimal acceptable form: record the parent diff on
|
|
423
|
+
* the child node (the worker re-reads it). Never blocks. Publishes
|
|
424
|
+
* schedule.softDelivered.
|
|
425
|
+
*/
|
|
426
|
+
export async function deliverSoftDiff(repoRoot, edge, bus) {
|
|
427
|
+
const graph = await readGraph(repoRoot);
|
|
428
|
+
const parent = graph.nodes[edge.from];
|
|
429
|
+
const child = graph.nodes[edge.to];
|
|
430
|
+
if (!parent || !child) {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const parentWorkspace = parent.worktree?.path ?? repoRoot;
|
|
434
|
+
const diff = await jjDiff(parentWorkspace).catch(() => "");
|
|
435
|
+
const isJudge = edge.type === "judge";
|
|
436
|
+
await updateGraph(repoRoot, (g) => {
|
|
437
|
+
const target = g.edges[edge.id];
|
|
438
|
+
if (target) {
|
|
439
|
+
target.delivered = true;
|
|
440
|
+
}
|
|
441
|
+
const childNode = g.nodes[edge.to];
|
|
442
|
+
if (childNode && diff.trim()) {
|
|
443
|
+
// Stash the delivered context on the child's prompt so the worker reads it
|
|
444
|
+
// when the run is created/continued. Append, do not replace, and never
|
|
445
|
+
// block on it. Judge nodes receive each variant's diff to compare.
|
|
446
|
+
const header = isJudge
|
|
447
|
+
? `\n\n--- Variant ${parent.id} (${parent.title}) diff to evaluate ---\n`
|
|
448
|
+
: `\n\n--- Context from merged sibling ${parent.id} (${parent.title}) ---\n`;
|
|
449
|
+
if (!childNode.prompt.includes(header)) {
|
|
450
|
+
childNode.prompt = `${childNode.prompt}${header}${diff.slice(0, 8000)}`;
|
|
451
|
+
}
|
|
452
|
+
childNode.updatedAt = nowIso();
|
|
453
|
+
}
|
|
454
|
+
return g;
|
|
455
|
+
});
|
|
456
|
+
bus.publish({
|
|
457
|
+
ts: nowIso(),
|
|
458
|
+
runId: child.runId ?? child.id,
|
|
459
|
+
nodeId: child.id,
|
|
460
|
+
type: "schedule.softDelivered",
|
|
461
|
+
message: isJudge
|
|
462
|
+
? `Delivered variant ${parent.id} diff to judge ${child.id}`
|
|
463
|
+
: `Delivered ${parent.id} diff to ${child.id}`,
|
|
464
|
+
data: { from: edge.from, to: edge.to, kind: edge.type },
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* One scheduler tick. Recompute each scheduled node's status from its run.json,
|
|
469
|
+
* read orchestrator config, hold if at capacity or over budget, else launch the
|
|
470
|
+
* selectable ready nodes and deliver undelivered soft diffs. A node that just
|
|
471
|
+
* reached completed (review) auto-merges when reviewGate==="auto".
|
|
472
|
+
*/
|
|
473
|
+
export async function scheduleTick(repoRoot, bus) {
|
|
474
|
+
const config = await loadConfig();
|
|
475
|
+
const orchestrator = config.orchestrator ?? { maxParallel: 3, reviewGate: "manual" };
|
|
476
|
+
const maxParallel = orchestrator.maxParallel ?? 3;
|
|
477
|
+
const budgetTokens = orchestrator.budget?.maxTokens;
|
|
478
|
+
// Recompute statuses from run.json and surface review/auto-merge transitions.
|
|
479
|
+
const graph = await readGraph(repoRoot);
|
|
480
|
+
let runningCount = 0;
|
|
481
|
+
let tokensSpent = 0;
|
|
482
|
+
const justReached = [];
|
|
483
|
+
// Token usage the backend reported on run.json that is newer than what the
|
|
484
|
+
// node currently carries; flushed into the graph so the budget sum is real.
|
|
485
|
+
const tokenUpdates = new Map();
|
|
486
|
+
for (const node of Object.values(graph.nodes)) {
|
|
487
|
+
let status = node.status;
|
|
488
|
+
let nodeTokens = node.tokens;
|
|
489
|
+
// A node held blocked with a resolver in flight is owned by that resolver,
|
|
490
|
+
// not by its original (already-completed) run. Do not re-project it back to
|
|
491
|
+
// review from the original run.json while the resolver works.
|
|
492
|
+
const resolverInFlight = node.status === "blocked" && Boolean(node.resolverRunId);
|
|
493
|
+
if (node.runId && !resolverInFlight) {
|
|
494
|
+
const run = await loadRunRecord(repoRoot, node.runId).catch(() => null);
|
|
495
|
+
status = projectNodeStatus(node, run ?? undefined);
|
|
496
|
+
// Budget accounting: copy the backend-reported run.tokens onto the node so
|
|
497
|
+
// scheduleTick's budget sum runs on real numbers (the worker captures them
|
|
498
|
+
// from claude/codex stream output). Only update when newer/larger.
|
|
499
|
+
if (run?.tokens && tokensNewer(node.tokens, run.tokens)) {
|
|
500
|
+
nodeTokens = { input: run.tokens.input, output: run.tokens.output };
|
|
501
|
+
tokenUpdates.set(node.id, nodeTokens);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// Budget is a soft cap on node-attributed tokens summed across every node
|
|
505
|
+
// that has reported usage (running + terminal + merged alike).
|
|
506
|
+
if (nodeTokens) {
|
|
507
|
+
tokensSpent += nodeTokens.input + nodeTokens.output;
|
|
508
|
+
}
|
|
509
|
+
if (status !== node.status) {
|
|
510
|
+
justReached.push({ node, status });
|
|
511
|
+
}
|
|
512
|
+
if (status === "running") {
|
|
513
|
+
runningCount += 1;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
// Persist any projected status changes (and capture review transitions) plus
|
|
517
|
+
// any freshened token counts.
|
|
518
|
+
if (justReached.length || tokenUpdates.size) {
|
|
519
|
+
await updateGraph(repoRoot, (g) => {
|
|
520
|
+
for (const { node, status } of justReached) {
|
|
521
|
+
const current = g.nodes[node.id];
|
|
522
|
+
if (current && current.status !== status) {
|
|
523
|
+
current.status = status;
|
|
524
|
+
if (status === "review") {
|
|
525
|
+
current.reviewState = current.reviewState ?? "pending";
|
|
526
|
+
}
|
|
527
|
+
current.updatedAt = nowIso();
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
for (const [nodeId, tokens] of tokenUpdates) {
|
|
531
|
+
const current = g.nodes[nodeId];
|
|
532
|
+
if (current) {
|
|
533
|
+
current.tokens = tokens;
|
|
534
|
+
current.updatedAt = nowIso();
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return g;
|
|
538
|
+
});
|
|
539
|
+
for (const { node, status } of justReached) {
|
|
540
|
+
if (status === "review") {
|
|
541
|
+
bus.publish({ ts: nowIso(), runId: node.runId ?? node.id, nodeId: node.id, type: "node.review" });
|
|
542
|
+
}
|
|
543
|
+
else if (status === "failed") {
|
|
544
|
+
bus.publish({ ts: nowIso(), runId: node.runId ?? node.id, nodeId: node.id, type: "node.failed" });
|
|
545
|
+
}
|
|
546
|
+
else if (status === "merged") {
|
|
547
|
+
bus.publish({ ts: nowIso(), runId: node.runId ?? node.id, nodeId: node.id, type: "node.merged" });
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
await renderLiveRudderMd(repoRoot).catch(() => undefined);
|
|
551
|
+
}
|
|
552
|
+
// Auto-merge review nodes when the gate is auto. Skip fan-out variants (nodes
|
|
553
|
+
// that feed a judge node via a judge edge): only the judge node merges; the
|
|
554
|
+
// variants end in review, superseded by the judge's choice.
|
|
555
|
+
if (orchestrator.reviewGate === "auto") {
|
|
556
|
+
const fresh = await readGraph(repoRoot);
|
|
557
|
+
for (const node of Object.values(fresh.nodes)) {
|
|
558
|
+
if (node.status === "review" && !node.supersededBy && !isJudgedVariant(fresh, node.id)) {
|
|
559
|
+
await mergeNodeIntoIntegration(repoRoot, node, bus);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
const overBudget = isOverBudget(tokensSpent, budgetTokens);
|
|
564
|
+
if (runningCount >= maxParallel || overBudget) {
|
|
565
|
+
bus.publish({
|
|
566
|
+
ts: nowIso(),
|
|
567
|
+
runId: "scheduler",
|
|
568
|
+
type: "schedule.tick",
|
|
569
|
+
message: overBudget
|
|
570
|
+
? `holding: budget exceeded (${tokensSpent}/${budgetTokens} tokens)`
|
|
571
|
+
: "holding: at parallel capacity",
|
|
572
|
+
data: { runningCount, maxParallel, tokensSpent, ...(budgetTokens ? { maxTokens: budgetTokens } : {}), overBudget },
|
|
573
|
+
});
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
// Deliver pending judge diffs BEFORE launching so a judge node that is about
|
|
577
|
+
// to become ready has every variant diff already stitched into its prompt.
|
|
578
|
+
const beforeLaunch = await readGraph(repoRoot);
|
|
579
|
+
for (const edge of undeliveredJudgeEdges(beforeLaunch)) {
|
|
580
|
+
await deliverSoftDiff(repoRoot, edge, bus);
|
|
581
|
+
}
|
|
582
|
+
const latest = await readGraph(repoRoot);
|
|
583
|
+
const launchable = selectLaunchable(latest, runningCount, maxParallel);
|
|
584
|
+
for (const node of launchable) {
|
|
585
|
+
await launchNode(repoRoot, latest, node, bus);
|
|
586
|
+
}
|
|
587
|
+
const afterLaunch = launchable.length ? await readGraph(repoRoot) : latest;
|
|
588
|
+
for (const edge of undeliveredSoftEdges(afterLaunch)) {
|
|
589
|
+
await deliverSoftDiff(repoRoot, edge, bus);
|
|
590
|
+
}
|
|
591
|
+
bus.publish({
|
|
592
|
+
ts: nowIso(),
|
|
593
|
+
runId: "scheduler",
|
|
594
|
+
type: "schedule.tick",
|
|
595
|
+
message: `tick: launched ${launchable.length}`,
|
|
596
|
+
data: { runningCount, maxParallel, launched: launchable.length },
|
|
597
|
+
});
|
|
598
|
+
if (launchable.length) {
|
|
599
|
+
await renderLiveRudderMd(repoRoot).catch(() => undefined);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Called by the daemon when a run.json changes. Projects the owning node's
|
|
604
|
+
* status: completed -> review (auto-merge if configured); failed/cancelled ->
|
|
605
|
+
* node.failed + propagate blocked to hard dependents. Then ticks the scheduler.
|
|
606
|
+
*/
|
|
607
|
+
export async function onRunTransition(repoRoot, runId, bus) {
|
|
608
|
+
const run = await loadRunRecord(repoRoot, runId).catch(() => null);
|
|
609
|
+
// A resolver run finishing is handled specially: it owns no graph node of its
|
|
610
|
+
// own; instead it points (resolverFor) at the originally-blocked node.
|
|
611
|
+
if (run?.resolverFor && (run.status === "completed" || run.status === "merged")) {
|
|
612
|
+
await onResolverTransition(repoRoot, run.resolverFor, run, bus);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const graph = await readGraph(repoRoot);
|
|
616
|
+
const node = Object.values(graph.nodes).find((candidate) => candidate.runId === runId);
|
|
617
|
+
if (!node) {
|
|
618
|
+
// Not a graph-managed run (e.g. an ad-hoc TUI run); just tick.
|
|
619
|
+
await scheduleTick(repoRoot, bus);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
// If this node is held blocked with a resolver in flight, the resolver (not
|
|
623
|
+
// the original completed run) drives it. Ignore the original run's transition.
|
|
624
|
+
if (node.status === "blocked" && node.resolverRunId) {
|
|
625
|
+
await scheduleTick(repoRoot, bus);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const projected = projectNodeStatus(node, run ?? undefined);
|
|
629
|
+
if (projected === "review" && node.status !== "review") {
|
|
630
|
+
await updateGraph(repoRoot, (g) => {
|
|
631
|
+
const current = g.nodes[node.id];
|
|
632
|
+
if (current) {
|
|
633
|
+
current.status = "review";
|
|
634
|
+
current.reviewState = current.reviewState ?? "pending";
|
|
635
|
+
current.updatedAt = nowIso();
|
|
636
|
+
}
|
|
637
|
+
return g;
|
|
638
|
+
});
|
|
639
|
+
bus.publish({ ts: nowIso(), runId, nodeId: node.id, type: "node.review" });
|
|
640
|
+
await renderLiveRudderMd(repoRoot).catch(() => undefined);
|
|
641
|
+
const config = await loadConfig();
|
|
642
|
+
// A fan-out variant never auto-merges: it only feeds the judge node, which
|
|
643
|
+
// is what eventually merges. Let scheduleTick deliver the variant diff and
|
|
644
|
+
// launch the judge once all variants have reached review.
|
|
645
|
+
if (config.orchestrator?.reviewGate === "auto" && !isJudgedVariant(graph, node.id)) {
|
|
646
|
+
const fresh = await readGraph(repoRoot);
|
|
647
|
+
const refreshed = fresh.nodes[node.id];
|
|
648
|
+
if (refreshed) {
|
|
649
|
+
await mergeNodeIntoIntegration(repoRoot, refreshed, bus);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
else if (projected === "failed" && node.status !== "failed") {
|
|
654
|
+
const blocked = computeBlocked(graph, node.id);
|
|
655
|
+
await updateGraph(repoRoot, (g) => {
|
|
656
|
+
const current = g.nodes[node.id];
|
|
657
|
+
if (current) {
|
|
658
|
+
current.status = "failed";
|
|
659
|
+
current.updatedAt = nowIso();
|
|
660
|
+
}
|
|
661
|
+
for (const id of blocked) {
|
|
662
|
+
const target = g.nodes[id];
|
|
663
|
+
if (target && target.status !== "merged") {
|
|
664
|
+
target.status = "blocked";
|
|
665
|
+
target.updatedAt = nowIso();
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return g;
|
|
669
|
+
});
|
|
670
|
+
bus.publish({ ts: nowIso(), runId, nodeId: node.id, type: "node.failed" });
|
|
671
|
+
for (const id of blocked) {
|
|
672
|
+
bus.publish({ ts: nowIso(), runId, nodeId: id, type: "node.blocked" });
|
|
673
|
+
}
|
|
674
|
+
await renderLiveRudderMd(repoRoot).catch(() => undefined);
|
|
675
|
+
}
|
|
676
|
+
await scheduleTick(repoRoot, bus);
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* A resolver run finished. Re-check its workspace for conflicts. If none remain,
|
|
680
|
+
* finalize the original node: mark it merged, advance integrationChangeId to the
|
|
681
|
+
* (now-resolved) merge change, exportToGit, publish merge.merged + node.merged,
|
|
682
|
+
* and tick (to unblock dependents). If conflicts remain, hold the node blocked
|
|
683
|
+
* and re-publish merge.conflict. Auto-retry is capped at 1 (the original node
|
|
684
|
+
* already has resolverRunId set, so mergeNodeIntoIntegration would not respin).
|
|
685
|
+
*/
|
|
686
|
+
async function onResolverTransition(repoRoot, originalNodeId, resolverRun, bus) {
|
|
687
|
+
const workspacePath = resolverRun.worktree?.path || repoRoot;
|
|
688
|
+
const remaining = await listConflicts(workspacePath).catch(() => []);
|
|
689
|
+
const mergeChangeId = resolverRun.worktree?.jjChangeId || (await currentJjChangeId(workspacePath).catch(() => "")) || "";
|
|
690
|
+
const graph = await readGraph(repoRoot);
|
|
691
|
+
const node = graph.nodes[originalNodeId];
|
|
692
|
+
if (!resolverShouldFinalize({ resolverFor: resolverRun.resolverFor, remainingConflicts: remaining })) {
|
|
693
|
+
// Conflicts remain: keep the node blocked. We do not respin (cap at 1).
|
|
694
|
+
await updateGraph(repoRoot, (g) => {
|
|
695
|
+
const current = g.nodes[originalNodeId];
|
|
696
|
+
if (current && current.status !== "merged") {
|
|
697
|
+
current.status = "blocked";
|
|
698
|
+
current.updatedAt = nowIso();
|
|
699
|
+
}
|
|
700
|
+
return g;
|
|
701
|
+
});
|
|
702
|
+
bus.publish({
|
|
703
|
+
ts: nowIso(),
|
|
704
|
+
runId: resolverRun.id,
|
|
705
|
+
nodeId: originalNodeId,
|
|
706
|
+
type: "merge.conflict",
|
|
707
|
+
message: `Resolver ${resolverRun.id} left ${remaining.length} conflict(s) for ${originalNodeId}`,
|
|
708
|
+
data: { conflictedFiles: remaining, mergeChangeId },
|
|
709
|
+
});
|
|
710
|
+
await renderLiveRudderMd(repoRoot).catch(() => undefined);
|
|
711
|
+
await scheduleTick(repoRoot, bus);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
// Clean: finalize the original node onto the resolved merge change.
|
|
715
|
+
await updateGraph(repoRoot, (g) => {
|
|
716
|
+
const current = g.nodes[originalNodeId];
|
|
717
|
+
if (current) {
|
|
718
|
+
current.status = "merged";
|
|
719
|
+
current.reviewState = "approved";
|
|
720
|
+
if (current.merge) {
|
|
721
|
+
current.merge = { ...current.merge, status: "merged", conflictedFiles: [] };
|
|
722
|
+
}
|
|
723
|
+
current.updatedAt = nowIso();
|
|
724
|
+
}
|
|
725
|
+
if (mergeChangeId) {
|
|
726
|
+
g.integrationChangeId = mergeChangeId;
|
|
727
|
+
}
|
|
728
|
+
return g;
|
|
729
|
+
});
|
|
730
|
+
await exportToGit(repoRoot).catch(() => undefined);
|
|
731
|
+
bus.publish({
|
|
732
|
+
ts: nowIso(),
|
|
733
|
+
runId: resolverRun.id,
|
|
734
|
+
nodeId: originalNodeId,
|
|
735
|
+
type: "resolver.resolved",
|
|
736
|
+
message: `Resolver ${resolverRun.id} resolved ${originalNodeId}`,
|
|
737
|
+
data: { mergeChangeId },
|
|
738
|
+
});
|
|
739
|
+
bus.publish({
|
|
740
|
+
ts: nowIso(),
|
|
741
|
+
runId: node?.runId ?? originalNodeId,
|
|
742
|
+
nodeId: originalNodeId,
|
|
743
|
+
type: "merge.merged",
|
|
744
|
+
message: `Merged ${originalNodeId} (conflict resolved)`,
|
|
745
|
+
data: { mergeChangeId },
|
|
746
|
+
});
|
|
747
|
+
bus.publish({ ts: nowIso(), runId: node?.runId ?? originalNodeId, nodeId: originalNodeId, type: "node.merged" });
|
|
748
|
+
await renderLiveRudderMd(repoRoot).catch(() => undefined);
|
|
749
|
+
await scheduleTick(repoRoot, bus);
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* The single injection chokepoint. A typed task (terminal pane or board
|
|
753
|
+
* composer) becomes a NEW node reconciled against the frontier, never blindly
|
|
754
|
+
* appended. Adds the node (status "planned", source "injection") + inferred
|
|
755
|
+
* edges, scaffolds its empty jj change, publishes plan.reconciled, then ticks.
|
|
756
|
+
*/
|
|
757
|
+
export async function reconcileInjection(repoRoot, input, bus) {
|
|
758
|
+
const graph = await readGraph(repoRoot);
|
|
759
|
+
const frontierNodes = graphFrontier(graph).map((node) => ({
|
|
760
|
+
id: node.id,
|
|
761
|
+
title: node.title,
|
|
762
|
+
prompt: node.prompt,
|
|
763
|
+
deps: [],
|
|
764
|
+
}));
|
|
765
|
+
const branch = await currentBranch(repoRoot).catch(() => "main");
|
|
766
|
+
const result = await reconcile(input.prompt, frontierNodes, { root: repoRoot, branch }).catch(() => ({
|
|
767
|
+
node: {
|
|
768
|
+
id: "new",
|
|
769
|
+
title: input.prompt.slice(0, 72),
|
|
770
|
+
prompt: input.prompt,
|
|
771
|
+
goal: deriveGoal(input.prompt),
|
|
772
|
+
success: DEFAULT_SUCCESS,
|
|
773
|
+
deps: [],
|
|
774
|
+
},
|
|
775
|
+
inferredDeps: [],
|
|
776
|
+
}));
|
|
777
|
+
const title = input.title?.trim() || result.node.title || input.prompt.slice(0, 72);
|
|
778
|
+
const nodeId = newNodeId(title);
|
|
779
|
+
// Scaffold an empty jj change parented on the inferred deps' changes (or the
|
|
780
|
+
// integration trunk). Soft-edge fallback never blocks.
|
|
781
|
+
const parentChangeIds = result.inferredDeps
|
|
782
|
+
.map((dep) => graph.nodes[dep.node]?.jjChangeId)
|
|
783
|
+
.filter((value) => Boolean(value));
|
|
784
|
+
const trunk = graph.integrationChangeId || (await currentJjChangeId(repoRoot)) || "@";
|
|
785
|
+
const parents = parentChangeIds.length ? parentChangeIds : [trunk];
|
|
786
|
+
let changeId = "";
|
|
787
|
+
try {
|
|
788
|
+
changeId = await createEmptyChange({
|
|
789
|
+
repoRoot,
|
|
790
|
+
parents,
|
|
791
|
+
description: `rudder-node:${nodeId} ${title}`,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
catch {
|
|
795
|
+
// Degrade gracefully: a node with no scaffolded change still launches off
|
|
796
|
+
// the integration trunk in launchNode.
|
|
797
|
+
changeId = "";
|
|
798
|
+
}
|
|
799
|
+
const createdAt = nowIso();
|
|
800
|
+
await updateGraph(repoRoot, (g) => {
|
|
801
|
+
const incomingEdgeIds = [];
|
|
802
|
+
for (const dep of result.inferredDeps) {
|
|
803
|
+
if (!g.nodes[dep.node]) {
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
const edgeId = newEdgeId(dep.node, nodeId);
|
|
807
|
+
incomingEdgeIds.push(edgeId);
|
|
808
|
+
g.edges[edgeId] = {
|
|
809
|
+
id: edgeId,
|
|
810
|
+
from: dep.node,
|
|
811
|
+
to: nodeId,
|
|
812
|
+
type: dep.type,
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
const goal = result.node.goal ?? deriveGoal(title || input.prompt);
|
|
816
|
+
const success = result.node.success ?? DEFAULT_SUCCESS;
|
|
817
|
+
g.nodes[nodeId] = {
|
|
818
|
+
id: nodeId,
|
|
819
|
+
title,
|
|
820
|
+
// Reconciled (injected) nodes lead with the /goal-format header too.
|
|
821
|
+
prompt: formatGoalPrompt({ goal, success, body: result.node.prompt || input.prompt }),
|
|
822
|
+
goal,
|
|
823
|
+
success,
|
|
824
|
+
backend: input.backend ?? "claude",
|
|
825
|
+
...(input.model ? { model: input.model } : {}),
|
|
826
|
+
...(input.effort ? { effort: input.effort } : {}),
|
|
827
|
+
status: "planned",
|
|
828
|
+
...(changeId ? { jjChangeId: changeId } : {}),
|
|
829
|
+
deps: incomingEdgeIds,
|
|
830
|
+
source: "injection",
|
|
831
|
+
createdAt,
|
|
832
|
+
updatedAt: createdAt,
|
|
833
|
+
};
|
|
834
|
+
return g;
|
|
835
|
+
});
|
|
836
|
+
bus.publish({
|
|
837
|
+
ts: nowIso(),
|
|
838
|
+
runId: nodeId,
|
|
839
|
+
nodeId,
|
|
840
|
+
type: "plan.reconciled",
|
|
841
|
+
message: `Reconciled injection ${nodeId} against ${frontierNodes.length} frontier node(s)`,
|
|
842
|
+
data: { inferredDeps: result.inferredDeps },
|
|
843
|
+
});
|
|
844
|
+
bus.publish({ ts: nowIso(), runId: nodeId, nodeId, type: "node.created" });
|
|
845
|
+
await renderLiveRudderMd(repoRoot).catch(() => undefined);
|
|
846
|
+
await scheduleTick(repoRoot, bus);
|
|
847
|
+
return { nodeId };
|
|
848
|
+
}
|
|
849
|
+
//# sourceMappingURL=scheduler.js.map
|