@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 +102 -6
- package/lib/orchestrator.ts +24 -5
- package/package.json +1 -1
- package/src/ui/server.ts +13 -4
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(
|
|
300
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
466
|
+
for (const kind of ORDER) out[kind] = await engineCliFound(kind, scoped);
|
|
371
467
|
return out;
|
|
372
468
|
}
|
|
373
469
|
|
package/lib/orchestrator.ts
CHANGED
|
@@ -1634,11 +1634,27 @@ export class Orchestrator {
|
|
|
1634
1634
|
return remaining;
|
|
1635
1635
|
}
|
|
1636
1636
|
|
|
1637
|
-
/**
|
|
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
|
-
|
|
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
|
|
1770
|
-
//
|
|
1771
|
-
|
|
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
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(
|
|
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
|
}
|