@tetsuo-ai/agenc 0.17.0 → 0.19.1

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/src/launcher.mjs CHANGED
@@ -5,8 +5,14 @@ import { createRequire } from "node:module";
5
5
  import { homedir } from "node:os";
6
6
  import { delimiter, dirname, join, resolve } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
+ import { performance } from "node:perf_hooks";
9
+ import { setTimeout as delay } from "node:timers/promises";
10
+ import { resolveAgenCHome } from "../lib/home-authority.mjs";
11
+ import { preflightStartupArguments } from "../generated/startup-preflight.mjs";
8
12
 
9
- const DEFAULT_READY_TIMEOUT_MS = 2000;
13
+ export { resolveAgenCHome } from "../lib/home-authority.mjs";
14
+
15
+ const DEFAULT_READY_TIMEOUT_MS = 45_000;
10
16
  const DEFAULT_POLL_MS = 25;
11
17
  const READY_TIMEOUT_ENV = "AGENC_DAEMON_READY_TIMEOUT_MS";
12
18
  const AUTOSTART_ENV = "AGENC_DAEMON_AUTOSTART";
@@ -23,19 +29,12 @@ export function resolveReadyTimeoutMs(env = process.env) {
23
29
  const raw = env[READY_TIMEOUT_ENV]?.trim();
24
30
  if (raw === undefined || raw.length === 0) return DEFAULT_READY_TIMEOUT_MS;
25
31
  const parsed = Number.parseInt(raw, 10);
26
- if (!Number.isInteger(parsed) || String(parsed) !== raw || parsed <= 0) {
32
+ if (!Number.isInteger(parsed) || String(parsed) !== raw || parsed <= 0 || parsed > 2_147_483_647) {
27
33
  throw new Error(`${READY_TIMEOUT_ENV} must be a positive integer`);
28
34
  }
29
35
  return parsed;
30
36
  }
31
37
 
32
- export function resolveAgenCHome(env = process.env, userHome = homedir()) {
33
- const configured = env.AGENC_HOME?.trim();
34
- return configured && configured.length > 0
35
- ? configured
36
- : join(userHome, ".agenc");
37
- }
38
-
39
38
  export function resolveDaemonPidPath(env = process.env, userHome = homedir()) {
40
39
  return join(resolveAgenCHome(env, userHome), "daemon.pid");
41
40
  }
@@ -152,6 +151,8 @@ export async function spawnNodeScript(
152
151
  spawnFn = spawn,
153
152
  nodeBin = process.execPath,
154
153
  nodeLibraryPath,
154
+ signal,
155
+ registerCleanup,
155
156
  } = {},
156
157
  ) {
157
158
  const childEnv = { ...env };
@@ -167,6 +168,15 @@ export async function spawnNodeScript(
167
168
  // directory.
168
169
  childEnv.LD_LIBRARY_PATH = nodeLibraryPath;
169
170
  }
171
+ signal?.throwIfAborted();
172
+ if (signal !== undefined) {
173
+ const child = spawnFn(nodeBin, [scriptPath, ...args], { cwd, env: childEnv, stdio });
174
+ const result = observeStarter(child, signal);
175
+ registerCleanup?.(result.then(() => {}, (error) => {
176
+ if (error instanceof AggregateError) throw error;
177
+ }));
178
+ return result;
179
+ }
170
180
  return new Promise((resolveExit, reject) => {
171
181
  const child = spawnFn(nodeBin, [scriptPath, ...args], {
172
182
  cwd,
@@ -217,14 +227,22 @@ export async function isDaemonReady(
217
227
  export async function waitForDaemonReady(options = {}) {
218
228
  const timeoutMs = options.timeoutMs ?? resolveReadyTimeoutMs(options.env);
219
229
  const pollMs = options.pollMs ?? DEFAULT_POLL_MS;
220
- const startedAt = Date.now();
221
- const sleep = options.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
222
-
223
- while (Date.now() - startedAt < timeoutMs) {
224
- if (await isDaemonReady(options)) return true;
225
- await sleep(pollMs);
230
+ const deadline = options.deadline ?? launchDeadline(timeoutMs, options.signal);
231
+ try {
232
+ for (;;) {
233
+ if (await withinLaunchDeadline(deadline, () => isDaemonReady(options))) return true;
234
+ if (options.probeOnly) return false;
235
+ const pause = Math.min(pollMs, deadline.remaining());
236
+ await withinLaunchDeadline(deadline, () => options.sleep
237
+ ? options.sleep(pause)
238
+ : delay(pause, undefined, { signal: deadline.signal }));
239
+ }
240
+ } catch (error) {
241
+ if (options.deadline === undefined && error === deadline.timeoutError) return false;
242
+ throw error;
243
+ } finally {
244
+ if (options.deadline === undefined) deadline.dispose();
226
245
  }
227
- return isDaemonReady(options);
228
246
  }
229
247
 
230
248
  export async function ensureDaemonForLaunch({
@@ -239,35 +257,134 @@ export async function ensureDaemonForLaunch({
239
257
  signalPid = process.kill,
240
258
  spawnDaemonFn = spawnDaemon,
241
259
  waitForReadyFn = waitForDaemonReady,
260
+ signal,
242
261
  } = {}) {
243
262
  if (isDaemonCommand(argv)) return { status: "skipped-daemon-command" };
244
263
  if (!shouldAutostartDaemon(env)) return { status: "disabled" };
245
264
 
246
- if (
247
- await waitForReadyFn({
248
- env,
249
- userHome,
250
- readText,
251
- signalPid,
252
- timeoutMs: 1,
253
- pollMs: 1,
254
- sleep: async () => {},
255
- })
256
- ) {
257
- return { status: "already-running" };
265
+ const deadline = launchDeadline(resolveReadyTimeoutMs(env), signal);
266
+ const cleanups = [];
267
+ const probeOptions = { env, userHome, readText, signalPid, deadline, signal: deadline.signal };
268
+ try {
269
+ if (await withinLaunchDeadline(deadline, () => waitForReadyFn({
270
+ ...probeOptions, timeoutMs: deadline.remaining(), probeOnly: true,
271
+ }))) return { status: "already-running" };
272
+
273
+ await withinLaunchDeadline(deadline, () => spawnDaemonFn(runtimeBin, {
274
+ env: { ...env, [READY_TIMEOUT_ENV]: String(deadline.remaining()) },
275
+ cwd,
276
+ nodeBin: runtimeNodeBin,
277
+ nodeLibraryPath: runtimeNodeLibraryPath,
278
+ signal: deadline.signal,
279
+ registerCleanup: (cleanup) => {
280
+ cleanup.catch(() => {});
281
+ cleanups.push(cleanup);
282
+ },
283
+ }));
284
+ const ready = await withinLaunchDeadline(deadline, () => waitForReadyFn({
285
+ ...probeOptions, timeoutMs: deadline.remaining(),
286
+ }));
287
+ if (!ready) throw deadline.timeoutError;
288
+ return { status: "started" };
289
+ } catch (error) {
290
+ deadline.abort(error);
291
+ throw error;
292
+ } finally {
293
+ deadline.dispose();
294
+ await Promise.all(cleanups);
258
295
  }
296
+ }
259
297
 
260
- await spawnDaemonFn(runtimeBin, {
261
- env,
262
- cwd,
263
- nodeBin: runtimeNodeBin,
264
- nodeLibraryPath: runtimeNodeLibraryPath,
265
- });
266
- const ready = await waitForReadyFn({ env, userHome, readText, signalPid });
267
- if (!ready) {
268
- throw new Error("AgenC daemon did not become ready before timeout");
298
+ function launchDeadline(timeoutMs, externalSignal) {
299
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) {
300
+ throw new RangeError("Daemon readiness timeout must be positive and no greater than 2147483647");
269
301
  }
270
- return { status: "started" };
302
+ const controller = new AbortController();
303
+ const expires = performance.now() + timeoutMs;
304
+ const timeoutError = new Error(`AgenC daemon did not become ready within ${timeoutMs}ms`);
305
+ const abort = (reason) => controller.abort(reason);
306
+ const forwardAbort = () => abort(externalSignal.reason);
307
+ externalSignal?.addEventListener("abort", forwardAbort, { once: true });
308
+ if (externalSignal?.aborted) forwardAbort();
309
+ const timer = setTimeout(() => abort(timeoutError), timeoutMs);
310
+ return {
311
+ signal: controller.signal,
312
+ timeoutError,
313
+ abort,
314
+ remaining() {
315
+ if (performance.now() >= expires) abort(timeoutError);
316
+ controller.signal.throwIfAborted();
317
+ return Math.max(1, Math.ceil(expires - performance.now()));
318
+ },
319
+ dispose() {
320
+ clearTimeout(timer);
321
+ externalSignal?.removeEventListener("abort", forwardAbort);
322
+ },
323
+ };
324
+ }
325
+
326
+ function withinLaunchDeadline(deadline, operation) {
327
+ return new Promise((resolveResult, reject) => {
328
+ const onAbort = () => reject(deadline.signal.reason);
329
+ deadline.signal.addEventListener("abort", onAbort, { once: true });
330
+ Promise.resolve().then(() => {
331
+ deadline.remaining();
332
+ return operation();
333
+ }).then((value) => {
334
+ deadline.remaining();
335
+ resolveResult(value);
336
+ }).catch(reject).finally(() => deadline.signal.removeEventListener("abort", onAbort));
337
+ });
338
+ }
339
+
340
+ function observeStarter(child, signal) {
341
+ return new Promise((resolveExit, reject) => {
342
+ let finished = false;
343
+ let cancelled = false;
344
+ let reason;
345
+ let killTimer;
346
+ let reapTimer;
347
+ const complete = (code, cleanupFailed = false) => {
348
+ if (finished) return;
349
+ finished = true;
350
+ clearTimeout(killTimer);
351
+ clearTimeout(reapTimer);
352
+ child.removeListener("close", onClose);
353
+ child.removeListener("error", onError);
354
+ signal.removeEventListener("abort", onAbort);
355
+ if (cleanupFailed) {
356
+ reject(new AggregateError([reason], "Daemon starter did not close within 1000ms of cancellation", { cause: reason }));
357
+ } else if (cancelled) {
358
+ reject(reason);
359
+ } else {
360
+ resolveExit(code ?? 1);
361
+ }
362
+ };
363
+ const kill = (terminationSignal) => {
364
+ try { child.kill(terminationSignal); } catch { return; }
365
+ };
366
+ const cancel = (error) => {
367
+ if (finished || cancelled) return;
368
+ cancelled = true;
369
+ reason = error;
370
+ killTimer = setTimeout(() => kill("SIGKILL"), 100);
371
+ reapTimer = setTimeout(() => complete(null, true), 1_000);
372
+ kill("SIGTERM");
373
+ };
374
+ const onError = (error) => cancel(error);
375
+ const onAbort = () => cancel(signal.reason);
376
+ const onClose = (code, exitSignal) => {
377
+ if (!cancelled && (signal.aborted || exitSignal)) {
378
+ cancelled = true;
379
+ reason = signal.aborted ? signal.reason : new Error(`agenc runtime exited from signal ${exitSignal}`);
380
+ }
381
+ complete(code);
382
+ };
383
+ child.on("error", onError);
384
+ child.once("close", onClose);
385
+ signal.addEventListener("abort", onAbort, { once: true });
386
+ if (signal.aborted) onAbort();
387
+ });
271
388
  }
272
389
 
273
390
  export async function main(
@@ -282,6 +399,14 @@ export async function main(
282
399
  userHome = homedir(),
283
400
  } = {},
284
401
  ) {
402
+ const startupFailure = preflightStartupArguments(argv, {
403
+ isTTY: Boolean(process.stdin.isTTY),
404
+ isStdoutTTY: Boolean(process.stdout.isTTY),
405
+ });
406
+ if (startupFailure !== null) {
407
+ process.stderr.write(`${startupFailure.message}\n`);
408
+ return startupFailure.exitCode;
409
+ }
285
410
  // Resolve (and, in a published install, download + verify) the runtime before
286
411
  // anything tries to spawn it. Sync default is avoided: it returns null when
287
412
  // runtime isn't an npm dep, which is the normal published case.