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
@@ -1,1222 +0,0 @@
1
- import { spawn, execSync } from 'node:child_process';
2
- import { mkdirSync, writeFileSync, readFileSync, unlinkSync, existsSync, appendFileSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
- import { join } from 'node:path';
5
- import { PipelineOrchestrator } from '../lib/orchestrator.js';
6
- import { RalphLoop } from '../lib/ralph-loop.js';
7
- import { RunMemory } from '../lib/run-memory.js';
8
- import { EventQueue } from '../lib/event-queue.js';
9
- import { PipelineWsClient } from '../lib/ws-client.js';
10
- import { generateMcpConfig, generateGateProxyMcpConfig, cleanupMcpConfig, cleanupGateProxyConfigs } from '../lib/mcp-config.js';
11
- import { PipelineLogger } from '../lib/logger.js';
12
- import { composeLightPrompt, parseLightResponse } from '../lib/light-call.js';
13
- import { composeAdvisorPrompt, parseAdvisorResponse } from '../lib/advisor.js';
14
- import { composeStuckDetectionPrompt, parseStuckDetectionResponse } from '../lib/stuck-detector.js';
15
- import { cleanupWorktree } from '../lib/worktree.js';
16
- import { spawnReaper, killReaper } from '../lib/reaper.js';
17
- import { StreamJsonParser } from '../lib/stream-parser.js';
18
- import { parseGateConfig, resolveGatesForColumn, parseTimeout } from '../lib/gate-config.js';
19
- import { GateSnapshotStore } from '../lib/gate-snapshot.js';
20
- import { runGates } from '../lib/gate-runner.js';
21
- import { PipelineCostTracker } from '../lib/cost-tracker.js';
22
- import { composeReplannerPrompt, parseReplannerResponse } from '../lib/replanner.js';
23
- import { PipelineEventEmitter } from '../lib/event-emitter.js';
24
- function parseArgs(args) {
25
- const positional = [];
26
- let once = false;
27
- let dryRun = false;
28
- let columnFilter = null;
29
- let maxIterations = null;
30
- let maxBudget = null;
31
- let model = null;
32
- let concurrency = null;
33
- let logRetention = 7;
34
- let yes = false;
35
- for (let i = 0; i < args.length; i++) {
36
- const arg = args[i];
37
- switch (arg) {
38
- case '--once':
39
- once = true;
40
- break;
41
- case '--dry-run':
42
- dryRun = true;
43
- break;
44
- case '--yes':
45
- case '-y':
46
- yes = true;
47
- break;
48
- case '--column':
49
- columnFilter = args[++i] ?? null;
50
- break;
51
- case '--max-iterations': {
52
- const val = Number(args[++i]);
53
- if (isNaN(val) || val <= 0) {
54
- console.error(`Error: --max-iterations requires a positive number, got: ${args[i]}`);
55
- process.exit(1);
56
- }
57
- maxIterations = val;
58
- break;
59
- }
60
- case '--max-budget': {
61
- const val = Number(args[++i]);
62
- if (isNaN(val) || val < 0) {
63
- console.error(`Error: --max-budget requires a non-negative number, got: ${args[i]}`);
64
- process.exit(1);
65
- }
66
- maxBudget = val;
67
- break;
68
- }
69
- case '--model':
70
- model = args[++i] ?? null;
71
- break;
72
- case '--concurrency': {
73
- const val = Number(args[++i]);
74
- if (isNaN(val) || val <= 0) {
75
- console.error(`Error: --concurrency requires a positive number, got: ${args[i]}`);
76
- process.exit(1);
77
- }
78
- concurrency = val;
79
- break;
80
- }
81
- case '--log-retention':
82
- logRetention = Number(args[++i]) || 7;
83
- break;
84
- default:
85
- if (!arg.startsWith('--'))
86
- positional.push(arg);
87
- break;
88
- }
89
- }
90
- const boardId = positional[0] ?? '';
91
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
92
- if (boardId && !UUID_RE.test(boardId)) {
93
- console.error(`Error: invalid board ID "${boardId}" — expected UUID`);
94
- process.exit(1);
95
- }
96
- if (!boardId) {
97
- console.error(`Usage: kantban pipeline <board-id> [flags]
98
-
99
- Flags:
100
- --once Run one scan, wait for loops, then exit
101
- --dry-run Show config without starting
102
- --column <id> Filter to a single column
103
- --max-iterations <n> Override max iterations per ticket
104
- --max-budget <usd> Per-ticket budget cap (USD)
105
- --model <model> Override model preference
106
- --concurrency <n> Override concurrency per column
107
- --log-retention <d> Log retention in days (default: 7)
108
- --yes, -y Skip safety confirmation`);
109
- process.exit(1);
110
- }
111
- return { boardId, once, dryRun, columnFilter, maxIterations, maxBudget, model, concurrency, logRetention, yes };
112
- }
113
- // ---------------------------------------------------------------------------
114
- // PID file helpers
115
- // ---------------------------------------------------------------------------
116
- function pidDir(boardId) {
117
- return join(homedir(), '.kantban', 'pipelines', boardId);
118
- }
119
- function pidFilePath(boardId) {
120
- return join(pidDir(boardId), 'orchestrator.pid');
121
- }
122
- function writePidFile(boardId) {
123
- const dir = pidDir(boardId);
124
- mkdirSync(dir, { recursive: true, mode: 0o700 });
125
- writeFileSync(pidFilePath(boardId), String(process.pid), { mode: 0o600 });
126
- }
127
- function removePidFile(boardId) {
128
- try {
129
- unlinkSync(pidFilePath(boardId));
130
- }
131
- catch {
132
- /* already removed */
133
- }
134
- }
135
- // ---------------------------------------------------------------------------
136
- // Child PID manifest helpers — tracks spawned child PIDs on disk for orphan
137
- // cleanup by the watchdog reaper or next-startup cleanup.
138
- // ---------------------------------------------------------------------------
139
- function childManifestPath(boardId) {
140
- return join(pidDir(boardId), 'children.pid');
141
- }
142
- function appendChildPid(boardId, pid) {
143
- const dir = pidDir(boardId);
144
- mkdirSync(dir, { recursive: true, mode: 0o700 });
145
- appendFileSync(childManifestPath(boardId), `${String(pid)}\n`, { mode: 0o600 });
146
- }
147
- function removeChildPid(boardId, pid) {
148
- const manifestPath = childManifestPath(boardId);
149
- try {
150
- const contents = readFileSync(manifestPath, 'utf-8');
151
- const pids = contents.split('\n').filter(l => l.trim() !== '' && l.trim() !== String(pid));
152
- writeFileSync(manifestPath, pids.length > 0 ? pids.join('\n') + '\n' : '', { mode: 0o600 });
153
- }
154
- catch {
155
- /* manifest doesn't exist or unreadable — nothing to remove */
156
- }
157
- }
158
- function readChildManifest(boardId) {
159
- try {
160
- const contents = readFileSync(childManifestPath(boardId), 'utf-8');
161
- return contents
162
- .split('\n')
163
- .map(l => l.trim())
164
- .filter(l => l !== '')
165
- .map(Number)
166
- .filter(n => !isNaN(n) && n > 0);
167
- }
168
- catch {
169
- return [];
170
- }
171
- }
172
- function removeChildManifest(boardId) {
173
- try {
174
- unlinkSync(childManifestPath(boardId));
175
- }
176
- catch {
177
- /* already removed */
178
- }
179
- }
180
- /**
181
- * Kill any orphaned pipeline processes from a previous run.
182
- * Reads the stale PID file, kills that process tree, then removes the file.
183
- * Also kills any lingering `claude -p` processes that reference our MCP config dir.
184
- */
185
- function cleanupOrphanedProcesses(boardId) {
186
- // 1. Kill stale orchestrator from PID file
187
- const pidPath = pidFilePath(boardId);
188
- if (existsSync(pidPath)) {
189
- try {
190
- const stalePid = parseInt(readFileSync(pidPath, 'utf-8').trim(), 10);
191
- if (stalePid && stalePid !== process.pid) {
192
- try {
193
- process.kill(stalePid, 0); // test if alive
194
- process.kill(stalePid, 'SIGTERM');
195
- console.log(`Killed stale orchestrator (PID ${String(stalePid)})`);
196
- }
197
- catch {
198
- // already dead
199
- }
200
- }
201
- }
202
- catch {
203
- // can't read PID file
204
- }
205
- removePidFile(boardId);
206
- }
207
- // 1.5. Kill children from the PID manifest (more targeted than grep)
208
- const manifestPids = readChildManifest(boardId);
209
- if (manifestPids.length > 0) {
210
- for (const pid of manifestPids) {
211
- try {
212
- process.kill(pid, 0); // test if alive
213
- process.kill(pid, 'SIGTERM');
214
- }
215
- catch {
216
- // already dead
217
- }
218
- }
219
- console.log(`Killed ${String(manifestPids.length)} orphaned child process(es) from manifest`);
220
- removeChildManifest(boardId);
221
- }
222
- // 1.6. Kill stale reaper if still running
223
- const staleReaperPath = join(pidDir(boardId), 'reaper.pid');
224
- try {
225
- if (existsSync(staleReaperPath)) {
226
- const reaperPid = parseInt(readFileSync(staleReaperPath, 'utf-8').trim(), 10);
227
- if (reaperPid && !isNaN(reaperPid)) {
228
- try {
229
- process.kill(reaperPid, 0);
230
- process.kill(reaperPid, 'SIGTERM');
231
- console.log(`Killed stale reaper (PID ${String(reaperPid)})`);
232
- }
233
- catch {
234
- // already dead
235
- }
236
- }
237
- unlinkSync(staleReaperPath);
238
- }
239
- }
240
- catch {
241
- // can't read reaper PID — not critical
242
- }
243
- // 2. Kill orphaned claude -p processes with THIS BOARD's MCP configs
244
- try {
245
- const boardDir = pidDir(boardId).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
246
- const out = execSync(`ps aux | grep 'claude.*-p' | grep '${boardDir}' | grep -v grep | awk '{print $2}'`, { encoding: 'utf-8', timeout: 5000 }).trim();
247
- if (out) {
248
- const pids = out.split('\n').filter(Boolean);
249
- for (const pid of pids) {
250
- try {
251
- process.kill(parseInt(pid, 10), 'SIGTERM');
252
- }
253
- catch {
254
- // already dead
255
- }
256
- }
257
- if (pids.length > 0) {
258
- console.log(`Killed ${String(pids.length)} orphaned claude agent(s)`);
259
- }
260
- }
261
- }
262
- catch {
263
- // ps/grep failed — not critical
264
- }
265
- }
266
- // ---------------------------------------------------------------------------
267
- // invokeClaudeP — spawns a Claude Code process with -p flag
268
- // Children are spawned in the orchestrator's process group so that killing
269
- // the group (e.g. on crash) takes all children down with it.
270
- // ---------------------------------------------------------------------------
271
- const activeChildProcesses = new Set();
272
- let currentBoardId = '';
273
- const CLAUDE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour max per invocation
274
- async function invokeClaudeP(prompt, options) {
275
- const args = ['-p', prompt, '--dangerously-skip-permissions', '--output-format', 'stream-json', '--verbose'];
276
- // MCP config — omit for toolless invocations
277
- if (options.includeMcpConfig !== false && options.mcpConfigPath) {
278
- args.push('--mcp-config', options.mcpConfigPath);
279
- }
280
- if (options.model)
281
- args.push('--model', options.model);
282
- if (options.maxTurns) {
283
- args.push('--max-turns', String(options.maxTurns));
284
- }
285
- else if (options.maxBudgetUsd !== undefined && options.maxBudgetUsd !== null) {
286
- args.push('--max-turns', String(Math.max(1, Math.ceil(options.maxBudgetUsd * 10))));
287
- }
288
- if (options.worktree)
289
- args.push('--worktree', options.worktree);
290
- // Tool scoping flags
291
- if (options.tools !== undefined) {
292
- args.push('--tools', options.tools);
293
- }
294
- if (options.allowedTools?.length) {
295
- args.push('--allowedTools', ...options.allowedTools);
296
- }
297
- if (options.disallowedTools?.length) {
298
- args.push('--disallowedTools', ...options.disallowedTools);
299
- }
300
- return new Promise((resolve) => {
301
- const child = spawn('claude', args, {
302
- stdio: ['pipe', 'pipe', 'pipe'],
303
- });
304
- activeChildProcesses.add(child);
305
- if (child.pid && currentBoardId)
306
- appendChildPid(currentBoardId, child.pid);
307
- const parser = new StreamJsonParser();
308
- parser.on('error', (err) => {
309
- process.stderr.write(`[stream-parser] ${err.message}\n`);
310
- });
311
- let lastOutput = '';
312
- let tokensIn = 0;
313
- let tokensOut = 0;
314
- parser.on('event', (event) => {
315
- // Capture final result metadata
316
- if (event.type === 'result') {
317
- const usage = event.usage;
318
- tokensIn += usage?.input_tokens ?? 0;
319
- tokensOut += usage?.output_tokens ?? 0;
320
- if (typeof event.result === 'string')
321
- lastOutput = event.result;
322
- }
323
- // Forward to stream callback if provided
324
- options.onStreamEvent?.(event);
325
- });
326
- child.stdout?.on('data', (chunk) => {
327
- parser.feed(chunk.toString());
328
- });
329
- // Capture stderr for error diagnostics
330
- let stderr = '';
331
- child.stderr?.on('data', (chunk) => {
332
- stderr += chunk.toString();
333
- });
334
- // Close stdin immediately (we pass prompt via -p flag)
335
- child.stdin?.end();
336
- // Set up timeout
337
- let killTimer;
338
- const timeoutHandle = setTimeout(() => {
339
- try {
340
- child.kill('SIGTERM');
341
- }
342
- catch { /* already dead */ }
343
- // Escalate to SIGKILL if process doesn't exit within 5s
344
- killTimer = setTimeout(() => {
345
- if (!resolved) {
346
- try {
347
- child.kill('SIGKILL');
348
- }
349
- catch { /* already dead */ }
350
- }
351
- }, 5000);
352
- }, CLAUDE_TIMEOUT_MS);
353
- let resolved = false;
354
- child.on('close', (code) => {
355
- if (resolved)
356
- return;
357
- resolved = true;
358
- clearTimeout(timeoutHandle);
359
- if (killTimer)
360
- clearTimeout(killTimer);
361
- activeChildProcesses.delete(child);
362
- if (child.pid && currentBoardId)
363
- removeChildPid(currentBoardId, child.pid);
364
- parser.flush();
365
- resolve({
366
- exitCode: code ?? 1,
367
- output: lastOutput || stderr,
368
- toolCallCount: parser.getToolCallCount(),
369
- tokensIn,
370
- tokensOut,
371
- });
372
- });
373
- child.on('error', (err) => {
374
- if (resolved)
375
- return;
376
- resolved = true;
377
- clearTimeout(timeoutHandle);
378
- if (killTimer)
379
- clearTimeout(killTimer);
380
- activeChildProcesses.delete(child);
381
- if (child.pid && currentBoardId)
382
- removeChildPid(currentBoardId, child.pid);
383
- parser.flush();
384
- resolve({
385
- exitCode: 1,
386
- output: err.message,
387
- toolCallCount: parser.getToolCallCount(),
388
- tokensIn,
389
- tokensOut,
390
- });
391
- });
392
- });
393
- }
394
- function killAllChildProcesses() {
395
- for (const child of activeChildProcesses) {
396
- try {
397
- if (child.pid) {
398
- try {
399
- process.kill(-child.pid, 'SIGTERM');
400
- }
401
- catch {
402
- try {
403
- child.kill('SIGTERM');
404
- }
405
- catch { /* already dead */ }
406
- }
407
- }
408
- else {
409
- try {
410
- child.kill('SIGTERM');
411
- }
412
- catch { /* already dead */ }
413
- }
414
- }
415
- catch {
416
- // Process already exited — safe to ignore
417
- }
418
- }
419
- }
420
- // ---------------------------------------------------------------------------
421
- // Safety confirmation
422
- // ---------------------------------------------------------------------------
423
- function printSafetyWarning() {
424
- console.log(`
425
- === SAFETY WARNING ===
426
-
427
- This pipeline will run Claude Code agents with --dangerously-skip-permissions.
428
- Agents will have unrestricted access to tools, file system, and MCP servers.
429
-
430
- Before proceeding:
431
- 1. Review all prompt documents linked to pipeline columns
432
- 2. Ensure your project signals/guardrails are configured correctly
433
- 3. Verify your MCP server configuration is appropriate
434
-
435
- Press Ctrl+C to cancel, or re-run with --yes to skip this warning.
436
- `);
437
- }
438
- function waitForConfirmation() {
439
- return new Promise((resolve) => {
440
- // Non-TTY (piped input) — read from stdin instead of rejecting
441
- process.stdout.write('Continue? [y/N] ');
442
- process.stdin.setEncoding('utf8');
443
- process.stdin.once('data', (data) => {
444
- const answer = data.trim().toLowerCase();
445
- resolve(answer === 'y' || answer === 'yes');
446
- });
447
- // If stdin ends without data (empty pipe), default to no
448
- process.stdin.once('end', () => resolve(false));
449
- });
450
- }
451
- // ---------------------------------------------------------------------------
452
- // runPipeline — main entry point
453
- // ---------------------------------------------------------------------------
454
- export async function runPipeline(client, args) {
455
- // Handle subcommands
456
- if (args[0] === 'init') {
457
- const { runPipelineInit } = await import('./pipeline-init.js');
458
- await runPipelineInit();
459
- return;
460
- }
461
- const opts = parseArgs(args);
462
- currentBoardId = opts.boardId;
463
- // Safety confirmation
464
- if (!opts.yes && !opts.dryRun) {
465
- printSafetyWarning();
466
- const confirmed = await waitForConfirmation();
467
- if (!confirmed) {
468
- console.log('Aborted.');
469
- return;
470
- }
471
- }
472
- // Validate gate file
473
- const gateFilePath = join(process.cwd(), 'pipeline.gates.yaml');
474
- let gateConfig;
475
- if (!existsSync(gateFilePath)) {
476
- console.error(`Error: pipeline.gates.yaml not found in ${process.cwd()}`);
477
- console.error('Run "kantban pipeline init" to generate a starter gate file.');
478
- process.exit(1);
479
- }
480
- try {
481
- const gateYaml = readFileSync(gateFilePath, 'utf-8');
482
- gateConfig = parseGateConfig(gateYaml);
483
- console.log(`Gate config loaded: ${gateConfig.default.length} default gate(s)`);
484
- }
485
- catch (err) {
486
- const message = err instanceof Error ? err.message : String(err);
487
- console.error(`Error: Invalid pipeline.gates.yaml: ${message}`);
488
- process.exit(1);
489
- }
490
- // Create cost tracker if budget is configured
491
- const costTracker = gateConfig.settings?.budget
492
- ? new PipelineCostTracker(crypto.randomUUID(), gateConfig.settings.budget)
493
- : undefined;
494
- // Gate snapshot store — per-ticket gate history for gutter detection and advisor
495
- const gateSnapshotStore = new GateSnapshotStore();
496
- // Resolve project ID
497
- let projectId = process.env['KANTBAN_PROJECT_ID'] ?? '';
498
- if (!projectId) {
499
- try {
500
- const result = await client.getBoardProject(opts.boardId);
501
- projectId = result.project_id;
502
- }
503
- catch (err) {
504
- const message = err instanceof Error ? err.message : String(err);
505
- console.error(`Error: Could not resolve project ID for board ${opts.boardId}: ${message}`);
506
- console.error('Set KANTBAN_PROJECT_ID environment variable or ensure the board exists.');
507
- process.exit(1);
508
- }
509
- }
510
- // Generate MCP config (stable path per board — survives restarts)
511
- const mcpConfigPath = generateMcpConfig(client.baseUrl, client.token, opts.boardId);
512
- // Initialize logger
513
- const logBaseDir = join(homedir(), '.kantban', 'pipelines');
514
- const logger = new PipelineLogger(logBaseDir, opts.boardId);
515
- logger.pruneOldLogs(opts.logRetention);
516
- // Declared early so closures in deps can capture it by reference before initialization
517
- let runMemory = null;
518
- // Stable session ID for all pipeline events in this run
519
- const pipelineSessionId = crypto.randomUUID();
520
- const eventEmitter = new PipelineEventEmitter(client.baseUrl, projectId, client.token, opts.boardId, pipelineSessionId);
521
- // Wire up OrchestratorDeps
522
- const deps = {
523
- fetchBoardScope: (boardId) => client.get(`/projects/${projectId}/pipeline-context`, { boardId }),
524
- fetchColumnScope: (columnId) => client.get(`/projects/${projectId}/pipeline-context`, { columnId }),
525
- startLoop: async (ticketId, columnId, config) => {
526
- // Apply CLI overrides
527
- const effectiveConfig = {
528
- ...config,
529
- ...(opts.maxIterations !== null && { maxIterations: opts.maxIterations }),
530
- ...(opts.maxBudget !== null && { maxBudgetUsd: opts.maxBudget }),
531
- ...(opts.model !== null && { model: opts.model }),
532
- };
533
- // Capture run memory ref before async work so closures below see the same instance
534
- const mem = runMemory;
535
- // Resolve column name once upfront so we can select the right MCP config.
536
- // This also pre-warms cachedColumnName for onPostIterationGates below.
537
- const colScopeForName = await client.get(`/projects/${projectId}/pipeline-context`, { columnId });
538
- const resolvedColumnName = colScopeForName.column.name;
539
- // Choose MCP config: gate-proxy variant when gates are configured for this column,
540
- // global variant otherwise.
541
- const columnGates = resolveGatesForColumn(gateConfig, resolvedColumnName);
542
- const effectiveMcpConfigPath = columnGates.length > 0
543
- ? generateGateProxyMcpConfig(client.baseUrl, client.token, opts.boardId, gateFilePath, columnId, resolvedColumnName, projectId)
544
- : mcpConfigPath;
545
- const loopDeps = {
546
- fetchTicketContext: (tid) => client.get(`/projects/${projectId}/pipeline-context`, { ticketId: tid }),
547
- fetchColumnContext: (cid) => client.get(`/projects/${projectId}/pipeline-context`, { columnId: cid }),
548
- fetchFingerprint: (tid) => client.getFingerprint(projectId, tid),
549
- invokeClaudeP,
550
- mcpConfigPath: effectiveMcpConfigPath,
551
- projectId,
552
- log: (msg) => {
553
- logger.orchestrator(`[${ticketId}] ${msg}`);
554
- },
555
- // Run memory enrichment — inject accumulated discoveries into each iteration prompt
556
- fetchRunMemoryContent: mem
557
- ? async () => {
558
- const content = await mem.getContent();
559
- const lines = content.split('\n');
560
- if (lines.length > 500) {
561
- return lines.slice(-500).join('\n');
562
- }
563
- return content;
564
- }
565
- : undefined,
566
- // Lookahead enrichment — fetch next-column prompt doc so agent can anticipate exit criteria
567
- fetchLookaheadDocument: effectiveConfig.lookaheadColumnId
568
- ? async () => {
569
- const colScope = await client.get(`/projects/${projectId}/pipeline-context`, { columnId: effectiveConfig.lookaheadColumnId });
570
- if (!colScope.prompt_document)
571
- return undefined;
572
- return { title: colScope.prompt_document.title, content: colScope.prompt_document.content };
573
- }
574
- : undefined,
575
- // Pipeline event emission — forward stream/session events to WS for browser delivery
576
- onStreamEvent: deps.emitPipelineEvent
577
- ? (event, context) => {
578
- deps.emitPipelineEvent({
579
- type: 'pipeline:stream',
580
- payload: {
581
- boardId: opts.boardId,
582
- ticketId: context.ticketId,
583
- columnId: context.columnId,
584
- runId: context.runId,
585
- sessionId: pipelineSessionId,
586
- event: event,
587
- },
588
- });
589
- }
590
- : undefined,
591
- onSessionStart: deps.emitPipelineEvent
592
- ? (meta) => {
593
- deps.emitPipelineEvent({
594
- type: 'pipeline:session-start',
595
- payload: {
596
- boardId: opts.boardId,
597
- ticketId,
598
- columnId,
599
- runId: meta.runId,
600
- sessionId: pipelineSessionId,
601
- model: meta.model,
602
- invocationType: 'heavy',
603
- iteration: meta.iteration,
604
- },
605
- });
606
- }
607
- : undefined,
608
- onSessionEnd: (meta) => {
609
- // Cost tracking — always active
610
- costTracker?.record({
611
- ticketId,
612
- columnId,
613
- model: effectiveConfig.model ?? 'default',
614
- tokensIn: meta.tokensIn,
615
- tokensOut: meta.tokensOut,
616
- type: 'heavy',
617
- });
618
- // Pipeline event emission — only when WS client is connected
619
- if (deps.emitPipelineEvent) {
620
- deps.emitPipelineEvent({
621
- type: 'pipeline:session-end',
622
- payload: {
623
- boardId: opts.boardId,
624
- runId: meta.runId,
625
- exitReason: null,
626
- tokensIn: meta.tokensIn,
627
- tokensOut: meta.tokensOut,
628
- toolCallCount: meta.toolCallCount,
629
- durationMs: meta.durationMs,
630
- },
631
- });
632
- }
633
- },
634
- };
635
- // Wire checkpoint callback when setFieldValue is available
636
- if (deps.setFieldValue) {
637
- effectiveConfig.onCheckpoint = async (tid, checkpoint) => {
638
- try {
639
- await deps.setFieldValue(tid, 'loop_checkpoint', checkpoint);
640
- }
641
- catch {
642
- // Fire-and-forget
643
- }
644
- };
645
- }
646
- // Wire stuck detection — invokes a lightweight LLM to classify trajectory mid-run
647
- if (effectiveConfig.stuckDetection) {
648
- effectiveConfig.invokeStuckDetection = async (input) => {
649
- const prompt = composeStuckDetectionPrompt(input);
650
- const { exitCode, output } = await invokeClaudeP(prompt, {
651
- mcpConfigPath,
652
- model: 'haiku',
653
- maxBudgetUsd: 0.01,
654
- tools: '',
655
- includeMcpConfig: false,
656
- });
657
- if (exitCode !== 0) {
658
- throw new Error(`Stuck detection call failed (exit ${String(exitCode)})`);
659
- }
660
- return parseStuckDetectionResponse(output);
661
- };
662
- }
663
- // Wire post-iteration gate runner — runs all gates after each Claude invocation
664
- // resolvedColumnName is already fetched above — no per-iteration API call needed.
665
- effectiveConfig.onPostIterationGates = async (tid, iteration) => {
666
- const gates = resolveGatesForColumn(gateConfig, resolvedColumnName);
667
- if (gates.length === 0) {
668
- // No gates — return a synthetic all-pass snapshot
669
- return gateSnapshotStore.record(tid, iteration, []);
670
- }
671
- const runOpts = {};
672
- if (gateConfig.settings?.cwd)
673
- runOpts.cwd = gateConfig.settings.cwd;
674
- if (gateConfig.settings?.env)
675
- runOpts.env = gateConfig.settings.env;
676
- if (gateConfig.settings?.total_timeout) {
677
- runOpts.totalTimeoutMs = parseTimeout(gateConfig.settings.total_timeout);
678
- }
679
- const results = await runGates(gates, runOpts);
680
- return gateSnapshotStore.record(tid, iteration, results);
681
- };
682
- const loop = new RalphLoop(ticketId, columnId, effectiveConfig, loopDeps);
683
- // Track active loops for graceful shutdown
684
- activeRalphLoops.add(loop);
685
- return loop.run().finally(() => {
686
- activeRalphLoops.delete(loop);
687
- });
688
- },
689
- createComment: (ticketId, body) => client.post(`/projects/${projectId}/tickets/${ticketId}/comments`, { body }),
690
- createSignal: (ticketId, body) => client.post(`/projects/${projectId}/signals`, {
691
- scopeType: 'ticket',
692
- scopeId: ticketId,
693
- content: body,
694
- }),
695
- createColumnSignal: (columnId, body) => client.post(`/projects/${projectId}/signals`, {
696
- scopeType: 'column',
697
- scopeId: columnId,
698
- content: body,
699
- }),
700
- claimTicket: (ticketId) => client.claimTicket(projectId, ticketId),
701
- fetchBlockedTickets: (ticketId) => client.get(`/projects/${projectId}/tickets/${ticketId}/blocked-tickets`),
702
- hasUnresolvedBlockers: async (ticketId) => {
703
- const blockers = await client.get(`/projects/${projectId}/tickets/${ticketId}/unresolved-blockers`);
704
- return blockers.length > 0;
705
- },
706
- dispatchLightCall: async (ticketId, columnId) => {
707
- // Fetch minimal context for light call (parallel)
708
- const [ticketCtx, colScope, boardScope] = await Promise.all([
709
- client.get(`/projects/${projectId}/pipeline-context`, { ticketId }),
710
- client.get(`/projects/${projectId}/pipeline-context`, { columnId }),
711
- client.get(`/projects/${projectId}/pipeline-context`, { boardId: opts.boardId }),
712
- ]);
713
- const lightCtx = {
714
- ticketId,
715
- ticketNumber: ticketCtx.ticket.ticket_number,
716
- ticketTitle: ticketCtx.ticket.title,
717
- ...(ticketCtx.ticket.description ? { ticketDescription: ticketCtx.ticket.description } : {}),
718
- columnName: colScope.column.name,
719
- fieldValues: ticketCtx.field_values,
720
- toolPrefix: colScope.tool_prefix,
721
- projectId,
722
- ...(colScope.transition_rules ? { transitionRules: colScope.transition_rules } : {}),
723
- ...(boardScope.columns && boardScope.columns.length > 0 && {
724
- availableColumns: boardScope.columns.map((col) => ({ id: col.id, name: col.name })),
725
- }),
726
- };
727
- const prompt = composeLightPrompt(lightCtx);
728
- logger.orchestrator(`[${ticketId}] Light call: composing prompt for column "${colScope.column.name}"`);
729
- const { exitCode, output, tokensIn, tokensOut } = await invokeClaudeP(prompt, {
730
- mcpConfigPath,
731
- model: 'haiku',
732
- maxBudgetUsd: 0.01,
733
- maxTurns: 3,
734
- tools: '', // Strip all built-in tools
735
- includeMcpConfig: false, // No MCP tools either
736
- });
737
- costTracker?.record({ ticketId, columnId, model: 'haiku', tokensIn, tokensOut, type: 'light' });
738
- if (exitCode !== 0) {
739
- throw new Error(`Light call exited with code ${exitCode}: ${output.slice(0, 200)}`);
740
- }
741
- const response = parseLightResponse(output);
742
- logger.orchestrator(`[${ticketId}] Light call result: ${response.action} — ${response.reason}`);
743
- return response;
744
- },
745
- // Advisor invocation — recovers from failure exits (stalled/error/max_iterations)
746
- invokeAdvisor: async (input) => {
747
- const prompt = composeAdvisorPrompt(input);
748
- logger.orchestrator(`[${input.ticketId}] Advisor: invoking for ${input.exitReason}`);
749
- const { exitCode, output, tokensIn, tokensOut } = await invokeClaudeP(prompt, {
750
- mcpConfigPath,
751
- model: 'haiku',
752
- maxBudgetUsd: 0.01,
753
- tools: '',
754
- includeMcpConfig: false,
755
- });
756
- costTracker?.record({ ticketId: input.ticketId, columnId: '', model: 'haiku', tokensIn, tokensOut, type: 'advisor' });
757
- if (exitCode !== 0) {
758
- throw new Error(`Advisor call exited with code ${exitCode}: ${output.slice(0, 200)}`);
759
- }
760
- const response = parseAdvisorResponse(output);
761
- logger.orchestrator(`[${input.ticketId}] Advisor: ${response.action} — ${response.reason}`);
762
- return response;
763
- },
764
- // Field value management — used for debt items, checkpoint writes, and escalation metadata
765
- setFieldValue: async (ticketId, fieldName, value) => {
766
- await client.put(`/projects/${projectId}/tickets/${ticketId}/field-values`, {
767
- values: { [fieldName]: value },
768
- });
769
- },
770
- getFieldValues: async (ticketId) => {
771
- const ctx = await client.get(`/projects/${projectId}/pipeline-context`, { ticketId });
772
- return ctx.field_values;
773
- },
774
- // Ticket management for advisor actions (ESCALATE, SPLIT_TICKET)
775
- moveTicketToColumn: async (ticketId, columnId, handoff) => {
776
- await client.patch(`/projects/${projectId}/tickets/${ticketId}/move`, {
777
- column_id: columnId,
778
- handoff,
779
- });
780
- },
781
- createTickets: async (parentTicketId, specs) => {
782
- const ids = [];
783
- for (const spec of specs) {
784
- const result = await client.post(`/projects/${projectId}/tickets`, { title: spec.title, description: spec.description, parent_id: parentTicketId });
785
- ids.push(result.id);
786
- }
787
- return ids;
788
- },
789
- archiveTicket: async (ticketId) => {
790
- await client.post(`/projects/${projectId}/tickets/${ticketId}/archive`, {});
791
- },
792
- // Run memory append — closure captures runMemory by reference (set after initialization)
793
- appendRunMemory: (section, content) => runMemory ? runMemory.append(section, content) : Promise.resolve(),
794
- cleanupWorktree: (name) => cleanupWorktree(name),
795
- // Pipeline event emission — wsClient captured by reference (set later before any loops run)
796
- emitPipelineEvent: (event) => {
797
- wsClient?.send(event);
798
- },
799
- boardId: opts.boardId,
800
- eventEmitter,
801
- costTracker,
802
- gateSnapshotStore,
803
- invokeReplanner: async (state) => {
804
- const prompt = composeReplannerPrompt(state);
805
- const { exitCode, output, tokensIn, tokensOut } = await invokeClaudeP(prompt, {
806
- mcpConfigPath,
807
- model: 'haiku',
808
- maxBudgetUsd: 0.01,
809
- tools: '',
810
- includeMcpConfig: false,
811
- });
812
- costTracker?.record({ ticketId: 'replanner', columnId: 'pipeline', model: 'haiku', tokensIn, tokensOut, type: 'orchestrator' });
813
- if (exitCode !== 0)
814
- throw new Error(`Replanner failed`);
815
- return parseReplannerResponse(output);
816
- },
817
- };
818
- // Create orchestrator
819
- const orchestrator = new PipelineOrchestrator(opts.boardId, projectId, deps);
820
- // Initialize — discovers pipeline columns
821
- console.log(`Initializing pipeline for board ${opts.boardId}...`);
822
- logger.orchestrator('Initializing pipeline');
823
- try {
824
- await orchestrator.initialize();
825
- }
826
- catch (err) {
827
- const message = err instanceof Error ? err.message : String(err);
828
- console.error(`Error: Failed to initialize pipeline: ${message}`);
829
- console.error('Check that the board exists, has pipeline columns configured, and the API is reachable.');
830
- cleanupMcpConfig(mcpConfigPath);
831
- cleanupGateProxyConfigs(pidDir(opts.boardId));
832
- process.exit(1);
833
- }
834
- const columnIds = orchestrator.pipelineColumnIds;
835
- // Initialize run memory if any pipeline column has run_memory enabled
836
- try {
837
- for (const colId of columnIds) {
838
- const colScope = await client.get(`/projects/${projectId}/pipeline-context`, { columnId: colId });
839
- if (colScope.agent_config?.run_memory) {
840
- // Fetch board name for the run memory document title
841
- const boardScope = await client.get(`/projects/${projectId}/pipeline-context`, { boardId: opts.boardId });
842
- // Resolve a space to store the run memory document — use first available space
843
- const spaces = await client.get(`/projects/${projectId}/spaces`);
844
- const spaceId = spaces[0]?.id;
845
- if (!spaceId) {
846
- logger.orchestrator('Run memory: no spaces found in project — skipping run memory');
847
- console.warn('Warning: Run memory requires at least one document space in the project — skipping.');
848
- break;
849
- }
850
- runMemory = new RunMemory(opts.boardId, boardScope.board.name, {
851
- createDocument: async (content, title) => {
852
- const result = await client.post(`/projects/${projectId}/spaces/${spaceId}/documents`, { title, content });
853
- return result.id;
854
- },
855
- getDocument: async (docId) => {
856
- const doc = await client.get(`/projects/${projectId}/documents/${docId}`);
857
- return doc.content;
858
- },
859
- updateDocument: async (docId, content) => {
860
- await client.patch(`/projects/${projectId}/documents/${docId}`, { content });
861
- },
862
- });
863
- await runMemory.initialize();
864
- logger.orchestrator(`Run memory initialized (doc=${runMemory.documentId ?? 'none'})`);
865
- console.log(`Run memory enabled (document: ${runMemory.documentId ?? 'none'})`);
866
- break;
867
- }
868
- }
869
- }
870
- catch (err) {
871
- const msg = err instanceof Error ? err.message : String(err);
872
- logger.orchestrator(`Run memory init failed (non-fatal): ${msg}`);
873
- console.error(`Warning: Run memory initialization failed: ${msg}`);
874
- }
875
- if (columnIds.length === 0) {
876
- console.log('No pipeline columns found (columns need has_prompt=true and type !== "done").');
877
- cleanupMcpConfig(mcpConfigPath);
878
- cleanupGateProxyConfigs(pidDir(opts.boardId));
879
- return;
880
- }
881
- console.log(`Discovered ${String(columnIds.length)} pipeline column(s).`);
882
- logger.orchestrator(`Discovered ${String(columnIds.length)} pipeline columns: ${columnIds.join(', ')}`);
883
- // Dry-run: print config summary and exit
884
- if (opts.dryRun) {
885
- console.log('\n--- Dry Run Configuration ---');
886
- console.log(`Board ID: ${opts.boardId}`);
887
- console.log(`Project ID: ${projectId}`);
888
- console.log(`Pipeline cols: ${String(columnIds.length)}`);
889
- console.log(`Column filter: ${opts.columnFilter ?? '(none)'}`);
890
- console.log(`Max iterations: ${opts.maxIterations !== null ? String(opts.maxIterations) : '(per-column default)'}`);
891
- console.log(`Max budget: ${opts.maxBudget !== null ? `$${String(opts.maxBudget)}` : '(per-column default)'}`);
892
- console.log(`Model: ${opts.model ?? '(per-column default)'}`);
893
- console.log(`Concurrency: ${opts.concurrency !== null ? String(opts.concurrency) : '(per-column default)'}`);
894
- console.log(`Mode: ${opts.once ? 'once' : 'persistent'}`);
895
- console.log(`Log retention: ${String(opts.logRetention)} days`);
896
- console.log(`MCP config: ${mcpConfigPath}`);
897
- console.log('\n[Dry run -- no agents started]');
898
- cleanupMcpConfig(mcpConfigPath);
899
- cleanupGateProxyConfigs(pidDir(opts.boardId));
900
- return;
901
- }
902
- // --- Live mode ---
903
- // Set up graceful shutdown
904
- let shutdownInProgress = false;
905
- const shutdown = async (signal) => {
906
- if (shutdownInProgress)
907
- return;
908
- shutdownInProgress = true;
909
- console.log(`\nReceived ${signal}. Shutting down gracefully...`);
910
- logger.orchestrator(`Shutdown initiated (${signal})`);
911
- if (rescanTimer)
912
- clearInterval(rescanTimer);
913
- eventQueue?.stop();
914
- // Notify browser clients that the pipeline is stopping (before closing WS)
915
- wsClient?.send({ type: 'pipeline:stopped', payload: { boardId: opts.boardId } });
916
- // Give the WS a moment to flush the stop notification
917
- await new Promise(r => setTimeout(r, 200));
918
- await eventEmitter.close();
919
- wsClient?.stop();
920
- for (const loop of activeRalphLoops) {
921
- loop.stop();
922
- }
923
- killAllChildProcesses();
924
- // Wait for children to actually exit (up to 5 seconds)
925
- const deadline = Date.now() + 5000;
926
- while (activeChildProcesses.size > 0 && Date.now() < deadline) {
927
- await new Promise(r => setTimeout(r, 200));
928
- }
929
- if (activeChildProcesses.size > 0) {
930
- console.error(`Warning: ${String(activeChildProcesses.size)} child process(es) did not exit. Sending SIGKILL...`);
931
- for (const child of activeChildProcesses) {
932
- try {
933
- if (child.pid) {
934
- try {
935
- process.kill(-child.pid, 'SIGKILL');
936
- }
937
- catch {
938
- try {
939
- child.kill('SIGKILL');
940
- }
941
- catch { /* already dead */ }
942
- }
943
- }
944
- else {
945
- try {
946
- child.kill('SIGKILL');
947
- }
948
- catch { /* already dead */ }
949
- }
950
- }
951
- catch {
952
- // Process already exited
953
- }
954
- }
955
- }
956
- // Print cost report
957
- if (costTracker) {
958
- console.log('\n--- Pipeline Cost Report ---');
959
- console.log(costTracker.generateReport(gateConfig.settings?.pricing));
960
- }
961
- cleanupMcpConfig(mcpConfigPath);
962
- cleanupGateProxyConfigs(pidDir(opts.boardId));
963
- killReaper(reaperPidPath);
964
- removePidFile(opts.boardId);
965
- removeChildManifest(opts.boardId);
966
- logger.orchestrator('Shutdown complete');
967
- console.log('Pipeline stopped.');
968
- process.exit(0);
969
- };
970
- process.on('SIGTERM', () => {
971
- shutdown('SIGTERM').catch((err) => {
972
- console.error('Error during shutdown:', err);
973
- process.exit(1);
974
- });
975
- });
976
- process.on('SIGINT', () => {
977
- shutdown('SIGINT').catch((err) => {
978
- console.error('Error during shutdown:', err);
979
- process.exit(1);
980
- });
981
- });
982
- process.on('SIGHUP', () => {
983
- shutdown('SIGHUP').catch((err) => {
984
- console.error('Error during shutdown:', err);
985
- process.exit(1);
986
- });
987
- });
988
- // Clean up orphaned processes from a previous run before starting
989
- cleanupOrphanedProcesses(opts.boardId);
990
- // Write PID file
991
- writePidFile(opts.boardId);
992
- logger.orchestrator(`PID file written: ${String(process.pid)}`);
993
- // Spawn watchdog reaper — kills children if orchestrator dies unexpectedly
994
- const reaperPidPath = join(pidDir(opts.boardId), 'reaper.pid');
995
- const reaperProcess = spawnReaper({
996
- orchestratorPid: process.pid,
997
- manifestPath: childManifestPath(opts.boardId),
998
- pidFilePath: pidFilePath(opts.boardId),
999
- reaperPidPath,
1000
- mcpConfigPath,
1001
- pipelineDir: pidDir(opts.boardId),
1002
- });
1003
- logger.orchestrator(`Watchdog reaper spawned (PID ${String(reaperProcess.pid ?? 'unknown')})`);
1004
- // Set up event queue and WS client
1005
- let eventQueue = null;
1006
- let wsClient = null;
1007
- let rescanTimer = null;
1008
- const onEventError = (event, err) => {
1009
- const msg = err instanceof Error ? err.message : String(err);
1010
- logger.orchestrator(`Event handler error for ${event.type} ticket=${event.ticketId}: ${msg}`);
1011
- console.error(`Event handler error: ${msg}`);
1012
- };
1013
- eventQueue = new EventQueue((event) => orchestrator.handleEvent(event), {
1014
- drainRateMs: 100,
1015
- onError: onEventError,
1016
- });
1017
- wsClient = new PipelineWsClient(client, {
1018
- boardId: opts.boardId,
1019
- projectId,
1020
- onEvent: (wsEvent) => {
1021
- // Map WS events to pipeline events
1022
- const payload = wsEvent.payload;
1023
- // ticket:moved and ticket:created nest the id inside payload.ticket
1024
- // ticket:deleted and ticket:archived have payload.ticketId directly
1025
- const ticket = payload['ticket'];
1026
- const ticketId = (typeof payload['ticketId'] === 'string' ? payload['ticketId'] : null) ??
1027
- (ticket && typeof ticket['id'] === 'string' ? ticket['id'] : null);
1028
- // ticket:created has payload.columnId; ticket:moved has the new column in ticket.column_id
1029
- const columnId = (typeof payload['columnId'] === 'string' ? payload['columnId'] : null) ??
1030
- (ticket && typeof ticket['column_id'] === 'string' ? ticket['column_id'] : null);
1031
- // Handle firing constraint changes — refresh constraint caches
1032
- if (wsEvent.type === 'firing_constraint:created' ||
1033
- wsEvent.type === 'firing_constraint:updated' ||
1034
- wsEvent.type === 'firing_constraint:deleted') {
1035
- logger.orchestrator(`WS event: ${wsEvent.type} — refreshing constraint caches`);
1036
- void orchestrator.refreshConstraints().catch((err) => {
1037
- const msg = err instanceof Error ? err.message : String(err);
1038
- logger.orchestrator(`Constraint cache refresh failed: ${msg}`);
1039
- });
1040
- return;
1041
- }
1042
- if (!ticketId)
1043
- return;
1044
- const eventType = wsEvent.type;
1045
- if (eventType === 'ticket:created' ||
1046
- eventType === 'ticket:moved' ||
1047
- eventType === 'ticket:updated' ||
1048
- eventType === 'ticket:archived' ||
1049
- eventType === 'ticket:deleted') {
1050
- const pipelineEvent = { type: eventType, ticketId, columnId };
1051
- eventQueue.push(pipelineEvent);
1052
- logger.orchestrator(`WS event: ${eventType} ticket=${ticketId} column=${columnId ?? 'null'}`);
1053
- }
1054
- },
1055
- onConnect: () => {
1056
- console.log('WebSocket connected. Listening for board events...');
1057
- logger.orchestrator('WebSocket connected');
1058
- },
1059
- onDisconnect: () => {
1060
- logger.orchestrator('WebSocket disconnected');
1061
- },
1062
- });
1063
- // Try initial WS connection (non-fatal)
1064
- try {
1065
- await wsClient.connect();
1066
- }
1067
- catch (err) {
1068
- const message = err instanceof Error ? err.message : String(err);
1069
- console.error(`Warning: WebSocket connection failed: ${message}`);
1070
- console.error('Pipeline will poll for changes and retry WS periodically.');
1071
- logger.orchestrator(`WebSocket connection failed: ${message}`);
1072
- }
1073
- // Start event queue
1074
- eventQueue.start();
1075
- // Initial scan and spawn
1076
- console.log('Scanning pipeline columns for tickets...');
1077
- logger.orchestrator('Starting scan and spawn');
1078
- try {
1079
- await orchestrator.scanAndSpawn();
1080
- logger.orchestrator(`Scan complete. Active loops: ${String(orchestrator.activeLoopCount)}`);
1081
- console.log(`Scan complete. Active loops: ${String(orchestrator.activeLoopCount)}`);
1082
- }
1083
- catch (scanErr) {
1084
- const scanMsg = scanErr instanceof Error ? scanErr.message : String(scanErr);
1085
- logger.orchestrator(`Scan error: ${scanMsg}`);
1086
- console.error(`Scan error: ${scanMsg}`);
1087
- }
1088
- if (opts.once) {
1089
- // Wait for all active loops to complete, then exit
1090
- console.log('Running in --once mode. Waiting for all loops to complete...');
1091
- logger.orchestrator('Once mode: waiting for loops to complete');
1092
- // Poll until no active loops or queued work remain
1093
- await waitForAllLoops(orchestrator);
1094
- // Print cost report
1095
- if (costTracker) {
1096
- console.log('\n--- Pipeline Cost Report ---');
1097
- console.log(costTracker.generateReport(gateConfig.settings?.pricing));
1098
- }
1099
- // Notify browser clients that the pipeline has finished
1100
- wsClient.send({ type: 'pipeline:stopped', payload: { boardId: opts.boardId } });
1101
- await new Promise(r => setTimeout(r, 200));
1102
- // Cleanup
1103
- await eventEmitter.close();
1104
- wsClient.stop();
1105
- eventQueue.stop();
1106
- cleanupMcpConfig(mcpConfigPath);
1107
- cleanupGateProxyConfigs(pidDir(opts.boardId));
1108
- killReaper(reaperPidPath);
1109
- removePidFile(opts.boardId);
1110
- removeChildManifest(opts.boardId);
1111
- logger.orchestrator('Once mode complete. Exiting.');
1112
- console.log('All loops complete. Pipeline exiting.');
1113
- }
1114
- else {
1115
- // Persistent mode — unified poll loop: rescan columns and try to
1116
- // reconnect WS if disconnected. This replaces the separate polling
1117
- // fallback and rescan timer with a single 30s heartbeat.
1118
- let consecutiveScanFailures = 0;
1119
- rescanTimer = setInterval(() => {
1120
- void (async () => {
1121
- try {
1122
- await orchestrator.scanAndSpawn();
1123
- if (consecutiveScanFailures > 0) {
1124
- console.log(`Rescan recovered after ${String(consecutiveScanFailures)} failure(s)`);
1125
- }
1126
- consecutiveScanFailures = 0;
1127
- }
1128
- catch (err) {
1129
- consecutiveScanFailures++;
1130
- const msg = err instanceof Error ? err.message : String(err);
1131
- logger.orchestrator(`Periodic rescan error (${String(consecutiveScanFailures)}x): ${msg}`);
1132
- if (consecutiveScanFailures === 1 || consecutiveScanFailures % 10 === 0) {
1133
- console.error(`Warning: Rescan has failed ${String(consecutiveScanFailures)} consecutive time(s): ${msg}`);
1134
- }
1135
- }
1136
- if (wsClient && !wsClient.connected) {
1137
- const reconnected = await wsClient.tryReconnect();
1138
- if (reconnected) {
1139
- logger.orchestrator('WS reconnected via poll cycle');
1140
- }
1141
- }
1142
- })();
1143
- }, 30_000);
1144
- console.log(`Pipeline running (PID ${String(process.pid)}). Use 'kantban pipeline stop ${opts.boardId}' to stop.`);
1145
- logger.orchestrator('Pipeline running in persistent mode');
1146
- }
1147
- }
1148
- // ---------------------------------------------------------------------------
1149
- // Active loop tracking for graceful shutdown
1150
- // ---------------------------------------------------------------------------
1151
- const activeRalphLoops = new Set();
1152
- async function waitForAllLoops(orchestrator, timeoutMs = 4 * 60 * 60 * 1000) {
1153
- let consecutiveIdle = 0;
1154
- const startTime = Date.now();
1155
- let lastProgressLog = 0;
1156
- while (consecutiveIdle < 2) {
1157
- if (Date.now() - startTime > timeoutMs) {
1158
- console.error(`Warning: waitForAllLoops timed out after ${String(Math.round(timeoutMs / 60_000))}m. ` +
1159
- `Active: ${String(activeRalphLoops.size)}, orchestrator: ${String(orchestrator.activeLoopCount)}, ` +
1160
- `queued: ${String(orchestrator.hasQueuedWork)}`);
1161
- return;
1162
- }
1163
- await new Promise((resolve) => setTimeout(resolve, 1000));
1164
- if (activeRalphLoops.size === 0 && orchestrator.activeLoopCount === 0 && !orchestrator.hasActiveQueuedWork) {
1165
- consecutiveIdle++;
1166
- }
1167
- else {
1168
- consecutiveIdle = 0;
1169
- const now = Date.now();
1170
- if (now - lastProgressLog > 30_000) {
1171
- lastProgressLog = now;
1172
- console.log(`Waiting... Active loops: ${String(activeRalphLoops.size)}, ` +
1173
- `queued work: ${String(orchestrator.hasQueuedWork)}`);
1174
- }
1175
- }
1176
- }
1177
- }
1178
- // ---------------------------------------------------------------------------
1179
- // stopPipeline — sends SIGTERM to a running pipeline
1180
- // ---------------------------------------------------------------------------
1181
- export async function stopPipeline(args) {
1182
- const boardId = args[0];
1183
- if (!boardId) {
1184
- console.error('Usage: kantban pipeline stop <board-id>');
1185
- process.exit(1);
1186
- }
1187
- const pidFile = pidFilePath(boardId);
1188
- let pid;
1189
- try {
1190
- const content = readFileSync(pidFile, 'utf8').trim();
1191
- pid = Number(content);
1192
- if (isNaN(pid) || pid <= 0) {
1193
- throw new Error('Invalid PID');
1194
- }
1195
- }
1196
- catch {
1197
- console.error(`No running pipeline found for board ${boardId}.`);
1198
- console.error(`Expected PID file at: ${pidFile}`);
1199
- process.exit(1);
1200
- return; // unreachable, but helps TS
1201
- }
1202
- try {
1203
- // Kill the entire process group to take down child claude -p processes too
1204
- try {
1205
- process.kill(-pid, 'SIGTERM');
1206
- console.log(`Sent SIGTERM to pipeline process group (pgid ${String(pid)}) for board ${boardId}.`);
1207
- }
1208
- catch {
1209
- // Process group kill failed (not a group leader) — fall back to direct kill
1210
- process.kill(pid, 'SIGTERM');
1211
- console.log(`Sent SIGTERM to pipeline process ${String(pid)} for board ${boardId}.`);
1212
- }
1213
- }
1214
- catch (err) {
1215
- const message = err instanceof Error ? err.message : String(err);
1216
- console.error(`Failed to stop pipeline (PID ${String(pid)}): ${message}`);
1217
- console.error('The process may have already exited. Removing stale PID file.');
1218
- removePidFile(boardId);
1219
- process.exit(1);
1220
- }
1221
- }
1222
- //# sourceMappingURL=pipeline.js.map