@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.
Files changed (58) hide show
  1. package/dist/auth.d.ts +2 -0
  2. package/dist/auth.js +22 -1
  3. package/dist/auth.js.map +1 -1
  4. package/dist/backends.js +88 -1
  5. package/dist/backends.js.map +1 -1
  6. package/dist/board/board.css +1 -0
  7. package/dist/board/board.js +2 -0
  8. package/dist/board/daemon.d.ts +21 -0
  9. package/dist/board/daemon.js +838 -0
  10. package/dist/board/daemon.js.map +1 -0
  11. package/dist/brain.d.ts +9 -0
  12. package/dist/brain.js +32 -5
  13. package/dist/brain.js.map +1 -1
  14. package/dist/bus.d.ts +9 -0
  15. package/dist/bus.js +23 -0
  16. package/dist/bus.js.map +1 -0
  17. package/dist/daemon.d.ts +21 -0
  18. package/dist/daemon.js +141 -0
  19. package/dist/daemon.js.map +1 -0
  20. package/dist/git.d.ts +5 -16
  21. package/dist/git.js +43 -50
  22. package/dist/git.js.map +1 -1
  23. package/dist/goal.d.ts +30 -0
  24. package/dist/goal.js +75 -0
  25. package/dist/goal.js.map +1 -0
  26. package/dist/graph.d.ts +56 -0
  27. package/dist/graph.js +213 -0
  28. package/dist/graph.js.map +1 -0
  29. package/dist/jj.d.ts +121 -0
  30. package/dist/jj.js +524 -0
  31. package/dist/jj.js.map +1 -0
  32. package/dist/main.js +171 -6
  33. package/dist/main.js.map +1 -1
  34. package/dist/native/rudder-native +0 -0
  35. package/dist/planner.d.ts +27 -0
  36. package/dist/planner.js +540 -0
  37. package/dist/planner.js.map +1 -0
  38. package/dist/run-manager.d.ts +7 -0
  39. package/dist/run-manager.js +98 -38
  40. package/dist/run-manager.js.map +1 -1
  41. package/dist/scheduler.d.ts +124 -0
  42. package/dist/scheduler.js +849 -0
  43. package/dist/scheduler.js.map +1 -0
  44. package/dist/state.d.ts +16 -1
  45. package/dist/state.js +101 -0
  46. package/dist/state.js.map +1 -1
  47. package/dist/surfaces.d.ts +23 -0
  48. package/dist/surfaces.js +196 -0
  49. package/dist/surfaces.js.map +1 -0
  50. package/dist/task-summary.d.ts +18 -0
  51. package/dist/task-summary.js +132 -0
  52. package/dist/task-summary.js.map +1 -1
  53. package/dist/types.d.ts +198 -1
  54. package/dist/types.js +1 -1
  55. package/dist/types.js.map +1 -1
  56. package/dist/util.js +1 -0
  57. package/dist/util.js.map +1 -1
  58. 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