doer-agent 0.1.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/README.md +153 -0
- package/dist/agent.js +1380 -0
- package/dist/apply-patch.js +260 -0
- package/dist/cli.js +2 -0
- package/dist/codex-cli.js +66 -0
- package/dist/playwright-mcp-call-cli.js +2 -0
- package/dist/playwright-mcp-call.js +103 -0
- package/dist/playwright-mcp-daemon.js +175 -0
- package/package.json +39 -0
- package/runtime/bin/apply_patch +5 -0
- package/runtime/bin/doer-mcp-proxy +39 -0
- package/runtime/bin/git-askpass.sh +6 -0
- package/runtime/bin/playwright-mcp-proxy-launcher.sh +15 -0
package/dist/agent.js
ADDED
|
@@ -0,0 +1,1380 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import { arch, homedir } from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { AckPolicy, connect, DeliverPolicy, JSONCodec, RetentionPolicy, StorageType } from "nats";
|
|
9
|
+
const PLAYWRIGHT_SKIP_BROWSER_GC = "1";
|
|
10
|
+
const PLAYWRIGHT_MCP_DAEMON_IDLE_TTL_SECONDS_DEFAULT = 10800;
|
|
11
|
+
const PLAYWRIGHT_MCP_DAEMON_SIGNATURE_VERSION = "2026-03-15";
|
|
12
|
+
const AGENT_MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const AGENT_PROJECT_DIR = path.join(AGENT_MODULE_DIR, "..");
|
|
14
|
+
let activeTaskLogContext = null;
|
|
15
|
+
const activeTaskCancelRequests = new Map();
|
|
16
|
+
function sanitizeUserId(userId) {
|
|
17
|
+
const normalized = userId.trim().replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
18
|
+
return normalized.length > 0 ? normalized : "anonymous";
|
|
19
|
+
}
|
|
20
|
+
function normalizeNatsServers(value) {
|
|
21
|
+
if (!Array.isArray(value)) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
return value.filter((item) => typeof item === "string").map((v) => v.trim()).filter((v) => v.length > 0);
|
|
25
|
+
}
|
|
26
|
+
function parseBootstrapTaskConfig(value) {
|
|
27
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const task = value;
|
|
31
|
+
const stream = typeof task.stream === "string" ? task.stream.trim() : "";
|
|
32
|
+
const subject = typeof task.subject === "string" ? task.subject.trim() : "";
|
|
33
|
+
const durable = typeof task.durable === "string" ? task.durable.trim() : "";
|
|
34
|
+
if (!stream || !subject || !durable) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return { stream, subject, durable };
|
|
38
|
+
}
|
|
39
|
+
function normalizeTaskIds(value) {
|
|
40
|
+
if (!Array.isArray(value)) {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const item of value) {
|
|
45
|
+
if (typeof item !== "string") {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const id = item.trim();
|
|
49
|
+
if (!id) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
out.push(id);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
function normalizeNatsToken(value) {
|
|
57
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const auth = value;
|
|
61
|
+
const token = typeof auth.token === "string" ? auth.token.trim() : "";
|
|
62
|
+
return token.length > 0 ? token : null;
|
|
63
|
+
}
|
|
64
|
+
async function ensureJetStreamInfra(args) {
|
|
65
|
+
const streamInfo = await args.jsm.streams.info(args.stream).catch(() => null);
|
|
66
|
+
if (!streamInfo) {
|
|
67
|
+
await args.jsm.streams.add({
|
|
68
|
+
name: args.stream,
|
|
69
|
+
subjects: [args.subject],
|
|
70
|
+
storage: StorageType.File,
|
|
71
|
+
retention: RetentionPolicy.Limits,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
if (args.durable) {
|
|
75
|
+
const consumerInfo = await args.jsm.consumers.info(args.stream, args.durable).catch(() => null);
|
|
76
|
+
if (!consumerInfo) {
|
|
77
|
+
await args.jsm.consumers.add(args.stream, {
|
|
78
|
+
durable_name: args.durable,
|
|
79
|
+
ack_policy: AckPolicy.Explicit,
|
|
80
|
+
deliver_policy: DeliverPolicy.All,
|
|
81
|
+
filter_subject: args.subject,
|
|
82
|
+
ack_wait: 30_000_000_000,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function initJetStreamContext(args) {
|
|
88
|
+
const sanitized = sanitizeUserId(args.userId);
|
|
89
|
+
const stream = `DOER_AGENT_EVENTS_${sanitized}`;
|
|
90
|
+
const subject = `doer.agent.events.${sanitized}`;
|
|
91
|
+
const durable = `doer-agent-uploader-${sanitized}`;
|
|
92
|
+
const nc = await connect(args.token ? { servers: args.servers, token: args.token } : { servers: args.servers });
|
|
93
|
+
const jsm = await nc.jetstreamManager();
|
|
94
|
+
await ensureJetStreamInfra({ jsm, stream, subject, durable });
|
|
95
|
+
await ensureJetStreamInfra({
|
|
96
|
+
jsm,
|
|
97
|
+
stream: args.taskStream,
|
|
98
|
+
subject: args.taskSubject,
|
|
99
|
+
durable: args.taskDurable,
|
|
100
|
+
});
|
|
101
|
+
void nc.closed().then((error) => {
|
|
102
|
+
if (error) {
|
|
103
|
+
writeAgentInfraError(`nats connection closed with error: ${error.message}`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
writeAgentInfraError("nats connection closed cleanly");
|
|
107
|
+
});
|
|
108
|
+
void (async () => {
|
|
109
|
+
try {
|
|
110
|
+
for await (const status of nc.status()) {
|
|
111
|
+
const statusType = typeof status.type === "string" ? status.type : "unknown";
|
|
112
|
+
if (statusType === "pingTimer") {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const statusData = formatNatsStatusData(status.data);
|
|
116
|
+
writeAgentInfraError("nats status type=" + statusType + " data=" + statusData);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
121
|
+
writeAgentInfraError(`nats status loop ended: ${message}`);
|
|
122
|
+
}
|
|
123
|
+
})();
|
|
124
|
+
return {
|
|
125
|
+
nc,
|
|
126
|
+
js: nc.jetstream(),
|
|
127
|
+
jsm,
|
|
128
|
+
codec: JSONCodec(),
|
|
129
|
+
taskCodec: JSONCodec(),
|
|
130
|
+
subject,
|
|
131
|
+
stream,
|
|
132
|
+
durable,
|
|
133
|
+
servers: args.servers,
|
|
134
|
+
taskStream: args.taskStream,
|
|
135
|
+
taskSubject: args.taskSubject,
|
|
136
|
+
taskDurable: args.taskDurable,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function resolveCodexHomePath() {
|
|
140
|
+
return path.join(homedir(), ".codex");
|
|
141
|
+
}
|
|
142
|
+
function parseEnvBoolean(value) {
|
|
143
|
+
return value?.trim().toLowerCase() === "true";
|
|
144
|
+
}
|
|
145
|
+
function parseEnvStringArray(value) {
|
|
146
|
+
if (!value?.trim()) {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
const parsed = JSON.parse(value);
|
|
151
|
+
return Array.isArray(parsed) && parsed.every((item) => typeof item === "string") ? parsed : [];
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function parseEnvInteger(value, fallback) {
|
|
158
|
+
const normalized = value?.trim();
|
|
159
|
+
if (!normalized) {
|
|
160
|
+
return fallback;
|
|
161
|
+
}
|
|
162
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
163
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
164
|
+
}
|
|
165
|
+
function resolvePlaywrightMcpProxyPath() {
|
|
166
|
+
const candidates = [
|
|
167
|
+
path.join(AGENT_PROJECT_DIR, "runtime/bin/doer-mcp-proxy"),
|
|
168
|
+
path.join(process.cwd(), "agent/runtime/bin/doer-mcp-proxy"),
|
|
169
|
+
path.join(process.cwd(), "runtime/bin/doer-mcp-proxy"),
|
|
170
|
+
];
|
|
171
|
+
for (const candidate of candidates) {
|
|
172
|
+
if (existsSync(candidate)) {
|
|
173
|
+
return candidate;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return "";
|
|
177
|
+
}
|
|
178
|
+
const PLAYWRIGHT_MCP_PROXY_LAUNCHER_PATH = path.join(AGENT_PROJECT_DIR, "runtime/bin/playwright-mcp-proxy-launcher.sh");
|
|
179
|
+
function resolvePlaywrightMcpDaemonStatePaths() {
|
|
180
|
+
const daemonDir = path.join(resolveAgentStateDir(), "playwright-mcp-daemon");
|
|
181
|
+
return {
|
|
182
|
+
daemonDir,
|
|
183
|
+
socketPath: path.join(daemonDir, "playwright-mcp.sock"),
|
|
184
|
+
pidPath: path.join(daemonDir, "daemon.pid"),
|
|
185
|
+
metaPath: path.join(daemonDir, "daemon-meta.json"),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function parseEnvAssignmentArgs(values) {
|
|
189
|
+
const envPatch = {};
|
|
190
|
+
for (const value of values) {
|
|
191
|
+
const separatorIndex = value.indexOf("=");
|
|
192
|
+
if (separatorIndex <= 0) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const key = value.slice(0, separatorIndex).trim();
|
|
196
|
+
const envValue = value.slice(separatorIndex + 1);
|
|
197
|
+
if (!key) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
envPatch[key] = envValue;
|
|
201
|
+
}
|
|
202
|
+
return envPatch;
|
|
203
|
+
}
|
|
204
|
+
function escapeShellArg(value) {
|
|
205
|
+
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
|
|
206
|
+
}
|
|
207
|
+
async function readPidFile(pidPath) {
|
|
208
|
+
const raw = await readFile(pidPath, "utf8").catch(() => "");
|
|
209
|
+
const parsed = Number.parseInt(raw.trim(), 10);
|
|
210
|
+
return Number.isInteger(parsed) && parsed > 1 ? parsed : null;
|
|
211
|
+
}
|
|
212
|
+
function isProcessAlive(pid) {
|
|
213
|
+
try {
|
|
214
|
+
process.kill(pid, 0);
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async function waitForPlaywrightMcpSocketReady(socketPath, timeoutMs) {
|
|
222
|
+
const startedAt = Date.now();
|
|
223
|
+
while (Date.now() - startedAt <= timeoutMs) {
|
|
224
|
+
const isReady = await new Promise((resolve) => {
|
|
225
|
+
const socket = net.createConnection({ path: socketPath });
|
|
226
|
+
let settled = false;
|
|
227
|
+
const finish = (value) => {
|
|
228
|
+
if (settled) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
settled = true;
|
|
232
|
+
resolve(value);
|
|
233
|
+
};
|
|
234
|
+
socket.once("connect", () => {
|
|
235
|
+
socket.end();
|
|
236
|
+
finish(true);
|
|
237
|
+
});
|
|
238
|
+
socket.once("error", () => {
|
|
239
|
+
finish(false);
|
|
240
|
+
});
|
|
241
|
+
setTimeout(() => {
|
|
242
|
+
socket.destroy();
|
|
243
|
+
finish(false);
|
|
244
|
+
}, 250).unref?.();
|
|
245
|
+
});
|
|
246
|
+
if (isReady) {
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
await sleep(120);
|
|
250
|
+
}
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
async function stopPlaywrightMcpDaemon(paths) {
|
|
254
|
+
const pid = await readPidFile(paths.pidPath);
|
|
255
|
+
if (pid && isProcessAlive(pid)) {
|
|
256
|
+
try {
|
|
257
|
+
process.kill(pid, "SIGTERM");
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// ignore
|
|
261
|
+
}
|
|
262
|
+
const waitStartedAt = Date.now();
|
|
263
|
+
while (Date.now() - waitStartedAt < 1800 && isProcessAlive(pid)) {
|
|
264
|
+
await sleep(120);
|
|
265
|
+
}
|
|
266
|
+
if (isProcessAlive(pid)) {
|
|
267
|
+
try {
|
|
268
|
+
process.kill(pid, "SIGKILL");
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
// ignore
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
await Promise.all([
|
|
276
|
+
rename(paths.socketPath, `${paths.socketPath}.stale.${Date.now()}`).catch(() => undefined),
|
|
277
|
+
rename(paths.pidPath, `${paths.pidPath}.stale.${Date.now()}`).catch(() => undefined),
|
|
278
|
+
rename(paths.metaPath, `${paths.metaPath}.stale.${Date.now()}`).catch(() => undefined),
|
|
279
|
+
]);
|
|
280
|
+
}
|
|
281
|
+
async function ensureManagedPlaywrightMcpDaemon(args) {
|
|
282
|
+
const paths = resolvePlaywrightMcpDaemonStatePaths();
|
|
283
|
+
await mkdir(paths.daemonDir, { recursive: true });
|
|
284
|
+
const daemonCommand = args.command;
|
|
285
|
+
const targetEnvPatch = parseEnvAssignmentArgs(args.browserEnvArgs);
|
|
286
|
+
const daemonCommandArgs = args.daemonArgs;
|
|
287
|
+
const signature = JSON.stringify({
|
|
288
|
+
version: PLAYWRIGHT_MCP_DAEMON_SIGNATURE_VERSION,
|
|
289
|
+
daemonCommand,
|
|
290
|
+
daemonCommandArgs,
|
|
291
|
+
targetEnvPatch,
|
|
292
|
+
idleTtlSeconds: parseEnvInteger(process.env.DOER_PLAYWRIGHT_MCP_DAEMON_IDLE_TTL_SECONDS, PLAYWRIGHT_MCP_DAEMON_IDLE_TTL_SECONDS_DEFAULT),
|
|
293
|
+
});
|
|
294
|
+
const existingMetaRaw = await readFile(paths.metaPath, "utf8").catch(() => "");
|
|
295
|
+
let existingMeta = null;
|
|
296
|
+
if (existingMetaRaw) {
|
|
297
|
+
try {
|
|
298
|
+
existingMeta = JSON.parse(existingMetaRaw);
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
existingMeta = null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const existingPid = await readPidFile(paths.pidPath);
|
|
305
|
+
if (existingMeta?.signature === signature
|
|
306
|
+
&& existingPid
|
|
307
|
+
&& isProcessAlive(existingPid)
|
|
308
|
+
&& await waitForPlaywrightMcpSocketReady(paths.socketPath, 350)) {
|
|
309
|
+
return paths.socketPath;
|
|
310
|
+
}
|
|
311
|
+
await stopPlaywrightMcpDaemon(paths);
|
|
312
|
+
const daemonScriptPath = path.join(AGENT_MODULE_DIR, "playwright-mcp-daemon.ts");
|
|
313
|
+
const idleTtlSeconds = String(parseEnvInteger(process.env.DOER_PLAYWRIGHT_MCP_DAEMON_IDLE_TTL_SECONDS, PLAYWRIGHT_MCP_DAEMON_IDLE_TTL_SECONDS_DEFAULT));
|
|
314
|
+
const child = spawn(process.execPath, ["--import", "tsx", daemonScriptPath], {
|
|
315
|
+
cwd: AGENT_PROJECT_DIR,
|
|
316
|
+
detached: true,
|
|
317
|
+
stdio: "ignore",
|
|
318
|
+
env: {
|
|
319
|
+
...process.env,
|
|
320
|
+
DOER_PLAYWRIGHT_MCP_DAEMON_SOCKET: paths.socketPath,
|
|
321
|
+
DOER_PLAYWRIGHT_MCP_DAEMON_IDLE_TTL_SECONDS: idleTtlSeconds,
|
|
322
|
+
DOER_PLAYWRIGHT_MCP_TARGET_COMMAND: daemonCommand,
|
|
323
|
+
DOER_PLAYWRIGHT_MCP_TARGET_ARGS_JSON: JSON.stringify(daemonCommandArgs),
|
|
324
|
+
DOER_PLAYWRIGHT_MCP_TARGET_ENV_JSON: JSON.stringify(targetEnvPatch),
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
child.unref();
|
|
328
|
+
if (!child.pid) {
|
|
329
|
+
throw new Error("failed to start playwright mcp daemon: missing pid");
|
|
330
|
+
}
|
|
331
|
+
await writeFile(paths.pidPath, `${child.pid}\n`, "utf8");
|
|
332
|
+
await writeFile(paths.metaPath, `${JSON.stringify({ signature, pid: child.pid, socketPath: paths.socketPath, updatedAt: new Date().toISOString() }, null, 2)}\n`, "utf8");
|
|
333
|
+
const ready = await waitForPlaywrightMcpSocketReady(paths.socketPath, 6000);
|
|
334
|
+
if (!ready) {
|
|
335
|
+
throw new Error(`playwright mcp daemon socket not ready: ${paths.socketPath}`);
|
|
336
|
+
}
|
|
337
|
+
return paths.socketPath;
|
|
338
|
+
}
|
|
339
|
+
async function ensureCodexPlaywrightMcpLauncher() {
|
|
340
|
+
const browserEnvArgs = [
|
|
341
|
+
`PLAYWRIGHT_SKIP_BROWSER_GC=${PLAYWRIGHT_SKIP_BROWSER_GC}`,
|
|
342
|
+
];
|
|
343
|
+
const daemonArgsFromEnv = parseEnvStringArray(process.env.DOER_PLAYWRIGHT_MCP_DAEMON_ARGS_JSON);
|
|
344
|
+
const [daemonCommandFromArgs, ...daemonArgsRest] = daemonArgsFromEnv;
|
|
345
|
+
const daemonCommand = daemonCommandFromArgs || "npx";
|
|
346
|
+
let daemonArgs = daemonCommandFromArgs && daemonArgsFromEnv.length > 0
|
|
347
|
+
? daemonArgsRest
|
|
348
|
+
: ["-y", "@playwright/mcp"];
|
|
349
|
+
const hasBrowserOption = daemonArgs.some((arg) => arg === "--browser" || arg.startsWith("--browser="));
|
|
350
|
+
if (arch() === "arm64" && !hasBrowserOption) {
|
|
351
|
+
daemonArgs = [...daemonArgs, "--browser", "chromium"];
|
|
352
|
+
}
|
|
353
|
+
const hasNoSandboxOption = daemonArgs.some((arg) => arg === "--no-sandbox");
|
|
354
|
+
if (typeof process.getuid === "function" && process.getuid() === 0 && !hasNoSandboxOption) {
|
|
355
|
+
daemonArgs = [...daemonArgs, "--no-sandbox"];
|
|
356
|
+
}
|
|
357
|
+
const socketPath = await ensureManagedPlaywrightMcpDaemon({
|
|
358
|
+
command: daemonCommand,
|
|
359
|
+
daemonArgs,
|
|
360
|
+
browserEnvArgs,
|
|
361
|
+
});
|
|
362
|
+
if (!existsSync(PLAYWRIGHT_MCP_PROXY_LAUNCHER_PATH)) {
|
|
363
|
+
throw new Error(`playwright mcp proxy launcher script not found: ${PLAYWRIGHT_MCP_PROXY_LAUNCHER_PATH}`);
|
|
364
|
+
}
|
|
365
|
+
return PLAYWRIGHT_MCP_PROXY_LAUNCHER_PATH;
|
|
366
|
+
}
|
|
367
|
+
function resolveAgentStateDir() {
|
|
368
|
+
return process.env.DOER_AGENT_STATE_DIR?.trim() || path.join(homedir(), ".doer-agent");
|
|
369
|
+
}
|
|
370
|
+
function resolveContainerReachableServerBaseUrl(serverBaseUrl) {
|
|
371
|
+
return serverBaseUrl;
|
|
372
|
+
}
|
|
373
|
+
function pickFirstNonEmpty(values) {
|
|
374
|
+
for (const value of values) {
|
|
375
|
+
if (typeof value !== "string") {
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
const normalized = value.trim();
|
|
379
|
+
if (normalized) {
|
|
380
|
+
return normalized;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return "";
|
|
384
|
+
}
|
|
385
|
+
async function ensureGitAskpassScript() {
|
|
386
|
+
const binDir = path.join(AGENT_PROJECT_DIR, "runtime/bin");
|
|
387
|
+
const scriptPath = path.join(binDir, "git-askpass.sh");
|
|
388
|
+
const scriptBody = `#!/bin/sh
|
|
389
|
+
case "$1" in
|
|
390
|
+
*Username*) printf "%s\\n" "x-access-token" ;;
|
|
391
|
+
*Password*) printf "%s\\n" "\${GITHUB_TOKEN:-\${GH_TOKEN:-}}" ;;
|
|
392
|
+
*) printf "\\n" ;;
|
|
393
|
+
esac
|
|
394
|
+
`;
|
|
395
|
+
await mkdir(binDir, { recursive: true });
|
|
396
|
+
await writeFile(scriptPath, scriptBody, "utf8");
|
|
397
|
+
await chmod(scriptPath, 0o700).catch(() => undefined);
|
|
398
|
+
return scriptPath;
|
|
399
|
+
}
|
|
400
|
+
function applyGitIdentityIfPossible(args) {
|
|
401
|
+
if (!args.cwd) {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
const inRepo = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
405
|
+
cwd: args.cwd,
|
|
406
|
+
stdio: "ignore",
|
|
407
|
+
});
|
|
408
|
+
if (inRepo.status !== 0) {
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
const setName = spawnSync("git", ["config", "--local", "user.name", args.userName], {
|
|
412
|
+
cwd: args.cwd,
|
|
413
|
+
stdio: "ignore",
|
|
414
|
+
});
|
|
415
|
+
if (setName.status !== 0) {
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
const setEmail = spawnSync("git", ["config", "--local", "user.email", args.userEmail], {
|
|
419
|
+
cwd: args.cwd,
|
|
420
|
+
stdio: "ignore",
|
|
421
|
+
});
|
|
422
|
+
return setEmail.status === 0;
|
|
423
|
+
}
|
|
424
|
+
async function prepareTaskGitEnv(args) {
|
|
425
|
+
const envPatch = {
|
|
426
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
427
|
+
GCM_INTERACTIVE: "Never",
|
|
428
|
+
};
|
|
429
|
+
const githubToken = pickFirstNonEmpty([
|
|
430
|
+
args.baseEnvPatch.GITHUB_TOKEN,
|
|
431
|
+
args.baseEnvPatch.GH_TOKEN,
|
|
432
|
+
process.env.GITHUB_TOKEN,
|
|
433
|
+
process.env.GH_TOKEN,
|
|
434
|
+
]);
|
|
435
|
+
if (githubToken) {
|
|
436
|
+
envPatch.GITHUB_TOKEN = githubToken;
|
|
437
|
+
envPatch.GH_TOKEN = githubToken;
|
|
438
|
+
envPatch.GIT_ASKPASS_REQUIRE = "force";
|
|
439
|
+
envPatch.GIT_ASKPASS = await ensureGitAskpassScript();
|
|
440
|
+
}
|
|
441
|
+
const userName = pickFirstNonEmpty([
|
|
442
|
+
args.baseEnvPatch.DOER_GIT_USER_NAME,
|
|
443
|
+
args.baseEnvPatch.GIT_USER_NAME,
|
|
444
|
+
args.baseEnvPatch.GIT_AUTHOR_NAME,
|
|
445
|
+
args.baseEnvPatch.GIT_COMMITTER_NAME,
|
|
446
|
+
]);
|
|
447
|
+
const userEmail = pickFirstNonEmpty([
|
|
448
|
+
args.baseEnvPatch.DOER_GIT_USER_EMAIL,
|
|
449
|
+
args.baseEnvPatch.GIT_USER_EMAIL,
|
|
450
|
+
args.baseEnvPatch.GIT_AUTHOR_EMAIL,
|
|
451
|
+
args.baseEnvPatch.GIT_COMMITTER_EMAIL,
|
|
452
|
+
]);
|
|
453
|
+
const gitIdentityApplied = userName && userEmail
|
|
454
|
+
? applyGitIdentityIfPossible({
|
|
455
|
+
cwd: args.cwd,
|
|
456
|
+
userName,
|
|
457
|
+
userEmail,
|
|
458
|
+
})
|
|
459
|
+
: false;
|
|
460
|
+
return {
|
|
461
|
+
envPatch,
|
|
462
|
+
meta: {
|
|
463
|
+
gitAskpassEnabled: Boolean(envPatch.GIT_ASKPASS),
|
|
464
|
+
gitIdentityApplied,
|
|
465
|
+
gitIdentityProvided: Boolean(userName && userEmail),
|
|
466
|
+
},
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
function normalizeEnvPatch(value) {
|
|
470
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
471
|
+
return {};
|
|
472
|
+
}
|
|
473
|
+
const out = {};
|
|
474
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
475
|
+
if (typeof raw !== "string") {
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
const normalizedKey = key.trim();
|
|
479
|
+
if (!normalizedKey) {
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
out[normalizedKey] = raw;
|
|
483
|
+
}
|
|
484
|
+
return out;
|
|
485
|
+
}
|
|
486
|
+
async function prepareTaskRuntimeConfig(args) {
|
|
487
|
+
const bundle = await postJson(`${args.serverBaseUrl}/api/agent/tasks/${encodeURIComponent(args.taskId)}/runtime-config`, {
|
|
488
|
+
userId: args.userId,
|
|
489
|
+
agentToken: args.agentToken,
|
|
490
|
+
}).catch((error) => {
|
|
491
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
492
|
+
writeAgentError(`task=${args.taskId} runtime config sync skipped: ${message}`);
|
|
493
|
+
return null;
|
|
494
|
+
});
|
|
495
|
+
if (!bundle) {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
const envPatch = normalizeEnvPatch(bundle.envPatch);
|
|
499
|
+
return {
|
|
500
|
+
envPatch,
|
|
501
|
+
meta: {
|
|
502
|
+
runtimeConfigIssuedAt: bundle.issuedAt ?? null,
|
|
503
|
+
runtimeConfigExpiresAt: bundle.expiresAt ?? null,
|
|
504
|
+
runtimeConfigVarCount: Object.keys(envPatch).length,
|
|
505
|
+
runtimeConfigSynced: true,
|
|
506
|
+
...(bundle.meta && typeof bundle.meta === "object" && !Array.isArray(bundle.meta) ? bundle.meta : {}),
|
|
507
|
+
},
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
function fatalExit(message, error) {
|
|
511
|
+
const detail = error instanceof Error ? error.message : typeof error === "string" ? error : error ? String(error) : "";
|
|
512
|
+
const full = detail ? `${message}: ${detail}` : message;
|
|
513
|
+
writeAgentError(`fatal: ${full}`);
|
|
514
|
+
process.exit(1);
|
|
515
|
+
}
|
|
516
|
+
function sleep(ms) {
|
|
517
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
518
|
+
}
|
|
519
|
+
function writeAgentInfo(message) {
|
|
520
|
+
process.stdout.write(`[doer-agent] ${message}\n`);
|
|
521
|
+
emitAgentMetaLog("info", message);
|
|
522
|
+
}
|
|
523
|
+
function writeAgentError(message) {
|
|
524
|
+
process.stderr.write(`[doer-agent] ${message}\n`);
|
|
525
|
+
emitAgentMetaLog("error", message);
|
|
526
|
+
}
|
|
527
|
+
function writeAgentInfraError(message) {
|
|
528
|
+
try {
|
|
529
|
+
process.stderr.write(`[doer-agent] ${message}\n`);
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
// Keep heartbeat/connectivity failures non-fatal.
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
function formatNatsStatusData(value) {
|
|
536
|
+
if (value === null || value === undefined) {
|
|
537
|
+
return "null";
|
|
538
|
+
}
|
|
539
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
540
|
+
return String(value);
|
|
541
|
+
}
|
|
542
|
+
try {
|
|
543
|
+
return JSON.stringify(value);
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
return String(value);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
function writeTaskStream(taskId, stream, chunk) {
|
|
550
|
+
const target = stream === "stdout" ? process.stdout : process.stderr;
|
|
551
|
+
const lines = chunk.replace(/\r/g, "\n").split("\n");
|
|
552
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
553
|
+
const line = lines[i];
|
|
554
|
+
if (line.length === 0 && i === lines.length - 1) {
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
target.write(`[doer-agent][task=${taskId}][${stream}] ${line}\n`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
function writeTaskUpload(taskId, message) {
|
|
561
|
+
process.stdout.write(`[doer-agent][task=${taskId}][upload] ${message}\n`);
|
|
562
|
+
}
|
|
563
|
+
function isLikelyNatsAuthError(error) {
|
|
564
|
+
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
565
|
+
return (message.includes("auth")
|
|
566
|
+
|| message.includes("authorization")
|
|
567
|
+
|| message.includes("authentication")
|
|
568
|
+
|| message.includes("permission")
|
|
569
|
+
|| message.includes("jwt")
|
|
570
|
+
|| message.includes("token"));
|
|
571
|
+
}
|
|
572
|
+
function isLikelyNatsReconnectError(error) {
|
|
573
|
+
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
574
|
+
return (message.includes("connection_closed")
|
|
575
|
+
|| message.includes("connection closed")
|
|
576
|
+
|| message.includes("closed connection")
|
|
577
|
+
|| message.includes("disconnected")
|
|
578
|
+
|| message.includes("timeout")
|
|
579
|
+
|| message.includes("no responders"));
|
|
580
|
+
}
|
|
581
|
+
function sendSignalToTaskProcess(child, signal) {
|
|
582
|
+
if (process.platform !== "win32" && typeof child.pid === "number") {
|
|
583
|
+
try {
|
|
584
|
+
// Detached child owns a process group; signal the whole group first.
|
|
585
|
+
process.kill(-child.pid, signal);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
// Fall back to direct child signaling.
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
try {
|
|
593
|
+
child.kill(signal);
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
// noop
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
function requestTaskCancellation(taskId, reason) {
|
|
600
|
+
const requestCancel = activeTaskCancelRequests.get(taskId);
|
|
601
|
+
if (!requestCancel) {
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
604
|
+
try {
|
|
605
|
+
requestCancel();
|
|
606
|
+
writeAgentInfo(`task cancel requested taskId=${taskId} via=${reason}`);
|
|
607
|
+
return true;
|
|
608
|
+
}
|
|
609
|
+
catch (error) {
|
|
610
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
611
|
+
writeAgentError(`task cancel request failed taskId=${taskId} via=${reason}: ${message}`);
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
function resolveLogTimeZone() {
|
|
616
|
+
const configured = process.env.DOER_AGENT_LOG_TIMEZONE?.trim() || process.env.TZ?.trim();
|
|
617
|
+
return configured && configured.length > 0 ? configured : "Asia/Seoul";
|
|
618
|
+
}
|
|
619
|
+
function resolveTimeZoneOffsetString(date, timeZone) {
|
|
620
|
+
try {
|
|
621
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
622
|
+
timeZone,
|
|
623
|
+
timeZoneName: "shortOffset",
|
|
624
|
+
hour: "2-digit",
|
|
625
|
+
minute: "2-digit",
|
|
626
|
+
hour12: false,
|
|
627
|
+
}).formatToParts(date);
|
|
628
|
+
const token = parts.find((part) => part.type === "timeZoneName")?.value || "GMT+0";
|
|
629
|
+
const matched = token.match(/GMT([+-]\d{1,2})(?::?(\d{2}))?/i);
|
|
630
|
+
if (!matched) {
|
|
631
|
+
return "+00:00";
|
|
632
|
+
}
|
|
633
|
+
const hourRaw = matched[1] || "+0";
|
|
634
|
+
const minuteRaw = matched[2] || "00";
|
|
635
|
+
const sign = hourRaw.startsWith("-") ? "-" : "+";
|
|
636
|
+
const absHour = String(Math.abs(Number.parseInt(hourRaw, 10))).padStart(2, "0");
|
|
637
|
+
const absMinute = String(Math.abs(Number.parseInt(minuteRaw, 10))).padStart(2, "0");
|
|
638
|
+
return `${sign}${absHour}:${absMinute}`;
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
return "+00:00";
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
function formatLocalTimestamp(date = new Date()) {
|
|
645
|
+
const timeZone = resolveLogTimeZone();
|
|
646
|
+
try {
|
|
647
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
648
|
+
timeZone,
|
|
649
|
+
year: "numeric",
|
|
650
|
+
month: "2-digit",
|
|
651
|
+
day: "2-digit",
|
|
652
|
+
hour: "2-digit",
|
|
653
|
+
minute: "2-digit",
|
|
654
|
+
second: "2-digit",
|
|
655
|
+
hour12: false,
|
|
656
|
+
}).formatToParts(date);
|
|
657
|
+
const pick = (type) => {
|
|
658
|
+
return parts.find((part) => part.type === type)?.value || "00";
|
|
659
|
+
};
|
|
660
|
+
const year = pick("year");
|
|
661
|
+
const month = pick("month");
|
|
662
|
+
const day = pick("day");
|
|
663
|
+
const hours = pick("hour");
|
|
664
|
+
const minutes = pick("minute");
|
|
665
|
+
const seconds = pick("second");
|
|
666
|
+
const ms = String(date.getMilliseconds()).padStart(3, "0");
|
|
667
|
+
const offset = resolveTimeZoneOffsetString(date, timeZone);
|
|
668
|
+
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${ms}${offset}`;
|
|
669
|
+
}
|
|
670
|
+
catch {
|
|
671
|
+
return date.toISOString();
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
function parseArgs(argv) {
|
|
675
|
+
const out = {};
|
|
676
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
677
|
+
const key = argv[i];
|
|
678
|
+
if (!key.startsWith("--")) {
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
const value = argv[i + 1];
|
|
682
|
+
if (typeof value === "string" && !value.startsWith("--")) {
|
|
683
|
+
out[key.slice(2)] = value;
|
|
684
|
+
i += 1;
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
out[key.slice(2)] = "true";
|
|
688
|
+
}
|
|
689
|
+
return out;
|
|
690
|
+
}
|
|
691
|
+
function resolveArgOrEnv(args, argKeys, envKeys, fallback = "") {
|
|
692
|
+
for (const key of argKeys) {
|
|
693
|
+
const value = args[key]?.trim();
|
|
694
|
+
if (value) {
|
|
695
|
+
return value;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
for (const key of envKeys) {
|
|
699
|
+
const value = process.env[key]?.trim();
|
|
700
|
+
if (value) {
|
|
701
|
+
return value;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return fallback;
|
|
705
|
+
}
|
|
706
|
+
function resolveShellPath() {
|
|
707
|
+
if (process.platform === "win32") {
|
|
708
|
+
return process.env.ComSpec || "cmd.exe";
|
|
709
|
+
}
|
|
710
|
+
const candidates = [process.env.SHELL, "/bin/bash", "/usr/bin/bash", "/bin/sh", "/usr/bin/sh"].filter((value) => typeof value === "string" && value.trim().length > 0);
|
|
711
|
+
for (const candidate of candidates) {
|
|
712
|
+
if (existsSync(candidate)) {
|
|
713
|
+
return candidate;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
throw new Error("No shell executable found. Set SHELL env or install /bin/sh (or bash).");
|
|
717
|
+
}
|
|
718
|
+
async function postJson(url, body) {
|
|
719
|
+
const res = await fetch(url, {
|
|
720
|
+
method: "POST",
|
|
721
|
+
headers: { "Content-Type": "application/json" },
|
|
722
|
+
body: JSON.stringify(body),
|
|
723
|
+
});
|
|
724
|
+
const text = await res.text();
|
|
725
|
+
let data = {};
|
|
726
|
+
if (text) {
|
|
727
|
+
try {
|
|
728
|
+
data = JSON.parse(text);
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
data = {};
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
if (!res.ok) {
|
|
735
|
+
const errObj = (data && typeof data === "object" ? data : {});
|
|
736
|
+
const message = typeof errObj.error === "string" ? errObj.error : `HTTP ${res.status}`;
|
|
737
|
+
throw new Error(message);
|
|
738
|
+
}
|
|
739
|
+
return data;
|
|
740
|
+
}
|
|
741
|
+
async function getJson(url) {
|
|
742
|
+
const res = await fetch(url);
|
|
743
|
+
const text = await res.text();
|
|
744
|
+
let data = {};
|
|
745
|
+
if (text) {
|
|
746
|
+
try {
|
|
747
|
+
data = JSON.parse(text);
|
|
748
|
+
}
|
|
749
|
+
catch {
|
|
750
|
+
data = {};
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
if (!res.ok) {
|
|
754
|
+
const errObj = (data && typeof data === "object" ? data : {});
|
|
755
|
+
const message = typeof errObj.error === "string" ? errObj.error : `HTTP ${res.status}`;
|
|
756
|
+
throw new Error(message);
|
|
757
|
+
}
|
|
758
|
+
return data;
|
|
759
|
+
}
|
|
760
|
+
const nextEventSeqByTask = new Map();
|
|
761
|
+
function reserveNextEventSeq(taskId) {
|
|
762
|
+
const current = nextEventSeqByTask.get(taskId) ?? 1;
|
|
763
|
+
nextEventSeqByTask.set(taskId, current + 1);
|
|
764
|
+
return current;
|
|
765
|
+
}
|
|
766
|
+
function emitAgentMetaLog(level, message) {
|
|
767
|
+
const ctx = activeTaskLogContext;
|
|
768
|
+
if (!ctx) {
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const seq = reserveNextEventSeq(ctx.taskId);
|
|
772
|
+
void recordAgentEvent({
|
|
773
|
+
jetstream: ctx.jetstream,
|
|
774
|
+
serverBaseUrl: ctx.serverBaseUrl,
|
|
775
|
+
taskId: ctx.taskId,
|
|
776
|
+
userId: ctx.userId,
|
|
777
|
+
type: "meta",
|
|
778
|
+
seq,
|
|
779
|
+
payload: {
|
|
780
|
+
channel: "agent",
|
|
781
|
+
level,
|
|
782
|
+
message,
|
|
783
|
+
at: formatLocalTimestamp(),
|
|
784
|
+
},
|
|
785
|
+
}).catch((error) => {
|
|
786
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
787
|
+
process.stderr.write(`[doer-agent] meta log persist failed task=${ctx.taskId}: ${detail}\n`);
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
async function recordAgentEvent(args) {
|
|
791
|
+
await args.jetstream.js.publish(args.jetstream.subject, args.jetstream.codec.encode({
|
|
792
|
+
serverBaseUrl: args.serverBaseUrl,
|
|
793
|
+
userId: args.userId,
|
|
794
|
+
taskId: args.taskId,
|
|
795
|
+
type: args.type,
|
|
796
|
+
seq: args.seq,
|
|
797
|
+
payload: args.payload,
|
|
798
|
+
}));
|
|
799
|
+
}
|
|
800
|
+
function persistEventOrFatal(args) {
|
|
801
|
+
void (async () => {
|
|
802
|
+
let attempt = 0;
|
|
803
|
+
let delayMs = 150;
|
|
804
|
+
while (attempt < 3) {
|
|
805
|
+
attempt += 1;
|
|
806
|
+
try {
|
|
807
|
+
await recordAgentEvent(args);
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
catch (error) {
|
|
811
|
+
if (attempt >= 3) {
|
|
812
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
813
|
+
writeAgentError(`task=${args.taskId} ${args.context}: ${message} (dropped after ${attempt} attempts)`);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
await sleep(delayMs);
|
|
817
|
+
delayMs *= 2;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
})();
|
|
821
|
+
}
|
|
822
|
+
async function heartbeatAgent(args) {
|
|
823
|
+
await postJson(`${args.serverBaseUrl}/api/agent/heartbeat`, {
|
|
824
|
+
userId: args.userId,
|
|
825
|
+
agentToken: args.agentToken,
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
async function claimTaskById(args) {
|
|
829
|
+
const response = await postJson(`${args.serverBaseUrl}/api/agent/tasks/claim`, {
|
|
830
|
+
userId: args.userId,
|
|
831
|
+
agentToken: args.agentToken,
|
|
832
|
+
taskId: args.taskId,
|
|
833
|
+
});
|
|
834
|
+
return response.task ?? null;
|
|
835
|
+
}
|
|
836
|
+
async function runClaimedTask(args) {
|
|
837
|
+
try {
|
|
838
|
+
writeAgentInfo(`run task=${args.task.id} command=${args.task.command}`);
|
|
839
|
+
await runTask({
|
|
840
|
+
serverBaseUrl: args.serverBaseUrl,
|
|
841
|
+
taskId: args.task.id,
|
|
842
|
+
command: args.task.command,
|
|
843
|
+
cwd: args.task.cwd,
|
|
844
|
+
userId: args.userId,
|
|
845
|
+
agentToken: args.agentToken,
|
|
846
|
+
jetstream: args.jetstream,
|
|
847
|
+
}).catch(async (error) => {
|
|
848
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
849
|
+
writeAgentError(`task=${args.task.id} run failed: ${message}`);
|
|
850
|
+
const failPayload = {
|
|
851
|
+
status: "failed",
|
|
852
|
+
error: message,
|
|
853
|
+
finishedAt: formatLocalTimestamp(),
|
|
854
|
+
};
|
|
855
|
+
await recordAgentEvent({
|
|
856
|
+
jetstream: args.jetstream,
|
|
857
|
+
serverBaseUrl: args.serverBaseUrl,
|
|
858
|
+
taskId: args.task.id,
|
|
859
|
+
userId: args.userId,
|
|
860
|
+
type: "status",
|
|
861
|
+
seq: reserveNextEventSeq(args.task.id),
|
|
862
|
+
payload: failPayload,
|
|
863
|
+
});
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
finally {
|
|
867
|
+
if (activeTaskLogContext?.taskId === args.task.id) {
|
|
868
|
+
activeTaskLogContext = null;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
async function checkCancelRequested(args) {
|
|
873
|
+
const query = new URLSearchParams({
|
|
874
|
+
userId: args.userId,
|
|
875
|
+
agentToken: args.agentToken,
|
|
876
|
+
});
|
|
877
|
+
const response = await getJson(`${args.serverBaseUrl}/api/agent/tasks/${encodeURIComponent(args.taskId)}/events?${query.toString()}`);
|
|
878
|
+
return Boolean(response.task?.cancelRequested);
|
|
879
|
+
}
|
|
880
|
+
async function prepareTaskCodexAuth(args) {
|
|
881
|
+
const bundle = await postJson(`${args.serverBaseUrl}/api/agent/tasks/${encodeURIComponent(args.taskId)}/codex-auth`, {
|
|
882
|
+
userId: args.userId,
|
|
883
|
+
agentToken: args.agentToken,
|
|
884
|
+
}).catch((error) => {
|
|
885
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
886
|
+
writeAgentError(`task=${args.taskId} codex auth sync skipped: ${message}`);
|
|
887
|
+
return null;
|
|
888
|
+
});
|
|
889
|
+
if (!bundle || typeof bundle.authJson !== "string") {
|
|
890
|
+
return null;
|
|
891
|
+
}
|
|
892
|
+
const codexHome = resolveCodexHomePath();
|
|
893
|
+
await mkdir(codexHome, { recursive: true });
|
|
894
|
+
const authFile = path.join(codexHome, "auth.json");
|
|
895
|
+
await writeFile(authFile, bundle.authJson, "utf8");
|
|
896
|
+
await chmod(authFile, 0o600).catch(() => undefined);
|
|
897
|
+
const envPatch = {
|
|
898
|
+
CODEX_HOME: codexHome,
|
|
899
|
+
};
|
|
900
|
+
if (typeof bundle.apiKey === "string" && bundle.apiKey.trim()) {
|
|
901
|
+
envPatch.OPENAI_API_KEY = bundle.apiKey.trim();
|
|
902
|
+
}
|
|
903
|
+
const cleanup = async () => { };
|
|
904
|
+
return {
|
|
905
|
+
envPatch,
|
|
906
|
+
cleanup,
|
|
907
|
+
meta: {
|
|
908
|
+
codexAuthMode: bundle.authMode ?? null,
|
|
909
|
+
codexAuthIssuedAt: bundle.issuedAt ?? null,
|
|
910
|
+
codexAuthExpiresAt: bundle.expiresAt ?? null,
|
|
911
|
+
codexAuthSynced: true,
|
|
912
|
+
},
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
async function runTask(args) {
|
|
916
|
+
activeTaskLogContext = {
|
|
917
|
+
jetstream: args.jetstream,
|
|
918
|
+
serverBaseUrl: args.serverBaseUrl,
|
|
919
|
+
taskId: args.taskId,
|
|
920
|
+
userId: args.userId,
|
|
921
|
+
};
|
|
922
|
+
const shellPath = resolveShellPath();
|
|
923
|
+
const runtimeConfig = await prepareTaskRuntimeConfig({
|
|
924
|
+
serverBaseUrl: args.serverBaseUrl,
|
|
925
|
+
taskId: args.taskId,
|
|
926
|
+
userId: args.userId,
|
|
927
|
+
agentToken: args.agentToken,
|
|
928
|
+
});
|
|
929
|
+
const codexAuth = await prepareTaskCodexAuth({
|
|
930
|
+
serverBaseUrl: args.serverBaseUrl,
|
|
931
|
+
taskId: args.taskId,
|
|
932
|
+
userId: args.userId,
|
|
933
|
+
agentToken: args.agentToken,
|
|
934
|
+
});
|
|
935
|
+
const taskWorkspace = args.cwd || process.env.WORKSPACE?.trim() || process.cwd();
|
|
936
|
+
const baseTaskEnvPatch = {
|
|
937
|
+
...(runtimeConfig?.envPatch ?? {}),
|
|
938
|
+
...(codexAuth?.envPatch ?? {}),
|
|
939
|
+
WORKSPACE: taskWorkspace,
|
|
940
|
+
};
|
|
941
|
+
const taskGitEnv = await prepareTaskGitEnv({
|
|
942
|
+
cwd: taskWorkspace,
|
|
943
|
+
baseEnvPatch: baseTaskEnvPatch,
|
|
944
|
+
});
|
|
945
|
+
await ensureCodexPlaywrightMcpLauncher();
|
|
946
|
+
const codexMcpEnvPatch = {
|
|
947
|
+
PLAYWRIGHT_SKIP_BROWSER_GC: PLAYWRIGHT_SKIP_BROWSER_GC,
|
|
948
|
+
};
|
|
949
|
+
await recordAgentEvent({ jetstream: args.jetstream,
|
|
950
|
+
serverBaseUrl: args.serverBaseUrl,
|
|
951
|
+
taskId: args.taskId,
|
|
952
|
+
userId: args.userId,
|
|
953
|
+
type: "meta",
|
|
954
|
+
seq: reserveNextEventSeq(args.taskId),
|
|
955
|
+
payload: {
|
|
956
|
+
host: process.platform,
|
|
957
|
+
pid: process.pid,
|
|
958
|
+
startedAt: formatLocalTimestamp(),
|
|
959
|
+
command: args.command,
|
|
960
|
+
cwd: args.cwd,
|
|
961
|
+
shell: shellPath,
|
|
962
|
+
...(runtimeConfig?.meta ?? { runtimeConfigSynced: false }),
|
|
963
|
+
...(codexAuth?.meta ?? { codexAuthSynced: false }),
|
|
964
|
+
...(taskGitEnv.meta ?? {}),
|
|
965
|
+
},
|
|
966
|
+
});
|
|
967
|
+
try {
|
|
968
|
+
let terminationReason = null;
|
|
969
|
+
let cancelStage1Timer = null;
|
|
970
|
+
let cancelStage2Timer = null;
|
|
971
|
+
let stopCancelPolling = false;
|
|
972
|
+
let cancelSignalSent = false;
|
|
973
|
+
const runtimeBinPath = path.join(AGENT_PROJECT_DIR, "runtime/bin");
|
|
974
|
+
const taskPath = [runtimeBinPath, process.env.PATH || ""].filter(Boolean).join(path.delimiter);
|
|
975
|
+
const child = spawn(args.command, {
|
|
976
|
+
cwd: args.cwd || process.cwd(),
|
|
977
|
+
shell: shellPath,
|
|
978
|
+
detached: process.platform !== "win32",
|
|
979
|
+
env: {
|
|
980
|
+
...process.env,
|
|
981
|
+
...baseTaskEnvPatch,
|
|
982
|
+
...taskGitEnv.envPatch,
|
|
983
|
+
...codexMcpEnvPatch,
|
|
984
|
+
PATH: taskPath,
|
|
985
|
+
DOER_AGENT_TOKEN: args.agentToken,
|
|
986
|
+
},
|
|
987
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
988
|
+
});
|
|
989
|
+
child.stdout.setEncoding("utf8");
|
|
990
|
+
child.stderr.setEncoding("utf8");
|
|
991
|
+
const requestCancel = () => {
|
|
992
|
+
if (cancelSignalSent || terminationReason === "cancel") {
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
cancelSignalSent = true;
|
|
996
|
+
terminationReason = "cancel";
|
|
997
|
+
sendSignalToTaskProcess(child, "SIGINT");
|
|
998
|
+
cancelStage1Timer = setTimeout(() => {
|
|
999
|
+
sendSignalToTaskProcess(child, "SIGTERM");
|
|
1000
|
+
}, 1200);
|
|
1001
|
+
cancelStage1Timer.unref?.();
|
|
1002
|
+
cancelStage2Timer = setTimeout(() => {
|
|
1003
|
+
sendSignalToTaskProcess(child, "SIGKILL");
|
|
1004
|
+
}, 3500);
|
|
1005
|
+
cancelStage2Timer.unref?.();
|
|
1006
|
+
};
|
|
1007
|
+
activeTaskCancelRequests.set(args.taskId, requestCancel);
|
|
1008
|
+
child.stdout.on("data", (chunk) => {
|
|
1009
|
+
writeTaskStream(args.taskId, "stdout", chunk);
|
|
1010
|
+
const seq = reserveNextEventSeq(args.taskId);
|
|
1011
|
+
persistEventOrFatal({
|
|
1012
|
+
jetstream: args.jetstream,
|
|
1013
|
+
serverBaseUrl: args.serverBaseUrl,
|
|
1014
|
+
taskId: args.taskId,
|
|
1015
|
+
userId: args.userId,
|
|
1016
|
+
type: "stdout",
|
|
1017
|
+
seq,
|
|
1018
|
+
payload: { chunk, at: formatLocalTimestamp() },
|
|
1019
|
+
context: "stdout persist failed",
|
|
1020
|
+
});
|
|
1021
|
+
});
|
|
1022
|
+
child.stderr.on("data", (chunk) => {
|
|
1023
|
+
writeTaskStream(args.taskId, "stderr", chunk);
|
|
1024
|
+
const seq = reserveNextEventSeq(args.taskId);
|
|
1025
|
+
persistEventOrFatal({
|
|
1026
|
+
jetstream: args.jetstream,
|
|
1027
|
+
serverBaseUrl: args.serverBaseUrl,
|
|
1028
|
+
taskId: args.taskId,
|
|
1029
|
+
userId: args.userId,
|
|
1030
|
+
type: "stderr",
|
|
1031
|
+
seq,
|
|
1032
|
+
payload: { chunk, at: formatLocalTimestamp() },
|
|
1033
|
+
context: "stderr persist failed",
|
|
1034
|
+
});
|
|
1035
|
+
});
|
|
1036
|
+
const cancelPoller = (async () => {
|
|
1037
|
+
while (!stopCancelPolling) {
|
|
1038
|
+
await sleep(5000);
|
|
1039
|
+
if (stopCancelPolling || terminationReason === "cancel") {
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
const cancelRequested = await checkCancelRequested({
|
|
1043
|
+
serverBaseUrl: args.serverBaseUrl,
|
|
1044
|
+
taskId: args.taskId,
|
|
1045
|
+
userId: args.userId,
|
|
1046
|
+
agentToken: args.agentToken,
|
|
1047
|
+
}).catch(() => false);
|
|
1048
|
+
if (!cancelRequested) {
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
requestCancel();
|
|
1052
|
+
}
|
|
1053
|
+
})();
|
|
1054
|
+
const result = await new Promise((resolve, reject) => {
|
|
1055
|
+
child.once("error", (error) => {
|
|
1056
|
+
reject(error);
|
|
1057
|
+
});
|
|
1058
|
+
child.once("close", (code, signal) => {
|
|
1059
|
+
resolve({ code, signal });
|
|
1060
|
+
});
|
|
1061
|
+
}).finally(() => {
|
|
1062
|
+
stopCancelPolling = true;
|
|
1063
|
+
if (cancelStage1Timer) {
|
|
1064
|
+
clearTimeout(cancelStage1Timer);
|
|
1065
|
+
}
|
|
1066
|
+
if (cancelStage2Timer) {
|
|
1067
|
+
clearTimeout(cancelStage2Timer);
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
1070
|
+
await cancelPoller.catch(() => undefined);
|
|
1071
|
+
const canceled = await checkCancelRequested({
|
|
1072
|
+
serverBaseUrl: args.serverBaseUrl,
|
|
1073
|
+
taskId: args.taskId,
|
|
1074
|
+
userId: args.userId,
|
|
1075
|
+
agentToken: args.agentToken,
|
|
1076
|
+
}).catch(() => false);
|
|
1077
|
+
const status = canceled || terminationReason === "cancel"
|
|
1078
|
+
? "canceled"
|
|
1079
|
+
: (result.code ?? 1) === 0
|
|
1080
|
+
? "completed"
|
|
1081
|
+
: "failed";
|
|
1082
|
+
const statusPayload = {
|
|
1083
|
+
status,
|
|
1084
|
+
exitCode: typeof result.code === "number" ? result.code : null,
|
|
1085
|
+
signal: result.signal,
|
|
1086
|
+
finishedAt: formatLocalTimestamp(),
|
|
1087
|
+
error: status === "failed"
|
|
1088
|
+
? `Command exited with code ${result.code ?? "null"}`
|
|
1089
|
+
: null,
|
|
1090
|
+
};
|
|
1091
|
+
await recordAgentEvent({ jetstream: args.jetstream,
|
|
1092
|
+
serverBaseUrl: args.serverBaseUrl,
|
|
1093
|
+
taskId: args.taskId,
|
|
1094
|
+
userId: args.userId,
|
|
1095
|
+
type: "status",
|
|
1096
|
+
seq: reserveNextEventSeq(args.taskId),
|
|
1097
|
+
payload: statusPayload,
|
|
1098
|
+
});
|
|
1099
|
+
writeAgentInfo(`task=${args.taskId} status=${status} exitCode=${typeof result.code === "number" ? result.code : "null"} signal=${result.signal ?? "null"}`);
|
|
1100
|
+
}
|
|
1101
|
+
finally {
|
|
1102
|
+
activeTaskCancelRequests.delete(args.taskId);
|
|
1103
|
+
activeTaskLogContext = null;
|
|
1104
|
+
await codexAuth?.cleanup().catch(() => undefined);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
async function connectBootstrapWithRetry(args) {
|
|
1108
|
+
let attempt = 0;
|
|
1109
|
+
while (true) {
|
|
1110
|
+
attempt += 1;
|
|
1111
|
+
try {
|
|
1112
|
+
const natsBootstrap = await postJson(`${args.serverBaseUrl}/api/agent/nats`, {
|
|
1113
|
+
userId: args.userId,
|
|
1114
|
+
agentToken: args.agentToken,
|
|
1115
|
+
});
|
|
1116
|
+
const natsServers = normalizeNatsServers(natsBootstrap.servers);
|
|
1117
|
+
if (natsServers.length === 0) {
|
|
1118
|
+
throw new Error("No NATS servers configured by server");
|
|
1119
|
+
}
|
|
1120
|
+
const taskConfig = parseBootstrapTaskConfig(natsBootstrap.tasks);
|
|
1121
|
+
if (!taskConfig) {
|
|
1122
|
+
throw new Error("Invalid task dispatch config from server");
|
|
1123
|
+
}
|
|
1124
|
+
const natsToken = normalizeNatsToken(natsBootstrap.auth);
|
|
1125
|
+
const pendingTaskIds = normalizeTaskIds(natsBootstrap.pendingTaskIds);
|
|
1126
|
+
const jetstream = await initJetStreamContext({
|
|
1127
|
+
userId: args.userId,
|
|
1128
|
+
servers: natsServers,
|
|
1129
|
+
token: natsToken,
|
|
1130
|
+
taskStream: taskConfig.stream,
|
|
1131
|
+
taskSubject: taskConfig.subject,
|
|
1132
|
+
taskDurable: taskConfig.durable,
|
|
1133
|
+
});
|
|
1134
|
+
writeAgentInfraError(`bootstrap ok servers=${natsServers.length} taskStream=${taskConfig.stream} taskSubject=${taskConfig.subject} taskDurable=${taskConfig.durable}`);
|
|
1135
|
+
return { natsBootstrap, pendingTaskIds, jetstream };
|
|
1136
|
+
}
|
|
1137
|
+
catch (error) {
|
|
1138
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1139
|
+
const retryMs = Math.min(30_000, 1000 * Math.max(1, attempt));
|
|
1140
|
+
writeAgentError(`bootstrap failed: ${message} (retry in ${Math.floor(retryMs / 1000)}s, attempt=${attempt})`);
|
|
1141
|
+
await sleep(retryMs);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
async function main() {
|
|
1146
|
+
const args = parseArgs(process.argv.slice(2));
|
|
1147
|
+
const workspaceDir = resolveArgOrEnv(args, ["workspace-dir", "workspaceDir"], ["WORKSPACE"]);
|
|
1148
|
+
if (workspaceDir) {
|
|
1149
|
+
process.chdir(path.resolve(workspaceDir));
|
|
1150
|
+
}
|
|
1151
|
+
const serverBaseUrlRaw = resolveArgOrEnv(args, ["server", "url"], ["DOER_AGENT_SERVER"], "http://localhost:2020");
|
|
1152
|
+
const requestedServerBaseUrl = serverBaseUrlRaw.replace(/\/$/, "");
|
|
1153
|
+
const serverBaseUrl = resolveContainerReachableServerBaseUrl(requestedServerBaseUrl);
|
|
1154
|
+
const userId = resolveArgOrEnv(args, ["user-id", "userId"], ["DOER_AGENT_USER_ID"]);
|
|
1155
|
+
const agentSecret = resolveArgOrEnv(args, ["agent-secret", "agentSecret"], ["DOER_AGENT_SECRET"]);
|
|
1156
|
+
if (!userId || !agentSecret) {
|
|
1157
|
+
throw new Error("user-id and agent-secret are required");
|
|
1158
|
+
}
|
|
1159
|
+
const agentToken = agentSecret;
|
|
1160
|
+
let { natsBootstrap, pendingTaskIds, jetstream } = await connectBootstrapWithRetry({
|
|
1161
|
+
serverBaseUrl,
|
|
1162
|
+
userId,
|
|
1163
|
+
agentToken,
|
|
1164
|
+
});
|
|
1165
|
+
const maxConcurrency = Math.max(1, parseEnvInteger(process.env.DOER_AGENT_MAX_CONCURRENCY, 3));
|
|
1166
|
+
process.stdout.write(`\n[doer-agent]\n`);
|
|
1167
|
+
process.stdout.write(`- server: ${serverBaseUrl}\n`);
|
|
1168
|
+
process.stdout.write(`- userId: ${userId}\n`);
|
|
1169
|
+
process.stdout.write(`- agentId: ${typeof natsBootstrap.agentId === "string" ? natsBootstrap.agentId : "unknown"}\n`);
|
|
1170
|
+
process.stdout.write(`\n- transport: nats\n`);
|
|
1171
|
+
process.stdout.write(`- natsServers: ${jetstream.servers.join(",")}\n`);
|
|
1172
|
+
process.stdout.write(`- natsStream: ${jetstream.stream}\n`);
|
|
1173
|
+
process.stdout.write(`- natsSubject: ${jetstream.subject}\n`);
|
|
1174
|
+
process.stdout.write(`- natsDurable: ${jetstream.durable}\n\n`);
|
|
1175
|
+
process.stdout.write(`- taskStream: ${jetstream.taskStream}\n`);
|
|
1176
|
+
process.stdout.write(`- taskSubject: ${jetstream.taskSubject}\n`);
|
|
1177
|
+
process.stdout.write(`- taskDurable: ${jetstream.taskDurable}\n`);
|
|
1178
|
+
process.stdout.write(`- pendingTasks: ${pendingTaskIds.length}\n`);
|
|
1179
|
+
process.stdout.write(`- maxConcurrency: ${maxConcurrency}\n\n`);
|
|
1180
|
+
process.stdout.write(`- workspace: ${process.cwd()}\n\n`);
|
|
1181
|
+
if (requestedServerBaseUrl !== serverBaseUrl) {
|
|
1182
|
+
writeAgentInfo(`detected container runtime, server endpoint rewritten: ${requestedServerBaseUrl} -> ${serverBaseUrl}`);
|
|
1183
|
+
}
|
|
1184
|
+
let heartbeatHealthy = null;
|
|
1185
|
+
const heartbeatTimer = setInterval(() => {
|
|
1186
|
+
void heartbeatAgent({ serverBaseUrl, userId, agentToken })
|
|
1187
|
+
.then(() => {
|
|
1188
|
+
if (heartbeatHealthy === false) {
|
|
1189
|
+
writeAgentInfraError(`heartbeat reconnected at=${formatLocalTimestamp()}`);
|
|
1190
|
+
}
|
|
1191
|
+
heartbeatHealthy = true;
|
|
1192
|
+
})
|
|
1193
|
+
.catch((error) => {
|
|
1194
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1195
|
+
if (heartbeatHealthy !== false) {
|
|
1196
|
+
writeAgentInfraError(`heartbeat failed: ${message}`);
|
|
1197
|
+
}
|
|
1198
|
+
heartbeatHealthy = false;
|
|
1199
|
+
});
|
|
1200
|
+
}, 10_000);
|
|
1201
|
+
const inFlightTasks = new Set();
|
|
1202
|
+
async function waitForAvailableSlot() {
|
|
1203
|
+
while (inFlightTasks.size >= maxConcurrency) {
|
|
1204
|
+
try {
|
|
1205
|
+
await Promise.race(inFlightTasks);
|
|
1206
|
+
}
|
|
1207
|
+
catch {
|
|
1208
|
+
// keep draining slots even when a task fails.
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
function trackInFlight(taskPromise) {
|
|
1213
|
+
inFlightTasks.add(taskPromise);
|
|
1214
|
+
void taskPromise.finally(() => {
|
|
1215
|
+
inFlightTasks.delete(taskPromise);
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
function scheduleTask(taskPromiseFactory) {
|
|
1219
|
+
const taskPromise = taskPromiseFactory();
|
|
1220
|
+
trackInFlight(taskPromise);
|
|
1221
|
+
}
|
|
1222
|
+
for (const pendingTaskId of pendingTaskIds) {
|
|
1223
|
+
await waitForAvailableSlot();
|
|
1224
|
+
scheduleTask(async () => {
|
|
1225
|
+
try {
|
|
1226
|
+
const task = await claimTaskById({
|
|
1227
|
+
serverBaseUrl,
|
|
1228
|
+
userId,
|
|
1229
|
+
agentToken,
|
|
1230
|
+
taskId: pendingTaskId,
|
|
1231
|
+
});
|
|
1232
|
+
if (task) {
|
|
1233
|
+
await runClaimedTask({ task, serverBaseUrl, userId, agentToken, jetstream });
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
catch (error) {
|
|
1237
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1238
|
+
writeAgentError(`pending task bootstrap failed taskId=${pendingTaskId}: ${message}`);
|
|
1239
|
+
}
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
let connected = false;
|
|
1243
|
+
while (true) {
|
|
1244
|
+
try {
|
|
1245
|
+
const consumer = await jetstream.js.consumers.get(jetstream.taskStream, jetstream.taskDurable);
|
|
1246
|
+
if (!connected) {
|
|
1247
|
+
writeAgentInfo(`connected to task stream (NATS ok) at=${formatLocalTimestamp()} userId=${userId}`);
|
|
1248
|
+
connected = true;
|
|
1249
|
+
}
|
|
1250
|
+
const messages = await consumer.fetch({ max_messages: 200, expires: 5_000 });
|
|
1251
|
+
for await (const msg of messages) {
|
|
1252
|
+
await waitForAvailableSlot();
|
|
1253
|
+
scheduleTask(async () => {
|
|
1254
|
+
let dispatch;
|
|
1255
|
+
try {
|
|
1256
|
+
dispatch = jetstream.taskCodec.decode(msg.data);
|
|
1257
|
+
}
|
|
1258
|
+
catch (error) {
|
|
1259
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1260
|
+
writeAgentError(`task dispatch decode failed: ${message}`);
|
|
1261
|
+
msg.term();
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
writeAgentInfo(`task dispatch received taskId=${dispatch.taskId} createdAt=${dispatch.createdAt} subject=${jetstream.taskSubject} durable=${jetstream.taskDurable}`);
|
|
1265
|
+
const ackKeepAliveIntervalMs = 10_000;
|
|
1266
|
+
let ackKeepAliveTimer = null;
|
|
1267
|
+
const stopAckKeepAlive = () => {
|
|
1268
|
+
if (ackKeepAliveTimer) {
|
|
1269
|
+
clearInterval(ackKeepAliveTimer);
|
|
1270
|
+
ackKeepAliveTimer = null;
|
|
1271
|
+
}
|
|
1272
|
+
};
|
|
1273
|
+
try {
|
|
1274
|
+
ackKeepAliveTimer = setInterval(() => {
|
|
1275
|
+
try {
|
|
1276
|
+
msg.working();
|
|
1277
|
+
}
|
|
1278
|
+
catch (error) {
|
|
1279
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1280
|
+
writeAgentError(`task dispatch keepalive failed taskId=${dispatch.taskId}: ${message}`);
|
|
1281
|
+
}
|
|
1282
|
+
}, ackKeepAliveIntervalMs);
|
|
1283
|
+
ackKeepAliveTimer.unref?.();
|
|
1284
|
+
if (dispatch.type === "cancel") {
|
|
1285
|
+
stopAckKeepAlive();
|
|
1286
|
+
const canceled = requestTaskCancellation(dispatch.taskId, "nats_dispatch");
|
|
1287
|
+
writeAgentInfo(`task cancel dispatch handled taskId=${dispatch.taskId} result=${canceled ? "signaled" : "not-running"}`);
|
|
1288
|
+
msg.ack();
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
const task = await claimTaskById({
|
|
1292
|
+
serverBaseUrl,
|
|
1293
|
+
userId,
|
|
1294
|
+
agentToken,
|
|
1295
|
+
taskId: dispatch.taskId,
|
|
1296
|
+
});
|
|
1297
|
+
if (!task) {
|
|
1298
|
+
stopAckKeepAlive();
|
|
1299
|
+
writeAgentInfo(`task dispatch acked without run taskId=${dispatch.taskId} reason=already-claimed`);
|
|
1300
|
+
msg.ack();
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
await runClaimedTask({ task, serverBaseUrl, userId, agentToken, jetstream });
|
|
1304
|
+
stopAckKeepAlive();
|
|
1305
|
+
msg.ack();
|
|
1306
|
+
writeAgentInfo(`task dispatch acked taskId=${dispatch.taskId}`);
|
|
1307
|
+
}
|
|
1308
|
+
catch (error) {
|
|
1309
|
+
stopAckKeepAlive();
|
|
1310
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1311
|
+
writeAgentError(`task dispatch handle failed taskId=${dispatch.taskId}: ${message}`);
|
|
1312
|
+
writeAgentError(`task dispatch sending nak taskId=${dispatch.taskId}`);
|
|
1313
|
+
msg.nak();
|
|
1314
|
+
}
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
catch (error) {
|
|
1319
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1320
|
+
if (connected) {
|
|
1321
|
+
writeAgentError(`task stream disconnected at=${formatLocalTimestamp()} reason=${message}`);
|
|
1322
|
+
}
|
|
1323
|
+
connected = false;
|
|
1324
|
+
if (isLikelyNatsAuthError(error)) {
|
|
1325
|
+
writeAgentError(`nats auth error detected. refreshing bootstrap credentials...`);
|
|
1326
|
+
}
|
|
1327
|
+
else if (isLikelyNatsReconnectError(error)) {
|
|
1328
|
+
writeAgentError(`nats connection lost. refreshing bootstrap/session...`);
|
|
1329
|
+
}
|
|
1330
|
+
else {
|
|
1331
|
+
writeAgentError(`task stream error detected. forcing bootstrap/session refresh... reason=${message}`);
|
|
1332
|
+
}
|
|
1333
|
+
if (inFlightTasks.size > 0) {
|
|
1334
|
+
writeAgentInfo(`waiting for in-flight tasks before reconnect count=${inFlightTasks.size}`);
|
|
1335
|
+
await Promise.allSettled(Array.from(inFlightTasks));
|
|
1336
|
+
}
|
|
1337
|
+
try {
|
|
1338
|
+
await jetstream.nc.close();
|
|
1339
|
+
}
|
|
1340
|
+
catch {
|
|
1341
|
+
// noop
|
|
1342
|
+
}
|
|
1343
|
+
const refreshed = await connectBootstrapWithRetry({
|
|
1344
|
+
serverBaseUrl,
|
|
1345
|
+
userId,
|
|
1346
|
+
agentToken,
|
|
1347
|
+
});
|
|
1348
|
+
natsBootstrap = refreshed.natsBootstrap;
|
|
1349
|
+
pendingTaskIds = refreshed.pendingTaskIds;
|
|
1350
|
+
jetstream = refreshed.jetstream;
|
|
1351
|
+
for (const pendingTaskId of pendingTaskIds) {
|
|
1352
|
+
await waitForAvailableSlot();
|
|
1353
|
+
scheduleTask(async () => {
|
|
1354
|
+
try {
|
|
1355
|
+
const task = await claimTaskById({
|
|
1356
|
+
serverBaseUrl,
|
|
1357
|
+
userId,
|
|
1358
|
+
agentToken,
|
|
1359
|
+
taskId: pendingTaskId,
|
|
1360
|
+
});
|
|
1361
|
+
if (task) {
|
|
1362
|
+
await runClaimedTask({ task, serverBaseUrl, userId, agentToken, jetstream });
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
catch (pendingError) {
|
|
1366
|
+
const pendingMessage = pendingError instanceof Error ? pendingError.message : String(pendingError);
|
|
1367
|
+
writeAgentError(`pending task refresh failed taskId=${pendingTaskId}: ${pendingMessage}`);
|
|
1368
|
+
}
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
writeAgentInfo(`nats credentials refreshed at=${formatLocalTimestamp()} agentId=${typeof natsBootstrap.agentId === "string" ? natsBootstrap.agentId : "unknown"}`);
|
|
1372
|
+
continue;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
main().catch((error) => {
|
|
1377
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1378
|
+
writeAgentError(`fatal: ${message}`);
|
|
1379
|
+
process.exitCode = 1;
|
|
1380
|
+
});
|