@exla/rudder 0.0.0-stage → 3.4.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 (181) hide show
  1. package/LICENSE +100 -0
  2. package/README.md +733 -2
  3. package/assets/rudder.icns +0 -0
  4. package/assets/sounds/NOTICE.md +8 -0
  5. package/assets/sounds/ping.mp3 +0 -0
  6. package/dist/agent-attention.d.ts +5 -0
  7. package/dist/agent-attention.js +54 -0
  8. package/dist/agent-attention.js.map +1 -0
  9. package/dist/analytics.d.ts +36 -0
  10. package/dist/analytics.js +178 -0
  11. package/dist/analytics.js.map +1 -0
  12. package/dist/attach-session.d.ts +91 -0
  13. package/dist/attach-session.js +290 -0
  14. package/dist/attach-session.js.map +1 -0
  15. package/dist/auth.d.ts +36 -0
  16. package/dist/auth.js +392 -0
  17. package/dist/auth.js.map +1 -0
  18. package/dist/backends.d.ts +3 -0
  19. package/dist/backends.js +638 -0
  20. package/dist/backends.js.map +1 -0
  21. package/dist/board/board.css +1 -0
  22. package/dist/board/board.js +2 -0
  23. package/dist/board/daemon.d.ts +39 -0
  24. package/dist/board/daemon.js +1510 -0
  25. package/dist/board/daemon.js.map +1 -0
  26. package/dist/brain.d.ts +24 -0
  27. package/dist/brain.js +244 -0
  28. package/dist/brain.js.map +1 -0
  29. package/dist/bus.d.ts +9 -0
  30. package/dist/bus.js +23 -0
  31. package/dist/bus.js.map +1 -0
  32. package/dist/checkout-sync.d.ts +35 -0
  33. package/dist/checkout-sync.js +247 -0
  34. package/dist/checkout-sync.js.map +1 -0
  35. package/dist/cloud.d.ts +48 -0
  36. package/dist/cloud.js +4596 -0
  37. package/dist/cloud.js.map +1 -0
  38. package/dist/codex-binary.d.ts +12 -0
  39. package/dist/codex-binary.js +184 -0
  40. package/dist/codex-binary.js.map +1 -0
  41. package/dist/context-audit.d.ts +21 -0
  42. package/dist/context-audit.js +109 -0
  43. package/dist/context-audit.js.map +1 -0
  44. package/dist/daemon.d.ts +27 -0
  45. package/dist/daemon.js +203 -0
  46. package/dist/daemon.js.map +1 -0
  47. package/dist/effort.d.ts +9 -0
  48. package/dist/effort.js +72 -0
  49. package/dist/effort.js.map +1 -0
  50. package/dist/feedback.d.ts +46 -0
  51. package/dist/feedback.js +173 -0
  52. package/dist/feedback.js.map +1 -0
  53. package/dist/gc.d.ts +3 -0
  54. package/dist/gc.js +262 -0
  55. package/dist/gc.js.map +1 -0
  56. package/dist/git.d.ts +31 -0
  57. package/dist/git.js +455 -0
  58. package/dist/git.js.map +1 -0
  59. package/dist/goal.d.ts +33 -0
  60. package/dist/goal.js +95 -0
  61. package/dist/goal.js.map +1 -0
  62. package/dist/graph.d.ts +93 -0
  63. package/dist/graph.js +372 -0
  64. package/dist/graph.js.map +1 -0
  65. package/dist/handoff.d.ts +110 -0
  66. package/dist/handoff.js +565 -0
  67. package/dist/handoff.js.map +1 -0
  68. package/dist/improve/advisor.d.ts +10 -0
  69. package/dist/improve/advisor.js +115 -0
  70. package/dist/improve/advisor.js.map +1 -0
  71. package/dist/improve/collect.d.ts +49 -0
  72. package/dist/improve/collect.js +274 -0
  73. package/dist/improve/collect.js.map +1 -0
  74. package/dist/improve/gate.d.ts +17 -0
  75. package/dist/improve/gate.js +60 -0
  76. package/dist/improve/gate.js.map +1 -0
  77. package/dist/improve/index.d.ts +9 -0
  78. package/dist/improve/index.js +376 -0
  79. package/dist/improve/index.js.map +1 -0
  80. package/dist/improve/judge.d.ts +32 -0
  81. package/dist/improve/judge.js +114 -0
  82. package/dist/improve/judge.js.map +1 -0
  83. package/dist/improve/mine.d.ts +31 -0
  84. package/dist/improve/mine.js +192 -0
  85. package/dist/improve/mine.js.map +1 -0
  86. package/dist/improve/propose.d.ts +51 -0
  87. package/dist/improve/propose.js +150 -0
  88. package/dist/improve/propose.js.map +1 -0
  89. package/dist/improve/rank.d.ts +16 -0
  90. package/dist/improve/rank.js +49 -0
  91. package/dist/improve/rank.js.map +1 -0
  92. package/dist/improve/schedule.d.ts +1 -0
  93. package/dist/improve/schedule.js +95 -0
  94. package/dist/improve/schedule.js.map +1 -0
  95. package/dist/improve/ship.d.ts +25 -0
  96. package/dist/improve/ship.js +82 -0
  97. package/dist/improve/ship.js.map +1 -0
  98. package/dist/improve/state.d.ts +157 -0
  99. package/dist/improve/state.js +264 -0
  100. package/dist/improve/state.js.map +1 -0
  101. package/dist/index.d.ts +2 -0
  102. package/dist/index.js +88 -0
  103. package/dist/index.js.map +1 -0
  104. package/dist/jj-binary.d.ts +36 -0
  105. package/dist/jj-binary.js +143 -0
  106. package/dist/jj-binary.js.map +1 -0
  107. package/dist/jj.d.ts +163 -0
  108. package/dist/jj.js +926 -0
  109. package/dist/jj.js.map +1 -0
  110. package/dist/logger.d.ts +22 -0
  111. package/dist/logger.js +110 -0
  112. package/dist/logger.js.map +1 -0
  113. package/dist/main.d.ts +3 -0
  114. package/dist/main.js +1366 -0
  115. package/dist/main.js.map +1 -0
  116. package/dist/migration.d.ts +82 -0
  117. package/dist/migration.js +198 -0
  118. package/dist/migration.js.map +1 -0
  119. package/dist/models.d.ts +9 -0
  120. package/dist/models.js +623 -0
  121. package/dist/models.js.map +1 -0
  122. package/dist/move-home.d.ts +82 -0
  123. package/dist/move-home.js +472 -0
  124. package/dist/move-home.js.map +1 -0
  125. package/dist/native/darwin-arm64/rudder-native +0 -0
  126. package/dist/native/darwin-x64/rudder-native +0 -0
  127. package/dist/native/linux-x64/rudder-native +0 -0
  128. package/dist/native-agents.d.ts +8 -0
  129. package/dist/native-agents.js +178 -0
  130. package/dist/native-agents.js.map +1 -0
  131. package/dist/native-binary.d.ts +4 -0
  132. package/dist/native-binary.js +140 -0
  133. package/dist/native-binary.js.map +1 -0
  134. package/dist/paste-bridge.d.ts +36 -0
  135. package/dist/paste-bridge.js +279 -0
  136. package/dist/paste-bridge.js.map +1 -0
  137. package/dist/plan-mode.d.ts +2 -0
  138. package/dist/plan-mode.js +22 -0
  139. package/dist/plan-mode.js.map +1 -0
  140. package/dist/planner.d.ts +27 -0
  141. package/dist/planner.js +587 -0
  142. package/dist/planner.js.map +1 -0
  143. package/dist/predict.d.ts +67 -0
  144. package/dist/predict.js +369 -0
  145. package/dist/predict.js.map +1 -0
  146. package/dist/report.d.ts +74 -0
  147. package/dist/report.js +412 -0
  148. package/dist/report.js.map +1 -0
  149. package/dist/rudder-md.d.ts +33 -0
  150. package/dist/rudder-md.js +142 -0
  151. package/dist/rudder-md.js.map +1 -0
  152. package/dist/run-manager.d.ts +62 -0
  153. package/dist/run-manager.js +1400 -0
  154. package/dist/run-manager.js.map +1 -0
  155. package/dist/scheduler.d.ts +128 -0
  156. package/dist/scheduler.js +955 -0
  157. package/dist/scheduler.js.map +1 -0
  158. package/dist/state.d.ts +127 -0
  159. package/dist/state.js +704 -0
  160. package/dist/state.js.map +1 -0
  161. package/dist/surfaces.d.ts +91 -0
  162. package/dist/surfaces.js +460 -0
  163. package/dist/surfaces.js.map +1 -0
  164. package/dist/task-summary.d.ts +30 -0
  165. package/dist/task-summary.js +472 -0
  166. package/dist/task-summary.js.map +1 -0
  167. package/dist/types.d.ts +492 -0
  168. package/dist/types.js +2 -0
  169. package/dist/types.js.map +1 -0
  170. package/dist/usage.d.ts +198 -0
  171. package/dist/usage.js +830 -0
  172. package/dist/usage.js.map +1 -0
  173. package/dist/util.d.ts +73 -0
  174. package/dist/util.js +399 -0
  175. package/dist/util.js.map +1 -0
  176. package/dist/version-check.d.ts +16 -0
  177. package/dist/version-check.js +269 -0
  178. package/dist/version-check.js.map +1 -0
  179. package/package.json +68 -4
  180. package/scripts/copy-native.mjs +89 -0
  181. package/scripts/postinstall.mjs +34 -0
