kantban-cli 0.1.8 → 0.1.11

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 (155) hide show
  1. package/dist/chunk-ZCUIGFSP.js +4111 -0
  2. package/dist/chunk-ZCUIGFSP.js.map +1 -0
  3. package/dist/context-7YDNTI3P.js +30 -0
  4. package/dist/context-7YDNTI3P.js.map +1 -0
  5. package/dist/cron-OKQP6QDF.js +112 -0
  6. package/dist/cron-OKQP6QDF.js.map +1 -0
  7. package/dist/index.d.ts +0 -2
  8. package/dist/index.js +179 -44
  9. package/dist/index.js.map +1 -1
  10. package/dist/pipeline-7LG74YA2.js +4098 -0
  11. package/dist/pipeline-7LG74YA2.js.map +1 -0
  12. package/dist/pipeline-init-IGZZOOLK.js +103 -0
  13. package/dist/pipeline-init-IGZZOOLK.js.map +1 -0
  14. package/dist/status-4GFXMVIM.js +128 -0
  15. package/dist/status-4GFXMVIM.js.map +1 -0
  16. package/dist/work-2V33NZAT.js +81 -0
  17. package/dist/work-2V33NZAT.js.map +1 -0
  18. package/package.json +5 -4
  19. package/dist/client.d.ts +0 -38
  20. package/dist/client.d.ts.map +0 -1
  21. package/dist/client.js +0 -163
  22. package/dist/client.js.map +0 -1
  23. package/dist/commands/context.d.ts +0 -3
  24. package/dist/commands/context.d.ts.map +0 -1
  25. package/dist/commands/context.js +0 -27
  26. package/dist/commands/context.js.map +0 -1
  27. package/dist/commands/cron.d.ts +0 -3
  28. package/dist/commands/cron.d.ts.map +0 -1
  29. package/dist/commands/cron.js +0 -106
  30. package/dist/commands/cron.js.map +0 -1
  31. package/dist/commands/pipeline-init.d.ts +0 -2
  32. package/dist/commands/pipeline-init.d.ts.map +0 -1
  33. package/dist/commands/pipeline-init.js +0 -100
  34. package/dist/commands/pipeline-init.js.map +0 -1
  35. package/dist/commands/pipeline.d.ts +0 -4
  36. package/dist/commands/pipeline.d.ts.map +0 -1
  37. package/dist/commands/pipeline.js +0 -1222
  38. package/dist/commands/pipeline.js.map +0 -1
  39. package/dist/commands/status.d.ts +0 -3
  40. package/dist/commands/status.d.ts.map +0 -1
  41. package/dist/commands/status.js +0 -135
  42. package/dist/commands/status.js.map +0 -1
  43. package/dist/commands/work.d.ts +0 -3
  44. package/dist/commands/work.d.ts.map +0 -1
  45. package/dist/commands/work.js +0 -76
  46. package/dist/commands/work.js.map +0 -1
  47. package/dist/index.d.ts.map +0 -1
  48. package/dist/lib/advisor.d.ts +0 -108
  49. package/dist/lib/advisor.d.ts.map +0 -1
  50. package/dist/lib/advisor.js +0 -139
  51. package/dist/lib/advisor.js.map +0 -1
  52. package/dist/lib/checkpoint.d.ts +0 -15
  53. package/dist/lib/checkpoint.d.ts.map +0 -1
  54. package/dist/lib/checkpoint.js +0 -49
  55. package/dist/lib/checkpoint.js.map +0 -1
  56. package/dist/lib/constraint-evaluator.d.ts +0 -40
  57. package/dist/lib/constraint-evaluator.d.ts.map +0 -1
  58. package/dist/lib/constraint-evaluator.js +0 -189
  59. package/dist/lib/constraint-evaluator.js.map +0 -1
  60. package/dist/lib/cost-tracker.d.ts +0 -46
  61. package/dist/lib/cost-tracker.d.ts.map +0 -1
  62. package/dist/lib/cost-tracker.js +0 -120
  63. package/dist/lib/cost-tracker.js.map +0 -1
  64. package/dist/lib/evaluator.d.ts +0 -17
  65. package/dist/lib/evaluator.d.ts.map +0 -1
  66. package/dist/lib/evaluator.js +0 -71
  67. package/dist/lib/evaluator.js.map +0 -1
  68. package/dist/lib/event-emitter.d.ts +0 -28
  69. package/dist/lib/event-emitter.d.ts.map +0 -1
  70. package/dist/lib/event-emitter.js +0 -100
  71. package/dist/lib/event-emitter.js.map +0 -1
  72. package/dist/lib/event-queue.d.ts +0 -28
  73. package/dist/lib/event-queue.d.ts.map +0 -1
  74. package/dist/lib/event-queue.js +0 -73
  75. package/dist/lib/event-queue.js.map +0 -1
  76. package/dist/lib/gate-config.d.ts +0 -7
  77. package/dist/lib/gate-config.d.ts.map +0 -1
  78. package/dist/lib/gate-config.js +0 -68
  79. package/dist/lib/gate-config.js.map +0 -1
  80. package/dist/lib/gate-proxy-server.d.ts +0 -16
  81. package/dist/lib/gate-proxy-server.d.ts.map +0 -1
  82. package/dist/lib/gate-proxy-server.js +0 -385
  83. package/dist/lib/gate-proxy-server.js.map +0 -1
  84. package/dist/lib/gate-proxy.d.ts +0 -46
  85. package/dist/lib/gate-proxy.d.ts.map +0 -1
  86. package/dist/lib/gate-proxy.js +0 -104
  87. package/dist/lib/gate-proxy.js.map +0 -1
  88. package/dist/lib/gate-runner.d.ts +0 -13
  89. package/dist/lib/gate-runner.d.ts.map +0 -1
  90. package/dist/lib/gate-runner.js +0 -104
  91. package/dist/lib/gate-runner.js.map +0 -1
  92. package/dist/lib/gate-snapshot.d.ts +0 -12
  93. package/dist/lib/gate-snapshot.d.ts.map +0 -1
  94. package/dist/lib/gate-snapshot.js +0 -49
  95. package/dist/lib/gate-snapshot.js.map +0 -1
  96. package/dist/lib/light-call.d.ts +0 -37
  97. package/dist/lib/light-call.d.ts.map +0 -1
  98. package/dist/lib/light-call.js +0 -62
  99. package/dist/lib/light-call.js.map +0 -1
  100. package/dist/lib/logger.d.ts +0 -22
  101. package/dist/lib/logger.d.ts.map +0 -1
  102. package/dist/lib/logger.js +0 -98
  103. package/dist/lib/logger.js.map +0 -1
  104. package/dist/lib/mcp-config.d.ts +0 -24
  105. package/dist/lib/mcp-config.d.ts.map +0 -1
  106. package/dist/lib/mcp-config.js +0 -115
  107. package/dist/lib/mcp-config.js.map +0 -1
  108. package/dist/lib/orchestrator.d.ts +0 -392
  109. package/dist/lib/orchestrator.d.ts.map +0 -1
  110. package/dist/lib/orchestrator.js +0 -1636
  111. package/dist/lib/orchestrator.js.map +0 -1
  112. package/dist/lib/parse-utils.d.ts +0 -6
  113. package/dist/lib/parse-utils.d.ts.map +0 -1
  114. package/dist/lib/parse-utils.js +0 -64
  115. package/dist/lib/parse-utils.js.map +0 -1
  116. package/dist/lib/prompt-composer.d.ts +0 -131
  117. package/dist/lib/prompt-composer.d.ts.map +0 -1
  118. package/dist/lib/prompt-composer.js +0 -317
  119. package/dist/lib/prompt-composer.js.map +0 -1
  120. package/dist/lib/ralph-loop.d.ts +0 -123
  121. package/dist/lib/ralph-loop.d.ts.map +0 -1
  122. package/dist/lib/ralph-loop.js +0 -383
  123. package/dist/lib/ralph-loop.js.map +0 -1
  124. package/dist/lib/reaper.d.ts +0 -14
  125. package/dist/lib/reaper.d.ts.map +0 -1
  126. package/dist/lib/reaper.js +0 -114
  127. package/dist/lib/reaper.js.map +0 -1
  128. package/dist/lib/replanner.d.ts +0 -49
  129. package/dist/lib/replanner.d.ts.map +0 -1
  130. package/dist/lib/replanner.js +0 -61
  131. package/dist/lib/replanner.js.map +0 -1
  132. package/dist/lib/run-memory.d.ts +0 -37
  133. package/dist/lib/run-memory.d.ts.map +0 -1
  134. package/dist/lib/run-memory.js +0 -115
  135. package/dist/lib/run-memory.js.map +0 -1
  136. package/dist/lib/stream-parser.d.ts +0 -20
  137. package/dist/lib/stream-parser.d.ts.map +0 -1
  138. package/dist/lib/stream-parser.js +0 -65
  139. package/dist/lib/stream-parser.js.map +0 -1
  140. package/dist/lib/stuck-detector.d.ts +0 -47
  141. package/dist/lib/stuck-detector.d.ts.map +0 -1
  142. package/dist/lib/stuck-detector.js +0 -105
  143. package/dist/lib/stuck-detector.js.map +0 -1
  144. package/dist/lib/tool-profiles.d.ts +0 -19
  145. package/dist/lib/tool-profiles.d.ts.map +0 -1
  146. package/dist/lib/tool-profiles.js +0 -22
  147. package/dist/lib/tool-profiles.js.map +0 -1
  148. package/dist/lib/worktree.d.ts +0 -12
  149. package/dist/lib/worktree.d.ts.map +0 -1
  150. package/dist/lib/worktree.js +0 -29
  151. package/dist/lib/worktree.js.map +0 -1
  152. package/dist/lib/ws-client.d.ts +0 -31
  153. package/dist/lib/ws-client.d.ts.map +0 -1
  154. package/dist/lib/ws-client.js +0 -113
  155. package/dist/lib/ws-client.js.map +0 -1
