bosun 0.40.18 → 0.40.21

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.
@@ -1,1431 +1,1445 @@
1
- /**
2
- * agent-endpoint.mjs — Lightweight HTTP server for agent self-reporting
3
- *
4
- * Agents running in worktrees use this REST API to tell the orchestrator
5
- * "I'm done" / "I hit an error" / "I'm still alive" without polling.
6
- *
7
- * Features:
8
- * - Node.js built-in `http.createServer` — zero external dependencies
9
- * - Binds to 127.0.0.1 on configurable port (AGENT_ENDPOINT_PORT or 18432)
10
- * - JSON request/response, CORS for localhost
11
- * - 30s request timeout, 1MB max body
12
- * - Callback hooks for monitor integration
13
- *
14
- * EXPORTS:
15
- * AgentEndpoint — Main class
16
- * createAgentEndpoint() — Factory function
17
- */
18
-
19
- import { createServer } from "node:http";
20
- import { resolve, dirname } from "node:path";
21
- import { fileURLToPath } from "node:url";
22
- import { writeFileSync, mkdirSync, unlinkSync, readFileSync } from "node:fs";
23
- import { randomUUID } from "node:crypto";
24
-
25
- const __filename = fileURLToPath(import.meta.url);
26
- const __dirname = dirname(__filename);
27
-
28
- const TAG = "[agent-endpoint]";
29
-
30
- const DEFAULT_PORT = 18432;
31
- const MAX_BODY_SIZE = 1024 * 1024; // 1 MB
32
- const REQUEST_TIMEOUT_MS = 30_000; // 30 seconds
1
+ /**
2
+ * agent-endpoint.mjs — Lightweight HTTP server for agent self-reporting
3
+ *
4
+ * Agents running in worktrees use this REST API to tell the orchestrator
5
+ * "I'm done" / "I hit an error" / "I'm still alive" without polling.
6
+ *
7
+ * Features:
8
+ * - Node.js built-in `http.createServer` — zero external dependencies
9
+ * - Binds to 127.0.0.1 on configurable port (AGENT_ENDPOINT_PORT or 18432)
10
+ * - JSON request/response, CORS for localhost
11
+ * - 30s request timeout, 1MB max body
12
+ * - Callback hooks for monitor integration
13
+ *
14
+ * EXPORTS:
15
+ * AgentEndpoint — Main class
16
+ * createAgentEndpoint() — Factory function
17
+ */
18
+
19
+ import { createServer } from "node:http";
20
+ import { resolve, dirname } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import { writeFileSync, mkdirSync, unlinkSync, readFileSync } from "node:fs";
23
+ import { randomUUID } from "node:crypto";
24
+
25
+ const __filename = fileURLToPath(import.meta.url);
26
+ const __dirname = dirname(__filename);
27
+
28
+ const TAG = "[agent-endpoint]";
29
+
30
+ const DEFAULT_PORT = 18432;
31
+ const MAX_BODY_SIZE = 1024 * 1024; // 1 MB
32
+ const REQUEST_TIMEOUT_MS = 30_000; // 30 seconds
33
33
  const ACCESS_DENIED_COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes
