@shogo-ai/worker 2.3.8 → 2.3.10
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/dist/cli.mjs +29 -27
- package/package.json +2 -1
- package/src/lib/__tests__/runtime-activity.test.ts +152 -0
- package/src/lib/__tests__/runtime-manager-active-work.test.ts +15 -3
- package/src/lib/__tests__/runtime-manager-busy-guard.test.ts +363 -0
- package/src/lib/__tests__/runtime-manager-idle-eviction.test.ts +8 -1
- package/src/lib/__tests__/runtime-manager-on-runtime-gone.test.ts +7 -1
- package/src/lib/__tests__/runtime-manager-vite-watch.e2e.test.ts +55 -1
- package/src/lib/runtime-activity.ts +218 -0
- package/src/lib/runtime-manager.ts +300 -21
|
@@ -51,8 +51,19 @@ import {
|
|
|
51
51
|
import type { ResolveRejection, RuntimeResolver } from './tunnel.ts';
|
|
52
52
|
import { CloudFileTransport } from '@shogo-ai/sdk/cloud-file-transport';
|
|
53
53
|
import { killViteWatchFromPidfile } from '@shogo-ai/sdk/vite-watch';
|
|
54
|
+
import { reapChildLedger } from '@shogo-ai/sdk/child-ledger';
|
|
54
55
|
import { CloudSyncWatcher } from './cloud-sync-watcher.ts';
|
|
55
56
|
import { cloneProject, gitIsAvailable, isGitRepo } from './git-cloner.ts';
|
|
57
|
+
import {
|
|
58
|
+
ACTIVITY_RESAMPLE_BEFORE_EVICT_MS,
|
|
59
|
+
ACTIVITY_SAMPLE_MS,
|
|
60
|
+
ZERO_COUNTS,
|
|
61
|
+
decideBusy,
|
|
62
|
+
describeBusyReason,
|
|
63
|
+
type ActivityCounts,
|
|
64
|
+
type ActivitySample,
|
|
65
|
+
type BusyDecision,
|
|
66
|
+
} from './runtime-activity.ts';
|
|
56
67
|
|
|
57
68
|
/** Port range for random allocation (mirrors apps/api desktop manager). */
|
|
58
69
|
const PORT_RANGE_START = 37100;
|
|
@@ -98,6 +109,13 @@ const STREAM_ACTIVE_WINDOW_MS = 30 * 1000;
|
|
|
98
109
|
/** Budget for asking a runtime whether it has work in progress before evicting it. */
|
|
99
110
|
const ACTIVITY_PROBE_TIMEOUT_MS = 3000;
|
|
100
111
|
|
|
112
|
+
/**
|
|
113
|
+
* How many of a runtime's last stderr lines to keep. An exit nobody asked for
|
|
114
|
+
* is logged with its code and these lines, so a host log explains the exit
|
|
115
|
+
* instead of only counting it (Samsara had 13 unexplained exits in 24h).
|
|
116
|
+
*/
|
|
117
|
+
const STDERR_TAIL_LINES = 8;
|
|
118
|
+
|
|
101
119
|
/** Restart backoff bounds. */
|
|
102
120
|
const RESTART_BACKOFF_BASE_MS = 1_000;
|
|
103
121
|
const RESTART_BACKOFF_MAX_MS = 60_000;
|
|
@@ -413,7 +431,7 @@ export interface RuntimeGoneInfo {
|
|
|
413
431
|
signal?: NodeJS.Signals | null;
|
|
414
432
|
}
|
|
415
433
|
|
|
416
|
-
export type RuntimeStopReason = 'stop' | 'idle-evict' | 'lru-evict';
|
|
434
|
+
export type RuntimeStopReason = 'stop' | 'idle-evict' | 'lru-evict' | 'dead';
|
|
417
435
|
|
|
418
436
|
export interface AutoPullOptions {
|
|
419
437
|
/** Master switch. Defaults to false; the `worker start` command flips
|
|
@@ -527,6 +545,16 @@ interface InternalRuntime {
|
|
|
527
545
|
* dedupe instead of double-spawning.
|
|
528
546
|
*/
|
|
529
547
|
startPromise: Promise<InternalRuntime> | null;
|
|
548
|
+
/**
|
|
549
|
+
* Cached reading of the runtime's own activity, refreshed by
|
|
550
|
+
* {@link WorkerRuntimeManager.sampleActivity} every
|
|
551
|
+
* {@link ACTIVITY_SAMPLE_MS}. Eviction decisions read THIS, never a fresh
|
|
552
|
+
* probe: a probe taken while the host is under load times out, and a
|
|
553
|
+
* timeout used to read as "idle" (SHO-143).
|
|
554
|
+
*/
|
|
555
|
+
activity?: ActivitySample | null;
|
|
556
|
+
/** Last few stderr lines, logged when the process exits unrequested. */
|
|
557
|
+
stderrTail?: string[];
|
|
530
558
|
}
|
|
531
559
|
|
|
532
560
|
/**
|
|
@@ -657,11 +685,18 @@ export class WorkerRuntimeManager implements RuntimeResolver {
|
|
|
657
685
|
* to pick between git commit-push and file-transport flush modes. */
|
|
658
686
|
private readonly syncModes = new Map<string, SyncMode>();
|
|
659
687
|
|
|
688
|
+
/** Timer that refreshes every running runtime's cached activity sample. */
|
|
689
|
+
private activityTimer: ReturnType<typeof setInterval> | null = null;
|
|
690
|
+
|
|
660
691
|
constructor(opts: WorkerRuntimeManagerOptions = {}) {
|
|
661
692
|
this.opts = opts;
|
|
662
693
|
this.log = opts.logger ?? console;
|
|
663
694
|
this.spawnCommand = opts.spawnCommand ?? defaultSpawnCommand;
|
|
664
695
|
this.sweepOrphanedViteWatches();
|
|
696
|
+
// Activity is sampled on its own timer rather than probed at eviction
|
|
697
|
+
// time — see runtime-activity.ts. Unref'd so it never holds the process
|
|
698
|
+
// open.
|
|
699
|
+
this.ensureActivitySampler();
|
|
665
700
|
}
|
|
666
701
|
|
|
667
702
|
/**
|
|
@@ -874,21 +909,44 @@ export class WorkerRuntimeManager implements RuntimeResolver {
|
|
|
874
909
|
|
|
875
910
|
private async evictLru(cap: number, runningCount: number, candidates: InternalRuntime[], now: number): Promise<void> {
|
|
876
911
|
let overBy = runningCount - cap;
|
|
912
|
+
// Ask every candidate at once (each answer is cached when fresh, probed
|
|
913
|
+
// when not) so one hung runtime cannot stall the pass behind its 3s
|
|
914
|
+
// timeout, and so the decision for each one is taken before anything is
|
|
915
|
+
// killed. A failed probe is `unknown`, and unknown keeps the runtime
|
|
916
|
+
// (SHO-143) — this pass may leave the host briefly over the cap rather
|
|
917
|
+
// than kill work.
|
|
918
|
+
const verdicts = new Map<string, BusyDecision>();
|
|
919
|
+
await Promise.all(
|
|
920
|
+
candidates.map(async (c) => {
|
|
921
|
+
verdicts.set(c.projectId, await this.freshActivityDecision(c.projectId));
|
|
922
|
+
}),
|
|
923
|
+
);
|
|
877
924
|
for (const victim of candidates) {
|
|
878
925
|
if (overBy <= 0) break;
|
|
879
|
-
const work = await this.activeWork(victim);
|
|
880
926
|
if (victim.status !== 'running' || this.runtimes.get(victim.projectId) !== victim) continue;
|
|
881
|
-
|
|
927
|
+
const decision = verdicts.get(victim.projectId);
|
|
928
|
+
if (!decision) continue;
|
|
929
|
+
if (decision.dead) {
|
|
930
|
+
this.log.log(
|
|
931
|
+
`[WorkerRuntimeManager] maxRuntimes=${cap}: reaping dead ${victim.projectId} (${describeBusyReason(decision)})`,
|
|
932
|
+
);
|
|
933
|
+
void this.stop(victim.projectId, 'SIGTERM', 'dead').catch((err: any) => {
|
|
934
|
+
this.log.warn(`[WorkerRuntimeManager] reap of ${victim.projectId} failed: ${err?.message ?? err}`);
|
|
935
|
+
});
|
|
936
|
+
overBy--;
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
if (decision.busy) {
|
|
882
940
|
this.log.log(
|
|
883
|
-
`[WorkerRuntimeManager] maxRuntimes=${cap}:
|
|
884
|
-
|
|
941
|
+
`[WorkerRuntimeManager] maxRuntimes=${cap}: keeping ${victim.projectId} over cap=${cap}: ` +
|
|
942
|
+
`${decision.reason} (${describeBusyReason(decision)})`,
|
|
885
943
|
);
|
|
886
944
|
continue;
|
|
887
945
|
}
|
|
888
946
|
const idleMs = now - victim.lastUsedAt;
|
|
889
947
|
this.log.log(
|
|
890
948
|
`[WorkerRuntimeManager] maxRuntimes=${cap} exceeded (${runningCount} running) — ` +
|
|
891
|
-
`LRU-evicting ${victim.projectId} (idle ${Math.round(idleMs / 1000)}s)`,
|
|
949
|
+
`LRU-evicting ${victim.projectId} (idle ${Math.round(idleMs / 1000)}s, ${decision.countsText})`,
|
|
892
950
|
);
|
|
893
951
|
void this.stop(victim.projectId, 'SIGTERM', 'lru-evict').catch((err: any) => {
|
|
894
952
|
this.log.warn(
|
|
@@ -901,7 +959,7 @@ export class WorkerRuntimeManager implements RuntimeResolver {
|
|
|
901
959
|
if (overBy > 0) {
|
|
902
960
|
this.log.log(
|
|
903
961
|
`[WorkerRuntimeManager] maxRuntimes=${cap} still exceeded by ${overBy} after eviction pass — ` +
|
|
904
|
-
`remaining over-cap slots are active; will retry on next spawn / idle reap`,
|
|
962
|
+
`remaining over-cap slots are active or unverifiable; will retry on next spawn / idle reap`,
|
|
905
963
|
);
|
|
906
964
|
}
|
|
907
965
|
}
|
|
@@ -1203,6 +1261,7 @@ export class WorkerRuntimeManager implements RuntimeResolver {
|
|
|
1203
1261
|
// the rest of the group too in case any child ignored SIGTERM.
|
|
1204
1262
|
this.killProcessGroup(r, 'SIGKILL');
|
|
1205
1263
|
this.killViteWatch(r);
|
|
1264
|
+
this.reapChildLedgerFor(projectId, r.spawnConfig.projectDir);
|
|
1206
1265
|
}
|
|
1207
1266
|
r.pid = null;
|
|
1208
1267
|
this.releasePort(r.agentPort);
|
|
@@ -1280,6 +1339,10 @@ export class WorkerRuntimeManager implements RuntimeResolver {
|
|
|
1280
1339
|
|
|
1281
1340
|
async stopAll(signal: NodeJS.Signals = 'SIGTERM'): Promise<void> {
|
|
1282
1341
|
this.stopped = true;
|
|
1342
|
+
if (this.activityTimer) {
|
|
1343
|
+
clearInterval(this.activityTimer);
|
|
1344
|
+
this.activityTimer = null;
|
|
1345
|
+
}
|
|
1283
1346
|
// Stop watchers FIRST so their final flush has a chance to PUT before
|
|
1284
1347
|
// we tear down processes. We don't await individual stops in parallel
|
|
1285
1348
|
// with runtime stops because watcher.stop() does network IO and we
|
|
@@ -1407,6 +1470,11 @@ export class WorkerRuntimeManager implements RuntimeResolver {
|
|
|
1407
1470
|
slot.pid = proc.pid ?? null;
|
|
1408
1471
|
slot.status = 'starting';
|
|
1409
1472
|
slot.startedAt = Date.now();
|
|
1473
|
+
// A fresh incarnation owns a fresh activity reading and stderr tail; the
|
|
1474
|
+
// previous process's work (and its last words) are not evidence about this
|
|
1475
|
+
// one.
|
|
1476
|
+
slot.activity = null;
|
|
1477
|
+
slot.stderrTail = [];
|
|
1410
1478
|
const openAttemptId = slot.spawnConfig.extraEnv?.SHOGO_OPEN_ID;
|
|
1411
1479
|
perfLog('agent-spawned', slot.projectId, openAttemptId, {
|
|
1412
1480
|
pid: slot.pid,
|
|
@@ -1469,8 +1537,12 @@ export class WorkerRuntimeManager implements RuntimeResolver {
|
|
|
1469
1537
|
stream: 'stderr',
|
|
1470
1538
|
});
|
|
1471
1539
|
}
|
|
1540
|
+
const tail = (slot.stderrTail ??= []);
|
|
1472
1541
|
for (const line of data.toString().trimEnd().split('\n')) {
|
|
1473
|
-
if (line)
|
|
1542
|
+
if (!line) continue;
|
|
1543
|
+
this.log.error(`${prefix} ${line}`);
|
|
1544
|
+
tail.push(line);
|
|
1545
|
+
if (tail.length > STDERR_TAIL_LINES) tail.splice(0, tail.length - STDERR_TAIL_LINES);
|
|
1474
1546
|
}
|
|
1475
1547
|
});
|
|
1476
1548
|
|
|
@@ -1484,6 +1556,9 @@ export class WorkerRuntimeManager implements RuntimeResolver {
|
|
|
1484
1556
|
slot.lastUsedAt = Date.now();
|
|
1485
1557
|
this.armIdleTimer(slot);
|
|
1486
1558
|
this.armGraceTimer(slot);
|
|
1559
|
+
// Warm the activity cache immediately; the startup grace covers the gap
|
|
1560
|
+
// until this lands.
|
|
1561
|
+
void this.sampleActivity(slot).catch(() => null);
|
|
1487
1562
|
return slot;
|
|
1488
1563
|
} catch (err: any) {
|
|
1489
1564
|
slot.status = 'error';
|
|
@@ -1750,6 +1825,38 @@ export class WorkerRuntimeManager implements RuntimeResolver {
|
|
|
1750
1825
|
}
|
|
1751
1826
|
}
|
|
1752
1827
|
|
|
1828
|
+
/**
|
|
1829
|
+
* Reap the detached process groups a runtime recorded before it died
|
|
1830
|
+
* (SHO-144).
|
|
1831
|
+
*
|
|
1832
|
+
* Every exec shell and the preview API sidecar lead their own group, so the
|
|
1833
|
+
* runtime's own group kill never reached them — a SIGKILL, a jetsam kill or a
|
|
1834
|
+
* crash left them orphaned to launchd/init. The ledger's command-line check
|
|
1835
|
+
* means a reused pid is never signalled. POSIX only: on Windows the runtime's
|
|
1836
|
+
* own kill-on-close job object does this in the kernel.
|
|
1837
|
+
*/
|
|
1838
|
+
private reapChildLedgerFor(projectId: string, workspaceDir: string | undefined): void {
|
|
1839
|
+
if (!workspaceDir || process.platform === 'win32') return;
|
|
1840
|
+
try {
|
|
1841
|
+
const { reaped, dropped } = reapChildLedger(workspaceDir, { logger: this.log });
|
|
1842
|
+
if (reaped.length > 0) {
|
|
1843
|
+
this.log.log(
|
|
1844
|
+
`[WorkerRuntimeManager] Reaped ${reaped.length} leftover child group(s) for ${projectId}: ` +
|
|
1845
|
+
reaped.map((child) => `${child.label}(pgid=${child.pgid})`).join(', '),
|
|
1846
|
+
);
|
|
1847
|
+
}
|
|
1848
|
+
if (dropped.length > 0) {
|
|
1849
|
+
this.log.log(
|
|
1850
|
+
`[WorkerRuntimeManager] Dropped ${dropped.length} stale ledger entr(ies) for ${projectId} (process gone or pid reused)`,
|
|
1851
|
+
);
|
|
1852
|
+
}
|
|
1853
|
+
} catch (err: any) {
|
|
1854
|
+
this.log.warn(
|
|
1855
|
+
`[WorkerRuntimeManager] Child ledger reap for ${projectId} failed: ${err?.message ?? err}`,
|
|
1856
|
+
);
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1753
1860
|
/**
|
|
1754
1861
|
* Clean pidfiles left by a previous worker process before this manager
|
|
1755
1862
|
* starts accepting projects. The command-line verification in
|
|
@@ -1775,6 +1882,9 @@ export class WorkerRuntimeManager implements RuntimeResolver {
|
|
|
1775
1882
|
`[WorkerRuntimeManager] Reaped orphaned Vite watch for ${entry.name} (pid=${reaped.pid})`,
|
|
1776
1883
|
);
|
|
1777
1884
|
}
|
|
1885
|
+
// The ledger covers what the pidfile never could: the exec shells and
|
|
1886
|
+
// the API sidecar of a runtime that was killed without a shutdown.
|
|
1887
|
+
this.reapChildLedgerFor(entry.name, workspaceDir);
|
|
1778
1888
|
} catch (err: any) {
|
|
1779
1889
|
this.log.warn(
|
|
1780
1890
|
`[WorkerRuntimeManager] Vite watch startup sweep failed for ${entry.name}: ${err?.message ?? err}`,
|
|
@@ -1793,9 +1903,21 @@ export class WorkerRuntimeManager implements RuntimeResolver {
|
|
|
1793
1903
|
|
|
1794
1904
|
private handleExit(slot: InternalRuntime, code: number | null, signal: NodeJS.Signals | null): void {
|
|
1795
1905
|
const exitedClean = signal === null && code === 0;
|
|
1906
|
+
const requested = slot.status === 'stopping' || this.stopped;
|
|
1796
1907
|
this.log.log(
|
|
1797
1908
|
`[WorkerRuntimeManager] runtime ${slot.projectId} exited (code=${code}, signal=${signal})`,
|
|
1798
1909
|
);
|
|
1910
|
+
if (!requested) {
|
|
1911
|
+
// Nobody asked for this. Say why as far as we can tell: the exit code,
|
|
1912
|
+
// how long it ran, what it was doing, and the child's own last words.
|
|
1913
|
+
// Before this, 13 of 201 exits on one host had no cause logged at all.
|
|
1914
|
+
const tail = slot.stderrTail?.length ? `\n ${slot.stderrTail.join('\n ')}` : ' (nothing on stderr)';
|
|
1915
|
+
this.log.warn(
|
|
1916
|
+
`[WorkerRuntimeManager] runtime ${slot.projectId} exited WITHOUT a stop request ` +
|
|
1917
|
+
`(code=${code}, signal=${signal}, uptime ${Math.round((Date.now() - slot.startedAt) / 1000)}s, ` +
|
|
1918
|
+
`last activity ${this.activityDecision(slot.projectId).countsText}) — last stderr:${tail}`,
|
|
1919
|
+
);
|
|
1920
|
+
}
|
|
1799
1921
|
slot.proc = null;
|
|
1800
1922
|
if (slot.graceTimer) {
|
|
1801
1923
|
clearTimeout(slot.graceTimer);
|
|
@@ -1841,6 +1963,7 @@ export class WorkerRuntimeManager implements RuntimeResolver {
|
|
|
1841
1963
|
// circuit breaker below stops that loop.
|
|
1842
1964
|
this.killProcessGroup(slot, 'SIGKILL');
|
|
1843
1965
|
this.killViteWatch(slot);
|
|
1966
|
+
this.reapChildLedgerFor(slot.projectId, slot.spawnConfig.projectDir);
|
|
1844
1967
|
slot.pid = null;
|
|
1845
1968
|
|
|
1846
1969
|
const now = Date.now();
|
|
@@ -1931,13 +2054,26 @@ export class WorkerRuntimeManager implements RuntimeResolver {
|
|
|
1931
2054
|
return;
|
|
1932
2055
|
}
|
|
1933
2056
|
// Touch signals can miss work that isn't proxied through the host
|
|
1934
|
-
// (schedules, heartbeats, background tasks), so
|
|
1935
|
-
|
|
2057
|
+
// (schedules, heartbeats, background tasks), so the verdict comes from
|
|
2058
|
+
// the runtime's own activity. It reads the CACHED sample and only re-asks
|
|
2059
|
+
// when that sample has aged out — never a probe issued because the host
|
|
2060
|
+
// just decided to kill something, which is what used to time out under
|
|
2061
|
+
// load and read as "idle" (SHO-143). A failed probe is `unknown` → keep.
|
|
2062
|
+
const decision = await this.freshActivityDecision(slot.projectId);
|
|
1936
2063
|
if (this.runtimes.get(slot.projectId) !== slot || slot.status !== 'running' || slot.idleTimer) return;
|
|
1937
|
-
if (
|
|
2064
|
+
if (decision.dead) {
|
|
2065
|
+
this.log.log(
|
|
2066
|
+
`[WorkerRuntimeManager] reaping dead ${slot.projectId} (${describeBusyReason(decision)})`,
|
|
2067
|
+
);
|
|
2068
|
+
void this.stop(slot.projectId, 'SIGTERM', 'dead').catch((err) => {
|
|
2069
|
+
this.log.warn(`[WorkerRuntimeManager] reap stop failed: ${err?.message ?? err}`);
|
|
2070
|
+
});
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
if (decision.busy) {
|
|
1938
2074
|
this.log.log(
|
|
1939
2075
|
`[WorkerRuntimeManager] not idle-evicting ${slot.projectId}: ` +
|
|
1940
|
-
`${
|
|
2076
|
+
`${decision.reason} (${describeBusyReason(decision)})`,
|
|
1941
2077
|
);
|
|
1942
2078
|
this.touch(slot.projectId);
|
|
1943
2079
|
return;
|
|
@@ -1951,17 +2087,22 @@ export class WorkerRuntimeManager implements RuntimeResolver {
|
|
|
1951
2087
|
|
|
1952
2088
|
/**
|
|
1953
2089
|
* True while the runtime reports work in progress: agent turns (chat,
|
|
1954
|
-
* schedule, heartbeat), background tasks
|
|
1955
|
-
* chat streams.
|
|
2090
|
+
* schedule, heartbeat), background tasks, live child processes, or open
|
|
2091
|
+
* chat streams.
|
|
2092
|
+
*
|
|
2093
|
+
* This is the RAW probe, and "could not tell" reads as false so callers that
|
|
2094
|
+
* describe a runtime to a human (`workspaceActivity` → "Working in N other
|
|
2095
|
+
* workspaces") never claim work that isn't there. Eviction must NOT use it:
|
|
2096
|
+
* use {@link activityDecision}, where unknown means KEEP (SHO-143).
|
|
1956
2097
|
*/
|
|
1957
2098
|
async hasActiveWork(projectId: string): Promise<boolean> {
|
|
1958
2099
|
const slot = this.runtimes.get(projectId);
|
|
1959
|
-
|
|
2100
|
+
if (!slot) return false;
|
|
2101
|
+
const counts = await this.activeWork(slot);
|
|
2102
|
+
return !!counts && counts.turns + counts.tasks + counts.streams + counts.children > 0;
|
|
1960
2103
|
}
|
|
1961
2104
|
|
|
1962
|
-
private async activeWork(
|
|
1963
|
-
slot: InternalRuntime,
|
|
1964
|
-
): Promise<{ turns: number; tasks: number; streams: number } | null> {
|
|
2105
|
+
private async activeWork(slot: InternalRuntime): Promise<ActivityCounts | null> {
|
|
1965
2106
|
if (slot.status !== 'running' || !slot.agentPort) return null;
|
|
1966
2107
|
try {
|
|
1967
2108
|
const resp = await fetch(`http://127.0.0.1:${slot.agentPort}/pool/activity`, {
|
|
@@ -1969,14 +2110,152 @@ export class WorkerRuntimeManager implements RuntimeResolver {
|
|
|
1969
2110
|
signal: AbortSignal.timeout(ACTIVITY_PROBE_TIMEOUT_MS),
|
|
1970
2111
|
});
|
|
1971
2112
|
if (!resp.ok) return null;
|
|
1972
|
-
const a = (await resp.json()) as {
|
|
1973
|
-
|
|
1974
|
-
|
|
2113
|
+
const a = (await resp.json()) as {
|
|
2114
|
+
activeTurns?: number;
|
|
2115
|
+
activeTasks?: number;
|
|
2116
|
+
activeStreams?: number;
|
|
2117
|
+
activeChildren?: number;
|
|
2118
|
+
};
|
|
2119
|
+
// A runtime too old to report task counts answers with liveness only. We
|
|
2120
|
+
// cannot tell what it is running, so this is "unknown", not "idle" —
|
|
2121
|
+
// returning zeros here would re-open the SHO-143 eviction bug on any
|
|
2122
|
+
// rolling deploy that leaves an older runtime running.
|
|
2123
|
+
const reported =
|
|
2124
|
+
typeof a.activeTurns === 'number' ||
|
|
2125
|
+
typeof a.activeTasks === 'number' ||
|
|
2126
|
+
typeof a.activeStreams === 'number' ||
|
|
2127
|
+
typeof a.activeChildren === 'number';
|
|
2128
|
+
if (!reported) return null;
|
|
2129
|
+
return {
|
|
2130
|
+
turns: a.activeTurns ?? 0,
|
|
2131
|
+
tasks: a.activeTasks ?? 0,
|
|
2132
|
+
streams: a.activeStreams ?? 0,
|
|
2133
|
+
children: a.activeChildren ?? 0,
|
|
2134
|
+
};
|
|
1975
2135
|
} catch {
|
|
1976
2136
|
return null;
|
|
1977
2137
|
}
|
|
1978
2138
|
}
|
|
1979
2139
|
|
|
2140
|
+
/**
|
|
2141
|
+
* Refresh `slot.activity` from the runtime. Runs on
|
|
2142
|
+
* {@link ACTIVITY_SAMPLE_MS}, NEVER from an eviction path: a probe issued at
|
|
2143
|
+
* the moment the host decides to kill something times out exactly when the
|
|
2144
|
+
* host is busy, and a timeout used to read as "idle".
|
|
2145
|
+
*/
|
|
2146
|
+
async sampleActivity(slot: InternalRuntime): Promise<ActivitySample> {
|
|
2147
|
+
const now = Date.now();
|
|
2148
|
+
const counts = await this.activeWork(slot);
|
|
2149
|
+
const prev = slot.activity;
|
|
2150
|
+
const sample: ActivitySample = counts
|
|
2151
|
+
? { counts, at: now, ok: true }
|
|
2152
|
+
: {
|
|
2153
|
+
// Keep the last real counts for the log line only — `ok: false`
|
|
2154
|
+
// means the decision treats this sample as unknown, not as idle.
|
|
2155
|
+
counts: prev?.ok ? prev.counts : ZERO_COUNTS,
|
|
2156
|
+
at: now,
|
|
2157
|
+
ok: false,
|
|
2158
|
+
error:
|
|
2159
|
+
slot.status !== 'running'
|
|
2160
|
+
? `status=${slot.status}`
|
|
2161
|
+
: slot.agentPort
|
|
2162
|
+
? 'no answer from /pool/activity'
|
|
2163
|
+
: 'no agent port',
|
|
2164
|
+
failingSince: prev && !prev.ok ? (prev.failingSince ?? now) : now,
|
|
2165
|
+
};
|
|
2166
|
+
slot.activity = sample;
|
|
2167
|
+
return sample;
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
/** Re-read every running runtime's activity. Best-effort, never throws. */
|
|
2171
|
+
async sampleAllActivity(): Promise<void> {
|
|
2172
|
+
const running = [...this.runtimes.values()].filter((s) => s.status === 'running' && !!s.agentPort);
|
|
2173
|
+
if (running.length === 0) return;
|
|
2174
|
+
await Promise.all(running.map((s) => this.sampleActivity(s).catch(() => null)));
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
private ensureActivitySampler(): void {
|
|
2178
|
+
if (this.activityTimer) return;
|
|
2179
|
+
const timer = setInterval(() => {
|
|
2180
|
+
void this.sampleAllActivity();
|
|
2181
|
+
}, ACTIVITY_SAMPLE_MS);
|
|
2182
|
+
(timer as { unref?: () => void }).unref?.();
|
|
2183
|
+
this.activityTimer = timer;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
/**
|
|
2187
|
+
* The one eviction verdict, shared with the desktop API's pools. Reads the
|
|
2188
|
+
* cached sample, so it never does I/O and can never be delayed into a wrong
|
|
2189
|
+
* answer by CPU pressure. Unknown (no sample, failed probe, stale sample,
|
|
2190
|
+
* still starting) is BUSY; only a provably dead runtime is `dead`.
|
|
2191
|
+
*/
|
|
2192
|
+
activityDecision(
|
|
2193
|
+
projectId: string,
|
|
2194
|
+
opts?: { terminalSessions?: number; inflightCalls?: number },
|
|
2195
|
+
): BusyDecision {
|
|
2196
|
+
const now = Date.now();
|
|
2197
|
+
const slot = this.runtimes.get(projectId);
|
|
2198
|
+
if (!slot) {
|
|
2199
|
+
// No slot at all: nothing to keep, nothing to evict.
|
|
2200
|
+
return decideBusy({ now, startedAt: now, status: 'stopped', processExited: true });
|
|
2201
|
+
}
|
|
2202
|
+
return decideBusy({
|
|
2203
|
+
now,
|
|
2204
|
+
startedAt: slot.startedAt,
|
|
2205
|
+
status: slot.status,
|
|
2206
|
+
sample: slot.activity ?? null,
|
|
2207
|
+
terminalSessions: opts?.terminalSessions,
|
|
2208
|
+
inflightCalls: opts?.inflightCalls,
|
|
2209
|
+
processExited: this.processExited(slot),
|
|
2210
|
+
});
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
private processExited(slot: InternalRuntime): boolean {
|
|
2214
|
+
if (slot.status === 'stopped' || slot.status === 'failed') return true;
|
|
2215
|
+
// A live handle means a live process. `handleExit` nulls `proc` before any
|
|
2216
|
+
// decision runs, so a null handle plus a slot that still claims to be
|
|
2217
|
+
// running is a stale record, not a running runtime.
|
|
2218
|
+
if (slot.proc) return false;
|
|
2219
|
+
return slot.status === 'running';
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
/**
|
|
2223
|
+
* {@link activityDecision} with a fresh sample when the cached one has aged
|
|
2224
|
+
* out (or does not exist yet). Eviction paths use this so a stale cache
|
|
2225
|
+
* never decides alone — and a probe that fails is still "unknown", i.e.
|
|
2226
|
+
* keep, so re-asking can cost time but can never kill work.
|
|
2227
|
+
*
|
|
2228
|
+
* {@link ACTIVITY_RESAMPLE_BEFORE_EVICT_MS} is deliberately much shorter than
|
|
2229
|
+
* {@link ACTIVITY_SAMPLE_MS}: the timer keeps the cache warm, this covers the
|
|
2230
|
+
* window in which a turn started after the last tick.
|
|
2231
|
+
*/
|
|
2232
|
+
private async freshActivityDecision(
|
|
2233
|
+
projectId: string,
|
|
2234
|
+
opts?: { terminalSessions?: number; inflightCalls?: number },
|
|
2235
|
+
): Promise<BusyDecision> {
|
|
2236
|
+
const slot = this.runtimes.get(projectId);
|
|
2237
|
+
if (slot && slot.status === 'running' && slot.agentPort) {
|
|
2238
|
+
const sample = slot.activity;
|
|
2239
|
+
if (!sample || Date.now() - sample.at > ACTIVITY_RESAMPLE_BEFORE_EVICT_MS) {
|
|
2240
|
+
await this.sampleActivity(slot).catch(() => null);
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
return this.activityDecision(projectId, opts);
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
/**
|
|
2247
|
+
* Re-read one runtime's activity now, by key. The desktop API's pools call
|
|
2248
|
+
* this before evicting so they decide on the same fresh sample the worker's
|
|
2249
|
+
* own LRU uses, instead of whatever the timer last cached.
|
|
2250
|
+
*/
|
|
2251
|
+
async refreshActivity(projectId: string): Promise<void> {
|
|
2252
|
+
const slot = this.runtimes.get(projectId);
|
|
2253
|
+
if (!slot || slot.status !== 'running' || !slot.agentPort) return;
|
|
2254
|
+
const sample = slot.activity;
|
|
2255
|
+
if (sample && Date.now() - sample.at <= ACTIVITY_RESAMPLE_BEFORE_EVICT_MS) return;
|
|
2256
|
+
await this.sampleActivity(slot).catch(() => null);
|
|
2257
|
+
}
|
|
2258
|
+
|
|
1980
2259
|
private async allocatePort(): Promise<number> {
|
|
1981
2260
|
const range = PORT_RANGE_END - PORT_RANGE_START;
|
|
1982
2261
|
const maxAttempts = Math.min(range, 50);
|