@runuai/host 0.9.7 → 0.9.9

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/lib/engines.ts CHANGED
@@ -296,18 +296,104 @@ function defaultPtyWrap(bin: string, args: string[]): PtyCommand | null {
296
296
  * harmless — every installer this module runs lands in a candidate dir,
297
297
  * which is checked before the probe.
298
298
  */
299
- async function probeLoginShellPath(): Promise<string | null> {
300
- const now = Date.now();
299
+ export async function probeLoginShellPath(
300
+ budgetMs: number = LOGIN_PATH_PROBE_BUDGET_MS,
301
+ seams: Partial<LoginShellProbeSeams> = {},
302
+ ): Promise<string | null> {
303
+ const { runShell, now: clock } = { ...defaultProbeSeams(), ...seams };
304
+ const now = clock();
301
305
  if (loginPathCache && now - loginPathCache.at < LOGIN_PATH_CACHE_MS) {
302
306
  return loginPathCache.value;
303
307
  }
304
- const value = (await shellPath("-lic")) ?? (await shellPath("-lc"));
308
+ // ONE deadline for the pair. A per-spawn timeout bounds each attempt but not
309
+ // the request: two 8s attempts is a 16s stall on a live /api/engines poll.
310
+ //
311
+ // The pair shares the budget, but `-lic` may not SPEND it all: the reserve
312
+ // below keeps the `-lc` retry reachable. Sharing one deadline outright would
313
+ // starve the fallback in exactly the case it exists for — see the reserve.
314
+ const deadline = now + budgetMs;
315
+ const value =
316
+ (await runBounded(
317
+ runShell,
318
+ clock,
319
+ "-lic",
320
+ deadline - fallbackReserve(budgetMs),
321
+ )) ?? (await runBounded(runShell, clock, "-lc", deadline));
305
322
  loginPathCache = { value, at: now };
306
323
  return value;
307
324
  }
308
325
 
326
+ /**
327
+ * Convert the remaining slice of the shared budget into one attempt's timeout,
328
+ * skipping the spawn entirely when a previous attempt already consumed it.
329
+ */
330
+ async function runBounded(
331
+ runShell: LoginShellProbeSeams["runShell"],
332
+ clock: LoginShellProbeSeams["now"],
333
+ flags: string,
334
+ deadline: number,
335
+ ): Promise<string | null> {
336
+ const remaining = deadline - clock();
337
+ if (remaining <= 0) return null;
338
+ return runShell(flags, remaining);
339
+ }
340
+
341
+ /** Drop the memoized login PATH. Exported for tests (module-level cache). */
342
+ export function resetLoginShellPathCache(): void {
343
+ loginPathCache = null;
344
+ }
345
+
309
346
  let loginPathCache: { value: string | null; at: number } | null = null;
310
347
  const LOGIN_PATH_CACHE_MS = 5 * 60_000;
348
+ /**
349
+ * Total wall-clock budget for the login-shell PATH probe, across BOTH
350
+ * attempts. `/api/engines` awaits this on a cold cache, so the bound has to
351
+ * be on the pair — see probeLoginShellPath.
352
+ */
353
+ const LOGIN_PATH_PROBE_BUDGET_MS = 8_000;
354
+ /**
355
+ * Slice of the budget held back so the `-lc` retry can always run.
356
+ *
357
+ * The interactive attempt is the one that stalls — it sources `.zshrc`, which
358
+ * is what makes it slow — and that is precisely when the non-interactive
359
+ * fallback is worth trying, because `-lc` skips that file. Letting `-lic`
360
+ * spend the whole budget therefore starves the fallback in the one case it
361
+ * exists for, and the probe resolves null: NOT "no login PATH" but "we gave
362
+ * up", cached as the former for LOGIN_PATH_CACHE_MS. `/api/engines` then
363
+ * reports every CLI absent for five minutes on a box where they are installed.
364
+ *
365
+ * Bounding the pair and keeping the fallback are not in tension: the total is
366
+ * still `budgetMs`, it is just no longer spendable entirely by the attempt
367
+ * most likely to hang.
368
+ */
369
+ const LOGIN_PATH_FALLBACK_RESERVE_MS = 3_000;
370
+
371
+ /**
372
+ * Never larger than half the budget, so a caller-shrunk budget (tests) still
373
+ * gives both attempts a real slice instead of collapsing one to zero.
374
+ */
375
+ function fallbackReserve(budgetMs: number): number {
376
+ return Math.min(LOGIN_PATH_FALLBACK_RESERVE_MS, Math.floor(budgetMs / 2));
377
+ }
378
+
379
+ /**
380
+ * The probe's two side effects, injectable like every other seam in this file.
381
+ *
382
+ * Budget arithmetic is the whole point of this code and it is a function of
383
+ * elapsed time, so a test that races real `sleep`s against a real clock is
384
+ * measuring the runner's load, not the logic — it fails on a busy self-hosted
385
+ * runner exactly like the stall it was written to prevent. Pin both and the
386
+ * assertions become the timeouts handed to each attempt.
387
+ */
388
+ export interface LoginShellProbeSeams {
389
+ /** Run `$SHELL <flags> /usr/bin/env`, bounded by `timeoutMs`. */
390
+ runShell: (flags: string, timeoutMs: number) => Promise<string | null>;
391
+ now: () => number;
392
+ }
393
+
394
+ function defaultProbeSeams(): LoginShellProbeSeams {
395
+ return { runShell: shellPath, now: Date.now };
396
+ }
311
397
 
312
398
  /**
313
399
  * Run `$SHELL <flags> /usr/bin/env` and pull PATH out. `/usr/bin/env` (not
@@ -315,13 +401,13 @@ const LOGIN_PATH_CACHE_MS = 5 * 60_000;
315
401
  * shell — fish's `$PATH` is a list and would echo space-joined — and the
316
402
  * LAST `PATH=` line wins so profile echo noise can't spoof it.
317
403
  */
318
- function shellPath(flags: string): Promise<string | null> {
404
+ function shellPath(flags: string, timeoutMs: number): Promise<string | null> {
319
405
  return new Promise((resolve) => {
320
406
  const shell = process.env.SHELL?.trim() || "/bin/zsh";
321
407
  nodeExecFile(
322
408
  shell,
323
409
  [flags, "/usr/bin/env"],
324
- { timeout: 8_000 },
410
+ { timeout: timeoutMs },
325
411
  (err, stdout) => {
326
412
  if (err) return resolve(null);
327
413
  const lines = String(stdout)
@@ -366,8 +452,18 @@ export async function engineCliStatuses(
366
452
  seams: Partial<EngineSeams> = {},
367
453
  ): Promise<Record<EngineKind, boolean>> {
368
454
  const s = withDefaults(seams);
455
+ // Resolve the login PATH at most ONCE per call. Every kind that isn't in a
456
+ // candidate dir falls through to the probe, so six kinds would otherwise
457
+ // await it six times — bounding each probe wouldn't bound the request. The
458
+ // default seam memoizes for 5 min, but the per-request bound must not
459
+ // depend on that cache being warm.
460
+ let loginOnce: Promise<string | null> | null = null;
461
+ const scoped: EngineSeams = {
462
+ ...s,
463
+ loginShellPath: () => (loginOnce ??= s.loginShellPath()),
464
+ };
369
465
  const out = {} as Record<EngineKind, boolean>;
370
- for (const kind of ORDER) out[kind] = await engineCliFound(kind, s);
466
+ for (const kind of ORDER) out[kind] = await engineCliFound(kind, scoped);
371
467
  return out;
372
468
  }
373
469
 
@@ -1634,11 +1634,27 @@ export class Orchestrator {
1634
1634
  return remaining;
1635
1635
  }
1636
1636
 
1637
- /** Retire turn-local state owned by a runner generation before replacement. */
1637
+ /**
1638
+ * Retire turn-local state owned by a runner generation before replacement.
1639
+ *
1640
+ * A replacement's identity guard suppresses the old runner's eventual
1641
+ * terminal event. If the human already stopped that turn, close its cloud
1642
+ * boundary here and discard its replay prompt; otherwise credential refresh
1643
+ * could silently restart canceled work on the new generation.
1644
+ */
1638
1645
  private retireRunnerGeneration(channel: Channel, agentId: string): void {
1646
+ const aborted = channel.interrupted.delete(agentId);
1639
1647
  channel.activeTurns.delete(agentId);
1640
1648
  channel.openTurns.delete(agentId);
1641
- channel.interrupted.delete(agentId);
1649
+ if (aborted) {
1650
+ channel.lastPrompt.delete(agentId);
1651
+ this.emitHost({
1652
+ kind: "agent.turn_complete",
1653
+ taskId: channel.taskId,
1654
+ agentId,
1655
+ aborted: true,
1656
+ });
1657
+ }
1642
1658
  }
1643
1659
 
1644
1660
  private startPrompt(
@@ -1766,9 +1782,12 @@ export class Orchestrator {
1766
1782
  // An interrupted turn is a half-turn — its eventual turn_complete must not
1767
1783
  // hand the buffered fragment to @-mentioned peers. Flag it so the boundary
1768
1784
  // goes out `aborted` and the cloud discards the buffer. Only when the turn
1769
- // actually streamed output (openTurns): an idle-agent ESC must not mark
1770
- // the NEXT legitimate turn as aborted.
1771
- if (channel.openTurns.has(agentId)) channel.interrupted.add(agentId);
1785
+ // is actually in flight (activeTurns): an idle-agent ESC must not mark the
1786
+ // NEXT legitimate turn as aborted, while a stop before the first output is
1787
+ // still a cancellation and must not be replayed by runner replacement.
1788
+ if ((channel.activeTurns.get(agentId) ?? 0) > 0) {
1789
+ channel.interrupted.add(agentId);
1790
+ }
1772
1791
  void session.interrupt();
1773
1792
  this.emitSystemNote(taskId, `Stopped @${agentId}.`);
1774
1793
  return { ok: true };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
package/src/ui/server.ts CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  engineCatalog,
38
38
  engineStatuses,
39
39
  isEngineKind,
40
+ type EngineSeams,
40
41
  } from "../../lib/engines";
41
42
  import {
42
43
  addEngineAccount,
@@ -86,6 +87,12 @@ export interface UiServerOptions {
86
87
  * Optional (tests omit it).
87
88
  */
88
89
  stopTask?: (taskId: string) => Promise<{ ok: boolean; error?: string }>;
90
+ /**
91
+ * Engine probe seams for /api/engines. Production omits this and gets the
92
+ * real filesystem + login-shell probes; tests pin `loginShellPath` so the
93
+ * endpoint never spawns a login shell (which is bounded, but real, I/O).
94
+ */
95
+ engineSeams?: Partial<EngineSeams>;
89
96
  }
90
97
 
91
98
  export interface UiServerHandle {
@@ -199,7 +206,7 @@ async function handle(
199
206
  case "/api/users":
200
207
  return sendJson(res, UsersResponse, usersBody(opts));
201
208
  case "/api/engines":
202
- return sendJson(res, EnginesResponse, await enginesBody());
209
+ return sendJson(res, EnginesResponse, await enginesBody(opts.engineSeams));
203
210
  }
204
211
  if (path.startsWith("/api/")) {
205
212
  return sendError(res, 404, `no such endpoint: ${path}`);
@@ -215,15 +222,17 @@ async function handle(
215
222
  /** Engine kinds that participate in the multi-account model (ADR-076). */
216
223
  const ACCOUNT_KINDS = ["claude", "codex", "opencode"] as const;
217
224
 
218
- async function enginesBody(): Promise<EnginesResponse> {
225
+ async function enginesBody(
226
+ seams: Partial<EngineSeams> = {},
227
+ ): Promise<EnginesResponse> {
219
228
  const accounts: Record<string, ReturnType<typeof listEngineAccounts>> = {};
220
229
  for (const kind of ACCOUNT_KINDS) {
221
230
  accounts[kind] = listEngineAccounts(kind);
222
231
  }
223
232
  return {
224
233
  catalog: engineCatalog(),
225
- statuses: engineStatuses(),
226
- cli: await engineCliStatuses(),
234
+ statuses: engineStatuses(seams),
235
+ cli: await engineCliStatuses(seams),
227
236
  accounts,
228
237
  };
229
238
  }