@virtengine/openfleet 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +914 -0
- package/LICENSE +190 -0
- package/README.md +500 -0
- package/agent-endpoint.mjs +918 -0
- package/agent-hook-bridge.mjs +230 -0
- package/agent-hooks.mjs +1188 -0
- package/agent-pool.mjs +2403 -0
- package/agent-prompts.mjs +689 -0
- package/agent-sdk.mjs +141 -0
- package/anomaly-detector.mjs +1195 -0
- package/autofix.mjs +1294 -0
- package/claude-shell.mjs +708 -0
- package/cli.mjs +906 -0
- package/codex-config.mjs +1274 -0
- package/codex-model-profiles.mjs +135 -0
- package/codex-shell.mjs +762 -0
- package/config-doctor.mjs +613 -0
- package/config.mjs +1720 -0
- package/conflict-resolver.mjs +248 -0
- package/container-runner.mjs +450 -0
- package/copilot-shell.mjs +827 -0
- package/daemon-restart-policy.mjs +56 -0
- package/diff-stats.mjs +282 -0
- package/error-detector.mjs +829 -0
- package/fetch-runtime.mjs +34 -0
- package/fleet-coordinator.mjs +838 -0
- package/get-telegram-chat-id.mjs +71 -0
- package/git-safety.mjs +170 -0
- package/github-reconciler.mjs +403 -0
- package/hook-profiles.mjs +651 -0
- package/kanban-adapter.mjs +4491 -0
- package/lib/logger.mjs +645 -0
- package/maintenance.mjs +828 -0
- package/merge-strategy.mjs +1171 -0
- package/monitor.mjs +12207 -0
- package/openfleet.config.example.json +115 -0
- package/openfleet.schema.json +465 -0
- package/package.json +203 -0
- package/postinstall.mjs +187 -0
- package/pr-cleanup-daemon.mjs +978 -0
- package/preflight.mjs +408 -0
- package/prepublish-check.mjs +90 -0
- package/presence.mjs +328 -0
- package/primary-agent.mjs +282 -0
- package/publish.mjs +151 -0
- package/repo-root.mjs +29 -0
- package/restart-controller.mjs +100 -0
- package/review-agent.mjs +557 -0
- package/rotate-agent-logs.sh +133 -0
- package/sdk-conflict-resolver.mjs +973 -0
- package/session-tracker.mjs +880 -0
- package/setup.mjs +3937 -0
- package/shared-knowledge.mjs +410 -0
- package/shared-state-manager.mjs +841 -0
- package/shared-workspace-cli.mjs +199 -0
- package/shared-workspace-registry.mjs +537 -0
- package/shared-workspaces.json +18 -0
- package/startup-service.mjs +1070 -0
- package/sync-engine.mjs +1063 -0
- package/task-archiver.mjs +801 -0
- package/task-assessment.mjs +550 -0
- package/task-claims.mjs +924 -0
- package/task-complexity.mjs +581 -0
- package/task-executor.mjs +5111 -0
- package/task-store.mjs +753 -0
- package/telegram-bot.mjs +9281 -0
- package/telegram-sentinel.mjs +2010 -0
- package/ui/app.js +867 -0
- package/ui/app.legacy.js +1464 -0
- package/ui/app.monolith.js +2488 -0
- package/ui/components/charts.js +226 -0
- package/ui/components/chat-view.js +567 -0
- package/ui/components/command-palette.js +587 -0
- package/ui/components/diff-viewer.js +190 -0
- package/ui/components/forms.js +327 -0
- package/ui/components/kanban-board.js +451 -0
- package/ui/components/session-list.js +305 -0
- package/ui/components/shared.js +473 -0
- package/ui/index.html +70 -0
- package/ui/modules/api.js +297 -0
- package/ui/modules/icons.js +461 -0
- package/ui/modules/router.js +81 -0
- package/ui/modules/settings-schema.js +261 -0
- package/ui/modules/state.js +679 -0
- package/ui/modules/telegram.js +331 -0
- package/ui/modules/utils.js +270 -0
- package/ui/styles/animations.css +140 -0
- package/ui/styles/base.css +98 -0
- package/ui/styles/components.css +1915 -0
- package/ui/styles/kanban.css +286 -0
- package/ui/styles/layout.css +809 -0
- package/ui/styles/sessions.css +827 -0
- package/ui/styles/variables.css +188 -0
- package/ui/styles.css +141 -0
- package/ui/styles.monolith.css +1046 -0
- package/ui/tabs/agents.js +1417 -0
- package/ui/tabs/chat.js +74 -0
- package/ui/tabs/control.js +887 -0
- package/ui/tabs/dashboard.js +515 -0
- package/ui/tabs/infra.js +537 -0
- package/ui/tabs/logs.js +783 -0
- package/ui/tabs/settings.js +1487 -0
- package/ui/tabs/tasks.js +1385 -0
- package/ui-server.mjs +4073 -0
- package/update-check.mjs +465 -0
- package/utils.mjs +172 -0
- package/ve-kanban.mjs +654 -0
- package/ve-kanban.ps1 +1365 -0
- package/ve-kanban.sh +18 -0
- package/ve-orchestrator.mjs +340 -0
- package/ve-orchestrator.ps1 +6546 -0
- package/ve-orchestrator.sh +18 -0
- package/vibe-kanban-wrapper.mjs +41 -0
- package/vk-error-resolver.mjs +470 -0
- package/vk-log-stream.mjs +914 -0
- package/whatsapp-channel.mjs +520 -0
- package/workspace-monitor.mjs +581 -0
- package/workspace-reaper.mjs +405 -0
- package/workspace-registry.mjs +238 -0
- package/worktree-manager.mjs +1266 -0
|
@@ -0,0 +1,918 @@
|
|
|
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
|
+
|
|
24
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
25
|
+
const __dirname = dirname(__filename);
|
|
26
|
+
|
|
27
|
+
const TAG = "[agent-endpoint]";
|
|
28
|
+
|
|
29
|
+
const DEFAULT_PORT = 18432;
|
|
30
|
+
const MAX_BODY_SIZE = 1024 * 1024; // 1 MB
|
|
31
|
+
const REQUEST_TIMEOUT_MS = 30_000; // 30 seconds
|
|
32
|
+
const ACCESS_DENIED_COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes
|
|
33
|
+
|
|
34
|
+
// Valid status transitions when an agent self-reports
|
|
35
|
+
const VALID_TRANSITIONS = {
|
|
36
|
+
inprogress: ["inreview", "blocked", "done"],
|
|
37
|
+
inreview: ["done"],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Track ports where we lack permission to terminate the owning process.
|
|
41
|
+
const accessDeniedPorts = new Map();
|
|
42
|
+
const accessDeniedCachePath = resolve(
|
|
43
|
+
__dirname,
|
|
44
|
+
".cache",
|
|
45
|
+
"agent-endpoint-access-denied.json",
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
function loadAccessDeniedCache() {
|
|
49
|
+
try {
|
|
50
|
+
const raw = readFileSync(accessDeniedCachePath, "utf8");
|
|
51
|
+
const data = JSON.parse(raw);
|
|
52
|
+
if (!data || typeof data !== "object") return;
|
|
53
|
+
Object.entries(data).forEach(([port, ts]) => {
|
|
54
|
+
const parsedPort = Number(port);
|
|
55
|
+
const parsedTs = Number(ts);
|
|
56
|
+
if (Number.isFinite(parsedPort) && Number.isFinite(parsedTs)) {
|
|
57
|
+
accessDeniedPorts.set(parsedPort, parsedTs);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
} catch {
|
|
61
|
+
// Ignore missing/invalid cache
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function persistAccessDeniedCache() {
|
|
66
|
+
try {
|
|
67
|
+
mkdirSync(dirname(accessDeniedCachePath), { recursive: true });
|
|
68
|
+
const payload = Object.fromEntries(accessDeniedPorts.entries());
|
|
69
|
+
writeFileSync(accessDeniedCachePath, JSON.stringify(payload, null, 2));
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.warn(
|
|
72
|
+
`${TAG} Failed to persist access-denied cache: ${err.message || err}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function pruneAccessDeniedCache() {
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
let changed = false;
|
|
80
|
+
for (const [port, ts] of accessDeniedPorts.entries()) {
|
|
81
|
+
if (!ts || now - ts > ACCESS_DENIED_COOLDOWN_MS) {
|
|
82
|
+
accessDeniedPorts.delete(port);
|
|
83
|
+
changed = true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (changed) persistAccessDeniedCache();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
loadAccessDeniedCache();
|
|
90
|
+
|
|
91
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parse JSON body from an incoming request with size limit.
|
|
95
|
+
* @param {import("node:http").IncomingMessage} req
|
|
96
|
+
* @returns {Promise<object>}
|
|
97
|
+
*/
|
|
98
|
+
function parseBody(req) {
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const chunks = [];
|
|
101
|
+
let size = 0;
|
|
102
|
+
|
|
103
|
+
req.on("data", (chunk) => {
|
|
104
|
+
size += chunk.length;
|
|
105
|
+
if (size > MAX_BODY_SIZE) {
|
|
106
|
+
req.destroy();
|
|
107
|
+
reject(new Error("Request body too large"));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
chunks.push(chunk);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
req.on("end", () => {
|
|
114
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
115
|
+
if (!raw || raw.trim() === "") {
|
|
116
|
+
resolve({});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
resolve(JSON.parse(raw));
|
|
121
|
+
} catch (err) {
|
|
122
|
+
// Include preview of malformed JSON for debugging (truncate to 200 chars)
|
|
123
|
+
const preview = raw.length > 200 ? raw.slice(0, 200) + "..." : raw;
|
|
124
|
+
reject(
|
|
125
|
+
new Error(`Invalid JSON body: ${err.message} — Preview: ${preview}`),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
req.on("error", reject);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Send a JSON response.
|
|
136
|
+
* @param {import("node:http").ServerResponse} res
|
|
137
|
+
* @param {number} status
|
|
138
|
+
* @param {object} body
|
|
139
|
+
*/
|
|
140
|
+
function sendJson(res, status, body) {
|
|
141
|
+
const payload = JSON.stringify(body);
|
|
142
|
+
res.writeHead(status, {
|
|
143
|
+
"Content-Type": "application/json",
|
|
144
|
+
"Access-Control-Allow-Origin": "http://localhost",
|
|
145
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
146
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
147
|
+
"Cache-Control": "no-store",
|
|
148
|
+
});
|
|
149
|
+
res.end(payload);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Extract a task ID from a URL pathname like /api/tasks/:id/...
|
|
154
|
+
* @param {string} pathname
|
|
155
|
+
* @returns {string|null}
|
|
156
|
+
*/
|
|
157
|
+
function extractTaskId(pathname) {
|
|
158
|
+
const match = pathname.match(/^\/api\/tasks\/([^/]+)/);
|
|
159
|
+
return match ? match[1] : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── AgentEndpoint Class ─────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
export class AgentEndpoint {
|
|
165
|
+
/**
|
|
166
|
+
* @param {object} options
|
|
167
|
+
* @param {number} [options.port] — Listen port (default: env or 18432)
|
|
168
|
+
* @param {object} [options.taskStore] — Task store instance (kanban adapter)
|
|
169
|
+
* @param {Function} [options.onTaskComplete] — (taskId, data) => void
|
|
170
|
+
* @param {Function} [options.onTaskError] — (taskId, data) => void
|
|
171
|
+
* @param {Function} [options.onStatusChange] — (taskId, newStatus, source) => void
|
|
172
|
+
*/
|
|
173
|
+
constructor(options = {}) {
|
|
174
|
+
this._port =
|
|
175
|
+
options.port ||
|
|
176
|
+
(process.env.AGENT_ENDPOINT_PORT
|
|
177
|
+
? Number(process.env.AGENT_ENDPOINT_PORT)
|
|
178
|
+
: DEFAULT_PORT);
|
|
179
|
+
this._taskStore = options.taskStore || null;
|
|
180
|
+
this._onTaskComplete = options.onTaskComplete || null;
|
|
181
|
+
this._onTaskError = options.onTaskError || null;
|
|
182
|
+
this._onStatusChange = options.onStatusChange || null;
|
|
183
|
+
this._onPauseTasks = options.onPauseTasks || null;
|
|
184
|
+
this._onResumeTasks = options.onResumeTasks || null;
|
|
185
|
+
this._getExecutorStatus = options.getExecutorStatus || null;
|
|
186
|
+
this._server = null;
|
|
187
|
+
this._running = false;
|
|
188
|
+
this._startedAt = null;
|
|
189
|
+
this._portFilePath = resolve(__dirname, ".cache", "agent-endpoint-port");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Lifecycle ───────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Start the HTTP server.
|
|
196
|
+
* @returns {Promise<void>}
|
|
197
|
+
*/
|
|
198
|
+
async start() {
|
|
199
|
+
if (this._running) return;
|
|
200
|
+
|
|
201
|
+
const MAX_PORT_RETRIES = 5;
|
|
202
|
+
let lastErr;
|
|
203
|
+
pruneAccessDeniedCache();
|
|
204
|
+
|
|
205
|
+
for (let attempt = 0; attempt < MAX_PORT_RETRIES; attempt++) {
|
|
206
|
+
const port = this._port + attempt;
|
|
207
|
+
try {
|
|
208
|
+
await this._tryListen(port);
|
|
209
|
+
this._port = port; // update in case we incremented
|
|
210
|
+
return;
|
|
211
|
+
} catch (err) {
|
|
212
|
+
lastErr = err;
|
|
213
|
+
if (err.code === "EADDRINUSE") {
|
|
214
|
+
const deniedAt = accessDeniedPorts.get(port);
|
|
215
|
+
if (deniedAt && Date.now() - deniedAt < ACCESS_DENIED_COOLDOWN_MS) {
|
|
216
|
+
console.warn(
|
|
217
|
+
`${TAG} Port ${port} in use and kill blocked (access denied). Skipping retry during cooldown.`,
|
|
218
|
+
);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
console.warn(
|
|
222
|
+
`${TAG} Port ${port} in use (attempt ${attempt + 1}/${MAX_PORT_RETRIES}), trying to free it...`,
|
|
223
|
+
);
|
|
224
|
+
// Try to kill the process holding the port (Windows)
|
|
225
|
+
await this._killProcessOnPort(port);
|
|
226
|
+
// Retry same port once after kill
|
|
227
|
+
try {
|
|
228
|
+
await this._tryListen(port);
|
|
229
|
+
this._port = port;
|
|
230
|
+
return;
|
|
231
|
+
} catch (retryErr) {
|
|
232
|
+
if (retryErr.code === "EADDRINUSE") {
|
|
233
|
+
console.warn(
|
|
234
|
+
`${TAG} Port ${port} still in use after kill, trying next port`,
|
|
235
|
+
);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
throw retryErr;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// All retries exhausted — start without endpoint (non-fatal)
|
|
246
|
+
console.error(
|
|
247
|
+
`${TAG} Could not bind to any port after ${MAX_PORT_RETRIES} attempts: ${lastErr?.message}`,
|
|
248
|
+
);
|
|
249
|
+
console.warn(
|
|
250
|
+
`${TAG} Running WITHOUT agent endpoint — agents can still work via poll-based completion`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Attempt to listen on a specific port. Returns a promise.
|
|
256
|
+
* @param {number} port
|
|
257
|
+
* @returns {Promise<void>}
|
|
258
|
+
*/
|
|
259
|
+
_tryListen(port) {
|
|
260
|
+
return new Promise((resolveStart, rejectStart) => {
|
|
261
|
+
const server = createServer((req, res) => this._handleRequest(req, res));
|
|
262
|
+
server.setTimeout(REQUEST_TIMEOUT_MS);
|
|
263
|
+
|
|
264
|
+
server.on("timeout", (socket) => {
|
|
265
|
+
console.log(`${TAG} Request timed out, destroying socket`);
|
|
266
|
+
socket.destroy();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
server.on("error", (err) => {
|
|
270
|
+
if (!this._running) {
|
|
271
|
+
rejectStart(err);
|
|
272
|
+
} else {
|
|
273
|
+
console.error(`${TAG} Server error:`, err.message);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
server.listen(port, "127.0.0.1", () => {
|
|
278
|
+
this._server = server;
|
|
279
|
+
this._running = true;
|
|
280
|
+
this._startedAt = Date.now();
|
|
281
|
+
console.log(`${TAG} Listening on 127.0.0.1:${port}`);
|
|
282
|
+
this._writePortFile();
|
|
283
|
+
resolveStart();
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Attempt to kill whatever process is holding a port (Windows netstat+taskkill).
|
|
290
|
+
* @param {number} port
|
|
291
|
+
* @returns {Promise<void>}
|
|
292
|
+
*/
|
|
293
|
+
async _killProcessOnPort(port) {
|
|
294
|
+
try {
|
|
295
|
+
const { execSync } = await import("node:child_process");
|
|
296
|
+
const isWindows = process.platform === "win32";
|
|
297
|
+
let output;
|
|
298
|
+
let pids = new Set();
|
|
299
|
+
|
|
300
|
+
if (isWindows) {
|
|
301
|
+
// Windows: netstat -ano | findstr
|
|
302
|
+
output = execSync(`netstat -ano | findstr ":${port}"`, {
|
|
303
|
+
encoding: "utf8",
|
|
304
|
+
timeout: 5000,
|
|
305
|
+
}).trim();
|
|
306
|
+
const lines = output.split("\n").filter((l) => l.includes("LISTENING"));
|
|
307
|
+
for (const line of lines) {
|
|
308
|
+
const parts = line.trim().split(/\s+/);
|
|
309
|
+
const pid = parts[parts.length - 1];
|
|
310
|
+
if (pid && /^\d+$/.test(pid) && pid !== String(process.pid)) {
|
|
311
|
+
pids.add(pid);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
} else {
|
|
315
|
+
// Linux/macOS: lsof -i
|
|
316
|
+
try {
|
|
317
|
+
output = execSync(`lsof -ti :${port}`, {
|
|
318
|
+
encoding: "utf8",
|
|
319
|
+
timeout: 5000,
|
|
320
|
+
}).trim();
|
|
321
|
+
const pidList = output.split("\n").filter((p) => p.trim());
|
|
322
|
+
for (const pid of pidList) {
|
|
323
|
+
if (pid && /^\d+$/.test(pid) && pid !== String(process.pid)) {
|
|
324
|
+
pids.add(pid);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
} catch (lsofErr) {
|
|
328
|
+
// lsof returns exit code 1 when no processes found (port is free)
|
|
329
|
+
if (lsofErr.status === 1) {
|
|
330
|
+
return; // Port is already free
|
|
331
|
+
}
|
|
332
|
+
throw lsofErr;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
for (const pid of pids) {
|
|
337
|
+
console.log(`${TAG} Killing stale process PID ${pid} on port ${port}`);
|
|
338
|
+
try {
|
|
339
|
+
if (isWindows) {
|
|
340
|
+
execSync(`taskkill /F /PID ${pid}`, {
|
|
341
|
+
encoding: "utf8",
|
|
342
|
+
timeout: 5000,
|
|
343
|
+
});
|
|
344
|
+
} else {
|
|
345
|
+
execSync(`kill -9 ${pid}`, {
|
|
346
|
+
encoding: "utf8",
|
|
347
|
+
timeout: 5000,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
} catch (killErr) {
|
|
351
|
+
/* may already be dead — log for diagnostics */
|
|
352
|
+
const stderrText = String(killErr.stderr || "");
|
|
353
|
+
if (
|
|
354
|
+
stderrText.toLowerCase().includes("access is denied") ||
|
|
355
|
+
stderrText.toLowerCase().includes("operation not permitted")
|
|
356
|
+
) {
|
|
357
|
+
accessDeniedPorts.set(port, Date.now());
|
|
358
|
+
persistAccessDeniedCache();
|
|
359
|
+
}
|
|
360
|
+
console.warn(
|
|
361
|
+
`${TAG} kill PID ${pid} failed: ${killErr.stderr?.trim() || killErr.message || "unknown error"}`,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
// Give OS time to release the port
|
|
366
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
367
|
+
} catch (outerErr) {
|
|
368
|
+
// Commands may fail if port already free
|
|
369
|
+
if (outerErr.status !== 1) {
|
|
370
|
+
// status 1 = no matching entries (port already free)
|
|
371
|
+
console.warn(
|
|
372
|
+
`${TAG} _killProcessOnPort(${port}) failed: ${outerErr.message || "unknown error"}`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Stop the HTTP server.
|
|
380
|
+
* @returns {Promise<void>}
|
|
381
|
+
*/
|
|
382
|
+
stop() {
|
|
383
|
+
if (!this._running || !this._server) return Promise.resolve();
|
|
384
|
+
|
|
385
|
+
return new Promise((resolveStop) => {
|
|
386
|
+
this._running = false;
|
|
387
|
+
this._removePortFile();
|
|
388
|
+
this._server.close(() => {
|
|
389
|
+
console.log(`${TAG} Server stopped`);
|
|
390
|
+
resolveStop();
|
|
391
|
+
});
|
|
392
|
+
// Force-close lingering connections after 5s
|
|
393
|
+
setTimeout(() => {
|
|
394
|
+
resolveStop();
|
|
395
|
+
}, 5000);
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** @returns {number} */
|
|
400
|
+
getPort() {
|
|
401
|
+
return this._port;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** @returns {boolean} */
|
|
405
|
+
isRunning() {
|
|
406
|
+
return this._running;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Lightweight status for diagnostics (/agents).
|
|
411
|
+
* @returns {{ running: boolean, port: number, startedAt: number|null, uptimeMs: number }}
|
|
412
|
+
*/
|
|
413
|
+
getStatus() {
|
|
414
|
+
return {
|
|
415
|
+
running: this._running,
|
|
416
|
+
port: this._port,
|
|
417
|
+
startedAt: this._startedAt || null,
|
|
418
|
+
uptimeMs:
|
|
419
|
+
this._running && this._startedAt
|
|
420
|
+
? Math.max(0, Date.now() - this._startedAt)
|
|
421
|
+
: 0,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ── Port Discovery File ─────────────────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
_writePortFile() {
|
|
428
|
+
try {
|
|
429
|
+
const dir = dirname(this._portFilePath);
|
|
430
|
+
mkdirSync(dir, { recursive: true });
|
|
431
|
+
writeFileSync(this._portFilePath, String(this._port));
|
|
432
|
+
console.log(
|
|
433
|
+
`${TAG} Port file written: ${this._portFilePath} → ${this._port}`,
|
|
434
|
+
);
|
|
435
|
+
} catch (err) {
|
|
436
|
+
console.error(`${TAG} Failed to write port file:`, err.message);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
_removePortFile() {
|
|
441
|
+
try {
|
|
442
|
+
unlinkSync(this._portFilePath);
|
|
443
|
+
} catch {
|
|
444
|
+
// Ignore — file may already be gone
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ── Request Router ──────────────────────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* @param {import("node:http").IncomingMessage} req
|
|
452
|
+
* @param {import("node:http").ServerResponse} res
|
|
453
|
+
*/
|
|
454
|
+
async _handleRequest(req, res) {
|
|
455
|
+
// Handle CORS preflight
|
|
456
|
+
if (req.method === "OPTIONS") {
|
|
457
|
+
sendJson(res, 204, {});
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
|
462
|
+
const pathname = url.pathname;
|
|
463
|
+
const method = req.method;
|
|
464
|
+
|
|
465
|
+
try {
|
|
466
|
+
// ── Static routes ───────────────────────────────────────────────
|
|
467
|
+
if (method === "GET" && pathname === "/health") {
|
|
468
|
+
return this._handleHealth(res);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (method === "GET" && pathname === "/api/status") {
|
|
472
|
+
return this._handleStatus(res);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (method === "GET" && pathname === "/api/tasks") {
|
|
476
|
+
return await this._handleListTasks(url, res);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (method === "GET" && pathname === "/api/executor") {
|
|
480
|
+
return this._handleExecutorStatus(res);
|
|
481
|
+
}
|
|
482
|
+
if (method === "POST" && pathname === "/api/executor/pause") {
|
|
483
|
+
return await this._handlePauseTasks(res);
|
|
484
|
+
}
|
|
485
|
+
if (method === "POST" && pathname === "/api/executor/resume") {
|
|
486
|
+
return await this._handleResumeTasks(res);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ── Task-specific routes ────────────────────────────────────────
|
|
490
|
+
const taskId = extractTaskId(pathname);
|
|
491
|
+
|
|
492
|
+
if (taskId) {
|
|
493
|
+
if (method === "GET" && pathname === `/api/tasks/${taskId}`) {
|
|
494
|
+
return await this._handleGetTask(taskId, res);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (method === "POST" && pathname === `/api/tasks/${taskId}/status`) {
|
|
498
|
+
const body = await parseBody(req);
|
|
499
|
+
return await this._handleStatusChange(taskId, body, res);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (
|
|
503
|
+
method === "POST" &&
|
|
504
|
+
pathname === `/api/tasks/${taskId}/heartbeat`
|
|
505
|
+
) {
|
|
506
|
+
const body = await parseBody(req);
|
|
507
|
+
return await this._handleHeartbeat(taskId, body, res);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (method === "POST" && pathname === `/api/tasks/${taskId}/complete`) {
|
|
511
|
+
const body = await parseBody(req);
|
|
512
|
+
return await this._handleComplete(taskId, body, res);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (method === "POST" && pathname === `/api/tasks/${taskId}/error`) {
|
|
516
|
+
const body = await parseBody(req);
|
|
517
|
+
return await this._handleError(taskId, body, res);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ── 404 ─────────────────────────────────────────────────────────
|
|
522
|
+
sendJson(res, 404, { error: "Not found", path: pathname });
|
|
523
|
+
} catch (err) {
|
|
524
|
+
console.error(`${TAG} ${method} ${pathname} error:`, err.message);
|
|
525
|
+
sendJson(res, 500, { error: err.message || "Internal server error" });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ── Route Handlers ──────────────────────────────────────────────────────
|
|
530
|
+
|
|
531
|
+
_handleHealth(res) {
|
|
532
|
+
const uptimeSeconds =
|
|
533
|
+
this._startedAt != null
|
|
534
|
+
? Math.floor((Date.now() - this._startedAt) / 1000)
|
|
535
|
+
: 0;
|
|
536
|
+
sendJson(res, 200, { ok: true, uptime: uptimeSeconds });
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
_handleStatus(res) {
|
|
540
|
+
const uptimeSeconds =
|
|
541
|
+
this._startedAt != null
|
|
542
|
+
? Math.floor((Date.now() - this._startedAt) / 1000)
|
|
543
|
+
: 0;
|
|
544
|
+
const storeStats = this._taskStore
|
|
545
|
+
? { connected: true }
|
|
546
|
+
: { connected: false };
|
|
547
|
+
|
|
548
|
+
sendJson(res, 200, {
|
|
549
|
+
executor: { running: this._running, port: this._port },
|
|
550
|
+
store: storeStats,
|
|
551
|
+
uptime: uptimeSeconds,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async _handleListTasks(url, res) {
|
|
556
|
+
if (!this._taskStore) {
|
|
557
|
+
sendJson(res, 503, { error: "Task store not configured" });
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const statusFilter = url.searchParams.get("status") || undefined;
|
|
562
|
+
|
|
563
|
+
try {
|
|
564
|
+
let tasks;
|
|
565
|
+
if (typeof this._taskStore.listTasks === "function") {
|
|
566
|
+
// kanban adapter-style store
|
|
567
|
+
tasks = await this._taskStore.listTasks(null, { status: statusFilter });
|
|
568
|
+
} else if (typeof this._taskStore.list === "function") {
|
|
569
|
+
tasks = await this._taskStore.list({ status: statusFilter });
|
|
570
|
+
} else {
|
|
571
|
+
sendJson(res, 501, { error: "Task store does not support listing" });
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const taskList = Array.isArray(tasks) ? tasks : [];
|
|
576
|
+
sendJson(res, 200, { tasks: taskList, count: taskList.length });
|
|
577
|
+
} catch (err) {
|
|
578
|
+
console.error(`${TAG} listTasks error:`, err.message);
|
|
579
|
+
sendJson(res, 500, { error: `Failed to list tasks: ${err.message}` });
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async _handleGetTask(taskId, res) {
|
|
584
|
+
if (!this._taskStore) {
|
|
585
|
+
sendJson(res, 503, { error: "Task store not configured" });
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
try {
|
|
590
|
+
let task;
|
|
591
|
+
if (typeof this._taskStore.getTask === "function") {
|
|
592
|
+
task = await this._taskStore.getTask(taskId);
|
|
593
|
+
} else if (typeof this._taskStore.get === "function") {
|
|
594
|
+
task = await this._taskStore.get(taskId);
|
|
595
|
+
} else {
|
|
596
|
+
sendJson(res, 501, { error: "Task store does not support get" });
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (!task) {
|
|
601
|
+
sendJson(res, 404, { error: "Task not found" });
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
sendJson(res, 200, { task });
|
|
606
|
+
} catch (err) {
|
|
607
|
+
console.error(`${TAG} getTask(${taskId}) error:`, err.message);
|
|
608
|
+
sendJson(res, 404, { error: "Task not found" });
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
_handleExecutorStatus(res) {
|
|
613
|
+
if (typeof this._getExecutorStatus !== "function") {
|
|
614
|
+
sendJson(res, 503, { error: "Executor status provider not configured" });
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
try {
|
|
618
|
+
const status = this._getExecutorStatus() || {};
|
|
619
|
+
sendJson(res, 200, { ok: true, status });
|
|
620
|
+
} catch (err) {
|
|
621
|
+
sendJson(res, 500, {
|
|
622
|
+
error: `Failed to get executor status: ${err.message}`,
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async _handlePauseTasks(res) {
|
|
628
|
+
if (typeof this._onPauseTasks !== "function") {
|
|
629
|
+
sendJson(res, 503, { error: "Pause control not configured" });
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
try {
|
|
633
|
+
const result = await this._onPauseTasks();
|
|
634
|
+
sendJson(res, 200, { ok: true, result: result ?? { paused: true } });
|
|
635
|
+
} catch (err) {
|
|
636
|
+
sendJson(res, 500, { error: `Failed to pause tasks: ${err.message}` });
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
async _handleResumeTasks(res) {
|
|
641
|
+
if (typeof this._onResumeTasks !== "function") {
|
|
642
|
+
sendJson(res, 503, { error: "Resume control not configured" });
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
try {
|
|
646
|
+
const result = await this._onResumeTasks();
|
|
647
|
+
sendJson(res, 200, { ok: true, result: result ?? { paused: false } });
|
|
648
|
+
} catch (err) {
|
|
649
|
+
sendJson(res, 500, { error: `Failed to resume tasks: ${err.message}` });
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async _handleStatusChange(taskId, body, res) {
|
|
654
|
+
if (!this._taskStore) {
|
|
655
|
+
sendJson(res, 503, { error: "Task store not configured" });
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const { status, message } = body;
|
|
660
|
+
if (!status) {
|
|
661
|
+
sendJson(res, 400, { error: "Missing 'status' in body" });
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const allowed = ["inreview", "done", "blocked"];
|
|
666
|
+
if (!allowed.includes(status)) {
|
|
667
|
+
sendJson(res, 400, {
|
|
668
|
+
error: `Invalid status '${status}'. Allowed: ${allowed.join(", ")}`,
|
|
669
|
+
});
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Validate transition
|
|
674
|
+
try {
|
|
675
|
+
let currentTask;
|
|
676
|
+
if (typeof this._taskStore.getTask === "function") {
|
|
677
|
+
currentTask = await this._taskStore.getTask(taskId);
|
|
678
|
+
} else if (typeof this._taskStore.get === "function") {
|
|
679
|
+
currentTask = await this._taskStore.get(taskId);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (currentTask) {
|
|
683
|
+
const currentStatus = currentTask.status || "unknown";
|
|
684
|
+
const validNext = VALID_TRANSITIONS[currentStatus];
|
|
685
|
+
if (validNext && !validNext.includes(status)) {
|
|
686
|
+
sendJson(res, 409, {
|
|
687
|
+
error: `Invalid transition: ${currentStatus} → ${status}. Allowed: ${validNext.join(", ")}`,
|
|
688
|
+
});
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
} catch {
|
|
693
|
+
// If we can't fetch current task, proceed anyway
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
try {
|
|
697
|
+
let updatedTask;
|
|
698
|
+
if (typeof this._taskStore.updateTaskStatus === "function") {
|
|
699
|
+
updatedTask = await this._taskStore.updateTaskStatus(taskId, status);
|
|
700
|
+
} else if (typeof this._taskStore.update === "function") {
|
|
701
|
+
updatedTask = await this._taskStore.update(taskId, { status });
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
console.log(
|
|
705
|
+
`${TAG} Task ${taskId} status → ${status} (source=agent)${message ? ` msg="${message}"` : ""}`,
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
if (this._onStatusChange) {
|
|
709
|
+
try {
|
|
710
|
+
await this._onStatusChange(taskId, status, "agent");
|
|
711
|
+
} catch (err) {
|
|
712
|
+
console.error(`${TAG} onStatusChange callback error:`, err.message);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
sendJson(res, 200, {
|
|
717
|
+
ok: true,
|
|
718
|
+
task: updatedTask || { id: taskId, status },
|
|
719
|
+
});
|
|
720
|
+
} catch (err) {
|
|
721
|
+
console.error(`${TAG} statusChange(${taskId}) error:`, err.message);
|
|
722
|
+
sendJson(res, 500, { error: `Failed to update status: ${err.message}` });
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
async _handleHeartbeat(taskId, body, res) {
|
|
727
|
+
const timestamp = new Date().toISOString();
|
|
728
|
+
const { message } = body;
|
|
729
|
+
|
|
730
|
+
console.log(
|
|
731
|
+
`${TAG} Heartbeat from task ${taskId}${message ? `: ${message}` : ""}`,
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
// Try to update lastActivityAt on the task if the store supports it
|
|
735
|
+
if (this._taskStore) {
|
|
736
|
+
try {
|
|
737
|
+
if (typeof this._taskStore.update === "function") {
|
|
738
|
+
await this._taskStore.update(taskId, { lastActivityAt: timestamp });
|
|
739
|
+
} else if (typeof this._taskStore.updateTaskStatus === "function") {
|
|
740
|
+
// kanban adapter doesn't have a generic update, but heartbeat is still recorded
|
|
741
|
+
}
|
|
742
|
+
} catch {
|
|
743
|
+
// Non-critical — heartbeat is logged regardless
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
sendJson(res, 200, { ok: true, timestamp });
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
async _handleComplete(taskId, body, res) {
|
|
751
|
+
const { hasCommits, branch, prUrl, output, prNumber } = body;
|
|
752
|
+
|
|
753
|
+
console.log(
|
|
754
|
+
`${TAG} Task ${taskId} complete: hasCommits=${!!hasCommits}, branch=${branch || "none"}, pr=${prUrl || "none"}`,
|
|
755
|
+
);
|
|
756
|
+
|
|
757
|
+
if (this._taskStore) {
|
|
758
|
+
try {
|
|
759
|
+
if (typeof this._taskStore.recordAgentAttempt === "function") {
|
|
760
|
+
await this._taskStore.recordAgentAttempt(taskId, {
|
|
761
|
+
output,
|
|
762
|
+
hasCommits: !!hasCommits,
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (typeof this._taskStore.update === "function") {
|
|
767
|
+
const updates = {};
|
|
768
|
+
if (branch) updates.branchName = branch;
|
|
769
|
+
if (prUrl) updates.prUrl = prUrl;
|
|
770
|
+
if (prNumber) updates.prNumber = prNumber;
|
|
771
|
+
if (Object.keys(updates).length > 0) {
|
|
772
|
+
await this._taskStore.update(taskId, updates);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
} catch (err) {
|
|
776
|
+
console.warn(
|
|
777
|
+
`${TAG} Failed to record completion details for ${taskId}: ${err.message || err}`,
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
let nextAction = "cooldown";
|
|
783
|
+
|
|
784
|
+
if (hasCommits) {
|
|
785
|
+
nextAction = "review";
|
|
786
|
+
|
|
787
|
+
// Update task status to inreview
|
|
788
|
+
if (this._taskStore) {
|
|
789
|
+
try {
|
|
790
|
+
if (typeof this._taskStore.updateTaskStatus === "function") {
|
|
791
|
+
await this._taskStore.updateTaskStatus(taskId, "inreview");
|
|
792
|
+
} else if (typeof this._taskStore.update === "function") {
|
|
793
|
+
await this._taskStore.update(taskId, { status: "inreview" });
|
|
794
|
+
}
|
|
795
|
+
} catch (err) {
|
|
796
|
+
console.error(
|
|
797
|
+
`${TAG} Failed to set task ${taskId} to inreview:`,
|
|
798
|
+
err.message,
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
} else {
|
|
803
|
+
// No commits — record the attempt but don't change status
|
|
804
|
+
console.log(`${TAG} Task ${taskId} completed with no commits`);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Fire callback
|
|
808
|
+
if (this._onTaskComplete) {
|
|
809
|
+
try {
|
|
810
|
+
await this._onTaskComplete(taskId, {
|
|
811
|
+
hasCommits,
|
|
812
|
+
branch,
|
|
813
|
+
prUrl,
|
|
814
|
+
output,
|
|
815
|
+
});
|
|
816
|
+
} catch (err) {
|
|
817
|
+
console.error(`${TAG} onTaskComplete callback error:`, err.message);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Retrieve updated task for response
|
|
822
|
+
let task = { id: taskId };
|
|
823
|
+
if (this._taskStore) {
|
|
824
|
+
try {
|
|
825
|
+
if (typeof this._taskStore.getTask === "function") {
|
|
826
|
+
task = (await this._taskStore.getTask(taskId)) || task;
|
|
827
|
+
} else if (typeof this._taskStore.get === "function") {
|
|
828
|
+
task = (await this._taskStore.get(taskId)) || task;
|
|
829
|
+
}
|
|
830
|
+
} catch {
|
|
831
|
+
// Use fallback
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
sendJson(res, 200, { ok: true, task, nextAction });
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
async _handleError(taskId, body, res) {
|
|
839
|
+
const { error: errorMsg, pattern, output } = body;
|
|
840
|
+
|
|
841
|
+
if (!errorMsg) {
|
|
842
|
+
sendJson(res, 400, { error: "Missing 'error' in body" });
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const validPatterns = [
|
|
847
|
+
"plan_stuck",
|
|
848
|
+
"rate_limit",
|
|
849
|
+
"token_overflow",
|
|
850
|
+
"api_error",
|
|
851
|
+
];
|
|
852
|
+
if (pattern && !validPatterns.includes(pattern)) {
|
|
853
|
+
console.log(
|
|
854
|
+
`${TAG} Task ${taskId} error with unknown pattern '${pattern}': ${errorMsg}`,
|
|
855
|
+
);
|
|
856
|
+
} else {
|
|
857
|
+
console.log(
|
|
858
|
+
`${TAG} Task ${taskId} error${pattern ? ` (${pattern})` : ""}: ${errorMsg}`,
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (this._taskStore) {
|
|
863
|
+
try {
|
|
864
|
+
if (typeof this._taskStore.recordAgentAttempt === "function") {
|
|
865
|
+
await this._taskStore.recordAgentAttempt(taskId, {
|
|
866
|
+
output,
|
|
867
|
+
error: errorMsg,
|
|
868
|
+
hasCommits: false,
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
if (
|
|
872
|
+
pattern &&
|
|
873
|
+
typeof this._taskStore.recordErrorPattern === "function"
|
|
874
|
+
) {
|
|
875
|
+
await this._taskStore.recordErrorPattern(taskId, pattern);
|
|
876
|
+
}
|
|
877
|
+
} catch (err) {
|
|
878
|
+
console.warn(
|
|
879
|
+
`${TAG} Failed to record error details for ${taskId}: ${err.message || err}`,
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Determine action based on pattern
|
|
885
|
+
let action = "retry";
|
|
886
|
+
if (pattern === "rate_limit") {
|
|
887
|
+
action = "cooldown";
|
|
888
|
+
} else if (pattern === "token_overflow") {
|
|
889
|
+
action = "blocked";
|
|
890
|
+
} else if (pattern === "plan_stuck") {
|
|
891
|
+
action = "retry";
|
|
892
|
+
} else if (pattern === "api_error") {
|
|
893
|
+
action = "cooldown";
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// Fire callback
|
|
897
|
+
if (this._onTaskError) {
|
|
898
|
+
try {
|
|
899
|
+
await this._onTaskError(taskId, { error: errorMsg, pattern });
|
|
900
|
+
} catch (err) {
|
|
901
|
+
console.error(`${TAG} onTaskError callback error:`, err.message);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
sendJson(res, 200, { ok: true, action });
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// ── Factory ─────────────────────────────────────────────────────────────────
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Create an AgentEndpoint instance.
|
|
913
|
+
* @param {object} [options] — Same as AgentEndpoint constructor
|
|
914
|
+
* @returns {AgentEndpoint}
|
|
915
|
+
*/
|
|
916
|
+
export function createAgentEndpoint(options) {
|
|
917
|
+
return new AgentEndpoint(options);
|
|
918
|
+
}
|