agentus 0.3.2 → 0.3.3

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.
@@ -195,6 +195,19 @@ git branch --set-upstream-to=origin/main main
195
195
  Judge the outcome by identity, not by "the push succeeded": `git ls-remote origin
196
196
  refs/heads/main` must equal the commit live is actually serving.
197
197
 
198
+ > **Do not merge into the live tree before promoting.** `promote` decides whether to restart by
199
+ > diffing **the live tree's old tip** against the commit it is promoting. If the live tree already
200
+ > sits on that commit (e.g. someone ran `git merge --ff-only <dev-branch>` inside
201
+ > `worktrees/agentus`), the diff is empty: promote prints `no server-side change`, ships the new web
202
+ > bundle, and **leaves the old server process running** — a half release that looks green. Restart
203
+ > detection is only as good as the tree it reads, so move `main` through the ref (`git push
204
+ > origin <dev-branch>:main`), never by checking it out in the live worktree.
205
+ >
206
+ > If it already happened: the release is not done. Re-run the restart explicitly
207
+ > (`scripts/relaunch.sh --port <live> --tree <live tree> --launcher <launcher> --launchd <label>`)
208
+ > and then resume the slots promote captured, exactly as promote's step 6 would have. Prove it by
209
+ > the startup log line for the new feature, not by the pid alone.
210
+
198
211
  ## Rollback
199
212
 
200
213
  The release path has no separate rollback mechanism — you revert the change and promote
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentus",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Multi-session web cockpit for ACP-speaking coding agents (Qoder CLI / Hermes). UI has no intelligence — the agent lives in spawned CLI subprocesses.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -64,12 +64,12 @@
64
64
  "store-fold": "tsx scripts/qa/store-fold.mts",
65
65
  "live-stream": "tsx scripts/qa/live-stream.mts",
66
66
  "open-target": "tsx scripts/qa/open-target.mts",
67
- "rail-recent": "tsx scripts/qa/rail-recent.mts",
68
- "turn-pulse": "tsx scripts/qa/turn-pulse.mts",
69
- "resume-tail": "tsx scripts/qa/resume-tail.mts",
70
- "render-lag-sweep": "node scripts/qa/render-lag-sweep.mjs",
71
- "replay-window": "node scripts/qa/replay-window.mjs",
72
- "think-cadence": "node scripts/qa/think-cadence.mjs"
67
+ "rail-recent": "tsx scripts/qa/rail-recent.mts",
68
+ "turn-pulse": "tsx scripts/qa/turn-pulse.mts",
69
+ "resume-tail": "tsx scripts/qa/resume-tail.mts",
70
+ "render-lag-sweep": "node scripts/qa/render-lag-sweep.mjs",
71
+ "replay-window": "node scripts/qa/replay-window.mjs",
72
+ "think-cadence": "node scripts/qa/think-cadence.mjs"
73
73
  },
