kantban-cli 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts +3 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +54 -0
- package/dist/client.js.map +1 -1
- package/dist/commands/cron.js +2 -2
- package/dist/commands/cron.js.map +1 -1
- package/dist/commands/pipeline-init.d.ts +2 -0
- package/dist/commands/pipeline-init.d.ts.map +1 -0
- package/dist/commands/pipeline-init.js +100 -0
- package/dist/commands/pipeline-init.js.map +1 -0
- package/dist/commands/pipeline.d.ts.map +1 -1
- package/dist/commands/pipeline.js +637 -44
- package/dist/commands/pipeline.js.map +1 -1
- package/dist/lib/advisor.d.ts +108 -0
- package/dist/lib/advisor.d.ts.map +1 -0
- package/dist/lib/advisor.js +139 -0
- package/dist/lib/advisor.js.map +1 -0
- package/dist/lib/checkpoint.d.ts +15 -0
- package/dist/lib/checkpoint.d.ts.map +1 -0
- package/dist/lib/checkpoint.js +49 -0
- package/dist/lib/checkpoint.js.map +1 -0
- package/dist/lib/constraint-evaluator.d.ts +40 -0
- package/dist/lib/constraint-evaluator.d.ts.map +1 -0
- package/dist/lib/constraint-evaluator.js +189 -0
- package/dist/lib/constraint-evaluator.js.map +1 -0
- package/dist/lib/cost-tracker.d.ts +46 -0
- package/dist/lib/cost-tracker.d.ts.map +1 -0
- package/dist/lib/cost-tracker.js +120 -0
- package/dist/lib/cost-tracker.js.map +1 -0
- package/dist/lib/evaluator.d.ts +17 -0
- package/dist/lib/evaluator.d.ts.map +1 -0
- package/dist/lib/evaluator.js +71 -0
- package/dist/lib/evaluator.js.map +1 -0
- package/dist/lib/event-emitter.d.ts +28 -0
- package/dist/lib/event-emitter.d.ts.map +1 -0
- package/dist/lib/event-emitter.js +100 -0
- package/dist/lib/event-emitter.js.map +1 -0
- package/dist/lib/gate-config.d.ts +7 -0
- package/dist/lib/gate-config.d.ts.map +1 -0
- package/dist/lib/gate-config.js +68 -0
- package/dist/lib/gate-config.js.map +1 -0
- package/dist/lib/gate-proxy-server.d.ts +16 -0
- package/dist/lib/gate-proxy-server.d.ts.map +1 -0
- package/dist/lib/gate-proxy-server.js +385 -0
- package/dist/lib/gate-proxy-server.js.map +1 -0
- package/dist/lib/gate-proxy.d.ts +46 -0
- package/dist/lib/gate-proxy.d.ts.map +1 -0
- package/dist/lib/gate-proxy.js +104 -0
- package/dist/lib/gate-proxy.js.map +1 -0
- package/dist/lib/gate-runner.d.ts +13 -0
- package/dist/lib/gate-runner.d.ts.map +1 -0
- package/dist/lib/gate-runner.js +104 -0
- package/dist/lib/gate-runner.js.map +1 -0
- package/dist/lib/gate-snapshot.d.ts +12 -0
- package/dist/lib/gate-snapshot.d.ts.map +1 -0
- package/dist/lib/gate-snapshot.js +49 -0
- package/dist/lib/gate-snapshot.js.map +1 -0
- package/dist/lib/light-call.d.ts +37 -0
- package/dist/lib/light-call.d.ts.map +1 -0
- package/dist/lib/light-call.js +62 -0
- package/dist/lib/light-call.js.map +1 -0
- package/dist/lib/logger.d.ts +2 -0
- package/dist/lib/logger.d.ts.map +1 -1
- package/dist/lib/logger.js +55 -9
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/mcp-config.d.ts +15 -0
- package/dist/lib/mcp-config.d.ts.map +1 -1
- package/dist/lib/mcp-config.js +70 -6
- package/dist/lib/mcp-config.js.map +1 -1
- package/dist/lib/orchestrator.d.ts +220 -6
- package/dist/lib/orchestrator.d.ts.map +1 -1
- package/dist/lib/orchestrator.js +1265 -58
- package/dist/lib/orchestrator.js.map +1 -1
- package/dist/lib/parse-utils.d.ts +6 -0
- package/dist/lib/parse-utils.d.ts.map +1 -0
- package/dist/lib/parse-utils.js +64 -0
- package/dist/lib/parse-utils.js.map +1 -0
- package/dist/lib/prompt-composer.d.ts +30 -1
- package/dist/lib/prompt-composer.d.ts.map +1 -1
- package/dist/lib/prompt-composer.js +162 -27
- package/dist/lib/prompt-composer.js.map +1 -1
- package/dist/lib/ralph-loop.d.ts +78 -4
- package/dist/lib/ralph-loop.d.ts.map +1 -1
- package/dist/lib/ralph-loop.js +249 -40
- package/dist/lib/ralph-loop.js.map +1 -1
- package/dist/lib/reaper.d.ts +14 -0
- package/dist/lib/reaper.d.ts.map +1 -0
- package/dist/lib/reaper.js +114 -0
- package/dist/lib/reaper.js.map +1 -0
- package/dist/lib/replanner.d.ts +49 -0
- package/dist/lib/replanner.d.ts.map +1 -0
- package/dist/lib/replanner.js +61 -0
- package/dist/lib/replanner.js.map +1 -0
- package/dist/lib/run-memory.d.ts +37 -0
- package/dist/lib/run-memory.d.ts.map +1 -0
- package/dist/lib/run-memory.js +115 -0
- package/dist/lib/run-memory.js.map +1 -0
- package/dist/lib/stream-parser.d.ts +20 -0
- package/dist/lib/stream-parser.d.ts.map +1 -0
- package/dist/lib/stream-parser.js +65 -0
- package/dist/lib/stream-parser.js.map +1 -0
- package/dist/lib/stuck-detector.d.ts +47 -0
- package/dist/lib/stuck-detector.d.ts.map +1 -0
- package/dist/lib/stuck-detector.js +105 -0
- package/dist/lib/stuck-detector.js.map +1 -0
- package/dist/lib/tool-profiles.d.ts +19 -0
- package/dist/lib/tool-profiles.d.ts.map +1 -0
- package/dist/lib/tool-profiles.js +22 -0
- package/dist/lib/tool-profiles.js.map +1 -0
- package/dist/lib/worktree.d.ts +12 -0
- package/dist/lib/worktree.d.ts.map +1 -0
- package/dist/lib/worktree.js +29 -0
- package/dist/lib/worktree.js.map +1 -0
- package/dist/lib/ws-client.d.ts +1 -1
- package/dist/lib/ws-client.d.ts.map +1 -1
- package/dist/lib/ws-client.js +5 -2
- package/dist/lib/ws-client.js.map +1 -1
- package/package.json +3 -1
|
@@ -1,13 +1,26 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { mkdirSync, writeFileSync, readFileSync, unlinkSync, existsSync } from 'node:fs';
|
|
1
|
+
import { spawn, execSync } from 'node:child_process';
|
|
2
|
+
import { mkdirSync, writeFileSync, readFileSync, unlinkSync, existsSync, appendFileSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { PipelineOrchestrator } from '../lib/orchestrator.js';
|
|
6
6
|
import { RalphLoop } from '../lib/ralph-loop.js';
|
|
7
|
+
import { RunMemory } from '../lib/run-memory.js';
|
|
7
8
|
import { EventQueue } from '../lib/event-queue.js';
|
|
8
9
|
import { PipelineWsClient } from '../lib/ws-client.js';
|
|
9
|
-
import { generateMcpConfig, cleanupMcpConfig } from '../lib/mcp-config.js';
|
|
10
|
+
import { generateMcpConfig, generateGateProxyMcpConfig, cleanupMcpConfig, cleanupGateProxyConfigs } from '../lib/mcp-config.js';
|
|
10
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';
|
|
11
24
|
function parseArgs(args) {
|
|
12
25
|
const positional = [];
|
|
13
26
|
let once = false;
|
|
@@ -75,6 +88,11 @@ function parseArgs(args) {
|
|
|
75
88
|
}
|
|
76
89
|
}
|
|
77
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
|
+
}
|
|
78
96
|
if (!boardId) {
|
|
79
97
|
console.error(`Usage: kantban pipeline <board-id> [flags]
|
|
80
98
|
|
|
@@ -103,8 +121,8 @@ function pidFilePath(boardId) {
|
|
|
103
121
|
}
|
|
104
122
|
function writePidFile(boardId) {
|
|
105
123
|
const dir = pidDir(boardId);
|
|
106
|
-
mkdirSync(dir, { recursive: true });
|
|
107
|
-
writeFileSync(pidFilePath(boardId), String(process.pid));
|
|
124
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
125
|
+
writeFileSync(pidFilePath(boardId), String(process.pid), { mode: 0o600 });
|
|
108
126
|
}
|
|
109
127
|
function removePidFile(boardId) {
|
|
110
128
|
try {
|
|
@@ -114,6 +132,51 @@ function removePidFile(boardId) {
|
|
|
114
132
|
/* already removed */
|
|
115
133
|
}
|
|
116
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
|
+
}
|
|
117
180
|
/**
|
|
118
181
|
* Kill any orphaned pipeline processes from a previous run.
|
|
119
182
|
* Reads the stale PID file, kills that process tree, then removes the file.
|
|
@@ -141,9 +204,46 @@ function cleanupOrphanedProcesses(boardId) {
|
|
|
141
204
|
}
|
|
142
205
|
removePidFile(boardId);
|
|
143
206
|
}
|
|
144
|
-
//
|
|
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
|
|
145
244
|
try {
|
|
146
|
-
const
|
|
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();
|
|
147
247
|
if (out) {
|
|
148
248
|
const pids = out.split('\n').filter(Boolean);
|
|
149
249
|
for (const pid of pids) {
|
|
@@ -169,37 +269,126 @@ function cleanupOrphanedProcesses(boardId) {
|
|
|
169
269
|
// the group (e.g. on crash) takes all children down with it.
|
|
170
270
|
// ---------------------------------------------------------------------------
|
|
171
271
|
const activeChildProcesses = new Set();
|
|
272
|
+
let currentBoardId = '';
|
|
172
273
|
const CLAUDE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour max per invocation
|
|
173
274
|
async function invokeClaudeP(prompt, options) {
|
|
174
|
-
const args = ['-p', prompt, '--
|
|
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
|
+
}
|
|
175
280
|
if (options.model)
|
|
176
281
|
args.push('--model', options.model);
|
|
177
|
-
if (options.
|
|
178
|
-
args.push('--max-turns', String(
|
|
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
|
+
}
|
|
179
288
|
if (options.worktree)
|
|
180
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
|
+
}
|
|
181
300
|
return new Promise((resolve) => {
|
|
182
|
-
const child =
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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;
|
|
197
322
|
}
|
|
198
|
-
|
|
199
|
-
|
|
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');
|
|
200
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
|
+
});
|
|
201
391
|
});
|
|
202
|
-
activeChildProcesses.add(child);
|
|
203
392
|
});
|
|
204
393
|
}
|
|
205
394
|
function killAllChildProcesses() {
|
|
@@ -246,25 +435,31 @@ Before proceeding:
|
|
|
246
435
|
Press Ctrl+C to cancel, or re-run with --yes to skip this warning.
|
|
247
436
|
`);
|
|
248
437
|
}
|
|
249
|
-
|
|
438
|
+
function waitForConfirmation() {
|
|
250
439
|
return new Promise((resolve) => {
|
|
440
|
+
// Non-TTY (piped input) — read from stdin instead of rejecting
|
|
251
441
|
process.stdout.write('Continue? [y/N] ');
|
|
252
442
|
process.stdin.setEncoding('utf8');
|
|
253
443
|
process.stdin.once('data', (data) => {
|
|
254
444
|
const answer = data.trim().toLowerCase();
|
|
255
445
|
resolve(answer === 'y' || answer === 'yes');
|
|
256
446
|
});
|
|
257
|
-
// If stdin
|
|
258
|
-
|
|
259
|
-
resolve(false);
|
|
260
|
-
}
|
|
447
|
+
// If stdin ends without data (empty pipe), default to no
|
|
448
|
+
process.stdin.once('end', () => resolve(false));
|
|
261
449
|
});
|
|
262
450
|
}
|
|
263
451
|
// ---------------------------------------------------------------------------
|
|
264
452
|
// runPipeline — main entry point
|
|
265
453
|
// ---------------------------------------------------------------------------
|
|
266
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
|
+
}
|
|
267
461
|
const opts = parseArgs(args);
|
|
462
|
+
currentBoardId = opts.boardId;
|
|
268
463
|
// Safety confirmation
|
|
269
464
|
if (!opts.yes && !opts.dryRun) {
|
|
270
465
|
printSafetyWarning();
|
|
@@ -274,6 +469,30 @@ export async function runPipeline(client, args) {
|
|
|
274
469
|
return;
|
|
275
470
|
}
|
|
276
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();
|
|
277
496
|
// Resolve project ID
|
|
278
497
|
let projectId = process.env['KANTBAN_PROJECT_ID'] ?? '';
|
|
279
498
|
if (!projectId) {
|
|
@@ -294,28 +513,171 @@ export async function runPipeline(client, args) {
|
|
|
294
513
|
const logBaseDir = join(homedir(), '.kantban', 'pipelines');
|
|
295
514
|
const logger = new PipelineLogger(logBaseDir, opts.boardId);
|
|
296
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);
|
|
297
521
|
// Wire up OrchestratorDeps
|
|
298
522
|
const deps = {
|
|
299
523
|
fetchBoardScope: (boardId) => client.get(`/projects/${projectId}/pipeline-context`, { boardId }),
|
|
300
524
|
fetchColumnScope: (columnId) => client.get(`/projects/${projectId}/pipeline-context`, { columnId }),
|
|
301
|
-
startLoop: (ticketId, columnId, config) => {
|
|
525
|
+
startLoop: async (ticketId, columnId, config) => {
|
|
302
526
|
// Apply CLI overrides
|
|
303
527
|
const effectiveConfig = {
|
|
304
528
|
...config,
|
|
305
529
|
...(opts.maxIterations !== null && { maxIterations: opts.maxIterations }),
|
|
306
530
|
...(opts.maxBudget !== null && { maxBudgetUsd: opts.maxBudget }),
|
|
307
|
-
...(opts.model !== null && {
|
|
531
|
+
...(opts.model !== null && { model: opts.model }),
|
|
308
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;
|
|
309
545
|
const loopDeps = {
|
|
310
546
|
fetchTicketContext: (tid) => client.get(`/projects/${projectId}/pipeline-context`, { ticketId: tid }),
|
|
311
547
|
fetchColumnContext: (cid) => client.get(`/projects/${projectId}/pipeline-context`, { columnId: cid }),
|
|
312
548
|
fetchFingerprint: (tid) => client.getFingerprint(projectId, tid),
|
|
313
549
|
invokeClaudeP,
|
|
314
|
-
mcpConfigPath,
|
|
550
|
+
mcpConfigPath: effectiveMcpConfigPath,
|
|
315
551
|
projectId,
|
|
316
552
|
log: (msg) => {
|
|
317
553
|
logger.orchestrator(`[${ticketId}] ${msg}`);
|
|
318
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);
|
|
319
681
|
};
|
|
320
682
|
const loop = new RalphLoop(ticketId, columnId, effectiveConfig, loopDeps);
|
|
321
683
|
// Track active loops for graceful shutdown
|
|
@@ -325,13 +687,133 @@ export async function runPipeline(client, args) {
|
|
|
325
687
|
});
|
|
326
688
|
},
|
|
327
689
|
createComment: (ticketId, body) => client.post(`/projects/${projectId}/tickets/${ticketId}/comments`, { body }),
|
|
328
|
-
createSignal: (ticketId, body) => client.post(`/projects/${projectId}/
|
|
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
|
+
}),
|
|
329
700
|
claimTicket: (ticketId) => client.claimTicket(projectId, ticketId),
|
|
330
701
|
fetchBlockedTickets: (ticketId) => client.get(`/projects/${projectId}/tickets/${ticketId}/blocked-tickets`),
|
|
331
702
|
hasUnresolvedBlockers: async (ticketId) => {
|
|
332
703
|
const blockers = await client.get(`/projects/${projectId}/tickets/${ticketId}/unresolved-blockers`);
|
|
333
704
|
return blockers.length > 0;
|
|
334
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
|
+
},
|
|
335
817
|
};
|
|
336
818
|
// Create orchestrator
|
|
337
819
|
const orchestrator = new PipelineOrchestrator(opts.boardId, projectId, deps);
|
|
@@ -346,12 +828,54 @@ export async function runPipeline(client, args) {
|
|
|
346
828
|
console.error(`Error: Failed to initialize pipeline: ${message}`);
|
|
347
829
|
console.error('Check that the board exists, has pipeline columns configured, and the API is reachable.');
|
|
348
830
|
cleanupMcpConfig(mcpConfigPath);
|
|
831
|
+
cleanupGateProxyConfigs(pidDir(opts.boardId));
|
|
349
832
|
process.exit(1);
|
|
350
833
|
}
|
|
351
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
|
+
}
|
|
352
875
|
if (columnIds.length === 0) {
|
|
353
876
|
console.log('No pipeline columns found (columns need has_prompt=true and type !== "done").');
|
|
354
877
|
cleanupMcpConfig(mcpConfigPath);
|
|
878
|
+
cleanupGateProxyConfigs(pidDir(opts.boardId));
|
|
355
879
|
return;
|
|
356
880
|
}
|
|
357
881
|
console.log(`Discovered ${String(columnIds.length)} pipeline column(s).`);
|
|
@@ -372,6 +896,7 @@ export async function runPipeline(client, args) {
|
|
|
372
896
|
console.log(`MCP config: ${mcpConfigPath}`);
|
|
373
897
|
console.log('\n[Dry run -- no agents started]');
|
|
374
898
|
cleanupMcpConfig(mcpConfigPath);
|
|
899
|
+
cleanupGateProxyConfigs(pidDir(opts.boardId));
|
|
375
900
|
return;
|
|
376
901
|
}
|
|
377
902
|
// --- Live mode ---
|
|
@@ -383,10 +908,15 @@ export async function runPipeline(client, args) {
|
|
|
383
908
|
shutdownInProgress = true;
|
|
384
909
|
console.log(`\nReceived ${signal}. Shutting down gracefully...`);
|
|
385
910
|
logger.orchestrator(`Shutdown initiated (${signal})`);
|
|
386
|
-
wsClient?.stop();
|
|
387
911
|
if (rescanTimer)
|
|
388
912
|
clearInterval(rescanTimer);
|
|
389
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();
|
|
390
920
|
for (const loop of activeRalphLoops) {
|
|
391
921
|
loop.stop();
|
|
392
922
|
}
|
|
@@ -400,14 +930,39 @@ export async function runPipeline(client, args) {
|
|
|
400
930
|
console.error(`Warning: ${String(activeChildProcesses.size)} child process(es) did not exit. Sending SIGKILL...`);
|
|
401
931
|
for (const child of activeChildProcesses) {
|
|
402
932
|
try {
|
|
403
|
-
if (child.pid)
|
|
404
|
-
|
|
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
|
|
405
953
|
}
|
|
406
|
-
catch { /* ignore */ }
|
|
407
954
|
}
|
|
408
955
|
}
|
|
956
|
+
// Print cost report
|
|
957
|
+
if (costTracker) {
|
|
958
|
+
console.log('\n--- Pipeline Cost Report ---');
|
|
959
|
+
console.log(costTracker.generateReport(gateConfig.settings?.pricing));
|
|
960
|
+
}
|
|
409
961
|
cleanupMcpConfig(mcpConfigPath);
|
|
962
|
+
cleanupGateProxyConfigs(pidDir(opts.boardId));
|
|
963
|
+
killReaper(reaperPidPath);
|
|
410
964
|
removePidFile(opts.boardId);
|
|
965
|
+
removeChildManifest(opts.boardId);
|
|
411
966
|
logger.orchestrator('Shutdown complete');
|
|
412
967
|
console.log('Pipeline stopped.');
|
|
413
968
|
process.exit(0);
|
|
@@ -424,11 +979,28 @@ export async function runPipeline(client, args) {
|
|
|
424
979
|
process.exit(1);
|
|
425
980
|
});
|
|
426
981
|
});
|
|
982
|
+
process.on('SIGHUP', () => {
|
|
983
|
+
shutdown('SIGHUP').catch((err) => {
|
|
984
|
+
console.error('Error during shutdown:', err);
|
|
985
|
+
process.exit(1);
|
|
986
|
+
});
|
|
987
|
+
});
|
|
427
988
|
// Clean up orphaned processes from a previous run before starting
|
|
428
989
|
cleanupOrphanedProcesses(opts.boardId);
|
|
429
990
|
// Write PID file
|
|
430
991
|
writePidFile(opts.boardId);
|
|
431
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')})`);
|
|
432
1004
|
// Set up event queue and WS client
|
|
433
1005
|
let eventQueue = null;
|
|
434
1006
|
let wsClient = null;
|
|
@@ -446,8 +1018,6 @@ export async function runPipeline(client, args) {
|
|
|
446
1018
|
boardId: opts.boardId,
|
|
447
1019
|
projectId,
|
|
448
1020
|
onEvent: (wsEvent) => {
|
|
449
|
-
// Log ALL WS events for debugging
|
|
450
|
-
logger.orchestrator(`WS raw event: ${wsEvent.type} payload=${JSON.stringify(wsEvent.payload)}`);
|
|
451
1021
|
// Map WS events to pipeline events
|
|
452
1022
|
const payload = wsEvent.payload;
|
|
453
1023
|
// ticket:moved and ticket:created nest the id inside payload.ticket
|
|
@@ -458,6 +1028,17 @@ export async function runPipeline(client, args) {
|
|
|
458
1028
|
// ticket:created has payload.columnId; ticket:moved has the new column in ticket.column_id
|
|
459
1029
|
const columnId = (typeof payload['columnId'] === 'string' ? payload['columnId'] : null) ??
|
|
460
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
|
+
}
|
|
461
1042
|
if (!ticketId)
|
|
462
1043
|
return;
|
|
463
1044
|
const eventType = wsEvent.type;
|
|
@@ -510,11 +1091,23 @@ export async function runPipeline(client, args) {
|
|
|
510
1091
|
logger.orchestrator('Once mode: waiting for loops to complete');
|
|
511
1092
|
// Poll until no active loops or queued work remain
|
|
512
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));
|
|
513
1102
|
// Cleanup
|
|
1103
|
+
await eventEmitter.close();
|
|
514
1104
|
wsClient.stop();
|
|
515
1105
|
eventQueue.stop();
|
|
516
1106
|
cleanupMcpConfig(mcpConfigPath);
|
|
1107
|
+
cleanupGateProxyConfigs(pidDir(opts.boardId));
|
|
1108
|
+
killReaper(reaperPidPath);
|
|
517
1109
|
removePidFile(opts.boardId);
|
|
1110
|
+
removeChildManifest(opts.boardId);
|
|
518
1111
|
logger.orchestrator('Once mode complete. Exiting.');
|
|
519
1112
|
console.log('All loops complete. Pipeline exiting.');
|
|
520
1113
|
}
|
|
@@ -568,7 +1161,7 @@ async function waitForAllLoops(orchestrator, timeoutMs = 4 * 60 * 60 * 1000) {
|
|
|
568
1161
|
return;
|
|
569
1162
|
}
|
|
570
1163
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
571
|
-
if (activeRalphLoops.size === 0 && orchestrator.activeLoopCount === 0 && !orchestrator.
|
|
1164
|
+
if (activeRalphLoops.size === 0 && orchestrator.activeLoopCount === 0 && !orchestrator.hasActiveQueuedWork) {
|
|
572
1165
|
consecutiveIdle++;
|
|
573
1166
|
}
|
|
574
1167
|
else {
|