kantban-cli 0.1.8 → 0.1.10
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/chunk-ZCUIGFSP.js +4111 -0
- package/dist/chunk-ZCUIGFSP.js.map +1 -0
- package/dist/context-7YDNTI3P.js +30 -0
- package/dist/context-7YDNTI3P.js.map +1 -0
- package/dist/cron-OKQP6QDF.js +112 -0
- package/dist/cron-OKQP6QDF.js.map +1 -0
- package/dist/index.d.ts +0 -2
- package/dist/index.js +179 -44
- package/dist/index.js.map +1 -1
- package/dist/pipeline-HTGCXNPL.js +4049 -0
- package/dist/pipeline-HTGCXNPL.js.map +1 -0
- package/dist/pipeline-init-IGZZOOLK.js +103 -0
- package/dist/pipeline-init-IGZZOOLK.js.map +1 -0
- package/dist/status-4GFXMVIM.js +128 -0
- package/dist/status-4GFXMVIM.js.map +1 -0
- package/dist/work-2V33NZAT.js +81 -0
- package/dist/work-2V33NZAT.js.map +1 -0
- package/package.json +5 -4
- package/dist/client.d.ts +0 -38
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js +0 -163
- package/dist/client.js.map +0 -1
- package/dist/commands/context.d.ts +0 -3
- package/dist/commands/context.d.ts.map +0 -1
- package/dist/commands/context.js +0 -27
- package/dist/commands/context.js.map +0 -1
- package/dist/commands/cron.d.ts +0 -3
- package/dist/commands/cron.d.ts.map +0 -1
- package/dist/commands/cron.js +0 -106
- package/dist/commands/cron.js.map +0 -1
- package/dist/commands/pipeline-init.d.ts +0 -2
- package/dist/commands/pipeline-init.d.ts.map +0 -1
- package/dist/commands/pipeline-init.js +0 -100
- package/dist/commands/pipeline-init.js.map +0 -1
- package/dist/commands/pipeline.d.ts +0 -4
- package/dist/commands/pipeline.d.ts.map +0 -1
- package/dist/commands/pipeline.js +0 -1222
- package/dist/commands/pipeline.js.map +0 -1
- package/dist/commands/status.d.ts +0 -3
- package/dist/commands/status.d.ts.map +0 -1
- package/dist/commands/status.js +0 -135
- package/dist/commands/status.js.map +0 -1
- package/dist/commands/work.d.ts +0 -3
- package/dist/commands/work.d.ts.map +0 -1
- package/dist/commands/work.js +0 -76
- package/dist/commands/work.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/lib/advisor.d.ts +0 -108
- package/dist/lib/advisor.d.ts.map +0 -1
- package/dist/lib/advisor.js +0 -139
- package/dist/lib/advisor.js.map +0 -1
- package/dist/lib/checkpoint.d.ts +0 -15
- package/dist/lib/checkpoint.d.ts.map +0 -1
- package/dist/lib/checkpoint.js +0 -49
- package/dist/lib/checkpoint.js.map +0 -1
- package/dist/lib/constraint-evaluator.d.ts +0 -40
- package/dist/lib/constraint-evaluator.d.ts.map +0 -1
- package/dist/lib/constraint-evaluator.js +0 -189
- package/dist/lib/constraint-evaluator.js.map +0 -1
- package/dist/lib/cost-tracker.d.ts +0 -46
- package/dist/lib/cost-tracker.d.ts.map +0 -1
- package/dist/lib/cost-tracker.js +0 -120
- package/dist/lib/cost-tracker.js.map +0 -1
- package/dist/lib/evaluator.d.ts +0 -17
- package/dist/lib/evaluator.d.ts.map +0 -1
- package/dist/lib/evaluator.js +0 -71
- package/dist/lib/evaluator.js.map +0 -1
- package/dist/lib/event-emitter.d.ts +0 -28
- package/dist/lib/event-emitter.d.ts.map +0 -1
- package/dist/lib/event-emitter.js +0 -100
- package/dist/lib/event-emitter.js.map +0 -1
- package/dist/lib/event-queue.d.ts +0 -28
- package/dist/lib/event-queue.d.ts.map +0 -1
- package/dist/lib/event-queue.js +0 -73
- package/dist/lib/event-queue.js.map +0 -1
- package/dist/lib/gate-config.d.ts +0 -7
- package/dist/lib/gate-config.d.ts.map +0 -1
- package/dist/lib/gate-config.js +0 -68
- package/dist/lib/gate-config.js.map +0 -1
- package/dist/lib/gate-proxy-server.d.ts +0 -16
- package/dist/lib/gate-proxy-server.d.ts.map +0 -1
- package/dist/lib/gate-proxy-server.js +0 -385
- package/dist/lib/gate-proxy-server.js.map +0 -1
- package/dist/lib/gate-proxy.d.ts +0 -46
- package/dist/lib/gate-proxy.d.ts.map +0 -1
- package/dist/lib/gate-proxy.js +0 -104
- package/dist/lib/gate-proxy.js.map +0 -1
- package/dist/lib/gate-runner.d.ts +0 -13
- package/dist/lib/gate-runner.d.ts.map +0 -1
- package/dist/lib/gate-runner.js +0 -104
- package/dist/lib/gate-runner.js.map +0 -1
- package/dist/lib/gate-snapshot.d.ts +0 -12
- package/dist/lib/gate-snapshot.d.ts.map +0 -1
- package/dist/lib/gate-snapshot.js +0 -49
- package/dist/lib/gate-snapshot.js.map +0 -1
- package/dist/lib/light-call.d.ts +0 -37
- package/dist/lib/light-call.d.ts.map +0 -1
- package/dist/lib/light-call.js +0 -62
- package/dist/lib/light-call.js.map +0 -1
- package/dist/lib/logger.d.ts +0 -22
- package/dist/lib/logger.d.ts.map +0 -1
- package/dist/lib/logger.js +0 -98
- package/dist/lib/logger.js.map +0 -1
- package/dist/lib/mcp-config.d.ts +0 -24
- package/dist/lib/mcp-config.d.ts.map +0 -1
- package/dist/lib/mcp-config.js +0 -115
- package/dist/lib/mcp-config.js.map +0 -1
- package/dist/lib/orchestrator.d.ts +0 -392
- package/dist/lib/orchestrator.d.ts.map +0 -1
- package/dist/lib/orchestrator.js +0 -1636
- package/dist/lib/orchestrator.js.map +0 -1
- package/dist/lib/parse-utils.d.ts +0 -6
- package/dist/lib/parse-utils.d.ts.map +0 -1
- package/dist/lib/parse-utils.js +0 -64
- package/dist/lib/parse-utils.js.map +0 -1
- package/dist/lib/prompt-composer.d.ts +0 -131
- package/dist/lib/prompt-composer.d.ts.map +0 -1
- package/dist/lib/prompt-composer.js +0 -317
- package/dist/lib/prompt-composer.js.map +0 -1
- package/dist/lib/ralph-loop.d.ts +0 -123
- package/dist/lib/ralph-loop.d.ts.map +0 -1
- package/dist/lib/ralph-loop.js +0 -383
- package/dist/lib/ralph-loop.js.map +0 -1
- package/dist/lib/reaper.d.ts +0 -14
- package/dist/lib/reaper.d.ts.map +0 -1
- package/dist/lib/reaper.js +0 -114
- package/dist/lib/reaper.js.map +0 -1
- package/dist/lib/replanner.d.ts +0 -49
- package/dist/lib/replanner.d.ts.map +0 -1
- package/dist/lib/replanner.js +0 -61
- package/dist/lib/replanner.js.map +0 -1
- package/dist/lib/run-memory.d.ts +0 -37
- package/dist/lib/run-memory.d.ts.map +0 -1
- package/dist/lib/run-memory.js +0 -115
- package/dist/lib/run-memory.js.map +0 -1
- package/dist/lib/stream-parser.d.ts +0 -20
- package/dist/lib/stream-parser.d.ts.map +0 -1
- package/dist/lib/stream-parser.js +0 -65
- package/dist/lib/stream-parser.js.map +0 -1
- package/dist/lib/stuck-detector.d.ts +0 -47
- package/dist/lib/stuck-detector.d.ts.map +0 -1
- package/dist/lib/stuck-detector.js +0 -105
- package/dist/lib/stuck-detector.js.map +0 -1
- package/dist/lib/tool-profiles.d.ts +0 -19
- package/dist/lib/tool-profiles.d.ts.map +0 -1
- package/dist/lib/tool-profiles.js +0 -22
- package/dist/lib/tool-profiles.js.map +0 -1
- package/dist/lib/worktree.d.ts +0 -12
- package/dist/lib/worktree.d.ts.map +0 -1
- package/dist/lib/worktree.js +0 -29
- package/dist/lib/worktree.js.map +0 -1
- package/dist/lib/ws-client.d.ts +0 -31
- package/dist/lib/ws-client.d.ts.map +0 -1
- package/dist/lib/ws-client.js +0 -113
- 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
|