@@ -0,0 +1,4098 @@
1
+ import {
2
+ LoopCheckpointSchema,
3
+ RalphLoop,
4
+ VerdictSchema,
5
+ classifyTrajectory,
6
+ cleanupGateProxyConfigs,
7
+ cleanupMcpConfig,
8
+ composeStuckDetectionPrompt,
9
+ generateGateProxyMcpConfig,
10
+ generateMcpConfig,
11
+ parseGateConfig,
12
+ parseJsonFromLlmOutput,
13
+ parseStuckDetectionResponse,
14
+ parseTimeout,
15
+ resolveGatesForColumn
16
+ } from "./chunk-ZCUIGFSP.js";
17
+
18
+ // src/commands/pipeline.ts
19
+ import { spawn as spawn2, execSync } from "child_process";
20
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync3, readFileSync as readFileSync2, unlinkSync as unlinkSync2, existsSync, appendFileSync as appendFileSync2 } from "fs";
21
+ import { homedir } from "os";
22
+ import { join as join2 } from "path";
23
+
24
+ // src/lib/tool-profiles.ts
25
+ function resolveToolRestrictions(builtinTools, allowedTools, disallowedTools) {
26
+ const hasRestrictions = builtinTools !== void 0 || allowedTools && allowedTools.length > 0 || disallowedTools && disallowedTools.length > 0;
27
+ if (!hasRestrictions) {
28
+ return { includeMcpConfig: true };
29
+ }
30
+ return {
31
+ ...builtinTools !== void 0 && { tools: builtinTools },
32
+ ...allowedTools && allowedTools.length > 0 && { allowedTools },
33
+ ...disallowedTools && disallowedTools.length > 0 && { disallowedTools },
34
+ includeMcpConfig: true
35
+ };
36
+ }
37
+
38
+ // src/lib/worktree.ts
39
+ import { execFile as defaultExecFile } from "child_process";
40
+ function generateWorktreeName(ticketNumber, columnName) {
41
+ const slug = columnName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
42
+ return `kantban-${ticketNumber}-${slug}`;
43
+ }
44
+ async function cleanupWorktree(worktreeName, exec = defaultExecFile) {
45
+ return new Promise((resolve) => {
46
+ exec("git", ["worktree", "remove", "--force", worktreeName], (err) => {
47
+ if (err) {
48
+ console.error(`[worktree] cleanup failed for ${worktreeName}: ${err.message}`);
49
+ resolve(false);
50
+ } else {
51
+ resolve(true);
52
+ }
53
+ });
54
+ });
55
+ }
56
+ function execPromise(exec, cmd, args) {
57
+ return new Promise((resolve, reject) => {
58
+ exec(cmd, args, (err, stdout, stderr) => {
59
+ if (err) reject(Object.assign(err, { stdout, stderr }));
60
+ else resolve({ stdout, stderr });
61
+ });
62
+ });
63
+ }
64
+ async function mergeWorktreeBranch(worktreeName, integrationBranch, exec = defaultExecFile) {
65
+ try {
66
+ await execPromise(exec, "git", ["branch", integrationBranch, "HEAD"]).catch(() => {
67
+ });
68
+ const { stdout: baseOut } = await execPromise(exec, "git", ["merge-base", integrationBranch, worktreeName]);
69
+ const mergeBase = baseOut.trim();
70
+ const { stdout: integrationSha } = await execPromise(exec, "git", ["rev-parse", integrationBranch]);
71
+ if (integrationSha.trim() === mergeBase) {
72
+ const { stdout: worktreeSha } = await execPromise(exec, "git", ["rev-parse", worktreeName]);
73
+ await execPromise(exec, "git", ["update-ref", `refs/heads/${integrationBranch}`, worktreeSha.trim()]);
74
+ console.error(`[worktree] fast-forward merged ${worktreeName} \u2192 ${integrationBranch}`);
75
+ return true;
76
+ }
77
+ const tmpWorktree = `merge-tmp-${Date.now()}`;
78
+ try {
79
+ await execPromise(exec, "git", ["worktree", "add", tmpWorktree, integrationBranch]);
80
+ await execPromise(exec, "git", ["-C", tmpWorktree, "merge", "--no-edit", worktreeName]);
81
+ console.error(`[worktree] merged ${worktreeName} \u2192 ${integrationBranch}`);
82
+ } finally {
83
+ await execPromise(exec, "git", ["worktree", "remove", "--force", tmpWorktree]).catch(() => {
84
+ });
85
+ }
86
+ return true;
87
+ } catch (err) {
88
+ const msg = err instanceof Error ? err.message : String(err);
89
+ console.error(`[worktree] merge failed for ${worktreeName} \u2192 ${integrationBranch}: ${msg}`);
90
+ return false;
91
+ }
92
+ }
93
+
94
+ // src/lib/constraint-evaluator.ts
95
+ function resolveColumn(board, columnId, subjectRef) {
96
+ const ref = subjectRef ?? "self";
97
+ if (ref === "self" || ref === null) {
98
+ const col2 = board.columns.find((c) => c.id === columnId) ?? null;
99
+ return { column: col2, error: col2 ? null : `Column ${columnId} not found` };
100
+ }
101
+ if (ref === "next") {
102
+ const sorted = [...board.columns].sort((a, b) => a.position - b.position);
103
+ const idx = sorted.findIndex((c) => c.id === columnId);
104
+ const next = idx >= 0 && idx < sorted.length - 1 ? sorted[idx + 1] : null;
105
+ return { column: next, error: next ? null : "No next column exists" };
106
+ }
107
+ if (ref === "prev") {
108
+ const sorted = [...board.columns].sort((a, b) => a.position - b.position);
109
+ const idx = sorted.findIndex((c) => c.id === columnId);
110
+ const prev = idx > 0 ? sorted[idx - 1] : null;
111
+ return { column: prev, error: prev ? null : "No prev column exists" };
112
+ }
113
+ const col = board.columns.find((c) => c.id === ref) ?? null;
114
+ return { column: col, error: col ? null : `Column ${ref} not found` };
115
+ }
116
+ function applyOperator(resolved, operator, threshold) {
117
+ if (operator === "eq" || operator === "neq") {
118
+ const rn = Number(resolved);
119
+ const tn = Number(threshold);
120
+ const bothNumeric = !isNaN(rn) && !isNaN(tn) && resolved !== "" && threshold !== "";
121
+ const equal = bothNumeric ? rn === tn : resolved === threshold;
122
+ return operator === "eq" ? equal : !equal;
123
+ }
124
+ const r = Number(resolved);
125
+ const t = Number(threshold);
126
+ if (isNaN(r) || isNaN(t)) return false;
127
+ switch (operator) {
128
+ case "lt":
129
+ return r < t;
130
+ case "lte":
131
+ return r <= t;
132
+ case "gt":
133
+ return r > t;
134
+ case "gte":
135
+ return r >= t;
136
+ default:
137
+ return false;
138
+ }
139
+ }
140
+ function evaluateConstraints(constraints, board, columnId, now) {
141
+ const enabled = constraints.filter((c) => c.enabled && c.scope === "column").sort((a, b) => a.position - b.position);
142
+ const results = [];
143
+ const summary = { total: 0, passed: 0, failed: 0, errors: 0 };
144
+ for (const constraint of enabled) {
145
+ summary.total++;
146
+ let resolvedValue = null;
147
+ let resolveError = null;
148
+ const subjectType = constraint.subject_type;
149
+ const subjectRef = constraint.subject_ref;
150
+ switch (subjectType) {
151
+ case "column.ticket_count": {
152
+ const { column, error } = resolveColumn(board, columnId, subjectRef);
153
+ if (error || !column) {
154
+ resolveError = error ?? "Column resolution failed";
155
+ } else {
156
+ resolvedValue = column.ticket_count;
157
+ }
158
+ break;
159
+ }
160
+ case "column.active_loops": {
161
+ const { column, error } = resolveColumn(board, columnId, subjectRef);
162
+ if (error || !column) {
163
+ resolveError = error ?? "Column resolution failed";
164
+ } else {
165
+ resolvedValue = board.active_loops.get(column.id) ?? 0;
166
+ }
167
+ break;
168
+ }
169
+ case "column.wip_remaining": {
170
+ const { column, error } = resolveColumn(board, columnId, subjectRef);
171
+ if (error || !column) {
172
+ resolveError = error ?? "Column resolution failed";
173
+ } else {
174
+ resolvedValue = column.wip_limit === null ? 999999 : column.wip_limit - column.ticket_count;
175
+ }
176
+ break;
177
+ }
178
+ case "column.last_fired_at": {
179
+ const { column, error } = resolveColumn(board, columnId, subjectRef);
180
+ if (error || !column) {
181
+ resolveError = error ?? "Column resolution failed";
182
+ } else {
183
+ const secondsSinceLastFire = board.last_fired_at.get(column.id);
184
+ resolvedValue = secondsSinceLastFire ?? 999999;
185
+ }
186
+ break;
187
+ }
188
+ case "board.total_active_loops": {
189
+ let total = 0;
190
+ for (const v of board.active_loops.values()) {
191
+ total += v;
192
+ }
193
+ resolvedValue = total;
194
+ break;
195
+ }
196
+ case "board.circuit_breaker_count": {
197
+ resolvedValue = board.circuit_breaker_count;
198
+ break;
199
+ }
200
+ case "backlog.ticket_count": {
201
+ resolvedValue = board.backlog_ticket_count;
202
+ break;
203
+ }
204
+ case "ticket.field_value": {
205
+ resolveError = "ticket.field_value requires ticket context (ticket-scope only)";
206
+ resolvedValue = null;
207
+ break;
208
+ }
209
+ case "time.hour": {
210
+ const date = now ?? /* @__PURE__ */ new Date();
211
+ resolvedValue = date.getUTCHours();
212
+ break;
213
+ }
214
+ default: {
215
+ resolveError = `Unknown subject type: ${subjectType}`;
216
+ break;
217
+ }
218
+ }
219
+ if (resolveError !== null) {
220
+ summary.errors++;
221
+ const failOpen = constraint.fail_open === true;
222
+ const passed2 = failOpen;
223
+ results.push({
224
+ constraint_id: constraint.id,
225
+ name: constraint.name,
226
+ column_id: constraint.column_id,
227
+ passed: passed2,
228
+ resolved_value: null,
229
+ threshold: { operator: constraint.operator, value: constraint.value },
230
+ error: failOpen ? `Unresolvable (fail_open=true): ${resolveError}` : `Unresolvable (fail-closed): ${resolveError}`
231
+ });
232
+ if (passed2) {
233
+ summary.passed++;
234
+ } else {
235
+ summary.failed++;
236
+ }
237
+ continue;
238
+ }
239
+ const passed = applyOperator(resolvedValue, constraint.operator, constraint.value);
240
+ results.push({
241
+ constraint_id: constraint.id,
242
+ name: constraint.name,
243
+ column_id: constraint.column_id,
244
+ passed,
245
+ resolved_value: resolvedValue,
246
+ threshold: { operator: constraint.operator, value: constraint.value }
247
+ });
248
+ if (passed) {
249
+ summary.passed++;
250
+ } else {
251
+ summary.failed++;
252
+ }
253
+ }
254
+ return { results, summary };
255
+ }
256
+
257
+ // src/lib/checkpoint.ts
258
+ var CHECKPOINT_FIELD = "loop_checkpoint";
259
+ var DEFAULT_STALE_THRESHOLD_MINUTES = 600;
260
+ async function readCheckpoint(deps, ticketId, currentColumnId, staleMinutes) {
261
+ try {
262
+ const fields = await deps.getFieldValues(ticketId);
263
+ const entry = fields.find((f) => f.field_name === CHECKPOINT_FIELD);
264
+ if (!entry) return null;
265
+ const parsed = LoopCheckpointSchema.safeParse(entry.value);
266
+ if (!parsed.success) return null;
267
+ const checkpoint = parsed.data;
268
+ if (checkpoint.column_id !== currentColumnId) return null;
269
+ const threshold = Math.max(staleMinutes ?? DEFAULT_STALE_THRESHOLD_MINUTES, 1);
270
+ const updatedAt = new Date(checkpoint.updated_at).getTime();
271
+ const ageMinutes = (Date.now() - updatedAt) / (1e3 * 60);
272
+ if (ageMinutes > threshold) return null;
273
+ return checkpoint;
274
+ } catch (err) {
275
+ console.warn(`[checkpoint] readCheckpoint failed for ${ticketId}: ${err instanceof Error ? err.message : String(err)}`);
276
+ return null;
277
+ }
278
+ }
279
+
280
+ // src/lib/evaluator.ts
281
+ function parseVerdict(raw) {
282
+ try {
283
+ const parsed = parseJsonFromLlmOutput(raw);
284
+ return VerdictSchema.parse(parsed);
285
+ } catch (err) {
286
+ const msg = err instanceof Error ? err.message : "unknown";
287
+ return {
288
+ decision: "reject",
289
+ summary: `Evaluator verdict parse error: ${msg.slice(0, 100)}`,
290
+ findings: [{ severity: "blocker", description: `Verdict parse error \u2014 raw output did not contain valid JSON verdict. Error: ${msg.slice(0, 200)}` }],
291
+ parseFailed: true
292
+ };
293
+ }
294
+ }
295
+ function resolveVerdictAction(verdict) {
296
+ if (verdict.decision === "approve") return "forward";
297
+ const hasBlockers = verdict.findings.some((f) => f.severity === "blocker");
298
+ if (hasBlockers || verdict.findings.length === 0) return "reject";
299
+ return "forward_with_signals";
300
+ }
301
+
302
+ // src/lib/replanner.ts
303
+ import { z } from "zod";
304
+ function shouldFireReplanner(triggers, state) {
305
+ if (state.escalatedTickets >= triggers.escalation_count) return true;
306
+ if (state.maxInputTokens > 0) {
307
+ const pct = state.totalTokensIn / state.maxInputTokens * 100;
308
+ if (pct >= triggers.cost_threshold_pct) return true;
309
+ }
310
+ for (const count of Object.values(state.repeatedGateFailures)) {
311
+ if (count >= triggers.repeated_gate_failure_count) return true;
312
+ }
313
+ if (state.durationMinutes >= triggers.duration_threshold_minutes) return true;
314
+ return false;
315
+ }
316
+ var ReplannerActionSchema = z.enum([
317
+ "CONTINUE",
318
+ "PAUSE_PIPELINE",
319
+ "ARCHIVE_TICKETS",
320
+ "CREATE_SIGNAL",
321
+ "ADJUST_BUDGET",
322
+ "ESCALATE_ALL"
323
+ ]);
324
+ var ReplannerResponseSchema = z.object({
325
+ action: ReplannerActionSchema,
326
+ reason: z.string(),
327
+ ticket_ids: z.array(z.string()).optional(),
328
+ signal_content: z.string().optional(),
329
+ new_max_input_tokens: z.number().optional()
330
+ });
331
+ function composeReplannerPrompt(state) {
332
+ const parts = [];
333
+ parts.push("You are a pipeline replanner. A trigger threshold has been crossed.");
334
+ parts.push(`
335
+ Trigger: ${state.triggerReason}`);
336
+ parts.push(`
337
+ Pipeline State:`);
338
+ parts.push(`- Escalated tickets: ${state.escalatedTickets}`);
339
+ parts.push(`- Token usage: ${state.totalTokensIn} / ${state.maxInputTokens}`);
340
+ parts.push(`- Duration: ${state.durationMinutes} minutes`);
341
+ if (Object.keys(state.repeatedGateFailures).length > 0) {
342
+ parts.push(`
343
+ Repeated gate failures:`);
344
+ for (const [gate, count] of Object.entries(state.repeatedGateFailures)) {
345
+ parts.push(`- ${gate}: ${count} tickets failing`);
346
+ }
347
+ }
348
+ parts.push(`
349
+ Ticket summaries:`);
350
+ for (const t of state.ticketSummaries) {
351
+ parts.push(`- ${t.title} [${t.column}] status=${t.status} iterations=${t.iterations} gate_pass=${t.gatePassRate}%`);
352
+ }
353
+ parts.push(`
354
+ Actions available: CONTINUE, PAUSE_PIPELINE, ARCHIVE_TICKETS, CREATE_SIGNAL, ADJUST_BUDGET, ESCALATE_ALL`);
355
+ parts.push(`
356
+ Be conservative. Primary value is knowing when to stop.`);
357
+ parts.push(`
358
+ Respond with a JSON object: { "action": "...", "reason": "..." }`);
359
+ return parts.join("\n");
360
+ }
361
+ function parseReplannerResponse(raw) {
362
+ try {
363
+ const parsed = parseJsonFromLlmOutput(raw);
364
+ return ReplannerResponseSchema.parse(parsed);
365
+ } catch {
366
+ return { action: "PAUSE_PIPELINE", reason: "Failed to parse replanner response \u2014 defaulting to pause for safety" };
367
+ }
368
+ }
369
+
370
+ // src/lib/orchestrator.ts
371
+ function classifyTier(input) {
372
+ if (input.invocationTier === "light") return "light";
373
+ if (input.invocationTier === "heavy") return "heavy";
374
+ if (!input.hasPromptDocument) return "light";
375
+ return "heavy";
376
+ }
377
+ var PipelineOrchestrator = class {
378
+ boardId;
379
+ projectId;
380
+ deps;
381
+ /** Column ID -> column configuration */
382
+ pipelineColumns = /* @__PURE__ */ new Map();
383
+ /** Column ID -> cached column scope (from initialize) */
384
+ columnScopes = /* @__PURE__ */ new Map();
385
+ /** Ticket ID -> active loop tracking */
386
+ activeLoops = /* @__PURE__ */ new Map();
387
+ /** Column ID -> queued ticket IDs (FIFO) */
388
+ loopQueues = /* @__PURE__ */ new Map();
389
+ /** Ticket IDs that have been spawned or queued (prevents re-processing) */
390
+ knownTickets = /* @__PURE__ */ new Set();
391
+ /** Tickets deferred because they have unresolved blockers. Re-queued when a blocker reaches Done. */
392
+ deferredTickets = /* @__PURE__ */ new Map();
393
+ // ticketId → columnId
394
+ /** Ticket IDs currently in the spawning process (prevents double-spawn race) */
395
+ spawning = /* @__PURE__ */ new Set();
396
+ /** Ticket IDs currently in onLoopComplete (prevents double-spawn during async advisor recovery) */
397
+ completing = /* @__PURE__ */ new Set();
398
+ /** Per-column reservation count for in-flight spawns (prevents concurrency overshoot) */
399
+ columnReservations = /* @__PURE__ */ new Map();
400
+ /** Cached board scope for constraint evaluation — refreshed each scan cycle */
401
+ cachedBoardScope = null;
402
+ /** Last time a loop was spawned per column — for column.last_fired_at subject */
403
+ lastFiredAt = /* @__PURE__ */ new Map();
404
+ /** Column IDs currently blocked by firing constraints (prevents redundant re-evaluation within a scan) */
405
+ blockedColumns = /* @__PURE__ */ new Set();
406
+ /** Per-ticket advisor invocation count for the current column transit */
407
+ advisorBudget = /* @__PURE__ */ new Map();
408
+ /** Per-ticket model override (set by RETRY_DIFFERENT_MODEL, consumed by startTrackedLoop) */
409
+ ticketModelOverrides = /* @__PURE__ */ new Map();
410
+ /** Stable session ID for this orchestrator instance (pipeline run) */
411
+ sessionId;
412
+ /** Replanner invocation count for the current pipeline run */
413
+ replannerInvocations = 0;
414
+ /** Maximum replanner invocations before auto-pause */
415
+ replannerMaxInvocations = 3;
416
+ /** When true, pipeline is paused — scanAndSpawn and spawnOrQueue will skip */
417
+ pipelinePaused = false;
418
+ /** Per-column consecutive scope refresh failure counts — auto-pauses after 3 for any column */
419
+ columnScopeRefreshFailures = /* @__PURE__ */ new Map();
420
+ /** Consecutive board scope refresh failure count — auto-pauses after 3 */
421
+ boardScopeRefreshFailures = 0;
422
+ /** Timestamp when the pipeline started (for duration-based replanner triggers) */
423
+ pipelineStartTime = Date.now();
424
+ constructor(boardId, projectId, deps) {
425
+ this.boardId = boardId;
426
+ this.projectId = projectId;
427
+ this.deps = deps;
428
+ this.sessionId = crypto.randomUUID();
429
+ }
430
+ /**
431
+ * Execute an async action with logged error handling.
432
+ * Returns true if the action succeeded, false if it threw.
433
+ * Non-blocking — callers can ignore the return value for fire-and-forget.
434
+ */
435
+ async safeAction(ticketId, label, fn) {
436
+ try {
437
+ await fn();
438
+ return true;
439
+ } catch (err) {
440
+ const msg = err instanceof Error ? err.message : String(err);
441
+ console.error(` [error] ${label} failed for ${ticketId}: ${msg}`);
442
+ return false;
443
+ }
444
+ }
445
+ /** Returns the IDs of all discovered pipeline columns. */
446
+ get pipelineColumnIds() {
447
+ return Array.from(this.pipelineColumns.keys());
448
+ }
449
+ /** Returns the total number of active loops. */
450
+ get activeLoopCount() {
451
+ return this.activeLoops.size;
452
+ }
453
+ /** Returns true if any tickets are queued or deferred (waiting for a slot). */
454
+ get hasQueuedWork() {
455
+ for (const [, queue] of this.loopQueues) {
456
+ if (queue.length > 0) return true;
457
+ }
458
+ return this.deferredTickets.size > 0 || this.spawning.size > 0;
459
+ }
460
+ /** Returns true if any tickets are queued (waiting for a slot) or spawning — excludes deferred tickets. */
461
+ get hasActiveQueuedWork() {
462
+ for (const [, queue] of this.loopQueues) {
463
+ if (queue.length > 0) return true;
464
+ }
465
+ return this.spawning.size > 0;
466
+ }
467
+ /** Returns the number of queued (waiting) tickets for a column. */
468
+ queuedCount(columnId) {
469
+ return this.loopQueues.get(columnId)?.length ?? 0;
470
+ }
471
+ /**
472
+ * Initialize the orchestrator: fetch board scope, identify pipeline columns,
473
+ * and fetch column-level agent configs.
474
+ */
475
+ async initialize() {
476
+ const boardScope = await this.deps.fetchBoardScope(this.boardId);
477
+ this.cachedBoardScope = boardScope;
478
+ const pipelineCols = boardScope.columns.filter(
479
+ (col) => (col.has_prompt || col.type === "evaluator") && col.type !== "done"
480
+ );
481
+ await Promise.all(
482
+ pipelineCols.map(async (col) => {
483
+ const colScope = await this.deps.fetchColumnScope(col.id);
484
+ const cfg = colScope.agent_config;
485
+ this.pipelineColumns.set(col.id, {
486
+ columnId: col.id,
487
+ name: col.name,
488
+ columnType: col.type,
489
+ concurrency: cfg?.concurrency ?? 1,
490
+ maxIterations: cfg?.max_iterations ?? 10,
491
+ gutterThreshold: cfg?.gutter_threshold ?? 3,
492
+ modelPreference: cfg?.model_preference,
493
+ maxBudgetUsd: cfg?.max_budget_usd,
494
+ worktreeEnabled: cfg?.worktree?.enabled,
495
+ worktreeOnMove: cfg?.worktree?.on_move,
496
+ worktreeOnDone: cfg?.worktree?.on_done,
497
+ worktreeIntegrationBranch: cfg?.worktree?.integration_branch,
498
+ invocationTier: cfg?.invocation_tier,
499
+ lookaheadColumnId: cfg?.lookahead_column_id,
500
+ runMemory: cfg?.run_memory,
501
+ advisorEnabled: cfg?.advisor?.enabled,
502
+ advisorMaxInvocations: cfg?.advisor?.max_invocations ?? 2,
503
+ advisorModel: cfg?.advisor?.model ?? "haiku",
504
+ checkpointEnabled: cfg?.checkpoint,
505
+ modelRouting: cfg?.model_routing ? {
506
+ initial: cfg.model_routing.initial,
507
+ escalation: cfg.model_routing.escalation,
508
+ escalateAfter: cfg.model_routing.escalate_after ?? 2
509
+ } : void 0,
510
+ stuckDetection: cfg?.stuck_detection?.enabled ? {
511
+ enabled: true,
512
+ firstCheck: cfg.stuck_detection.first_check ?? 3,
513
+ interval: cfg.stuck_detection.interval ?? 2
514
+ } : void 0,
515
+ builtinTools: cfg?.builtin_tools,
516
+ allowedTools: cfg?.allowed_tools,
517
+ disallowedTools: cfg?.disallowed_tools
518
+ });
519
+ this.columnScopes.set(col.id, colScope);
520
+ this.loopQueues.set(col.id, []);
521
+ })
522
+ );
523
+ }
524
+ /**
525
+ * Refresh the cached column scope for a single column.
526
+ * Keeps stale scope on error rather than crashing.
527
+ */
528
+ async refreshColumnScope(columnId) {
529
+ try {
530
+ const colScope = await this.deps.fetchColumnScope(columnId);
531
+ this.columnScopes.set(columnId, colScope);
532
+ this.columnScopeRefreshFailures.delete(columnId);
533
+ } catch (err) {
534
+ const count = (this.columnScopeRefreshFailures.get(columnId) ?? 0) + 1;
535
+ this.columnScopeRefreshFailures.set(columnId, count);
536
+ const msg = err instanceof Error ? err.message : String(err);
537
+ console.error(` [warn] refreshColumnScope failed for ${columnId} (attempt ${count}, keeping stale): ${msg}`);
538
+ if (count >= 3) {
539
+ console.error(` [error] 3 consecutive scope refresh failures for column ${columnId} \u2014 pausing pipeline`);
540
+ this.pipelinePaused = true;
541
+ }
542
+ }
543
+ }
544
+ /**
545
+ * Refresh the cached board scope. Called at the start of each scan cycle
546
+ * to get fresh ticket counts for constraint evaluation.
547
+ */
548
+ async refreshBoardScope() {
549
+ try {
550
+ this.cachedBoardScope = await this.deps.fetchBoardScope(this.boardId);
551
+ this.boardScopeRefreshFailures = 0;
552
+ if (this.pipelinePaused) {
553
+ const anyColumnFailing = Array.from(this.columnScopeRefreshFailures.values()).some((c) => c >= 3);
554
+ if (!anyColumnFailing) {
555
+ console.error(" [info] Scope refresh succeeded \u2014 unpausing pipeline");
556
+ this.pipelinePaused = false;
557
+ }
558
+ }
559
+ } catch (err) {
560
+ this.boardScopeRefreshFailures++;
561
+ const msg = err instanceof Error ? err.message : String(err);
562
+ console.error(` [warn] refreshBoardScope failed (attempt ${this.boardScopeRefreshFailures}, keeping stale): ${msg}`);
563
+ if (this.boardScopeRefreshFailures >= 3) {
564
+ console.error(" [error] 3 consecutive scope refresh failures \u2014 pausing pipeline");
565
+ this.pipelinePaused = true;
566
+ }
567
+ }
568
+ }
569
+ /**
570
+ * Public method to invalidate constraint caches when WS events arrive
571
+ * (firing_constraint:created/updated/deleted). Forces re-fetch on next access.
572
+ */
573
+ async refreshConstraints() {
574
+ await Promise.all(
575
+ Array.from(this.pipelineColumns.keys()).map((colId) => this.refreshColumnScope(colId))
576
+ );
577
+ this.blockedColumns.clear();
578
+ }
579
+ /**
580
+ * Build the BoardState required by the constraint evaluator from current
581
+ * orchestrator state + cached board scope.
582
+ */
583
+ buildBoardState() {
584
+ const bs = this.cachedBoardScope;
585
+ if (!bs) {
586
+ return {
587
+ columns: [],
588
+ active_loops: /* @__PURE__ */ new Map(),
589
+ last_fired_at: /* @__PURE__ */ new Map(),
590
+ circuit_breaker_count: 0,
591
+ backlog_ticket_count: 0
592
+ };
593
+ }
594
+ const activeLoopsPerColumn = /* @__PURE__ */ new Map();
595
+ for (const [, loop] of this.activeLoops) {
596
+ activeLoopsPerColumn.set(loop.columnId, (activeLoopsPerColumn.get(loop.columnId) ?? 0) + 1);
597
+ }
598
+ const now = Date.now();
599
+ const lastFiredSeconds = /* @__PURE__ */ new Map();
600
+ for (const [colId, firedAt] of this.lastFiredAt) {
601
+ lastFiredSeconds.set(colId, Math.floor((now - firedAt.getTime()) / 1e3));
602
+ }
603
+ let circuitBreakerCount = 0;
604
+ if (bs.circuit_breaker.target_column_id) {
605
+ const targetCol = bs.columns.find((c) => c.id === bs.circuit_breaker.target_column_id);
606
+ if (targetCol) circuitBreakerCount = targetCol.ticket_count;
607
+ }
608
+ return {
609
+ columns: bs.columns.map((c) => ({
610
+ id: c.id,
611
+ name: c.name,
612
+ position: c.position,
613
+ column_type: c.type,
614
+ wip_limit: c.wip_limit,
615
+ ticket_count: c.ticket_count
616
+ })),
617
+ active_loops: activeLoopsPerColumn,
618
+ last_fired_at: lastFiredSeconds,
619
+ circuit_breaker_count: circuitBreakerCount,
620
+ backlog_ticket_count: bs.backlog_ticket_count
621
+ };
622
+ }
623
+ /**
624
+ * Convert FiringConstraintLite from column scope to full FiringConstraint
625
+ * expected by the evaluator.
626
+ */
627
+ toFiringConstraints(lites) {
628
+ return lites.map((c) => ({
629
+ id: c.id,
630
+ project_id: this.projectId,
631
+ board_id: this.boardId,
632
+ column_id: c.column_id,
633
+ name: c.name,
634
+ description: null,
635
+ enabled: c.enabled,
636
+ subject_type: c.subject_type,
637
+ subject_ref: c.subject_ref,
638
+ subject_param: null,
639
+ operator: c.operator,
640
+ value: c.value,
641
+ scope: c.scope,
642
+ notify: c.notify,
643
+ position: 0,
644
+ fail_open: c.fail_open ?? false,
645
+ system: c.system ?? false,
646
+ created_at: "",
647
+ updated_at: ""
648
+ }));
649
+ }
650
+ /**
651
+ * Evaluate firing constraints for a column. Returns the EvalResult if any
652
+ * constraint failed, or null if all passed (column is clear to fire).
653
+ *
654
+ * Handles logging and optional signal creation for blocked columns.
655
+ */
656
+ evaluateColumnConstraints(columnId) {
657
+ const colScope = this.columnScopes.get(columnId);
658
+ let constraints = [];
659
+ if (colScope?.firing_constraints && colScope.firing_constraints.length > 0) {
660
+ constraints = this.toFiringConstraints(colScope.firing_constraints);
661
+ }
662
+ const hasTicketCountConstraint = constraints.some(
663
+ (c) => c.enabled && c.scope === "column" && c.subject_type === "column.ticket_count" && (c.subject_ref === null || c.subject_ref === "self")
664
+ );
665
+ if (!hasTicketCountConstraint && this.pipelineColumns.has(columnId)) {
666
+ constraints.push({
667
+ id: "00000000-0000-0000-0000-000000000000",
668
+ project_id: this.projectId,
669
+ board_id: this.boardId,
670
+ column_id: columnId,
671
+ name: "Minimum tickets (synthetic)",
672
+ description: null,
673
+ enabled: true,
674
+ subject_type: "column.ticket_count",
675
+ subject_ref: null,
676
+ subject_param: null,
677
+ operator: "gt",
678
+ value: 0,
679
+ scope: "column",
680
+ notify: false,
681
+ position: -1,
682
+ fail_open: false,
683
+ system: true,
684
+ created_at: "",
685
+ updated_at: ""
686
+ });
687
+ }
688
+ if (constraints.length === 0) {
689
+ return null;
690
+ }
691
+ const boardState = this.buildBoardState();
692
+ const result = evaluateConstraints(constraints, boardState, columnId);
693
+ if (result.summary.failed === 0) {
694
+ return null;
695
+ }
696
+ const colName = colScope?.column.name ?? columnId;
697
+ for (const r of result.results) {
698
+ if (!r.passed && !r.error) {
699
+ console.error(
700
+ ` [blocked] Column "${colName}" (${columnId}): constraint "${r.name}" FAILED \u2014 resolved=${String(r.resolved_value)} ${r.threshold.operator} ${String(r.threshold.value)}`
701
+ );
702
+ }
703
+ }
704
+ for (const r of result.results) {
705
+ if (!r.passed && !r.error) {
706
+ this.deps.eventEmitter?.emit({
707
+ layer: "constraint",
708
+ eventType: "constraint_blocked",
709
+ severity: "failure",
710
+ summary: `Constraint "${r.name}" blocked [${colName}]`,
711
+ detail: {
712
+ constraint_name: r.name,
713
+ constraint_id: r.constraint_id,
714
+ resolved_value: r.resolved_value,
715
+ threshold: { operator: r.threshold.operator, value: r.threshold.value },
716
+ fail_open: false,
717
+ blocked_tickets: [],
718
+ column_name: colName
719
+ },
720
+ columnId
721
+ });
722
+ }
723
+ }
724
+ if (result.summary.errors > 0) {
725
+ for (const r of result.results) {
726
+ if (r.error) {
727
+ console.error(
728
+ ` [constraint-error] Column "${colName}" (${columnId}): "${r.name}" \u2014 ${r.error} (fail-open)`
729
+ );
730
+ }
731
+ }
732
+ }
733
+ const constraintMap = new Map(constraints.map((c) => [c.id, c]));
734
+ for (const r of result.results) {
735
+ if (!r.passed && !r.error) {
736
+ const constraint = constraintMap.get(r.constraint_id);
737
+ if (constraint?.notify && this.deps.createColumnSignal) {
738
+ void this.deps.createColumnSignal(
739
+ columnId,
740
+ `Firing constraint "${r.name}" blocked column: resolved=${String(r.resolved_value)} ${r.threshold.operator} ${String(r.threshold.value)}`
741
+ ).catch((err) => {
742
+ const msg = err instanceof Error ? err.message : String(err);
743
+ console.error(` [warn] Failed to create constraint signal: ${msg}`);
744
+ });
745
+ }
746
+ }
747
+ }
748
+ return result;
749
+ }
750
+ /**
751
+ * Check if a column is blocked by firing constraints.
752
+ * Returns true if blocked (should not fire), false if clear.
753
+ */
754
+ isColumnBlocked(columnId) {
755
+ const result = this.evaluateColumnConstraints(columnId);
756
+ return result !== null;
757
+ }
758
+ /**
759
+ * Scan all pipeline columns for existing tickets and spawn loops.
760
+ * Refreshes column scopes before scanning to avoid stale ticket lists.
761
+ * Evaluates firing constraints per column before processing tickets.
762
+ * Respects per-column concurrency limits — excess tickets are queued.
763
+ */
764
+ async scanAndSpawn() {
765
+ if (this.pipelinePaused) {
766
+ console.error(" [scan] Pipeline paused by replanner \u2014 skipping scan");
767
+ return;
768
+ }
769
+ if (this.deps.costTracker?.isExhausted()) {
770
+ if (!this.pipelinePaused) {
771
+ this.pipelinePaused = true;
772
+ console.error("[budget] Token budget exhausted \u2014 pausing pipeline");
773
+ }
774
+ return;
775
+ }
776
+ this.knownTickets.clear();
777
+ for (const ticketId of this.activeLoops.keys()) {
778
+ this.knownTickets.add(ticketId);
779
+ }
780
+ for (const [, queue] of this.loopQueues) {
781
+ for (const ticketId of queue) {
782
+ this.knownTickets.add(ticketId);
783
+ }
784
+ }
785
+ for (const [ticketId, columnId] of Array.from(this.deferredTickets)) {
786
+ try {
787
+ const stillBlocked = await this.deps.hasUnresolvedBlockers(ticketId);
788
+ if (!stillBlocked) {
789
+ this.deferredTickets.delete(ticketId);
790
+ await this.spawnOrQueue(ticketId, columnId, true);
791
+ } else {
792
+ this.knownTickets.add(ticketId);
793
+ }
794
+ } catch (err) {
795
+ const msg = err instanceof Error ? err.message : String(err);
796
+ console.error(` [warn] Deferred blocker re-evaluation failed for ${ticketId}: ${msg}`);
797
+ this.knownTickets.add(ticketId);
798
+ }
799
+ }
800
+ await this.refreshBoardScope();
801
+ this.blockedColumns.clear();
802
+ for (const [columnId] of this.pipelineColumns) {
803
+ await this.refreshColumnScope(columnId);
804
+ const colScope = this.columnScopes.get(columnId);
805
+ if (!colScope) {
806
+ console.error(` [scan] Column ${columnId}: no cached scope`);
807
+ continue;
808
+ }
809
+ if (this.isColumnBlocked(columnId)) {
810
+ this.blockedColumns.add(columnId);
811
+ console.error(` [scan] Column ${columnId} (${colScope.column.name}): BLOCKED by firing constraints \u2014 skipping ${String(colScope.tickets.length)} ticket(s)`);
812
+ continue;
813
+ }
814
+ console.error(` [scan] Column ${columnId} (${colScope.column.name}): ${String(colScope.tickets.length)} ticket(s)`);
815
+ for (const ticket of colScope.tickets) {
816
+ await this.spawnOrQueue(ticket.id, columnId);
817
+ }
818
+ }
819
+ }
820
+ /**
821
+ * Handle a pipeline event (typically from WebSocket via EventQueue).
822
+ */
823
+ async handleEvent(event) {
824
+ switch (event.type) {
825
+ case "ticket:moved":
826
+ case "ticket:created": {
827
+ if (event.columnId && this.pipelineColumns.has(event.columnId)) {
828
+ if (this.isColumnBlocked(event.columnId)) {
829
+ console.error(` [event] ${event.type} ${event.ticketId} \u2192 column ${event.columnId}: BLOCKED by firing constraints \u2014 deferred`);
830
+ this.deferredTickets.set(event.ticketId, event.columnId);
831
+ } else {
832
+ await this.spawnOrQueue(event.ticketId, event.columnId, true);
833
+ }
834
+ }
835
+ if (event.type === "ticket:moved") {
836
+ let blockedIds = null;
837
+ try {
838
+ const blocked = await this.deps.fetchBlockedTickets(event.ticketId);
839
+ blockedIds = new Set(blocked.map((b) => b.id));
840
+ for (const dep of blocked) {
841
+ if (dep.id === event.ticketId) continue;
842
+ if (dep.column_id && this.pipelineColumns.has(dep.column_id)) {
843
+ this.knownTickets.delete(dep.id);
844
+ this.deferredTickets.delete(dep.id);
845
+ await this.spawnOrQueue(dep.id, dep.column_id, true);
846
+ }
847
+ }
848
+ } catch (err) {
849
+ const msg = err instanceof Error ? err.message : String(err);
850
+ console.error(` [warn] Failed to fetch blocked tickets for ${event.ticketId}: ${msg}`);
851
+ }
852
+ const toReEvaluate = Array.from(this.deferredTickets).filter(
853
+ ([deferredId]) => deferredId !== event.ticketId && (!blockedIds || blockedIds.has(deferredId))
854
+ );
855
+ for (const [deferredId, deferredCol] of toReEvaluate) {
856
+ this.knownTickets.delete(deferredId);
857
+ this.deferredTickets.delete(deferredId);
858
+ await this.spawnOrQueue(deferredId, deferredCol, true);
859
+ }
860
+ }
861
+ break;
862
+ }
863
+ case "ticket:updated": {
864
+ if (event.columnId && this.pipelineColumns.has(event.columnId)) {
865
+ if (this.deferredTickets.has(event.ticketId)) {
866
+ this.knownTickets.delete(event.ticketId);
867
+ this.deferredTickets.delete(event.ticketId);
868
+ await this.spawnOrQueue(event.ticketId, event.columnId, true);
869
+ }
870
+ }
871
+ break;
872
+ }
873
+ case "ticket:deleted":
874
+ case "ticket:archived": {
875
+ this.activeLoops.delete(event.ticketId);
876
+ this.knownTickets.delete(event.ticketId);
877
+ this.deferredTickets.delete(event.ticketId);
878
+ this.spawning.delete(event.ticketId);
879
+ this.advisorBudget.delete(event.ticketId);
880
+ for (const [, queue] of this.loopQueues) {
881
+ const idx = queue.indexOf(event.ticketId);
882
+ if (idx !== -1) {
883
+ queue.splice(idx, 1);
884
+ }
885
+ }
886
+ break;
887
+ }
888
+ }
889
+ }
890
+ /**
891
+ * Spawn a loop for a ticket if under concurrency limit, otherwise queue it.
892
+ * @param skipKnownCheck - true for event-driven spawns (bypass scan dedup)
893
+ */
894
+ async spawnOrQueue(ticketId, columnId, skipKnownCheck = false, skipCompletingCheck = false) {
895
+ if (this.pipelinePaused) return;
896
+ if (this.activeLoops.has(ticketId)) return;
897
+ if (this.spawning.has(ticketId)) return;
898
+ if (!skipCompletingCheck && this.completing.has(ticketId)) return;
899
+ if (!skipKnownCheck && this.knownTickets.has(ticketId)) return;
900
+ const colConfig = this.pipelineColumns.get(columnId);
901
+ if (!colConfig) return;
902
+ if (this.isColumnBlocked(columnId)) {
903
+ return;
904
+ }
905
+ const activeInColumn = this.activeCountForColumn(columnId);
906
+ this.knownTickets.add(ticketId);
907
+ if (activeInColumn >= colConfig.concurrency) {
908
+ const queue = this.loopQueues.get(columnId);
909
+ if (queue && !queue.includes(ticketId)) {
910
+ queue.push(ticketId);
911
+ }
912
+ return;
913
+ }
914
+ this.spawning.add(ticketId);
915
+ this.reserveSlot(columnId);
916
+ try {
917
+ const blocked = await this.deps.hasUnresolvedBlockers(ticketId);
918
+ if (blocked) {
919
+ this.spawning.delete(ticketId);
920
+ this.releaseSlot(columnId);
921
+ this.deferredTickets.set(ticketId, columnId);
922
+ console.error(` [skip] ${ticketId} has unresolved blockers \u2014 deferred`);
923
+ void this.drainQueue(columnId).catch((err) => {
924
+ const msg = err instanceof Error ? err.message : String(err);
925
+ console.error(` [error] drainQueue failed for column ${columnId}: ${msg}`);
926
+ });
927
+ return;
928
+ }
929
+ } catch (err) {
930
+ const msg = err instanceof Error ? err.message : String(err);
931
+ console.error(` [warn] Blocker check failed for ${ticketId}, deferring: ${msg}`);
932
+ this.spawning.delete(ticketId);
933
+ this.releaseSlot(columnId);
934
+ this.deferredTickets.set(ticketId, columnId);
935
+ void this.drainQueue(columnId).catch((err2) => {
936
+ const msg2 = err2 instanceof Error ? err2.message : String(err2);
937
+ console.error(` [error] drainQueue failed for column ${columnId}: ${msg2}`);
938
+ });
939
+ return;
940
+ }
941
+ try {
942
+ await this.deps.claimTicket(ticketId);
943
+ this.startTrackedLoop(ticketId, columnId, colConfig);
944
+ } catch (err) {
945
+ const msg = err instanceof Error ? err.message : String(err);
946
+ console.error(` [error] Failed to claim ticket ${ticketId}: ${msg}`);
947
+ this.knownTickets.delete(ticketId);
948
+ this.deferredTickets.set(ticketId, columnId);
949
+ } finally {
950
+ this.spawning.delete(ticketId);
951
+ this.releaseSlot(columnId);
952
+ }
953
+ }
954
+ /**
955
+ * Start a loop and track it. Attach completion handler for cleanup + queue drain.
956
+ */
957
+ startTrackedLoop(ticketId, columnId, config) {
958
+ const tier = config.columnType === "evaluator" ? "heavy" : classifyTier({
959
+ hasPromptDocument: this.columnScopes.get(columnId)?.prompt_document != null,
960
+ invocationTier: config.invocationTier
961
+ });
962
+ if (tier === "light" && this.deps.dispatchLightCall) {
963
+ const promise = this.deps.dispatchLightCall(ticketId, columnId).then(
964
+ async (response) => {
965
+ try {
966
+ switch (response.action) {
967
+ case "move_ticket": {
968
+ const targetColId = response.params.column_id;
969
+ if (targetColId && this.deps.moveTicketToColumn) {
970
+ await this.deps.moveTicketToColumn(ticketId, targetColId, {
971
+ reason: response.reason,
972
+ source: "light_call"
973
+ });
974
+ }
975
+ return { reason: "moved", iterations: 1, gutterCount: 0 };
976
+ }
977
+ case "set_field_value": {
978
+ if (this.deps.setFieldValue) {
979
+ const fieldName = response.params.field_name;
980
+ const value = response.params.value;
981
+ if (fieldName) await this.deps.setFieldValue(ticketId, fieldName, value);
982
+ }
983
+ return { reason: "stalled", iterations: 1, gutterCount: 0 };
984
+ }
985
+ case "archive_ticket": {
986
+ if (this.deps.archiveTicket) {
987
+ await this.deps.archiveTicket(ticketId);
988
+ }
989
+ return { reason: "moved", iterations: 1, gutterCount: 0 };
990
+ }
991
+ case "create_comment": {
992
+ const body = response.params.body;
993
+ if (body) await this.deps.createComment(ticketId, body);
994
+ return { reason: "stalled", iterations: 1, gutterCount: 0 };
995
+ }
996
+ case "no_action":
997
+ default:
998
+ return { reason: "stalled", iterations: 1, gutterCount: 0 };
999
+ }
1000
+ } catch (err) {
1001
+ return {
1002
+ reason: "error",
1003
+ iterations: 1,
1004
+ gutterCount: 0,
1005
+ lastError: err instanceof Error ? err.message : String(err)
1006
+ };
1007
+ }
1008
+ },
1009
+ (err) => ({
1010
+ reason: "error",
1011
+ iterations: 1,
1012
+ gutterCount: 0,
1013
+ lastError: err instanceof Error ? err.message : String(err)
1014
+ })
1015
+ );
1016
+ this.activeLoops.set(ticketId, { columnId, promise });
1017
+ this.lastFiredAt.set(columnId, /* @__PURE__ */ new Date());
1018
+ void promise.then(
1019
+ (result) => this.onLoopComplete(ticketId, columnId, result).catch((err) => {
1020
+ console.error(`onLoopComplete error for ${ticketId}:`, err);
1021
+ })
1022
+ );
1023
+ return;
1024
+ }
1025
+ const modelOverride = this.ticketModelOverrides.get(ticketId);
1026
+ this.ticketModelOverrides.delete(ticketId);
1027
+ if (config.checkpointEnabled && this.deps.getFieldValues) {
1028
+ let timeoutId;
1029
+ const placeholder = new Promise((_, reject) => {
1030
+ timeoutId = setTimeout(() => {
1031
+ this.completing.add(ticketId);
1032
+ this.activeLoops.delete(ticketId);
1033
+ this.knownTickets.delete(ticketId);
1034
+ this.completing.delete(ticketId);
1035
+ console.error(` [warn] Checkpoint read timed out for ${ticketId} \u2014 releasing lock`);
1036
+ reject(new Error("Checkpoint read timeout"));
1037
+ }, 3e4);
1038
+ });
1039
+ placeholder.catch(() => {
1040
+ });
1041
+ this.activeLoops.set(ticketId, { columnId, promise: placeholder });
1042
+ this.lastFiredAt.set(columnId, /* @__PURE__ */ new Date());
1043
+ void readCheckpoint(
1044
+ {
1045
+ setFieldValue: async () => {
1046
+ },
1047
+ getFieldValues: this.deps.getFieldValues
1048
+ },
1049
+ ticketId,
1050
+ columnId
1051
+ ).then((checkpoint) => {
1052
+ clearTimeout(timeoutId);
1053
+ if (checkpoint) {
1054
+ console.error(` [checkpoint] Resuming ${ticketId} at iteration ${String(checkpoint.iteration + 1)} (model: ${checkpoint.model_tier})`);
1055
+ this.startLoopWithConfig(ticketId, columnId, config, checkpoint.iteration + 1, checkpoint.gutter_count, modelOverride ?? checkpoint.model_tier, checkpoint.last_fingerprint);
1056
+ } else {
1057
+ this.startLoopWithConfig(ticketId, columnId, config, void 0, void 0, modelOverride);
1058
+ }
1059
+ }).catch((err) => {
1060
+ clearTimeout(timeoutId);
1061
+ const msg = err instanceof Error ? err.message : String(err);
1062
+ console.error(` [warn] Checkpoint read failed for ${ticketId} (starting fresh): ${msg}`);
1063
+ this.startLoopWithConfig(ticketId, columnId, config, void 0, void 0, modelOverride);
1064
+ });
1065
+ return;
1066
+ }
1067
+ this.startLoopWithConfig(ticketId, columnId, config, void 0, void 0, modelOverride);
1068
+ }
1069
+ /**
1070
+ * Build a LoopConfig and start a loop, tracking it in activeLoops.
1071
+ * Handles model routing resolution: resumeModelTier > modelRouting.initial > modelPreference.
1072
+ */
1073
+ startLoopWithConfig(ticketId, columnId, config, startIteration, startGutterCount, resumeModelTier, startFingerprint) {
1074
+ const effectiveModel = resumeModelTier ?? config.modelRouting?.initial ?? config.modelPreference;
1075
+ const loopConfig = {
1076
+ maxIterations: config.maxIterations,
1077
+ gutterThreshold: config.gutterThreshold,
1078
+ ...effectiveModel !== void 0 && { model: effectiveModel },
1079
+ ...config.maxBudgetUsd !== void 0 && { maxBudgetUsd: config.maxBudgetUsd },
1080
+ // Resolve worktree name from ticket context
1081
+ ...(() => {
1082
+ const colScope = this.columnScopes.get(columnId);
1083
+ const ticket = colScope?.tickets.find((t) => t.id === ticketId);
1084
+ const wName = config.worktreeEnabled && ticket ? generateWorktreeName(ticket.ticket_number, config.name) : void 0;
1085
+ return wName !== void 0 ? { worktreeName: wName } : {};
1086
+ })(),
1087
+ ...config.lookaheadColumnId !== void 0 && { lookaheadColumnId: config.lookaheadColumnId },
1088
+ // Resume from checkpoint iteration/gutter if provided
1089
+ ...startIteration !== void 0 && { startIteration },
1090
+ ...startGutterCount !== void 0 && { startGutterCount },
1091
+ ...startFingerprint !== void 0 && { startFingerprint },
1092
+ ...config.stuckDetection && { stuckDetection: config.stuckDetection }
1093
+ };
1094
+ const toolRestrictions = resolveToolRestrictions(
1095
+ config.builtinTools,
1096
+ config.allowedTools,
1097
+ config.disallowedTools
1098
+ );
1099
+ if (toolRestrictions.tools !== void 0 || toolRestrictions.allowedTools || toolRestrictions.disallowedTools) {
1100
+ loopConfig.toolRestrictions = toolRestrictions;
1101
+ }
1102
+ const promise = this.deps.startLoop(ticketId, columnId, loopConfig);
1103
+ this.activeLoops.set(ticketId, { columnId, promise });
1104
+ this.lastFiredAt.set(columnId, /* @__PURE__ */ new Date());
1105
+ void promise.then(
1106
+ (result) => this.onLoopComplete(ticketId, columnId, result).catch((err) => {
1107
+ console.error(`onLoopComplete error for ${ticketId}:`, err);
1108
+ }),
1109
+ (err) => this.onLoopComplete(ticketId, columnId, {
1110
+ reason: "error",
1111
+ iterations: 0,
1112
+ gutterCount: 0,
1113
+ lastError: err instanceof Error ? err.message : String(err)
1114
+ }).catch((completionErr) => {
1115
+ console.error(`onLoopComplete error for ${ticketId}:`, completionErr);
1116
+ })
1117
+ );
1118
+ }
1119
+ /**
1120
+ * Phase 2: Invoke the advisor for failure recovery.
1121
+ * Returns true if the ticket should be retried, false if handled.
1122
+ */
1123
+ async invokeAdvisorRecovery(ticketId, columnId, result, colConfig) {
1124
+ if (!this.deps.invokeAdvisor) return false;
1125
+ if (!colConfig.advisorEnabled) return false;
1126
+ const remaining = this.advisorBudget.get(ticketId) ?? colConfig.advisorMaxInvocations ?? 2;
1127
+ if (remaining <= 0) return false;
1128
+ const colScope = this.columnScopes.get(columnId);
1129
+ const input = {
1130
+ ticketTitle: "",
1131
+ ticketDescription: "",
1132
+ ticketId,
1133
+ ticketNumber: 0,
1134
+ columnName: colScope?.column.name ?? columnId,
1135
+ exitReason: result.reason,
1136
+ iterations: result.iterations,
1137
+ gutterCount: result.gutterCount,
1138
+ ...result.lastError !== void 0 && { lastError: result.lastError },
1139
+ // recentComments: LoopResult does not carry comments; leave empty.
1140
+ recentComments: [],
1141
+ fieldValues: [],
1142
+ failurePatterns: "",
1143
+ remainingBudget: remaining,
1144
+ modelTier: result.model ?? colConfig.modelPreference ?? "default",
1145
+ escalationModels: colConfig.modelRouting?.escalation ?? [],
1146
+ ...this.cachedBoardScope?.circuit_breaker.target_column_id != null && {
1147
+ circuitBreakerTargetId: this.cachedBoardScope.circuit_breaker.target_column_id
1148
+ }
1149
+ };
1150
+ const ticket = colScope?.tickets.find((t) => t.id === ticketId);
1151
+ if (ticket) {
1152
+ input.ticketTitle = ticket.title;
1153
+ input.ticketNumber = ticket.ticket_number;
1154
+ }
1155
+ if (this.deps.gateSnapshotStore) {
1156
+ const latest = this.deps.gateSnapshotStore.getLatest(ticketId);
1157
+ if (latest) {
1158
+ const failedGates = latest.results.filter((r) => !r.passed);
1159
+ if (failedGates.length > 0) {
1160
+ input.failurePatterns = failedGates.map((r) => {
1161
+ const label = r.required ? `${r.name} (required)` : r.name;
1162
+ const snippet = r.output ? ` \u2014 ${r.output.slice(0, 300)}` : "";
1163
+ return `${label}${snippet}`;
1164
+ }).join("\n");
1165
+ }
1166
+ }
1167
+ }
1168
+ if (this.deps.getFieldValues) {
1169
+ try {
1170
+ input.fieldValues = await this.deps.getFieldValues(ticketId);
1171
+ } catch (err) {
1172
+ const msg = err instanceof Error ? err.message : String(err);
1173
+ console.error(` [warn] Advisor getFieldValues failed for ${ticketId}: ${msg}`);
1174
+ }
1175
+ }
1176
+ if (this.deps.gateSnapshotStore) {
1177
+ const recentSnapshots = this.deps.gateSnapshotStore.getRecent(ticketId, 3);
1178
+ if (recentSnapshots.length > 0) {
1179
+ input.gateHistory = recentSnapshots;
1180
+ const latest = this.deps.gateSnapshotStore.getLatest(ticketId);
1181
+ if (latest) {
1182
+ input.currentGateResults = latest.results;
1183
+ }
1184
+ input.trajectory = classifyTrajectory(recentSnapshots);
1185
+ }
1186
+ }
1187
+ let response;
1188
+ try {
1189
+ response = await this.deps.invokeAdvisor(input);
1190
+ } catch (err) {
1191
+ const msg = err instanceof Error ? err.message : String(err);
1192
+ console.error(` [advisor] Failed for ${ticketId}: ${msg} \u2014 falling back to default behavior`);
1193
+ return false;
1194
+ }
1195
+ this.advisorBudget.set(ticketId, remaining - 1);
1196
+ const gateSnapshot = this.deps.gateSnapshotStore?.getLatest(ticketId);
1197
+ const gatePassed = gateSnapshot ? gateSnapshot.results.filter((r) => r.passed).length : 0;
1198
+ const gateTotal = gateSnapshot ? gateSnapshot.results.length : 0;
1199
+ this.deps.eventEmitter?.emit({
1200
+ layer: "advisor",
1201
+ eventType: `advisor_${response.action.toLowerCase()}`,
1202
+ severity: response.action === "ESCALATE" ? "failure" : response.action === "RETRY_WITH_FEEDBACK" || response.action === "RETRY_DIFFERENT_MODEL" ? "info" : "warning",
1203
+ summary: `Advisor: ${response.action} \u2014 ${response.reason.slice(0, 200)}`,
1204
+ detail: {
1205
+ action: response.action,
1206
+ reason: response.reason,
1207
+ feedback: response.feedback ?? null,
1208
+ budget_remaining: `${String(remaining - 1)}/${String(colConfig.advisorMaxInvocations ?? 2)}`,
1209
+ gate_summary: gateTotal > 0 ? { passed: gatePassed, total: gateTotal } : null,
1210
+ trajectory: input.trajectory ? { status: input.trajectory.status, confidence: input.trajectory.confidence } : null,
1211
+ ticket_number: ticket?.ticket_number ?? null
1212
+ },
1213
+ ticketId,
1214
+ columnId
1215
+ });
1216
+ switch (response.action) {
1217
+ case "RETRY_WITH_FEEDBACK": {
1218
+ if (response.feedback) {
1219
+ await this.safeAction(
1220
+ ticketId,
1221
+ "advisor:createComment",
1222
+ () => this.deps.createComment(ticketId, `ADVISOR FEEDBACK:
1223
+ ${response.feedback}`)
1224
+ );
1225
+ }
1226
+ this.knownTickets.delete(ticketId);
1227
+ await this.spawnOrQueue(ticketId, columnId, true, true);
1228
+ this.completing.delete(ticketId);
1229
+ return true;
1230
+ }
1231
+ case "RETRY_DIFFERENT_MODEL": {
1232
+ const currentModel = result.model ?? colConfig.modelPreference ?? "default";
1233
+ const escalation = colConfig.modelRouting?.escalation ?? [];
1234
+ let nextModel;
1235
+ const fullLadder = colConfig.modelRouting ? [colConfig.modelRouting.initial, ...colConfig.modelRouting.escalation] : [];
1236
+ const currentIdx = fullLadder.indexOf(currentModel);
1237
+ if (currentIdx >= 0 && currentIdx < fullLadder.length - 1) {
1238
+ nextModel = fullLadder[currentIdx + 1];
1239
+ } else if (escalation.length > 0) {
1240
+ nextModel = escalation[0];
1241
+ }
1242
+ const escalatedModel = nextModel ?? currentModel;
1243
+ if (escalatedModel === currentModel) {
1244
+ return false;
1245
+ }
1246
+ await this.safeAction(
1247
+ ticketId,
1248
+ "advisor:createComment",
1249
+ () => this.deps.createComment(
1250
+ ticketId,
1251
+ `ADVISOR: Escalating model ${currentModel} \u2192 ${escalatedModel} \u2014 ${response.reason}`
1252
+ )
1253
+ );
1254
+ this.knownTickets.delete(ticketId);
1255
+ this.ticketModelOverrides.set(ticketId, escalatedModel);
1256
+ await this.spawnOrQueue(ticketId, columnId, true, true);
1257
+ this.completing.delete(ticketId);
1258
+ return true;
1259
+ }
1260
+ case "RELAX_WITH_DEBT": {
1261
+ const waivedGates = (response.debt_items ?? []).filter((d) => d.type === "waived_gate").map((d) => d.description);
1262
+ if (response.debt_items && response.debt_items.length > 0 && this.deps.setFieldValue) {
1263
+ const items = response.debt_items.map((d) => ({ ...d, source_column: colScope?.column.name ?? "" }));
1264
+ await this.safeAction(
1265
+ ticketId,
1266
+ "advisor:setFieldValue:debt_items",
1267
+ () => this.deps.setFieldValue(ticketId, "debt_items", items)
1268
+ );
1269
+ if (waivedGates.length > 0) {
1270
+ await this.safeAction(
1271
+ ticketId,
1272
+ "advisor:setFieldValue:gate_waiver",
1273
+ () => this.deps.setFieldValue(ticketId, "gate_waiver", waivedGates)
1274
+ );
1275
+ }
1276
+ await this.safeAction(
1277
+ ticketId,
1278
+ "advisor:createSignal:debt",
1279
+ () => this.deps.createSignal(
1280
+ ticketId,
1281
+ `DEBT: This ticket advanced with ${String(items.length)} unresolved items (${String(items.filter((d) => d.severity === "high").length)} high, ${String(items.filter((d) => d.severity === "medium").length)} medium).`
1282
+ )
1283
+ );
1284
+ if (this.deps.fetchBlockedTickets) {
1285
+ try {
1286
+ const blocked = await this.deps.fetchBlockedTickets(ticketId);
1287
+ const ticketNum = colScope?.tickets.find((t) => t.id === ticketId)?.ticket_number;
1288
+ for (const dep of blocked) {
1289
+ if (dep.id === ticketId) continue;
1290
+ await this.safeAction(
1291
+ dep.id,
1292
+ "advisor:createSignal:upstream_debt",
1293
+ () => this.deps.createSignal(
1294
+ dep.id,
1295
+ `UPSTREAM DEBT: Blocking ticket #${String(ticketNum ?? "?")} advanced with debt. Review debt_items before building on its interfaces.`
1296
+ )
1297
+ );
1298
+ }
1299
+ } catch (err) {
1300
+ const msg = err instanceof Error ? err.message : String(err);
1301
+ console.error(` [warn] Upstream debt signal propagation failed for ${ticketId}: ${msg}`);
1302
+ }
1303
+ }
1304
+ }
1305
+ await this.safeAction(
1306
+ ticketId,
1307
+ "advisor:createComment",
1308
+ () => this.deps.createComment(ticketId, `ADVISOR: Relaxing with debt \u2014 ${response.reason}`)
1309
+ );
1310
+ if (this.deps.moveTicketToColumn) {
1311
+ const nextColumn = this.findNextColumn(columnId);
1312
+ if (nextColumn) {
1313
+ const moved = await this.safeAction(
1314
+ ticketId,
1315
+ "advisor:moveTicket",
1316
+ () => this.deps.moveTicketToColumn(ticketId, nextColumn.columnId, {
1317
+ relaxed_with_debt: true,
1318
+ source_column: colScope?.column.name,
1319
+ debt_item_count: response.debt_items?.length ?? 0,
1320
+ gate_waivers: waivedGates
1321
+ })
1322
+ );
1323
+ if (!moved) {
1324
+ await this.safeAction(
1325
+ ticketId,
1326
+ "advisor:failComment",
1327
+ () => this.deps.createComment(ticketId, `ADVISOR: Attempted to move ticket but the operation failed. Manual intervention needed.`)
1328
+ );
1329
+ }
1330
+ }
1331
+ }
1332
+ return false;
1333
+ }
1334
+ case "SPLIT_TICKET": {
1335
+ if (response.split_specs && response.split_specs.length > 0 && this.deps.createTickets) {
1336
+ try {
1337
+ await this.deps.createTickets(ticketId, response.split_specs);
1338
+ if (this.deps.archiveTicket) {
1339
+ await this.safeAction(
1340
+ ticketId,
1341
+ "advisor:archiveTicket",
1342
+ () => this.deps.archiveTicket(ticketId)
1343
+ );
1344
+ }
1345
+ } catch (err) {
1346
+ const msg = err instanceof Error ? err.message : String(err);
1347
+ console.error(` [warn] SPLIT_TICKET createTickets failed for ${ticketId}: ${msg}`);
1348
+ }
1349
+ }
1350
+ await this.safeAction(
1351
+ ticketId,
1352
+ "advisor:createComment",
1353
+ () => this.deps.createComment(ticketId, `ADVISOR: Split ticket \u2014 ${response.reason}`)
1354
+ );
1355
+ return false;
1356
+ }
1357
+ case "ESCALATE": {
1358
+ const targetColId = this.cachedBoardScope?.circuit_breaker.target_column_id;
1359
+ if (targetColId && this.deps.moveTicketToColumn) {
1360
+ const moved = await this.safeAction(
1361
+ ticketId,
1362
+ "advisor:moveTicket",
1363
+ () => this.deps.moveTicketToColumn(ticketId, targetColId, {
1364
+ escalation_reason: response.reason,
1365
+ source_column: colScope?.column.name
1366
+ })
1367
+ );
1368
+ if (!moved) {
1369
+ await this.safeAction(
1370
+ ticketId,
1371
+ "advisor:failComment",
1372
+ () => this.deps.createComment(ticketId, `ADVISOR: Attempted to move ticket but the operation failed. Manual intervention needed.`)
1373
+ );
1374
+ }
1375
+ }
1376
+ await this.safeAction(
1377
+ ticketId,
1378
+ "advisor:createComment",
1379
+ () => this.deps.createComment(ticketId, `ADVISOR: Escalated for human review \u2014 ${response.reason}`)
1380
+ );
1381
+ return false;
1382
+ }
1383
+ }
1384
+ return false;
1385
+ }
1386
+ /**
1387
+ * Handle evaluator column verdict: parse the output, determine action, move ticket accordingly.
1388
+ */
1389
+ async handleEvaluatorVerdict(ticketId, columnId, result, colConfig) {
1390
+ const verdict = parseVerdict(result.output ?? "");
1391
+ if ("parseFailed" in verdict && verdict.parseFailed) {
1392
+ await this.deps.createComment(ticketId, `EVALUATOR: Verdict could not be parsed \u2014 ticket held for manual review.
1393
+
1394
+ Raw output: ${(result.output ?? "").slice(0, 500)}`).catch((e) => {
1395
+ console.error(` [warn] Failed to write parse-failure comment for ${ticketId}: ${e instanceof Error ? e.message : String(e)}`);
1396
+ });
1397
+ return;
1398
+ }
1399
+ const action = resolveVerdictAction(verdict);
1400
+ const blockers = verdict.findings.filter((f) => f.severity === "blocker");
1401
+ const warnings = verdict.findings.filter((f) => f.severity === "warning");
1402
+ const nits = verdict.findings.filter((f) => f.severity === "nit");
1403
+ this.deps.eventEmitter?.emit({
1404
+ layer: "evaluator",
1405
+ eventType: action === "forward" ? "evaluator_approved" : action === "reject" ? "evaluator_rejected" : "evaluator_forwarded",
1406
+ severity: action === "forward" ? "info" : action === "reject" ? "failure" : "warning",
1407
+ summary: `Evaluator: ${action} (${String(blockers.length)} blockers, ${String(warnings.length)} warnings, ${String(nits.length)} nits)`,
1408
+ detail: {
1409
+ decision: verdict.decision,
1410
+ summary: verdict.summary,
1411
+ findings: verdict.findings,
1412
+ ticket_number: this.columnScopes.get(columnId)?.tickets.find((t) => t.id === ticketId)?.ticket_number ?? null
1413
+ },
1414
+ ticketId,
1415
+ columnId
1416
+ });
1417
+ switch (action) {
1418
+ case "forward": {
1419
+ if (this.deps.moveTicketToColumn) {
1420
+ const nextColumn = this.findNextColumn(columnId);
1421
+ if (nextColumn) {
1422
+ const moved = await this.safeAction(
1423
+ ticketId,
1424
+ "evaluator:moveTicket:forward",
1425
+ () => this.deps.moveTicketToColumn(ticketId, nextColumn.columnId, {
1426
+ evaluator_verdict: "approved",
1427
+ verdict_summary: verdict.summary
1428
+ })
1429
+ );
1430
+ if (!moved) {
1431
+ await this.safeAction(
1432
+ ticketId,
1433
+ "evaluator:failComment",
1434
+ () => this.deps.createComment(ticketId, `EVALUATOR: Attempted to move ticket forward but the operation failed. Manual intervention needed.`)
1435
+ );
1436
+ }
1437
+ }
1438
+ }
1439
+ await this.safeAction(
1440
+ ticketId,
1441
+ "evaluator:createComment",
1442
+ () => this.deps.createComment(ticketId, `EVALUATOR: Approved \u2014 ${verdict.summary}`)
1443
+ );
1444
+ break;
1445
+ }
1446
+ case "reject": {
1447
+ if (this.deps.moveTicketToColumn) {
1448
+ const prevColumn = this.findPreviousColumn(columnId);
1449
+ if (prevColumn) {
1450
+ const moved = await this.safeAction(
1451
+ ticketId,
1452
+ "evaluator:moveTicket:reject",
1453
+ () => this.deps.moveTicketToColumn(ticketId, prevColumn.columnId, {
1454
+ evaluator_verdict: "rejected",
1455
+ verdict_summary: verdict.summary,
1456
+ findings: verdict.findings
1457
+ })
1458
+ );
1459
+ if (!moved) {
1460
+ await this.safeAction(
1461
+ ticketId,
1462
+ "evaluator:failComment",
1463
+ () => this.deps.createComment(ticketId, `EVALUATOR: Attempted to reject ticket but the move operation failed. Manual intervention needed.`)
1464
+ );
1465
+ }
1466
+ }
1467
+ }
1468
+ const findingsText = verdict.findings.map((f) => `- [${f.severity}] ${f.description}${f.file ? ` (${f.file}${f.line ? `:${String(f.line)}` : ""})` : ""}`).join("\n");
1469
+ await this.safeAction(
1470
+ ticketId,
1471
+ "evaluator:createComment",
1472
+ () => this.deps.createComment(ticketId, `EVALUATOR: Rejected \u2014 ${verdict.summary}
1473
+
1474
+ ${findingsText}`)
1475
+ );
1476
+ break;
1477
+ }
1478
+ case "forward_with_signals": {
1479
+ if (this.deps.moveTicketToColumn) {
1480
+ const nextColumn = this.findNextColumn(columnId);
1481
+ if (nextColumn) {
1482
+ const moved = await this.safeAction(
1483
+ ticketId,
1484
+ "evaluator:moveTicket:forward_with_signals",
1485
+ () => this.deps.moveTicketToColumn(ticketId, nextColumn.columnId, {
1486
+ evaluator_verdict: "approved_with_warnings",
1487
+ verdict_summary: verdict.summary
1488
+ })
1489
+ );
1490
+ if (!moved) {
1491
+ await this.safeAction(
1492
+ ticketId,
1493
+ "evaluator:failComment",
1494
+ () => this.deps.createComment(ticketId, `EVALUATOR: Attempted to move ticket forward but the operation failed. Manual intervention needed.`)
1495
+ );
1496
+ }
1497
+ }
1498
+ }
1499
+ for (const f of verdict.findings) {
1500
+ await this.safeAction(
1501
+ ticketId,
1502
+ "evaluator:createSignal",
1503
+ () => this.deps.createSignal(ticketId, `EVALUATOR: [${f.severity}] ${f.description}`)
1504
+ );
1505
+ }
1506
+ await this.safeAction(
1507
+ ticketId,
1508
+ "evaluator:createComment",
1509
+ () => this.deps.createComment(ticketId, `EVALUATOR: Forwarded with ${String(verdict.findings.length)} warning(s) \u2014 ${verdict.summary}`)
1510
+ );
1511
+ break;
1512
+ }
1513
+ }
1514
+ }
1515
+ /**
1516
+ * Find the previous pipeline column by board position (for evaluator rejections — send back to worker).
1517
+ * Returns null if current column is the first pipeline column.
1518
+ */
1519
+ findPreviousColumn(currentColumnId) {
1520
+ const bs = this.cachedBoardScope;
1521
+ if (!bs) return null;
1522
+ const currentBoardCol = bs.columns.find((c) => c.id === currentColumnId);
1523
+ if (!currentBoardCol) return null;
1524
+ const prevBoardCol = bs.columns.filter((c) => c.position < currentBoardCol.position && this.pipelineColumns.has(c.id)).sort((a, b) => b.position - a.position)[0];
1525
+ return prevBoardCol ? this.pipelineColumns.get(prevBoardCol.id) ?? null : null;
1526
+ }
1527
+ /**
1528
+ * Find the next pipeline column by board position (for RELAX_WITH_DEBT forward moves).
1529
+ * Returns null if current column is the last pipeline column.
1530
+ */
1531
+ findNextColumn(currentColumnId) {
1532
+ const bs = this.cachedBoardScope;
1533
+ if (!bs) return null;
1534
+ const currentBoardCol = bs.columns.find((c) => c.id === currentColumnId);
1535
+ if (!currentBoardCol) return null;
1536
+ const nextBoardCol = bs.columns.filter((c) => c.position > currentBoardCol.position && this.pipelineColumns.has(c.id)).sort((a, b) => a.position - b.position)[0];
1537
+ return nextBoardCol ? this.pipelineColumns.get(nextBoardCol.id) ?? null : null;
1538
+ }
1539
+ /**
1540
+ * Called when a loop finishes. Cleans up tracking and drains the queue.
1541
+ */
1542
+ async onLoopComplete(ticketId, columnId, result) {
1543
+ this.completing.add(ticketId);
1544
+ this.activeLoops.delete(ticketId);
1545
+ try {
1546
+ const colScope = this.columnScopes.get(columnId);
1547
+ const ticket = colScope?.tickets.find((t) => t.id === ticketId);
1548
+ const colCfg = this.pipelineColumns.get(columnId);
1549
+ const invocationType = colCfg?.columnType === "evaluator" ? "evaluator" : colCfg?.invocationTier === "light" ? "light" : "heavy";
1550
+ this.deps.eventEmitter?.emit({
1551
+ layer: "session",
1552
+ eventType: result.reason === "error" ? "session_error" : "session_ended",
1553
+ severity: result.reason === "error" ? "failure" : "info",
1554
+ summary: `Session ended: ${result.reason} (${String(result.iterations)} iter${result.model ? `, ${result.model}` : ""})`,
1555
+ detail: {
1556
+ model: result.model ?? null,
1557
+ invocation_type: invocationType,
1558
+ duration_ms: result.durationMs ?? null,
1559
+ tokens_in: result.tokensIn ?? null,
1560
+ tokens_out: result.tokensOut ?? null,
1561
+ tool_call_count: result.toolCallCount ?? null,
1562
+ exit_reason: result.reason,
1563
+ gutter_count: result.gutterCount,
1564
+ worktree_name: colCfg?.worktreeEnabled && ticket ? generateWorktreeName(ticket.ticket_number, colCfg.name) : null,
1565
+ ticket_number: ticket?.ticket_number ?? null
1566
+ },
1567
+ ticketId,
1568
+ columnId,
1569
+ iteration: result.iterations
1570
+ });
1571
+ if (result.finalGateSnapshot && this.deps.eventEmitter) {
1572
+ const gateSnapshots = this.deps.gateSnapshotStore?.getRecent(ticketId, 10) ?? [];
1573
+ const trajectory = gateSnapshots.length > 0 ? classifyTrajectory(gateSnapshots) : null;
1574
+ for (const gate of result.finalGateSnapshot.results) {
1575
+ const history = gateSnapshots.map((s) => {
1576
+ const gr = s.results.find((r) => r.name === gate.name);
1577
+ return gr ? { passed: gr.passed, iteration: s.iteration } : null;
1578
+ }).filter(Boolean);
1579
+ this.deps.eventEmitter.emit({
1580
+ layer: "gate",
1581
+ eventType: gate.passed ? "gate_passed" : "gate_failed",
1582
+ severity: gate.passed ? "info" : "failure",
1583
+ summary: `Gate "${gate.name}" ${gate.passed ? "passed" : gate.timed_out ? "timed out" : "failed"} for ${ticket ? `PET-${String(ticket.ticket_number)}` : ticketId.slice(0, 8)}`,
1584
+ detail: {
1585
+ gate_name: gate.name,
1586
+ exit_code: gate.exit_code,
1587
+ duration_ms: gate.duration_ms,
1588
+ required: gate.required,
1589
+ timed_out: gate.timed_out,
1590
+ output: gate.output.slice(0, 2e3),
1591
+ gate_history: history,
1592
+ trajectory: trajectory?.status ?? null,
1593
+ ticket_number: ticket?.ticket_number ?? null
1594
+ },
1595
+ ticketId,
1596
+ columnId,
1597
+ iteration: result.iterations
1598
+ });
1599
+ }
1600
+ }
1601
+ if (this.deps.eventEmitter && this.deps.costTracker) {
1602
+ const tc = this.deps.costTracker.getTicketCost(ticketId);
1603
+ if (tc) {
1604
+ const totalIn = this.deps.costTracker.totalTokensIn;
1605
+ const totalOut = this.deps.costTracker.totalTokensOut;
1606
+ const maxIn = this.deps.costTracker.maxInputTokens;
1607
+ const pctUsed = maxIn > 0 ? Math.round((totalIn + totalOut) / (maxIn * 2) * 100) : 0;
1608
+ const severity = this.deps.costTracker.isExhausted() ? "failure" : this.deps.costTracker.isWarning() ? "warning" : "info";
1609
+ this.deps.eventEmitter.emit({
1610
+ layer: "cost",
1611
+ eventType: this.deps.costTracker.isExhausted() ? "budget_exhausted" : this.deps.costTracker.isWarning() ? "budget_warning" : "tokens_tracked",
1612
+ severity,
1613
+ summary: `Token usage: ${String(result.tokensIn ?? 0)} in / ${String(result.tokensOut ?? 0)} out`,
1614
+ detail: {
1615
+ tokens_in: result.tokensIn ?? 0,
1616
+ tokens_out: result.tokensOut ?? 0,
1617
+ pct_used: pctUsed,
1618
+ max_tokens: maxIn,
1619
+ ticket_number: ticket?.ticket_number ?? null
1620
+ },
1621
+ ticketId,
1622
+ columnId,
1623
+ iteration: result.iterations
1624
+ });
1625
+ }
1626
+ }
1627
+ const colConfig = this.pipelineColumns.get(columnId);
1628
+ const terminalCleanup = async () => {
1629
+ this.advisorBudget.delete(ticketId);
1630
+ if (colConfig?.checkpointEnabled && this.deps.setFieldValue) {
1631
+ await this.safeAction(
1632
+ ticketId,
1633
+ "clearCheckpoint",
1634
+ () => this.deps.setFieldValue(ticketId, "loop_checkpoint", null)
1635
+ );
1636
+ }
1637
+ const isTerminal = result.reason === "moved" || result.reason === "max_iterations" || result.reason === "error" || result.reason === "stalled" || result.reason === "stopped" || result.reason === "deleted";
1638
+ if (isTerminal && colConfig) {
1639
+ const colScope2 = this.columnScopes.get(columnId);
1640
+ const ticket2 = colScope2?.tickets.find((t) => t.id === ticketId);
1641
+ if (ticket2) {
1642
+ const worktreeName = generateWorktreeName(ticket2.ticket_number, colConfig.name);
1643
+ if (colConfig.worktreeOnDone === "merge" && colConfig.worktreeIntegrationBranch && this.deps.mergeWorktree) {
1644
+ void this.deps.mergeWorktree(worktreeName, colConfig.worktreeIntegrationBranch).then((success) => {
1645
+ if (!success) {
1646
+ console.error(` [warn] Worktree merge failed for ${worktreeName} \u2192 ${colConfig.worktreeIntegrationBranch} \u2014 may need manual resolution`);
1647
+ }
1648
+ }).catch((err) => {
1649
+ console.error(` [warn] Worktree merge error for ${worktreeName}: ${err instanceof Error ? err.message : String(err)}`);
1650
+ });
1651
+ } else if (colConfig.worktreeOnDone === "cleanup" && this.deps.cleanupWorktree) {
1652
+ void this.deps.cleanupWorktree(worktreeName).then((success) => {
1653
+ if (!success) {
1654
+ console.error(` [warn] Worktree cleanup failed for ${worktreeName} \u2014 may need manual removal`);
1655
+ }
1656
+ }).catch((err) => {
1657
+ console.error(` [warn] Worktree cleanup error for ${worktreeName}: ${err instanceof Error ? err.message : String(err)}`);
1658
+ });
1659
+ }
1660
+ }
1661
+ }
1662
+ this.deps.gateSnapshotStore?.clear(ticketId);
1663
+ };
1664
+ if (colConfig?.columnType === "evaluator") {
1665
+ await this.handleEvaluatorVerdict(ticketId, columnId, result, colConfig);
1666
+ await this.drainQueue(columnId);
1667
+ await terminalCleanup();
1668
+ return;
1669
+ }
1670
+ const isFailure = result.reason === "stalled" || result.reason === "error" || result.reason === "max_iterations";
1671
+ if (isFailure && colConfig) {
1672
+ const retried = await this.invokeAdvisorRecovery(ticketId, columnId, result, colConfig);
1673
+ if (retried) return;
1674
+ }
1675
+ await this.drainQueue(columnId);
1676
+ if (this.deps.invokeReplanner && !this.pipelinePaused) {
1677
+ const state = this.buildPipelineState();
1678
+ const triggers = {
1679
+ escalation_count: 3,
1680
+ cost_threshold_pct: 75,
1681
+ repeated_gate_failure_count: 3,
1682
+ duration_threshold_minutes: 480
1683
+ };
1684
+ if (shouldFireReplanner(triggers, state) && this.replannerInvocations < this.replannerMaxInvocations) {
1685
+ this.replannerInvocations++;
1686
+ try {
1687
+ const ticketSummaries = [];
1688
+ for (const [colId, scope] of this.columnScopes) {
1689
+ const colConfig2 = this.pipelineColumns.get(colId);
1690
+ const columnName = colConfig2?.name ?? colId;
1691
+ const colQueue = this.loopQueues.get(colId) ?? [];
1692
+ for (const ticket2 of scope.tickets) {
1693
+ const isActive = this.activeLoops.has(ticket2.id);
1694
+ const isQueued = colQueue.includes(ticket2.id);
1695
+ const status = isActive ? "active" : isQueued ? "queued" : "idle";
1696
+ let gatePassRate = 0;
1697
+ if (this.deps.gateSnapshotStore) {
1698
+ const latest = this.deps.gateSnapshotStore.getLatest(ticket2.id);
1699
+ if (latest) {
1700
+ const total = latest.results.length;
1701
+ const passed = latest.results.filter((r) => r.passed).length;
1702
+ gatePassRate = total > 0 ? passed / total : 0;
1703
+ }
1704
+ }
1705
+ ticketSummaries.push({
1706
+ id: ticket2.id,
1707
+ title: ticket2.title,
1708
+ column: columnName,
1709
+ status,
1710
+ iterations: 0,
1711
+ // Not tracked at orchestrator level
1712
+ gatePassRate
1713
+ });
1714
+ }
1715
+ }
1716
+ const triggerReason = this.identifyTriggerReason(triggers, state);
1717
+ const response = await this.deps.invokeReplanner({
1718
+ ...state,
1719
+ ticketSummaries,
1720
+ triggerReason
1721
+ });
1722
+ this.deps.eventEmitter?.emit({
1723
+ layer: "replanner",
1724
+ eventType: `replanner_${response.action.toLowerCase()}`,
1725
+ severity: response.action === "CONTINUE" ? "info" : response.action === "PAUSE_PIPELINE" ? "warning" : "info",
1726
+ summary: `Replanner: ${response.action} \u2014 ${response.reason.slice(0, 200)}`,
1727
+ detail: {
1728
+ action: response.action,
1729
+ reason: response.reason
1730
+ }
1731
+ });
1732
+ await this.executeReplannerAction(response);
1733
+ if (response.action === "CONTINUE") {
1734
+ this.replannerInvocations--;
1735
+ }
1736
+ } catch (err) {
1737
+ const msg = err instanceof Error ? err.message : String(err);
1738
+ console.error(` [replanner] Failed: ${msg} \u2014 pausing pipeline for safety`);
1739
+ this.pipelinePaused = true;
1740
+ }
1741
+ }
1742
+ if (this.replannerInvocations >= this.replannerMaxInvocations) {
1743
+ this.pipelinePaused = true;
1744
+ console.error("Replanner fired 3 non-CONTINUE actions \u2014 auto-pausing pipeline");
1745
+ }
1746
+ }
1747
+ let comment;
1748
+ switch (result.reason) {
1749
+ case "moved":
1750
+ comment = `Pipeline agent advanced ticket after ${result.iterations} iteration(s).`;
1751
+ if (this.deps.appendRunMemory) {
1752
+ await this.safeAction(
1753
+ ticketId,
1754
+ "appendRunMemory",
1755
+ () => this.deps.appendRunMemory(
1756
+ "Discovered Interfaces",
1757
+ `- Ticket ${ticketId} completed in ${colConfig?.name ?? columnId} after ${String(result.iterations)} iteration(s)`
1758
+ )
1759
+ );
1760
+ }
1761
+ break;
1762
+ case "max_iterations":
1763
+ comment = `Pipeline agent reached iteration limit (${result.iterations}) without advancing. Manual review needed.`;
1764
+ break;
1765
+ case "stalled":
1766
+ comment = `Pipeline agent stalled \u2014 no progress for ${result.gutterCount} consecutive iterations (of ${result.iterations} total). Manual review needed.`;
1767
+ break;
1768
+ case "error":
1769
+ comment = `Pipeline agent encountered an error after ${result.iterations} iteration(s): ${result.lastError ?? "unknown error"}`;
1770
+ break;
1771
+ case "stopped":
1772
+ comment = `Pipeline agent was stopped externally after ${result.iterations} iteration(s).`;
1773
+ break;
1774
+ case "deleted":
1775
+ comment = `Pipeline agent stopped \u2014 ticket was deleted or archived during iteration ${result.iterations}.`;
1776
+ break;
1777
+ }
1778
+ if (result.reason !== "deleted") {
1779
+ await this.deps.createComment(ticketId, comment).catch((err) => {
1780
+ const msg = err instanceof Error ? err.message : String(err);
1781
+ console.error(` [warn] Failed to write completion comment for ${ticketId}: ${msg}`);
1782
+ });
1783
+ }
1784
+ if (result.reason === "stalled") {
1785
+ await this.deps.createSignal(
1786
+ ticketId,
1787
+ `Previous pipeline run stalled after ${result.iterations} iterations with no progress. Review comments for details before retrying.`
1788
+ ).catch((err) => {
1789
+ const msg = err instanceof Error ? err.message : String(err);
1790
+ console.error(` [warn] Failed to write signal for ${ticketId}: ${msg}`);
1791
+ });
1792
+ } else if (result.reason === "error") {
1793
+ await this.deps.createSignal(
1794
+ ticketId,
1795
+ `Previous pipeline run failed after ${result.iterations} iteration(s): ${result.lastError ?? "unknown error"}. Review comments for details before retrying.`
1796
+ ).catch((err) => {
1797
+ const msg = err instanceof Error ? err.message : String(err);
1798
+ console.error(` [warn] Failed to write signal for ${ticketId}: ${msg}`);
1799
+ });
1800
+ }
1801
+ await terminalCleanup();
1802
+ } finally {
1803
+ this.completing.delete(ticketId);
1804
+ }
1805
+ }
1806
+ /**
1807
+ * Build PipelineState from orchestrator internals for replanner evaluation.
1808
+ */
1809
+ buildPipelineState() {
1810
+ const costTracker = this.deps.costTracker;
1811
+ const totalTokensIn = costTracker ? costTracker.totalTokensIn : 0;
1812
+ const maxInputTokens = costTracker ? costTracker.maxInputTokens : 5e6;
1813
+ let escalatedTickets = 0;
1814
+ const targetColId = this.cachedBoardScope?.circuit_breaker.target_column_id;
1815
+ if (targetColId) {
1816
+ const targetCol = this.cachedBoardScope?.columns.find((c) => c.id === targetColId);
1817
+ if (targetCol) escalatedTickets = targetCol.ticket_count;
1818
+ }
1819
+ const repeatedGateFailures = {};
1820
+ if (this.deps.gateSnapshotStore) {
1821
+ const ticketIds = this.deps.gateSnapshotStore.getAllTicketIds();
1822
+ for (const tid of ticketIds) {
1823
+ const latest = this.deps.gateSnapshotStore.getLatest(tid);
1824
+ if (latest) {
1825
+ for (const r of latest.results) {
1826
+ if (!r.passed) {
1827
+ repeatedGateFailures[r.name] = (repeatedGateFailures[r.name] ?? 0) + 1;
1828
+ }
1829
+ }
1830
+ }
1831
+ }
1832
+ }
1833
+ const durationMinutes = (Date.now() - this.pipelineStartTime) / 6e4;
1834
+ return { escalatedTickets, totalTokensIn, maxInputTokens, repeatedGateFailures, durationMinutes };
1835
+ }
1836
+ /**
1837
+ * Identify which trigger condition caused the replanner to fire.
1838
+ */
1839
+ identifyTriggerReason(triggers, state) {
1840
+ const reasons = [];
1841
+ if (state.escalatedTickets >= triggers.escalation_count) {
1842
+ reasons.push(`${String(state.escalatedTickets)} tickets escalated (threshold: ${String(triggers.escalation_count)})`);
1843
+ }
1844
+ if (state.maxInputTokens > 0) {
1845
+ const pct = state.totalTokensIn / state.maxInputTokens * 100;
1846
+ if (pct >= triggers.cost_threshold_pct) {
1847
+ reasons.push(`Token usage at ${pct.toFixed(0)}% (threshold: ${String(triggers.cost_threshold_pct)}%)`);
1848
+ }
1849
+ }
1850
+ for (const [gate, count] of Object.entries(state.repeatedGateFailures)) {
1851
+ if (count >= triggers.repeated_gate_failure_count) {
1852
+ reasons.push(`Gate "${gate}" failing on ${String(count)} ticket(s) (threshold: ${String(triggers.repeated_gate_failure_count)})`);
1853
+ }
1854
+ }
1855
+ if (state.durationMinutes >= triggers.duration_threshold_minutes) {
1856
+ reasons.push(`Pipeline running for ${state.durationMinutes.toFixed(0)}min (threshold: ${String(triggers.duration_threshold_minutes)}min)`);
1857
+ }
1858
+ return reasons.length > 0 ? reasons.join("; ") : "Unknown trigger";
1859
+ }
1860
+ /**
1861
+ * Execute the action returned by the replanner.
1862
+ */
1863
+ async executeReplannerAction(response) {
1864
+ switch (response.action) {
1865
+ case "CONTINUE":
1866
+ break;
1867
+ case "PAUSE_PIPELINE":
1868
+ this.pipelinePaused = true;
1869
+ break;
1870
+ case "ARCHIVE_TICKETS":
1871
+ if (response.ticket_ids && this.deps.archiveTicket) {
1872
+ for (const id of response.ticket_ids) {
1873
+ await this.safeAction(
1874
+ id,
1875
+ "replanner:archiveTicket",
1876
+ () => this.deps.archiveTicket(id)
1877
+ );
1878
+ }
1879
+ }
1880
+ break;
1881
+ case "CREATE_SIGNAL":
1882
+ if (response.signal_content && this.deps.createColumnSignal) {
1883
+ for (const colId of this.pipelineColumns.keys()) {
1884
+ await this.safeAction(
1885
+ colId,
1886
+ "replanner:createColumnSignal",
1887
+ () => this.deps.createColumnSignal(colId, response.signal_content)
1888
+ );
1889
+ }
1890
+ }
1891
+ break;
1892
+ case "ESCALATE_ALL":
1893
+ this.pipelinePaused = true;
1894
+ break;
1895
+ case "ADJUST_BUDGET":
1896
+ console.error(" [replanner] ADJUST_BUDGET requested but not yet implemented \u2014 treating as CONTINUE");
1897
+ break;
1898
+ }
1899
+ }
1900
+ /**
1901
+ * Start queued tickets for a column until concurrency limit is reached (H16: fills multiple slots).
1902
+ */
1903
+ async drainQueue(columnId) {
1904
+ const colConfig = this.pipelineColumns.get(columnId);
1905
+ if (!colConfig) return;
1906
+ const queue = this.loopQueues.get(columnId);
1907
+ if (!queue || queue.length === 0) return;
1908
+ if (this.isColumnBlocked(columnId)) {
1909
+ console.error(` [drain] Column ${columnId}: BLOCKED by firing constraints \u2014 ${String(queue.length)} ticket(s) remain queued`);
1910
+ return;
1911
+ }
1912
+ while (queue.length > 0) {
1913
+ const activeInColumn = this.activeCountForColumn(columnId);
1914
+ if (activeInColumn >= colConfig.concurrency) return;
1915
+ const nextTicketId = queue.shift();
1916
+ if (this.spawning.has(nextTicketId) || this.activeLoops.has(nextTicketId)) {
1917
+ continue;
1918
+ }
1919
+ this.spawning.add(nextTicketId);
1920
+ this.reserveSlot(columnId);
1921
+ try {
1922
+ const blocked = await this.deps.hasUnresolvedBlockers(nextTicketId);
1923
+ if (blocked) {
1924
+ this.deferredTickets.set(nextTicketId, columnId);
1925
+ this.spawning.delete(nextTicketId);
1926
+ this.releaseSlot(columnId);
1927
+ continue;
1928
+ }
1929
+ } catch (err) {
1930
+ const msg = err instanceof Error ? err.message : String(err);
1931
+ console.error(` [warn] drainQueue blocker check failed for ${nextTicketId}: ${msg} \u2014 deferring`);
1932
+ this.deferredTickets.set(nextTicketId, columnId);
1933
+ this.spawning.delete(nextTicketId);
1934
+ this.releaseSlot(columnId);
1935
+ continue;
1936
+ }
1937
+ try {
1938
+ await this.deps.claimTicket(nextTicketId);
1939
+ this.startTrackedLoop(nextTicketId, columnId, colConfig);
1940
+ } catch (err) {
1941
+ const msg = err instanceof Error ? err.message : String(err);
1942
+ console.error(` [error] Failed to claim/start queued ticket ${nextTicketId}: ${msg}`);
1943
+ this.knownTickets.delete(nextTicketId);
1944
+ } finally {
1945
+ this.spawning.delete(nextTicketId);
1946
+ this.releaseSlot(columnId);
1947
+ }
1948
+ }
1949
+ }
1950
+ /**
1951
+ * Count active loops + in-flight reservations for a specific column.
1952
+ */
1953
+ activeCountForColumn(columnId) {
1954
+ let count = 0;
1955
+ for (const [, loop] of this.activeLoops) {
1956
+ if (loop.columnId === columnId) count++;
1957
+ }
1958
+ return count + (this.columnReservations.get(columnId) ?? 0);
1959
+ }
1960
+ reserveSlot(columnId) {
1961
+ this.columnReservations.set(columnId, (this.columnReservations.get(columnId) ?? 0) + 1);
1962
+ }
1963
+ releaseSlot(columnId) {
1964
+ const current = this.columnReservations.get(columnId) ?? 0;
1965
+ if (current > 0) this.columnReservations.set(columnId, current - 1);
1966
+ }
1967
+ };
1968
+
1969
+ // src/lib/run-memory.ts
1970
+ var DEFAULT_COMPACTION_THRESHOLD = 500;
1971
+ var RunMemory = class {
1972
+ boardName;
1973
+ deps;
1974
+ _documentId = null;
1975
+ _writeQueue = Promise.resolve();
1976
+ constructor(_boardId, boardName, deps) {
1977
+ this.boardName = boardName;
1978
+ this.deps = deps;
1979
+ }
1980
+ get documentId() {
1981
+ return this._documentId;
1982
+ }
1983
+ /**
1984
+ * Create the run memory document. Optional seedContent prepopulates the
1985
+ * Codebase Conventions section.
1986
+ */
1987
+ async initialize(seedContent) {
1988
+ if (this._documentId !== null) return;
1989
+ const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1990
+ const title = `Pipeline Run Memory \u2014 ${this.boardName} \u2014 ${date}`;
1991
+ const content = buildTemplate(seedContent);
1992
+ this._documentId = await this.deps.createDocument(content, title);
1993
+ }
1994
+ /**
1995
+ * Get current document content. Returns '' if not initialized or on error.
1996
+ */
1997
+ async getContent() {
1998
+ if (this._documentId === null) {
1999
+ return "";
2000
+ }
2001
+ try {
2002
+ return await this.deps.getDocument(this._documentId);
2003
+ } catch (err) {
2004
+ console.warn(`[run-memory] getContent failed: ${err instanceof Error ? err.message : String(err)}`);
2005
+ return "";
2006
+ }
2007
+ }
2008
+ /**
2009
+ * Check if document exceeds line threshold (default 500).
2010
+ */
2011
+ async needsCompaction(threshold = DEFAULT_COMPACTION_THRESHOLD) {
2012
+ const content = await this.getContent();
2013
+ if (!content) return false;
2014
+ const lineCount = content.split("\n").length;
2015
+ return lineCount > threshold;
2016
+ }
2017
+ /**
2018
+ * Append content under a specific section heading.
2019
+ * Agents call this after successful iterations to share discoveries.
2020
+ */
2021
+ async append(section, content) {
2022
+ if (this._documentId === null) return;
2023
+ this._writeQueue = this._writeQueue.then(async () => {
2024
+ try {
2025
+ const current = await this.deps.getDocument(this._documentId);
2026
+ const sectionHeader = `## ${section}`;
2027
+ const idx = current.indexOf(sectionHeader);
2028
+ if (idx === -1) {
2029
+ await this.deps.updateDocument(this._documentId, current + `
2030
+
2031
+ ${sectionHeader}
2032
+
2033
+ ${content}`);
2034
+ } else {
2035
+ const afterHeader = idx + sectionHeader.length;
2036
+ const nextSection = current.indexOf("\n## ", afterHeader);
2037
+ const insertAt = nextSection === -1 ? current.length : nextSection;
2038
+ const updated = current.slice(0, insertAt) + `
2039
+ ${content}
2040
+ ` + current.slice(insertAt);
2041
+ await this.deps.updateDocument(this._documentId, updated);
2042
+ }
2043
+ } catch (err) {
2044
+ console.warn(`[run-memory] append failed (section="${section}"): ${err instanceof Error ? err.message : String(err)}`);
2045
+ }
2046
+ }).catch(() => {
2047
+ });
2048
+ await this._writeQueue;
2049
+ }
2050
+ /**
2051
+ * Replace document content with a compacted version.
2052
+ * Called by the orchestrator after a Haiku summarization call.
2053
+ */
2054
+ async compact(compactedContent) {
2055
+ if (this._documentId === null) return;
2056
+ this._writeQueue = this._writeQueue.then(async () => {
2057
+ try {
2058
+ await this.deps.updateDocument(this._documentId, compactedContent);
2059
+ } catch (err) {
2060
+ console.warn(`[run-memory] compact failed: ${err instanceof Error ? err.message : String(err)}`);
2061
+ }
2062
+ }).catch(() => {
2063
+ });
2064
+ await this._writeQueue;
2065
+ }
2066
+ };
2067
+ function buildTemplate(seedContent) {
2068
+ const conventionsSection = seedContent ? `## Codebase Conventions
2069
+
2070
+ ${seedContent}` : `## Codebase Conventions`;
2071
+ return [
2072
+ conventionsSection,
2073
+ `## Discovered Interfaces`,
2074
+ `## Failure Patterns`,
2075
+ `## QA Rejection History`
2076
+ ].join("\n\n");
2077
+ }
2078
+
2079
+ // src/lib/event-queue.ts
2080
+ function isDestructive(type) {
2081
+ return type === "ticket:archived" || type === "ticket:deleted";
2082
+ }
2083
+ var EventQueue = class {
2084
+ queue = /* @__PURE__ */ new Map();
2085
+ // keyed by ticketId for coalescing
2086
+ handler;
2087
+ onError;
2088
+ timer = null;
2089
+ drainRateMs;
2090
+ running = false;
2091
+ draining = false;
2092
+ constructor(handler, options = {}) {
2093
+ this.handler = handler;
2094
+ this.drainRateMs = options.drainRateMs ?? 100;
2095
+ this.onError = options.onError ?? ((event, error) => console.error("EventQueue handler error for event", event, error));
2096
+ }
2097
+ start() {
2098
+ this.running = true;
2099
+ this.timer = setInterval(() => void this.drain(), this.drainRateMs);
2100
+ }
2101
+ stop() {
2102
+ this.running = false;
2103
+ if (this.timer) {
2104
+ clearInterval(this.timer);
2105
+ this.timer = null;
2106
+ }
2107
+ this.queue.clear();
2108
+ }
2109
+ push(event) {
2110
+ if (!this.running) return;
2111
+ const existing = this.queue.get(event.ticketId);
2112
+ if (existing && isDestructive(existing.type) && !isDestructive(event.type)) {
2113
+ return;
2114
+ }
2115
+ this.queue.set(event.ticketId, event);
2116
+ }
2117
+ pushPriority(event) {
2118
+ if (!this.running) return;
2119
+ this.queue.delete(event.ticketId);
2120
+ const result = this.handler(event);
2121
+ if (result instanceof Promise) {
2122
+ result.catch((error) => this.onError(event, error));
2123
+ }
2124
+ }
2125
+ async drain() {
2126
+ if (this.draining) return;
2127
+ if (this.queue.size === 0) return;
2128
+ this.draining = true;
2129
+ try {
2130
+ const [ticketId, event] = this.queue.entries().next().value;
2131
+ this.queue.delete(ticketId);
2132
+ try {
2133
+ await this.handler(event);
2134
+ } catch (error) {
2135
+ this.onError(event, error);
2136
+ }
2137
+ } finally {
2138
+ this.draining = false;
2139
+ }
2140
+ }
2141
+ };
2142
+
2143
+ // src/lib/ws-client.ts
2144
+ import WebSocket from "ws";
2145
+ var PipelineWsClient = class {
2146
+ ws = null;
2147
+ client;
2148
+ options;
2149
+ pingTimer = null;
2150
+ stopped = false;
2151
+ constructor(client, options) {
2152
+ this.client = client;
2153
+ this.options = options;
2154
+ }
2155
+ /** Whether the WebSocket is currently open. */
2156
+ get connected() {
2157
+ return this.ws?.readyState === WebSocket.OPEN;
2158
+ }
2159
+ async connect() {
2160
+ if (this.ws) {
2161
+ try {
2162
+ this.ws.removeAllListeners();
2163
+ this.ws.close();
2164
+ } catch {
2165
+ }
2166
+ this.ws = null;
2167
+ }
2168
+ this.cleanupPing();
2169
+ const { ticket } = await this.client.post("/ws-ticket");
2170
+ const wsUrl = this.client.baseUrl.replace(/^http/, "ws") + `/ws?ticket=${ticket}`;
2171
+ return new Promise((resolve, reject) => {
2172
+ let resolved = false;
2173
+ this.ws = new WebSocket(wsUrl);
2174
+ let subscribed = false;
2175
+ this.ws.on("open", () => {
2176
+ this.pingTimer = setInterval(() => this.send({ type: "ping", payload: {} }), 3e4);
2177
+ });
2178
+ this.ws.on("message", (data) => {
2179
+ try {
2180
+ const event = JSON.parse(data.toString());
2181
+ if (!subscribed) {
2182
+ subscribed = true;
2183
+ this.send({ type: "board:subscribe", payload: { boardId: this.options.boardId, projectId: this.options.projectId } });
2184
+ this.options.onConnect();
2185
+ if (!resolved) {
2186
+ resolved = true;
2187
+ resolve();
2188
+ }
2189
+ }
2190
+ this.options.onEvent(event);
2191
+ } catch (err) {
2192
+ console.error("WS message parse error:", err);
2193
+ }
2194
+ });
2195
+ this.ws.on("close", (code) => {
2196
+ this.cleanupPing();
2197
+ this.options.onDisconnect();
2198
+ if (!resolved) {
2199
+ resolved = true;
2200
+ reject(new Error(`WebSocket closed before handshake (code ${code})`));
2201
+ }
2202
+ });
2203
+ this.ws.on("error", (err) => {
2204
+ if (!resolved) {
2205
+ resolved = true;
2206
+ reject(err);
2207
+ }
2208
+ });
2209
+ });
2210
+ }
2211
+ /**
2212
+ * Attempt to reconnect. Called by the polling fallback on each cycle.
2213
+ * Returns true if reconnected, false if it failed (polling continues).
2214
+ */
2215
+ async tryReconnect() {
2216
+ if (this.stopped || this.connected) return this.connected;
2217
+ try {
2218
+ await this.connect();
2219
+ return true;
2220
+ } catch {
2221
+ return false;
2222
+ }
2223
+ }
2224
+ stop() {
2225
+ this.stopped = true;
2226
+ this.cleanupPing();
2227
+ if (this.ws) {
2228
+ try {
2229
+ this.ws.removeAllListeners();
2230
+ this.ws.close();
2231
+ } catch {
2232
+ }
2233
+ this.ws = null;
2234
+ }
2235
+ }
2236
+ send(data) {
2237
+ if (this.ws?.readyState === WebSocket.OPEN) {
2238
+ this.ws.send(JSON.stringify(data));
2239
+ }
2240
+ }
2241
+ cleanupPing() {
2242
+ if (this.pingTimer) {
2243
+ clearInterval(this.pingTimer);
2244
+ this.pingTimer = null;
2245
+ }
2246
+ }
2247
+ };
2248
+
2249
+ // src/lib/logger.ts
2250
+ import { mkdirSync, writeFileSync, appendFileSync, readdirSync, rmSync, statSync, renameSync } from "fs";
2251
+ import { join } from "path";
2252
+ var MAX_LOG_BYTES = 5 * 1024 * 1024;
2253
+ var MAX_ROTATED_FILES = 3;
2254
+ var ROTATION_CHECK_INTERVAL = 100;
2255
+ var PipelineLogger = class {
2256
+ boardDir;
2257
+ writeCount = 0;
2258
+ constructor(baseDir, boardId) {
2259
+ this.boardDir = join(baseDir, boardId);
2260
+ mkdirSync(this.boardDir, { recursive: true });
2261
+ }
2262
+ orchestrator(message) {
2263
+ const logPath = join(this.boardDir, "orchestrator.log");
2264
+ const entry = `[${(/* @__PURE__ */ new Date()).toISOString()}] ${message}
2265
+ `;
2266
+ appendFileSync(logPath, entry);
2267
+ this.writeCount++;
2268
+ if (this.writeCount >= ROTATION_CHECK_INTERVAL) {
2269
+ this.writeCount = 0;
2270
+ this.rotateIfNeeded(logPath);
2271
+ }
2272
+ }
2273
+ rotateIfNeeded(logPath) {
2274
+ try {
2275
+ const stat = statSync(logPath);
2276
+ if (stat.size < MAX_LOG_BYTES) return;
2277
+ for (let i = MAX_ROTATED_FILES; i >= 1; i--) {
2278
+ const from = i === 1 ? logPath : `${logPath}.${String(i - 1)}`;
2279
+ const to = `${logPath}.${String(i)}`;
2280
+ try {
2281
+ if (i === MAX_ROTATED_FILES) {
2282
+ try {
2283
+ rmSync(to, { force: true });
2284
+ } catch {
2285
+ }
2286
+ }
2287
+ renameSync(from, to);
2288
+ } catch {
2289
+ }
2290
+ }
2291
+ } catch {
2292
+ }
2293
+ }
2294
+ iteration(ticketNumber, iterationNum, data) {
2295
+ const ticketDir = join(this.boardDir, ticketNumber);
2296
+ mkdirSync(ticketDir, { recursive: true });
2297
+ const padded = String(iterationNum).padStart(3, "0");
2298
+ const logPath = join(ticketDir, `iteration-${padded}.log`);
2299
+ const entry = {
2300
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2301
+ iteration: iterationNum,
2302
+ ...data
2303
+ };
2304
+ writeFileSync(logPath, JSON.stringify(entry, null, 2));
2305
+ }
2306
+ formatConsole(ticketNumber, columnName, iteration, status) {
2307
+ const time = (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
2308
+ const ticket = ticketNumber.padEnd(10);
2309
+ const col = columnName.padEnd(12);
2310
+ return `[${time}] ${ticket} ${col} iter:${iteration} ${status}`;
2311
+ }
2312
+ pruneOldLogs(retentionDays) {
2313
+ const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1e3;
2314
+ try {
2315
+ const entries = readdirSync(this.boardDir, { withFileTypes: true });
2316
+ for (const entry of entries) {
2317
+ const fullPath = join(this.boardDir, entry.name);
2318
+ if (entry.isDirectory() && entry.name !== ".") {
2319
+ try {
2320
+ const stat = statSync(fullPath);
2321
+ if (stat.mtimeMs < cutoff) {
2322
+ rmSync(fullPath, { recursive: true, force: true });
2323
+ }
2324
+ } catch {
2325
+ }
2326
+ }
2327
+ if (entry.isFile() && /^orchestrator\.log\.\d+$/.test(entry.name)) {
2328
+ try {
2329
+ const stat = statSync(fullPath);
2330
+ if (stat.mtimeMs < cutoff) {
2331
+ rmSync(fullPath, { force: true });
2332
+ }
2333
+ } catch {
2334
+ }
2335
+ }
2336
+ }
2337
+ } catch {
2338
+ }
2339
+ }
2340
+ };
2341
+
2342
+ // src/lib/light-call.ts
2343
+ import { z as z2 } from "zod";
2344
+ var LightResponseSchema = z2.object({
2345
+ action: z2.enum(["move_ticket", "set_field_value", "create_comment", "archive_ticket", "no_action"]),
2346
+ params: z2.record(z2.string(), z2.unknown()),
2347
+ reason: z2.string()
2348
+ });
2349
+ function composeLightPrompt(ctx) {
2350
+ const fieldLines = ctx.fieldValues.length > 0 ? ctx.fieldValues.map((f) => ` ${f.field_name}: ${f.value != null ? JSON.stringify(f.value) : "(not set)"}`).join("\n") : " (none)";
2351
+ const parts = [];
2352
+ parts.push(`Ticket #${String(ctx.ticketNumber)}: "${ctx.ticketTitle}"
2353
+ Column: ${ctx.columnName}
2354
+ Project: ${ctx.projectId}
2355
+ Tool prefix: ${ctx.toolPrefix}`);
2356
+ if (ctx.ticketDescription) {
2357
+ const truncated = ctx.ticketDescription.length > 300 ? ctx.ticketDescription.slice(0, 300) + "..." : ctx.ticketDescription;
2358
+ parts.push(`Description: ${truncated}`);
2359
+ }
2360
+ parts.push(`
2361
+ Field values:
2362
+ ${fieldLines}`);
2363
+ if (ctx.availableColumns && ctx.availableColumns.length > 0) {
2364
+ const colLines = ctx.availableColumns.map((c) => ` ${c.name} (${c.id})`).join("\n");
2365
+ parts.push(`Available columns:
2366
+ ${colLines}`);
2367
+ }
2368
+ if (ctx.transitionRules) {
2369
+ parts.push(`Transition rules:
2370
+ ${ctx.transitionRules}`);
2371
+ }
2372
+ parts.push(`Decide the single best next action for this ticket.
2373
+ Respond with ONLY a JSON object \u2014 no markdown, no explanation outside the JSON:
2374
+
2375
+ {
2376
+ "action": "<move_ticket|set_field_value|create_comment|archive_ticket|no_action>",
2377
+ "params": { /* action-specific params */ },
2378
+ "reason": "<one sentence>"
2379
+ }
2380
+
2381
+ Action params shapes:
2382
+ - move_ticket: { column_id: string }
2383
+ - set_field_value: { field_name: string, value: unknown }
2384
+ - create_comment: { body: string }
2385
+ - archive_ticket: {}
2386
+ - no_action: {}`);
2387
+ return parts.join("\n");
2388
+ }
2389
+ function parseLightResponse(raw) {
2390
+ let parsed;
2391
+ try {
2392
+ parsed = parseJsonFromLlmOutput(raw);
2393
+ } catch (err) {
2394
+ throw new Error(`LightResponse: ${err instanceof Error ? err.message : String(err)}`);
2395
+ }
2396
+ const result = LightResponseSchema.safeParse(parsed);
2397
+ if (!result.success) {
2398
+ throw new Error(`LightResponse: validation failed \u2014 ${result.error.message}`);
2399
+ }
2400
+ return result.data;
2401
+ }
2402
+
2403
+ // src/lib/advisor.ts
2404
+ import { z as z3 } from "zod";
2405
+ var AdvisorResponseSchema = z3.object({
2406
+ action: z3.enum([
2407
+ "RETRY_WITH_FEEDBACK",
2408
+ "RETRY_DIFFERENT_MODEL",
2409
+ "RELAX_WITH_DEBT",
2410
+ "SPLIT_TICKET",
2411
+ "ESCALATE"
2412
+ ]),
2413
+ reason: z3.string(),
2414
+ feedback: z3.string().nullish().transform((v) => v ?? void 0),
2415
+ debt_items: z3.array(z3.object({
2416
+ type: z3.string(),
2417
+ description: z3.string(),
2418
+ severity: z3.enum(["high", "medium", "low"]).default("medium")
2419
+ })).nullish().transform((v) => v ?? void 0),
2420
+ split_specs: z3.array(z3.object({
2421
+ title: z3.string(),
2422
+ description: z3.string()
2423
+ })).nullish().transform((v) => v ?? void 0)
2424
+ });
2425
+ function composeAdvisorPrompt(input) {
2426
+ const parts = [];
2427
+ parts.push(`You are a pipeline recovery advisor. A Ralph Loop has exited without moving the ticket.
2428
+ Analyze the failure context and choose the single best recovery action.`);
2429
+ parts.push(`
2430
+ Ticket: #${String(input.ticketNumber)} "${input.ticketTitle}"`);
2431
+ parts.push(`Column: ${input.columnName}`);
2432
+ parts.push(`Model: ${input.modelTier}`);
2433
+ if (input.ticketDescription) {
2434
+ parts.push(`
2435
+ Description:
2436
+ ${input.ticketDescription}`);
2437
+ }
2438
+ parts.push(`
2439
+ Failure context:`);
2440
+ parts.push(` Exit reason: ${input.exitReason}`);
2441
+ parts.push(` Iterations completed: ${input.iterations}`);
2442
+ parts.push(` Gutter (no-progress) count: ${input.gutterCount}`);
2443
+ if (input.lastError) {
2444
+ parts.push(` Last error: ${input.lastError}`);
2445
+ }
2446
+ if (input.gateHistory && input.gateHistory.length > 0) {
2447
+ parts.push(`
2448
+ ## Gate History (last ${String(input.gateHistory.length)} iterations)`);
2449
+ for (const snap of input.gateHistory) {
2450
+ const passed = snap.results.filter((r) => r.passed).length;
2451
+ const total = snap.results.length;
2452
+ const deltaLabel = snap.delta_from_previous === "first_check" ? "first check (no baseline)" : snap.delta_from_previous;
2453
+ parts.push(`- Iter ${String(snap.iteration)}: ${String(passed)}/${String(total)} gates passed (delta: ${deltaLabel})`);
2454
+ }
2455
+ }
2456
+ if (input.currentGateResults && input.currentGateResults.length > 0) {
2457
+ parts.push(`
2458
+ ## Current Gate Results`);
2459
+ for (const r of input.currentGateResults) {
2460
+ const status = r.passed ? "PASS" : "FAIL";
2461
+ parts.push(`- ${r.name}: ${status}${r.required ? " (required)" : ""}`);
2462
+ if (!r.passed && r.output) {
2463
+ parts.push(` Output: ${r.output.slice(0, 500)}`);
2464
+ }
2465
+ }
2466
+ }
2467
+ if (input.trajectory) {
2468
+ parts.push(`
2469
+ Trajectory: ${input.trajectory.status} \u2014 ${input.trajectory.evidence}`);
2470
+ }
2471
+ if (input.diffStats) {
2472
+ parts.push(`
2473
+ Git diff: ${String(input.diffStats.files_changed)} files, +${String(input.diffStats.insertions)}/-${String(input.diffStats.deletions)}`);
2474
+ }
2475
+ const commentLines = input.recentComments.length > 0 ? input.recentComments.map((c) => ` [${c.author}] ${c.body.slice(0, 150)}`).join("\n") : " (none)";
2476
+ parts.push(`
2477
+ Recent comments:
2478
+ ${commentLines}`);
2479
+ const fieldLines = input.fieldValues.length > 0 ? input.fieldValues.map((f) => ` ${f.field_name}: ${f.value != null ? JSON.stringify(f.value) : "(not set)"}`).join("\n") : " (none)";
2480
+ parts.push(`
2481
+ Field values:
2482
+ ${fieldLines}`);
2483
+ parts.push(`
2484
+ Failure patterns from run memory:
2485
+ ${input.failurePatterns}`);
2486
+ parts.push(`
2487
+ ## Decision Guide
2488
+ | Gate pattern | Recommended action |
2489
+ |---|---|
2490
+ | Same test failing, different error each time | RETRY_WITH_FEEDBACK |
2491
+ | Same exact error every iteration | RETRY_DIFFERENT_MODEL |
2492
+ | All gates pass but agent didn't move | RETRY_WITH_FEEDBACK ("Gates pass \u2014 call move_ticket") |
2493
+ | Gates regressing across iterations | SPLIT_TICKET |
2494
+ | Zero gates pass after max iterations | ESCALATE |
2495
+ | Most gates pass, one stubborn failure | RELAX_WITH_DEBT |`);
2496
+ const escalationSection = input.escalationModels.length > 0 ? `Available escalation models: ${input.escalationModels.join(", ")}` : `Available escalation models: none \u2014 RETRY_DIFFERENT_MODEL is unavailable`;
2497
+ const budgetNote = input.remainingBudget <= 1 ? `
2498
+ This is your final invocation (remaining budget: ${String(input.remainingBudget)}). Prefer RELAX_WITH_DEBT or ESCALATE.` : `Remaining advisor budget: ${String(input.remainingBudget)}`;
2499
+ parts.push(`
2500
+ Model context:`);
2501
+ parts.push(` Current model: ${input.modelTier}`);
2502
+ parts.push(` ${escalationSection}`);
2503
+ parts.push(` ${budgetNote}`);
2504
+ if (input.circuitBreakerTargetId) {
2505
+ parts.push(` Circuit breaker target column ID: ${input.circuitBreakerTargetId}`);
2506
+ }
2507
+ parts.push(`
2508
+ Choose one of the following actions. Respond with ONLY a JSON object \u2014 no markdown, no explanation outside the JSON:
2509
+
2510
+ {
2511
+ "action": "<action>",
2512
+ "reason": "<one sentence>",
2513
+ "feedback": "<optional: guidance for retry>",
2514
+ "debt_items": [{ "type": "...", "description": "...", "severity": "high|medium|low" }],
2515
+ "split_specs": [{ "title": "...", "description": "..." }]
2516
+ }
2517
+
2518
+ Actions:
2519
+ - RETRY_WITH_FEEDBACK \u2014 Retry the loop with targeted feedback in the prompt. Include "feedback" field.
2520
+ - RETRY_DIFFERENT_MODEL \u2014 Retry using a higher-capability model. Only valid if escalation models are available.
2521
+ - RELAX_WITH_DEBT \u2014 Accept partial progress; log unmet criteria as debt. Include "debt_items" array.
2522
+ - SPLIT_TICKET \u2014 Ticket is too broad; propose sub-tickets. Include "split_specs" array.
2523
+ - ESCALATE \u2014 Surface to a human; cannot be resolved automatically.`);
2524
+ return parts.join("\n");
2525
+ }
2526
+ function parseAdvisorResponse(raw) {
2527
+ let parsed;
2528
+ try {
2529
+ parsed = parseJsonFromLlmOutput(raw);
2530
+ } catch (err) {
2531
+ throw new Error(`AdvisorResponse: ${err instanceof Error ? err.message : String(err)}`);
2532
+ }
2533
+ const result = AdvisorResponseSchema.safeParse(parsed);
2534
+ if (!result.success) {
2535
+ throw new Error(`AdvisorResponse: validation failed \u2014 ${result.error.message}`);
2536
+ }
2537
+ return result.data;
2538
+ }
2539
+
2540
+ // src/lib/reaper.ts
2541
+ import { spawn } from "child_process";
2542
+ import { writeFileSync as writeFileSync2, unlinkSync, readFileSync } from "fs";
2543
+ var REAPER_POLL_MS = 3e3;
2544
+ function buildReaperScript(config) {
2545
+ return `
2546
+ const fs = require('fs');
2547
+ const path = require('path');
2548
+
2549
+ const ORCHESTRATOR_PID = ${config.orchestratorPid};
2550
+ const MANIFEST_PATH = ${JSON.stringify(config.manifestPath)};
2551
+ const PID_FILE_PATH = ${JSON.stringify(config.pidFilePath)};
2552
+ const REAPER_PID_PATH = ${JSON.stringify(config.reaperPidPath)};
2553
+ const MCP_CONFIG_PATH = ${JSON.stringify(config.mcpConfigPath)};
2554
+ const PIPELINE_DIR = ${JSON.stringify(config.pipelineDir)};
2555
+ const POLL_MS = ${REAPER_POLL_MS};
2556
+
2557
+ function isAlive(pid) {
2558
+ try { process.kill(pid, 0); return true; } catch { return false; }
2559
+ }
2560
+
2561
+ function readManifest() {
2562
+ try {
2563
+ return fs.readFileSync(MANIFEST_PATH, 'utf-8')
2564
+ .split('\\n')
2565
+ .map(l => parseInt(l.trim(), 10))
2566
+ .filter(p => !isNaN(p) && p > 0);
2567
+ } catch { return []; }
2568
+ }
2569
+
2570
+ function killPid(pid, signal) {
2571
+ try { process.kill(pid, signal); } catch { /* already dead */ }
2572
+ }
2573
+
2574
+ function cleanup() {
2575
+ const pids = readManifest();
2576
+ for (const pid of pids) {
2577
+ killPid(pid, 'SIGTERM');
2578
+ try { process.kill(-pid, 'SIGTERM'); } catch { /* ignore */ }
2579
+ }
2580
+
2581
+ setTimeout(() => {
2582
+ for (const pid of pids) {
2583
+ if (isAlive(pid)) {
2584
+ killPid(pid, 'SIGKILL');
2585
+ try { process.kill(-pid, 'SIGKILL'); } catch { /* ignore */ }
2586
+ }
2587
+ }
2588
+
2589
+ try { fs.unlinkSync(MANIFEST_PATH); } catch {}
2590
+ try { fs.unlinkSync(PID_FILE_PATH); } catch {}
2591
+ try { fs.unlinkSync(REAPER_PID_PATH); } catch {}
2592
+ try { fs.unlinkSync(MCP_CONFIG_PATH); } catch {}
2593
+
2594
+ try {
2595
+ const files = fs.readdirSync(PIPELINE_DIR);
2596
+ for (const f of files) {
2597
+ if (f.startsWith('mcp-config-') && f.endsWith('.json')) {
2598
+ try { fs.unlinkSync(path.join(PIPELINE_DIR, f)); } catch {}
2599
+ }
2600
+ }
2601
+ } catch {}
2602
+
2603
+ process.exit(0);
2604
+ }, 5000);
2605
+ }
2606
+
2607
+ function pidFileExists() {
2608
+ try { fs.accessSync(PID_FILE_PATH); return true; } catch { return false; }
2609
+ }
2610
+
2611
+ const timer = setInterval(() => {
2612
+ if (!pidFileExists()) {
2613
+ clearInterval(timer);
2614
+ process.exit(0);
2615
+ return;
2616
+ }
2617
+
2618
+ if (!isAlive(ORCHESTRATOR_PID)) {
2619
+ clearInterval(timer);
2620
+ cleanup();
2621
+ }
2622
+ }, POLL_MS);
2623
+ `;
2624
+ }
2625
+ function spawnReaper(config) {
2626
+ const script = buildReaperScript(config);
2627
+ const child = spawn(process.execPath, ["-e", script], {
2628
+ detached: true,
2629
+ stdio: "ignore"
2630
+ });
2631
+ child.unref();
2632
+ if (child.pid) {
2633
+ writeFileSync2(config.reaperPidPath, String(child.pid), { mode: 384 });
2634
+ }
2635
+ return child;
2636
+ }
2637
+ function killReaper(reaperPidPath) {
2638
+ try {
2639
+ const pid = parseInt(
2640
+ readFileSync(reaperPidPath, "utf-8").trim(),
2641
+ 10
2642
+ );
2643
+ if (pid && !isNaN(pid)) {
2644
+ process.kill(pid, "SIGTERM");
2645
+ }
2646
+ } catch {
2647
+ }
2648
+ try {
2649
+ unlinkSync(reaperPidPath);
2650
+ } catch {
2651
+ }
2652
+ }
2653
+
2654
+ // src/lib/stream-parser.ts
2655
+ import { EventEmitter } from "events";
2656
+ var StreamJsonParser = class extends EventEmitter {
2657
+ buffer = "";
2658
+ toolCallCount = 0;
2659
+ feed(chunk) {
2660
+ this.buffer += chunk;
2661
+ const lines = this.buffer.split("\n");
2662
+ this.buffer = lines.pop() ?? "";
2663
+ for (const line of lines) {
2664
+ const trimmed = line.trim();
2665
+ if (!trimmed) continue;
2666
+ try {
2667
+ const event = JSON.parse(trimmed);
2668
+ if (event.type === "assistant" && Array.isArray(event.content)) {
2669
+ for (const block of event.content) {
2670
+ if (block.type === "tool_use") this.toolCallCount++;
2671
+ }
2672
+ }
2673
+ this.emit("event", event);
2674
+ } catch {
2675
+ this.emit("error", new Error(`Failed to parse stream-json line: ${trimmed.slice(0, 100)}`));
2676
+ }
2677
+ }
2678
+ }
2679
+ /** Flush any remaining buffer content (call after stream ends). */
2680
+ flush() {
2681
+ const trimmed = this.buffer.trim();
2682
+ this.buffer = "";
2683
+ if (!trimmed) return;
2684
+ try {
2685
+ const event = JSON.parse(trimmed);
2686
+ if (event.type === "assistant" && Array.isArray(event.content)) {
2687
+ for (const block of event.content) {
2688
+ if (block.type === "tool_use") this.toolCallCount++;
2689
+ }
2690
+ }
2691
+ this.emit("event", event);
2692
+ } catch {
2693
+ }
2694
+ }
2695
+ getToolCallCount() {
2696
+ return this.toolCallCount;
2697
+ }
2698
+ reset() {
2699
+ this.buffer = "";
2700
+ this.toolCallCount = 0;
2701
+ }
2702
+ };
2703
+
2704
+ // src/lib/gate-snapshot.ts
2705
+ var MAX_SNAPSHOTS_PER_TICKET = 100;
2706
+ function computeDelta(previous, currentResults) {
2707
+ if (!previous) return "first_check";
2708
+ const prevPassing = previous.results.filter((r) => r.passed).length;
2709
+ const currPassing = currentResults.filter((r) => r.passed).length;
2710
+ if (currPassing > prevPassing) return "improved";
2711
+ if (currPassing < prevPassing) return "regressed";
2712
+ return "same";
2713
+ }
2714
+ var GateSnapshotStore = class {
2715
+ snapshots = /* @__PURE__ */ new Map();
2716
+ record(ticketId, iteration, results) {
2717
+ const previous = this.getLatest(ticketId);
2718
+ const delta = computeDelta(previous, results);
2719
+ const snapshot = {
2720
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2721
+ iteration,
2722
+ results,
2723
+ all_required_passed: results.filter((r) => r.required).every((r) => r.passed),
2724
+ delta_from_previous: delta
2725
+ };
2726
+ const existing = this.snapshots.get(ticketId) ?? [];
2727
+ existing.push(snapshot);
2728
+ if (existing.length > MAX_SNAPSHOTS_PER_TICKET) {
2729
+ existing.splice(0, existing.length - MAX_SNAPSHOTS_PER_TICKET);
2730
+ }
2731
+ this.snapshots.set(ticketId, existing);
2732
+ return snapshot;
2733
+ }
2734
+ getLatest(ticketId) {
2735
+ const snaps = this.snapshots.get(ticketId);
2736
+ return snaps?.[snaps.length - 1];
2737
+ }
2738
+ getRecent(ticketId, count) {
2739
+ const snaps = this.snapshots.get(ticketId) ?? [];
2740
+ return snaps.slice(-count);
2741
+ }
2742
+ clear(ticketId) {
2743
+ this.snapshots.delete(ticketId);
2744
+ }
2745
+ /** Returns all ticket IDs that have at least one snapshot recorded. */
2746
+ getAllTicketIds() {
2747
+ return Array.from(this.snapshots.keys());
2748
+ }
2749
+ };
2750
+
2751
+ // src/lib/gate-runner.ts
2752
+ import { execFile } from "child_process";
2753
+ async function runGate(gate, options = {}) {
2754
+ const timeoutMs = gate.timeout ? parseTimeout(gate.timeout) : options.timeoutMs ?? 6e4;
2755
+ const start = Date.now();
2756
+ return new Promise((resolve) => {
2757
+ const child = execFile("sh", ["-c", gate.run], {
2758
+ timeout: timeoutMs,
2759
+ cwd: options.cwd,
2760
+ env: { ...process.env, ...options.env },
2761
+ maxBuffer: options.maxBuffer ?? 1024 * 1024
2762
+ // 1MB output cap
2763
+ }, (error, stdout, stderr) => {
2764
+ const duration_ms = Date.now() - start;
2765
+ const output = (stdout + stderr).trim();
2766
+ const timed_out = error?.killed === true;
2767
+ const buffer_exceeded = error?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
2768
+ if (error) {
2769
+ let annotation = "";
2770
+ if (timed_out) annotation = `
2771
+ [TIMED OUT after ${timeoutMs}ms]`;
2772
+ else if (buffer_exceeded) annotation = `
2773
+ [OUTPUT TRUNCATED \u2014 buffer limit exceeded]`;
2774
+ resolve({
2775
+ name: gate.name,
2776
+ passed: false,
2777
+ required: gate.required ?? true,
2778
+ duration_ms,
2779
+ output: output + annotation,
2780
+ stderr: stderr.trim(),
2781
+ exit_code: child.exitCode ?? (typeof error.code === "number" ? error.code : 1),
2782
+ timed_out: timed_out || buffer_exceeded
2783
+ });
2784
+ return;
2785
+ }
2786
+ resolve({
2787
+ name: gate.name,
2788
+ passed: true,
2789
+ required: gate.required ?? true,
2790
+ duration_ms,
2791
+ output,
2792
+ stderr: stderr.trim(),
2793
+ exit_code: 0,
2794
+ timed_out: false
2795
+ });
2796
+ });
2797
+ });
2798
+ }
2799
+ async function runGates(gates, options = {}) {
2800
+ const results = [];
2801
+ const totalStart = Date.now();
2802
+ for (const gate of gates) {
2803
+ if (options.totalTimeoutMs) {
2804
+ const elapsed = Date.now() - totalStart;
2805
+ if (elapsed >= options.totalTimeoutMs) {
2806
+ results.push({
2807
+ name: gate.name,
2808
+ passed: false,
2809
+ required: gate.required ?? true,
2810
+ duration_ms: 0,
2811
+ output: "[SKIPPED \u2014 total timeout exceeded]",
2812
+ stderr: "",
2813
+ exit_code: -1,
2814
+ timed_out: true
2815
+ });
2816
+ continue;
2817
+ }
2818
+ const remainingMs = options.totalTimeoutMs - elapsed;
2819
+ const gateTimeout = gate.timeout ? parseTimeout(gate.timeout) : options.timeoutMs ?? 6e4;
2820
+ const effectiveTimeout = Math.min(gateTimeout, remainingMs);
2821
+ results.push(await runGate(gate, { ...options, timeoutMs: effectiveTimeout }));
2822
+ } else {
2823
+ results.push(await runGate(gate, options));
2824
+ }
2825
+ }
2826
+ return results;
2827
+ }
2828
+
2829
+ // src/lib/cost-tracker.ts
2830
+ var PipelineCostTracker = class {
2831
+ runId;
2832
+ budget;
2833
+ _totalIn = 0;
2834
+ _totalOut = 0;
2835
+ ticketCosts = /* @__PURE__ */ new Map();
2836
+ columnCosts = /* @__PURE__ */ new Map();
2837
+ modelCosts = /* @__PURE__ */ new Map();
2838
+ constructor(runId, budget) {
2839
+ this.runId = runId;
2840
+ this.budget = budget;
2841
+ }
2842
+ get totalTokensIn() {
2843
+ return this._totalIn;
2844
+ }
2845
+ get totalTokensOut() {
2846
+ return this._totalOut;
2847
+ }
2848
+ get maxInputTokens() {
2849
+ return this.budget?.max_input_tokens ?? 5e6;
2850
+ }
2851
+ record(inv) {
2852
+ this._totalIn += inv.tokensIn;
2853
+ this._totalOut += inv.tokensOut;
2854
+ const tc = this.ticketCosts.get(inv.ticketId) ?? {
2855
+ tokens_in: 0,
2856
+ tokens_out: 0,
2857
+ iterations: 0,
2858
+ advisor_calls: 0
2859
+ };
2860
+ tc.tokens_in += inv.tokensIn;
2861
+ tc.tokens_out += inv.tokensOut;
2862
+ if (inv.type === "heavy") tc.iterations++;
2863
+ if (inv.type === "advisor") tc.advisor_calls++;
2864
+ this.ticketCosts.set(inv.ticketId, tc);
2865
+ const cc = this.columnCosts.get(inv.columnId) ?? {
2866
+ tokens_in: 0,
2867
+ tokens_out: 0,
2868
+ light_calls: 0,
2869
+ heavy_calls: 0,
2870
+ advisor_calls: 0
2871
+ };
2872
+ cc.tokens_in += inv.tokensIn;
2873
+ cc.tokens_out += inv.tokensOut;
2874
+ if (inv.type === "light") cc.light_calls++;
2875
+ if (inv.type === "heavy") cc.heavy_calls++;
2876
+ if (inv.type === "advisor" || inv.type === "stuck_detection") cc.advisor_calls++;
2877
+ this.columnCosts.set(inv.columnId, cc);
2878
+ const mc = this.modelCosts.get(inv.model) ?? { tokens_in: 0, tokens_out: 0, calls: 0 };
2879
+ mc.tokens_in += inv.tokensIn;
2880
+ mc.tokens_out += inv.tokensOut;
2881
+ mc.calls++;
2882
+ this.modelCosts.set(inv.model, mc);
2883
+ }
2884
+ getTicketCost(ticketId) {
2885
+ return this.ticketCosts.get(ticketId);
2886
+ }
2887
+ getColumnCost(columnId) {
2888
+ return this.columnCosts.get(columnId);
2889
+ }
2890
+ isWarning() {
2891
+ if (!this.budget) return false;
2892
+ const inPct = this._totalIn / this.budget.max_input_tokens * 100;
2893
+ const outPct = this._totalOut / this.budget.max_output_tokens * 100;
2894
+ return inPct >= this.budget.warn_pct || outPct >= this.budget.warn_pct;
2895
+ }
2896
+ isExhausted() {
2897
+ if (!this.budget) return false;
2898
+ return this._totalIn >= this.budget.max_input_tokens || this._totalOut >= this.budget.max_output_tokens;
2899
+ }
2900
+ generateReport(pricing) {
2901
+ const fmt = (n) => {
2902
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
2903
+ if (n >= 1e3) return `${(n / 1e3).toFixed(0)}K`;
2904
+ return String(n);
2905
+ };
2906
+ const lines = [];
2907
+ const budgetStr = this.budget ? ` (of ${fmt(this.budget.max_input_tokens)} / ${fmt(this.budget.max_output_tokens)} budget)` : "";
2908
+ lines.push(`Tokens: ${fmt(this._totalIn)} in / ${fmt(this._totalOut)} out${budgetStr}`);
2909
+ if (this.columnCosts.size > 0) {
2910
+ const colParts = Array.from(this.columnCosts.entries()).map(
2911
+ ([id, c]) => `${id.slice(0, 8)} ${fmt(c.tokens_in)}/${fmt(c.tokens_out)}`
2912
+ );
2913
+ lines.push(` By column: ${colParts.join(", ")}`);
2914
+ }
2915
+ if (this.modelCosts.size > 0) {
2916
+ const modelParts = Array.from(this.modelCosts.entries()).map(
2917
+ ([model, c]) => `${model} ${fmt(c.tokens_in)}/${fmt(c.tokens_out)} (${c.calls} calls)`
2918
+ );
2919
+ lines.push(` By model: ${modelParts.join(", ")}`);
2920
+ }
2921
+ if (pricing) {
2922
+ let totalCost = 0;
2923
+ for (const [model, mc] of this.modelCosts) {
2924
+ const p = pricing[model];
2925
+ if (p) {
2926
+ totalCost += mc.tokens_in / 1e6 * p.input_per_mtok;
2927
+ totalCost += mc.tokens_out / 1e6 * p.output_per_mtok;
2928
+ }
2929
+ }
2930
+ if (totalCost > 0) {
2931
+ lines.push(` Estimated cost: $${totalCost.toFixed(2)} (user-provided pricing)`);
2932
+ }
2933
+ }
2934
+ return lines.join("\n");
2935
+ }
2936
+ };
2937
+
2938
+ // src/lib/event-emitter.ts
2939
+ var SIGNIFICANT_TYPES = /* @__PURE__ */ new Set([
2940
+ "advisor_escalate",
2941
+ "evaluator_rejected",
2942
+ "budget_exhausted",
2943
+ "replanner_paused"
2944
+ ]);
2945
+ var FLUSH_INTERVAL_MS = 5e3;
2946
+ var MAX_BATCH_SIZE = 100;
2947
+ var PipelineEventEmitter = class _PipelineEventEmitter {
2948
+ constructor(apiBaseUrl, projectId, apiToken, boardId, runId) {
2949
+ this.apiBaseUrl = apiBaseUrl;
2950
+ this.projectId = projectId;
2951
+ this.apiToken = apiToken;
2952
+ this.boardId = boardId;
2953
+ this.runId = runId;
2954
+ this.flushTimer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS);
2955
+ }
2956
+ buffer = [];
2957
+ flushTimer = null;
2958
+ closed = false;
2959
+ inFlight = false;
2960
+ static MAX_BUFFER_SIZE = 1e4;
2961
+ emit(opts) {
2962
+ if (this.closed) return;
2963
+ const event = {
2964
+ board_id: this.boardId,
2965
+ ticket_id: opts.ticketId ?? null,
2966
+ column_id: opts.columnId ?? null,
2967
+ run_id: this.runId,
2968
+ session_id: opts.sessionId ?? null,
2969
+ layer: opts.layer,
2970
+ event_type: opts.eventType,
2971
+ severity: opts.severity,
2972
+ summary: opts.summary,
2973
+ detail: opts.detail ?? null,
2974
+ iteration: opts.iteration ?? null
2975
+ };
2976
+ this.buffer.push(event);
2977
+ if (SIGNIFICANT_TYPES.has(opts.eventType) || this.buffer.length >= MAX_BATCH_SIZE) {
2978
+ void this.flush();
2979
+ }
2980
+ }
2981
+ async flush() {
2982
+ if (this.inFlight || this.buffer.length === 0) return;
2983
+ this.inFlight = true;
2984
+ const batch = this.buffer.splice(0, MAX_BATCH_SIZE);
2985
+ try {
2986
+ const res = await fetch(`${this.apiBaseUrl}/projects/${this.projectId}/pipeline-events`, {
2987
+ method: "POST",
2988
+ headers: {
2989
+ "Content-Type": "application/json",
2990
+ Authorization: `Bearer ${this.apiToken}`
2991
+ },
2992
+ body: JSON.stringify({ events: batch })
2993
+ });
2994
+ if (!res.ok) {
2995
+ if (res.status >= 500) {
2996
+ this.buffer.unshift(...batch);
2997
+ if (this.buffer.length > _PipelineEventEmitter.MAX_BUFFER_SIZE) {
2998
+ this.buffer.splice(0, this.buffer.length - _PipelineEventEmitter.MAX_BUFFER_SIZE);
2999
+ }
3000
+ } else {
3001
+ console.error(`[event-emitter] Dropping ${batch.length} events: HTTP ${res.status}`);
3002
+ }
3003
+ }
3004
+ } catch {
3005
+ this.buffer.unshift(...batch);
3006
+ if (this.buffer.length > _PipelineEventEmitter.MAX_BUFFER_SIZE) {
3007
+ this.buffer.splice(0, this.buffer.length - _PipelineEventEmitter.MAX_BUFFER_SIZE);
3008
+ }
3009
+ } finally {
3010
+ this.inFlight = false;
3011
+ }
3012
+ }
3013
+ async close() {
3014
+ this.closed = true;
3015
+ if (this.flushTimer) {
3016
+ clearInterval(this.flushTimer);
3017
+ this.flushTimer = null;
3018
+ }
3019
+ while (this.inFlight) {
3020
+ await new Promise((r) => setTimeout(r, 50));
3021
+ }
3022
+ await this.flush();
3023
+ }
3024
+ };
3025
+
3026
+ // src/commands/pipeline.ts
3027
+ function parseArgs(args) {
3028
+ const positional = [];
3029
+ let once = false;
3030
+ let dryRun = false;
3031
+ let columnFilter = null;
3032
+ let maxIterations = null;
3033
+ let maxBudget = null;
3034
+ let model = null;
3035
+ let concurrency = null;
3036
+ let logRetention = 7;
3037
+ let yes = false;
3038
+ for (let i = 0; i < args.length; i++) {
3039
+ const arg = args[i];
3040
+ switch (arg) {
3041
+ case "--once":
3042
+ once = true;
3043
+ break;
3044
+ case "--dry-run":
3045
+ dryRun = true;
3046
+ break;
3047
+ case "--yes":
3048
+ case "-y":
3049
+ yes = true;
3050
+ break;
3051
+ case "--column":
3052
+ columnFilter = args[++i] ?? null;
3053
+ break;
3054
+ case "--max-iterations": {
3055
+ const val = Number(args[++i]);
3056
+ if (isNaN(val) || val <= 0) {
3057
+ console.error(`Error: --max-iterations requires a positive number, got: ${args[i]}`);
3058
+ process.exit(1);
3059
+ }
3060
+ maxIterations = val;
3061
+ break;
3062
+ }
3063
+ case "--max-budget": {
3064
+ const val = Number(args[++i]);
3065
+ if (isNaN(val) || val < 0) {
3066
+ console.error(`Error: --max-budget requires a non-negative number, got: ${args[i]}`);
3067
+ process.exit(1);
3068
+ }
3069
+ maxBudget = val;
3070
+ break;
3071
+ }
3072
+ case "--model":
3073
+ model = args[++i] ?? null;
3074
+ break;
3075
+ case "--concurrency": {
3076
+ const val = Number(args[++i]);
3077
+ if (isNaN(val) || val <= 0) {
3078
+ console.error(`Error: --concurrency requires a positive number, got: ${args[i]}`);
3079
+ process.exit(1);
3080
+ }
3081
+ concurrency = val;
3082
+ break;
3083
+ }
3084
+ case "--log-retention":
3085
+ logRetention = Number(args[++i]) || 7;
3086
+ break;
3087
+ default:
3088
+ if (!arg.startsWith("--")) positional.push(arg);
3089
+ break;
3090
+ }
3091
+ }
3092
+ const boardId = positional[0] ?? "";
3093
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3094
+ if (boardId && !UUID_RE.test(boardId)) {
3095
+ console.error(`Error: invalid board ID "${boardId}" \u2014 expected UUID`);
3096
+ process.exit(1);
3097
+ }
3098
+ if (!boardId) {
3099
+ console.error(`Usage: kantban pipeline <board-id> [flags]
3100
+
3101
+ Flags:
3102
+ --once Run one scan, wait for loops, then exit
3103
+ --dry-run Show config without starting
3104
+ --column <id> Filter to a single column
3105
+ --max-iterations <n> Override max iterations per ticket
3106
+ --max-budget <usd> Per-ticket budget cap (USD)
3107
+ --model <model> Override model preference
3108
+ --concurrency <n> Override concurrency per column
3109
+ --log-retention <d> Log retention in days (default: 7)
3110
+ --yes, -y Skip safety confirmation`);
3111
+ process.exit(1);
3112
+ }
3113
+ return { boardId, once, dryRun, columnFilter, maxIterations, maxBudget, model, concurrency, logRetention, yes };
3114
+ }
3115
+ function pidDir(boardId) {
3116
+ return join2(homedir(), ".kantban", "pipelines", boardId);
3117
+ }
3118
+ function pidFilePath(boardId) {
3119
+ return join2(pidDir(boardId), "orchestrator.pid");
3120
+ }
3121
+ function writePidFile(boardId) {
3122
+ const dir = pidDir(boardId);
3123
+ mkdirSync2(dir, { recursive: true, mode: 448 });
3124
+ writeFileSync3(pidFilePath(boardId), String(process.pid), { mode: 384 });
3125
+ }
3126
+ function removePidFile(boardId) {
3127
+ try {
3128
+ unlinkSync2(pidFilePath(boardId));
3129
+ } catch {
3130
+ }
3131
+ }
3132
+ function childManifestPath(boardId) {
3133
+ return join2(pidDir(boardId), "children.pid");
3134
+ }
3135
+ function appendChildPid(boardId, pid) {
3136
+ const dir = pidDir(boardId);
3137
+ mkdirSync2(dir, { recursive: true, mode: 448 });
3138
+ appendFileSync2(childManifestPath(boardId), `${String(pid)}
3139
+ `, { mode: 384 });
3140
+ }
3141
+ function removeChildPid(boardId, pid) {
3142
+ const manifestPath = childManifestPath(boardId);
3143
+ try {
3144
+ const contents = readFileSync2(manifestPath, "utf-8");
3145
+ const pids = contents.split("\n").filter((l) => l.trim() !== "" && l.trim() !== String(pid));
3146
+ writeFileSync3(manifestPath, pids.length > 0 ? pids.join("\n") + "\n" : "", { mode: 384 });
3147
+ } catch {
3148
+ }
3149
+ }
3150
+ function readChildManifest(boardId) {
3151
+ try {
3152
+ const contents = readFileSync2(childManifestPath(boardId), "utf-8");
3153
+ return contents.split("\n").map((l) => l.trim()).filter((l) => l !== "").map(Number).filter((n) => !isNaN(n) && n > 0);
3154
+ } catch {
3155
+ return [];
3156
+ }
3157
+ }
3158
+ function removeChildManifest(boardId) {
3159
+ try {
3160
+ unlinkSync2(childManifestPath(boardId));
3161
+ } catch {
3162
+ }
3163
+ }
3164
+ function cleanupOrphanedProcesses(boardId) {
3165
+ const pidPath = pidFilePath(boardId);
3166
+ if (existsSync(pidPath)) {
3167
+ try {
3168
+ const stalePid = parseInt(readFileSync2(pidPath, "utf-8").trim(), 10);
3169
+ if (stalePid && stalePid !== process.pid) {
3170
+ try {
3171
+ process.kill(stalePid, 0);
3172
+ process.kill(stalePid, "SIGTERM");
3173
+ console.log(`Killed stale orchestrator (PID ${String(stalePid)})`);
3174
+ } catch {
3175
+ }
3176
+ }
3177
+ } catch {
3178
+ }
3179
+ removePidFile(boardId);
3180
+ }
3181
+ const manifestPids = readChildManifest(boardId);
3182
+ if (manifestPids.length > 0) {
3183
+ for (const pid of manifestPids) {
3184
+ try {
3185
+ process.kill(pid, 0);
3186
+ process.kill(pid, "SIGTERM");
3187
+ } catch {
3188
+ }
3189
+ }
3190
+ console.log(`Killed ${String(manifestPids.length)} orphaned child process(es) from manifest`);
3191
+ removeChildManifest(boardId);
3192
+ }
3193
+ const staleReaperPath = join2(pidDir(boardId), "reaper.pid");
3194
+ try {
3195
+ if (existsSync(staleReaperPath)) {
3196
+ const reaperPid = parseInt(readFileSync2(staleReaperPath, "utf-8").trim(), 10);
3197
+ if (reaperPid && !isNaN(reaperPid)) {
3198
+ try {
3199
+ process.kill(reaperPid, 0);
3200
+ process.kill(reaperPid, "SIGTERM");
3201
+ console.log(`Killed stale reaper (PID ${String(reaperPid)})`);
3202
+ } catch {
3203
+ }
3204
+ }
3205
+ unlinkSync2(staleReaperPath);
3206
+ }
3207
+ } catch {
3208
+ }
3209
+ try {
3210
+ const boardDir = pidDir(boardId).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3211
+ const out = execSync(
3212
+ `ps aux | grep 'claude.*-p' | grep '${boardDir}' | grep -v grep | awk '{print $2}'`,
3213
+ { encoding: "utf-8", timeout: 5e3 }
3214
+ ).trim();
3215
+ if (out) {
3216
+ const pids = out.split("\n").filter(Boolean);
3217
+ for (const pid of pids) {
3218
+ try {
3219
+ process.kill(parseInt(pid, 10), "SIGTERM");
3220
+ } catch {
3221
+ }
3222
+ }
3223
+ if (pids.length > 0) {
3224
+ console.log(`Killed ${String(pids.length)} orphaned claude agent(s)`);
3225
+ }
3226
+ }
3227
+ } catch {
3228
+ }
3229
+ }
3230
+ var activeChildProcesses = /* @__PURE__ */ new Set();
3231
+ var currentBoardId = "";
3232
+ var CLAUDE_TIMEOUT_MS = 60 * 60 * 1e3;
3233
+ async function invokeClaudeP(prompt, options) {
3234
+ const args = ["-p", prompt, "--dangerously-skip-permissions", "--output-format", "stream-json", "--verbose"];
3235
+ if (options.includeMcpConfig !== false && options.mcpConfigPath) {
3236
+ args.push("--mcp-config", options.mcpConfigPath);
3237
+ }
3238
+ if (options.model) args.push("--model", options.model);
3239
+ if (options.maxTurns) {
3240
+ args.push("--max-turns", String(options.maxTurns));
3241
+ } else if (options.maxBudgetUsd !== void 0 && options.maxBudgetUsd !== null) {
3242
+ args.push("--max-turns", String(Math.max(1, Math.ceil(options.maxBudgetUsd * 10))));
3243
+ }
3244
+ if (options.worktree) args.push("--worktree", options.worktree);
3245
+ if (options.tools !== void 0) {
3246
+ args.push("--tools", options.tools);
3247
+ }
3248
+ if (options.allowedTools?.length) {
3249
+ args.push("--allowedTools", ...options.allowedTools);
3250
+ }
3251
+ if (options.disallowedTools?.length) {
3252
+ args.push("--disallowedTools", ...options.disallowedTools);
3253
+ }
3254
+ return new Promise((resolve) => {
3255
+ const child = spawn2("claude", args, {
3256
+ stdio: ["pipe", "pipe", "pipe"]
3257
+ });
3258
+ activeChildProcesses.add(child);
3259
+ if (child.pid && currentBoardId) appendChildPid(currentBoardId, child.pid);
3260
+ const parser = new StreamJsonParser();
3261
+ parser.on("error", (err) => {
3262
+ process.stderr.write(`[stream-parser] ${err.message}
3263
+ `);
3264
+ });
3265
+ let lastOutput = "";
3266
+ let tokensIn = 0;
3267
+ let tokensOut = 0;
3268
+ parser.on("event", (event) => {
3269
+ if (event.type === "result") {
3270
+ const usage = event.usage;
3271
+ tokensIn += usage?.input_tokens ?? 0;
3272
+ tokensOut += usage?.output_tokens ?? 0;
3273
+ if (typeof event.result === "string") lastOutput = event.result;
3274
+ }
3275
+ options.onStreamEvent?.(event);
3276
+ });
3277
+ child.stdout?.on("data", (chunk) => {
3278
+ parser.feed(chunk.toString());
3279
+ });
3280
+ let stderr = "";
3281
+ child.stderr?.on("data", (chunk) => {
3282
+ stderr += chunk.toString();
3283
+ });
3284
+ child.stdin?.end();
3285
+ let killTimer;
3286
+ const timeoutHandle = setTimeout(() => {
3287
+ try {
3288
+ child.kill("SIGTERM");
3289
+ } catch {
3290
+ }
3291
+ killTimer = setTimeout(() => {
3292
+ if (!resolved) {
3293
+ try {
3294
+ child.kill("SIGKILL");
3295
+ } catch {
3296
+ }
3297
+ }
3298
+ }, 5e3);
3299
+ }, CLAUDE_TIMEOUT_MS);
3300
+ let resolved = false;
3301
+ child.on("close", (code) => {
3302
+ if (resolved) return;
3303
+ resolved = true;
3304
+ clearTimeout(timeoutHandle);
3305
+ if (killTimer) clearTimeout(killTimer);
3306
+ activeChildProcesses.delete(child);
3307
+ if (child.pid && currentBoardId) removeChildPid(currentBoardId, child.pid);
3308
+ parser.flush();
3309
+ resolve({
3310
+ exitCode: code ?? 1,
3311
+ output: lastOutput || stderr,
3312
+ toolCallCount: parser.getToolCallCount(),
3313
+ tokensIn,
3314
+ tokensOut
3315
+ });
3316
+ });
3317
+ child.on("error", (err) => {
3318
+ if (resolved) return;
3319
+ resolved = true;
3320
+ clearTimeout(timeoutHandle);
3321
+ if (killTimer) clearTimeout(killTimer);
3322
+ activeChildProcesses.delete(child);
3323
+ if (child.pid && currentBoardId) removeChildPid(currentBoardId, child.pid);
3324
+ parser.flush();
3325
+ resolve({
3326
+ exitCode: 1,
3327
+ output: err.message,
3328
+ toolCallCount: parser.getToolCallCount(),
3329
+ tokensIn,
3330
+ tokensOut
3331
+ });
3332
+ });
3333
+ });
3334
+ }
3335
+ function killAllChildProcesses() {
3336
+ for (const child of activeChildProcesses) {
3337
+ try {
3338
+ if (child.pid) {
3339
+ try {
3340
+ process.kill(-child.pid, "SIGTERM");
3341
+ } catch {
3342
+ try {
3343
+ child.kill("SIGTERM");
3344
+ } catch {
3345
+ }
3346
+ }
3347
+ } else {
3348
+ try {
3349
+ child.kill("SIGTERM");
3350
+ } catch {
3351
+ }
3352
+ }
3353
+ } catch {
3354
+ }
3355
+ }
3356
+ }
3357
+ function printSafetyWarning() {
3358
+ console.log(`
3359
+ === SAFETY WARNING ===
3360
+
3361
+ This pipeline will run Claude Code agents with --dangerously-skip-permissions.
3362
+ Agents will have unrestricted access to tools, file system, and MCP servers.
3363
+
3364
+ Before proceeding:
3365
+ 1. Review all prompt documents linked to pipeline columns
3366
+ 2. Ensure your project signals/guardrails are configured correctly
3367
+ 3. Verify your MCP server configuration is appropriate
3368
+
3369
+ Press Ctrl+C to cancel, or re-run with --yes to skip this warning.
3370
+ `);
3371
+ }
3372
+ function waitForConfirmation() {
3373
+ return new Promise((resolve) => {
3374
+ process.stdout.write("Continue? [y/N] ");
3375
+ process.stdin.setEncoding("utf8");
3376
+ process.stdin.once("data", (data) => {
3377
+ const answer = data.trim().toLowerCase();
3378
+ resolve(answer === "y" || answer === "yes");
3379
+ });
3380
+ process.stdin.once("end", () => resolve(false));
3381
+ });
3382
+ }
3383
+ async function runPipeline(client, args) {
3384
+ if (args[0] === "init") {
3385
+ const { runPipelineInit } = await import("./pipeline-init-IGZZOOLK.js");
3386
+ await runPipelineInit();
3387
+ return;
3388
+ }
3389
+ const opts = parseArgs(args);
3390
+ currentBoardId = opts.boardId;
3391
+ if (!opts.yes && !opts.dryRun) {
3392
+ printSafetyWarning();
3393
+ const confirmed = await waitForConfirmation();
3394
+ if (!confirmed) {
3395
+ console.log("Aborted.");
3396
+ return;
3397
+ }
3398
+ }
3399
+ const gateFilePath = join2(process.cwd(), "pipeline.gates.yaml");
3400
+ let gateConfig;
3401
+ if (!existsSync(gateFilePath)) {
3402
+ console.error(`Error: pipeline.gates.yaml not found in ${process.cwd()}`);
3403
+ console.error('Run "kantban pipeline init" to generate a starter gate file.');
3404
+ process.exit(1);
3405
+ }
3406
+ try {
3407
+ const gateYaml = readFileSync2(gateFilePath, "utf-8");
3408
+ gateConfig = parseGateConfig(gateYaml);
3409
+ console.log(`Gate config loaded: ${gateConfig.default.length} default gate(s)`);
3410
+ } catch (err) {
3411
+ const message = err instanceof Error ? err.message : String(err);
3412
+ console.error(`Error: Invalid pipeline.gates.yaml: ${message}`);
3413
+ process.exit(1);
3414
+ }
3415
+ const costTracker = gateConfig.settings?.budget ? new PipelineCostTracker(crypto.randomUUID(), gateConfig.settings.budget) : void 0;
3416
+ const gateSnapshotStore = new GateSnapshotStore();
3417
+ let projectId = process.env["KANTBAN_PROJECT_ID"] ?? "";
3418
+ if (!projectId) {
3419
+ try {
3420
+ const result = await client.getBoardProject(opts.boardId);
3421
+ projectId = result.project_id;
3422
+ } catch (err) {
3423
+ const message = err instanceof Error ? err.message : String(err);
3424
+ console.error(`Error: Could not resolve project ID for board ${opts.boardId}: ${message}`);
3425
+ console.error("Set KANTBAN_PROJECT_ID environment variable or ensure the board exists.");
3426
+ process.exit(1);
3427
+ }
3428
+ }
3429
+ const mcpConfigPath = generateMcpConfig(client.baseUrl, client.token, opts.boardId);
3430
+ const logBaseDir = join2(homedir(), ".kantban", "pipelines");
3431
+ const logger = new PipelineLogger(logBaseDir, opts.boardId);
3432
+ logger.pruneOldLogs(opts.logRetention);
3433
+ let runMemory = null;
3434
+ const pipelineSessionId = crypto.randomUUID();
3435
+ const eventEmitter = new PipelineEventEmitter(
3436
+ client.baseUrl,
3437
+ projectId,
3438
+ client.token,
3439
+ opts.boardId,
3440
+ pipelineSessionId
3441
+ );
3442
+ const deps = {
3443
+ fetchBoardScope: (boardId) => client.get(`/projects/${projectId}/pipeline-context`, { boardId }),
3444
+ fetchColumnScope: (columnId) => client.get(`/projects/${projectId}/pipeline-context`, { columnId }),
3445
+ startLoop: async (ticketId, columnId, config) => {
3446
+ const effectiveConfig = {
3447
+ ...config,
3448
+ ...opts.maxIterations !== null && { maxIterations: opts.maxIterations },
3449
+ ...opts.maxBudget !== null && { maxBudgetUsd: opts.maxBudget },
3450
+ ...opts.model !== null && { model: opts.model }
3451
+ };
3452
+ const mem = runMemory;
3453
+ const colScopeForName = await client.get(
3454
+ `/projects/${projectId}/pipeline-context`,
3455
+ { columnId }
3456
+ );
3457
+ const resolvedColumnName = colScopeForName.column.name;
3458
+ const columnGates = resolveGatesForColumn(gateConfig, resolvedColumnName);
3459
+ const effectiveMcpConfigPath = columnGates.length > 0 ? generateGateProxyMcpConfig(
3460
+ client.baseUrl,
3461
+ client.token,
3462
+ opts.boardId,
3463
+ gateFilePath,
3464
+ columnId,
3465
+ resolvedColumnName,
3466
+ projectId
3467
+ ) : mcpConfigPath;
3468
+ const loopDeps = {
3469
+ fetchTicketContext: (tid) => client.get(`/projects/${projectId}/pipeline-context`, { ticketId: tid }),
3470
+ fetchColumnContext: (cid) => client.get(`/projects/${projectId}/pipeline-context`, { columnId: cid }),
3471
+ fetchFingerprint: (tid) => client.getFingerprint(projectId, tid),
3472
+ invokeClaudeP,
3473
+ mcpConfigPath: effectiveMcpConfigPath,
3474
+ projectId,
3475
+ log: (msg) => {
3476
+ logger.orchestrator(`[${ticketId}] ${msg}`);
3477
+ },
3478
+ // Run memory enrichment — inject accumulated discoveries into each iteration prompt
3479
+ fetchRunMemoryContent: mem ? async () => {
3480
+ const content = await mem.getContent();
3481
+ const lines = content.split("\n");
3482
+ if (lines.length > 500) {
3483
+ return lines.slice(-500).join("\n");
3484
+ }
3485
+ return content;
3486
+ } : void 0,
3487
+ // Lookahead enrichment — fetch next-column prompt doc so agent can anticipate exit criteria
3488
+ fetchLookaheadDocument: effectiveConfig.lookaheadColumnId ? async () => {
3489
+ const colScope = await client.get(
3490
+ `/projects/${projectId}/pipeline-context`,
3491
+ { columnId: effectiveConfig.lookaheadColumnId }
3492
+ );
3493
+ if (!colScope.prompt_document) return void 0;
3494
+ return { title: colScope.prompt_document.title, content: colScope.prompt_document.content };
3495
+ } : void 0,
3496
+ // Pipeline event emission — forward stream/session events to WS for browser delivery
3497
+ onStreamEvent: deps.emitPipelineEvent ? (event, context) => {
3498
+ deps.emitPipelineEvent({
3499
+ type: "pipeline:stream",
3500
+ payload: {
3501
+ boardId: opts.boardId,
3502
+ ticketId: context.ticketId,
3503
+ columnId: context.columnId,
3504
+ runId: context.runId,
3505
+ sessionId: pipelineSessionId,
3506
+ event
3507
+ }
3508
+ });
3509
+ } : void 0,
3510
+ onSessionStart: deps.emitPipelineEvent ? (meta) => {
3511
+ deps.emitPipelineEvent({
3512
+ type: "pipeline:session-start",
3513
+ payload: {
3514
+ boardId: opts.boardId,
3515
+ ticketId,
3516
+ columnId,
3517
+ runId: meta.runId,
3518
+ sessionId: pipelineSessionId,
3519
+ model: meta.model,
3520
+ invocationType: "heavy",
3521
+ iteration: meta.iteration
3522
+ }
3523
+ });
3524
+ } : void 0,
3525
+ onSessionEnd: (meta) => {
3526
+ costTracker?.record({
3527
+ ticketId,
3528
+ columnId,
3529
+ model: effectiveConfig.model ?? "default",
3530
+ tokensIn: meta.tokensIn,
3531
+ tokensOut: meta.tokensOut,
3532
+ type: "heavy"
3533
+ });
3534
+ if (deps.emitPipelineEvent) {
3535
+ deps.emitPipelineEvent({
3536
+ type: "pipeline:session-end",
3537
+ payload: {
3538
+ boardId: opts.boardId,
3539
+ runId: meta.runId,
3540
+ exitReason: null,
3541
+ tokensIn: meta.tokensIn,
3542
+ tokensOut: meta.tokensOut,
3543
+ toolCallCount: meta.toolCallCount,
3544
+ durationMs: meta.durationMs
3545
+ }
3546
+ });
3547
+ }
3548
+ }
3549
+ };
3550
+ if (deps.setFieldValue) {
3551
+ effectiveConfig.onCheckpoint = async (tid, checkpoint) => {
3552
+ try {
3553
+ await deps.setFieldValue(tid, "loop_checkpoint", checkpoint);
3554
+ } catch {
3555
+ }
3556
+ };
3557
+ }
3558
+ if (effectiveConfig.stuckDetection) {
3559
+ effectiveConfig.invokeStuckDetection = async (input) => {
3560
+ const prompt = composeStuckDetectionPrompt(input);
3561
+ const { exitCode, output } = await invokeClaudeP(prompt, {
3562
+ mcpConfigPath,
3563
+ model: "haiku",
3564
+ maxBudgetUsd: 0.01,
3565
+ tools: "",
3566
+ includeMcpConfig: false
3567
+ });
3568
+ if (exitCode !== 0) {
3569
+ throw new Error(`Stuck detection call failed (exit ${String(exitCode)})`);
3570
+ }
3571
+ return parseStuckDetectionResponse(output);
3572
+ };
3573
+ }
3574
+ effectiveConfig.onPostIterationGates = async (tid, iteration) => {
3575
+ const gates = resolveGatesForColumn(gateConfig, resolvedColumnName);
3576
+ if (gates.length === 0) {
3577
+ return gateSnapshotStore.record(tid, iteration, []);
3578
+ }
3579
+ const runOpts = {};
3580
+ if (gateConfig.settings?.cwd) runOpts.cwd = gateConfig.settings.cwd;
3581
+ if (gateConfig.settings?.env) runOpts.env = gateConfig.settings.env;
3582
+ if (gateConfig.settings?.total_timeout) {
3583
+ runOpts.totalTimeoutMs = parseTimeout(gateConfig.settings.total_timeout);
3584
+ }
3585
+ const results = await runGates(gates, runOpts);
3586
+ return gateSnapshotStore.record(tid, iteration, results);
3587
+ };
3588
+ const loop = new RalphLoop(ticketId, columnId, effectiveConfig, loopDeps);
3589
+ activeRalphLoops.add(loop);
3590
+ return loop.run().finally(() => {
3591
+ activeRalphLoops.delete(loop);
3592
+ });
3593
+ },
3594
+ createComment: (ticketId, body) => client.post(`/projects/${projectId}/tickets/${ticketId}/comments`, { body }),
3595
+ createSignal: (ticketId, body) => client.post(`/projects/${projectId}/signals`, {
3596
+ scopeType: "ticket",
3597
+ scopeId: ticketId,
3598
+ content: body
3599
+ }),
3600
+ createColumnSignal: (columnId, body) => client.post(`/projects/${projectId}/signals`, {
3601
+ scopeType: "column",
3602
+ scopeId: columnId,
3603
+ content: body
3604
+ }),
3605
+ claimTicket: (ticketId) => client.claimTicket(projectId, ticketId),
3606
+ fetchBlockedTickets: (ticketId) => client.get(
3607
+ `/projects/${projectId}/tickets/${ticketId}/blocked-tickets`
3608
+ ),
3609
+ hasUnresolvedBlockers: async (ticketId) => {
3610
+ const blockers = await client.get(
3611
+ `/projects/${projectId}/tickets/${ticketId}/unresolved-blockers`
3612
+ );
3613
+ return blockers.length > 0;
3614
+ },
3615
+ dispatchLightCall: async (ticketId, columnId) => {
3616
+ const [ticketCtx, colScope, boardScope] = await Promise.all([
3617
+ client.get(`/projects/${projectId}/pipeline-context`, { ticketId }),
3618
+ client.get(`/projects/${projectId}/pipeline-context`, { columnId }),
3619
+ client.get(
3620
+ `/projects/${projectId}/pipeline-context`,
3621
+ { boardId: opts.boardId }
3622
+ )
3623
+ ]);
3624
+ const lightCtx = {
3625
+ ticketId,
3626
+ ticketNumber: ticketCtx.ticket.ticket_number,
3627
+ ticketTitle: ticketCtx.ticket.title,
3628
+ ...ticketCtx.ticket.description ? { ticketDescription: ticketCtx.ticket.description } : {},
3629
+ columnName: colScope.column.name,
3630
+ fieldValues: ticketCtx.field_values,
3631
+ toolPrefix: colScope.tool_prefix,
3632
+ projectId,
3633
+ ...colScope.transition_rules ? { transitionRules: colScope.transition_rules } : {},
3634
+ ...boardScope.columns && boardScope.columns.length > 0 && {
3635
+ availableColumns: boardScope.columns.map((col) => ({ id: col.id, name: col.name }))
3636
+ }
3637
+ };
3638
+ const prompt = composeLightPrompt(lightCtx);
3639
+ logger.orchestrator(`[${ticketId}] Light call: composing prompt for column "${colScope.column.name}"`);
3640
+ const { exitCode, output, tokensIn, tokensOut } = await invokeClaudeP(prompt, {
3641
+ mcpConfigPath,
3642
+ model: "haiku",
3643
+ maxBudgetUsd: 0.01,
3644
+ maxTurns: 3,
3645
+ tools: "",
3646
+ // Strip all built-in tools
3647
+ includeMcpConfig: false
3648
+ // No MCP tools either
3649
+ });
3650
+ costTracker?.record({ ticketId, columnId, model: "haiku", tokensIn, tokensOut, type: "light" });
3651
+ if (exitCode !== 0) {
3652
+ throw new Error(`Light call exited with code ${exitCode}: ${output.slice(0, 200)}`);
3653
+ }
3654
+ const response = parseLightResponse(output);
3655
+ logger.orchestrator(`[${ticketId}] Light call result: ${response.action} \u2014 ${response.reason}`);
3656
+ return response;
3657
+ },
3658
+ // Advisor invocation — recovers from failure exits (stalled/error/max_iterations)
3659
+ invokeAdvisor: async (input) => {
3660
+ const prompt = composeAdvisorPrompt(input);
3661
+ logger.orchestrator(`[${input.ticketId}] Advisor: invoking for ${input.exitReason}`);
3662
+ const { exitCode, output, tokensIn, tokensOut } = await invokeClaudeP(prompt, {
3663
+ mcpConfigPath,
3664
+ model: "haiku",
3665
+ maxBudgetUsd: 0.01,
3666
+ tools: "",
3667
+ includeMcpConfig: false
3668
+ });
3669
+ costTracker?.record({ ticketId: input.ticketId, columnId: "", model: "haiku", tokensIn, tokensOut, type: "advisor" });
3670
+ if (exitCode !== 0) {
3671
+ throw new Error(`Advisor call exited with code ${exitCode}: ${output.slice(0, 200)}`);
3672
+ }
3673
+ const response = parseAdvisorResponse(output);
3674
+ logger.orchestrator(`[${input.ticketId}] Advisor: ${response.action} \u2014 ${response.reason}`);
3675
+ return response;
3676
+ },
3677
+ // Field value management — used for debt items, checkpoint writes, and escalation metadata
3678
+ setFieldValue: async (ticketId, fieldName, value) => {
3679
+ await client.put(`/projects/${projectId}/tickets/${ticketId}/field-values`, {
3680
+ values: { [fieldName]: value }
3681
+ });
3682
+ },
3683
+ getFieldValues: async (ticketId) => {
3684
+ const ctx = await client.get(
3685
+ `/projects/${projectId}/pipeline-context`,
3686
+ { ticketId }
3687
+ );
3688
+ return ctx.field_values;
3689
+ },
3690
+ // Ticket management for advisor actions (ESCALATE, SPLIT_TICKET)
3691
+ moveTicketToColumn: async (ticketId, columnId, handoff) => {
3692
+ await client.patch(`/projects/${projectId}/tickets/${ticketId}/move`, {
3693
+ column_id: columnId,
3694
+ handoff
3695
+ });
3696
+ },
3697
+ createTickets: async (parentTicketId, specs) => {
3698
+ const ids = [];
3699
+ for (const spec of specs) {
3700
+ const result = await client.post(
3701
+ `/projects/${projectId}/tickets`,
3702
+ { title: spec.title, description: spec.description, parent_id: parentTicketId }
3703
+ );
3704
+ ids.push(result.id);
3705
+ }
3706
+ return ids;
3707
+ },
3708
+ archiveTicket: async (ticketId) => {
3709
+ await client.post(`/projects/${projectId}/tickets/${ticketId}/archive`, {});
3710
+ },
3711
+ // Run memory append — closure captures runMemory by reference (set after initialization)
3712
+ appendRunMemory: (section, content) => runMemory ? runMemory.append(section, content) : Promise.resolve(),
3713
+ cleanupWorktree: (name) => cleanupWorktree(name),
3714
+ mergeWorktree: (name, integrationBranch) => mergeWorktreeBranch(name, integrationBranch),
3715
+ // Pipeline event emission — wsClient captured by reference (set later before any loops run)
3716
+ emitPipelineEvent: (event) => {
3717
+ wsClient?.send(event);
3718
+ },
3719
+ boardId: opts.boardId,
3720
+ eventEmitter,
3721
+ costTracker,
3722
+ gateSnapshotStore,
3723
+ invokeReplanner: async (state) => {
3724
+ const prompt = composeReplannerPrompt(state);
3725
+ const { exitCode, output, tokensIn, tokensOut } = await invokeClaudeP(prompt, {
3726
+ mcpConfigPath,
3727
+ model: "haiku",
3728
+ maxBudgetUsd: 0.01,
3729
+ tools: "",
3730
+ includeMcpConfig: false
3731
+ });
3732
+ costTracker?.record({ ticketId: "replanner", columnId: "pipeline", model: "haiku", tokensIn, tokensOut, type: "orchestrator" });
3733
+ if (exitCode !== 0) throw new Error(`Replanner failed`);
3734
+ return parseReplannerResponse(output);
3735
+ }
3736
+ };
3737
+ const orchestrator = new PipelineOrchestrator(opts.boardId, projectId, deps);
3738
+ console.log(`Initializing pipeline for board ${opts.boardId}...`);
3739
+ logger.orchestrator("Initializing pipeline");
3740
+ try {
3741
+ await orchestrator.initialize();
3742
+ } catch (err) {
3743
+ const message = err instanceof Error ? err.message : String(err);
3744
+ console.error(`Error: Failed to initialize pipeline: ${message}`);
3745
+ console.error("Check that the board exists, has pipeline columns configured, and the API is reachable.");
3746
+ cleanupMcpConfig(mcpConfigPath);
3747
+ cleanupGateProxyConfigs(pidDir(opts.boardId));
3748
+ process.exit(1);
3749
+ }
3750
+ const columnIds = orchestrator.pipelineColumnIds;
3751
+ try {
3752
+ for (const colId of columnIds) {
3753
+ const colScope = await client.get(
3754
+ `/projects/${projectId}/pipeline-context`,
3755
+ { columnId: colId }
3756
+ );
3757
+ if (colScope.agent_config?.run_memory) {
3758
+ const boardScope = await client.get(
3759
+ `/projects/${projectId}/pipeline-context`,
3760
+ { boardId: opts.boardId }
3761
+ );
3762
+ const spaces = await client.get(
3763
+ `/projects/${projectId}/spaces`
3764
+ );
3765
+ const spaceId = spaces[0]?.id;
3766
+ if (!spaceId) {
3767
+ logger.orchestrator("Run memory: no spaces found in project \u2014 skipping run memory");
3768
+ console.warn("Warning: Run memory requires at least one document space in the project \u2014 skipping.");
3769
+ break;
3770
+ }
3771
+ runMemory = new RunMemory(opts.boardId, boardScope.board.name, {
3772
+ createDocument: async (content, title) => {
3773
+ const result = await client.post(
3774
+ `/projects/${projectId}/spaces/${spaceId}/documents`,
3775
+ { title, content }
3776
+ );
3777
+ return result.id;
3778
+ },
3779
+ getDocument: async (docId) => {
3780
+ const doc = await client.get(
3781
+ `/projects/${projectId}/documents/${docId}`
3782
+ );
3783
+ return doc.content;
3784
+ },
3785
+ updateDocument: async (docId, content) => {
3786
+ await client.patch(`/projects/${projectId}/documents/${docId}`, { content });
3787
+ }
3788
+ });
3789
+ await runMemory.initialize();
3790
+ logger.orchestrator(`Run memory initialized (doc=${runMemory.documentId ?? "none"})`);
3791
+ console.log(`Run memory enabled (document: ${runMemory.documentId ?? "none"})`);
3792
+ break;
3793
+ }
3794
+ }
3795
+ } catch (err) {
3796
+ const msg = err instanceof Error ? err.message : String(err);
3797
+ logger.orchestrator(`Run memory init failed (non-fatal): ${msg}`);
3798
+ console.error(`Warning: Run memory initialization failed: ${msg}`);
3799
+ }
3800
+ if (columnIds.length === 0) {
3801
+ console.log('No pipeline columns found (columns need has_prompt=true and type !== "done").');
3802
+ cleanupMcpConfig(mcpConfigPath);
3803
+ cleanupGateProxyConfigs(pidDir(opts.boardId));
3804
+ return;
3805
+ }
3806
+ console.log(`Discovered ${String(columnIds.length)} pipeline column(s).`);
3807
+ logger.orchestrator(`Discovered ${String(columnIds.length)} pipeline columns: ${columnIds.join(", ")}`);
3808
+ if (opts.dryRun) {
3809
+ console.log("\n--- Dry Run Configuration ---");
3810
+ console.log(`Board ID: ${opts.boardId}`);
3811
+ console.log(`Project ID: ${projectId}`);
3812
+ console.log(`Pipeline cols: ${String(columnIds.length)}`);
3813
+ console.log(`Column filter: ${opts.columnFilter ?? "(none)"}`);
3814
+ console.log(`Max iterations: ${opts.maxIterations !== null ? String(opts.maxIterations) : "(per-column default)"}`);
3815
+ console.log(`Max budget: ${opts.maxBudget !== null ? `$${String(opts.maxBudget)}` : "(per-column default)"}`);
3816
+ console.log(`Model: ${opts.model ?? "(per-column default)"}`);
3817
+ console.log(`Concurrency: ${opts.concurrency !== null ? String(opts.concurrency) : "(per-column default)"}`);
3818
+ console.log(`Mode: ${opts.once ? "once" : "persistent"}`);
3819
+ console.log(`Log retention: ${String(opts.logRetention)} days`);
3820
+ console.log(`MCP config: ${mcpConfigPath}`);
3821
+ console.log("\n[Dry run -- no agents started]");
3822
+ cleanupMcpConfig(mcpConfigPath);
3823
+ cleanupGateProxyConfigs(pidDir(opts.boardId));
3824
+ return;
3825
+ }
3826
+ let shutdownInProgress = false;
3827
+ const shutdown = async (signal) => {
3828
+ if (shutdownInProgress) return;
3829
+ shutdownInProgress = true;
3830
+ console.log(`
3831
+ Received ${signal}. Shutting down gracefully...`);
3832
+ logger.orchestrator(`Shutdown initiated (${signal})`);
3833
+ if (rescanTimer) clearInterval(rescanTimer);
3834
+ eventQueue?.stop();
3835
+ wsClient?.send({ type: "pipeline:stopped", payload: { boardId: opts.boardId } });
3836
+ await new Promise((r) => setTimeout(r, 200));
3837
+ await eventEmitter.close();
3838
+ wsClient?.stop();
3839
+ for (const loop of activeRalphLoops) {
3840
+ loop.stop();
3841
+ }
3842
+ killAllChildProcesses();
3843
+ const deadline = Date.now() + 5e3;
3844
+ while (activeChildProcesses.size > 0 && Date.now() < deadline) {
3845
+ await new Promise((r) => setTimeout(r, 200));
3846
+ }
3847
+ if (activeChildProcesses.size > 0) {
3848
+ console.error(`Warning: ${String(activeChildProcesses.size)} child process(es) did not exit. Sending SIGKILL...`);
3849
+ for (const child of activeChildProcesses) {
3850
+ try {
3851
+ if (child.pid) {
3852
+ try {
3853
+ process.kill(-child.pid, "SIGKILL");
3854
+ } catch {
3855
+ try {
3856
+ child.kill("SIGKILL");
3857
+ } catch {
3858
+ }
3859
+ }
3860
+ } else {
3861
+ try {
3862
+ child.kill("SIGKILL");
3863
+ } catch {
3864
+ }
3865
+ }
3866
+ } catch {
3867
+ }
3868
+ }
3869
+ }
3870
+ if (costTracker) {
3871
+ console.log("\n--- Pipeline Cost Report ---");
3872
+ console.log(costTracker.generateReport(gateConfig.settings?.pricing));
3873
+ }
3874
+ cleanupMcpConfig(mcpConfigPath);
3875
+ cleanupGateProxyConfigs(pidDir(opts.boardId));
3876
+ killReaper(reaperPidPath);
3877
+ removePidFile(opts.boardId);
3878
+ removeChildManifest(opts.boardId);
3879
+ logger.orchestrator("Shutdown complete");
3880
+ console.log("Pipeline stopped.");
3881
+ process.exit(0);
3882
+ };
3883
+ process.on("SIGTERM", () => {
3884
+ shutdown("SIGTERM").catch((err) => {
3885
+ console.error("Error during shutdown:", err);
3886
+ process.exit(1);
3887
+ });
3888
+ });
3889
+ process.on("SIGINT", () => {
3890
+ shutdown("SIGINT").catch((err) => {
3891
+ console.error("Error during shutdown:", err);
3892
+ process.exit(1);
3893
+ });
3894
+ });
3895
+ process.on("SIGHUP", () => {
3896
+ shutdown("SIGHUP").catch((err) => {
3897
+ console.error("Error during shutdown:", err);
3898
+ process.exit(1);
3899
+ });
3900
+ });
3901
+ cleanupOrphanedProcesses(opts.boardId);
3902
+ writePidFile(opts.boardId);
3903
+ logger.orchestrator(`PID file written: ${String(process.pid)}`);
3904
+ const reaperPidPath = join2(pidDir(opts.boardId), "reaper.pid");
3905
+ const reaperProcess = spawnReaper({
3906
+ orchestratorPid: process.pid,
3907
+ manifestPath: childManifestPath(opts.boardId),
3908
+ pidFilePath: pidFilePath(opts.boardId),
3909
+ reaperPidPath,
3910
+ mcpConfigPath,
3911
+ pipelineDir: pidDir(opts.boardId)
3912
+ });
3913
+ logger.orchestrator(`Watchdog reaper spawned (PID ${String(reaperProcess.pid ?? "unknown")})`);
3914
+ let eventQueue = null;
3915
+ let wsClient = null;
3916
+ let rescanTimer = null;
3917
+ const onEventError = (event, err) => {
3918
+ const msg = err instanceof Error ? err.message : String(err);
3919
+ logger.orchestrator(`Event handler error for ${event.type} ticket=${event.ticketId}: ${msg}`);
3920
+ console.error(`Event handler error: ${msg}`);
3921
+ };
3922
+ eventQueue = new EventQueue(
3923
+ (event) => orchestrator.handleEvent(event),
3924
+ {
3925
+ drainRateMs: 100,
3926
+ onError: onEventError
3927
+ }
3928
+ );
3929
+ wsClient = new PipelineWsClient(client, {
3930
+ boardId: opts.boardId,
3931
+ projectId,
3932
+ onEvent: (wsEvent) => {
3933
+ const payload = wsEvent.payload;
3934
+ const ticket = payload["ticket"];
3935
+ const ticketId = (typeof payload["ticketId"] === "string" ? payload["ticketId"] : null) ?? (ticket && typeof ticket["id"] === "string" ? ticket["id"] : null);
3936
+ const columnId = (typeof payload["columnId"] === "string" ? payload["columnId"] : null) ?? (ticket && typeof ticket["column_id"] === "string" ? ticket["column_id"] : null);
3937
+ if (wsEvent.type === "firing_constraint:created" || wsEvent.type === "firing_constraint:updated" || wsEvent.type === "firing_constraint:deleted") {
3938
+ logger.orchestrator(`WS event: ${wsEvent.type} \u2014 refreshing constraint caches`);
3939
+ void orchestrator.refreshConstraints().catch((err) => {
3940
+ const msg = err instanceof Error ? err.message : String(err);
3941
+ logger.orchestrator(`Constraint cache refresh failed: ${msg}`);
3942
+ });
3943
+ return;
3944
+ }
3945
+ if (!ticketId) return;
3946
+ const eventType = wsEvent.type;
3947
+ if (eventType === "ticket:created" || eventType === "ticket:moved" || eventType === "ticket:updated" || eventType === "ticket:archived" || eventType === "ticket:deleted") {
3948
+ const pipelineEvent = { type: eventType, ticketId, columnId };
3949
+ eventQueue.push(pipelineEvent);
3950
+ logger.orchestrator(`WS event: ${eventType} ticket=${ticketId} column=${columnId ?? "null"}`);
3951
+ }
3952
+ },
3953
+ onConnect: () => {
3954
+ console.log("WebSocket connected. Listening for board events...");
3955
+ logger.orchestrator("WebSocket connected");
3956
+ },
3957
+ onDisconnect: () => {
3958
+ logger.orchestrator("WebSocket disconnected");
3959
+ }
3960
+ });
3961
+ try {
3962
+ await wsClient.connect();
3963
+ } catch (err) {
3964
+ const message = err instanceof Error ? err.message : String(err);
3965
+ console.error(`Warning: WebSocket connection failed: ${message}`);
3966
+ console.error("Pipeline will poll for changes and retry WS periodically.");
3967
+ logger.orchestrator(`WebSocket connection failed: ${message}`);
3968
+ }
3969
+ eventQueue.start();
3970
+ console.log("Scanning pipeline columns for tickets...");
3971
+ logger.orchestrator("Starting scan and spawn");
3972
+ try {
3973
+ await orchestrator.scanAndSpawn();
3974
+ logger.orchestrator(`Scan complete. Active loops: ${String(orchestrator.activeLoopCount)}`);
3975
+ console.log(`Scan complete. Active loops: ${String(orchestrator.activeLoopCount)}`);
3976
+ } catch (scanErr) {
3977
+ const scanMsg = scanErr instanceof Error ? scanErr.message : String(scanErr);
3978
+ logger.orchestrator(`Scan error: ${scanMsg}`);
3979
+ console.error(`Scan error: ${scanMsg}`);
3980
+ }
3981
+ if (opts.once) {
3982
+ console.log("Running in --once mode. Waiting for all loops to complete...");
3983
+ logger.orchestrator("Once mode: waiting for loops to complete");
3984
+ await waitForAllLoops(orchestrator);
3985
+ if (costTracker) {
3986
+ console.log("\n--- Pipeline Cost Report ---");
3987
+ console.log(costTracker.generateReport(gateConfig.settings?.pricing));
3988
+ }
3989
+ wsClient.send({ type: "pipeline:stopped", payload: { boardId: opts.boardId } });
3990
+ await new Promise((r) => setTimeout(r, 200));
3991
+ await eventEmitter.close();
3992
+ wsClient.stop();
3993
+ eventQueue.stop();
3994
+ cleanupMcpConfig(mcpConfigPath);
3995
+ cleanupGateProxyConfigs(pidDir(opts.boardId));
3996
+ killReaper(reaperPidPath);
3997
+ removePidFile(opts.boardId);
3998
+ removeChildManifest(opts.boardId);
3999
+ logger.orchestrator("Once mode complete. Exiting.");
4000
+ console.log("All loops complete. Pipeline exiting.");
4001
+ } else {
4002
+ let consecutiveScanFailures = 0;
4003
+ rescanTimer = setInterval(() => {
4004
+ void (async () => {
4005
+ try {
4006
+ await orchestrator.scanAndSpawn();
4007
+ if (consecutiveScanFailures > 0) {
4008
+ console.log(`Rescan recovered after ${String(consecutiveScanFailures)} failure(s)`);
4009
+ }
4010
+ consecutiveScanFailures = 0;
4011
+ } catch (err) {
4012
+ consecutiveScanFailures++;
4013
+ const msg = err instanceof Error ? err.message : String(err);
4014
+ logger.orchestrator(`Periodic rescan error (${String(consecutiveScanFailures)}x): ${msg}`);
4015
+ if (consecutiveScanFailures === 1 || consecutiveScanFailures % 10 === 0) {
4016
+ console.error(`Warning: Rescan has failed ${String(consecutiveScanFailures)} consecutive time(s): ${msg}`);
4017
+ }
4018
+ }
4019
+ if (wsClient && !wsClient.connected) {
4020
+ const reconnected = await wsClient.tryReconnect();
4021
+ if (reconnected) {
4022
+ logger.orchestrator("WS reconnected via poll cycle");
4023
+ }
4024
+ }
4025
+ })();
4026
+ }, 3e4);
4027
+ console.log(`Pipeline running (PID ${String(process.pid)}). Use 'kantban pipeline stop ${opts.boardId}' to stop.`);
4028
+ logger.orchestrator("Pipeline running in persistent mode");
4029
+ }
4030
+ }
4031
+ var activeRalphLoops = /* @__PURE__ */ new Set();
4032
+ async function waitForAllLoops(orchestrator, timeoutMs = 4 * 60 * 60 * 1e3) {
4033
+ let consecutiveIdle = 0;
4034
+ const startTime = Date.now();
4035
+ let lastProgressLog = 0;
4036
+ while (consecutiveIdle < 2) {
4037
+ if (Date.now() - startTime > timeoutMs) {
4038
+ console.error(
4039
+ `Warning: waitForAllLoops timed out after ${String(Math.round(timeoutMs / 6e4))}m. Active: ${String(activeRalphLoops.size)}, orchestrator: ${String(orchestrator.activeLoopCount)}, queued: ${String(orchestrator.hasQueuedWork)}`
4040
+ );
4041
+ return;
4042
+ }
4043
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
4044
+ if (activeRalphLoops.size === 0 && orchestrator.activeLoopCount === 0 && !orchestrator.hasActiveQueuedWork) {
4045
+ consecutiveIdle++;
4046
+ } else {
4047
+ consecutiveIdle = 0;
4048
+ const now = Date.now();
4049
+ if (now - lastProgressLog > 3e4) {
4050
+ lastProgressLog = now;
4051
+ console.log(
4052
+ `Waiting... Active loops: ${String(activeRalphLoops.size)}, queued work: ${String(orchestrator.hasQueuedWork)}`
4053
+ );
4054
+ }
4055
+ }
4056
+ }
4057
+ }
4058
+ async function stopPipeline(args) {
4059
+ const boardId = args[0];
4060
+ if (!boardId) {
4061
+ console.error("Usage: kantban pipeline stop <board-id>");
4062
+ process.exit(1);
4063
+ }
4064
+ const pidFile = pidFilePath(boardId);
4065
+ let pid;
4066
+ try {
4067
+ const content = readFileSync2(pidFile, "utf8").trim();
4068
+ pid = Number(content);
4069
+ if (isNaN(pid) || pid <= 0) {
4070
+ throw new Error("Invalid PID");
4071
+ }
4072
+ } catch {
4073
+ console.error(`No running pipeline found for board ${boardId}.`);
4074
+ console.error(`Expected PID file at: ${pidFile}`);
4075
+ process.exit(1);
4076
+ return;
4077
+ }
4078
+ try {
4079
+ try {
4080
+ process.kill(-pid, "SIGTERM");
4081
+ console.log(`Sent SIGTERM to pipeline process group (pgid ${String(pid)}) for board ${boardId}.`);
4082
+ } catch {
4083
+ process.kill(pid, "SIGTERM");
4084
+ console.log(`Sent SIGTERM to pipeline process ${String(pid)} for board ${boardId}.`);
4085
+ }
4086
+ } catch (err) {
4087
+ const message = err instanceof Error ? err.message : String(err);
4088
+ console.error(`Failed to stop pipeline (PID ${String(pid)}): ${message}`);
4089
+ console.error("The process may have already exited. Removing stale PID file.");
4090
+ removePidFile(boardId);
4091
+ process.exit(1);
4092
+ }
4093
+ }
4094
+ export {
4095
+ runPipeline,
4096
+ stopPipeline
4097
+ };
4098
+ //# sourceMappingURL=pipeline-7LG74YA2.js.map