34
- const BOSUN_ROOT_HINT = __dirname.toLowerCase().replace(/\\/g, '/');
35
-
36
- // Valid status transitions when an agent self-reports
37
- const VALID_TRANSITIONS = {
38
- inprogress: ["inreview", "blocked", "done"],
39
- inreview: ["done"],
40
- };
41
-
42
- // Track ports where we lack permission to terminate the owning process.
43
- const accessDeniedPorts = new Map();
44
- const accessDeniedCooldownWarnAt = new Map();
45
- const accessDeniedCachePath = resolve(
46
- __dirname,
47
- "..", ".cache",
48
- "agent-endpoint-access-denied.json",
49
- );
50
-
51
- function loadAccessDeniedCache() {
52
- try {
53
- const raw = readFileSync(accessDeniedCachePath, "utf8");
54
- const data = JSON.parse(raw);
55
- if (!data || typeof data !== "object") return;
56
- Object.entries(data).forEach(([port, ts]) => {
57
- const parsedPort = Number(port);
58
- const parsedTs = Number(ts);
59
- if (Number.isFinite(parsedPort) && Number.isFinite(parsedTs)) {
60
- accessDeniedPorts.set(parsedPort, parsedTs);
61
- }
62
- });
63
- } catch {
64
- // Ignore missing/invalid cache
65
- }
66
- }
67
-
68
- function persistAccessDeniedCache() {
69
- try {
70
- mkdirSync(dirname(accessDeniedCachePath), { recursive: true });
71
- const payload = Object.fromEntries(accessDeniedPorts.entries());
72
- writeFileSync(accessDeniedCachePath, JSON.stringify(payload, null, 2));
73
- } catch (err) {
74
- console.warn(
75
- `${TAG} Failed to persist access-denied cache: ${err.message || err}`,
76
- );
77
- }
78
- }
79
-
80
- function pruneAccessDeniedCache() {
81
- const now = Date.now();
82
- let changed = false;
83
- for (const [port, ts] of accessDeniedPorts.entries()) {
84
- if (!ts || now - ts > ACCESS_DENIED_COOLDOWN_MS) {
85
- accessDeniedPorts.delete(port);
86
- accessDeniedCooldownWarnAt.delete(port);
87
- changed = true;
88
- }
89
- }
90
- if (changed) persistAccessDeniedCache();
91
- }
92
-
93
- function shouldLogAccessDeniedCooldown(port, nowMs = Date.now()) {
94
- const lastWarnAt = accessDeniedCooldownWarnAt.get(port);
95
- if (Number.isFinite(lastWarnAt) && nowMs - lastWarnAt < ACCESS_DENIED_COOLDOWN_MS) {
96
- return false;
97
- }
98
- accessDeniedCooldownWarnAt.set(port, nowMs);
99
- return true;
100
- }
101
-
102
- loadAccessDeniedCache();
103
-
104
- // ── Helpers ─────────────────────────────────────────────────────────────────
105
-
106
- /**
107
- * Parse JSON body from an incoming request with size limit.
108
- * @param {import("node:http").IncomingMessage} req
109
- * @returns {Promise<object>}
110
- */
111
- function parseBody(req) {
112
- return new Promise((resolve, reject) => {
113
- const chunks = [];
114
- let size = 0;
115
-
116
- req.on("data", (chunk) => {
117
- size += chunk.length;
118
- if (size > MAX_BODY_SIZE) {
119
- req.destroy();
120
- reject(new Error("Request body too large"));
121
- return;
122
- }
123
- chunks.push(chunk);
124
- });
125
-
126
- req.on("end", () => {
127
- const raw = Buffer.concat(chunks).toString("utf8");
128
- if (!raw || raw.trim() === "") {
129
- resolve({});
130
- return;
131
- }
132
- try {
133
- resolve(JSON.parse(raw));
134
- } catch (err) {
135
- // Include preview of malformed JSON for debugging (truncate to 200 chars)
136
- const preview = raw.length > 200 ? raw.slice(0, 200) + "..." : raw;
137
- reject(
138
- new Error(`Invalid JSON body: ${err.message} — Preview: ${preview}`),
139
- );
140
- }
141
- });
142
-
143
- req.on("error", reject);
144
- });
145
- }
146
-
147
- /**
148
- * Send a JSON response.
149
- * @param {import("node:http").ServerResponse} res
150
- * @param {number} status
151
- * @param {object} body
152
- */
153
- function sendJson(res, status, body) {
154
- const payload = JSON.stringify(body);
155
- res.writeHead(status, {
156
- "Content-Type": "application/json",
157
- "Access-Control-Allow-Origin": "http://localhost",
158
- "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
159
- "Access-Control-Allow-Headers": "Content-Type",
160
- "Cache-Control": "no-store",
161
- });
162
- res.end(payload);
163
- }
164
-
165
- /**
166
- * Extract a task ID from a URL pathname like /api/tasks/:id/...
167
- * @param {string} pathname
168
- * @returns {string|null}
169
- */
170
- function extractTaskId(pathname) {
171
- const match = pathname.match(/^\/api\/tasks\/([^/]+)/);
172
- return match ? match[1] : null;
173
- }
174
-
175
- function isAlreadyExitedProcessError(err) {
176
- const detail = [err?.stderr, err?.stdout, err?.message]
177
- .map((part) => String(part || ""))
178
- .join("\n")
179
- .toLowerCase();
180
- return (
181
- detail.includes("no running instance of the task") ||
182
- detail.includes("no running instance") ||
183
- detail.includes("no such process") ||
184
- detail.includes("cannot find the process") ||
185
- detail.includes("not found") ||
186
- detail.includes("esrch")
187
- );
188
- }
189
-
190
- function normalizeCommandLine(commandLine) {
191
- return String(commandLine || "").toLowerCase().replace(/\\/g, "/").trim();
192
- }
193
-
194
- function isLikelyBosunCommandLine(commandLine) {
195
- const normalized = normalizeCommandLine(commandLine);
196
- if (!normalized) return false;
197
-
198
- if (normalized.includes(BOSUN_ROOT_HINT)) return true;
199
-
200
- if (
201
- normalized.includes("/bosun/") &&
202
- (normalized.includes("monitor.mjs") ||
203
- normalized.includes("cli.mjs") ||
204
- normalized.includes("agent-endpoint.mjs") ||
205
- normalized.includes("ve-orchestrator"))
206
- ) {
207
- return true;
208
- }
209
-
210
- // Dev-mode often launches monitor as node monitor.mjs from bosun root.
211
- if (/\bnode(?:\.exe)?\b/.test(normalized) && /\bmonitor\.mjs\b/.test(normalized)) {
212
- return true;
213
- }
214
-
215
- return false;
216
- }
217
-
218
- function summarizeCommandLine(commandLine, maxLen = 140) {
219
- const compact = String(commandLine || "").replace(/\s+/g, " ").trim();
220
- if (!compact) return "command line unavailable";
221
- if (compact.length <= maxLen) return compact;
222
- return compact.slice(0, maxLen) + "...";
223
- }
224
-
225
- // ── AgentEndpoint Class ─────────────────────────────────────────────────────
226
-
227
- export class AgentEndpoint {
228
- /**
229
- * @param {object} options
230
- * @param {number} [options.port] — Listen port (default: env or 18432)
231
- * @param {object} [options.taskStore] Task store instance (kanban adapter)
232
- * @param {Function} [options.onTaskComplete] — (taskId, data) => void
233
- * @param {Function} [options.onTaskError] — (taskId, data) => void
234
- * @param {Function} [options.onStatusChange] — (taskId, newStatus, source) => void
235
- */
236
- constructor(options = {}) {
237
- this._port =
238
- options.port ||
239
- (process.env.AGENT_ENDPOINT_PORT
240
- ? Number(process.env.AGENT_ENDPOINT_PORT)
241
- : DEFAULT_PORT);
242
- this._taskStore = options.taskStore || null;
243
- this._onTaskComplete = options.onTaskComplete || null;
244
- this._onTaskError = options.onTaskError || null;
245
- this._onStatusChange = options.onStatusChange || null;
246
- this._onPauseTasks = options.onPauseTasks || null;
247
- this._onResumeTasks = options.onResumeTasks || null;
248
- this._getExecutorStatus = options.getExecutorStatus || null;
249
- this._server = null;
250
- this._running = false;
251
- this._startedAt = null;
252
- this._portFilePath = resolve(__dirname, "..", ".cache", "agent-endpoint-port");
253
- }
254
-
255
- // ── Lifecycle ───────────────────────────────────────────────────────────
256
-
257
- /**
258
- * Start the HTTP server.
259
- * @returns {Promise<void>}
260
- */
261
- async start() {
262
- if (this._running) return;
263
-
264
- const MAX_PORT_RETRIES = 5;
265
- let lastErr;
266
- pruneAccessDeniedCache();
267
-
268
- for (let attempt = 0; attempt < MAX_PORT_RETRIES; attempt++) {
269
- const port = this._port + attempt;
270
- try {
271
- await this._tryListen(port);
272
- this._port = port; // update in case we incremented
273
- return;
274
- } catch (err) {
275
- lastErr = err;
276
- if (err.code === "EADDRINUSE") {
277
- const deniedAt = accessDeniedPorts.get(port);
278
- const nowMs = Date.now();
279
- if (deniedAt && nowMs - deniedAt < ACCESS_DENIED_COOLDOWN_MS) {
280
- if (shouldLogAccessDeniedCooldown(port, nowMs)) {
281
- console.warn(
282
- `${TAG} Port ${port} in use and kill blocked (access denied). Skipping retry during cooldown.`,
283
- );
284
- }
285
- continue;
286
- }
287
- console.warn(
288
- `${TAG} Port ${port} in use (attempt ${attempt + 1}/${MAX_PORT_RETRIES}), trying to free it...`,
289
- );
290
- // Try to kill the process holding the port (Windows)
291
- await this._killProcessOnPort(port);
292
- // Retry same port once after kill
293
- try {
294
- await this._tryListen(port);
295
- this._port = port;
296
- return;
297
- } catch (retryErr) {
298
- if (retryErr.code === "EADDRINUSE") {
299
- console.warn(
300
- `${TAG} Port ${port} still in use after kill, trying next port`,
301
- );
302
- continue;
303
- }
304
- throw retryErr;
305
- }
306
- }
307
- throw err;
308
- }
309
- }
310
-
311
- // All retries exhausted — start without endpoint (non-fatal)
312
- console.error(
313
- `${TAG} Could not bind to any port after ${MAX_PORT_RETRIES} attempts: ${lastErr?.message}`,
314
- );
315
- console.warn(
316
- `${TAG} Running WITHOUT agent endpoint — agents can still work via poll-based completion`,
317
- );
318
- }
319
-
320
- /**
321
- * Attempt to listen on a specific port. Returns a promise.
322
- * @param {number} port
323
- * @returns {Promise<void>}
324
- */
325
- _tryListen(port) {
326
- return new Promise((resolveStart, rejectStart) => {
327
- const server = createServer((req, res) => this._handleRequest(req, res));
328
- server.setTimeout(REQUEST_TIMEOUT_MS);
329
-
330
- server.on("timeout", (socket) => {
331
- console.log(`${TAG} Request timed out, destroying socket`);
332
- socket.destroy();
333
- });
334
-
335
- server.on("error", (err) => {
336
- if (!this._running) {
337
- rejectStart(err);
338
- } else {
339
- console.error(`${TAG} Server error:`, err.message);
340
- }
341
- });
342
-
343
- server.listen(port, "127.0.0.1", () => {
344
- this._server = server;
345
- this._running = true;
346
- this._startedAt = Date.now();
347
- console.log(`${TAG} Listening on 127.0.0.1:${port}`);
348
- this._writePortFile();
349
- resolveStart();
350
- });
351
- });
352
- }
353
-
354
- /**
355
- * Attempt to kill whatever process is holding a port (Windows netstat+taskkill).
356
- * @param {number} port
357
- * @returns {Promise<void>}
358
- */
359
- async _killProcessOnPort(port) {
360
- try {
361
- const { spawnSync } = await import("node:child_process");
362
- const portNumber = Number.parseInt(String(port), 10);
363
- if (!Number.isInteger(portNumber) || portNumber <= 0 || portNumber > 65535) {
364
- throw new Error(`invalid port: ${port}`);
365
- }
366
- const isWindows = process.platform === "win32";
367
- let output;
368
- const pids = new Set();
369
-
370
- // PIDs we must NEVER kill — ourselves, our parent (cli.mjs fork host),
371
- // and any ancestor in the same process tree. lsof can return these when
372
- // the listening socket fd is inherited across fork().
373
- const protectedPids = new Set([
374
- String(process.pid),
375
- String(process.ppid),
376
- ]);
377
-
378
- const readProcessCommandLine = (pid) => {
379
- try {
380
- if (isWindows) {
381
- const query = `$p = Get-CimInstance Win32_Process -Filter "ProcessId=${pid}" -ErrorAction SilentlyContinue; if ($p) { $p.CommandLine }`;
382
- const result = spawnSync(
383
- "powershell",
384
- ["-NoProfile", "-Command", query],
385
- {
386
- encoding: "utf8",
387
- timeout: 5000,
388
- windowsHide: true,
389
- stdio: ["ignore", "pipe", "pipe"],
390
- },
391
- );
392
- if (result.error || result.status !== 0) return "";
393
- return String(result.stdout || "").trim();
394
- }
395
-
396
- const result = spawnSync("ps", ["-p", String(pid), "-o", "args="], {
397
- encoding: "utf8",
398
- timeout: 5000,
399
- stdio: ["ignore", "pipe", "pipe"],
400
- });
401
- if (result.error || result.status !== 0) return "";
402
- return String(result.stdout || "").trim();
403
- } catch {
404
- return "";
405
- }
406
- };
407
-
408
- if (isWindows) {
409
- // Windows: netstat -ano then filter in-process to avoid shell command injection.
410
- const netstatRes = spawnSync("netstat", ["-ano"], {
411
- encoding: "utf8",
412
- timeout: 5000,
413
- windowsHide: true,
414
- stdio: ["ignore", "pipe", "pipe"],
415
- });
416
- if (netstatRes.error) throw netstatRes.error;
417
- if (netstatRes.status !== 0) {
418
- throw new Error(
419
- String(
420
- netstatRes.stderr ||
421
- netstatRes.stdout ||
422
- `netstat exited with status ${netstatRes.status}`,
423
- ).trim(),
424
- );
425
- }
426
- output = String(netstatRes.stdout || "").trim();
427
- const lines = output
428
- .split("\n")
429
- .filter(
430
- (line) => line.includes("LISTENING") && line.includes(`:${portNumber}`),
431
- );
432
- for (const line of lines) {
433
- const parts = line.trim().split(/\s+/);
434
- const pid = parts[parts.length - 1];
435
- if (pid && /^\d+$/.test(pid) && !protectedPids.has(pid)) {
436
- pids.add(pid);
437
- }
438
- }
439
- } else {
440
- // Linux/macOS: lsof -i
441
- const lsofRes = spawnSync("lsof", ["-ti", `:${portNumber}`], {
442
- encoding: "utf8",
443
- timeout: 5000,
444
- stdio: ["ignore", "pipe", "pipe"],
445
- });
446
- if (lsofRes.error) throw lsofRes.error;
447
- // lsof returns exit code 1 when no processes found (port is free)
448
- if (lsofRes.status === 1) {
449
- return;
450
- }
451
- if (lsofRes.status !== 0) {
452
- throw new Error(
453
- String(
454
- lsofRes.stderr ||
455
- lsofRes.stdout ||
456
- `lsof exited with status ${lsofRes.status}`,
457
- ).trim(),
458
- );
459
- }
460
- output = String(lsofRes.stdout || "").trim();
461
- const pidList = output.split("\n").filter((pid) => pid.trim());
462
- for (const pid of pidList) {
463
- if (pid && /^\d+$/.test(pid) && !protectedPids.has(pid)) {
464
- pids.add(pid);
465
- }
466
- }
467
- if (pidList.length > 0 && pids.size === 0) {
468
- console.log(
469
- `${TAG} Port ${portNumber} held by own process tree (PIDs: ${pidList.join(", ")}) — skipping kill`,
470
- );
471
- return;
472
- }
473
- }
474
-
475
- const killEligiblePids = new Set();
476
- for (const pid of pids) {
477
- const commandLine = readProcessCommandLine(pid);
478
- if (!isLikelyBosunCommandLine(commandLine)) {
479
- console.warn(
480
- `${TAG} Port ${port} held by non-bosun PID ${pid} (${summarizeCommandLine(commandLine)}); skipping forced kill`,
481
- );
482
- continue;
483
- }
484
- killEligiblePids.add(pid);
485
- }
486
-
487
- if (killEligiblePids.size === 0) {
488
- return;
489
- }
490
-
491
- for (const pid of killEligiblePids) {
492
- console.log(`${TAG} Sending SIGTERM to stale process PID ${pid} on port ${port}`);
493
- try {
494
- if (isWindows) {
495
- const killRes = spawnSync(
496
- "taskkill",
497
- ["/F", "/PID", String(pid)],
498
- {
499
- encoding: "utf8",
500
- timeout: 5000,
501
- windowsHide: true,
502
- stdio: ["ignore", "pipe", "pipe"],
503
- },
504
- );
505
- if (killRes.error) {
506
- throw killRes.error;
507
- }
508
- if (killRes.status !== 0) {
509
- const err = new Error(
510
- String(
511
- killRes.stderr ||
512
- killRes.stdout ||
513
- `taskkill exited with status ${killRes.status}`,
514
- ).trim(),
515
- );
516
- err.stderr = killRes.stderr;
517
- err.stdout = killRes.stdout;
518
- throw err;
519
- }
520
- } else {
521
- // Graceful SIGTERM first — only escalate to SIGKILL if still alive
522
- process.kill(Number(pid), "SIGTERM");
523
- }
524
- } catch (killErr) {
525
- /* may already be dead — log for diagnostics */
526
- const stderrText = String(killErr.stderr || killErr.message || "");
527
- const stderrLower = stderrText.toLowerCase();
528
- if (isAlreadyExitedProcessError(killErr)) {
529
- console.log(
530
- `${TAG} PID ${pid} already exited before kill on port ${port}; continuing`,
531
- );
532
- continue;
533
- }
534
- if (
535
- stderrLower.includes("access is denied") ||
536
- stderrLower.includes("operation not permitted")
537
- ) {
538
- accessDeniedPorts.set(port, Date.now());
539
- accessDeniedCooldownWarnAt.delete(port);
540
- persistAccessDeniedCache();
541
- }
542
- console.warn(
543
- `${TAG} kill PID ${pid} failed: ${killErr.stderr?.trim() || killErr.message || "unknown error"}`,
544
- );
545
- }
546
- }
547
-
548
- // Give the SIGTERM'd processes time to exit gracefully
549
- await new Promise((r) => setTimeout(r, 2000));
550
-
551
- // Escalate: check if any are still alive and SIGKILL them
552
- if (!isWindows) {
553
- for (const pid of killEligiblePids) {
554
- try {
555
- process.kill(Number(pid), 0); // probe — throws if dead
556
- console.warn(`${TAG} PID ${pid} still alive after SIGTERM — sending SIGKILL`);
557
- process.kill(Number(pid), "SIGKILL");
558
- } catch {
559
- /* already dead — good */
560
- }
561
- }
562
- await new Promise((r) => setTimeout(r, 500));
563
- }
564
- } catch (outerErr) {
565
- // Commands may fail if port already free
566
- if (outerErr.status !== 1) {
567
- // status 1 = no matching entries (port already free)
568
- console.warn(
569
- `${TAG} _killProcessOnPort(${port}) failed: ${outerErr.message || "unknown error"}`,
570
- );
571
- }
572
- }
573
- }
574
- /**
575
- * Stop the HTTP server.
576
- * @returns {Promise<void>}
577
- */
578
- stop() {
579
- if (!this._running || !this._server) return Promise.resolve();
580
-
581
- return new Promise((resolveStop) => {
582
- this._running = false;
583
- this._removePortFile();
584
- this._server.close(() => {
585
- console.log(`${TAG} Server stopped`);
586
- resolveStop();
587
- });
588
- // Force-close lingering connections after 5s
589
- setTimeout(() => {
590
- resolveStop();
591
- }, 5000);
592
- });
593
- }
594
-
595
- /** @returns {number} */
596
- getPort() {
597
- return this._port;
598
- }
599
-
600
- /** @returns {boolean} */
601
- isRunning() {
602
- return this._running;
603
- }
604
-
605
- /**
606
- * Lightweight status for diagnostics (/agents).
607
- * @returns {{ running: boolean, port: number, startedAt: number|null, uptimeMs: number }}
608
- */
609
- getStatus() {
610
- return {
611
- running: this._running,
612
- port: this._port,
613
- startedAt: this._startedAt || null,
614
- uptimeMs:
615
- this._running && this._startedAt
616
- ? Math.max(0, Date.now() - this._startedAt)
617
- : 0,
618
- };
619
- }
620
-
621
- // ── Port Discovery File ─────────────────────────────────────────────────
622
-
623
- _writePortFile() {
624
- try {
625
- const dir = dirname(this._portFilePath);
626
- mkdirSync(dir, { recursive: true });
627
- writeFileSync(this._portFilePath, String(this._port));
628
- console.log(
629
- `${TAG} Port file written: ${this._portFilePath} ${this._port}`,
630
- );
631
- } catch (err) {
632
- console.error(`${TAG} Failed to write port file:`, err.message);
633
- }
634
- }
635
-
636
- _removePortFile() {
637
- try {
638
- unlinkSync(this._portFilePath);
639
- } catch {
640
- // Ignore file may already be gone
641
- }
642
- }
643
-
644
- // ── Request Router ──────────────────────────────────────────────────────
645
-
646
- /**
647
- * @param {import("node:http").IncomingMessage} req
648
- * @param {import("node:http").ServerResponse} res
649
- */
650
- async _handleRequest(req, res) {
651
- // Handle CORS preflight
652
- if (req.method === "OPTIONS") {
653
- sendJson(res, 204, {});
654
- return;
655
- }
656
-
657
- const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
658
- const pathname = url.pathname;
659
- const method = req.method;
660
-
661
- try {
662
- // ── Static routes ───────────────────────────────────────────────
663
- if (method === "GET" && pathname === "/health") {
664
- return this._handleHealth(res);
665
- }
666
-
667
- if (method === "GET" && pathname === "/api/status") {
668
- return this._handleStatus(res);
669
- }
670
-
671
- if (method === "GET" && pathname === "/api/tasks") {
672
- return await this._handleListTasks(url, res);
673
- }
674
-
675
- if (method === "POST" && pathname === "/api/tasks/create") {
676
- const body = await parseBody(req);
677
- return await this._handleCreateTask(body, res);
678
- }
679
-
680
- if (method === "GET" && pathname === "/api/tasks/stats") {
681
- return await this._handleTaskStats(res);
682
- }
683
-
684
- if (method === "POST" && pathname === "/api/tasks/import") {
685
- const body = await parseBody(req);
686
- return await this._handleImportTasks(body, res);
687
- }
688
-
689
- if (method === "GET" && pathname === "/api/executor") {
690
- return this._handleExecutorStatus(res);
691
- }
692
- if (method === "POST" && pathname === "/api/executor/pause") {
693
- return await this._handlePauseTasks(res);
694
- }
695
- if (method === "POST" && pathname === "/api/executor/resume") {
696
- return await this._handleResumeTasks(res);
697
- }
698
-
699
- // ── Task-specific routes ────────────────────────────────────────
700
- const taskId = extractTaskId(pathname);
701
-
702
- if (taskId) {
703
- if (method === "GET" && pathname === `/api/tasks/${taskId}`) {
704
- return await this._handleGetTask(taskId, res);
705
- }
706
-
707
- if (method === "POST" && pathname === `/api/tasks/${taskId}/status`) {
708
- const body = await parseBody(req);
709
- return await this._handleStatusChange(taskId, body, res);
710
- }
711
-
712
- if (
713
- method === "POST" &&
714
- pathname === `/api/tasks/${taskId}/heartbeat`
715
- ) {
716
- const body = await parseBody(req);
717
- return await this._handleHeartbeat(taskId, body, res);
718
- }
719
-
720
- if (method === "POST" && pathname === `/api/tasks/${taskId}/complete`) {
721
- const body = await parseBody(req);
722
- return await this._handleComplete(taskId, body, res);
723
- }
724
-
725
- if (method === "POST" && pathname === `/api/tasks/${taskId}/error`) {
726
- const body = await parseBody(req);
727
- return await this._handleError(taskId, body, res);
728
- }
729
-
730
- if (method === "POST" && pathname === `/api/tasks/${taskId}/update`) {
731
- const body = await parseBody(req);
732
- return await this._handleUpdateTask(taskId, body, res);
733
- }
734
-
735
- if (method === "DELETE" && pathname === `/api/tasks/${taskId}`) {
736
- return await this._handleDeleteTask(taskId, res);
737
- }
738
- }
739
-
740
- // ── 404 ─────────────────────────────────────────────────────────
741
- sendJson(res, 404, { error: "Not found", path: pathname });
742
- } catch (err) {
743
- console.error(`${TAG} ${method} ${pathname} error:`, err.message);
744
- sendJson(res, 500, { error: err.message || "Internal server error" });
745
- }
746
- }
747
-
748
- // ── Route Handlers ──────────────────────────────────────────────────────
749
-
750
- _handleHealth(res) {
751
- const uptimeSeconds =
752
- this._startedAt != null
753
- ? Math.floor((Date.now() - this._startedAt) / 1000)
754
- : 0;
755
- sendJson(res, 200, { ok: true, uptime: uptimeSeconds });
756
- }
757
-
758
- _handleStatus(res) {
759
- const uptimeSeconds =
760
- this._startedAt != null
761
- ? Math.floor((Date.now() - this._startedAt) / 1000)
762
- : 0;
763
- const storeStats = this._taskStore
764
- ? { connected: true }
765
- : { connected: false };
766
-
767
- sendJson(res, 200, {
768
- executor: { running: this._running, port: this._port },
769
- store: storeStats,
770
- uptime: uptimeSeconds,
771
- });
772
- }
773
-
774
- async _handleListTasks(url, res) {
775
- if (!this._taskStore) {
776
- sendJson(res, 503, { error: "Task store not configured" });
777
- return;
778
- }
779
-
780
- const statusFilter = url.searchParams.get("status") || undefined;
781
-
782
- try {
783
- let tasks;
784
- if (typeof this._taskStore.listTasks === "function") {
785
- // kanban adapter-style store
786
- tasks = await this._taskStore.listTasks(null, { status: statusFilter });
787
- } else if (typeof this._taskStore.list === "function") {
788
- tasks = await this._taskStore.list({ status: statusFilter });
789
- } else {
790
- sendJson(res, 501, { error: "Task store does not support listing" });
791
- return;
792
- }
793
-
794
- const taskList = Array.isArray(tasks) ? tasks : [];
795
- sendJson(res, 200, { tasks: taskList, count: taskList.length });
796
- } catch (err) {
797
- console.error(`${TAG} listTasks error:`, err.message);
798
- sendJson(res, 500, { error: `Failed to list tasks: ${err.message}` });
799
- }
800
- }
801
-
802
- async _handleGetTask(taskId, res) {
803
- if (!this._taskStore) {
804
- sendJson(res, 503, { error: "Task store not configured" });
805
- return;
806
- }
807
-
808
- try {
809
- let task;
810
- if (typeof this._taskStore.getTask === "function") {
811
- task = await this._taskStore.getTask(taskId);
812
- } else if (typeof this._taskStore.get === "function") {
813
- task = await this._taskStore.get(taskId);
814
- } else {
815
- sendJson(res, 501, { error: "Task store does not support get" });
816
- return;
817
- }
818
-
819
- if (!task) {
820
- sendJson(res, 404, { error: "Task not found" });
821
- return;
822
- }
823
-
824
- sendJson(res, 200, { task });
825
- } catch (err) {
826
- console.error(`${TAG} getTask(${taskId}) error:`, err.message);
827
- sendJson(res, 404, { error: "Task not found" });
828
- }
829
- }
830
-
831
- _handleExecutorStatus(res) {
832
- if (typeof this._getExecutorStatus !== "function") {
833
- sendJson(res, 503, { error: "Executor status provider not configured" });
834
- return;
835
- }
836
- try {
837
- const status = this._getExecutorStatus() || {};
838
- sendJson(res, 200, { ok: true, status });
839
- } catch (err) {
840
- sendJson(res, 500, {
841
- error: `Failed to get executor status: ${err.message}`,
842
- });
843
- }
844
- }
845
-
846
- async _handlePauseTasks(res) {
847
- if (typeof this._onPauseTasks !== "function") {
848
- sendJson(res, 503, { error: "Pause control not configured" });
849
- return;
850
- }
851
- try {
852
- const result = await this._onPauseTasks();
853
- sendJson(res, 200, { ok: true, result: result ?? { paused: true } });
854
- } catch (err) {
855
- sendJson(res, 500, { error: `Failed to pause tasks: ${err.message}` });
856
- }
857
- }
858
-
859
- async _handleResumeTasks(res) {
860
- if (typeof this._onResumeTasks !== "function") {
861
- sendJson(res, 503, { error: "Resume control not configured" });
862
- return;
863
- }
864
- try {
865
- const result = await this._onResumeTasks();
866
- sendJson(res, 200, { ok: true, result: result ?? { paused: false } });
867
- } catch (err) {
868
- sendJson(res, 500, { error: `Failed to resume tasks: ${err.message}` });
869
- }
870
- }
871
-
872
- async _handleStatusChange(taskId, body, res) {
873
- if (!this._taskStore) {
874
- sendJson(res, 503, { error: "Task store not configured" });
875
- return;
876
- }
877
-
878
- const { status, message } = body;
879
- if (!status) {
880
- sendJson(res, 400, { error: "Missing 'status' in body" });
881
- return;
882
- }
883
-
884
- const allowed = ["inreview", "done", "blocked"];
885
- if (!allowed.includes(status)) {
886
- sendJson(res, 400, {
887
- error: `Invalid status '${status}'. Allowed: ${allowed.join(", ")}`,
888
- });
889
- return;
890
- }
891
-
892
- // Validate transition
893
- try {
894
- let currentTask;
895
- if (typeof this._taskStore.getTask === "function") {
896
- currentTask = await this._taskStore.getTask(taskId);
897
- } else if (typeof this._taskStore.get === "function") {
898
- currentTask = await this._taskStore.get(taskId);
899
- }
900
-
901
- if (currentTask) {
902
- const currentStatus = currentTask.status || "unknown";
903
- const validNext = VALID_TRANSITIONS[currentStatus];
904
- if (validNext && !validNext.includes(status)) {
905
- sendJson(res, 409, {
906
- error: `Invalid transition: ${currentStatus} → ${status}. Allowed: ${validNext.join(", ")}`,
907
- });
908
- return;
909
- }
910
- }
911
- } catch {
912
- // If we can't fetch current task, proceed anyway
913
- }
914
-
915
- try {
916
- let updatedTask;
917
- if (typeof this._taskStore.updateTaskStatus === "function") {
918
- updatedTask = await this._taskStore.updateTaskStatus(taskId, status);
919
- } else if (typeof this._taskStore.update === "function") {
920
- updatedTask = await this._taskStore.update(taskId, { status });
921
- }
922
-
923
- console.log(
924
- `${TAG} Task ${taskId} status → ${status} (source=agent)${message ? ` msg="${message}"` : ""}`,
925
- );
926
-
927
- if (this._onStatusChange) {
928
- try {
929
- await this._onStatusChange(taskId, status, "agent");
930
- } catch (err) {
931
- console.error(`${TAG} onStatusChange callback error:`, err.message);
932
- }
933
- }
934
-
935
- sendJson(res, 200, {
936
- ok: true,
937
- task: updatedTask || { id: taskId, status },
938
- });
939
- } catch (err) {
940
- console.error(`${TAG} statusChange(${taskId}) error:`, err.message);
941
- sendJson(res, 500, { error: `Failed to update status: ${err.message}` });
942
- }
943
- }
944
-
945
- async _handleHeartbeat(taskId, body, res) {
946
- const timestamp = new Date().toISOString();
947
- const { message } = body;
948
-
949
- console.log(
950
- `${TAG} Heartbeat from task ${taskId}${message ? `: ${message}` : ""}`,
951
- );
952
-
953
- // Try to update lastActivityAt on the task if the store supports it
954
- if (this._taskStore) {
955
- try {
956
- if (typeof this._taskStore.update === "function") {
957
- await this._taskStore.update(taskId, { lastActivityAt: timestamp });
958
- } else if (typeof this._taskStore.updateTaskStatus === "function") {
959
- // kanban adapter doesn't have a generic update, but heartbeat is still recorded
960
- }
961
- } catch {
962
- // Non-critical — heartbeat is logged regardless
963
- }
964
- }
965
-
966
- sendJson(res, 200, { ok: true, timestamp });
967
- }
968
-
969
- async _handleComplete(taskId, body, res) {
970
- const { hasCommits, branch, prUrl, output, prNumber } = body;
971
-
972
- console.log(
973
- `${TAG} Task ${taskId} complete: hasCommits=${!!hasCommits}, branch=${branch || "none"}, pr=${prUrl || "none"}`,
974
- );
975
-
976
- if (this._taskStore) {
977
- try {
978
- if (typeof this._taskStore.recordAgentAttempt === "function") {
979
- await this._taskStore.recordAgentAttempt(taskId, {
980
- output,
981
- hasCommits: !!hasCommits,
982
- });
983
- }
984
-
985
- if (typeof this._taskStore.update === "function") {
986
- const updates = {};
987
- if (branch) updates.branchName = branch;
988
- if (prUrl) updates.prUrl = prUrl;
989
- if (prNumber) updates.prNumber = prNumber;
990
- if (Object.keys(updates).length > 0) {
991
- await this._taskStore.update(taskId, updates);
992
- }
993
- }
994
- } catch (err) {
995
- console.warn(
996
- `${TAG} Failed to record completion details for ${taskId}: ${err.message || err}`,
997
- );
998
- }
999
- }
1000
-
1001
- let nextAction = "cooldown";
1002
-
1003
- if (hasCommits) {
1004
- nextAction = "review";
1005
-
1006
- // Update task status to inreview
1007
- if (this._taskStore) {
1008
- try {
1009
- if (typeof this._taskStore.updateTaskStatus === "function") {
1010
- await this._taskStore.updateTaskStatus(taskId, "inreview");
1011
- } else if (typeof this._taskStore.update === "function") {
1012
- await this._taskStore.update(taskId, { status: "inreview" });
1013
- }
1014
- } catch (err) {
1015
- console.error(
1016
- `${TAG} Failed to set task ${taskId} to inreview:`,
1017
- err.message,
1018
- );
1019
- }
1020
- }
1021
- } else {
1022
- // No commits — record the attempt but don't change status
1023
- console.log(`${TAG} Task ${taskId} completed with no commits`);
1024
- }
1025
-
1026
- // Fire callback
1027
- if (this._onTaskComplete) {
1028
- try {
1029
- await this._onTaskComplete(taskId, {
1030
- hasCommits,
1031
- branch,
1032
- prUrl,
1033
- output,
1034
- });
1035
- } catch (err) {
1036
- console.error(`${TAG} onTaskComplete callback error:`, err.message);
1037
- }
1038
- }
1039
-
1040
- // Retrieve updated task for response
1041
- let task = { id: taskId };
1042
- if (this._taskStore) {
1043
- try {
1044
- if (typeof this._taskStore.getTask === "function") {
1045
- task = (await this._taskStore.getTask(taskId)) || task;
1046
- } else if (typeof this._taskStore.get === "function") {
1047
- task = (await this._taskStore.get(taskId)) || task;
1048
- }
1049
- } catch {
1050
- // Use fallback
1051
- }
1052
- }
1053
-
1054
- sendJson(res, 200, { ok: true, task, nextAction });
1055
- }
1056
-
1057
- // ── Task CRUD Handlers ──────────────────────────────────────────────────
1058
-
1059
- async _handleCreateTask(body, res) {
1060
- if (!this._taskStore) {
1061
- sendJson(res, 503, { error: "Task store not configured" });
1062
- return;
1063
- }
1064
-
1065
- const { title, description, status, priority, tags, baseBranch, base_branch, workspace, repository, repositories, implementation_steps, acceptance_criteria, verification, draft } = body;
1066
-
1067
- if (!title) {
1068
- sendJson(res, 400, { error: "Missing 'title' in body" });
1069
- return;
1070
- }
1071
-
1072
- try {
1073
- // Build description from structured fields if provided
1074
- let fullDescription = description || "";
1075
- if (implementation_steps?.length || acceptance_criteria?.length || verification?.length) {
1076
- const parts = [fullDescription];
1077
- if (implementation_steps?.length) {
1078
- parts.push("", "## Implementation Steps");
1079
- for (const step of implementation_steps) parts.push(`- ${step}`);
1080
- }
1081
- if (acceptance_criteria?.length) {
1082
- parts.push("", "## Acceptance Criteria");
1083
- for (const c of acceptance_criteria) parts.push(`- ${c}`);
1084
- }
1085
- if (verification?.length) {
1086
- parts.push("", "## Verification");
1087
- for (const v of verification) parts.push(`- ${v}`);
1088
- }
1089
- fullDescription = parts.join("\n");
1090
- }
1091
-
1092
- let task;
1093
- if (typeof this._taskStore.createTask === "function") {
1094
- // kanban adapter — handles ID generation
1095
- task = await this._taskStore.createTask(null, {
1096
- title,
1097
- description: fullDescription,
1098
- status: status || "draft",
1099
- priority: priority || "medium",
1100
- tags: tags || [],
1101
- baseBranch: baseBranch || base_branch || "main",
1102
- workspace: workspace || "",
1103
- repository: repository || "",
1104
- repositories: repositories || [],
1105
- draft: draft ?? (status === "draft" || !status),
1106
- });
1107
- } else if (typeof this._taskStore.addTask === "function") {
1108
- // raw task-store
1109
- task = this._taskStore.addTask({
1110
- id: randomUUID(),
1111
- title,
1112
- description: fullDescription,
1113
- status: status || "draft",
1114
- priority: priority || "medium",
1115
- tags: tags || [],
1116
- baseBranch: baseBranch || base_branch || "main",
1117
- workspace: workspace || "",
1118
- repository: repository || "",
1119
- repositories: repositories || [],
1120
- draft: draft ?? (status === "draft" || !status),
1121
- });
1122
- } else {
1123
- sendJson(res, 501, { error: "Task store does not support creation" });
1124
- return;
1125
- }
1126
-
1127
- if (!task) {
1128
- sendJson(res, 500, { error: "Failed to create task" });
1129
- return;
1130
- }
1131
-
1132
- console.log(`${TAG} Created task ${task.id}: ${task.title}`);
1133
- sendJson(res, 201, { ok: true, task });
1134
- } catch (err) {
1135
- console.error(`${TAG} createTask error:`, err.message);
1136
- sendJson(res, 500, { error: `Failed to create task: ${err.message}` });
1137
- }
1138
- }
1139
-
1140
- async _handleUpdateTask(taskId, body, res) {
1141
- if (!this._taskStore) {
1142
- sendJson(res, 503, { error: "Task store not configured" });
1143
- return;
1144
- }
1145
-
1146
- try {
1147
- // Verify task exists
1148
- let existing = null;
1149
- if (typeof this._taskStore.getTask === "function") {
1150
- existing = await this._taskStore.getTask(taskId);
1151
- } else if (typeof this._taskStore.get === "function") {
1152
- existing = await this._taskStore.get(taskId);
1153
- }
1154
- if (!existing) {
1155
- sendJson(res, 404, { error: "Task not found" });
1156
- return;
1157
- }
1158
-
1159
- const updates = { ...body };
1160
- delete updates.id; // Never allow ID change
1161
-
1162
- // Normalize base_branch → baseBranch
1163
- if (updates.base_branch) {
1164
- updates.baseBranch = updates.base_branch;
1165
- delete updates.base_branch;
1166
- }
1167
-
1168
- // Handle status change with history tracking
1169
- if (updates.status && updates.status !== existing.status) {
1170
- if (typeof this._taskStore.updateTaskStatus === "function") {
1171
- await this._taskStore.updateTaskStatus(taskId, updates.status);
1172
- }
1173
- delete updates.status;
1174
- }
1175
-
1176
- // Apply remaining updates
1177
- let updatedTask;
1178
- if (Object.keys(updates).length > 0) {
1179
- if (typeof this._taskStore.updateTask === "function") {
1180
- updatedTask = await this._taskStore.updateTask(taskId, updates);
1181
- } else if (typeof this._taskStore.update === "function") {
1182
- updatedTask = await this._taskStore.update(taskId, updates);
1183
- }
1184
- }
1185
-
1186
- // Re-fetch to get final state
1187
- if (typeof this._taskStore.getTask === "function") {
1188
- updatedTask = await this._taskStore.getTask(taskId);
1189
- } else if (typeof this._taskStore.get === "function") {
1190
- updatedTask = await this._taskStore.get(taskId);
1191
- }
1192
-
1193
- console.log(`${TAG} Updated task ${taskId}`);
1194
- sendJson(res, 200, { ok: true, task: updatedTask || { id: taskId } });
1195
- } catch (err) {
1196
- console.error(`${TAG} updateTask(${taskId}) error:`, err.message);
1197
- sendJson(res, 500, { error: `Failed to update task: ${err.message}` });
1198
- }
1199
- }
1200
-
1201
- async _handleDeleteTask(taskId, res) {
1202
- if (!this._taskStore) {
1203
- sendJson(res, 503, { error: "Task store not configured" });
1204
- return;
1205
- }
1206
-
1207
- try {
1208
- let removed = false;
1209
- if (typeof this._taskStore.deleteTask === "function") {
1210
- await this._taskStore.deleteTask(taskId);
1211
- removed = true;
1212
- } else if (typeof this._taskStore.removeTask === "function") {
1213
- removed = this._taskStore.removeTask(taskId);
1214
- } else {
1215
- sendJson(res, 501, { error: "Task store does not support deletion" });
1216
- return;
1217
- }
1218
-
1219
- if (!removed) {
1220
- sendJson(res, 404, { error: "Task not found" });
1221
- return;
1222
- }
1223
-
1224
- console.log(`${TAG} Deleted task ${taskId}`);
1225
- sendJson(res, 200, { ok: true, deleted: taskId });
1226
- } catch (err) {
1227
- console.error(`${TAG} deleteTask(${taskId}) error:`, err.message);
1228
- sendJson(res, 500, { error: `Failed to delete task: ${err.message}` });
1229
- }
1230
- }
1231
-
1232
- async _handleTaskStats(res) {
1233
- if (!this._taskStore) {
1234
- sendJson(res, 503, { error: "Task store not configured" });
1235
- return;
1236
- }
1237
-
1238
- try {
1239
- let stats;
1240
- if (typeof this._taskStore.getStats === "function") {
1241
- stats = this._taskStore.getStats();
1242
- } else {
1243
- // Compute from list
1244
- let tasks = [];
1245
- if (typeof this._taskStore.listTasks === "function") {
1246
- tasks = await this._taskStore.listTasks(null, {});
1247
- } else if (typeof this._taskStore.list === "function") {
1248
- tasks = await this._taskStore.list({});
1249
- }
1250
- const list = Array.isArray(tasks) ? tasks : [];
1251
- stats = {
1252
- draft: list.filter((t) => t.status === "draft").length,
1253
- todo: list.filter((t) => t.status === "todo").length,
1254
- inprogress: list.filter((t) => t.status === "inprogress").length,
1255
- inreview: list.filter((t) => t.status === "inreview").length,
1256
- done: list.filter((t) => t.status === "done").length,
1257
- blocked: list.filter((t) => t.status === "blocked").length,
1258
- total: list.length,
1259
- };
1260
- }
1261
-
1262
- sendJson(res, 200, { ok: true, stats });
1263
- } catch (err) {
1264
- console.error(`${TAG} taskStats error:`, err.message);
1265
- sendJson(res, 500, { error: `Failed to get stats: ${err.message}` });
1266
- }
1267
- }
1268
-
1269
- async _handleImportTasks(body, res) {
1270
- if (!this._taskStore) {
1271
- sendJson(res, 503, { error: "Task store not configured" });
1272
- return;
1273
- }
1274
-
1275
- const tasks = body.tasks || body.backlog || (Array.isArray(body) ? body : null);
1276
- if (!tasks || !Array.isArray(tasks)) {
1277
- sendJson(res, 400, { error: "Body must contain 'tasks' array" });
1278
- return;
1279
- }
1280
-
1281
- const results = { created: [], failed: [] };
1282
- for (const t of tasks) {
1283
- try {
1284
- // Recursively use our create handler logic
1285
- let fullDescription = t.description || "";
1286
- if (t.implementation_steps?.length || t.acceptance_criteria?.length || t.verification?.length) {
1287
- const parts = [fullDescription];
1288
- if (t.implementation_steps?.length) {
1289
- parts.push("", "## Implementation Steps");
1290
- for (const step of t.implementation_steps) parts.push(`- ${step}`);
1291
- }
1292
- if (t.acceptance_criteria?.length) {
1293
- parts.push("", "## Acceptance Criteria");
1294
- for (const c of t.acceptance_criteria) parts.push(`- ${c}`);
1295
- }
1296
- if (t.verification?.length) {
1297
- parts.push("", "## Verification");
1298
- for (const v of t.verification) parts.push(`- ${v}`);
1299
- }
1300
- fullDescription = parts.join("\n");
1301
- }
1302
-
1303
- let task;
1304
- if (typeof this._taskStore.createTask === "function") {
1305
- task = await this._taskStore.createTask(null, {
1306
- title: t.title,
1307
- description: fullDescription,
1308
- status: t.status || "draft",
1309
- priority: t.priority || "medium",
1310
- tags: t.tags || [],
1311
- baseBranch: t.baseBranch || t.base_branch || "main",
1312
- workspace: t.workspace || "",
1313
- repository: t.repository || "",
1314
- draft: t.draft ?? true,
1315
- });
1316
- } else if (typeof this._taskStore.addTask === "function") {
1317
- task = this._taskStore.addTask({
1318
- id: randomUUID(),
1319
- title: t.title,
1320
- description: fullDescription,
1321
- status: t.status || "draft",
1322
- priority: t.priority || "medium",
1323
- tags: t.tags || [],
1324
- baseBranch: t.baseBranch || t.base_branch || "main",
1325
- workspace: t.workspace || "",
1326
- repository: t.repository || "",
1327
- draft: t.draft ?? true,
1328
- });
1329
- }
1330
-
1331
- if (task) {
1332
- results.created.push({ id: task.id, title: task.title });
1333
- } else {
1334
- results.failed.push({ title: t.title, error: "addTask returned null" });
1335
- }
1336
- } catch (err) {
1337
- results.failed.push({ title: t.title || "untitled", error: err.message });
1338
- }
1339
- }
1340
-
1341
- console.log(`${TAG} Import: ${results.created.length} created, ${results.failed.length} failed`);
1342
- sendJson(res, 200, {
1343
- ok: true,
1344
- created: results.created.length,
1345
- failed: results.failed.length,
1346
- results,
1347
- });
1348
- }
1349
-
1350
- async _handleError(taskId, body, res) {
1351
- const { error: errorMsg, pattern, output } = body;
1352
-
1353
- if (!errorMsg) {
1354
- sendJson(res, 400, { error: "Missing 'error' in body" });
1355
- return;
1356
- }
1357
-
1358
- const validPatterns = [
1359
- "plan_stuck",
1360
- "rate_limit",
1361
- "token_overflow",
1362
- "api_error",
1363
- ];
1364
- if (pattern && !validPatterns.includes(pattern)) {
1365
- console.log(
1366
- `${TAG} Task ${taskId} error with unknown pattern '${pattern}': ${errorMsg}`,
1367
- );
1368
- } else {
1369
- console.log(
1370
- `${TAG} Task ${taskId} error${pattern ? ` (${pattern})` : ""}: ${errorMsg}`,
1371
- );
1372
- }
1373
-
1374
- if (this._taskStore) {
1375
- try {
1376
- if (typeof this._taskStore.recordAgentAttempt === "function") {
1377
- await this._taskStore.recordAgentAttempt(taskId, {
1378
- output,
1379
- error: errorMsg,
1380
- hasCommits: false,
1381
- });
1382
- }
1383
- if (
1384
- pattern &&
1385
- typeof this._taskStore.recordErrorPattern === "function"
1386
- ) {
1387
- await this._taskStore.recordErrorPattern(taskId, pattern);
1388
- }
1389
- } catch (err) {
1390
- console.warn(
1391
- `${TAG} Failed to record error details for ${taskId}: ${err.message || err}`,
1392
- );
1393
- }
1394
- }
1395
-
1396
- // Determine action based on pattern
1397
- let action = "retry";
1398
- if (pattern === "rate_limit") {
1399
- action = "cooldown";
1400
- } else if (pattern === "token_overflow") {
1401
- action = "blocked";
1402
- } else if (pattern === "plan_stuck") {
1403
- action = "retry";
1404
- } else if (pattern === "api_error") {
1405
- action = "cooldown";
1406
- }
1407
-
1408
- // Fire callback
1409
- if (this._onTaskError) {
1410
- try {
1411
- await this._onTaskError(taskId, { error: errorMsg, pattern });
1412
- } catch (err) {
1413
- console.error(`${TAG} onTaskError callback error:`, err.message);
1414
- }
1415
- }
1416
-
1417
- sendJson(res, 200, { ok: true, action });
1418
- }
1419
- }
1420
-
1421
- // ── Factory ─────────────────────────────────────────────────────────────────
1422
-
1423
- /**
1424
- * Create an AgentEndpoint instance.
1425
- * @param {object} [options] Same as AgentEndpoint constructor
1426
- * @returns {AgentEndpoint}
1427
- */
1428
- export function createAgentEndpoint(options) {
1429
- return new AgentEndpoint(options);
1430
- }
34
+ const BOSUN_ROOT_HINT = __dirname.toLowerCase().replace(/\\/g, '/');
35
+
36
+ // Valid status transitions when an agent self-reports
37
+ const VALID_TRANSITIONS = {
38
+ inprogress: ["inreview", "blocked", "done"],
39
+ inreview: ["done"],
40
+ };
41
+
42
+ // Track ports where we lack permission to terminate the owning process.
43
+ const accessDeniedPorts = new Map();
44
+ const accessDeniedCooldownWarnAt = new Map();
45
+ const accessDeniedCachePath = resolve(
46
+ __dirname,
47
+ "..", ".cache",
48
+ "agent-endpoint-access-denied.json",
49
+ );
50
+
51
+ function loadAccessDeniedCache() {
52
+ try {
53
+ const raw = readFileSync(accessDeniedCachePath, "utf8");
54
+ const data = JSON.parse(raw);
55
+ if (!data || typeof data !== "object") return;
56
+ Object.entries(data).forEach(([port, ts]) => {
57
+ const parsedPort = Number(port);
58
+ const parsedTs = Number(ts);
59
+ if (Number.isFinite(parsedPort) && Number.isFinite(parsedTs)) {
60
+ accessDeniedPorts.set(parsedPort, parsedTs);
61
+ }
62
+ });
63
+ } catch {
64
+ // Ignore missing/invalid cache
65
+ }
66
+ }
67
+
68
+ function persistAccessDeniedCache() {
69
+ try {
70
+ mkdirSync(dirname(accessDeniedCachePath), { recursive: true });
71
+ const payload = Object.fromEntries(accessDeniedPorts.entries());
72
+ writeFileSync(accessDeniedCachePath, JSON.stringify(payload, null, 2));
73
+ } catch (err) {
74
+ console.warn(
75
+ `${TAG} Failed to persist access-denied cache: ${err.message || err}`,
76
+ );
77
+ }
78
+ }
79
+
80
+ function pruneAccessDeniedCache() {
81
+ const now = Date.now();
82
+ let changed = false;
83
+ for (const [port, ts] of accessDeniedPorts.entries()) {
84
+ if (!ts || now - ts > ACCESS_DENIED_COOLDOWN_MS) {
85
+ accessDeniedPorts.delete(port);
86
+ accessDeniedCooldownWarnAt.delete(port);
87
+ changed = true;
88
+ }
89
+ }
90
+ if (changed) persistAccessDeniedCache();
91
+ }
92
+
93
+ function shouldLogAccessDeniedCooldown(port, nowMs = Date.now()) {
94
+ const lastWarnAt = accessDeniedCooldownWarnAt.get(port);
95
+ if (Number.isFinite(lastWarnAt) && nowMs - lastWarnAt < ACCESS_DENIED_COOLDOWN_MS) {
96
+ return false;
97
+ }
98
+ accessDeniedCooldownWarnAt.set(port, nowMs);
99
+ return true;
100
+ }
101
+
102
+ loadAccessDeniedCache();
103
+
104
+ // ── Helpers ─────────────────────────────────────────────────────────────────
105
+
106
+ /**
107
+ * Parse JSON body from an incoming request with size limit.
108
+ * @param {import("node:http").IncomingMessage} req
109
+ * @returns {Promise<object>}
110
+ */
111
+ function parseBody(req) {
112
+ return new Promise((resolve, reject) => {
113
+ const chunks = [];
114
+ let size = 0;
115
+
116
+ req.on("data", (chunk) => {
117
+ size += chunk.length;
118
+ if (size > MAX_BODY_SIZE) {
119
+ req.destroy();
120
+ reject(new Error("Request body too large"));
121
+ return;
122
+ }
123
+ chunks.push(chunk);
124
+ });
125
+
126
+ req.on("end", () => {
127
+ const raw = Buffer.concat(chunks).toString("utf8");
128
+ if (!raw || raw.trim() === "") {
129
+ resolve({});
130
+ return;
131
+ }
132
+ try {
133
+ resolve(JSON.parse(raw));
134
+ } catch (err) {
135
+ // Include preview of malformed JSON for debugging (truncate to 200 chars)
136
+ const preview = raw.length > 200 ? raw.slice(0, 200) + "..." : raw;
137
+ reject(
138
+ new Error(`Invalid JSON body: ${err.message} — Preview: ${preview}`),
139
+ );
140
+ }
141
+ });
142
+
143
+ req.on("error", reject);
144
+ });
145
+ }
146
+
147
+ /**
148
+ * Send a JSON response.
149
+ * @param {import("node:http").ServerResponse} res
150
+ * @param {number} status
151
+ * @param {object} body
152
+ */
153
+ function sendJson(res, status, body) {
154
+ const payload = JSON.stringify(body);
155
+ res.writeHead(status, {
156
+ "Content-Type": "application/json",
157
+ "Access-Control-Allow-Origin": "http://localhost",
158
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
159
+ "Access-Control-Allow-Headers": "Content-Type",
160
+ "Cache-Control": "no-store",
161
+ });
162
+ res.end(payload);
163
+ }
164
+
165
+ /**
166
+ * Extract a task ID from a URL pathname like /api/tasks/:id/...
167
+ * @param {string} pathname
168
+ * @returns {string|null}
169
+ */
170
+ function extractTaskId(pathname) {
171
+ const match = pathname.match(/^\/api\/tasks\/([^/]+)/);
172
+ return match ? match[1] : null;
173
+ }
174
+
175
+ function isAlreadyExitedProcessError(err) {
176
+ const detail = [err?.stderr, err?.stdout, err?.message]
177
+ .map((part) => String(part || ""))
178
+ .join("\n")
179
+ .toLowerCase();
180
+ return (
181
+ detail.includes("no running instance of the task") ||
182
+ detail.includes("no running instance") ||
183
+ detail.includes("no such process") ||
184
+ detail.includes("cannot find the process") ||
185
+ detail.includes("not found") ||
186
+ detail.includes("esrch")
187
+ );
188
+ }
189
+
190
+ function normalizeCommandLine(commandLine) {
191
+ return String(commandLine || "").toLowerCase().replace(/\\/g, "/").trim();
192
+ }
193
+
194
+ function isLikelyBosunCommandLine(commandLine) {
195
+ const normalized = normalizeCommandLine(commandLine);
196
+ if (!normalized) return false;
197
+
198
+ if (normalized.includes(BOSUN_ROOT_HINT)) return true;
199
+
200
+ if (
201
+ normalized.includes("/bosun/") &&
202
+ (normalized.includes("monitor.mjs") ||
203
+ normalized.includes("cli.mjs") ||
204
+ normalized.includes("agent-endpoint.mjs") ||
205
+ normalized.includes("ve-orchestrator"))
206
+ ) {
207
+ return true;
208
+ }
209
+
210
+ // Dev-mode often launches monitor as node monitor.mjs from bosun root.
211
+ if (/\bnode(?:\.exe)?\b/.test(normalized) && /\bmonitor\.mjs\b/.test(normalized)) {
212
+ return true;
213
+ }
214
+
215
+ return false;
216
+ }
217
+
218
+ function summarizeCommandLine(commandLine, maxLen = 140) {
219
+ const compact = String(commandLine || "").replace(/\s+/g, " ").trim();
220
+ if (!compact) return "command line unavailable";
221
+ if (compact.length <= maxLen) return compact;
222
+ return compact.slice(0, maxLen) + "...";
223
+ }
224
+
225
+ // ── AgentEndpoint Class ─────────────────────────────────────────────────────
226
+
227
+ export class AgentEndpoint {
228
+ /**
229
+ * @param {object} options
230
+ * @param {number} [options.port] — Listen port (default: env or 18432)
231
+ * @param {boolean} [options.allowConflictKill] Allow forced cleanup of conflicting listeners
232
+ * @param {object} [options.taskStore] Task store instance (kanban adapter)
233
+ * @param {Function} [options.onTaskComplete] — (taskId, data) => void
234
+ * @param {Function} [options.onTaskError] — (taskId, data) => void
235
+ * @param {Function} [options.onStatusChange] — (taskId, newStatus, source) => void
236
+ */
237
+ constructor(options = {}) {
238
+ const configuredPort =
239
+ options.port ??
240
+ (process.env.AGENT_ENDPOINT_PORT != null
241
+ ? Number(process.env.AGENT_ENDPOINT_PORT)
242
+ : process.env.BOSUN_AGENT_ENDPOINT_PORT != null
243
+ ? Number(process.env.BOSUN_AGENT_ENDPOINT_PORT)
244
+ : DEFAULT_PORT);
245
+ this._port =
246
+ Number.isInteger(configuredPort) && configuredPort > 0 && configuredPort <= 65535
247
+ ? configuredPort
248
+ : DEFAULT_PORT;
249
+ this._allowConflictKill = options.allowConflictKill === true;
250
+ this._taskStore = options.taskStore || null;
251
+ this._onTaskComplete = options.onTaskComplete || null;
252
+ this._onTaskError = options.onTaskError || null;
253
+ this._onStatusChange = options.onStatusChange || null;
254
+ this._onPauseTasks = options.onPauseTasks || null;
255
+ this._onResumeTasks = options.onResumeTasks || null;
256
+ this._getExecutorStatus = options.getExecutorStatus || null;
257
+ this._server = null;
258
+ this._running = false;
259
+ this._startedAt = null;
260
+ this._portFilePath = resolve(__dirname, "..", ".cache", "agent-endpoint-port");
261
+ }
262
+
263
+ // ── Lifecycle ───────────────────────────────────────────────────────────
264
+
265
+ /**
266
+ * Start the HTTP server.
267
+ * @returns {Promise<void>}
268
+ */
269
+ async start() {
270
+ if (this._running) return;
271
+
272
+ const MAX_PORT_RETRIES = 5;
273
+ let lastErr;
274
+ pruneAccessDeniedCache();
275
+
276
+ for (let attempt = 0; attempt < MAX_PORT_RETRIES; attempt++) {
277
+ const port = this._port + attempt;
278
+ try {
279
+ await this._tryListen(port);
280
+ this._port = port; // update in case we incremented
281
+ return;
282
+ } catch (err) {
283
+ lastErr = err;
284
+ if (err.code === "EADDRINUSE") {
285
+ if (!this._allowConflictKill) {
286
+ console.warn(
287
+ `${TAG} Port ${port} in use (attempt ${attempt + 1}/${MAX_PORT_RETRIES}), skipping forced kill and trying next port`,
288
+ );
289
+ continue;
290
+ }
291
+ const deniedAt = accessDeniedPorts.get(port);
292
+ const nowMs = Date.now();
293
+ if (deniedAt && nowMs - deniedAt < ACCESS_DENIED_COOLDOWN_MS) {
294
+ if (shouldLogAccessDeniedCooldown(port, nowMs)) {
295
+ console.warn(
296
+ `${TAG} Port ${port} in use and kill blocked (access denied). Skipping retry during cooldown.`,
297
+ );
298
+ }
299
+ continue;
300
+ }
301
+ console.warn(
302
+ `${TAG} Port ${port} in use (attempt ${attempt + 1}/${MAX_PORT_RETRIES}), trying to free it...`,
303
+ );
304
+ // Try to kill the process holding the port (Windows)
305
+ await this._killProcessOnPort(port);
306
+ // Retry same port once after kill
307
+ try {
308
+ await this._tryListen(port);
309
+ this._port = port;
310
+ return;
311
+ } catch (retryErr) {
312
+ if (retryErr.code === "EADDRINUSE") {
313
+ console.warn(
314
+ `${TAG} Port ${port} still in use after kill, trying next port`,
315
+ );
316
+ continue;
317
+ }
318
+ throw retryErr;
319
+ }
320
+ }
321
+ throw err;
322
+ }
323
+ }
324
+
325
+ // All retries exhausted — start without endpoint (non-fatal)
326
+ console.error(
327
+ `${TAG} Could not bind to any port after ${MAX_PORT_RETRIES} attempts: ${lastErr?.message}`,
328
+ );
329
+ console.warn(
330
+ `${TAG} Running WITHOUT agent endpoint — agents can still work via poll-based completion`,
331
+ );
332
+ }
333
+
334
+ /**
335
+ * Attempt to listen on a specific port. Returns a promise.
336
+ * @param {number} port
337
+ * @returns {Promise<void>}
338
+ */
339
+ _tryListen(port) {
340
+ return new Promise((resolveStart, rejectStart) => {
341
+ const server = createServer((req, res) => this._handleRequest(req, res));
342
+ server.setTimeout(REQUEST_TIMEOUT_MS);
343
+
344
+ server.on("timeout", (socket) => {
345
+ console.log(`${TAG} Request timed out, destroying socket`);
346
+ socket.destroy();
347
+ });
348
+
349
+ server.on("error", (err) => {
350
+ if (!this._running) {
351
+ rejectStart(err);
352
+ } else {
353
+ console.error(`${TAG} Server error:`, err.message);
354
+ }
355
+ });
356
+
357
+ server.listen(port, "127.0.0.1", () => {
358
+ this._server = server;
359
+ this._running = true;
360
+ this._startedAt = Date.now();
361
+ console.log(`${TAG} Listening on 127.0.0.1:${port}`);
362
+ this._writePortFile();
363
+ resolveStart();
364
+ });
365
+ });
366
+ }
367
+
368
+ /**
369
+ * Attempt to kill whatever process is holding a port (Windows netstat+taskkill).
370
+ * @param {number} port
371
+ * @returns {Promise<void>}
372
+ */
373
+ async _killProcessOnPort(port) {
374
+ try {
375
+ const { spawnSync } = await import("node:child_process");
376
+ const portNumber = Number.parseInt(String(port), 10);
377
+ if (!Number.isInteger(portNumber) || portNumber <= 0 || portNumber > 65535) {
378
+ throw new Error(`invalid port: ${port}`);
379
+ }
380
+ const isWindows = process.platform === "win32";
381
+ let output;
382
+ const pids = new Set();
383
+
384
+ // PIDs we must NEVER kill — ourselves, our parent (cli.mjs fork host),
385
+ // and any ancestor in the same process tree. lsof can return these when
386
+ // the listening socket fd is inherited across fork().
387
+ const protectedPids = new Set([
388
+ String(process.pid),
389
+ String(process.ppid),
390
+ ]);
391
+
392
+ const readProcessCommandLine = (pid) => {
393
+ try {
394
+ if (isWindows) {
395
+ const query = `$p = Get-CimInstance Win32_Process -Filter "ProcessId=${pid}" -ErrorAction SilentlyContinue; if ($p) { $p.CommandLine }`;
396
+ const result = spawnSync(
397
+ "powershell",
398
+ ["-NoProfile", "-Command", query],
399
+ {
400
+ encoding: "utf8",
401
+ timeout: 5000,
402
+ windowsHide: true,
403
+ stdio: ["ignore", "pipe", "pipe"],
404
+ },
405
+ );
406
+ if (result.error || result.status !== 0) return "";
407
+ return String(result.stdout || "").trim();
408
+ }
409
+
410
+ const result = spawnSync("ps", ["-p", String(pid), "-o", "args="], {
411
+ encoding: "utf8",
412
+ timeout: 5000,
413
+ stdio: ["ignore", "pipe", "pipe"],
414
+ });
415
+ if (result.error || result.status !== 0) return "";
416
+ return String(result.stdout || "").trim();
417
+ } catch {
418
+ return "";
419
+ }
420
+ };
421
+
422
+ if (isWindows) {
423
+ // Windows: netstat -ano then filter in-process to avoid shell command injection.
424
+ const netstatRes = spawnSync("netstat", ["-ano"], {
425
+ encoding: "utf8",
426
+ timeout: 5000,
427
+ windowsHide: true,
428
+ stdio: ["ignore", "pipe", "pipe"],
429
+ });
430
+ if (netstatRes.error) throw netstatRes.error;
431
+ if (netstatRes.status !== 0) {
432
+ throw new Error(
433
+ String(
434
+ netstatRes.stderr ||
435
+ netstatRes.stdout ||
436
+ `netstat exited with status ${netstatRes.status}`,
437
+ ).trim(),
438
+ );
439
+ }
440
+ output = String(netstatRes.stdout || "").trim();
441
+ const lines = output
442
+ .split("\n")
443
+ .filter(
444
+ (line) => line.includes("LISTENING") && line.includes(`:${portNumber}`),
445
+ );
446
+ for (const line of lines) {
447
+ const parts = line.trim().split(/\s+/);
448
+ const pid = parts[parts.length - 1];
449
+ if (pid && /^\d+$/.test(pid) && !protectedPids.has(pid)) {
450
+ pids.add(pid);
451
+ }
452
+ }
453
+ } else {
454
+ // Linux/macOS: lsof -i
455
+ const lsofRes = spawnSync("lsof", ["-ti", `:${portNumber}`], {
456
+ encoding: "utf8",
457
+ timeout: 5000,
458
+ stdio: ["ignore", "pipe", "pipe"],
459
+ });
460
+ if (lsofRes.error) throw lsofRes.error;
461
+ // lsof returns exit code 1 when no processes found (port is free)
462
+ if (lsofRes.status === 1) {
463
+ return;
464
+ }
465
+ if (lsofRes.status !== 0) {
466
+ throw new Error(
467
+ String(
468
+ lsofRes.stderr ||
469
+ lsofRes.stdout ||
470
+ `lsof exited with status ${lsofRes.status}`,
471
+ ).trim(),
472
+ );
473
+ }
474
+ output = String(lsofRes.stdout || "").trim();
475
+ const pidList = output.split("\n").filter((pid) => pid.trim());
476
+ for (const pid of pidList) {
477
+ if (pid && /^\d+$/.test(pid) && !protectedPids.has(pid)) {
478
+ pids.add(pid);
479
+ }
480
+ }
481
+ if (pidList.length > 0 && pids.size === 0) {
482
+ console.log(
483
+ `${TAG} Port ${portNumber} held by own process tree (PIDs: ${pidList.join(", ")}) — skipping kill`,
484
+ );
485
+ return;
486
+ }
487
+ }
488
+
489
+ const killEligiblePids = new Set();
490
+ for (const pid of pids) {
491
+ const commandLine = readProcessCommandLine(pid);
492
+ if (!isLikelyBosunCommandLine(commandLine)) {
493
+ console.warn(
494
+ `${TAG} Port ${port} held by non-bosun PID ${pid} (${summarizeCommandLine(commandLine)}); skipping forced kill`,
495
+ );
496
+ continue;
497
+ }
498
+ killEligiblePids.add(pid);
499
+ }
500
+
501
+ if (killEligiblePids.size === 0) {
502
+ return;
503
+ }
504
+
505
+ for (const pid of killEligiblePids) {
506
+ console.log(`${TAG} Sending SIGTERM to stale process PID ${pid} on port ${port}`);
507
+ try {
508
+ if (isWindows) {
509
+ const killRes = spawnSync(
510
+ "taskkill",
511
+ ["/F", "/PID", String(pid)],
512
+ {
513
+ encoding: "utf8",
514
+ timeout: 5000,
515
+ windowsHide: true,
516
+ stdio: ["ignore", "pipe", "pipe"],
517
+ },
518
+ );
519
+ if (killRes.error) {
520
+ throw killRes.error;
521
+ }
522
+ if (killRes.status !== 0) {
523
+ const err = new Error(
524
+ String(
525
+ killRes.stderr ||
526
+ killRes.stdout ||
527
+ `taskkill exited with status ${killRes.status}`,
528
+ ).trim(),
529
+ );
530
+ err.stderr = killRes.stderr;
531
+ err.stdout = killRes.stdout;
532
+ throw err;
533
+ }
534
+ } else {
535
+ // Graceful SIGTERM first — only escalate to SIGKILL if still alive
536
+ process.kill(Number(pid), "SIGTERM");
537
+ }
538
+ } catch (killErr) {
539
+ /* may already be dead — log for diagnostics */
540
+ const stderrText = String(killErr.stderr || killErr.message || "");
541
+ const stderrLower = stderrText.toLowerCase();
542
+ if (isAlreadyExitedProcessError(killErr)) {
543
+ console.log(
544
+ `${TAG} PID ${pid} already exited before kill on port ${port}; continuing`,
545
+ );
546
+ continue;
547
+ }
548
+ if (
549
+ stderrLower.includes("access is denied") ||
550
+ stderrLower.includes("operation not permitted")
551
+ ) {
552
+ accessDeniedPorts.set(port, Date.now());
553
+ accessDeniedCooldownWarnAt.delete(port);
554
+ persistAccessDeniedCache();
555
+ }
556
+ console.warn(
557
+ `${TAG} kill PID ${pid} failed: ${killErr.stderr?.trim() || killErr.message || "unknown error"}`,
558
+ );
559
+ }
560
+ }
561
+
562
+ // Give the SIGTERM'd processes time to exit gracefully
563
+ await new Promise((r) => setTimeout(r, 2000));
564
+
565
+ // Escalate: check if any are still alive and SIGKILL them
566
+ if (!isWindows) {
567
+ for (const pid of killEligiblePids) {
568
+ try {
569
+ process.kill(Number(pid), 0); // probe throws if dead
570
+ console.warn(`${TAG} PID ${pid} still alive after SIGTERM — sending SIGKILL`);
571
+ process.kill(Number(pid), "SIGKILL");
572
+ } catch {
573
+ /* already dead — good */
574
+ }
575
+ }
576
+ await new Promise((r) => setTimeout(r, 500));
577
+ }
578
+ } catch (outerErr) {
579
+ // Commands may fail if port already free
580
+ if (outerErr.status !== 1) {
581
+ // status 1 = no matching entries (port already free)
582
+ console.warn(
583
+ `${TAG} _killProcessOnPort(${port}) failed: ${outerErr.message || "unknown error"}`,
584
+ );
585
+ }
586
+ }
587
+ }
588
+ /**
589
+ * Stop the HTTP server.
590
+ * @returns {Promise<void>}
591
+ */
592
+ stop() {
593
+ if (!this._running || !this._server) return Promise.resolve();
594
+
595
+ return new Promise((resolveStop) => {
596
+ this._running = false;
597
+ this._removePortFile();
598
+ this._server.close(() => {
599
+ console.log(`${TAG} Server stopped`);
600
+ resolveStop();
601
+ });
602
+ // Force-close lingering connections after 5s
603
+ setTimeout(() => {
604
+ resolveStop();
605
+ }, 5000);
606
+ });
607
+ }
608
+
609
+ /** @returns {number} */
610
+ getPort() {
611
+ return this._port;
612
+ }
613
+
614
+ /** @returns {boolean} */
615
+ isRunning() {
616
+ return this._running;
617
+ }
618
+
619
+ /**
620
+ * Lightweight status for diagnostics (/agents).
621
+ * @returns {{ running: boolean, port: number, startedAt: number|null, uptimeMs: number }}
622
+ */
623
+ getStatus() {
624
+ return {
625
+ running: this._running,
626
+ port: this._port,
627
+ startedAt: this._startedAt || null,
628
+ uptimeMs:
629
+ this._running && this._startedAt
630
+ ? Math.max(0, Date.now() - this._startedAt)
631
+ : 0,
632
+ };
633
+ }
634
+
635
+ // ── Port Discovery File ─────────────────────────────────────────────────
636
+
637
+ _writePortFile() {
638
+ try {
639
+ const dir = dirname(this._portFilePath);
640
+ mkdirSync(dir, { recursive: true });
641
+ writeFileSync(this._portFilePath, String(this._port));
642
+ console.log(
643
+ `${TAG} Port file written: ${this._portFilePath} → ${this._port}`,
644
+ );
645
+ } catch (err) {
646
+ console.error(`${TAG} Failed to write port file:`, err.message);
647
+ }
648
+ }
649
+
650
+ _removePortFile() {
651
+ try {
652
+ unlinkSync(this._portFilePath);
653
+ } catch {
654
+ // Ignore — file may already be gone
655
+ }
656
+ }
657
+
658
+ // ── Request Router ──────────────────────────────────────────────────────
659
+
660
+ /**
661
+ * @param {import("node:http").IncomingMessage} req
662
+ * @param {import("node:http").ServerResponse} res
663
+ */
664
+ async _handleRequest(req, res) {
665
+ // Handle CORS preflight
666
+ if (req.method === "OPTIONS") {
667
+ sendJson(res, 204, {});
668
+ return;
669
+ }
670
+
671
+ const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
672
+ const pathname = url.pathname;
673
+ const method = req.method;
674
+
675
+ try {
676
+ // ── Static routes ───────────────────────────────────────────────
677
+ if (method === "GET" && pathname === "/health") {
678
+ return this._handleHealth(res);
679
+ }
680
+
681
+ if (method === "GET" && pathname === "/api/status") {
682
+ return this._handleStatus(res);
683
+ }
684
+
685
+ if (method === "GET" && pathname === "/api/tasks") {
686
+ return await this._handleListTasks(url, res);
687
+ }
688
+
689
+ if (method === "POST" && pathname === "/api/tasks/create") {
690
+ const body = await parseBody(req);
691
+ return await this._handleCreateTask(body, res);
692
+ }
693
+
694
+ if (method === "GET" && pathname === "/api/tasks/stats") {
695
+ return await this._handleTaskStats(res);
696
+ }
697
+
698
+ if (method === "POST" && pathname === "/api/tasks/import") {
699
+ const body = await parseBody(req);
700
+ return await this._handleImportTasks(body, res);
701
+ }
702
+
703
+ if (method === "GET" && pathname === "/api/executor") {
704
+ return this._handleExecutorStatus(res);
705
+ }
706
+ if (method === "POST" && pathname === "/api/executor/pause") {
707
+ return await this._handlePauseTasks(res);
708
+ }
709
+ if (method === "POST" && pathname === "/api/executor/resume") {
710
+ return await this._handleResumeTasks(res);
711
+ }
712
+
713
+ // ── Task-specific routes ────────────────────────────────────────
714
+ const taskId = extractTaskId(pathname);
715
+
716
+ if (taskId) {
717
+ if (method === "GET" && pathname === `/api/tasks/${taskId}`) {
718
+ return await this._handleGetTask(taskId, res);
719
+ }
720
+
721
+ if (method === "POST" && pathname === `/api/tasks/${taskId}/status`) {
722
+ const body = await parseBody(req);
723
+ return await this._handleStatusChange(taskId, body, res);
724
+ }
725
+
726
+ if (
727
+ method === "POST" &&
728
+ pathname === `/api/tasks/${taskId}/heartbeat`
729
+ ) {
730
+ const body = await parseBody(req);
731
+ return await this._handleHeartbeat(taskId, body, res);
732
+ }
733
+
734
+ if (method === "POST" && pathname === `/api/tasks/${taskId}/complete`) {
735
+ const body = await parseBody(req);
736
+ return await this._handleComplete(taskId, body, res);
737
+ }
738
+
739
+ if (method === "POST" && pathname === `/api/tasks/${taskId}/error`) {
740
+ const body = await parseBody(req);
741
+ return await this._handleError(taskId, body, res);
742
+ }
743
+
744
+ if (method === "POST" && pathname === `/api/tasks/${taskId}/update`) {
745
+ const body = await parseBody(req);
746
+ return await this._handleUpdateTask(taskId, body, res);
747
+ }
748
+
749
+ if (method === "DELETE" && pathname === `/api/tasks/${taskId}`) {
750
+ return await this._handleDeleteTask(taskId, res);
751
+ }
752
+ }
753
+
754
+ // ── 404 ─────────────────────────────────────────────────────────
755
+ sendJson(res, 404, { error: "Not found", path: pathname });
756
+ } catch (err) {
757
+ console.error(`${TAG} ${method} ${pathname} error:`, err.message);
758
+ sendJson(res, 500, { error: err.message || "Internal server error" });
759
+ }
760
+ }
761
+
762
+ // ── Route Handlers ──────────────────────────────────────────────────────
763
+
764
+ _handleHealth(res) {
765
+ const uptimeSeconds =
766
+ this._startedAt != null
767
+ ? Math.floor((Date.now() - this._startedAt) / 1000)
768
+ : 0;
769
+ sendJson(res, 200, { ok: true, uptime: uptimeSeconds });
770
+ }
771
+
772
+ _handleStatus(res) {
773
+ const uptimeSeconds =
774
+ this._startedAt != null
775
+ ? Math.floor((Date.now() - this._startedAt) / 1000)
776
+ : 0;
777
+ const storeStats = this._taskStore
778
+ ? { connected: true }
779
+ : { connected: false };
780
+
781
+ sendJson(res, 200, {
782
+ executor: { running: this._running, port: this._port },
783
+ store: storeStats,
784
+ uptime: uptimeSeconds,
785
+ });
786
+ }
787
+
788
+ async _handleListTasks(url, res) {
789
+ if (!this._taskStore) {
790
+ sendJson(res, 503, { error: "Task store not configured" });
791
+ return;
792
+ }
793
+
794
+ const statusFilter = url.searchParams.get("status") || undefined;
795
+
796
+ try {
797
+ let tasks;
798
+ if (typeof this._taskStore.listTasks === "function") {
799
+ // kanban adapter-style store
800
+ tasks = await this._taskStore.listTasks(null, { status: statusFilter });
801
+ } else if (typeof this._taskStore.list === "function") {
802
+ tasks = await this._taskStore.list({ status: statusFilter });
803
+ } else {
804
+ sendJson(res, 501, { error: "Task store does not support listing" });
805
+ return;
806
+ }
807
+
808
+ const taskList = Array.isArray(tasks) ? tasks : [];
809
+ sendJson(res, 200, { tasks: taskList, count: taskList.length });
810
+ } catch (err) {
811
+ console.error(`${TAG} listTasks error:`, err.message);
812
+ sendJson(res, 500, { error: `Failed to list tasks: ${err.message}` });
813
+ }
814
+ }
815
+
816
+ async _handleGetTask(taskId, res) {
817
+ if (!this._taskStore) {
818
+ sendJson(res, 503, { error: "Task store not configured" });
819
+ return;
820
+ }
821
+
822
+ try {
823
+ let task;
824
+ if (typeof this._taskStore.getTask === "function") {
825
+ task = await this._taskStore.getTask(taskId);
826
+ } else if (typeof this._taskStore.get === "function") {
827
+ task = await this._taskStore.get(taskId);
828
+ } else {
829
+ sendJson(res, 501, { error: "Task store does not support get" });
830
+ return;
831
+ }
832
+
833
+ if (!task) {
834
+ sendJson(res, 404, { error: "Task not found" });
835
+ return;
836
+ }
837
+
838
+ sendJson(res, 200, { task });
839
+ } catch (err) {
840
+ console.error(`${TAG} getTask(${taskId}) error:`, err.message);
841
+ sendJson(res, 404, { error: "Task not found" });
842
+ }
843
+ }
844
+
845
+ _handleExecutorStatus(res) {
846
+ if (typeof this._getExecutorStatus !== "function") {
847
+ sendJson(res, 503, { error: "Executor status provider not configured" });
848
+ return;
849
+ }
850
+ try {
851
+ const status = this._getExecutorStatus() || {};
852
+ sendJson(res, 200, { ok: true, status });
853
+ } catch (err) {
854
+ sendJson(res, 500, {
855
+ error: `Failed to get executor status: ${err.message}`,
856
+ });
857
+ }
858
+ }
859
+
860
+ async _handlePauseTasks(res) {
861
+ if (typeof this._onPauseTasks !== "function") {
862
+ sendJson(res, 503, { error: "Pause control not configured" });
863
+ return;
864
+ }
865
+ try {
866
+ const result = await this._onPauseTasks();
867
+ sendJson(res, 200, { ok: true, result: result ?? { paused: true } });
868
+ } catch (err) {
869
+ sendJson(res, 500, { error: `Failed to pause tasks: ${err.message}` });
870
+ }
871
+ }
872
+
873
+ async _handleResumeTasks(res) {
874
+ if (typeof this._onResumeTasks !== "function") {
875
+ sendJson(res, 503, { error: "Resume control not configured" });
876
+ return;
877
+ }
878
+ try {
879
+ const result = await this._onResumeTasks();
880
+ sendJson(res, 200, { ok: true, result: result ?? { paused: false } });
881
+ } catch (err) {
882
+ sendJson(res, 500, { error: `Failed to resume tasks: ${err.message}` });
883
+ }
884
+ }
885
+
886
+ async _handleStatusChange(taskId, body, res) {
887
+ if (!this._taskStore) {
888
+ sendJson(res, 503, { error: "Task store not configured" });
889
+ return;
890
+ }
891
+
892
+ const { status, message } = body;
893
+ if (!status) {
894
+ sendJson(res, 400, { error: "Missing 'status' in body" });
895
+ return;
896
+ }
897
+
898
+ const allowed = ["inreview", "done", "blocked"];
899
+ if (!allowed.includes(status)) {
900
+ sendJson(res, 400, {
901
+ error: `Invalid status '${status}'. Allowed: ${allowed.join(", ")}`,
902
+ });
903
+ return;
904
+ }
905
+
906
+ // Validate transition
907
+ try {
908
+ let currentTask;
909
+ if (typeof this._taskStore.getTask === "function") {
910
+ currentTask = await this._taskStore.getTask(taskId);
911
+ } else if (typeof this._taskStore.get === "function") {
912
+ currentTask = await this._taskStore.get(taskId);
913
+ }
914
+
915
+ if (currentTask) {
916
+ const currentStatus = currentTask.status || "unknown";
917
+ const validNext = VALID_TRANSITIONS[currentStatus];
918
+ if (validNext && !validNext.includes(status)) {
919
+ sendJson(res, 409, {
920
+ error: `Invalid transition: ${currentStatus} → ${status}. Allowed: ${validNext.join(", ")}`,
921
+ });
922
+ return;
923
+ }
924
+ }
925
+ } catch {
926
+ // If we can't fetch current task, proceed anyway
927
+ }
928
+
929
+ try {
930
+ let updatedTask;
931
+ if (typeof this._taskStore.updateTaskStatus === "function") {
932
+ updatedTask = await this._taskStore.updateTaskStatus(taskId, status);
933
+ } else if (typeof this._taskStore.update === "function") {
934
+ updatedTask = await this._taskStore.update(taskId, { status });
935
+ }
936
+
937
+ console.log(
938
+ `${TAG} Task ${taskId} status → ${status} (source=agent)${message ? ` msg="${message}"` : ""}`,
939
+ );
940
+
941
+ if (this._onStatusChange) {
942
+ try {
943
+ await this._onStatusChange(taskId, status, "agent");
944
+ } catch (err) {
945
+ console.error(`${TAG} onStatusChange callback error:`, err.message);
946
+ }
947
+ }
948
+
949
+ sendJson(res, 200, {
950
+ ok: true,
951
+ task: updatedTask || { id: taskId, status },
952
+ });
953
+ } catch (err) {
954
+ console.error(`${TAG} statusChange(${taskId}) error:`, err.message);
955
+ sendJson(res, 500, { error: `Failed to update status: ${err.message}` });
956
+ }
957
+ }
958
+
959
+ async _handleHeartbeat(taskId, body, res) {
960
+ const timestamp = new Date().toISOString();
961
+ const { message } = body;
962
+
963
+ console.log(
964
+ `${TAG} Heartbeat from task ${taskId}${message ? `: ${message}` : ""}`,
965
+ );
966
+
967
+ // Try to update lastActivityAt on the task if the store supports it
968
+ if (this._taskStore) {
969
+ try {
970
+ if (typeof this._taskStore.update === "function") {
971
+ await this._taskStore.update(taskId, { lastActivityAt: timestamp });
972
+ } else if (typeof this._taskStore.updateTaskStatus === "function") {
973
+ // kanban adapter doesn't have a generic update, but heartbeat is still recorded
974
+ }
975
+ } catch {
976
+ // Non-critical — heartbeat is logged regardless
977
+ }
978
+ }
979
+
980
+ sendJson(res, 200, { ok: true, timestamp });
981
+ }
982
+
983
+ async _handleComplete(taskId, body, res) {
984
+ const { hasCommits, branch, prUrl, output, prNumber } = body;
985
+
986
+ console.log(
987
+ `${TAG} Task ${taskId} complete: hasCommits=${!!hasCommits}, branch=${branch || "none"}, pr=${prUrl || "none"}`,
988
+ );
989
+
990
+ if (this._taskStore) {
991
+ try {
992
+ if (typeof this._taskStore.recordAgentAttempt === "function") {
993
+ await this._taskStore.recordAgentAttempt(taskId, {
994
+ output,
995
+ hasCommits: !!hasCommits,
996
+ });
997
+ }
998
+
999
+ if (typeof this._taskStore.update === "function") {
1000
+ const updates = {};
1001
+ if (branch) updates.branchName = branch;
1002
+ if (prUrl) updates.prUrl = prUrl;
1003
+ if (prNumber) updates.prNumber = prNumber;
1004
+ if (Object.keys(updates).length > 0) {
1005
+ await this._taskStore.update(taskId, updates);
1006
+ }
1007
+ }
1008
+ } catch (err) {
1009
+ console.warn(
1010
+ `${TAG} Failed to record completion details for ${taskId}: ${err.message || err}`,
1011
+ );
1012
+ }
1013
+ }
1014
+
1015
+ let nextAction = "cooldown";
1016
+
1017
+ if (hasCommits) {
1018
+ nextAction = "review";
1019
+
1020
+ // Update task status to inreview
1021
+ if (this._taskStore) {
1022
+ try {
1023
+ if (typeof this._taskStore.updateTaskStatus === "function") {
1024
+ await this._taskStore.updateTaskStatus(taskId, "inreview");
1025
+ } else if (typeof this._taskStore.update === "function") {
1026
+ await this._taskStore.update(taskId, { status: "inreview" });
1027
+ }
1028
+ } catch (err) {
1029
+ console.error(
1030
+ `${TAG} Failed to set task ${taskId} to inreview:`,
1031
+ err.message,
1032
+ );
1033
+ }
1034
+ }
1035
+ } else {
1036
+ // No commits record the attempt but don't change status
1037
+ console.log(`${TAG} Task ${taskId} completed with no commits`);
1038
+ }
1039
+
1040
+ // Fire callback
1041
+ if (this._onTaskComplete) {
1042
+ try {
1043
+ await this._onTaskComplete(taskId, {
1044
+ hasCommits,
1045
+ branch,
1046
+ prUrl,
1047
+ output,
1048
+ });
1049
+ } catch (err) {
1050
+ console.error(`${TAG} onTaskComplete callback error:`, err.message);
1051
+ }
1052
+ }
1053
+
1054
+ // Retrieve updated task for response
1055
+ let task = { id: taskId };
1056
+ if (this._taskStore) {
1057
+ try {
1058
+ if (typeof this._taskStore.getTask === "function") {
1059
+ task = (await this._taskStore.getTask(taskId)) || task;
1060
+ } else if (typeof this._taskStore.get === "function") {
1061
+ task = (await this._taskStore.get(taskId)) || task;
1062
+ }
1063
+ } catch {
1064
+ // Use fallback
1065
+ }
1066
+ }
1067
+
1068
+ sendJson(res, 200, { ok: true, task, nextAction });
1069
+ }
1070
+
1071
+ // ── Task CRUD Handlers ──────────────────────────────────────────────────
1072
+
1073
+ async _handleCreateTask(body, res) {
1074
+ if (!this._taskStore) {
1075
+ sendJson(res, 503, { error: "Task store not configured" });
1076
+ return;
1077
+ }
1078
+
1079
+ const { title, description, status, priority, tags, baseBranch, base_branch, workspace, repository, repositories, implementation_steps, acceptance_criteria, verification, draft } = body;
1080
+
1081
+ if (!title) {
1082
+ sendJson(res, 400, { error: "Missing 'title' in body" });
1083
+ return;
1084
+ }
1085
+
1086
+ try {
1087
+ // Build description from structured fields if provided
1088
+ let fullDescription = description || "";
1089
+ if (implementation_steps?.length || acceptance_criteria?.length || verification?.length) {
1090
+ const parts = [fullDescription];
1091
+ if (implementation_steps?.length) {
1092
+ parts.push("", "## Implementation Steps");
1093
+ for (const step of implementation_steps) parts.push(`- ${step}`);
1094
+ }
1095
+ if (acceptance_criteria?.length) {
1096
+ parts.push("", "## Acceptance Criteria");
1097
+ for (const c of acceptance_criteria) parts.push(`- ${c}`);
1098
+ }
1099
+ if (verification?.length) {
1100
+ parts.push("", "## Verification");
1101
+ for (const v of verification) parts.push(`- ${v}`);
1102
+ }
1103
+ fullDescription = parts.join("\n");
1104
+ }
1105
+
1106
+ let task;
1107
+ if (typeof this._taskStore.createTask === "function") {
1108
+ // kanban adapter — handles ID generation
1109
+ task = await this._taskStore.createTask(null, {
1110
+ title,
1111
+ description: fullDescription,
1112
+ status: status || "draft",
1113
+ priority: priority || "medium",
1114
+ tags: tags || [],
1115
+ baseBranch: baseBranch || base_branch || "main",
1116
+ workspace: workspace || "",
1117
+ repository: repository || "",
1118
+ repositories: repositories || [],
1119
+ draft: draft ?? (status === "draft" || !status),
1120
+ });
1121
+ } else if (typeof this._taskStore.addTask === "function") {
1122
+ // raw task-store
1123
+ task = this._taskStore.addTask({
1124
+ id: randomUUID(),
1125
+ title,
1126
+ description: fullDescription,
1127
+ status: status || "draft",
1128
+ priority: priority || "medium",
1129
+ tags: tags || [],
1130
+ baseBranch: baseBranch || base_branch || "main",
1131
+ workspace: workspace || "",
1132
+ repository: repository || "",
1133
+ repositories: repositories || [],
1134
+ draft: draft ?? (status === "draft" || !status),
1135
+ });
1136
+ } else {
1137
+ sendJson(res, 501, { error: "Task store does not support creation" });
1138
+ return;
1139
+ }
1140
+
1141
+ if (!task) {
1142
+ sendJson(res, 500, { error: "Failed to create task" });
1143
+ return;
1144
+ }
1145
+
1146
+ console.log(`${TAG} Created task ${task.id}: ${task.title}`);
1147
+ sendJson(res, 201, { ok: true, task });
1148
+ } catch (err) {
1149
+ console.error(`${TAG} createTask error:`, err.message);
1150
+ sendJson(res, 500, { error: `Failed to create task: ${err.message}` });
1151
+ }
1152
+ }
1153
+
1154
+ async _handleUpdateTask(taskId, body, res) {
1155
+ if (!this._taskStore) {
1156
+ sendJson(res, 503, { error: "Task store not configured" });
1157
+ return;
1158
+ }
1159
+
1160
+ try {
1161
+ // Verify task exists
1162
+ let existing = null;
1163
+ if (typeof this._taskStore.getTask === "function") {
1164
+ existing = await this._taskStore.getTask(taskId);
1165
+ } else if (typeof this._taskStore.get === "function") {
1166
+ existing = await this._taskStore.get(taskId);
1167
+ }
1168
+ if (!existing) {
1169
+ sendJson(res, 404, { error: "Task not found" });
1170
+ return;
1171
+ }
1172
+
1173
+ const updates = { ...body };
1174
+ delete updates.id; // Never allow ID change
1175
+
1176
+ // Normalize base_branch → baseBranch
1177
+ if (updates.base_branch) {
1178
+ updates.baseBranch = updates.base_branch;
1179
+ delete updates.base_branch;
1180
+ }
1181
+
1182
+ // Handle status change with history tracking
1183
+ if (updates.status && updates.status !== existing.status) {
1184
+ if (typeof this._taskStore.updateTaskStatus === "function") {
1185
+ await this._taskStore.updateTaskStatus(taskId, updates.status);
1186
+ }
1187
+ delete updates.status;
1188
+ }
1189
+
1190
+ // Apply remaining updates
1191
+ let updatedTask;
1192
+ if (Object.keys(updates).length > 0) {
1193
+ if (typeof this._taskStore.updateTask === "function") {
1194
+ updatedTask = await this._taskStore.updateTask(taskId, updates);
1195
+ } else if (typeof this._taskStore.update === "function") {
1196
+ updatedTask = await this._taskStore.update(taskId, updates);
1197
+ }
1198
+ }
1199
+
1200
+ // Re-fetch to get final state
1201
+ if (typeof this._taskStore.getTask === "function") {
1202
+ updatedTask = await this._taskStore.getTask(taskId);
1203
+ } else if (typeof this._taskStore.get === "function") {
1204
+ updatedTask = await this._taskStore.get(taskId);
1205
+ }
1206
+
1207
+ console.log(`${TAG} Updated task ${taskId}`);
1208
+ sendJson(res, 200, { ok: true, task: updatedTask || { id: taskId } });
1209
+ } catch (err) {
1210
+ console.error(`${TAG} updateTask(${taskId}) error:`, err.message);
1211
+ sendJson(res, 500, { error: `Failed to update task: ${err.message}` });
1212
+ }
1213
+ }
1214
+
1215
+ async _handleDeleteTask(taskId, res) {
1216
+ if (!this._taskStore) {
1217
+ sendJson(res, 503, { error: "Task store not configured" });
1218
+ return;
1219
+ }
1220
+
1221
+ try {
1222
+ let removed = false;
1223
+ if (typeof this._taskStore.deleteTask === "function") {
1224
+ await this._taskStore.deleteTask(taskId);
1225
+ removed = true;
1226
+ } else if (typeof this._taskStore.removeTask === "function") {
1227
+ removed = this._taskStore.removeTask(taskId);
1228
+ } else {
1229
+ sendJson(res, 501, { error: "Task store does not support deletion" });
1230
+ return;
1231
+ }
1232
+
1233
+ if (!removed) {
1234
+ sendJson(res, 404, { error: "Task not found" });
1235
+ return;
1236
+ }
1237
+
1238
+ console.log(`${TAG} Deleted task ${taskId}`);
1239
+ sendJson(res, 200, { ok: true, deleted: taskId });
1240
+ } catch (err) {
1241
+ console.error(`${TAG} deleteTask(${taskId}) error:`, err.message);
1242
+ sendJson(res, 500, { error: `Failed to delete task: ${err.message}` });
1243
+ }
1244
+ }
1245
+
1246
+ async _handleTaskStats(res) {
1247
+ if (!this._taskStore) {
1248
+ sendJson(res, 503, { error: "Task store not configured" });
1249
+ return;
1250
+ }
1251
+
1252
+ try {
1253
+ let stats;
1254
+ if (typeof this._taskStore.getStats === "function") {
1255
+ stats = this._taskStore.getStats();
1256
+ } else {
1257
+ // Compute from list
1258
+ let tasks = [];
1259
+ if (typeof this._taskStore.listTasks === "function") {
1260
+ tasks = await this._taskStore.listTasks(null, {});
1261
+ } else if (typeof this._taskStore.list === "function") {
1262
+ tasks = await this._taskStore.list({});
1263
+ }
1264
+ const list = Array.isArray(tasks) ? tasks : [];
1265
+ stats = {
1266
+ draft: list.filter((t) => t.status === "draft").length,
1267
+ todo: list.filter((t) => t.status === "todo").length,
1268
+ inprogress: list.filter((t) => t.status === "inprogress").length,
1269
+ inreview: list.filter((t) => t.status === "inreview").length,
1270
+ done: list.filter((t) => t.status === "done").length,
1271
+ blocked: list.filter((t) => t.status === "blocked").length,
1272
+ total: list.length,
1273
+ };
1274
+ }
1275
+
1276
+ sendJson(res, 200, { ok: true, stats });
1277
+ } catch (err) {
1278
+ console.error(`${TAG} taskStats error:`, err.message);
1279
+ sendJson(res, 500, { error: `Failed to get stats: ${err.message}` });
1280
+ }
1281
+ }
1282
+
1283
+ async _handleImportTasks(body, res) {
1284
+ if (!this._taskStore) {
1285
+ sendJson(res, 503, { error: "Task store not configured" });
1286
+ return;
1287
+ }
1288
+
1289
+ const tasks = body.tasks || body.backlog || (Array.isArray(body) ? body : null);
1290
+ if (!tasks || !Array.isArray(tasks)) {
1291
+ sendJson(res, 400, { error: "Body must contain 'tasks' array" });
1292
+ return;
1293
+ }
1294
+
1295
+ const results = { created: [], failed: [] };
1296
+ for (const t of tasks) {
1297
+ try {
1298
+ // Recursively use our create handler logic
1299
+ let fullDescription = t.description || "";
1300
+ if (t.implementation_steps?.length || t.acceptance_criteria?.length || t.verification?.length) {
1301
+ const parts = [fullDescription];
1302
+ if (t.implementation_steps?.length) {
1303
+ parts.push("", "## Implementation Steps");
1304
+ for (const step of t.implementation_steps) parts.push(`- ${step}`);
1305
+ }
1306
+ if (t.acceptance_criteria?.length) {
1307
+ parts.push("", "## Acceptance Criteria");
1308
+ for (const c of t.acceptance_criteria) parts.push(`- ${c}`);
1309
+ }
1310
+ if (t.verification?.length) {
1311
+ parts.push("", "## Verification");
1312
+ for (const v of t.verification) parts.push(`- ${v}`);
1313
+ }
1314
+ fullDescription = parts.join("\n");
1315
+ }
1316
+
1317
+ let task;
1318
+ if (typeof this._taskStore.createTask === "function") {
1319
+ task = await this._taskStore.createTask(null, {
1320
+ title: t.title,
1321
+ description: fullDescription,
1322
+ status: t.status || "draft",
1323
+ priority: t.priority || "medium",
1324
+ tags: t.tags || [],
1325
+ baseBranch: t.baseBranch || t.base_branch || "main",
1326
+ workspace: t.workspace || "",
1327
+ repository: t.repository || "",
1328
+ draft: t.draft ?? true,
1329
+ });
1330
+ } else if (typeof this._taskStore.addTask === "function") {
1331
+ task = this._taskStore.addTask({
1332
+ id: randomUUID(),
1333
+ title: t.title,
1334
+ description: fullDescription,
1335
+ status: t.status || "draft",
1336
+ priority: t.priority || "medium",
1337
+ tags: t.tags || [],
1338
+ baseBranch: t.baseBranch || t.base_branch || "main",
1339
+ workspace: t.workspace || "",
1340
+ repository: t.repository || "",
1341
+ draft: t.draft ?? true,
1342
+ });
1343
+ }
1344
+
1345
+ if (task) {
1346
+ results.created.push({ id: task.id, title: task.title });
1347
+ } else {
1348
+ results.failed.push({ title: t.title, error: "addTask returned null" });
1349
+ }
1350
+ } catch (err) {
1351
+ results.failed.push({ title: t.title || "untitled", error: err.message });
1352
+ }
1353
+ }
1354
+
1355
+ console.log(`${TAG} Import: ${results.created.length} created, ${results.failed.length} failed`);
1356
+ sendJson(res, 200, {
1357
+ ok: true,
1358
+ created: results.created.length,
1359
+ failed: results.failed.length,
1360
+ results,
1361
+ });
1362
+ }
1363
+
1364
+ async _handleError(taskId, body, res) {
1365
+ const { error: errorMsg, pattern, output } = body;
1366
+
1367
+ if (!errorMsg) {
1368
+ sendJson(res, 400, { error: "Missing 'error' in body" });
1369
+ return;
1370
+ }
1371
+
1372
+ const validPatterns = [
1373
+ "plan_stuck",
1374
+ "rate_limit",
1375
+ "token_overflow",
1376
+ "api_error",
1377
+ ];
1378
+ if (pattern && !validPatterns.includes(pattern)) {
1379
+ console.log(
1380
+ `${TAG} Task ${taskId} error with unknown pattern '${pattern}': ${errorMsg}`,
1381
+ );
1382
+ } else {
1383
+ console.log(
1384
+ `${TAG} Task ${taskId} error${pattern ? ` (${pattern})` : ""}: ${errorMsg}`,
1385
+ );
1386
+ }
1387
+
1388
+ if (this._taskStore) {
1389
+ try {
1390
+ if (typeof this._taskStore.recordAgentAttempt === "function") {
1391
+ await this._taskStore.recordAgentAttempt(taskId, {
1392
+ output,
1393
+ error: errorMsg,
1394
+ hasCommits: false,
1395
+ });
1396
+ }
1397
+ if (
1398
+ pattern &&
1399
+ typeof this._taskStore.recordErrorPattern === "function"
1400
+ ) {
1401
+ await this._taskStore.recordErrorPattern(taskId, pattern);
1402
+ }
1403
+ } catch (err) {
1404
+ console.warn(
1405
+ `${TAG} Failed to record error details for ${taskId}: ${err.message || err}`,
1406
+ );
1407
+ }
1408
+ }
1409
+
1410
+ // Determine action based on pattern
1411
+ let action = "retry";
1412
+ if (pattern === "rate_limit") {
1413
+ action = "cooldown";
1414
+ } else if (pattern === "token_overflow") {
1415
+ action = "blocked";
1416
+ } else if (pattern === "plan_stuck") {
1417
+ action = "retry";
1418
+ } else if (pattern === "api_error") {
1419
+ action = "cooldown";
1420
+ }
1421
+
1422
+ // Fire callback
1423
+ if (this._onTaskError) {
1424
+ try {
1425
+ await this._onTaskError(taskId, { error: errorMsg, pattern });
1426
+ } catch (err) {
1427
+ console.error(`${TAG} onTaskError callback error:`, err.message);
1428
+ }
1429
+ }
1430
+
1431
+ sendJson(res, 200, { ok: true, action });
1432
+ }
1433
+ }
1434
+
1435
+ // ── Factory ─────────────────────────────────────────────────────────────────
1436
+
1437
+ /**
1438
+ * Create an AgentEndpoint instance.
1439
+ * @param {object} [options] — Same as AgentEndpoint constructor
1440
+ * @returns {AgentEndpoint}
1441
+ */
1442
+ export function createAgentEndpoint(options) {
1443
+ return new AgentEndpoint(options);
1444
+ }
1431
1445