@@ -0,0 +1,1400 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import fsp from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { createSpec, renderContract, verifyRun } from "./brain.js";
7
+ import { getBackend } from "./backends.js";
8
+ import { mergeGeneratedRudderMd, withRudderMdLock } from "./rudder-md.js";
9
+ import { createRunRecord, agentContextPath, ensureProjectRuntimeIgnored, eventsPath, listRuns, loadConfig, loadRunRecord, outputPath, AGENT_WORKSPACES_DIR, LEGACY_AGENT_WORKSPACES_DIR, registerProject, rememberBackendSelection, resolveRun, runDir, saveRunRecord, stopRequestPath, } from "./state.js";
10
+ import { updateGraph } from "./graph.js";
11
+ import { appendEvent, } from "./state.js";
12
+ import { activeRunsForCheckout, currentBranch, findRepoRoot, mergeGitRunIntoCurrentBranch, processAlive, runProcessAlive, removeGitWorktree, syncGitRunWorktree, } from "./git.js";
13
+ import { createRunJjWorkspace, closestLocalBookmark, currentJjChangeId, ensureColocated, ensureJj, exportToGit, mergeJjRunIntoCurrentWorkspace, removeRunWorkspace as removeJjRunWorkspace, syncRunWorkspace as syncJjRunWorkspace, } from "./jj.js";
14
+ import { commandExists, ensureDir, isRecord, isTty, MissingToolError, newRunId, nowIso, pathExists, runCommand, shortenHome, textFromAssistantMessage, writeJson, } from "./util.js";
15
+ import { taskDisplayLabel } from "./task-summary.js";
16
+ import { captureSharedContextFromInput, redactSharedSecretValues, SHARED_CONTEXT_FILE, syncSharedContextToWorkspaces } from "./surfaces.js";
17
+ // The pause before an automatic steering pass. Overridable via
18
+ // RUDDER_AUTO_STEER_DELAY_MS so the end-to-end test can drain a DAG quickly
19
+ // instead of waiting 10s per node; defaults to 10s for real runs.
20
+ const AUTO_STEER_DELAY_MS = Number(process.env.RUDDER_AUTO_STEER_DELAY_MS) || 10_000;
21
+ function missingBackendError(backend, healthMessage) {
22
+ return new MissingToolError(backend, healthMessage);
23
+ }
24
+ export async function startRun(params) {
25
+ const repoRoot = findRepoRoot();
26
+ await registerProject(repoRoot).catch(() => undefined);
27
+ await captureSharedContextFromInput(repoRoot, params.task).catch(() => false);
28
+ const config = await loadConfig();
29
+ const backend = params.backend ?? config.lastUsedBackend ?? config.defaultBackend;
30
+ if (!commandExists(backend)) {
31
+ throw new MissingToolError(backend);
32
+ }
33
+ const model = params.model ??
34
+ (backend === "claude"
35
+ ? config.backends.claude?.model
36
+ : backend === "codex"
37
+ ? config.backends.codex?.model
38
+ : backend === "opencode"
39
+ ? config.backends.opencode?.model
40
+ : config.backends.acpx?.model);
41
+ const effort = params.effort ?? effortForBackend(backend, config);
42
+ ensureJj();
43
+ await ensureColocated(repoRoot);
44
+ await ensureProjectRuntimeIgnored(repoRoot);
45
+ const active = await activeRunsForCheckout(repoRoot, repoRoot);
46
+ if (params.queue && active.length > 0) {
47
+ throw new Error("Queue mode is not implemented yet; omit --queue to create a workspace run.");
48
+ }
49
+ const useWorkspace = Boolean(params.workspace || active.length > 0);
50
+ const baseCommit = await baseRevision(repoRoot);
51
+ const targetBranch = await targetRevision(repoRoot);
52
+ const id = newRunId(params.task);
53
+ const workspaceInfo = useWorkspace
54
+ ? await createRunWorkspace({ repoRoot, runId: id, task: params.task })
55
+ : { path: repoRoot, workspaceName: undefined, jjChangeId: undefined };
56
+ const run = await createRunRecord({
57
+ id,
58
+ repoRoot,
59
+ task: params.task,
60
+ backend,
61
+ model,
62
+ effort,
63
+ targetBranch,
64
+ baseCommit,
65
+ vcs: "jj",
66
+ useWorkspace,
67
+ workspaceName: workspaceInfo.workspaceName,
68
+ workspaceChangeId: workspaceInfo.jjChangeId,
69
+ workspacePath: workspaceInfo.path,
70
+ });
71
+ await emit(run, {
72
+ ts: nowIso(),
73
+ runId: run.id,
74
+ type: "run.created",
75
+ message: useWorkspace
76
+ ? `Created jj workspace ${shortenHome(workspaceInfo.path)}`
77
+ : "Created run in current checkout",
78
+ });
79
+ await writeAgentContext(repoRoot);
80
+ await rememberBackendSelection({
81
+ backend,
82
+ model: params.model,
83
+ effort: params.effort,
84
+ updateModel: params.model !== undefined,
85
+ updateEffort: params.effort !== undefined,
86
+ });
87
+ const attemptId = newAttemptId();
88
+ const pid = spawnWorker(repoRoot, run.id, attemptId);
89
+ run.process = {
90
+ pid,
91
+ controllerPid: pid,
92
+ attemptId,
93
+ startedAt: nowIso(),
94
+ };
95
+ run.status = "running";
96
+ await saveRunRecord(run);
97
+ if (params.json) {
98
+ console.log(JSON.stringify(run, null, 2));
99
+ return run;
100
+ }
101
+ if (!params.quiet && !params.silent) {
102
+ console.log(`Started ${run.id}`);
103
+ console.log(` backend: ${backend}${model ? ` (${model})` : ""}`);
104
+ console.log(` mode: ${useWorkspace ? `workspace ${shortenHome(workspaceInfo.path)}` : "current checkout"}`);
105
+ }
106
+ if (params.detach || !isTty()) {
107
+ if (params.silent) {
108
+ return run;
109
+ }
110
+ if (params.quiet) {
111
+ console.log(`Started ${run.id} in background. Use /watch ${run.id} or rudder watch ${run.id}.`);
112
+ }
113
+ else {
114
+ console.log(` watch: rudder watch ${run.id}`);
115
+ }
116
+ return run;
117
+ }
118
+ await watchRun({
119
+ repoRoot,
120
+ runId: run.id,
121
+ follow: true,
122
+ exitOnComplete: params.exitOnComplete,
123
+ signal: params.watchSignal,
124
+ view: params.view,
125
+ });
126
+ return run;
127
+ }
128
+ export async function continueRun(params) {
129
+ const repoRoot = findRepoRoot();
130
+ const run = await loadRunRecord(repoRoot, params.runId);
131
+ if (!run) {
132
+ throw new Error(`Run not found: ${params.runId}`);
133
+ }
134
+ if (run.mode && run.mode !== "execute") {
135
+ throw new Error("Only headless execute runs can be continued from the standalone board.");
136
+ }
137
+ if (isActiveStatus(run.status) && run.status !== "steering" && !params.interrupt) {
138
+ throw new Error("That agent is still running. Wait for it to finish before sending another message.");
139
+ }
140
+ if (params.interrupt) {
141
+ await interruptWorkerAttempt(repoRoot, run);
142
+ }
143
+ const prompt = params.prompt.trim();
144
+ if (!prompt) {
145
+ throw new Error("Missing prompt.");
146
+ }
147
+ const ts = nowIso();
148
+ const attemptId = newAttemptId();
149
+ run.status = "running";
150
+ run.currentPrompt = prompt;
151
+ run.lastUserInputAt = ts;
152
+ run.turns = [...(run.turns ?? []), { ts, prompt, source: "user" }];
153
+ run.autoSteer = { count: 0, max: run.autoSteer?.max ?? 2 };
154
+ const pid = spawnWorker(repoRoot, run.id, attemptId);
155
+ run.process = {
156
+ pid,
157
+ controllerPid: pid,
158
+ attemptId,
159
+ startedAt: ts,
160
+ };
161
+ await saveRunRecord(run);
162
+ await emit(run, {
163
+ ts,
164
+ runId: run.id,
165
+ type: "run.continued",
166
+ message: params.interrupt ? "User interrupted and redirected the agent" : "User continued the agent",
167
+ data: { prompt },
168
+ });
169
+ await writeAgentContext(repoRoot);
170
+ if (!params.silent) {
171
+ console.log(`Continued ${run.id}`);
172
+ }
173
+ return run;
174
+ }
175
+ async function interruptWorkerAttempt(repoRoot, owned) {
176
+ const known = new Set();
177
+ const controllerGroups = new Set();
178
+ const collect = (run) => {
179
+ if (run?.process?.controllerPid && run.process.controllerPid !== process.pid) {
180
+ controllerGroups.add(run.process.controllerPid);
181
+ }
182
+ for (const pid of [run?.process?.controllerPid, run?.process?.backendPid, run?.process?.pid]) {
183
+ if (pid && pid !== process.pid)
184
+ known.add(pid);
185
+ }
186
+ };
187
+ collect(owned);
188
+ const terminate = async (signal, timeoutMs) => {
189
+ const deadline = Date.now() + timeoutMs;
190
+ const signalled = new Set();
191
+ const signalledGroups = new Set();
192
+ const stopped = () => [...known].every((pid) => !processAlive(pid)) &&
193
+ [...controllerGroups].every((pid) => !processGroupAlive(pid));
194
+ while (Date.now() < deadline) {
195
+ const latest = await loadRunRecord(repoRoot, owned.id);
196
+ if (latest && sameAttempt(latest, owned))
197
+ collect(latest);
198
+ for (const pid of controllerGroups) {
199
+ if (!processGroupAlive(pid) || signalledGroups.has(pid))
200
+ continue;
201
+ signalProcessGroup(pid, signal);
202
+ signalledGroups.add(pid);
203
+ }
204
+ for (const pid of known) {
205
+ if (!processAlive(pid) || signalled.has(pid))
206
+ continue;
207
+ try {
208
+ process.kill(pid, signal);
209
+ }
210
+ catch {
211
+ // It exited between processAlive and kill.
212
+ }
213
+ signalled.add(pid);
214
+ }
215
+ if (stopped()) {
216
+ // Let a controller that just spawned a backend publish that pid before
217
+ // declaring the old attempt fully quiesced.
218
+ await delay(75);
219
+ const settled = await loadRunRecord(repoRoot, owned.id);
220
+ if (settled && sameAttempt(settled, owned))
221
+ collect(settled);
222
+ if (stopped())
223
+ return true;
224
+ }
225
+ await delay(50);
226
+ }
227
+ return stopped();
228
+ };
229
+ if (await terminate("SIGTERM", 2_000))
230
+ return;
231
+ if (await terminate("SIGKILL", 1_000))
232
+ return;
233
+ throw new Error("Could not stop the previous worker cleanly; redirect was not started.");
234
+ }
235
+ function processGroupAlive(controllerPid) {
236
+ if (process.platform === "win32")
237
+ return processAlive(controllerPid);
238
+ try {
239
+ process.kill(-controllerPid, 0);
240
+ return true;
241
+ }
242
+ catch {
243
+ return false;
244
+ }
245
+ }
246
+ function signalProcessGroup(controllerPid, signal) {
247
+ try {
248
+ process.kill(process.platform === "win32" ? controllerPid : -controllerPid, signal);
249
+ }
250
+ catch {
251
+ // The process group exited between the liveness check and signal.
252
+ }
253
+ }
254
+ /**
255
+ * The detached `rudder __worker` spawn primitive. Both startRun/continueRun and
256
+ * the scheduler launch workers through this one path so the spawn shape (flags,
257
+ * detached, stderr capture, unref) is defined in exactly one place. Returns the
258
+ * spawned worker pid (or undefined if the platform did not assign one).
259
+ */
260
+ export function spawnWorker(repoRoot, runId, attemptId) {
261
+ let stderrFd;
262
+ try {
263
+ fs.mkdirSync(runDir(repoRoot, runId), { recursive: true });
264
+ stderrFd = fs.openSync(path.join(runDir(repoRoot, runId), "spawn-stderr.log"), "w", 0o600);
265
+ }
266
+ catch {
267
+ stderrFd = undefined;
268
+ }
269
+ try {
270
+ const worker = spawn(process.execPath, [
271
+ process.argv[1] ?? "",
272
+ "__worker",
273
+ "--repo",
274
+ repoRoot,
275
+ "--run",
276
+ runId,
277
+ ...(attemptId ? ["--attempt", attemptId] : []),
278
+ ], {
279
+ cwd: repoRoot,
280
+ detached: true,
281
+ stdio: stderrFd === undefined ? "ignore" : ["ignore", "ignore", stderrFd],
282
+ });
283
+ worker.unref();
284
+ return worker.pid;
285
+ }
286
+ finally {
287
+ if (stderrFd !== undefined) {
288
+ try {
289
+ fs.closeSync(stderrFd);
290
+ }
291
+ catch {
292
+ // ignore
293
+ }
294
+ }
295
+ }
296
+ }
297
+ export function newAttemptId() {
298
+ return randomUUID();
299
+ }
300
+ async function loadOwnedAttempt(repoRoot, runId, attemptId) {
301
+ for (let i = 0; i < (attemptId ? 600 : 1); i += 1) {
302
+ const run = await loadRunRecord(repoRoot, runId);
303
+ if (!run)
304
+ return null;
305
+ if (!attemptId || run.process?.attemptId === attemptId)
306
+ return run;
307
+ // startRun/continueRun spawn immediately before their atomic run.json save.
308
+ // Give the cross-process JSON lock a bounded handoff window instead of
309
+ // letting the child read the previous attempt and execute the wrong prompt.
310
+ await delay(25);
311
+ }
312
+ return null;
313
+ }
314
+ function sameAttempt(left, right) {
315
+ const leftId = left.process?.attemptId;
316
+ const rightId = right.process?.attemptId;
317
+ if (leftId || rightId)
318
+ return Boolean(leftId && rightId && leftId === rightId);
319
+ return left.process?.startedAt === right.process?.startedAt;
320
+ }
321
+ async function attemptOwnsRun(repoRoot, run) {
322
+ const latest = await loadRunRecord(repoRoot, run.id);
323
+ return Boolean(latest && sameAttempt(latest, run));
324
+ }
325
+ function saveOwnedRun(run) {
326
+ const expectedAttemptId = run.process?.attemptId;
327
+ const expectedStartedAt = run.process?.startedAt;
328
+ return saveRunRecord(run, expectedAttemptId
329
+ ? { expectedAttemptId }
330
+ : expectedStartedAt
331
+ ? { expectedStartedAt }
332
+ : undefined);
333
+ }
334
+ export async function workerRun(repoRoot, runId, attemptId) {
335
+ const run = await loadOwnedAttempt(repoRoot, runId, attemptId);
336
+ if (!run) {
337
+ // A superseded worker may start after a redirect has already installed a
338
+ // newer attempt. Exiting quietly is the ownership contract, not an error.
339
+ if (attemptId && await loadRunRecord(repoRoot, runId)) {
340
+ return;
341
+ }
342
+ throw new Error(`Run not found: ${runId}`);
343
+ }
344
+ try {
345
+ let current = run;
346
+ while (true) {
347
+ // Verify this pass on what it said, not on earlier turns (verifyRun).
348
+ const transcriptFrom = await fsp
349
+ .stat(outputPath(repoRoot, runId))
350
+ .then((stat) => stat.size)
351
+ .catch(() => 0);
352
+ const pass = await runBackendPass(current);
353
+ current = pass.run;
354
+ if (!(await attemptOwnsRun(repoRoot, current))) {
355
+ await writeAgentContext(repoRoot);
356
+ return;
357
+ }
358
+ if (pass.exitCode !== 0) {
359
+ if (await newerWorkerOwnsRun(repoRoot, current)) {
360
+ await writeAgentContext(repoRoot);
361
+ return;
362
+ }
363
+ current.status = "failed";
364
+ if (!(await saveOwnedRun(current)))
365
+ return;
366
+ await emit(current, {
367
+ ts: nowIso(),
368
+ runId,
369
+ type: "run.failed",
370
+ message: `Backend exited with ${pass.exitCode}`,
371
+ });
372
+ await writeAgentContext(repoRoot);
373
+ return;
374
+ }
375
+ current.status = "verifying";
376
+ if (!(await saveOwnedRun(current)))
377
+ return;
378
+ const verification = await verifyRun(current, { transcriptFrom });
379
+ if (!(await attemptOwnsRun(repoRoot, current)))
380
+ return;
381
+ current.verification = verification;
382
+ if (!(await saveOwnedRun(current)))
383
+ return;
384
+ await emit(current, {
385
+ ts: nowIso(),
386
+ runId,
387
+ type: "verifier.result",
388
+ message: verification.notes,
389
+ data: verification,
390
+ });
391
+ if (shouldAutoSteer(current, verification)) {
392
+ const waitingSince = nowIso();
393
+ current.status = "steering";
394
+ current.autoSteer = {
395
+ count: current.autoSteer?.count ?? 0,
396
+ max: current.autoSteer?.max ?? 2,
397
+ waitingSince,
398
+ };
399
+ if (!(await saveOwnedRun(current)))
400
+ return;
401
+ await emit(current, {
402
+ ts: waitingSince,
403
+ runId,
404
+ type: "steerer.waiting",
405
+ message: "Waiting 10 seconds before automatic steering",
406
+ });
407
+ await writeAgentContext(repoRoot);
408
+ await delay(AUTO_STEER_DELAY_MS);
409
+ const latest = await loadRunRecord(repoRoot, runId);
410
+ if (!latest) {
411
+ return;
412
+ }
413
+ if (!sameAttempt(latest, current)) {
414
+ return;
415
+ }
416
+ if (latest.lastUserInputAt && latest.lastUserInputAt > waitingSince) {
417
+ if (latest.status !== "running") {
418
+ latest.status = "completed";
419
+ if (!(await saveOwnedRun(latest)))
420
+ return;
421
+ await emit(latest, {
422
+ ts: nowIso(),
423
+ runId,
424
+ type: "run.completed",
425
+ message: "Run completed; user follow-up took over steering",
426
+ });
427
+ }
428
+ await writeAgentContext(repoRoot);
429
+ return;
430
+ }
431
+ const steeringPrompt = buildSteeringPrompt(verification);
432
+ latest.currentPrompt = steeringPrompt;
433
+ latest.turns = [...(latest.turns ?? []), { ts: nowIso(), prompt: steeringPrompt, source: "steerer" }];
434
+ latest.autoSteer = {
435
+ count: (latest.autoSteer?.count ?? 0) + 1,
436
+ max: latest.autoSteer?.max ?? 2,
437
+ };
438
+ latest.status = "running";
439
+ if (!(await saveOwnedRun(latest)))
440
+ return;
441
+ await emit(latest, {
442
+ ts: nowIso(),
443
+ runId,
444
+ type: "steerer.prompt",
445
+ message: "Automatic steering prompt sent",
446
+ data: { prompt: steeringPrompt },
447
+ });
448
+ current = latest;
449
+ continue;
450
+ }
451
+ current.status = verification.shouldContinue ? "failed" : "completed";
452
+ if (!(await saveOwnedRun(current)))
453
+ return;
454
+ await emit(current, {
455
+ ts: nowIso(),
456
+ runId,
457
+ type: current.status === "completed" ? "run.completed" : "run.failed",
458
+ message: current.status === "completed"
459
+ ? "Run completed"
460
+ : `Run failed verification: ${verification.missing.join("; ")}`,
461
+ });
462
+ await writeAgentContext(repoRoot);
463
+ return;
464
+ }
465
+ }
466
+ catch (error) {
467
+ if (await newerWorkerOwnsRun(repoRoot, run)) {
468
+ await writeAgentContext(repoRoot);
469
+ return;
470
+ }
471
+ const message = error instanceof Error ? error.message : String(error);
472
+ run.status = "failed";
473
+ run.process = {
474
+ ...(run.process ?? {}),
475
+ endedAt: nowIso(),
476
+ exitCode: 1,
477
+ signal: null,
478
+ };
479
+ if (!(await saveOwnedRun(run)))
480
+ return;
481
+ await emit(run, {
482
+ ts: nowIso(),
483
+ runId,
484
+ type: "run.failed",
485
+ message,
486
+ });
487
+ await writeAgentContext(repoRoot);
488
+ }
489
+ }
490
+ async function newerWorkerOwnsRun(repoRoot, staleRun) {
491
+ const latest = await loadRunRecord(repoRoot, staleRun.id);
492
+ if (latest?.process?.attemptId && staleRun.process?.attemptId) {
493
+ return latest.process.attemptId !== staleRun.process.attemptId;
494
+ }
495
+ return Boolean(latest &&
496
+ isActiveStatus(latest.status) &&
497
+ latest.process?.startedAt &&
498
+ staleRun.process?.startedAt &&
499
+ latest.process.startedAt !== staleRun.process.startedAt);
500
+ }
501
+ async function runBackendPass(run) {
502
+ if (run.mode && run.mode !== "execute") {
503
+ throw new Error("Only execute runs use the background worker.");
504
+ }
505
+ const spec = await createSpec(run);
506
+ await emit(run, {
507
+ ts: nowIso(),
508
+ runId: run.id,
509
+ type: "planner.spec",
510
+ message: "Planner contract created",
511
+ data: spec,
512
+ });
513
+ const backend = getBackend(run.backend);
514
+ const health = await backend.verify();
515
+ if (!health.ok) {
516
+ throw missingBackendError(run.backend, health.message);
517
+ }
518
+ const exitCode = await backend.run({
519
+ run,
520
+ prompt: run.currentPrompt ?? run.task,
521
+ contract: renderContract(spec),
522
+ }, async (event) => {
523
+ await emit(run, event);
524
+ });
525
+ run.process = {
526
+ ...(run.process ?? {}),
527
+ endedAt: nowIso(),
528
+ exitCode,
529
+ signal: null,
530
+ };
531
+ await saveOwnedRun(run);
532
+ return { run, exitCode };
533
+ }
534
+ function shouldAutoSteer(run, verification) {
535
+ if (run.mode === "plan") {
536
+ return false;
537
+ }
538
+ if (run.status === "cancelled" || run.status === "merged" || run.status === "merge-conflict") {
539
+ return false;
540
+ }
541
+ const max = run.autoSteer?.max ?? 2;
542
+ const count = run.autoSteer?.count ?? 0;
543
+ if (count >= max) {
544
+ return false;
545
+ }
546
+ // Steer ONLY when the local verifier flagged a real gap (no file changes, or an
547
+ // unmet acceptance criterion). A verified-clean pass (missing=[], !shouldContinue)
548
+ // has already met its bar, so re-running the model on it is pure cost + a 10s wait
549
+ // with nothing to fix. This used to also fire on the FIRST pass whenever the run
550
+ // produced any changes, which meant EVERY successful node ran the backend twice.
551
+ return verification.shouldContinue || verification.missing.length > 0;
552
+ }
553
+ function buildSteeringPrompt(verification) {
554
+ const missing = verification.missing.length
555
+ ? `\nKnown gaps:\n${verification.missing.map((item) => `- ${item}`).join("\n")}`
556
+ : "";
557
+ return [
558
+ "Rudder automatic steering check:",
559
+ "Pause and review the work you just completed.",
560
+ "What remaining tasks are there, if any?",
561
+ "Does the implementation look correct and scoped?",
562
+ "Were the relevant tests/checks run? If not, run the smallest useful checks now or explain why not.",
563
+ "If anything is missing, fix it. If everything is done, say that clearly and do not make unnecessary changes.",
564
+ missing,
565
+ ]
566
+ .filter(Boolean)
567
+ .join("\n");
568
+ }
569
+ export async function writeAgentContext(repoRoot) {
570
+ const runs = await listRuns(repoRoot);
571
+ const context = classifyContextRuns(runs);
572
+ const lines = [
573
+ "# RUDDER - Orchestrated Run Status (read-only; re-read at the top of every significant step)",
574
+ "",
575
+ "This file is generated by Rudder. It is not user-authored repo documentation. Use it to coordinate with other Rudder agents in this checkout.",
576
+ "",
577
+ `Updated: ${nowIso()}`,
578
+ "",
579
+ "## Global job snapshot",
580
+ ...formatRunSnapshot(context),
581
+ "",
582
+ "## Active local Rudder agents",
583
+ ...(context.active.length
584
+ ? context.active.map((run) => formatAgentContextRun(run))
585
+ : ["- None"]),
586
+ "",
587
+ "## Ready local Rudder agents",
588
+ ...(context.ready.length
589
+ ? context.ready.map((run) => formatAgentContextRun(run))
590
+ : ["- None"]),
591
+ "",
592
+ "## Completed local Rudder agents",
593
+ ...(context.completed.length
594
+ ? context.completed.slice(0, 12).map((run) => formatAgentContextRun(run))
595
+ : ["- None"]),
596
+ "",
597
+ ];
598
+ await writeRudderContextFiles(repoRoot, runs, `${lines.join("\n")}\n`);
599
+ }
600
+ function classifyContextRuns(runs) {
601
+ const active = [];
602
+ const ready = [];
603
+ const completed = [];
604
+ const counts = {
605
+ total: runs.length,
606
+ running: 0,
607
+ waiting: 0,
608
+ ready: 0,
609
+ mergeReady: 0,
610
+ merged: 0,
611
+ failed: 0,
612
+ stopped: 0,
613
+ pendingStarts: 0,
614
+ claude: 0,
615
+ codex: 0,
616
+ acpx: 0,
617
+ };
618
+ for (const run of runs) {
619
+ if (run.backend === "claude")
620
+ counts.claude += 1;
621
+ if (run.backend === "codex")
622
+ counts.codex += 1;
623
+ if (run.backend === "acpx")
624
+ counts.acpx += 1;
625
+ if (isContextRunningStatus(run.status))
626
+ counts.running += 1;
627
+ if (isContextWaitingStatus(run.status))
628
+ counts.waiting += 1;
629
+ if (run.status === "created")
630
+ counts.pendingStarts += 1;
631
+ if (run.status === "merged")
632
+ counts.merged += 1;
633
+ if (run.status === "failed")
634
+ counts.failed += 1;
635
+ if (run.status === "cancelled")
636
+ counts.stopped += 1;
637
+ if (isContextActiveStatus(run.status)) {
638
+ active.push(run);
639
+ }
640
+ if (isContextReadyStatus(run.status)) {
641
+ ready.push(run);
642
+ counts.ready += 1;
643
+ if (runHasMergeSource(run)) {
644
+ counts.mergeReady += 1;
645
+ }
646
+ }
647
+ if (isContextCompletedStatus(run.status)) {
648
+ completed.push(run);
649
+ }
650
+ }
651
+ return { active, ready, completed, counts };
652
+ }
653
+ function formatRunSnapshot(context) {
654
+ const { counts } = context;
655
+ return [
656
+ `- totals: total=${counts.total} running=${counts.running} waiting=${counts.waiting} done=${counts.ready} merged=${counts.merged} failed=${counts.failed} stopped=${counts.stopped} pending-starts=${counts.pendingStarts}`,
657
+ `- active-now: running=${counts.running} waiting=${counts.waiting} review-ready=${counts.ready} merge-ready=${counts.mergeReady} pending-starts=${counts.pendingStarts}`,
658
+ `- completed: merged=${counts.merged} failed=${counts.failed} stopped=${counts.stopped}`,
659
+ `- backends: claude=${counts.claude} codex=${counts.codex} acpx=${counts.acpx}`,
660
+ `- ready-to-act: review-ready=${counts.ready} merge-ready=${counts.mergeReady}`,
661
+ `- archived: terminal-history=${context.completed.length}`,
662
+ ];
663
+ }
664
+ function formatAgentContextRun(run) {
665
+ const location = run.workspace.enabled
666
+ ? `${workspaceKind(run)}=${shortenHome(run.workspace.path)}`
667
+ : "current checkout";
668
+ const prompt = run.currentPrompt && run.currentPrompt !== run.task ? ` current="${previewAgentContext(run.currentPrompt, 140)}"` : "";
669
+ return `- ${run.id}: ${run.status}, ${run.backend}, ${location}, task="${previewAgentContext(run.task, 140)}"${prompt}`;
670
+ }
671
+ function previewAgentContext(value, max) {
672
+ const normalized = redactSharedSecretValues(value).replace(/\s+/g, " ").replace(/"/g, '\\"').trim();
673
+ return normalized.length > max ? `${normalized.slice(0, max)}...` : normalized;
674
+ }
675
+ async function writeRudderContextFiles(repoRoot, runs, content) {
676
+ await ensureLine(path.join(repoRoot, ".gitignore"), "RUDDER.md");
677
+ await ensureLine(path.join(repoRoot, ".gitignore"), SHARED_CONTEXT_FILE);
678
+ // Workspaces live INSIDE the project at <repo>/<agent-workspaces-dir>; ignore them in the
679
+ // user's repo so each worker checkout does not show up as untracked files (Rust parity:
680
+ // gitio.rs write_rudder_context).
681
+ await ensureLine(path.join(repoRoot, ".gitignore"), `${AGENT_WORKSPACES_DIR}/`);
682
+ await ensureLine(path.join(repoRoot, ".gitignore"), `${LEGACY_AGENT_WORKSPACES_DIR}/`);
683
+ const workspaces = new Set([repoRoot]);
684
+ for (const run of runs) {
685
+ if (run.workspace.path && await pathExists(run.workspace.path)) {
686
+ workspaces.add(run.workspace.path);
687
+ }
688
+ }
689
+ // RUDDER.md has concurrent writer processes (Rust TUI, other CLI invocations,
690
+ // the daemon); the lock keeps this read-modify-write from dropping their output.
691
+ await withRudderMdLock(repoRoot, async () => {
692
+ for (const workspace of workspaces) {
693
+ await ensureRudderExcluded(workspace);
694
+ await ensureDir(path.dirname(agentContextPath(workspace)));
695
+ const filePath = agentContextPath(workspace);
696
+ const existing = await fsp.readFile(filePath, "utf8").catch(() => "");
697
+ await fsp.writeFile(filePath, mergeGeneratedRudderMd(existing, content), "utf8");
698
+ }
699
+ });
700
+ await syncSharedContextToWorkspaces(repoRoot, workspaces);
701
+ }
702
+ async function ensureRudderExcluded(workspace) {
703
+ const result = await runCommand("git", ["rev-parse", "--git-path", "info/exclude"], {
704
+ cwd: workspace,
705
+ allowFailure: true,
706
+ });
707
+ const excludePath = result.stdout.trim();
708
+ if (!excludePath) {
709
+ return;
710
+ }
711
+ await ensureLine(path.resolve(workspace, excludePath), "RUDDER.md");
712
+ await ensureLine(path.resolve(workspace, excludePath), SHARED_CONTEXT_FILE);
713
+ }
714
+ async function ensureLine(filePath, line) {
715
+ const existing = await fsp.readFile(filePath, "utf8").catch(() => "");
716
+ const lines = existing.split(/\r?\n/).map((item) => item.trim());
717
+ if (lines.includes(line)) {
718
+ return;
719
+ }
720
+ const prefix = existing && !existing.endsWith("\n") ? "\n" : "";
721
+ await ensureDir(path.dirname(filePath));
722
+ await fsp.appendFile(filePath, `${prefix}${line}\n`, "utf8");
723
+ }
724
+ function delay(ms) {
725
+ return new Promise((resolve) => setTimeout(resolve, ms));
726
+ }
727
+ async function createRunWorkspace(params) {
728
+ const workspace = await createRunJjWorkspace(params);
729
+ const jjChangeId = await currentJjChangeId(workspace.path);
730
+ return {
731
+ path: workspace.path,
732
+ workspaceName: workspace.workspaceName,
733
+ jjChangeId: jjChangeId || undefined,
734
+ };
735
+ }
736
+ // Route through the run's recorded vcs. New runs are always jj; legacy git
737
+ // workspace runs (run.vcs === "git") still merge/sync through the git helpers.
738
+ async function mergeRunIntoCurrentBranch(run, allowDirty, strategy) {
739
+ if ((run.vcs ?? "git") === "jj") {
740
+ return await mergeJjRunIntoCurrentWorkspace(run, allowDirty);
741
+ }
742
+ return await mergeGitRunIntoCurrentBranch(run, allowDirty, strategy);
743
+ }
744
+ async function syncRunWorkspace(run, baseBranch) {
745
+ if ((run.vcs ?? "git") === "jj") {
746
+ return await syncJjRunWorkspace(run, baseBranch);
747
+ }
748
+ return await syncGitRunWorktree(run, baseBranch);
749
+ }
750
+ async function removeRunWorkspace(run, force = true) {
751
+ if ((run.vcs ?? "git") === "jj") {
752
+ await removeJjRunWorkspace(run);
753
+ return;
754
+ }
755
+ if (!run.workspace.enabled) {
756
+ return;
757
+ }
758
+ await removeGitWorktree(run.repoRoot, run.workspace.path, force);
759
+ }
760
+ async function baseRevision(repoRoot) {
761
+ return (await currentJjChangeId(repoRoot)) || "";
762
+ }
763
+ async function targetRevision(repoRoot) {
764
+ const branch = await currentBranch(repoRoot);
765
+ if (branch && branch !== "HEAD")
766
+ return branch;
767
+ return (await closestLocalBookmark(repoRoot)) || "main";
768
+ }
769
+ function effortForBackend(backend, config) {
770
+ if (backend === "claude") {
771
+ return config.backends.claude?.effort;
772
+ }
773
+ if (backend === "codex") {
774
+ return config.backends.codex?.reasoningEffort ?? config.backends.codex?.effort;
775
+ }
776
+ // opencode has no effort flag; normalizeEffortForBackend drops it anyway.
777
+ if (backend === "opencode") {
778
+ return undefined;
779
+ }
780
+ return config.backends.acpx?.reasoningEffort ?? config.backends.acpx?.effort;
781
+ }
782
+ function shortRunTask(run) {
783
+ return taskDisplayLabel(run, 34) || "agent";
784
+ }
785
+ function workspaceKind(run) {
786
+ return (run.vcs ?? "git") === "jj" ? "jj-workspace" : "git-worktree";
787
+ }
788
+ export async function statusRuns(options) {
789
+ const repoRoot = findRepoRoot();
790
+ const runs = await listRuns(repoRoot);
791
+ const active = runs.filter((run) => isActiveStatus(run.status));
792
+ if (options?.json) {
793
+ console.log(JSON.stringify({ repoRoot, active, runs }, null, 2));
794
+ return;
795
+ }
796
+ console.log(`Repo: ${repoRoot}`);
797
+ if (active.length === 0) {
798
+ console.log("No active runs.");
799
+ return;
800
+ }
801
+ for (const run of active) {
802
+ const alive = runProcessAlive(run) ? "alive" : "stale";
803
+ console.log(`${run.id} ${run.status} ${alive} ${run.backend} ${run.task}`);
804
+ }
805
+ }
806
+ export async function listProjectRuns(options) {
807
+ const repoRoot = findRepoRoot();
808
+ const runs = await listRuns(repoRoot);
809
+ if (options?.json) {
810
+ console.log(JSON.stringify(runs, null, 2));
811
+ return;
812
+ }
813
+ for (const run of runs) {
814
+ const wt = run.workspace.enabled ? ` ${workspaceKind(run)}=${shortenHome(run.workspace.path)}` : "";
815
+ console.log(`${run.id} ${run.status} ${run.backend}${wt} ${run.task}`);
816
+ }
817
+ }
818
+ export async function watchRun(params) {
819
+ const repoRoot = params.repoRoot ?? findRepoRoot();
820
+ const run = await resolveRun(repoRoot, params.runId);
821
+ if (!run) {
822
+ throw new Error("No runs found.");
823
+ }
824
+ const file = eventsPath(repoRoot, run.id);
825
+ await waitForFile(file);
826
+ let offset = 0;
827
+ const initial = await fsp.readFile(file, "utf8").catch(() => "");
828
+ offset = Buffer.byteLength(initial);
829
+ const view = params.view ?? "default";
830
+ const renderer = createEventRenderer(view);
831
+ renderer.print(initial);
832
+ if (!params.follow) {
833
+ return;
834
+ }
835
+ const alreadyDone = await loadRunRecord(repoRoot, run.id);
836
+ if (alreadyDone && !isActiveStatus(alreadyDone.status)) {
837
+ if (params.exitOnComplete !== false) {
838
+ process.exitCode = terminalExitCode(alreadyDone.status);
839
+ }
840
+ return;
841
+ }
842
+ if (view === "default") {
843
+ console.log(`Watching ${run.id}. Ctrl-C detaches; use 'rudder stop ${run.id}' to cancel.`);
844
+ }
845
+ await followFile(file, offset, async (chunk) => {
846
+ renderer.print(chunk);
847
+ const latest = await loadRunRecord(repoRoot, run.id);
848
+ if (latest && !isActiveStatus(latest.status)) {
849
+ if (params.exitOnComplete === false) {
850
+ renderer.finish();
851
+ return false;
852
+ }
853
+ renderer.finish();
854
+ process.exitCode = terminalExitCode(latest.status);
855
+ process.exit();
856
+ }
857
+ return true;
858
+ }, params.signal);
859
+ }
860
+ export async function printLogs(runId, follow = false) {
861
+ const repoRoot = findRepoRoot();
862
+ const run = await resolveRun(repoRoot, runId);
863
+ if (!run) {
864
+ throw new Error("No runs found.");
865
+ }
866
+ const file = outputPath(repoRoot, run.id);
867
+ if (!(await pathExists(file))) {
868
+ return;
869
+ }
870
+ let offset = 0;
871
+ await new Promise((resolve, reject) => {
872
+ const stream = fs.createReadStream(file);
873
+ stream.on("data", (chunk) => {
874
+ offset += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk);
875
+ process.stdout.write(chunk);
876
+ });
877
+ stream.on("error", reject);
878
+ stream.on("end", resolve);
879
+ });
880
+ if (follow) {
881
+ await followFile(file, offset, async (chunk) => {
882
+ process.stdout.write(chunk);
883
+ });
884
+ }
885
+ }
886
+ export async function stopRun(runId, options) {
887
+ const repoRoot = findRepoRoot();
888
+ const run = await loadRunRecord(repoRoot, runId);
889
+ if (!run) {
890
+ throw new Error(`Run not found: ${runId}`);
891
+ }
892
+ // The native dashboard owns interactive PTYs in another process. Record the
893
+ // stop intent first so its poll loop observes cancellation before child-exit
894
+ // handling can classify our SIGTERM/SIGKILL as a failure. Headless workers
895
+ // also tolerate the marker; it is removed by the native owner when present.
896
+ await writeJson(stopRequestPath(repoRoot, runId), { requestedAt: nowIso() });
897
+ await interruptWorkerAttempt(repoRoot, run);
898
+ run.status = "cancelled";
899
+ run.process = {
900
+ ...(run.process ?? {}),
901
+ // Invalidate every callback still carrying the old ownership token.
902
+ attemptId: newAttemptId(),
903
+ endedAt: nowIso(),
904
+ signal: "SIGTERM",
905
+ };
906
+ await saveRunRecord(run);
907
+ await emit(run, { ts: nowIso(), runId, type: "run.cancelled", message: "Run cancelled" });
908
+ await writeAgentContext(repoRoot);
909
+ if (!options?.silent) {
910
+ console.log(`Stopped ${runId}`);
911
+ }
912
+ }
913
+ export async function mergeRun(runId, allowDirty = false, options) {
914
+ const repoRoot = findRepoRoot();
915
+ const run = await loadRunRecord(repoRoot, runId);
916
+ if (!run) {
917
+ throw new Error(`Run not found: ${runId}`);
918
+ }
919
+ if (run.status === "merged") {
920
+ // SELF-HEAL before refusing. A run can be merged while the graph node it owns
921
+ // still reads review — the run merged through a path that did not reconcile
922
+ // the DAG. Left alone the node is stranded forever: its work is integrated,
923
+ // but everything hard-depending on it stays unlaunchable. Reconcile, then
924
+ // report the refusal.
925
+ await markGraphNodeMerged(repoRoot, run);
926
+ throw new Error(`Run ${runId} is already merged; re-running the merge would create a spurious second merge.`);
927
+ }
928
+ if (isActiveStatus(run.status) && !hasBlockedIntegration(run)) {
929
+ throw new Error(`Run ${runId} is still active; stop it or wait for it to finish before merging.`);
930
+ }
931
+ const config = await loadConfig();
932
+ const merged = await mergeRunIntoCurrentBranch(run, allowDirty, config.mergeStrategy);
933
+ await emit(merged, {
934
+ ts: nowIso(),
935
+ runId,
936
+ type: "merge.result",
937
+ message: mergeResultMessage(merged),
938
+ data: (merged.merge ?? {}),
939
+ });
940
+ if (merged.merge?.status === "merged") {
941
+ if ((merged.vcs ?? "git") === "jj") {
942
+ await exportToGit(repoRoot);
943
+ }
944
+ // A merged RUN is not yet a merged NODE. Without this the DAG never learns
945
+ // its work landed: the node keeps its old status, everything hard-depending
946
+ // on it stays unlaunchable, and the plan stalls with finished, integrated
947
+ // work sitting in review. That is how a whole plan's back half went dark
948
+ // behind five already-merged nodes.
949
+ await markGraphNodeMerged(repoRoot, merged);
950
+ await writeAgentContext(repoRoot);
951
+ if (!options?.silent) {
952
+ console.log(`Merged ${runId}`);
953
+ }
954
+ return merged;
955
+ }
956
+ await writeAgentContext(repoRoot);
957
+ if (!options?.silent) {
958
+ if (merged.merge?.status === "conflict") {
959
+ const kind = merged.merge.conflictKind === "rebase" ? "Rebase conflict" : "Merge conflict";
960
+ console.log(`${kind} for ${runId}`);
961
+ for (const file of merged.merge?.conflictedFiles ?? []) {
962
+ console.log(` ${file}`);
963
+ }
964
+ if (merged.merge.conflictKind === "rebase") {
965
+ console.log(`Resolve in ${shortenHome(merged.workspace.path)}, run git rebase --continue, then retry rudder merge ${runId}.`);
966
+ }
967
+ }
968
+ else {
969
+ console.log(`Merge failed for ${runId}: ${merged.merge?.error ?? "unknown error"}`);
970
+ }
971
+ }
972
+ return merged;
973
+ }
974
+ export async function syncRun(runId, options) {
975
+ const repoRoot = findRepoRoot();
976
+ const run = await resolveRun(repoRoot, runId);
977
+ if (!run) {
978
+ throw new Error("No runs found.");
979
+ }
980
+ const hasIsolation = run.workspace.enabled && (run.workspace.workspaceName || run.workspace.branch);
981
+ if (!hasIsolation) {
982
+ throw new Error(`Run ${run.id} has no workspace to sync.`);
983
+ }
984
+ if (isActiveStatus(run.status)) {
985
+ throw new Error(`Run ${run.id} is still active; wait for it to finish before syncing.`);
986
+ }
987
+ if (run.status === "merged") {
988
+ throw new Error(`Run ${run.id} is already merged.`);
989
+ }
990
+ const current = await currentBranch(repoRoot);
991
+ const baseBranch = current === "HEAD" ? run.targetBranch : current;
992
+ const synced = await syncRunWorkspace(run, baseBranch);
993
+ await emit(synced, {
994
+ ts: nowIso(),
995
+ runId: synced.id,
996
+ type: "sync.result",
997
+ message: syncResultMessage(synced),
998
+ data: (synced.sync ?? {}),
999
+ });
1000
+ await writeAgentContext(repoRoot);
1001
+ if (!options?.silent) {
1002
+ if (synced.sync?.status === "synced") {
1003
+ console.log(`Synced ${synced.id} with ${synced.sync.baseBranch ?? synced.targetBranch}`);
1004
+ }
1005
+ else if (synced.sync?.status === "conflict") {
1006
+ console.log(`Rebase conflict for ${synced.id}`);
1007
+ for (const file of synced.sync.conflictedFiles ?? []) {
1008
+ console.log(` ${file}`);
1009
+ }
1010
+ console.log(`Resolve in ${shortenHome(synced.workspace.path)}, run git rebase --continue, then retry rudder sync ${synced.id}.`);
1011
+ }
1012
+ else {
1013
+ console.log(`Sync failed for ${synced.id}: ${synced.sync?.error ?? "unknown error"}`);
1014
+ }
1015
+ }
1016
+ return synced;
1017
+ }
1018
+ export async function deleteRun(runId, options) {
1019
+ const repoRoot = findRepoRoot();
1020
+ const run = await loadRunRecord(repoRoot, runId);
1021
+ if (!run) {
1022
+ throw new Error(`Run not found: ${runId}`);
1023
+ }
1024
+ let mergeError;
1025
+ if (options?.mergeFirst) {
1026
+ try {
1027
+ await mergeRun(runId, false, { silent: true });
1028
+ }
1029
+ catch (error) {
1030
+ mergeError = error;
1031
+ }
1032
+ }
1033
+ const latest = await loadRunRecord(repoRoot, runId) ?? run;
1034
+ if (options?.mergeFirst && latest.merge?.status === "conflict") {
1035
+ throw new Error(`Merge conflict for ${runId}; resolve it before deleting the run.`);
1036
+ }
1037
+ if (mergeError && !options?.force) {
1038
+ const message = mergeError instanceof Error ? mergeError.message : String(mergeError);
1039
+ throw new Error(`Merge failed for ${runId}; run was not deleted. ${message}`);
1040
+ }
1041
+ await interruptWorkerAttempt(repoRoot, latest);
1042
+ if (latest.workspace.enabled) {
1043
+ await removeRunWorkspace(latest, options?.force ?? true).catch(() => undefined);
1044
+ }
1045
+ await fsp.rm(runDir(repoRoot, runId), { recursive: true, force: true });
1046
+ await writeAgentContext(repoRoot);
1047
+ if (!options?.silent) {
1048
+ console.log(`Deleted ${runId}`);
1049
+ }
1050
+ }
1051
+ function mergeResultMessage(run) {
1052
+ if (run.merge?.status === "merged") {
1053
+ return run.merge.strategy === "rebase"
1054
+ ? "Rebased and fast-forward merged successfully"
1055
+ : "Merged successfully";
1056
+ }
1057
+ if (run.merge?.status === "conflict") {
1058
+ const files = (run.merge.conflictedFiles ?? []).join(", ") || "unknown files";
1059
+ if (run.merge.conflictKind === "rebase") {
1060
+ return `Rebase conflict before merge: ${files}. Resolve in the workspace, run git rebase --continue, then retry merge.`;
1061
+ }
1062
+ return `Merge conflict: ${files}`;
1063
+ }
1064
+ if (run.merge?.status === "failed") {
1065
+ return `Merge failed: ${run.merge.error ?? "unknown error"}`;
1066
+ }
1067
+ return "Merge did not complete";
1068
+ }
1069
+ function syncResultMessage(run) {
1070
+ if (run.sync?.status === "synced") {
1071
+ return `Synced with ${run.sync.baseBranch ?? run.targetBranch}`;
1072
+ }
1073
+ if (run.sync?.status === "conflict") {
1074
+ const files = (run.sync.conflictedFiles ?? []).join(", ") || "unknown files";
1075
+ return `Rebase conflict while syncing: ${files}. Resolve in the workspace, run git rebase --continue, then retry sync.`;
1076
+ }
1077
+ if (run.sync?.status === "failed") {
1078
+ return `Sync failed: ${run.sync.error ?? "unknown error"}`;
1079
+ }
1080
+ return "Sync did not complete";
1081
+ }
1082
+ export async function cleanupRuns(force = false) {
1083
+ const repoRoot = findRepoRoot();
1084
+ const runs = await listRuns(repoRoot);
1085
+ for (const run of runs) {
1086
+ if (!canCleanupRun(run, force)) {
1087
+ continue;
1088
+ }
1089
+ try {
1090
+ await removeRunWorkspace(run, force);
1091
+ console.log(`Removed ${shortenHome(run.workspace.path)}`);
1092
+ }
1093
+ catch {
1094
+ // Best-effort cleanup matches the existing git workspace behavior.
1095
+ }
1096
+ }
1097
+ await writeAgentContext(repoRoot);
1098
+ }
1099
+ function canCleanupRun(run, force) {
1100
+ if (!run.workspace.enabled) {
1101
+ return false;
1102
+ }
1103
+ if (force) {
1104
+ return true;
1105
+ }
1106
+ if ((run.vcs ?? "git") === "jj") {
1107
+ return run.status === "merged" || run.status === "completed";
1108
+ }
1109
+ return run.status === "merged";
1110
+ }
1111
+ async function emit(run, event) {
1112
+ await ensureDir(runDir(run.repoRoot, run.id));
1113
+ await appendEvent(run.repoRoot, event);
1114
+ }
1115
+ async function waitForFile(file) {
1116
+ for (let i = 0; i < 50; i += 1) {
1117
+ if (await pathExists(file)) {
1118
+ return;
1119
+ }
1120
+ await new Promise((resolve) => setTimeout(resolve, 100));
1121
+ }
1122
+ }
1123
+ function createEventRenderer(view) {
1124
+ let sawStreamingText = false;
1125
+ let partialOpen = false;
1126
+ return {
1127
+ print(raw) {
1128
+ for (const line of raw.split(/\r?\n/)) {
1129
+ if (!line.trim()) {
1130
+ continue;
1131
+ }
1132
+ try {
1133
+ const event = JSON.parse(line);
1134
+ if (view === "shell") {
1135
+ const rendered = renderShellEvent(event, {
1136
+ sawStreamingText,
1137
+ partialOpen,
1138
+ });
1139
+ sawStreamingText = rendered.sawStreamingText;
1140
+ partialOpen = rendered.partialOpen;
1141
+ if (rendered.text) {
1142
+ if (rendered.inline) {
1143
+ process.stdout.write(rendered.text);
1144
+ }
1145
+ else {
1146
+ if (partialOpen) {
1147
+ process.stdout.write("\n");
1148
+ partialOpen = false;
1149
+ }
1150
+ console.log(rendered.text);
1151
+ }
1152
+ }
1153
+ continue;
1154
+ }
1155
+ printDefaultEvent(event);
1156
+ }
1157
+ catch {
1158
+ console.log(line);
1159
+ }
1160
+ }
1161
+ },
1162
+ finish() {
1163
+ if (partialOpen) {
1164
+ process.stdout.write("\n");
1165
+ partialOpen = false;
1166
+ }
1167
+ },
1168
+ };
1169
+ }
1170
+ function printDefaultEvent(event) {
1171
+ if (event.type === "backend.output" || event.type === "backend.error") {
1172
+ if (event.message) {
1173
+ console.log(event.message);
1174
+ }
1175
+ else if (event.data) {
1176
+ const text = formatBackendData(event.data);
1177
+ if (text) {
1178
+ console.log(text);
1179
+ }
1180
+ }
1181
+ }
1182
+ else if (event.message) {
1183
+ console.log(`[rudder] ${event.message}`);
1184
+ }
1185
+ }
1186
+ function renderShellEvent(event, state) {
1187
+ let sawStreamingText = state.sawStreamingText;
1188
+ let partialOpen = state.partialOpen;
1189
+ if (event.type === "run.created") {
1190
+ const message = event.message?.startsWith("Created jj workspace ")
1191
+ ? event.message.replace("Created jj workspace ", "workspace ")
1192
+ : event.message?.startsWith("Created workspace ")
1193
+ ? event.message.replace("Created workspace ", "workspace ")
1194
+ : undefined;
1195
+ return { text: message, sawStreamingText, partialOpen };
1196
+ }
1197
+ if (event.type === "planner.spec") {
1198
+ return { sawStreamingText, partialOpen };
1199
+ }
1200
+ if (event.type === "run.started") {
1201
+ const command = objectField(event.data, "command");
1202
+ return {
1203
+ text: command ? `running ${command}` : undefined,
1204
+ sawStreamingText,
1205
+ partialOpen,
1206
+ };
1207
+ }
1208
+ if (event.type === "backend.output" || event.type === "backend.error") {
1209
+ const rendered = renderBackendForShell(event.data ?? event.message, event.type === "backend.error", sawStreamingText);
1210
+ if (rendered.sawStreamingText) {
1211
+ sawStreamingText = true;
1212
+ }
1213
+ if (rendered.inline) {
1214
+ partialOpen = true;
1215
+ }
1216
+ else if (rendered.text) {
1217
+ partialOpen = false;
1218
+ }
1219
+ return { ...rendered, sawStreamingText, partialOpen };
1220
+ }
1221
+ if (event.type === "backend.exit") {
1222
+ return { sawStreamingText, partialOpen };
1223
+ }
1224
+ if (event.type === "verifier.result") {
1225
+ const missing = Array.isArray(event.data?.missing)
1226
+ ? event.data.missing.filter((item) => typeof item === "string")
1227
+ : [];
1228
+ return {
1229
+ text: missing.length ? `verification needs follow-up: ${missing.join("; ")}` : undefined,
1230
+ sawStreamingText,
1231
+ partialOpen,
1232
+ };
1233
+ }
1234
+ if (event.type === "run.completed") {
1235
+ return { text: "done", sawStreamingText, partialOpen };
1236
+ }
1237
+ if (event.type === "run.failed") {
1238
+ return { text: event.message ? `failed: ${event.message}` : "failed", sawStreamingText, partialOpen };
1239
+ }
1240
+ if (event.type === "run.cancelled") {
1241
+ return { text: "cancelled", sawStreamingText, partialOpen };
1242
+ }
1243
+ if (event.type === "merge.result") {
1244
+ return { text: event.message, sawStreamingText, partialOpen };
1245
+ }
1246
+ return { sawStreamingText, partialOpen };
1247
+ }
1248
+ function renderBackendForShell(data, stderr, sawStreamingText) {
1249
+ if (typeof data === "string") {
1250
+ return { text: stderr ? `error: ${data}` : data };
1251
+ }
1252
+ if (!data || typeof data !== "object") {
1253
+ return {};
1254
+ }
1255
+ const record = data;
1256
+ if (record.type === "stream_event" && isRecord(record.event)) {
1257
+ const event = record.event;
1258
+ if (event.type === "content_block_delta" && isRecord(event.delta) && typeof event.delta.text === "string") {
1259
+ return { text: event.delta.text, inline: true, sawStreamingText: true };
1260
+ }
1261
+ return {};
1262
+ }
1263
+ if (record.type === "assistant") {
1264
+ if (sawStreamingText) {
1265
+ return {};
1266
+ }
1267
+ const text = textFromAssistantMessage(record.message);
1268
+ return text ? { text } : {};
1269
+ }
1270
+ if (record.type === "result") {
1271
+ if (record.subtype === "success") {
1272
+ const text = typeof record.result === "string" && !sawStreamingText ? record.result.trim() : "";
1273
+ return text ? { text } : {};
1274
+ }
1275
+ const errors = Array.isArray(record.errors) ? record.errors.filter((item) => typeof item === "string") : [];
1276
+ return { text: `error: ${errors.join(", ") || String(record.subtype ?? "unknown")}` };
1277
+ }
1278
+ if (record.type === "system") {
1279
+ return {};
1280
+ }
1281
+ return {};
1282
+ }
1283
+ function objectField(data, key) {
1284
+ return isRecord(data) && typeof data[key] === "string" ? data[key] : undefined;
1285
+ }
1286
+ function formatBackendData(data) {
1287
+ if (typeof data === "string") {
1288
+ return data;
1289
+ }
1290
+ if (data && typeof data === "object") {
1291
+ const record = data;
1292
+ if (record.type === "system" || record.type === "rate_limit_event" || record.type === "tool_use_summary") {
1293
+ return "";
1294
+ }
1295
+ if (record.type === "stream_event" && isRecord(record.event)) {
1296
+ const event = record.event;
1297
+ if (event.type === "content_block_delta" && isRecord(event.delta) && typeof event.delta.text === "string") {
1298
+ return event.delta.text;
1299
+ }
1300
+ return "";
1301
+ }
1302
+ if (record.type === "assistant") {
1303
+ return textFromAssistantMessage(record.message);
1304
+ }
1305
+ if (record.type === "result") {
1306
+ if (record.subtype === "success" && typeof record.result === "string") {
1307
+ return record.result;
1308
+ }
1309
+ if (Array.isArray(record.errors)) {
1310
+ return record.errors.filter((item) => typeof item === "string").join(", ");
1311
+ }
1312
+ }
1313
+ for (const key of ["message", "text", "content", "delta"]) {
1314
+ if (typeof record[key] === "string") {
1315
+ return record[key];
1316
+ }
1317
+ }
1318
+ if (typeof record.type === "string") {
1319
+ return `[${record.type}] ${JSON.stringify(record)}`;
1320
+ }
1321
+ }
1322
+ return JSON.stringify(data);
1323
+ }
1324
+ async function followFile(file, startOffset, onChunk, signal) {
1325
+ let offset = startOffset;
1326
+ while (!signal?.aborted) {
1327
+ const stat = await fsp.stat(file).catch(() => null);
1328
+ if (stat && stat.size > offset) {
1329
+ const handle = await fsp.open(file, "r");
1330
+ try {
1331
+ const length = stat.size - offset;
1332
+ const buffer = Buffer.alloc(length);
1333
+ await handle.read(buffer, 0, length, offset);
1334
+ offset = stat.size;
1335
+ const keepGoing = await onChunk(buffer.toString("utf8"));
1336
+ if (keepGoing === false) {
1337
+ return;
1338
+ }
1339
+ }
1340
+ finally {
1341
+ await handle.close();
1342
+ }
1343
+ }
1344
+ await new Promise((resolve) => setTimeout(resolve, 350));
1345
+ }
1346
+ }
1347
+ /**
1348
+ * A run whose merge already conflicted is FINISHED work whose integration
1349
+ * failed — not a live agent. Its status can still read "running" (the native
1350
+ * side leaves the row live while a conflict is pending), which made the active
1351
+ * guard refuse the merge forever: the only way to retry integration was the very
1352
+ * command the guard blocked. Observed in the field as finished nodes parked in
1353
+ * review while every node hard-depending on them stayed unlaunchable.
1354
+ */
1355
+ function hasBlockedIntegration(run) {
1356
+ return run.merge?.status === "conflict" || Boolean(run.hadMergeConflict && run.merge);
1357
+ }
1358
+ /**
1359
+ * Flip the graph node this run owns to `merged` so the scheduler can see that
1360
+ * its hard dependents are now free to launch. No-op when the run owns no node
1361
+ * (an ad-hoc `rudder run`) or the node is already merged.
1362
+ */
1363
+ async function markGraphNodeMerged(repoRoot, run) {
1364
+ await updateGraph(repoRoot, (graph) => {
1365
+ const node = Object.values(graph.nodes).find((candidate) => candidate.runId === run.id);
1366
+ if (!node || node.status === "merged") {
1367
+ return graph;
1368
+ }
1369
+ node.status = "merged";
1370
+ node.merge = run.merge;
1371
+ node.updatedAt = nowIso();
1372
+ return graph;
1373
+ }).catch(() => undefined);
1374
+ }
1375
+ function isActiveStatus(status) {
1376
+ return status === "created" || status === "running" || status === "steering" || status === "verifying";
1377
+ }
1378
+ function isContextActiveStatus(status) {
1379
+ return isActiveStatus(status);
1380
+ }
1381
+ function isContextRunningStatus(status) {
1382
+ return status === "running" || status === "verifying";
1383
+ }
1384
+ function isContextWaitingStatus(status) {
1385
+ return status === "steering";
1386
+ }
1387
+ function isContextReadyStatus(status) {
1388
+ return status === "completed" || status === "merge-conflict";
1389
+ }
1390
+ function isContextCompletedStatus(status) {
1391
+ return status === "merged" || status === "failed" || status === "cancelled";
1392
+ }
1393
+ function runHasMergeSource(run) {
1394
+ return Boolean(run.workspace.enabled &&
1395
+ (run.workspace.path || run.workspace.branch || run.workspace.workspaceName || run.workspace.jjChangeId));
1396
+ }
1397
+ function terminalExitCode(status) {
1398
+ return status === "completed" || status === "merged" ? 0 : 1;
1399
+ }
1400
+ //# sourceMappingURL=run-manager.js.map