@shogo-ai/worker 2.3.9 → 2.3.11

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.
@@ -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
- if (work) {
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}: not evicting ${victim.projectId} ` +
884
- `(${work.turns} turn(s), ${work.tasks} task(s) active)`,
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) this.log.error(`${prefix} ${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 ask the runtime itself.
1935
- const work = await this.activeWork(slot);
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 (work) {
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
- `${work.turns} turn(s), ${work.tasks} task(s), ${work.streams} stream(s) active`,
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 that will wake the agent, or open
1955
- * chat streams. Unknown (no runtime, unreachable, older runtime) is false.
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
- return !!slot && !!(await this.activeWork(slot));
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 { activeTurns?: number; activeTasks?: number; activeStreams?: number };
1973
- const work = { turns: a.activeTurns ?? 0, tasks: a.activeTasks ?? 0, streams: a.activeStreams ?? 0 };
1974
- return work.turns + work.tasks + work.streams > 0 ? work : null;
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);