74
74
  "engines": {
75
75
  "node": ">=22.5"
@@ -7,6 +7,10 @@
7
7
  // MOCK_TOOL=1 -> prompt triggers a tool_call + requestPermission
8
8
  // MOCK_THINK=1 -> emits agent_thought_chunk before the answer
9
9
  // MOCK_SLOW_MS=n -> delay between chunks (default 60)
10
+ // MOCK_SPAWN_MS=n -> boot latency before the handshake answers (default 0). A real agent takes
11
+ // seconds to come up (node + initialize + session/load) and that window is
12
+ // where the cockpit's 「正在唤醒 agent 进程…」 state lives — a browser sweep
13
+ // cannot observe a state that lasts 80 ms.
10
14
  // MOCK_SINK=1 -> exit the process mid-turn (crash path for AC5/QA)
11
15
  import { randomUUID } from "node:crypto";
12
16
  import fs from "node:fs";
@@ -14,6 +18,10 @@ import { AgentSideConnection, RequestError, ndJsonStream } from "@agentclientpro
14
18
  import { Readable, Transform, Writable } from "node:stream";
15
19
 
16
20
  const SLOW = Number(process.env.MOCK_SLOW_MS || 60);
21
+ /** Boot latency — deliberately BEFORE the stream is wired, so the slot sits in `starting` for as
22
+ * long as QA asks. Defaults to 0: the mock is instant unless a sweep needs the wake window. */
23
+ const SPAWN_MS = Number(process.env.MOCK_SPAWN_MS || 0);
24
+ if (SPAWN_MS > 0) await new Promise((r) => setTimeout(r, SPAWN_MS));
17
25
 
18
26
  // The file a `[tool-diff]` turn pretends to edit: hermes hands the cockpit the whole file
19
27
  // before and after, so the approval surface has a path and a real change to show.
@@ -1276,7 +1276,34 @@ export class SessionManager {
1276
1276
  live.touchedAt = Date.now();
1277
1277
  }
1278
1278
 
1279
- /** Reclaim idle slots: stop the agent process of a session nobody has touched for the operator's
1279
+ /** The `n` most recently USED sessions that are not archived — 「热备」, the slots whose process is
1280
+ * kept no matter how quiet they go. Explicitly a FLOOR, not a cap: it names which slots may not be
1281
+ * reclaimed for being idle; it never bounds how many may run.
1282
+ *
1283
+ * Ranked over live AND COLD rows by the same clock the sweep uses (`max(touchedAt, lastActivity,
1284
+ * createdAt)`). Ranking only the live ones would leak the meaning: after a reap the seat would fall
1285
+ * to whatever still happens to hold a process, so a session nobody has opened in days could inherit
1286
+ * it and be shielded by the very rule that just fired on his own recent session.
1287
+ *
1288
+ * Archived rows never take a seat — 归档 is 「收起来」, its process is meant to go — so the seat
1289
+ * passes to the next most recent one instead of being held by a row he put away. */
1290
+ #warmSet(n: number): Set<string> {
1291
+ const lastAt = this.#store.lastActivityAt();
1292
+ return new Set(
1293
+ this.#store
1294
+ .listSessions(true)
1295
+ .filter((r) => r.archivedAt == null)
1296
+ .map((r) => ({
1297
+ id: r.id,
1298
+ at: Math.max(this.#sessions.get(r.id)?.touchedAt ?? 0, lastAt.get(r.id) ?? 0, r.createdAt),
1299
+ }))
1300
+ .sort((a, b) => b.at - a.at)
1301
+ .slice(0, n)
1302
+ .map((r) => r.id),
1303
+ );
1304
+ }
1305
+
1306
+ /** Reclaim slots: stop the agent process of a session nobody has touched for the operator's
1280
1307
  * threshold. Deliberately NOT an archive — the row stays in 工作空间 wearing `reaped` and a click
1281
1308
  * brings it back. AionUi ships exactly this switch (设置 → 系统 → 「Agent 空闲超时(分钟)」,
1282
1309
  * default 5, 1–60) and the guards below are what its changelog had to add after the first version
@@ -1284,12 +1311,39 @@ export class SessionManager {
1284
1311
  * one the operator is WATCHING right now (a reply that lands while he reads it must not be killed
1285
1312
  * under him).
1286
1313
  *
1287
- * `idleMs()` is read on every tick, so a settings change applies with no restart; 0 = off. */
1288
- startIdleReaper(opts: { idleMs: () => number; watched?: (id: string) => boolean; intervalMs?: number }): () => void {
1314
+ * TWO deadlines, and the difference is the whole point (2026-10-10): `idleMs` is the operator's
1315
+ * answer to 「空闲多久收掉」 for the sessions he is NOT working in, while the 热备 set (`warmSlots`,
1316
+ * the most recently used ones) is exempt from it — 「即使空闲也保留在那里」 — and only obeys its own
1317
+ * much longer `warmTtlMs`. A single time rule was what made 「我只是离开浏览器」 cost him the agent
1318
+ * of the session he had been in a minute earlier.
1319
+ *
1320
+ * Every callback is read on EVERY tick, so a settings change applies with no restart (a restart
1321
+ * would kill exactly the slots this feature exists to keep alive cheaply); 0 = off for `idleMs`,
1322
+ * 0 = 不过期 for `warmTtlMs`. */
1323
+ startIdleReaper(opts: {
1324
+ idleMs: () => number;
1325
+ /** How many of the most recent sessions are 热备 — exempt from `idleMs`. Read per tick, like the
1326
+ * threshold itself: changing it in 设置 must apply without a restart (a restart would kill
1327
+ * exactly the slots this feature exists to keep alive cheaply). */
1328
+ warmSlots: () => number;
1329
+ /** How long a warm slot may stay untouched before even its exemption expires; 0 = 不过期.
1330
+ * Read per tick too, and it is a SEPARATE deadline from `idleMs` (hours, not minutes). */
1331
+ warmTtlMs: () => number;
1332
+ watched?: (id: string) => boolean;
1333
+ intervalMs?: number;
1334
+ }): () => void {
1289
1335
  const interval = opts.intervalMs ?? 30_000;
1336
+ /** Two decimals where it matters: a QA run drives these thresholds in SECONDS, and 「0 min」 on
1337
+ * both sides of the message is a log line that cannot be read. */
1338
+ const fmt = (ms: number): string =>
1339
+ ms < 3_600_000 ? `${(ms / 60_000).toFixed(1)} min` : `${(ms / 3_600_000).toFixed(1)} h`;
1290
1340
  const tick = (): void => {
1291
1341
  const idleMs = opts.idleMs();
1292
- if (!(idleMs > 0)) return;
1342
+ const warmTtlMs = opts.warmTtlMs();
1343
+ // Either rule alone is a reason to sweep (idle off + a warm TTL still expires warm slots).
1344
+ if (!(idleMs > 0) && !(warmTtlMs > 0)) return;
1345
+ const warmN = opts.warmSlots();
1346
+ const warm = warmN > 0 ? this.#warmSet(warmN) : null;
1293
1347
  const now = Date.now();
1294
1348
  let reaped = 0;
1295
1349
  for (const s of [...this.#sessions.values()]) {
@@ -1303,10 +1357,20 @@ export class SessionManager {
1303
1357
  // 「刚点进去、没发消息、换个会话过一分钟 acp 就被回收」) — the fresh process inherited a
1304
1358
  // days-old stamp, so the very next tick found it over the threshold.
1305
1359
  const lastAt = Math.max(s.touchedAt, s.info.lastAt ?? 0, s.info.createdAt);
1306
- if (now - lastAt < idleMs) continue;
1307
- console.log(
1308
- `[agentus] idle reap: ${s.info.id} (${Math.round((now - lastAt) / 60_000)} min idle, limit ${Math.round(idleMs / 60_000)} min)`,
1309
- );
1360
+ const age = now - lastAt;
1361
+ if (warm?.has(s.info.id)) {
1362
+ // 热备 = 「即使空闲也保留在这里」. The idle rule does not apply; only its own, much longer
1363
+ // deadline does — otherwise five quiet slots are a permanent ~700 MB floor.
1364
+ if (!(warmTtlMs > 0) || age < warmTtlMs) continue;
1365
+ console.log(
1366
+ `[agentus] warm slot expired: ${s.info.id} (${fmt(age)} untouched, warm ttl ${fmt(warmTtlMs)})`,
1367
+ );
1368
+ } else {
1369
+ if (!(idleMs > 0) || age < idleMs) continue;
1370
+ console.log(
1371
+ `[agentus] idle reap: ${s.info.id} (${Math.round(age / 60_000)} min idle, limit ${Math.round(idleMs / 60_000)} min)`,
1372
+ );
1373
+ }
1310
1374
  this.#stop(s, "reaped");
1311
1375
  reaped += 1;
1312
1376
  }
@@ -270,6 +270,11 @@ setHotwordSource((sessionId) => hotwordsFor(store, sessionId).map((h) => h.word)
270
270
  };
271
271
  const envIdle = envNum(process.env.AGENTUS_IDLE_KILL_MIN);
272
272
  const envIdleMs = envIdle != null ? envIdle * 60_000 : null;
273
+ // 热备 (warm slots): same ABSENT≠0 discipline as above. The TTL takes a FRACTION of a minute so the
274
+ // warm-EXPIRY path can be exercised in seconds rather than by waiting out a day.
275
+ const envWarm = envNum(process.env.AGENTUS_WARM_SLOTS);
276
+ const envWarmTtlMin = envNum(process.env.AGENTUS_WARM_TTL_MIN);
277
+ const envWarmTtlMs = envWarmTtlMin != null ? envWarmTtlMin * 60_000 : null;
273
278
  const envSweep = envNum(process.env.AGENTUS_IDLE_SWEEP_MS);
274
279
  const sweepMs = envSweep != null && envSweep >= 1000 ? envSweep : 30_000;
275
280
  const source = envIdleMs != null ? "AGENTUS_IDLE_KILL_MIN" : "settings.agent.idleKillMin";
@@ -278,8 +283,18 @@ setHotwordSource((sessionId) => hotwordsFor(store, sessionId).map((h) => h.word)
278
283
  envIdleMs != null ? envIdle : agentSettings().idleKillMin
279
284
  } min (${source}), sweep ${Math.round(sweepMs / 1000)}s, 0 = off`,
280
285
  );
286
+ // The warm set is the other half of the same decision (which slots survive being quiet) and it is
287
+ // NEVER inferred from the idle value: 「最近 N 个即使空闲也保留」 and 「其余空闲多久收」 are two
288
+ // answers. Printing both with their sources is what turns 「为什么它被收了」 into one glance.
289
+ console.log(
290
+ `[agentus] warm slots: ${envWarm ?? agentSettings().warmSlots} kept up to ${
291
+ envWarmTtlMs != null ? envWarmTtlMs / 3_600_000 : agentSettings().warmTtlHours
292
+ } h untouched (${envWarm != null || envWarmTtlMs != null ? "AGENTUS_WARM_*" : "settings.agent"}), 0 = off / never`,
293
+ );
281
294
  mgr.startIdleReaper({
282
295
  idleMs: () => envIdleMs ?? agentSettings().idleKillMin * 60_000,
296
+ warmSlots: () => envWarm ?? agentSettings().warmSlots,
297
+ warmTtlMs: () => envWarmTtlMs ?? agentSettings().warmTtlHours * 3_600_000,
283
298
  watched: (id) => notify.operatorWatching(id),
284
299
  intervalMs: sweepMs,
285
300
  });
@@ -124,13 +124,28 @@ export interface AgentSettings {
124
124
  /** minutes of no activity before a live slot's agent process is stopped to reclaim memory;
125
125
  * 0 = never. The row and its transcript always survive — this only stops the process. */
126
126
  idleKillMin: number;
127
+ /** 「热备」= how many of the most recently USED sessions keep their agent process no matter how
128
+ * quiet they go. A FLOOR, not a ceiling: it names the slots that may not be reclaimed for being
129
+ * idle, it does not bound how many may run. Ranked over live AND cold rows by the same clock the
130
+ * sweep uses — a slot already reaped must not hand its seat to a session nobody has touched in
131
+ * days. Archived rows never occupy a seat. 0 = off (every slot obeys idleKillMin). */
132
+ warmSlots: number;
133
+ /** Hours a warm slot may go untouched before even its exemption expires. Without this the warm set
134
+ * is a permanent memory floor (5 × ~137 MB RSS measured); with it, a cockpit left alone for a day
135
+ * comes back to nothing running. 0 = 不过期. */
136
+ warmTtlHours: number;
127
137
  }
128
138
 
129
- export const AGENT_DEFAULT: AgentSettings = { idleKillMin: 5 };
139
+ export const AGENT_DEFAULT: AgentSettings = { idleKillMin: 5, warmSlots: 5, warmTtlHours: 24 };
130
140
 
131
141
  /** What the settings page may offer: the ends of the number field. Server-side enforcement uses
132
142
  * the same pair, so a hand-written request cannot store a nonsense value. */
133
143
  export const AGENT_IDLE_RANGE: [number, number] = [0, 60];
144
+ /** 0 = off. The upper end is a memory decision (one live agent ≈ 137 MB RSS measured), not a limit
145
+ * the server has to defend. */
146
+ export const AGENT_WARM_RANGE: [number, number] = [0, 20];
147
+ /** 0 = 不过期; a week is the longest span worth offering on the page. */
148
+ export const AGENT_WARM_TTL_RANGE: [number, number] = [0, 168];
134
149
 
135
150
  /** The NUMERIC knobs. `interruptMode` is a two-way choice, not a slider, so it is deliberately
136
151
  * outside both the range table and the loops that validate numbers. */
@@ -293,9 +308,15 @@ function pickCall(raw: unknown): CallSettings {
293
308
  function pickAgent(raw: unknown): AgentSettings {
294
309
  const out = { ...AGENT_DEFAULT };
295
310
  if (raw && typeof raw === "object") {
296
- const v = Number((raw as Record<string, unknown>).idleKillMin);
297
- const [lo, hi] = AGENT_IDLE_RANGE;
298
- if (Number.isFinite(v) && v >= lo && v <= hi) out.idleKillMin = v;
311
+ const r = raw as Record<string, unknown>;
312
+ const take = (key: keyof AgentSettings, range: [number, number]): void => {
313
+ const v = Number(r[key]);
314
+ const [lo, hi] = range;
315
+ if (Number.isFinite(v) && v >= lo && v <= hi) out[key] = v;
316
+ };
317
+ take("idleKillMin", AGENT_IDLE_RANGE);
318
+ take("warmSlots", AGENT_WARM_RANGE);
319
+ take("warmTtlHours", AGENT_WARM_TTL_RANGE);
299
320
  }
300
321
  return out;
301
322
  }
@@ -369,6 +390,8 @@ export function publicSettings(): Record<string, unknown> {
369
390
  agent: cache.agent,
370
391
  agentDefaults: AGENT_DEFAULT,
371
392
  agentIdleRange: AGENT_IDLE_RANGE,
393
+ agentWarmRange: AGENT_WARM_RANGE,
394
+ agentWarmTtlRange: AGENT_WARM_TTL_RANGE,
372
395
  // the defaults, so the UI can offer "reset to Bailian defaults"
373
396
  defaults: { ...DEFAULTS, apiKey: undefined, baseUrl: undefined },
374
397
  updatedAt: cache.updatedAt,
@@ -495,18 +518,23 @@ export function savePrefs(patch: Record<string, unknown>): Settings {
495
518
  }
496
519
 
497
520
  /** Agent-process knobs: its own update, like theme/call, so a bad number here cannot lock the
498
- * operator out of the page that would fix it. Range-checked against AGENT_IDLE_RANGE before it is
499
- * stored — this number decides whether agent processes get killed, so it is not a text field. */
521
+ * operator out of the page that would fix it. Range-checked against the section's range table
522
+ * before it is stored — these numbers decide whether agent processes get killed, so they are not
523
+ * text fields. */
500
524
  export function saveAgent(patch: Record<string, unknown>): Settings {
501
525
  const a = { ...cache.agent };
502
- if ("idleKillMin" in patch) {
503
- const raw = typeof patch.idleKillMin === "number" ? patch.idleKillMin : Number(patch.idleKillMin);
504
- const [lo, hi] = AGENT_IDLE_RANGE;
526
+ const set = (key: keyof AgentSettings, range: [number, number], unit: string, off: string): void => {
527
+ if (!(key in patch)) return;
528
+ const raw = typeof patch[key] === "number" ? (patch[key] as number) : Number(patch[key]);
529
+ const [lo, hi] = range;
505
530
  if (!Number.isFinite(raw) || raw < lo || raw > hi) {
506
- throw new Error(`invalid idleKillMin: ${JSON.stringify(patch.idleKillMin)} (expected ${lo}…${hi} minutes, 0 = off)`);
531
+ throw new Error(`invalid ${key}: ${JSON.stringify(patch[key])} (expected ${lo}…${hi} ${unit}, ${off})`);
507
532
  }
508
- a.idleKillMin = raw;
509
- }
533
+ a[key] = raw;
534
+ };
535
+ set("idleKillMin", AGENT_IDLE_RANGE, "minutes", "0 = off");
536
+ set("warmSlots", AGENT_WARM_RANGE, "slots", "0 = off");
537
+ set("warmTtlHours", AGENT_WARM_TTL_RANGE, "hours", "0 = never expires");
510
538
  cache = { ...cache, agent: a, updatedAt: Date.now() };
511
539
  persist();
512
540
  return cache;