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.
- package/docs/dev/03-release.md +13 -0
- package/package.json +7 -7
- package/packages/server/mock/agent.mjs +8 -0
- package/packages/server/src/acp/session-manager.ts +72 -8
- package/packages/server/src/index.ts +15 -0
- package/packages/server/src/settings.ts +40 -12
- package/packages/web/dist/assets/index-DCBs8dsw.js +119 -0
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/assets/index-DPCbsWh9.js +0 -283
package/docs/dev/03-release.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
1288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
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
|
|
297
|
-
const [
|
|
298
|
-
|
|
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
|
|
499
|
-
* stored —
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
const [
|
|
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
|
|
531
|
+
throw new Error(`invalid ${key}: ${JSON.stringify(patch[key])} (expected ${lo}…${hi} ${unit}, ${off})`);
|
|
507
532
|
}
|
|
508
|
-
a
|
|
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;
|