teapot-coding-agent 0.37.58 → 0.37.60
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/acp/adapter.js +4 -72
- package/dist/agent/agent.js +52 -2246
- package/dist/agent/compact-rules.js +0 -53
- package/dist/agent/editorconfig.js +4 -56
- package/dist/agent/goal-audit.js +0 -34
- package/dist/agent/key-pool.js +0 -52
- package/dist/agent/kill.js +1 -13
- package/dist/agent/llm.js +8 -309
- package/dist/agent/mcp-oauth-manager.js +2 -255
- package/dist/agent/mcp-registry.js +1 -188
- package/dist/agent/mcp.js +1 -85
- package/dist/agent/meta-tools.js +0 -26
- package/dist/agent/plugins.js +1 -81
- package/dist/agent/provider-headers.js +1 -28
- package/dist/agent/skills.js +8 -94
- package/dist/agent/tools.js +16 -809
- package/dist/bus.js +1 -2
- package/dist/config-schema.js +0 -40
- package/dist/home.js +1 -19
- package/dist/index.js +0 -136
- package/dist/log/events.js +3 -195
- package/dist/master.js +31 -1176
- package/dist/migrations/index.js +0 -55
- package/dist/migrations/providers.js +0 -13
- package/dist/migrations/registry.js +0 -9
- package/dist/migrations/session-titles.js +3 -43
- package/dist/migrations/sessions.js +2 -20
- package/dist/model-meta.js +2 -74
- package/dist/scheduler/cron.js +2 -17
- package/dist/server/api.js +12 -778
- package/dist/server/image-resize.js +1 -64
- package/dist/server/term-size.js +1 -120
- package/dist/server/terminals.js +0 -159
- package/dist/session-title.js +2 -35
- package/package.json +1 -1
- package/public/assets/index-DhJx7VDq.js +36 -0
- package/public/assets/index-DhJx7VDq.js.map +1 -0
- package/public/assets/{shiki-BaLUHSNL.js → shiki-LBMkaWYb.js} +2 -2
- package/public/assets/shiki-LBMkaWYb.js.map +1 -0
- package/public/index.html +1 -1
- package/public/assets/index-D_f1aal3.js +0 -36
- package/public/assets/index-D_f1aal3.js.map +0 -1
- package/public/assets/shiki-BaLUHSNL.js.map +0 -1
package/dist/master.js
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Master server: owns all agents and one low-frequency scheduler tick.
|
|
3
|
-
* Agents are in-process async loops (I/O bound only); all CPU-heavy work is
|
|
4
|
-
* delegated to subprocesses managed by the bash tool with hard timeouts.
|
|
5
|
-
*/
|
|
6
1
|
import { closeAllTerminals, closeTerminalsFor } from "./server/terminals.js";
|
|
7
2
|
import { MIGRATIONS, runMigrations } from "./migrations/registry.js";
|
|
8
3
|
import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSync, lstatSync, renameSync, unlinkSync, openSync, closeSync, readSync, fsyncSync, rmSync, } from "node:fs";
|
|
@@ -19,30 +14,9 @@ import { bus } from "./bus.js";
|
|
|
19
14
|
import { mcp } from "./agent/mcp-registry.js";
|
|
20
15
|
import { loadPlugins } from "./agent/plugins.js";
|
|
21
16
|
import { fetchModelList, peekModelList, contextLengthFor, isReasoningEffort, } from "./model-meta.js";
|
|
22
|
-
/**
|
|
23
|
-
* Did the effective data dir come from an ENV OVERRIDE that disagrees with the
|
|
24
|
-
* config file's own `dataDir`?
|
|
25
|
-
*
|
|
26
|
-
* When it does, the config's session pointers are meaningless: they name
|
|
27
|
-
* directories under the OLD root, and this process is looking at a different
|
|
28
|
-
* one. Every agent therefore mints a fresh session — and `recordSession` writes
|
|
29
|
-
* those new pointers back into the config, destroying the real mapping. The
|
|
30
|
-
* caller then throws the new root away (a test's temp dir), leaving a config that
|
|
31
|
-
* points at sessions that never existed.
|
|
32
|
-
*
|
|
33
|
-
* That is not hypothetical: `test/cli-boot-and-host.test.ts` spawns the built CLI
|
|
34
|
-
* with `TEAPOT_DATA_DIR=<temp>` but NO config override, so `pnpm test` on a
|
|
35
|
-
* developer machine rewrote their real config and orphaned 493 sessions' pointers
|
|
36
|
-
* — twice, before this guard. On CI it is invisible, because CI has no config
|
|
37
|
-
* file to damage.
|
|
38
|
-
*
|
|
39
|
-
* So: a mismatched root makes session resolution READ-ONLY. The boot still works
|
|
40
|
-
* (agents get working sessions in the new root) — it just does not rewrite a
|
|
41
|
-
* config that is describing somebody else's data dir.
|
|
42
|
-
*/
|
|
43
17
|
function dataDirOverrideMismatch(configFilePath, effective) {
|
|
44
18
|
if (!process.env.TEAPOT_DATA_DIR)
|
|
45
|
-
return false;
|
|
19
|
+
return false;
|
|
46
20
|
try {
|
|
47
21
|
const raw = JSON.parse(readFileSync(configFilePath, "utf8"));
|
|
48
22
|
if (typeof raw.dataDir !== "string" || !raw.dataDir)
|
|
@@ -50,23 +24,11 @@ function dataDirOverrideMismatch(configFilePath, effective) {
|
|
|
50
24
|
return path.resolve(raw.dataDir) !== path.resolve(effective);
|
|
51
25
|
}
|
|
52
26
|
catch {
|
|
53
|
-
return false;
|
|
27
|
+
return false;
|
|
54
28
|
}
|
|
55
29
|
}
|
|
56
30
|
const MAX_SPAWN_DEPTH = 3;
|
|
57
|
-
/**
|
|
58
|
-
* How long boot waits for an endpoint's model catalogue.
|
|
59
|
-
*
|
|
60
|
-
* The window and the reasoning-effort gate both come from `/models`, so a boot
|
|
61
|
-
* that waits on it is waiting for metadata, not for anything it needs to start.
|
|
62
|
-
* The lookup is fired concurrently for every distinct endpoint before the agent
|
|
63
|
-
* loop (see prefetchModelLists), so this bound is paid once per ENDPOINT rather
|
|
64
|
-
* than once per agent — and only when the endpoint is slow or dead.
|
|
65
|
-
*/
|
|
66
31
|
const MODEL_META_BOOT_MS = 1_500;
|
|
67
|
-
/** statuses that mean a child has finished working, for #130 */
|
|
68
|
-
// `needs_operator` is settled too: the child has stopped working and is waiting on a human, so
|
|
69
|
-
// the parent must not treat it as "still busy" and wait forever for it to finish.
|
|
70
32
|
const SETTLED = new Set([
|
|
71
33
|
"idle",
|
|
72
34
|
"stopped",
|
|
@@ -74,20 +36,7 @@ const SETTLED = new Set([
|
|
|
74
36
|
"needs_operator",
|
|
75
37
|
"error",
|
|
76
38
|
]);
|
|
77
|
-
/**
|
|
78
|
-
* Skills shipped with the package — resolved relative to this module so it
|
|
79
|
-
* works from a global `npm install -g` install, an npx cache, or a repo
|
|
80
|
-
* checkout alike. Lowest-priority skill root (workspace > global > bundled).
|
|
81
|
-
*/
|
|
82
39
|
const BUNDLED_SKILLS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../skills");
|
|
83
|
-
/**
|
|
84
|
-
* Application version, read from package.json next to this module.
|
|
85
|
-
*
|
|
86
|
-
* Walks up from `dist/master.js` / `src/master.ts` to the package root so it
|
|
87
|
-
* resolves in every layout (repo checkout, global install, npx cache). Falls
|
|
88
|
-
* back to `"0.0.0"` rather than throwing: a missing/unreadable package.json
|
|
89
|
-
* must never stop the server from booting.
|
|
90
|
-
*/
|
|
91
40
|
function readAppVersion() {
|
|
92
41
|
try {
|
|
93
42
|
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -105,33 +54,15 @@ function readAppVersion() {
|
|
|
105
54
|
}
|
|
106
55
|
}
|
|
107
56
|
catch {
|
|
108
|
-
/* fall through to the sentinel below */
|
|
109
57
|
}
|
|
110
58
|
return "0.0.0";
|
|
111
59
|
}
|
|
112
|
-
/**
|
|
113
|
-
* Turn a user-supplied workspace path into an absolute one.
|
|
114
|
-
*
|
|
115
|
-
* A relative path used to be resolved against `process.cwd()`, so a config
|
|
116
|
-
* entry like `"workspace": "workspaces/alpha"` (the form shipped in
|
|
117
|
-
* teapot.config.example.json) silently pointed at whatever directory the
|
|
118
|
-
* daemon happened to be started from — and the agent's bash tool then ran in
|
|
119
|
-
* the wrong directory. Relative workspaces are now anchored to the
|
|
120
|
-
* config file's own directory, which is what "the workspace" means inside a
|
|
121
|
-
* config file.
|
|
122
|
-
*
|
|
123
|
-
* `~` is expanded for both forms; absolute paths pass through untouched.
|
|
124
|
-
*/
|
|
125
60
|
export function resolveWorkspace(input, base) {
|
|
126
|
-
// `~` must not expand to the LITERAL "~" — that is how a directory named
|
|
127
|
-
// `~` got created on Windows, where process.env.HOME is unset. See src/home.ts.
|
|
128
61
|
const expanded = expandTilde(input.trim());
|
|
129
62
|
if (path.isAbsolute(expanded))
|
|
130
63
|
return path.normalize(expanded);
|
|
131
64
|
return path.resolve(base, expanded);
|
|
132
65
|
}
|
|
133
|
-
/** default sub-agent personas — mentionable from the composer (@name) and
|
|
134
|
-
* usable by agents via spawn_agent({persona}) */
|
|
135
66
|
export const SUB_PERSONAS = {
|
|
136
67
|
reviewer: {
|
|
137
68
|
label: "🔍️ reviewer",
|
|
@@ -165,16 +96,6 @@ const DATA_DIR = process.env.TEAPOT_DATA_DIR ??
|
|
|
165
96
|
path.join(os.homedir(), ".local", "share", "teapot-coding-agent");
|
|
166
97
|
const DEFAULT_CONFIG = {
|
|
167
98
|
port: Number(process.env.TEAPOT_PORT ?? 7788),
|
|
168
|
-
// this was `process.env.TEAPOT_HOST`, i.e. UNDEFINED when unset — and an
|
|
169
|
-
// undefined hostname makes `@hono/node-server` bind every interface. So the
|
|
170
|
-
// default was the opposite of the documented one: `--help` says
|
|
171
|
-
// "127.0.0.1 (default) · 0.0.0.0 exposes the UI to your network", and yet the
|
|
172
|
-
// default WAS "expose to your network".
|
|
173
|
-
//
|
|
174
|
-
// well as loopback.
|
|
175
|
-
//
|
|
176
|
-
// Loopback is the right default for something holding API tokens and agent
|
|
177
|
-
// workspaces; exposing it has to be a deliberate act.
|
|
178
99
|
host: process.env.TEAPOT_HOST ?? "127.0.0.1",
|
|
179
100
|
dataDir: DATA_DIR,
|
|
180
101
|
llm: {
|
|
@@ -186,17 +107,9 @@ const DEFAULT_CONFIG = {
|
|
|
186
107
|
agents: [],
|
|
187
108
|
tasks: [],
|
|
188
109
|
};
|
|
189
|
-
/** Where config is looked up / stored. */
|
|
190
110
|
export function configDir() {
|
|
191
111
|
return CONFIG_DIR;
|
|
192
112
|
}
|
|
193
|
-
/**
|
|
194
|
-
* Resolution order:
|
|
195
|
-
* 1. explicit path argument (CLI)
|
|
196
|
-
* 2. $TEAPOT_CONFIG
|
|
197
|
-
* 3. ~/.config/teapot-coding-agent/config.json
|
|
198
|
-
* 4. ./teapot.config.json (legacy project-local)
|
|
199
|
-
*/
|
|
200
113
|
export function resolveConfigPath(explicitPath) {
|
|
201
114
|
if (explicitPath)
|
|
202
115
|
return path.resolve(explicitPath);
|
|
@@ -208,24 +121,6 @@ export function resolveConfigPath(explicitPath) {
|
|
|
208
121
|
return path.resolve("teapot.config.json");
|
|
209
122
|
}
|
|
210
123
|
let masterRawConfig = {};
|
|
211
|
-
/**
|
|
212
|
-
* Normalise a provider entry's credentials into `apiKeys`.
|
|
213
|
-
*
|
|
214
|
-
* Both spellings are read, and the result is written back in the shape the UI and
|
|
215
|
-
* the pool consume. The rules:
|
|
216
|
-
*
|
|
217
|
-
* - `apiKeys` present -> used as-is (trimmed, blanks and duplicates dropped)
|
|
218
|
-
* - `apiKey` only -> becomes `apiKeys: [apiKey]` (the OLD shape, which is
|
|
219
|
-
* every config written before this feature)
|
|
220
|
-
* - neither -> left alone (a local endpoint may need no key)
|
|
221
|
-
*
|
|
222
|
-
* `apiKey` is KEPT in step with `apiKeys[0]` rather than deleted, because other
|
|
223
|
-
* readers still expect it (the setup wizard, `/api/providers`, hand-edited files) and
|
|
224
|
-
* a migration that removes a field the operator can see is a migration they will
|
|
225
|
-
* report as data loss.
|
|
226
|
-
*
|
|
227
|
-
* Returns a NEW entry; the caller decides whether to persist.
|
|
228
|
-
*/
|
|
229
124
|
export function migrateProviderKeys(p) {
|
|
230
125
|
const seen = new Set();
|
|
231
126
|
const clean = (list) => {
|
|
@@ -239,26 +134,16 @@ export function migrateProviderKeys(p) {
|
|
|
239
134
|
}
|
|
240
135
|
return out;
|
|
241
136
|
};
|
|
242
|
-
// `apiKeys` is AUTHORITATIVE once present. The single `apiKey` only SEEDS the pool
|
|
243
|
-
// when there is no list — appending it as well would resurrect a credential the
|
|
244
|
-
// operator had already replaced (they set a list precisely because the old key was
|
|
245
|
-
// exhausted), and the pool would happily rotate back onto it.
|
|
246
137
|
const hasList = Array.isArray(p.apiKeys);
|
|
247
138
|
const keys = hasList
|
|
248
139
|
? clean(p.apiKeys)
|
|
249
140
|
: clean(typeof p.apiKey === "string" ? [p.apiKey] : []);
|
|
250
141
|
if (keys.length === 0) {
|
|
251
|
-
// no credential at all (a local endpoint may need none): do not invent an empty
|
|
252
|
-
// array, which would read as "this provider has keys" to anything counting them
|
|
253
142
|
const { apiKeys: _drop, ...rest } = p;
|
|
254
143
|
return rest;
|
|
255
144
|
}
|
|
256
|
-
// `apiKey` is kept in step with the FIRST key rather than deleted: other readers
|
|
257
|
-
// still expect it (the setup wizard, `/api/providers`), and a migration that
|
|
258
|
-
// removes a field the operator can see reads as data loss.
|
|
259
145
|
return { ...p, apiKeys: keys, apiKey: keys[0] };
|
|
260
146
|
}
|
|
261
|
-
/** Every key for a provider, in order, with the legacy single key folded in. */
|
|
262
147
|
export function providerKeys(p) {
|
|
263
148
|
if (!p)
|
|
264
149
|
return [];
|
|
@@ -275,43 +160,21 @@ export function loadConfig(configPath) {
|
|
|
275
160
|
...user,
|
|
276
161
|
dataDir: user.dataDir ? path.resolve(user.dataDir.replace(/^~/, os.homedir())) : DEFAULT_CONFIG.dataDir,
|
|
277
162
|
llm: { ...DEFAULT_CONFIG.llm, ...user.llm },
|
|
278
|
-
// fold a legacy single `apiKey` into `apiKeys` on load, so every consumer
|
|
279
|
-
// below sees one shape. Done here rather than at each use site because the pool,
|
|
280
|
-
// the UI and the spawn path all need the same answer.
|
|
281
163
|
providers: Object.fromEntries(Object.entries(user.providers ?? {}).map(([name, p]) => [name, migrateProviderKeys(p)])),
|
|
282
164
|
};
|
|
283
165
|
}
|
|
284
|
-
/**
|
|
285
|
-
* Can this path be replaced by an atomic rename?
|
|
286
|
-
*
|
|
287
|
-
* `/dev/null`, a fifo, or a directory are all legitimate values for
|
|
288
|
-
* `configPath`, and `rename` over them either fails outright (EACCES for a temp
|
|
289
|
-
* beside `/dev/null`) or is not what the caller wants. Only a regular file gets
|
|
290
|
-
* the write-temp-then-rename treatment.
|
|
291
|
-
*/
|
|
292
166
|
function isRegularFileTarget(p) {
|
|
293
167
|
try {
|
|
294
|
-
// lstat, not stat: a SYMLINK must take the plain-write path. statSync
|
|
295
|
-
// follows the link, so a symlink→regular-file passed this check and the
|
|
296
|
-
// atomic rename then REPLACED THE LINK with a real file — the link's
|
|
297
|
-
// target (a dotfile repo, stow/chezmoi) kept the old value and a restart
|
|
298
|
-
// resurrected it. Same reasoning covers hardlinks (rename splits the
|
|
299
|
-
// inode; the write must go through the existing directory entry).
|
|
300
168
|
const st = lstatSync(p);
|
|
301
169
|
return st.isFile() && !st.isSymbolicLink();
|
|
302
170
|
}
|
|
303
171
|
catch {
|
|
304
|
-
// Does not exist yet. The ATOMIC path needs a real directory to rename within, so
|
|
305
|
-
// only take it when the parent exists — otherwise a test that passes `/dev/null`
|
|
306
|
-
// (present on POSIX, absent on Windows) tried to create `/dev/.config.json.tmp…`
|
|
307
|
-
// and failed with a bare 400 that read like a schema rejection.
|
|
308
172
|
return existsSync(path.dirname(p));
|
|
309
173
|
}
|
|
310
174
|
}
|
|
311
175
|
export function loadedRaw() {
|
|
312
176
|
return masterRawConfig;
|
|
313
177
|
}
|
|
314
|
-
/* ---------- console activity log ---------- */
|
|
315
178
|
const isTTY = process.stdout.isTTY;
|
|
316
179
|
const c = (code, s) => (isTTY ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
317
180
|
const dim = (s) => c("2", s);
|
|
@@ -320,10 +183,9 @@ const clip1 = (s, n) => {
|
|
|
320
183
|
return one.length > n ? one.slice(0, n) + "…" : one;
|
|
321
184
|
};
|
|
322
185
|
const hhmmss = () => new Date().toTimeString().slice(0, 8);
|
|
323
|
-
/** Print one agent event as a compact human-readable console line. */
|
|
324
186
|
function printAgentEvent(e) {
|
|
325
187
|
const ts = dim(hhmmss());
|
|
326
|
-
const who = c("36", e.agent);
|
|
188
|
+
const who = c("36", e.agent);
|
|
327
189
|
const d = e.data;
|
|
328
190
|
let line = null;
|
|
329
191
|
switch (e.type) {
|
|
@@ -352,7 +214,7 @@ function printAgentEvent(e) {
|
|
|
352
214
|
case "message":
|
|
353
215
|
if (d.final && d.content)
|
|
354
216
|
line = `${c("34", "🏁 final")} ${clip1(d.content, 140)}`;
|
|
355
|
-
break;
|
|
217
|
+
break;
|
|
356
218
|
case "progress":
|
|
357
219
|
line = `${c("32", "📈 progress")} ${clip1(d.doing, 100)}`;
|
|
358
220
|
break;
|
|
@@ -376,10 +238,6 @@ function printAgentEvent(e) {
|
|
|
376
238
|
else if (d.event === "model-changed")
|
|
377
239
|
line = `${c("33", "🧦 model")} → ${d.model} (${d.provider})`;
|
|
378
240
|
else if (d.event === "round-turn-cap")
|
|
379
|
-
// The code deliberately does NOT nudge the model here (see the note at the
|
|
380
|
-
// emit site): telling it "the round ended, consider finish()" invited premature
|
|
381
|
-
// finishes. The old text claimed a nudge that does not happen, which sends
|
|
382
|
-
// anyone reading the log looking for behaviour that was removed on purpose.
|
|
383
241
|
line = `${c("33", "⏸ round cap")} ${d.turns} turns — round ended, auto-continue starts the next one`;
|
|
384
242
|
break;
|
|
385
243
|
}
|
|
@@ -389,78 +247,29 @@ function printAgentEvent(e) {
|
|
|
389
247
|
if (line)
|
|
390
248
|
console.log(`${dim(ts)} ${who} ${line}`);
|
|
391
249
|
}
|
|
392
|
-
/**
|
|
393
|
-
* Reduce an agent id to a safe single path segment.
|
|
394
|
-
*
|
|
395
|
-
* Agent ids become directory names, so anything path-like in one is an escape
|
|
396
|
-
* from the sessions root. Kept in step with the per-call-site
|
|
397
|
-
* `.replace(/[^\w.-]/g, "-")` calls, and applied in `addAgent` too so the
|
|
398
|
-
* guarantee holds for every caller.
|
|
399
|
-
*/
|
|
400
250
|
export function sanitizeAgentId(id) {
|
|
401
251
|
const cleaned = String(id ?? "")
|
|
402
252
|
.replace(/[^\w.-]/g, "-")
|
|
403
|
-
// a leading dot would create a hidden dir; ".." would climb out
|
|
404
253
|
.replace(/^\.+/, "")
|
|
405
254
|
.slice(0, 60);
|
|
406
255
|
return cleaned || "agent";
|
|
407
256
|
}
|
|
408
257
|
export class Master {
|
|
409
258
|
agents = new Map();
|
|
410
|
-
/**
|
|
411
|
-
* Configured agents that could NOT be loaded, with the reason.
|
|
412
|
-
*
|
|
413
|
-
* `addAgent` throws when an entry is unusable (no model configured, duplicate id, unknown
|
|
414
|
-
* provider) and the boot loop skips it so one bad entry cannot take the server down. The skip was
|
|
415
|
-
* SILENT, though, and the agent's sessions stayed in `/api/sessions` — so the sidebar offered a
|
|
416
|
-
* timeline that `/api/agents/<id>/events` answered with 404, and the UI sat on "loading timeline…"
|
|
417
|
-
* (reported as "セッション切り替えがバカ重い"). Recording the reason turns that into something the
|
|
418
|
-
* UI can explain.
|
|
419
|
-
*/
|
|
420
259
|
unavailableAgents = new Map();
|
|
421
|
-
/** Agent Plugins loaded at boot (#162); their skill roots are offered to every agent */
|
|
422
260
|
plugins = [];
|
|
423
|
-
/**
|
|
424
|
-
* ONE key pool per provider name, shared by every agent on it.
|
|
425
|
-
*
|
|
426
|
-
* Shared on purpose: a 429 is a statement about the CREDENTIAL, not about the
|
|
427
|
-
* agent that happened to send the request. Per-agent pools would let agent B walk
|
|
428
|
-
* straight back into the key agent A just got rate-limited on — the rotation would
|
|
429
|
-
* exist and do nothing, which is the failure shape this repo keeps re-learning.
|
|
430
|
-
*
|
|
431
|
-
* Keyed by PROVIDER NAME rather than baseUrl: two entries for the same endpoint may
|
|
432
|
-
* legitimately hold different credentials, and those must not be merged.
|
|
433
|
-
*/
|
|
434
261
|
keyPools = new Map();
|
|
435
|
-
/**
|
|
436
|
-
* pid of the process that replaced us in a live update, or null while we are
|
|
437
|
-
* still the server. `restartServer()` sets it; `index.ts` reads it to keep
|
|
438
|
-
* supervising the replacement instead of exiting and dropping the operator
|
|
439
|
-
* back at the shell prompt.
|
|
440
|
-
*/
|
|
441
262
|
replacementPid = null;
|
|
442
263
|
tasks = [];
|
|
443
264
|
childWaiters = new Map();
|
|
444
|
-
/**
|
|
445
|
-
* Children that have actually started, for #130.
|
|
446
|
-
*
|
|
447
|
-
* A child is `idle` between `addAgent` and `start()`, and that is its FIRST
|
|
448
|
-
* status transition — so without this the parent is told a brand-new sub-agent is
|
|
449
|
-
* idle before it has done anything.
|
|
450
|
-
*/
|
|
451
265
|
reportedRunning = new Set();
|
|
452
266
|
startedAt = Date.now();
|
|
453
267
|
config;
|
|
454
268
|
configPath;
|
|
455
|
-
/** false on first boot with no config file → web UI shows the setup wizard */
|
|
456
269
|
configFileExists = true;
|
|
457
270
|
constructor(config, configPath) {
|
|
458
271
|
this.config = config;
|
|
459
272
|
this.configPath = configPath;
|
|
460
|
-
// an env data-dir override that disagrees with the config file means
|
|
461
|
-
// this config describes a DIFFERENT data dir — its session pointers cannot be
|
|
462
|
-
// valid here, so rewriting them would destroy the real mapping. See
|
|
463
|
-
// dataDirOverrideMismatch().
|
|
464
273
|
this.sessionsReadOnly = dataDirOverrideMismatch(configPath, config.dataDir);
|
|
465
274
|
if (this.sessionsReadOnly) {
|
|
466
275
|
console.warn(`[teapot] TEAPOT_DATA_DIR (${config.dataDir}) disagrees with the config's own dataDir; ` +
|
|
@@ -468,26 +277,7 @@ export class Master {
|
|
|
468
277
|
`Set TEAPOT_CONFIG to an isolated config to silence this.`);
|
|
469
278
|
}
|
|
470
279
|
}
|
|
471
|
-
/**
|
|
472
|
-
* Persist current logical config back to disk (lossless via raw user config).
|
|
473
|
-
*
|
|
474
|
-
* this was a bare `writeFileSync`, which TRUNCATES the target before it
|
|
475
|
-
* writes. config.json holds every agent, provider and scheduled task, so any
|
|
476
|
-
* interruption during the write — SIGINT, a crash, a full disk — left a ZERO-LENGTH
|
|
477
|
-
* file, and the next boot read `{}` and came up empty.
|
|
478
|
-
*
|
|
479
|
-
* Boot made this far more likely than it sounds: every agent that mints a
|
|
480
|
-
* session calls `recordSession` → `saveConfig`, so a machine with 30 configured
|
|
481
|
-
* agents rewrites the whole file 30 times before the server listens. A user
|
|
482
|
-
* pressing Ctrl-C during that window lost the lot.
|
|
483
|
-
*
|
|
484
|
-
* Now: write a sibling temp file, fsync it, then `rename` over the target.
|
|
485
|
-
* `rename` is atomic within a filesystem, so the file is either the old content
|
|
486
|
-
* or the new — never empty. A crash mid-write leaves the temp file behind and
|
|
487
|
-
* the real config untouched.
|
|
488
|
-
*/
|
|
489
280
|
saveConfig() {
|
|
490
|
-
// during boot, mark dirty instead of writing; flushed once at the end
|
|
491
281
|
if (this.deferConfigWrites) {
|
|
492
282
|
this.configDirty = true;
|
|
493
283
|
return;
|
|
@@ -496,49 +286,15 @@ export class Master {
|
|
|
496
286
|
this.raw.providers = this.config.providers;
|
|
497
287
|
this.raw.defaultProvider = this.config.defaultProvider;
|
|
498
288
|
this.raw.tasks = this.config.tasks;
|
|
499
|
-
// `raw` is what gets WRITTEN, and it is a hand-maintained mirror of `config`.
|
|
500
|
-
// `mcpServers` was added to the config and to the API but never to this list, so the
|
|
501
|
-
// servers existed in memory and in `GET /api/config` and were STILL lost on restart —
|
|
502
|
-
// a save→reopen appeared to work while the file never carried them.
|
|
503
|
-
//
|
|
504
|
-
// Every field that must survive a restart belongs here. Caught by asserting the FILE
|
|
505
|
-
// contents in test/mcp-config-roundtrip.test.ts, which was impossible while that test
|
|
506
|
-
// wrote to /dev/null.
|
|
507
289
|
this.raw.mcpServers = this.config.mcpServers;
|
|
508
|
-
/**
|
|
509
|
-
* Custom themes live in the config so they follow the machine, not one browser.
|
|
510
|
-
*
|
|
511
|
-
* This line is the whole persistence: `raw` is the hand-maintained mirror that actually gets
|
|
512
|
-
* written, and a field missing from it exists in memory and in `GET /api/config` while never
|
|
513
|
-
* reaching the file — exactly the #67 failure above, which is why it is called out there.
|
|
514
|
-
*/
|
|
515
290
|
if (this.config.customThemes !== undefined)
|
|
516
291
|
this.raw.customThemes = this.config.customThemes;
|
|
517
|
-
/**
|
|
518
|
-
* The API password — same mirror, same trap.
|
|
519
|
-
*
|
|
520
|
-
* It was never in `raw` because it was only ever written by the first-run setup path, which
|
|
521
|
-
* wrote the file wholesale. Now that the settings panel can change it, a missing mirror would
|
|
522
|
-
* make "set a password" appear to work and vanish on the next restart, leaving the server open
|
|
523
|
-
* while the operator believes it is closed — the worst possible direction for this field.
|
|
524
|
-
*/
|
|
525
292
|
if (this.config.password !== undefined)
|
|
526
293
|
this.raw.password = this.config.password;
|
|
527
294
|
else
|
|
528
295
|
delete this.raw.password;
|
|
529
296
|
if (this.config.themePrefs !== undefined)
|
|
530
297
|
this.raw.themePrefs = this.config.themePrefs;
|
|
531
|
-
/**
|
|
532
|
-
* The live-update resume set — and the reason it needs this line is the whole point of it.
|
|
533
|
-
*
|
|
534
|
-
* `resumeAfterUpdate()` CLEARS the set so a crash cannot make a later boot resume hours-old
|
|
535
|
-
* work. Without this mirror the clear lived only in memory, so the file kept the old set and the
|
|
536
|
-
* NEXT update would resume the same sessions again — MEASURED: a boot with
|
|
537
|
-
* `resumeAfterUpdate: ["worker"]` left the file unchanged at `["worker"]` after consuming it.
|
|
538
|
-
*
|
|
539
|
-
* Written unconditionally (not only when defined): `[]` is a meaningful value here, and skipping
|
|
540
|
-
* it is what kept the stale list on disk.
|
|
541
|
-
*/
|
|
542
298
|
this.raw.resumeAfterUpdate = this.config.resumeAfterUpdate;
|
|
543
299
|
if (this.config.shortContext !== undefined)
|
|
544
300
|
this.raw.shortContext = this.config.shortContext;
|
|
@@ -550,57 +306,19 @@ export class Master {
|
|
|
550
306
|
this.raw.progressMinChars = this.config.progressMinChars;
|
|
551
307
|
this.writeConfigAtomic(JSON.stringify(this.raw, null, 2) + "\n");
|
|
552
308
|
}
|
|
553
|
-
/**
|
|
554
|
-
* Coalesce config writes. #144.
|
|
555
|
-
*
|
|
556
|
-
* Boot calls `addAgent` for every configured agent, and any agent whose recorded
|
|
557
|
-
* session is gone mints a new one — which calls `recordSession` → `saveConfig`.
|
|
558
|
-
* A machine with 30 agents therefore rewrote the whole file 30 times before the
|
|
559
|
-
* server listened, and each was a window in which the file could be truncated.
|
|
560
|
-
*
|
|
561
|
-
* With the write now atomic that is no longer dangerous, but it is still 30
|
|
562
|
-
* unnecessary writes of a 148KB file. During boot, saves are marked dirty and
|
|
563
|
-
* flushed ONCE at the end. Outside boot (an edit, a spawn, a rename) behaviour is
|
|
564
|
-
* unchanged: synchronous and immediate.
|
|
565
|
-
*/
|
|
566
309
|
deferConfigWrites = false;
|
|
567
310
|
configDirty = false;
|
|
568
|
-
/**
|
|
569
|
-
* Endpoints whose catalogue boot has already WAITED for.
|
|
570
|
-
*
|
|
571
|
-
* The wait must be paid once per endpoint, not once per agent. `Promise.race`
|
|
572
|
-
* against a timeout is per-CALL: with a dead endpoint every agent on it paid
|
|
573
|
-
* the full timeout again, because the shared request was still pending. 493
|
|
574
|
-
* agents over one unreachable provider measured 4.4s of boot spent purely
|
|
575
|
-
* idle, for a single request.
|
|
576
|
-
*/
|
|
577
311
|
metaWaited = new Set();
|
|
578
|
-
/**
|
|
579
|
-
* `TEAPOT_DATA_DIR` points somewhere other than the config's own dataDir,
|
|
580
|
-
* so session pointers must not be written back (see dataDirOverrideMismatch).
|
|
581
|
-
*/
|
|
582
312
|
sessionsReadOnly = false;
|
|
583
|
-
/** write once and clear the dirty flag — used at the end of a batched section */
|
|
584
313
|
flushConfig() {
|
|
585
314
|
this.configDirty = false;
|
|
586
315
|
this.saveConfig();
|
|
587
316
|
}
|
|
588
|
-
/**
|
|
589
|
-
* The shared pool for a provider, created on first use.
|
|
590
|
-
*
|
|
591
|
-
* Cached so the cooldown state survives across agents AND across a provider being
|
|
592
|
-
* re-read: rebuilding the pool on every spawn would forget that a key is cooling
|
|
593
|
-
* down and hand it straight back out. Rebuilt only if the KEY LIST changes (the
|
|
594
|
-
* operator edited the provider), which is when the old cooldowns stop being
|
|
595
|
-
* meaningful.
|
|
596
|
-
*/
|
|
597
317
|
keyPoolFor(name, prov) {
|
|
598
318
|
const keys = providerKeys(prov);
|
|
599
319
|
if (keys.length === 0)
|
|
600
320
|
return undefined;
|
|
601
321
|
const existing = this.keyPools.get(name);
|
|
602
|
-
// reuse while the KEY LIST is unchanged, so the pool keeps its position in the
|
|
603
|
-
// cycle across spawns; a changed list is a new pool
|
|
604
322
|
if (existing && existing.keys.length === keys.length && existing.keys.every((k, i) => k === keys[i])) {
|
|
605
323
|
return existing;
|
|
606
324
|
}
|
|
@@ -608,13 +326,6 @@ export class Master {
|
|
|
608
326
|
this.keyPools.set(name, pool);
|
|
609
327
|
return pool;
|
|
610
328
|
}
|
|
611
|
-
/**
|
|
612
|
-
* Diagnostics: each provider's keys and which one is in use.
|
|
613
|
-
*
|
|
614
|
-
* Deliberately shows only position, not "spent until" timestamps: this pool has no
|
|
615
|
-
* such state — a key is rotated past once its retries are spent, and a new turn
|
|
616
|
-
* starts the cycle again.
|
|
617
|
-
*/
|
|
618
329
|
keyPoolReport() {
|
|
619
330
|
return [...this.keyPools.entries()].map(([provider, pool]) => ({
|
|
620
331
|
provider,
|
|
@@ -622,7 +333,6 @@ export class Master {
|
|
|
622
333
|
current: pool.current,
|
|
623
334
|
}));
|
|
624
335
|
}
|
|
625
|
-
/** start every provider's cycle over from its first key */
|
|
626
336
|
resetKeyPool(provider) {
|
|
627
337
|
const names = provider ? [provider] : [...this.keyPools.keys()];
|
|
628
338
|
for (const n of names) {
|
|
@@ -632,27 +342,16 @@ export class Master {
|
|
|
632
342
|
}
|
|
633
343
|
}
|
|
634
344
|
writeConfigAtomic(body) {
|
|
635
|
-
// `/dev/null` is a real, intentional value here — many callers (and
|
|
636
|
-
// every test that does not care about persistence) pass it as "no config
|
|
637
|
-
// file". The old `writeFileSync("/dev/null", …)` succeeded and discarded.
|
|
638
|
-
//
|
|
639
|
-
// Atomic writing cannot honour that: the temp file would be
|
|
640
|
-
// `/dev/.config.json.tmp-…`, which is EACCES for a normal user, so 22 tests
|
|
641
|
-
// failed with a bare 400 from POST /api/agents. Keep the old behaviour for a
|
|
642
|
-
// non-regular target, and only do write-rename where rename means something.
|
|
643
345
|
if (!isRegularFileTarget(this.configPath)) {
|
|
644
346
|
writeFileSync(this.configPath, body);
|
|
645
347
|
return;
|
|
646
348
|
}
|
|
647
349
|
const dir = path.dirname(this.configPath);
|
|
648
|
-
// same directory as the target, so rename() stays within one filesystem
|
|
649
350
|
const tmp = path.join(dir, `.config.json.tmp-${process.pid}-${Date.now()}`);
|
|
650
351
|
let fd;
|
|
651
352
|
try {
|
|
652
353
|
fd = openSync(tmp, "w");
|
|
653
354
|
writeFileSync(fd, body);
|
|
654
|
-
// fsync BEFORE the rename: without it the rename can land while the data is
|
|
655
|
-
// still in the page cache, which is the same data-loss window on a crash
|
|
656
355
|
fsyncSync(fd);
|
|
657
356
|
closeSync(fd);
|
|
658
357
|
fd = undefined;
|
|
@@ -664,31 +363,20 @@ export class Master {
|
|
|
664
363
|
closeSync(fd);
|
|
665
364
|
}
|
|
666
365
|
catch {
|
|
667
|
-
/* already closed */
|
|
668
366
|
}
|
|
669
367
|
}
|
|
670
368
|
try {
|
|
671
369
|
unlinkSync(tmp);
|
|
672
370
|
}
|
|
673
371
|
catch {
|
|
674
|
-
/* nothing to clean up */
|
|
675
372
|
}
|
|
676
|
-
// Losing the new state is survivable; losing the file is not, so rethrow
|
|
677
|
-
// rather than pretending the save succeeded
|
|
678
373
|
throw err;
|
|
679
374
|
}
|
|
680
375
|
}
|
|
681
376
|
raw = loadedRaw();
|
|
682
|
-
/** Apply partial config edits from the web UI and persist them. */
|
|
683
377
|
updateConfig(patch) {
|
|
684
378
|
if (patch.providers)
|
|
685
379
|
this.config.providers = patch.providers;
|
|
686
|
-
/**
|
|
687
|
-
* `!== undefined`, so `""` is honoured as "clear it".
|
|
688
|
-
*
|
|
689
|
-
* A truthiness check would silently keep the old password when the operator emptied the field,
|
|
690
|
-
* which is the one case where they are explicitly asking to remove the protection.
|
|
691
|
-
*/
|
|
692
380
|
if (patch.password !== undefined)
|
|
693
381
|
this.config.password = patch.password || undefined;
|
|
694
382
|
if (patch.defaultProvider !== undefined)
|
|
@@ -705,22 +393,16 @@ export class Master {
|
|
|
705
393
|
a.opts.progressMinChars =
|
|
706
394
|
patch.progressMinChars;
|
|
707
395
|
}
|
|
708
|
-
// explicit null clears a previously saved budget → back to per-model
|
|
709
|
-
// derivation (75% of each agent's known window); a positive number pins it
|
|
710
396
|
if (patch.contextTokenBudget !== undefined) {
|
|
711
397
|
this.config.contextTokenBudget =
|
|
712
398
|
(patch.contextTokenBudget ?? 0) > 0 ? patch.contextTokenBudget : undefined;
|
|
713
399
|
}
|
|
714
400
|
if (patch.maxSpawnDepth !== undefined)
|
|
715
401
|
this.config.maxSpawnDepth = patch.maxSpawnDepth;
|
|
716
|
-
// the registry is applied by the caller (it also connects); this only
|
|
717
|
-
// records the list so a restart comes back with the same servers.
|
|
718
402
|
if (patch.mcpServers !== undefined)
|
|
719
403
|
this.config.mcpServers = patch.mcpServers;
|
|
720
404
|
if (patch.shortContext !== undefined) {
|
|
721
405
|
this.config.shortContext = patch.shortContext;
|
|
722
|
-
// apply to LIVE agents too: the mode is read every turn, so a restart should not
|
|
723
|
-
// be the price of switching it
|
|
724
406
|
for (const a of this.agents.values())
|
|
725
407
|
a.opts.shortContext =
|
|
726
408
|
patch.shortContext;
|
|
@@ -731,17 +413,10 @@ export class Master {
|
|
|
731
413
|
this.config.onError = patch.onError;
|
|
732
414
|
if (patch.retryDelayMs !== undefined)
|
|
733
415
|
this.config.retryDelayMs = patch.retryDelayMs;
|
|
734
|
-
// #PUT-config: an explicit null clears a saved window pin → back to
|
|
735
|
-
// per-model inference; a positive number pins it (same contract as
|
|
736
|
-
// contextTokenBudget above)
|
|
737
416
|
if (patch.contextWindowTokens !== undefined) {
|
|
738
417
|
this.config.contextWindowTokens =
|
|
739
418
|
(patch.contextWindowTokens ?? 0) > 0 ? patch.contextWindowTokens : undefined;
|
|
740
419
|
}
|
|
741
|
-
// persist the scalar edits: saveConfig only mirrors agents/providers/etc.
|
|
742
|
-
// into raw, so these would otherwise pass validation, live for one
|
|
743
|
-
// process, and silently vanish from the file (a restart resurrected the
|
|
744
|
-
// old values). undefined DELETES the key so a null-clear also persists.
|
|
745
420
|
for (const k of [
|
|
746
421
|
"contextTokenBudget", "contextWindowTokens", "maxSpawnDepth",
|
|
747
422
|
"maxTurnsPerRound", "onError", "retryDelayMs",
|
|
@@ -754,7 +429,6 @@ export class Master {
|
|
|
754
429
|
}
|
|
755
430
|
if (patch.tasks) {
|
|
756
431
|
this.config.tasks = patch.tasks;
|
|
757
|
-
// rebuild schedule table live
|
|
758
432
|
this.tasks = patch.tasks.map((t) => ({
|
|
759
433
|
task: t,
|
|
760
434
|
schedule: parseSchedule(t.schedule),
|
|
@@ -763,13 +437,6 @@ export class Master {
|
|
|
763
437
|
}
|
|
764
438
|
this.saveConfig();
|
|
765
439
|
}
|
|
766
|
-
/**
|
|
767
|
-
* Load Agent Plugins and merge their MCP servers with the configured ones.
|
|
768
|
-
*
|
|
769
|
-
* Separate from `start()` so it can be exercised without booting a server: this is
|
|
770
|
-
* where plugin servers get QUALIFIED with their plugin name, and a naming collision
|
|
771
|
-
* here silently makes one plugin's tools disappear.
|
|
772
|
-
*/
|
|
773
440
|
async loadPluginsAndMcp() {
|
|
774
441
|
const pluginMcp = [];
|
|
775
442
|
if (this.config.pluginsDir !== false) {
|
|
@@ -781,10 +448,6 @@ export class Master {
|
|
|
781
448
|
for (const p of plugins) {
|
|
782
449
|
for (const w of p.warnings)
|
|
783
450
|
console.error(`[teapot] plugin "${p.name}": ${w}`);
|
|
784
|
-
// QUALIFY each server with its plugin name. A plugin's own `mcp.json` names its
|
|
785
|
-
// servers locally ("db"), and two plugins may each have one — the registry keys
|
|
786
|
-
// by name, so an unqualified collision would silently replace one with the other
|
|
787
|
-
// and its tools would appear to vanish. The plugin name is the natural prefix.
|
|
788
451
|
pluginMcp.push(...p.mcpServers.map((s) => ({ ...s, name: `${p.name}:${s.name}` })));
|
|
789
452
|
}
|
|
790
453
|
if (plugins.length)
|
|
@@ -794,23 +457,7 @@ export class Master {
|
|
|
794
457
|
}
|
|
795
458
|
async start() {
|
|
796
459
|
mkdirSync(this.config.dataDir, { recursive: true });
|
|
797
|
-
/**
|
|
798
|
-
* Clear the flag BEFORE the migrations, never after.
|
|
799
|
-
*
|
|
800
|
-
* `finally` below flushes only when `configDirty` is set, so a reset placed after the migration
|
|
801
|
-
* pass erases exactly what a migration just asked to be written. MEASURED with it after: 37
|
|
802
|
-
* titles derived in memory, `"sessionTitle"` in the config file: 0.
|
|
803
|
-
*/
|
|
804
460
|
this.configDirty = false;
|
|
805
|
-
/**
|
|
806
|
-
* Run the on-disk migrations ONCE, before any agent resolves a session directory.
|
|
807
|
-
*
|
|
808
|
-
* They used to be spread across the boot path — the flat-layout move ran inside
|
|
809
|
-
* `resolveSessionDir`, i.e. per agent, where it could interleave with another agent's
|
|
810
|
-
* resolution. Ordering is the thing that matters when a machine skips several releases: an
|
|
811
|
-
* operator jumping from an old build runs EVERY step at once, and a step that assumes another
|
|
812
|
-
* has run must actually see that. See src/migrations/index.ts.
|
|
813
|
-
*/
|
|
814
461
|
await runMigrations(MIGRATIONS, {
|
|
815
462
|
configDir: path.dirname(this.configPath),
|
|
816
463
|
raw: () => this.raw,
|
|
@@ -820,37 +467,9 @@ export class Master {
|
|
|
820
467
|
dataDir: this.config.dataDir,
|
|
821
468
|
log: (m) => console.log(`[teapot] ${m}`),
|
|
822
469
|
});
|
|
823
|
-
// ONE config write for the whole boot instead of one per agent.
|
|
824
|
-
//
|
|
825
|
-
// Every agent whose recorded session is gone mints a new one, and minting
|
|
826
|
-
// calls recordSession → saveConfig. With ~30 configured agents the user's log
|
|
827
|
-
// showed ~30 "session … is gone" lines, and therefore ~30 full rewrites of a
|
|
828
|
-
// 148KB config.json — all before the server ever listened. That is the window
|
|
829
|
-
// in which Ctrl-C emptied the file.
|
|
830
|
-
//
|
|
831
|
-
// `finally` so a throw mid-boot still flushes what it already changed: a
|
|
832
|
-
// deferred write that never flushes would leave config.json describing the PREVIOUS
|
|
833
|
-
// boot, which is its own silent data loss.
|
|
834
470
|
this.deferConfigWrites = true;
|
|
835
|
-
// start every endpoint's model-catalogue lookup NOW, concurrently.
|
|
836
|
-
//
|
|
837
|
-
// `addAgent` awaits this per agent, and the endpoints are shared by many
|
|
838
|
-
// agents (493 agents over 6 providers here). Fired up front, the requests
|
|
839
|
-
// overlap and each subsequent agent finds the promise already in flight or
|
|
840
|
-
// resolved — instead of the requests being serialised behind the agent loop,
|
|
841
|
-
// where a slow endpoint's timeout is paid again and again.
|
|
842
471
|
this.prefetchModelLists();
|
|
843
|
-
// connect configured MCP servers in the background. Deliberately NOT
|
|
844
|
-
// awaited: a slow or unreachable server must not delay listening, and its tools
|
|
845
|
-
// appear as the connection lands (an agent's next turn picks them up because
|
|
846
|
-
// `allToolSpecs()` reads the registry each call).
|
|
847
|
-
// Agent Plugins contribute skill roots and MCP servers. Loaded BEFORE the
|
|
848
|
-
// MCP connect below so a plugin's servers connect in the same pass as configured
|
|
849
|
-
// ones, and before any agent starts so its skills are discoverable on turn one.
|
|
850
472
|
if (this.config.mcpServers?.length || this.config.pluginsDir !== false) {
|
|
851
|
-
// OAuth grants live beside the data, not in config.json (a refresh token is a
|
|
852
|
-
// credential, and config.json is the file operators copy around). Must be pointed
|
|
853
|
-
// at the data dir BEFORE any server connects, or a stored grant is invisible.
|
|
854
473
|
mcp.init(this.config.dataDir);
|
|
855
474
|
const allMcp = await this.loadPluginsAndMcp();
|
|
856
475
|
if (allMcp.length) {
|
|
@@ -860,11 +479,7 @@ export class Master {
|
|
|
860
479
|
}
|
|
861
480
|
try {
|
|
862
481
|
for (const ac of this.config.agents) {
|
|
863
|
-
// archived agents keep their config entry and their history but do not run;
|
|
864
|
-
// starting one would spend a provider request and mint/rewrite a session for
|
|
865
|
-
// something the operator deliberately put away.
|
|
866
482
|
if (ac.archived) {
|
|
867
|
-
// archived: not started, but its history must stay readable (the sidebar lists it)
|
|
868
483
|
this.unavailableAgents.set(ac.id, "archived");
|
|
869
484
|
continue;
|
|
870
485
|
}
|
|
@@ -872,8 +487,6 @@ export class Master {
|
|
|
872
487
|
await this.addAgent(ac);
|
|
873
488
|
}
|
|
874
489
|
catch (err) {
|
|
875
|
-
// one bad entry (duplicate id, unknown provider, …) must not take the
|
|
876
|
-
// whole master down — skip it loudly and keep serving the rest
|
|
877
490
|
console.error(`[teapot] skipping agent "${ac.id}": ${err.message}`);
|
|
878
491
|
this.unavailableAgents.set(ac.id, err.message);
|
|
879
492
|
}
|
|
@@ -887,7 +500,6 @@ export class Master {
|
|
|
887
500
|
});
|
|
888
501
|
}
|
|
889
502
|
catch (err) {
|
|
890
|
-
// a hand-edited schedule typo must not stop the server from listening
|
|
891
503
|
console.error(`[teapot] skipping task "${t.id}": ${err.message}`);
|
|
892
504
|
}
|
|
893
505
|
}
|
|
@@ -897,15 +509,8 @@ export class Master {
|
|
|
897
509
|
if (this.configDirty)
|
|
898
510
|
this.flushConfig();
|
|
899
511
|
}
|
|
900
|
-
// single low-frequency tick for everything periodic (idle cost ≈ 0)
|
|
901
512
|
setInterval(() => void this.tick(), 15_000).unref();
|
|
902
513
|
}
|
|
903
|
-
/**
|
|
904
|
-
* First-run wizard bootstrap: write a minimal config file, apply it to the
|
|
905
|
-
* running master, and optionally create + start the first agent. Only
|
|
906
|
-
* available while no config file exists — after this, edits go through
|
|
907
|
-
* PUT /api/config behind whatever auth is configured.
|
|
908
|
-
*/
|
|
909
514
|
async applySetup(body) {
|
|
910
515
|
if (this.configFileExists)
|
|
911
516
|
throw new Error("setup already completed — edit the config file instead");
|
|
@@ -922,7 +527,6 @@ export class Master {
|
|
|
922
527
|
const dir = path.dirname(this.configPath);
|
|
923
528
|
mkdirSync(dir, { recursive: true });
|
|
924
529
|
writeFileSync(this.configPath, JSON.stringify(raw, null, 2) + "\n");
|
|
925
|
-
// hot-apply to the running master (no restart needed)
|
|
926
530
|
this.config.llm = { ...this.config.llm, ...llm };
|
|
927
531
|
if (body.password)
|
|
928
532
|
this.config.password = body.password;
|
|
@@ -930,19 +534,17 @@ export class Master {
|
|
|
930
534
|
this.raw.llm = llm;
|
|
931
535
|
if (body.password)
|
|
932
536
|
this.raw.password = body.password;
|
|
933
|
-
// learn the window size up front so compaction derives 75% of it
|
|
934
537
|
try {
|
|
935
538
|
const list = await fetchModelList(llm.baseUrl, body.apiKey);
|
|
936
539
|
const cl = contextLengthFor(list, llm.model);
|
|
937
540
|
if (cl) {
|
|
938
541
|
this.config.contextWindowTokens = cl;
|
|
939
542
|
this.raw.contextWindowTokens = cl;
|
|
940
|
-
raw.contextWindowTokens = cl;
|
|
543
|
+
raw.contextWindowTokens = cl;
|
|
941
544
|
writeFileSync(this.configPath, JSON.stringify(raw, null, 2) + "\n");
|
|
942
545
|
}
|
|
943
546
|
}
|
|
944
547
|
catch {
|
|
945
|
-
/* metadata is best-effort */
|
|
946
548
|
}
|
|
947
549
|
let agentId;
|
|
948
550
|
if (body.workspace?.trim()) {
|
|
@@ -961,25 +563,6 @@ export class Master {
|
|
|
961
563
|
}
|
|
962
564
|
return { ok: true, agentId };
|
|
963
565
|
}
|
|
964
|
-
/**
|
|
965
|
-
* Create an agent; optionally persist it to the config file.
|
|
966
|
-
* Each incarnation gets its own session directory under
|
|
967
|
-
* <dataDir>/sessions/<agentId>-<uuid>/ (chat.jsonl, goal.md, memory.md).
|
|
968
|
-
* Restarts reuse the latest existing session; fresh creations never touch
|
|
969
|
-
* an older one's history.
|
|
970
|
-
*/
|
|
971
|
-
/**
|
|
972
|
-
* Fire the model-catalogue lookup for every distinct endpoint, concurrently.
|
|
973
|
-
*
|
|
974
|
-
* `fetchModelList` already shares an in-flight request per baseUrl, so this is
|
|
975
|
-
* about STARTING them together: without it the requests begin one at a time, as
|
|
976
|
-
* the agent loop reaches each provider, and a slow endpoint's timeout is paid
|
|
977
|
-
* serially by every agent on it.
|
|
978
|
-
*
|
|
979
|
-
* Best-effort by design — nothing awaits this. A failure is cached briefly by
|
|
980
|
-
* `fetchModelList`, and an agent whose lookup is not ready simply gets no
|
|
981
|
-
* inferred window, which is the same outcome as today's timeout.
|
|
982
|
-
*/
|
|
983
566
|
prefetchModelLists() {
|
|
984
567
|
const endpoints = new Set();
|
|
985
568
|
const add = (baseUrl, apiKey) => {
|
|
@@ -995,22 +578,10 @@ export class Master {
|
|
|
995
578
|
const prov = this.config.providers?.[ac.provider ?? this.config.defaultProvider ?? ""];
|
|
996
579
|
add(ac.baseUrl ?? prov?.baseUrl ?? this.config.llm.baseUrl, ac.apiKey ?? prov?.apiKey ?? this.config.llm.apiKey);
|
|
997
580
|
}
|
|
998
|
-
// no configured agents yet (first-run wizard) — still warm the default so the
|
|
999
|
-
// first created agent does not wait
|
|
1000
581
|
if (endpoints.size === 0)
|
|
1001
582
|
add(this.config.llm.baseUrl, this.config.llm.apiKey);
|
|
1002
583
|
}
|
|
1003
584
|
async addAgent(ac, opts = {}) {
|
|
1004
|
-
// an agent id becomes a DIRECTORY name (sessions/<id>-<hash>), so it
|
|
1005
|
-
// must be a safe path segment. Every caller sanitised its own id — the HTTP
|
|
1006
|
-
// route, spawnChildFor's child name — except the ACP adapter, which passed
|
|
1007
|
-
// the raw client string through, so `session/load {"sessionId":"../../x"}`
|
|
1008
|
-
// created an agent logged outside <dataDir>/sessions/.
|
|
1009
|
-
//
|
|
1010
|
-
// Sanitised HERE as well as at the call sites: this is the one place that
|
|
1011
|
-
// guarantees the property, so a future caller cannot reintroduce the escape
|
|
1012
|
-
// by forgetting. A caller that already sanitised is unaffected — the
|
|
1013
|
-
// transform is idempotent on a safe id.
|
|
1014
585
|
const safeId = sanitizeAgentId(ac.id);
|
|
1015
586
|
if (safeId !== ac.id) {
|
|
1016
587
|
console.warn(`[teapot] agent id ${JSON.stringify(ac.id)} contained unsafe characters; using ${JSON.stringify(safeId)}`);
|
|
@@ -1018,22 +589,15 @@ export class Master {
|
|
|
1018
589
|
ac = { ...ac, id: safeId };
|
|
1019
590
|
if (this.agents.has(ac.id))
|
|
1020
591
|
throw new Error(`agent id already exists: ${ac.id}`);
|
|
1021
|
-
// provider resolution: inline overrides > named provider > legacy llm block
|
|
1022
592
|
const provName = ac.provider ?? this.config.defaultProvider ?? "openrouter";
|
|
1023
593
|
const prov = this.config.providers?.[provName];
|
|
1024
594
|
if (!prov && !ac.baseUrl && !this.config.llm.baseUrl) {
|
|
1025
595
|
throw new Error(`agent ${ac.id}: unknown provider "${provName}" and no fallback`);
|
|
1026
596
|
}
|
|
1027
|
-
// an INLINE `ac.apiKey` is this agent's own credential, so it must NOT be
|
|
1028
|
-
// joined to the provider's pool — the pool belongs to the named provider, and
|
|
1029
|
-
// folding a one-off override in would rotate an agent onto keys its operator did
|
|
1030
|
-
// not choose for it. Only the named provider gets a pool.
|
|
1031
597
|
const inlineKey = typeof ac.apiKey === "string" && ac.apiKey.trim() ? ac.apiKey.trim() : undefined;
|
|
1032
598
|
const pool = inlineKey ? undefined : this.keyPoolFor(provName, prov);
|
|
1033
599
|
const llm = {
|
|
1034
600
|
baseUrl: ac.baseUrl ?? prov?.baseUrl ?? this.config.llm.baseUrl,
|
|
1035
|
-
// the pool's CURRENT key is the sticky one: a restart must resume on the
|
|
1036
|
-
// account the operator's cycle had reached, not start over on a spent first one
|
|
1037
601
|
apiKey: inlineKey ??
|
|
1038
602
|
currentKey(pool) ??
|
|
1039
603
|
prov?.apiKey ??
|
|
@@ -1044,23 +608,7 @@ export class Master {
|
|
|
1044
608
|
};
|
|
1045
609
|
if (!llm.model)
|
|
1046
610
|
throw new Error(`agent ${ac.id}: no model configured (set model on the agent or on its provider)`);
|
|
1047
|
-
// spawn-tree depth: computed from the config chain (ac may not be
|
|
1048
|
-
// registered yet when this runs — spawnChildFor persists AFTER creation)
|
|
1049
611
|
const myDepth = ac.parent ? this.depthOf(ac.parent) + 1 : 0;
|
|
1050
|
-
// Model metadata: the context window (for the compaction budget) and the
|
|
1051
|
-
// catalogue entry that gates reasoning_effort support come from the
|
|
1052
|
-
// SAME `/models` list.
|
|
1053
|
-
//
|
|
1054
|
-
// boot must not WAIT for this. It only tunes the compaction budget and
|
|
1055
|
-
// the effort controls, so a missing list costs nothing that matters; what it
|
|
1056
|
-
// cost was boot time — measured at 4.4s of pure idle on 493 agents pointed at
|
|
1057
|
-
// one unreachable endpoint, because the `Promise.race` timeout below is
|
|
1058
|
-
// per-CALL and the shared request stayed pending for every agent.
|
|
1059
|
-
//
|
|
1060
|
-
// So: use the catalogue if `prefetchModelLists()` has already produced it
|
|
1061
|
-
// (the common case — the lookups were fired when boot started), and wait only
|
|
1062
|
-
// ONCE per endpoint for the stragglers, where the wait overlaps a request
|
|
1063
|
-
// that is already in flight.
|
|
1064
612
|
const needsWindow = !ac.contextWindowTokens && !this.config.contextWindowTokens;
|
|
1065
613
|
const root = llm.baseUrl.replace(/\/+$/, "");
|
|
1066
614
|
let list = peekModelList(llm.baseUrl);
|
|
@@ -1068,7 +616,6 @@ export class Master {
|
|
|
1068
616
|
this.metaWaited.add(root);
|
|
1069
617
|
list = await Promise.race([
|
|
1070
618
|
fetchModelList(llm.baseUrl, llm.apiKey),
|
|
1071
|
-
// a placeholder so the race can settle without the list
|
|
1072
619
|
new Promise((r) => setTimeout(() => r(undefined), MODEL_META_BOOT_MS)),
|
|
1073
620
|
]);
|
|
1074
621
|
}
|
|
@@ -1076,18 +623,10 @@ export class Master {
|
|
|
1076
623
|
const modelMeta = list?.find((m) => m.id === llm.model);
|
|
1077
624
|
const sessionDir = this.resolveSessionDir(ac.id, opts.fresh === true);
|
|
1078
625
|
await mkdirSync(sessionDir, { recursive: true });
|
|
1079
|
-
// every session dir this master hands out carries an ownership manifest —
|
|
1080
|
-
// legacy dirs get backfilled on their first restart under the new code
|
|
1081
626
|
this.writeSessionManifest(sessionDir, ac.id);
|
|
1082
627
|
const agent = new Agent({
|
|
1083
628
|
id: ac.id,
|
|
1084
629
|
workspace: resolveWorkspace(ac.workspace, path.dirname(this.configPath)),
|
|
1085
|
-
/**
|
|
1086
|
-
* Derive the session label when the first user prompt lands, and STORE it.
|
|
1087
|
-
*
|
|
1088
|
-
* The alternative — reading every session log while listing — was the dominant cost of
|
|
1089
|
-
* `/api/sessions`. Doing it here pays once per session instead of once per list.
|
|
1090
|
-
*/
|
|
1091
630
|
onFirstUserPrompt: (sessionId) => {
|
|
1092
631
|
void this.ensureSessionTitle(ac.id, sessionId);
|
|
1093
632
|
},
|
|
@@ -1099,14 +638,7 @@ export class Master {
|
|
|
1099
638
|
...(this.config.onError ? { onError: this.config.onError } : {}),
|
|
1100
639
|
...(this.config.retryDelayMs ? { retryDelayMs: this.config.retryDelayMs } : {}),
|
|
1101
640
|
autoContinue: ac.autoContinue ?? true,
|
|
1102
|
-
// BUG found while wiring #161: `autoCompact` was a field on AgentConfig and on
|
|
1103
|
-
// AgentOptions, but nothing ever carried it between the two — so a configured
|
|
1104
|
-
// `"autoCompact": false` was silently ignored and compaction stayed on. Passed
|
|
1105
|
-
// here for the same reason as shortContext: a config knob that does nothing is
|
|
1106
|
-
// worse than no knob.
|
|
1107
641
|
...(ac.autoCompact === false ? { autoCompact: false } : {}),
|
|
1108
|
-
// per-agent choice, else the global default. This mode DISCARDS information
|
|
1109
|
-
// early by design, so it must never switch on by accident.
|
|
1110
642
|
...((ac.shortContext ?? this.config.shortContext) ? { shortContext: true } : {}),
|
|
1111
643
|
...(this.config.contextTokenBudget ? { contextTokenBudget: this.config.contextTokenBudget } : {}),
|
|
1112
644
|
...(ac.contextWindowTokens || this.config.contextWindowTokens || inferredWindow
|
|
@@ -1118,8 +650,6 @@ export class Master {
|
|
|
1118
650
|
: {}),
|
|
1119
651
|
globalSkillsDir: path.join(CONFIG_DIR, "skills"),
|
|
1120
652
|
bundledSkillsDir: BUNDLED_SKILLS_DIR,
|
|
1121
|
-
// a plugin's own skills/ is a skill root, ranked with the global ones so
|
|
1122
|
-
// a workspace skill still wins on a name clash
|
|
1123
653
|
pluginSkillRoots: this.plugins
|
|
1124
654
|
.filter((p) => p.skillsDir)
|
|
1125
655
|
.map((p) => ({ dir: p.skillsDir, source: `plugin:${p.name}` })),
|
|
@@ -1128,58 +658,27 @@ export class Master {
|
|
|
1128
658
|
...(ac.readOnly ? { readOnlyTools: true } : {}),
|
|
1129
659
|
...(ac.parent ? { parent: ac.parent } : {}),
|
|
1130
660
|
...(ac.chatFn ? { chatFn: ac.chatFn } : {}),
|
|
1131
|
-
// reasoning effort is a per-agent choice; the catalogue decides
|
|
1132
|
-
// whether it can actually be sent (see effortForRequest)
|
|
1133
661
|
...(ac.reasoningEffort ? { reasoningEffort: ac.reasoningEffort } : {}),
|
|
1134
662
|
...(modelMeta?.supportedParameters
|
|
1135
663
|
? { supportedParameters: modelMeta.supportedParameters }
|
|
1136
664
|
: {}),
|
|
1137
|
-
// the model's own reasoning levels, so the picker offers exactly those
|
|
1138
665
|
...(modelMeta?.supportedEfforts?.length ? { supportedEfforts: modelMeta.supportedEfforts } : {}),
|
|
1139
666
|
});
|
|
1140
|
-
// pricing metadata (USD/token) for the runtime cost estimate — best
|
|
1141
|
-
// effort: providers without pricing in /models simply show no cost
|
|
1142
667
|
void fetchModelList(llm.baseUrl, llm.apiKey)
|
|
1143
668
|
.then((list) => agent.setModelPricing(list.find((m) => m.id === llm.model)?.pricing))
|
|
1144
669
|
.catch(() => { });
|
|
1145
|
-
// console line + broadcast: the web UI only refreshes on bus traffic, so
|
|
1146
|
-
// every appended event must reach it (otherwise messages sit invisible
|
|
1147
|
-
// until an unrelated status change happens to fire)
|
|
1148
670
|
agent.log.onEvent = (e) => {
|
|
1149
671
|
printAgentEvent(e);
|
|
1150
672
|
bus.emit("update", { kind: "event", agentId: e.agent, event: e });
|
|
1151
673
|
this.onChildEvent(ac, e);
|
|
1152
674
|
};
|
|
1153
|
-
// the CONTROL PLANE, separate from the event pipeline above.
|
|
1154
|
-
//
|
|
1155
|
-
// `onChildEvent` used to be what woke a parent's `wait_children`, and it runs
|
|
1156
|
-
// after the event is persisted. So a failed or slow write left the parent
|
|
1157
|
-
// parked forever — measured with every write failing, the wake never fired.
|
|
1158
|
-
//
|
|
1159
|
-
// A status change is CONTROL FLOW, not an observation, so it gets its own
|
|
1160
|
-
// immediate path. `onChildEvent` keeps its other job (mirroring the child's
|
|
1161
|
-
// activity into the parent's timeline, forwarding its final report), which
|
|
1162
|
-
// legitimately belongs to the event stream.
|
|
1163
675
|
agent.onControlStatusChange = (agentId, status) => {
|
|
1164
676
|
if (!ac.parent)
|
|
1165
677
|
return;
|
|
1166
678
|
const parent = this.agents.get(ac.parent);
|
|
1167
679
|
if (!parent)
|
|
1168
680
|
return;
|
|
1169
|
-
// the waiter re-checks whether ITS targets are all settled, so waking on
|
|
1170
|
-
// every movement is correct and cheap. ANCESTORS too: a waiter's targets
|
|
1171
|
-
// now span the whole subtree, so a grandchild's movement must reach it.
|
|
1172
681
|
this.wakeAncestorWaiters(ac.parent);
|
|
1173
|
-
// report only a SETTLED child, never its birth. A freshly spawned
|
|
1174
|
-
// child is `idle` before `start()` runs, so that first idle->running flip
|
|
1175
|
-
// would tell the parent "@kid is now idle" the instant it existed.
|
|
1176
|
-
// Requiring a prior `running` is what filters it out; the wake above is
|
|
1177
|
-
// unconditional, so `wait_children` is unaffected.
|
|
1178
|
-
//
|
|
1179
|
-
// the wording addresses the CHILD's outcome, not the parent's goal.
|
|
1180
|
-
// "Continue your task, or finish()..." invited the parent to finish its own
|
|
1181
|
-
// goal over a child's status; the child's report already states what
|
|
1182
|
-
// happened, so this points at it instead.
|
|
1183
682
|
if (this.reportedRunning.has(agentId) && SETTLED.has(status))
|
|
1184
683
|
parent.enqueuePrompt(`[harness] Sub-agent @${agentId} is now ${status} without having called finish(), so it has not reported a result.` +
|
|
1185
684
|
(status === "error"
|
|
@@ -1192,24 +691,15 @@ export class Master {
|
|
|
1192
691
|
else if (SETTLED.has(status))
|
|
1193
692
|
this.reportedRunning.delete(agentId);
|
|
1194
693
|
};
|
|
1195
|
-
// sub-agent management hooks — only when this agent can legally spawn
|
|
1196
694
|
if (myDepth < this.maxSpawnDepth()) {
|
|
1197
695
|
const self = agent;
|
|
1198
696
|
const hooks = {
|
|
1199
697
|
depth: myDepth,
|
|
1200
698
|
spawn: (o) => this.spawnChildFor(self, o),
|
|
1201
|
-
list: () =>
|
|
1202
|
-
// DIRECT children are the ones this agent can address (stop/message are
|
|
1203
|
-
// refused for anything deeper), but a descendant still working is
|
|
1204
|
-
// invisible to the parent unless it is LISTED — and the parent then
|
|
1205
|
-
// resumes over it. So the whole subtree is listed, each row naming its
|
|
1206
|
-
// own `parent` and flagged `nested`, which is what tells the model who
|
|
1207
|
-
// to address instead of guessing a level (the error `message_agent`
|
|
1208
|
-
// raises names the owner for the same reason).
|
|
1209
|
-
this.subtreeOf(ac.id).flatMap(({ id, depth, parent }) => {
|
|
699
|
+
list: () => this.subtreeOf(ac.id).flatMap(({ id, depth, parent }) => {
|
|
1210
700
|
const inst = this.agents.get(id);
|
|
1211
701
|
if (!inst)
|
|
1212
|
-
return [];
|
|
702
|
+
return [];
|
|
1213
703
|
return [
|
|
1214
704
|
{
|
|
1215
705
|
id,
|
|
@@ -1222,11 +712,7 @@ export class Master {
|
|
|
1222
712
|
}),
|
|
1223
713
|
stop: (ids) => this.stopChildrenFor(ac.id, ids),
|
|
1224
714
|
message: (id, text) => this.messageChild(ac.id, id, text),
|
|
1225
|
-
wait: (ids, ms) =>
|
|
1226
|
-
// wire the parent's tool-abort signal: a user stop / dispose must
|
|
1227
|
-
// interrupt the park immediately instead of leaving the run chain
|
|
1228
|
-
// blocked until the full timeout (up to 1h)
|
|
1229
|
-
this.waitChildren(ac.id, ids, ms, self.toolCtx.signal),
|
|
715
|
+
wait: (ids, ms) => this.waitChildren(ac.id, ids, ms, self.toolCtx.signal),
|
|
1230
716
|
};
|
|
1231
717
|
agent.toolCtx.subAgents = hooks;
|
|
1232
718
|
}
|
|
@@ -1236,39 +722,23 @@ export class Master {
|
|
|
1236
722
|
this.config.agents.push(ac);
|
|
1237
723
|
this.saveConfig();
|
|
1238
724
|
}
|
|
1239
|
-
// Remember which session this agent is bound to, so a restart reattaches
|
|
1240
|
-
// deterministically instead of re-guessing from directory mtimes.
|
|
1241
|
-
// Done for EVERY agent (persisted or spawned): a spawned child is re-created
|
|
1242
|
-
// on boot too, and guessing there is the same resurrection bug.
|
|
1243
725
|
this.recordSession(ac, path.basename(sessionDir));
|
|
1244
726
|
return agent;
|
|
1245
727
|
}
|
|
1246
|
-
/** Persist an agent's current session id into the config, if it changed. */
|
|
1247
728
|
recordSession(ac, sessionId) {
|
|
1248
729
|
if (!sessionId || ac.session === sessionId)
|
|
1249
730
|
return;
|
|
1250
731
|
ac.session = sessionId;
|
|
1251
732
|
const entry = this.config.agents.find((a) => a.id === ac.id);
|
|
1252
733
|
if (!entry)
|
|
1253
|
-
return;
|
|
734
|
+
return;
|
|
1254
735
|
entry.session = sessionId;
|
|
1255
|
-
/**
|
|
1256
|
-
* A title names ONE session, so moving clears it.
|
|
1257
|
-
*
|
|
1258
|
-
* Leaving it would label the new session with the previous one's first prompt — the sidebar
|
|
1259
|
-
* would show a conversation name for a conversation it does not describe. It is re-derived on
|
|
1260
|
-
* the next prompt (see `ensureSessionTitle`).
|
|
1261
|
-
*/
|
|
1262
736
|
delete entry.sessionTitle;
|
|
1263
737
|
delete ac.sessionTitle;
|
|
1264
|
-
// never write session pointers into a config that is describing another
|
|
1265
|
-
// data dir — every one of them would be wrong, and the correct mapping is
|
|
1266
|
-
// destroyed. The in-memory session above still works for this process.
|
|
1267
738
|
if (this.sessionsReadOnly)
|
|
1268
739
|
return;
|
|
1269
740
|
this.saveConfig();
|
|
1270
741
|
}
|
|
1271
|
-
/** spawn-tree depth of an agent (0 = top level); unknown → 0 */
|
|
1272
742
|
depthOf(id, seen = new Set()) {
|
|
1273
743
|
const cap = this.maxSpawnDepth();
|
|
1274
744
|
let depth = 0;
|
|
@@ -1280,12 +750,10 @@ export class Master {
|
|
|
1280
750
|
}
|
|
1281
751
|
return depth;
|
|
1282
752
|
}
|
|
1283
|
-
/** configured nesting limit (config.maxSpawnDepth, default 3) */
|
|
1284
753
|
maxSpawnDepth() {
|
|
1285
754
|
const v = this.config.maxSpawnDepth;
|
|
1286
755
|
return typeof v === "number" && v >= 0 ? v : MAX_SPAWN_DEPTH;
|
|
1287
756
|
}
|
|
1288
|
-
/** direct children of an agent, with their Agent instances */
|
|
1289
757
|
childrenOf(id) {
|
|
1290
758
|
const kids = this.config.agents.filter((a) => a.parent === id);
|
|
1291
759
|
const out = [];
|
|
@@ -1296,19 +764,6 @@ export class Master {
|
|
|
1296
764
|
}
|
|
1297
765
|
return out;
|
|
1298
766
|
}
|
|
1299
|
-
/**
|
|
1300
|
-
* Every configured descendant of `id`, at any depth, with its depth (1 = direct
|
|
1301
|
-
* child) and its own parent id.
|
|
1302
|
-
*
|
|
1303
|
-
* A parent's work is not finished while anything it caused is still running,
|
|
1304
|
-
* and that includes a sub-agent's sub-agent: C can delegate to G and return its
|
|
1305
|
-
* own turn, leaving C idle while G works. Deciding from DIRECT children alone
|
|
1306
|
-
* therefore let an ancestor report "all settled" over a live grandchild — the
|
|
1307
|
-
* reported "it delegates further and the parent never knows".
|
|
1308
|
-
*
|
|
1309
|
-
* Returns configured ids (not instances) so a suspended descendant still counts
|
|
1310
|
-
* as part of the subtree. `seen` guards a cycle in `parent`.
|
|
1311
|
-
*/
|
|
1312
767
|
subtreeOf(id, depth = 1, seen = new Set()) {
|
|
1313
768
|
const out = [];
|
|
1314
769
|
for (const a of this.config.agents) {
|
|
@@ -1321,16 +776,9 @@ export class Master {
|
|
|
1321
776
|
}
|
|
1322
777
|
return out;
|
|
1323
778
|
}
|
|
1324
|
-
/** the ids of every descendant of `id`, at any depth */
|
|
1325
779
|
descendantsOf(id, seen = new Set()) {
|
|
1326
780
|
return this.subtreeOf(id, 1, seen).map((x) => x.id);
|
|
1327
781
|
}
|
|
1328
|
-
/**
|
|
1329
|
-
* Create a sub-agent on behalf of `parent`. context "fork" writes a
|
|
1330
|
-
* sub_fork header into the child log pointing at the parent's current
|
|
1331
|
-
* tip — the parent's history is NEVER copied into the child's file; the
|
|
1332
|
-
* child resolves it at restore time.
|
|
1333
|
-
*/
|
|
1334
782
|
async spawnChildFor(parent, o) {
|
|
1335
783
|
const parentId = parent.opts_id();
|
|
1336
784
|
const persona = o.persona && Object.prototype.hasOwnProperty.call(SUB_PERSONAS, o.persona)
|
|
@@ -1341,34 +789,14 @@ export class Master {
|
|
|
1341
789
|
let n = 2;
|
|
1342
790
|
while (this.agents.has(id) || this.config.agents.some((a) => a.id === id))
|
|
1343
791
|
id = `${base.slice(0, 56)}-${n++}`;
|
|
1344
|
-
// persona directives shape how the sub approaches the task
|
|
1345
792
|
const directive = persona ? `${SUB_PERSONAS[persona].label}\n${SUB_PERSONAS[persona].directive}\n\n` : "";
|
|
1346
793
|
const pcfg = this.config.agents.find((a) => a.id === parentId);
|
|
1347
794
|
let forkTip;
|
|
1348
795
|
let forkBranch;
|
|
1349
796
|
if (o.context === "fork") {
|
|
1350
|
-
// capture the fork tip BEFORE creating the child (parent keeps running)
|
|
1351
797
|
forkTip = parent.log.lastEventId(parent.currentBranch);
|
|
1352
798
|
forkBranch = parent.currentBranch;
|
|
1353
799
|
}
|
|
1354
|
-
// reasoning effort for the child.
|
|
1355
|
-
//
|
|
1356
|
-
// Precedence mirrors OpenAI Codex (codex-rs/core/src/agent/child_config.rs:
|
|
1357
|
-
// `requested.or_else(configured_default).or(parent)`):
|
|
1358
|
-
//
|
|
1359
|
-
// 1. the spawn argument — explicit, per-call
|
|
1360
|
-
// 2. `defaultSubagentReasoningEffort` from config — a global backstop
|
|
1361
|
-
// 3. the parent's effort — last resort
|
|
1362
|
-
//
|
|
1363
|
-
// Codex deliberately does NOT inherit by default, and its own test proves the
|
|
1364
|
-
// intent: parent at XHigh, spawn requests Low, and the child runs at Low
|
|
1365
|
-
// (codex-rs/core/tests/suite/subagent_notifications.rs). Sub-agents are
|
|
1366
|
-
// usually narrow and cheap, so inheriting a parent's `high` across twenty
|
|
1367
|
-
// trivial scouts multiplies cost and latency for no gain.
|
|
1368
|
-
//
|
|
1369
|
-
// Before this, `reasoningEffort` was simply omitted here while `provider`,
|
|
1370
|
-
// `model` and `contextWindowTokens` two lines below WERE inherited — so a
|
|
1371
|
-
// parent's effort vanished silently, with no way for a spawn to express one.
|
|
1372
800
|
const childEffort = o.reasoning_effort || this.config.defaultSubagentReasoningEffort || pcfg?.reasoningEffort;
|
|
1373
801
|
const child = await this.addAgent({
|
|
1374
802
|
id,
|
|
@@ -1376,8 +804,6 @@ export class Master {
|
|
|
1376
804
|
provider: pcfg?.provider,
|
|
1377
805
|
model: pcfg?.model,
|
|
1378
806
|
contextWindowTokens: pcfg?.contextWindowTokens,
|
|
1379
|
-
// persisted with the child, like every other inherited field, so a
|
|
1380
|
-
// restart restores the same effort rather than reverting to the default
|
|
1381
807
|
...(childEffort ? { reasoningEffort: childEffort } : {}),
|
|
1382
808
|
parent: parentId,
|
|
1383
809
|
...(persona && SUB_PERSONAS[persona]?.readOnly ? { readOnly: true } : {}),
|
|
@@ -1389,15 +815,9 @@ export class Master {
|
|
|
1389
815
|
parentBranch: forkBranch,
|
|
1390
816
|
upToEvent: forkTip,
|
|
1391
817
|
});
|
|
1392
|
-
// the inherited prefix becomes visible history for the child's loop
|
|
1393
818
|
child.importMessages(parent.exportMessages());
|
|
1394
819
|
}
|
|
1395
820
|
await child.setGoal(`${directive}${o.task}`.slice(0, 2000));
|
|
1396
|
-
// SCOPE GUARD: a forked child inherits the parent's whole conversation as
|
|
1397
|
-
// reference context — without an explicit boundary, models have been seen
|
|
1398
|
-
// "continuing" some EARLIER task that appears in that history instead of
|
|
1399
|
-
// the task they were actually spawned for. State the boundary twice
|
|
1400
|
-
// (goal + first prompt) so it survives goal-driven auto-continue.
|
|
1401
821
|
await child.enqueuePrompt((o.context === "fork"
|
|
1402
822
|
? `[harness] You are sub-agent ${id}, spawned by @${parentId}.
|
|
1403
823
|
|
|
@@ -1416,68 +836,28 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1416
836
|
console.log(`[teapot] sub-agent ${id} spawned by ${parentId} (context: ${o.context ?? "none"}${persona ? `, persona: ${persona}` : ""})`);
|
|
1417
837
|
return { id };
|
|
1418
838
|
}
|
|
1419
|
-
/** Stop direct children (and their descendants by default) of an agent. */
|
|
1420
839
|
async stopChildrenFor(parentId, ids) {
|
|
1421
840
|
const stopped = [];
|
|
1422
|
-
// `inScope` is true once we are INSIDE a subtree we were asked to stop, and
|
|
1423
|
-
// stays true all the way down.
|
|
1424
|
-
//
|
|
1425
|
-
// the old walk passed `ids` down unchanged, so after matching an
|
|
1426
|
-
// explicit id every DESCENDANT failed `ids.includes(id)` and was skipped —
|
|
1427
|
-
// which is why the explicit branch hand-rolled its own one-level loop, and
|
|
1428
|
-
// why that loop could not reach a great-grandchild.
|
|
1429
841
|
const walk = (pid, inScope) => {
|
|
1430
842
|
for (const { id, agent } of this.childrenOf(pid)) {
|
|
1431
843
|
const take = inScope || !ids || ids.includes(id);
|
|
1432
844
|
if (!take) {
|
|
1433
|
-
// out of scope, but its own subtree may hold something in scope
|
|
1434
845
|
walk(id, false);
|
|
1435
846
|
continue;
|
|
1436
847
|
}
|
|
1437
848
|
agent.stop("stopped by parent agent");
|
|
1438
849
|
stopped.push(id);
|
|
1439
|
-
// RECURSE for both modes.
|
|
1440
|
-
//
|
|
1441
|
-
// The explicit-id branch used to iterate `childrenOf(id)` exactly once, so
|
|
1442
|
-
// a great-grandchild was never reached — stopping a child left its
|
|
1443
|
-
// grandchild's child running, indefinitely, still holding a model
|
|
1444
|
-
// connection and billing tokens:
|
|
1445
|
-
//
|
|
1446
|
-
// root-sub stopped
|
|
1447
|
-
// root-sub-sub stopped
|
|
1448
|
-
// root-sub-sub-sub running <-- orphaned
|
|
1449
|
-
//
|
|
1450
|
-
// `walk` already means "this subtree", and it stops every node it visits,
|
|
1451
|
-
// so one code path serves both cases. The only difference between the two
|
|
1452
|
-
// modes is the `ids && !ids.includes(id)` test at the top, which decides
|
|
1453
|
-
// whether a node is in scope — not how deep the walk goes.
|
|
1454
850
|
walk(id, true);
|
|
1455
851
|
}
|
|
1456
852
|
};
|
|
1457
853
|
walk(parentId, false);
|
|
1458
854
|
return { stopped };
|
|
1459
855
|
}
|
|
1460
|
-
/** Deliver a parent's message into a child's mailbox (and wake it). */
|
|
1461
|
-
/**
|
|
1462
|
-
* Event-driven parking for a parent waiting on sub-agents: resolves as soon
|
|
1463
|
-
* as ANY listed child settles (finish/error/stop/waiting) — zero API cost
|
|
1464
|
-
* while parked. Falls back to a timeout so waits can never hang forever.
|
|
1465
|
-
*/
|
|
1466
856
|
waitChildren(parentId, ids, timeoutMs, signal) {
|
|
1467
|
-
// The whole DESCENDANT subtree, not just direct children: C can delegate to G
|
|
1468
|
-
// and return its own turn, so C reads idle while G works. Waiting on C must
|
|
1469
|
-
// therefore mean "C and everything C caused" — otherwise an ancestor resumes
|
|
1470
|
-
// over live work it cannot see (see descendantsOf).
|
|
1471
857
|
const named = ids && ids.length ? ids : this.descendantsOf(parentId);
|
|
1472
858
|
const targets = [...new Set(named.flatMap((id) => [id, ...this.descendantsOf(id)]))].filter((id) => this.agents.has(id));
|
|
1473
859
|
if (targets.length === 0)
|
|
1474
860
|
return Promise.resolve({ note: "no live sub-agents to wait for" });
|
|
1475
|
-
// isBusy(), not the raw status: parkForTool overwrites a parked child's
|
|
1476
|
-
// status to "idle" (spinner suppression) while its loop is very much
|
|
1477
|
-
// alive — the raw check counted that child as settled and let the parent
|
|
1478
|
-
// resume over unfinished work. isBusy() sees parked tools, compactions
|
|
1479
|
-
// and an open ask_user; queued prompts are DORMANT (isLive()'s "stop vs
|
|
1480
|
-
// start" extra) and must not hold the wait. A stop request reads settled.
|
|
1481
861
|
const active = () => targets.filter((id) => this.agents.get(id)?.isBusy());
|
|
1482
862
|
const first = active();
|
|
1483
863
|
if (first.length === 0)
|
|
@@ -1502,7 +882,7 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1502
882
|
const wake = (_note) => {
|
|
1503
883
|
const left = active();
|
|
1504
884
|
if (left.length > 0)
|
|
1505
|
-
return;
|
|
885
|
+
return;
|
|
1506
886
|
clearTimeout(timer);
|
|
1507
887
|
finish(`all sub-agents settled: ${targets.join(", ")}`);
|
|
1508
888
|
};
|
|
@@ -1510,12 +890,10 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1510
890
|
if (!this.childWaiters.has(parentId))
|
|
1511
891
|
this.childWaiters.set(parentId, set);
|
|
1512
892
|
set.add(wake);
|
|
1513
|
-
// re-check immediately: children may have settled between snapshot & register
|
|
1514
893
|
if (active().length === 0)
|
|
1515
894
|
wake();
|
|
1516
895
|
});
|
|
1517
896
|
}
|
|
1518
|
-
/** wake every waiter of a parent (child lifecycle event happened) */
|
|
1519
897
|
wakeParentWaiters(parentId) {
|
|
1520
898
|
const set = this.childWaiters.get(parentId);
|
|
1521
899
|
if (!set)
|
|
@@ -1524,22 +902,9 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1524
902
|
try {
|
|
1525
903
|
fn("child event");
|
|
1526
904
|
}
|
|
1527
|
-
catch {
|
|
1528
|
-
}
|
|
1529
|
-
}
|
|
1530
|
-
/**
|
|
1531
|
-
* Wake the waiters of EVERY ancestor of an agent, not just its direct parent.
|
|
1532
|
-
*
|
|
1533
|
-
* A parked `wait_children` is woken by lifecycle events, and its targets now
|
|
1534
|
-
* span the whole subtree — so a grandchild settling has to reach the
|
|
1535
|
-
* grandparent, or the wake never arrives and the ancestor sits until its
|
|
1536
|
-
* timeout even though the work finished.
|
|
1537
|
-
*
|
|
1538
|
-
* `parentId` is passed in rather than looked up: the caller already has it, and
|
|
1539
|
-
* a spawned child that is not PERSISTED (a test, or a transient spawn) is absent
|
|
1540
|
-
* from `config.agents`, so a lookup would find no parent at all and the wake
|
|
1541
|
-
* would be silently skipped.
|
|
1542
|
-
*/
|
|
905
|
+
catch { }
|
|
906
|
+
}
|
|
907
|
+
}
|
|
1543
908
|
wakeAncestorWaiters(parentId) {
|
|
1544
909
|
const seen = new Set();
|
|
1545
910
|
let cur = parentId;
|
|
@@ -1550,24 +915,10 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1550
915
|
}
|
|
1551
916
|
}
|
|
1552
917
|
async messageChild(parentId, childId, text) {
|
|
1553
|
-
// childId is normally the live agent id — but an internal session id
|
|
1554
|
-
// (sessions/<id>, the stable timeline handle) also resolves, so callers
|
|
1555
|
-
// holding only a session reference can still reach the owning agent.
|
|
1556
918
|
let targetId = childId;
|
|
1557
919
|
const owned = this.sessionById(childId);
|
|
1558
920
|
if (owned)
|
|
1559
921
|
targetId = owned.agentId;
|
|
1560
|
-
// a sub-agent must be able to message its OWN PARENT.
|
|
1561
|
-
//
|
|
1562
|
-
// The rule below only ever permitted parent -> child, so a child asking its
|
|
1563
|
-
// parent a question was refused with the actively misleading
|
|
1564
|
-
//
|
|
1565
|
-
// not your sub-agent: p1. no such sub-agent exists.
|
|
1566
|
-
//
|
|
1567
|
-
// — naming the parent as nonexistent, when it is the caller's own parent and
|
|
1568
|
-
// plainly listed in config.agents. The parent link is the trust boundary: a
|
|
1569
|
-
// child may address its parent, a parent may address its children, and
|
|
1570
|
-
// NOTHING may address a sibling or an unrelated agent.
|
|
1571
922
|
const caller = this.config.agents.find((a) => a.id === parentId);
|
|
1572
923
|
const target = this.config.agents.find((a) => a.id === targetId);
|
|
1573
924
|
const childToParent = target?.parent === parentId;
|
|
@@ -1575,7 +926,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1575
926
|
const p = this.agents.get(targetId);
|
|
1576
927
|
if (!p)
|
|
1577
928
|
throw new Error(`sub-agent not running: ${targetId}`);
|
|
1578
|
-
// mirrored wording, so the recipient knows who is writing
|
|
1579
929
|
p.enqueuePrompt(`[harness] Message from sub-agent @${parentId}:\n\n${text}`, "harness");
|
|
1580
930
|
if (p.status !== "running")
|
|
1581
931
|
p.start(`message from sub-agent ${parentId}`);
|
|
@@ -1583,10 +933,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1583
933
|
}
|
|
1584
934
|
const cfg = target;
|
|
1585
935
|
if (!cfg || (!childToParent && cfg.parent !== parentId)) {
|
|
1586
|
-
// Scoping is correct — only the DIRECT parent may message a child, so a
|
|
1587
|
-
// grandchild is rightly refused. But "not your sub-agent: <id>" reads
|
|
1588
|
-
// like a bug when the agent simply guessed the wrong level of the tree.
|
|
1589
|
-
// Name the real owner so the model can re-address the message.
|
|
1590
936
|
const actual = cfg?.parent;
|
|
1591
937
|
const live = actual ? this.agents.get(actual) : undefined;
|
|
1592
938
|
const detail = actual
|
|
@@ -1601,96 +947,45 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1601
947
|
if (child.status !== "running")
|
|
1602
948
|
child.start(`message from ${parentId}`);
|
|
1603
949
|
}
|
|
1604
|
-
/**
|
|
1605
|
-
* Mirror interesting child events into the parent's timeline (tagged with
|
|
1606
|
-
* the acting sub id) and forward terminal outcomes to the parent's mailbox.
|
|
1607
|
-
*/
|
|
1608
950
|
onChildEvent(ac, e) {
|
|
1609
951
|
const parentId = ac.parent;
|
|
1610
952
|
if (!parentId)
|
|
1611
953
|
return;
|
|
1612
|
-
// any child lifecycle movement wakes a parked wait_children (the waiter
|
|
1613
|
-
// re-checks whether its targets are all settled) — ancestors included, since
|
|
1614
|
-
// a waiter's targets span its whole subtree
|
|
1615
954
|
this.wakeAncestorWaiters(parentId);
|
|
1616
955
|
const parent = this.agents.get(parentId);
|
|
1617
956
|
if (!parent)
|
|
1618
957
|
return;
|
|
1619
|
-
// forward outcomes so the parent hears the result without polling
|
|
1620
958
|
if (e.type === "message" && e.data.final) {
|
|
1621
959
|
const d = e.data;
|
|
1622
960
|
const summary = String(d.content ?? "").trim();
|
|
1623
|
-
// the finish() summary alone is often thin — the agent's last few real
|
|
1624
|
-
// conversation turns (findings, paths, numbers) carry the substance.
|
|
1625
|
-
// Include them so the parent can act without re-reading the child log.
|
|
1626
961
|
let report = summary;
|
|
1627
962
|
if (Array.isArray(d.recentContext) && d.recentContext.length) {
|
|
1628
963
|
report +=
|
|
1629
964
|
"\n\n[Last conversation turns before finishing]\n" +
|
|
1630
965
|
d.recentContext.map((r) => r).join("\n\n");
|
|
1631
966
|
}
|
|
1632
|
-
parent.enqueuePrompt(
|
|
1633
|
-
// NO truncation: the sub's report is the parent's primary input for
|
|
1634
|
-
// deciding what to do next. Clipping it (the old 2000/6000 caps)
|
|
1635
|
-
// silently dropped exactly the details the operator spawned the sub
|
|
1636
|
-
// to gather. Context management is compaction's job, not the pipe's.
|
|
1637
|
-
`[harness] Sub-agent ${ac.id} finished. Final report:\n${report || "(no summary)"}`, "harness");
|
|
1638
|
-
// a parent's report is delivered INTO a live agent — queueing it is
|
|
1639
|
-
// not enough. If the parent is idle (finished, or parked) the report sat in
|
|
1640
|
-
// the queue until something else started the loop, so the operator saw a
|
|
1641
|
-
// finished sub-agent and a parent that never reacted.
|
|
967
|
+
parent.enqueuePrompt(`[harness] Sub-agent ${ac.id} finished. Final report:\n${report || "(no summary)"}`, "harness");
|
|
1642
968
|
if (parent.status !== "running")
|
|
1643
969
|
parent.start(`sub-agent ${ac.id} finished`);
|
|
1644
970
|
}
|
|
1645
971
|
else if (e.type === "error") {
|
|
1646
972
|
parent.enqueuePrompt(`[harness] Sub-agent ${ac.id} hit an error: ${String(e.data.message ?? "").slice(0, 500)}`, "harness");
|
|
1647
|
-
// same for an error report
|
|
1648
973
|
if (parent.status !== "running")
|
|
1649
974
|
parent.start(`sub-agent ${ac.id} error`);
|
|
1650
975
|
}
|
|
1651
|
-
|
|
1652
|
-
// bought nothing — `sub` rows are display-only (`rebuildMessagesFrom` never
|
|
1653
|
-
// read them, so the model never saw them) and once they DID render the
|
|
1654
|
-
// parent's timeline was flooded with a child's transcript. What the parent
|
|
1655
|
-
// needs is the child's report, which already arrives as a harness prompt
|
|
1656
|
-
// above. A chat's timeline is its own.
|
|
1657
|
-
}
|
|
1658
|
-
/** sessions root + helpers */
|
|
1659
|
-
/**
|
|
1660
|
-
* Where session directories live.
|
|
1661
|
-
*
|
|
1662
|
-
* Public: it is the layout contract every part of the harness depends on, and a test
|
|
1663
|
-
* that plants a directory (a foreign manifest, an impostor owner) needs the same path
|
|
1664
|
-
* the master resolves rather than a re-derivation that can drift from it.
|
|
1665
|
-
*/
|
|
976
|
+
}
|
|
1666
977
|
sessionsRoot() {
|
|
1667
978
|
return path.join(this.config.dataDir, "sessions");
|
|
1668
979
|
}
|
|
1669
|
-
/**
|
|
1670
|
-
* Session-dir ownership manifest (sessions/<dir>/session.json). Ownership
|
|
1671
|
-
* used to be GUESSED from the directory name's prefix, which is ambiguous:
|
|
1672
|
-
* agent "teapot" matches "teapot-3-<hash>", and sibling ids where one is a
|
|
1673
|
-
* prefix of the other ("prismrv-root-sub-review" vs
|
|
1674
|
-
* "…-review-kernel") excluded each other's own dirs — every restart then
|
|
1675
|
-
* generated a fresh empty session and history read as zero. The owner is
|
|
1676
|
-
* now recorded explicitly at creation time; legacy dirs without a manifest
|
|
1677
|
-
* fall back to longest-prefix inference and get one backfilled on first
|
|
1678
|
-
* touch.
|
|
1679
|
-
*/
|
|
1680
980
|
readSessionOwner(dirName) {
|
|
1681
981
|
try {
|
|
1682
982
|
const raw = JSON.parse(readFileSync(path.join(this.sessionsRoot(), dirName, "session.json"), "utf8"));
|
|
1683
983
|
return typeof raw.agentId === "string" ? raw.agentId : null;
|
|
1684
984
|
}
|
|
1685
985
|
catch {
|
|
1686
|
-
return null;
|
|
986
|
+
return null;
|
|
1687
987
|
}
|
|
1688
988
|
}
|
|
1689
|
-
/**
|
|
1690
|
-
* Owner of a session dir — manifest first; legacy dirs are inferred from
|
|
1691
|
-
* the LONGEST configured/live id whose "<id>" or "<id>-<rest>" shape the
|
|
1692
|
-
* name matches. Returns {owner, hadManifest} so callers can backfill.
|
|
1693
|
-
*/
|
|
1694
989
|
inferSessionOwner(dirName, candidateIds) {
|
|
1695
990
|
const fromManifest = this.readSessionOwner(dirName);
|
|
1696
991
|
if (fromManifest)
|
|
@@ -1704,37 +999,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1704
999
|
}
|
|
1705
1000
|
return { owner: best?.id ?? null, hadManifest: false };
|
|
1706
1001
|
}
|
|
1707
|
-
/**
|
|
1708
|
-
* Who does this session directory really belong to, according to its LOG?
|
|
1709
|
-
*
|
|
1710
|
-
* The manifest is a CACHE; the log is the truth. Every event carries the
|
|
1711
|
-
* `agent` that wrote it, and only that agent ever appends — so the first
|
|
1712
|
-
* event's value cannot be stale or wrong.
|
|
1713
|
-
*
|
|
1714
|
-
* This matters because ownership decided from the manifest ALONE is how a
|
|
1715
|
-
* whole data directory gets abandoned. A manifest that is missing, unreadable,
|
|
1716
|
-
* or simply wrong made `inferSessionOwner` reject every directory the agent
|
|
1717
|
-
* owned, `findLatestSessionDir` return null, and the boot mint a fresh empty
|
|
1718
|
-
* session — REWRITING the agent's pointer in config.json. Nothing warned, and
|
|
1719
|
-
* the real history was left on disk, orphaned and unreachable.
|
|
1720
|
-
*
|
|
1721
|
-
* manifests: 493 agents were silently moved to empty sessions, and the
|
|
1722
|
-
* mapping back was only recoverable from these `agent` fields.
|
|
1723
|
-
*/
|
|
1724
|
-
/**
|
|
1725
|
-
* The owning agent, from the log's FIRST line.
|
|
1726
|
-
*
|
|
1727
|
-
* Read with a bounded `readSync` rather than `readFileSync(...).split("\n").find(...)`.
|
|
1728
|
-
*
|
|
1729
|
-
* MEASURED on this machine: 831 session logs totalling 471.8MB, the largest 50.6MB, nine of them
|
|
1730
|
-
* over 10MB. `readFileSync` decoded a whole log to reach its first line, and `split("\n")`
|
|
1731
|
-
* allocated an array of every line in it — for ONE field of the first event. Called once per
|
|
1732
|
-
* directory while resolving sessions, that is tens of megabytes of transient allocation per pass,
|
|
1733
|
-
* which is the baseline this boot carried.
|
|
1734
|
-
*
|
|
1735
|
-
* The first line is the first event (the log is append-only, in order), so a single small read
|
|
1736
|
-
* answers the question.
|
|
1737
|
-
*/
|
|
1738
1002
|
logOwner(dirName) {
|
|
1739
1003
|
let fd;
|
|
1740
1004
|
try {
|
|
@@ -1743,7 +1007,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1743
1007
|
const n = readSync(fd, buf, 0, buf.length, 0);
|
|
1744
1008
|
if (n <= 0)
|
|
1745
1009
|
return null;
|
|
1746
|
-
// a torn final line cannot be the one we want: take the first COMPLETE line
|
|
1747
1010
|
const head = buf.subarray(0, n).toString("utf8");
|
|
1748
1011
|
const nl = head.indexOf("\n");
|
|
1749
1012
|
const line = (nl >= 0 ? head.slice(0, nl) : head).trim();
|
|
@@ -1753,7 +1016,7 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1753
1016
|
return typeof parsed.agent === "string" && parsed.agent ? parsed.agent : null;
|
|
1754
1017
|
}
|
|
1755
1018
|
catch {
|
|
1756
|
-
return null;
|
|
1019
|
+
return null;
|
|
1757
1020
|
}
|
|
1758
1021
|
finally {
|
|
1759
1022
|
if (fd !== undefined) {
|
|
@@ -1761,39 +1024,17 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1761
1024
|
closeSync(fd);
|
|
1762
1025
|
}
|
|
1763
1026
|
catch {
|
|
1764
|
-
/* already closed */
|
|
1765
1027
|
}
|
|
1766
1028
|
}
|
|
1767
1029
|
}
|
|
1768
1030
|
}
|
|
1769
|
-
/**
|
|
1770
|
-
* A human title for a session dir, from its first user prompt, or null.
|
|
1771
|
-
*
|
|
1772
|
-
* Reads in bounded chunks and stops at the FIRST prompt event of any source —
|
|
1773
|
-
* see the note in allSessions() for why that early exit is what makes this
|
|
1774
|
-
* affordable. A torn final line is skipped rather than parsed: a chunk boundary
|
|
1775
|
-
* can cut a JSON object in half, and that must not abort the scan.
|
|
1776
|
-
*/
|
|
1777
|
-
/**
|
|
1778
|
-
* The stored title for a session, if the agent's config entry names THAT session.
|
|
1779
|
-
*
|
|
1780
|
-
* A single config lookup — no filesystem access — which is the point: this runs once per session
|
|
1781
|
-
* per list, and the log scan it replaced dominated the cost of listing.
|
|
1782
|
-
*/
|
|
1783
1031
|
titleFor(agentId, sessionId) {
|
|
1784
1032
|
const a = this.config.agents.find((x) => x.id === agentId);
|
|
1785
1033
|
if (!a || a.session !== sessionId)
|
|
1786
|
-
return undefined;
|
|
1034
|
+
return undefined;
|
|
1787
1035
|
const t = typeof a.sessionTitle === "string" ? a.sessionTitle.trim() : "";
|
|
1788
1036
|
return t ? t : undefined;
|
|
1789
1037
|
}
|
|
1790
|
-
/**
|
|
1791
|
-
* Derive and STORE a title for the agent's current session, once.
|
|
1792
|
-
*
|
|
1793
|
-
* Called when a session starts producing prompts, so the cost is paid per session rather than per
|
|
1794
|
-
* list. Idempotent: an existing title is never overwritten, which is also what will let the
|
|
1795
|
-
* operator rename a session by hand — a derived value must not clobber a chosen one.
|
|
1796
|
-
*/
|
|
1797
1038
|
async ensureSessionTitle(agentId, sessionId) {
|
|
1798
1039
|
const a = this.config.agents.find((x) => x.id === agentId);
|
|
1799
1040
|
if (!a || a.session !== sessionId)
|
|
@@ -1801,8 +1042,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1801
1042
|
if (a.sessionTitle?.trim())
|
|
1802
1043
|
return;
|
|
1803
1044
|
const dir = path.join(this.sessionsRoot(), sessionId);
|
|
1804
|
-
// the session id when nothing is derivable — see the migration: it never changes, so this
|
|
1805
|
-
// cannot leave the field unset and have the derivation re-run on every boot
|
|
1806
1045
|
a.sessionTitle = this.sessionTitleOf(dir) ?? sessionId;
|
|
1807
1046
|
this.saveConfig();
|
|
1808
1047
|
}
|
|
@@ -1811,7 +1050,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1811
1050
|
try {
|
|
1812
1051
|
fd = openSync(path.join(dirPath, "chat.jsonl"), "r");
|
|
1813
1052
|
const CHUNK = 16 * 1024;
|
|
1814
|
-
// cap per session so one enormous log cannot dominate boot
|
|
1815
1053
|
const CAP = 256 * 1024;
|
|
1816
1054
|
const buf = Buffer.alloc(CHUNK);
|
|
1817
1055
|
let offset = 0;
|
|
@@ -1831,12 +1069,10 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1831
1069
|
e = JSON.parse(l);
|
|
1832
1070
|
}
|
|
1833
1071
|
catch {
|
|
1834
|
-
continue;
|
|
1072
|
+
continue;
|
|
1835
1073
|
}
|
|
1836
1074
|
if (e.type !== "prompt")
|
|
1837
1075
|
continue;
|
|
1838
|
-
// a sub-agent session receives only harness prompts and is labelled from
|
|
1839
|
-
// its id, so a harness prompt is not a title
|
|
1840
1076
|
if (e.data?.source !== "user")
|
|
1841
1077
|
return null;
|
|
1842
1078
|
return sessionTitleFrom(typeof e.data.text === "string" ? e.data.text : null);
|
|
@@ -1845,7 +1081,7 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1845
1081
|
return null;
|
|
1846
1082
|
}
|
|
1847
1083
|
catch {
|
|
1848
|
-
return null;
|
|
1084
|
+
return null;
|
|
1849
1085
|
}
|
|
1850
1086
|
finally {
|
|
1851
1087
|
if (fd !== undefined)
|
|
@@ -1853,7 +1089,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1853
1089
|
closeSync(fd);
|
|
1854
1090
|
}
|
|
1855
1091
|
catch {
|
|
1856
|
-
/* already closed */
|
|
1857
1092
|
}
|
|
1858
1093
|
}
|
|
1859
1094
|
}
|
|
@@ -1861,31 +1096,22 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1861
1096
|
try {
|
|
1862
1097
|
const file = path.join(dirPath, "session.json");
|
|
1863
1098
|
if (existsSync(file)) {
|
|
1864
|
-
// A WRONG manifest must be repairable, or the mistake is permanent: the
|
|
1865
|
-
// old code returned here unconditionally, so one bad value kept every
|
|
1866
|
-
// later boot from recognising the directory (see logOwner()).
|
|
1867
1099
|
const current = this.readSessionOwner(path.basename(dirPath));
|
|
1868
1100
|
if (current === agentId)
|
|
1869
1101
|
return;
|
|
1870
1102
|
const proven = this.logOwner(path.basename(dirPath));
|
|
1871
1103
|
if (proven !== agentId)
|
|
1872
|
-
return;
|
|
1104
|
+
return;
|
|
1873
1105
|
console.log(`[teapot] repairing session manifest for ${path.basename(dirPath)}: ` +
|
|
1874
1106
|
`${JSON.stringify(current)} → ${JSON.stringify(agentId)} (the log says ${JSON.stringify(agentId)})`);
|
|
1875
1107
|
}
|
|
1876
1108
|
writeFileSync(file, `${JSON.stringify({ v: 1, agentId }, null, 2)}\n`);
|
|
1877
1109
|
}
|
|
1878
1110
|
catch {
|
|
1879
|
-
/* best-effort metadata */
|
|
1880
1111
|
}
|
|
1881
1112
|
}
|
|
1882
1113
|
findLatestSessionDir(agentId) {
|
|
1883
|
-
|
|
1884
|
-
// "teapot-3-<hash>", and sibling ids where one is a prefix of the other
|
|
1885
|
-
// ("prismrv-root-sub-review" vs "…-review-kernel") used to exclude each
|
|
1886
|
-
// other's own dirs. Ownership comes from the manifest; legacy dirs are
|
|
1887
|
-
// inferred by longest matching id.
|
|
1888
|
-
const candidateIds = new Set([agentId]); // the agent itself owns its bare-name dir
|
|
1114
|
+
const candidateIds = new Set([agentId]);
|
|
1889
1115
|
for (const a of this.config.agents)
|
|
1890
1116
|
if (a.id)
|
|
1891
1117
|
candidateIds.add(a.id);
|
|
@@ -1897,10 +1123,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1897
1123
|
const empty = [];
|
|
1898
1124
|
for (const d of readdirSync(this.sessionsRoot())) {
|
|
1899
1125
|
const { owner } = this.inferSessionOwner(d, candidateIds);
|
|
1900
|
-
// A wrong manifest must not hide a directory: consult the LOG before
|
|
1901
|
-
// rejecting. Without this, one bad manifest value made every dir the
|
|
1902
|
-
// agent owned invisible here, so the caller minted a fresh session and
|
|
1903
|
-
// rewrote config — silently orphaning the real history.
|
|
1904
1126
|
const mineByLog = owner !== agentId && this.logOwner(d) === agentId;
|
|
1905
1127
|
if (owner !== agentId && !mineByLog)
|
|
1906
1128
|
continue;
|
|
@@ -1908,20 +1130,14 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1908
1130
|
this.writeSessionManifest(path.join(this.sessionsRoot(), d), agentId);
|
|
1909
1131
|
const dirPath = path.join(this.sessionsRoot(), d);
|
|
1910
1132
|
const m = this.sessionMtime(dirPath);
|
|
1911
|
-
// an EMPTY chat.jsonl means the session was never really used — but it
|
|
1912
|
-
// must not SHADOW a real history dir: an incarnation's state events
|
|
1913
|
-
// keep refreshing the empty log's mtime, and newest-wins would then
|
|
1914
|
-
// pick the empty one forever ("timeline of zero" on every restart).
|
|
1915
1133
|
let size = 0;
|
|
1916
1134
|
try {
|
|
1917
1135
|
size = statSync(path.join(dirPath, "chat.jsonl")).size;
|
|
1918
1136
|
}
|
|
1919
|
-
catch {
|
|
1137
|
+
catch { }
|
|
1920
1138
|
(size > 0 ? withHistory : empty).push({ d, m });
|
|
1921
1139
|
}
|
|
1922
1140
|
const pick = (list) => list.sort((a, b) => b.m - a.m)[0]?.d;
|
|
1923
|
-
// prefer a dir that actually carries history; fall back to the newest
|
|
1924
|
-
// empty one ONLY when no history exists under this id at all
|
|
1925
1141
|
dirs = [pick(withHistory) ?? pick(empty)].filter((x) => !!x);
|
|
1926
1142
|
}
|
|
1927
1143
|
catch {
|
|
@@ -1940,54 +1156,34 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1940
1156
|
resolveSessionDir(agentId, fresh) {
|
|
1941
1157
|
const root = this.sessionsRoot();
|
|
1942
1158
|
mkdirSync(root, { recursive: true });
|
|
1943
|
-
// the ≤0.5.0 flat-layout migration runs once at boot, in src/migrations/ — doing it here meant
|
|
1944
|
-
// it ran per agent and could interleave with another agent's resolution
|
|
1945
|
-
// Prefer the recorded session: the config says exactly which timeline this
|
|
1946
|
-
// agent was on. Guessing by mtime let a stale-or-deleted dir whose
|
|
1947
|
-
// chat.jsonl happened to be newer win, handing the agent a timeline the
|
|
1948
|
-
// operator had moved off — the "deleted session comes back" report.
|
|
1949
1159
|
const recorded = this.config.agents.find((a) => a.id === agentId)?.session;
|
|
1950
1160
|
if (!fresh && recorded) {
|
|
1951
1161
|
const dir = path.join(root, recorded);
|
|
1952
|
-
// Trust it when it exists and is really THIS agent's dir. Ownership comes
|
|
1953
|
-
// from the manifest, but the LOG is consulted too: a wrong/absent manifest
|
|
1954
|
-
// must not make teapot abandon history it can plainly see (see logOwner).
|
|
1955
1162
|
const owner = this.readSessionOwner(recorded);
|
|
1956
1163
|
const mineByManifest = owner === null || owner === agentId;
|
|
1957
1164
|
const mineByLog = owner !== agentId && this.logOwner(recorded) === agentId;
|
|
1958
1165
|
if (existsSync(dir) && (mineByManifest || mineByLog)) {
|
|
1959
1166
|
if (mineByLog)
|
|
1960
|
-
this.writeSessionManifest(dir, agentId);
|
|
1167
|
+
this.writeSessionManifest(dir, agentId);
|
|
1961
1168
|
return dir;
|
|
1962
1169
|
}
|
|
1963
|
-
// the recorded dir is gone, or provably belongs to someone else — fall
|
|
1964
|
-
// through and mint a new one. Say WHICH, and do not do it quietly: this
|
|
1965
|
-
// line is the only trace that an agent changed session.
|
|
1966
1170
|
console.log(`[teapot] session ${recorded} for ${agentId} is unusable ` +
|
|
1967
1171
|
`(${existsSync(dir) ? `manifest owner ${JSON.stringify(owner)}` : "directory missing"}); ` +
|
|
1968
1172
|
`starting a new one`);
|
|
1969
1173
|
}
|
|
1970
|
-
// Prefer the LATEST session directory that actually carries history.
|
|
1971
|
-
// This handles the case where a bare-name directory (e.g. from legacy
|
|
1972
|
-
// migration) would otherwise shadow a newer UUID-suffixed incarnation
|
|
1973
|
-
// (the "example-session-2 timeline empty" bug).
|
|
1974
1174
|
if (!fresh) {
|
|
1975
1175
|
const existing = this.findLatestSessionDir(agentId);
|
|
1976
1176
|
if (existing)
|
|
1977
|
-
return existing;
|
|
1177
|
+
return existing;
|
|
1978
1178
|
}
|
|
1979
|
-
// exact-restart match: bare <agentId> directory — only if no history-carrying
|
|
1980
|
-
// UUID-suffixed directory was found above. This covers the very first run
|
|
1981
|
-
// before any UUID-suffixed dir exists, and legacy flat migrations.
|
|
1982
1179
|
const selfDir = path.join(root, agentId);
|
|
1983
1180
|
const selfOwner = this.readSessionOwner(agentId);
|
|
1984
1181
|
const selfUsable = selfOwner === null || selfOwner === agentId;
|
|
1985
1182
|
if (!fresh &&
|
|
1986
1183
|
selfUsable &&
|
|
1987
1184
|
statSync(path.join(selfDir, "chat.jsonl"), { throwIfNoEntry: false })?.size) {
|
|
1988
|
-
return selfDir;
|
|
1185
|
+
return selfDir;
|
|
1989
1186
|
}
|
|
1990
|
-
// fresh incarnation: guaranteed-unique directory
|
|
1991
1187
|
let sid = "";
|
|
1992
1188
|
do {
|
|
1993
1189
|
sid = `${agentId}-${randomUUID().slice(0, 8)}`;
|
|
@@ -1995,18 +1191,10 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1995
1191
|
this.writeSessionManifest(path.join(root, sid), agentId);
|
|
1996
1192
|
return path.join(root, sid);
|
|
1997
1193
|
}
|
|
1998
|
-
/**
|
|
1999
|
-
* All session dirs on disk with their inferred/manifested owner, newest
|
|
2000
|
-
* log first. One readdir powers every "list sessions" need — the UI calls
|
|
2001
|
-
* the aggregated endpoint instead of fanning out one request per agent.
|
|
2002
|
-
*/
|
|
2003
1194
|
allSessions() {
|
|
2004
1195
|
const candidateIds = new Set(this.config.agents.map((a) => a.id).filter(Boolean));
|
|
2005
1196
|
for (const [liveId] of this.agents)
|
|
2006
1197
|
candidateIds.add(liveId);
|
|
2007
|
-
// dir names themselves are candidate owners too: "s" owns legacy "s",
|
|
2008
|
-
// "teapot-3-3c0048d7" is best matched by the longest configured id —
|
|
2009
|
-
// inference needs the id set only, so nothing extra to add here.
|
|
2010
1198
|
let names = [];
|
|
2011
1199
|
try {
|
|
2012
1200
|
names = readdirSync(this.sessionsRoot());
|
|
@@ -2019,8 +1207,7 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2019
1207
|
const dirPath = path.join(this.sessionsRoot(), d);
|
|
2020
1208
|
const { owner, hadManifest } = this.inferSessionOwner(d, candidateIds);
|
|
2021
1209
|
if (!owner)
|
|
2022
|
-
continue;
|
|
2023
|
-
// backfill legacy dirs so the inference result becomes durable
|
|
1210
|
+
continue;
|
|
2024
1211
|
if (!hadManifest)
|
|
2025
1212
|
this.writeSessionManifest(dirPath, owner);
|
|
2026
1213
|
let m = 0;
|
|
@@ -2030,36 +1217,18 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2030
1217
|
m = st.mtimeMs;
|
|
2031
1218
|
size = st.size;
|
|
2032
1219
|
}
|
|
2033
|
-
catch {
|
|
2034
|
-
/**
|
|
2035
|
-
* The title is STORED, not derived.
|
|
2036
|
-
*
|
|
2037
|
-
* It used to be read from each session's log here, which meant this method opened up to 830
|
|
2038
|
-
* files and allocated a buffer for each — every time it ran, on every `/api/sessions`. The
|
|
2039
|
-
* value now lives on the agent's config entry (see `AgentConfig.sessionTitle`), so listing
|
|
2040
|
-
* sessions does no I/O beyond the directory and the stat above.
|
|
2041
|
-
*
|
|
2042
|
-
* `session` is the agent's CURRENT session, so a title only names `d` when they agree; a past
|
|
2043
|
-
* session of the same agent has no stored label and falls back to its id in the sidebar.
|
|
2044
|
-
*/
|
|
1220
|
+
catch { }
|
|
2045
1221
|
const title = owner ? this.titleFor(owner, d) : undefined;
|
|
2046
1222
|
out.push({ id: d, agentId: owner, mtimeMs: m, sizeBytes: size, ...(title ? { title } : {}) });
|
|
2047
1223
|
}
|
|
2048
1224
|
return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
2049
1225
|
}
|
|
2050
|
-
/**
|
|
2051
|
-
* Internal session ids (= directory names) owned by an agent, newest log
|
|
2052
|
-
* first. These are the stable handles the web UI routes by — agent ids can
|
|
2053
|
-
* be recycled across incarnations, internal session ids cannot.
|
|
2054
|
-
*/
|
|
2055
1226
|
sessionsOf(agentId) {
|
|
2056
1227
|
return this.allSessions()
|
|
2057
1228
|
.filter((s) => s.agentId === agentId)
|
|
2058
1229
|
.map(({ id, mtimeMs, sizeBytes }) => ({ id, mtimeMs, sizeBytes }));
|
|
2059
1230
|
}
|
|
2060
|
-
/** resolve an internal session id to (ownerAgentId, absolute dir) — null when it doesn't exist */
|
|
2061
1231
|
sessionById(sessionId) {
|
|
2062
|
-
// internal ids are directory names: refuse anything path-like up front
|
|
2063
1232
|
if (!sessionId || sessionId.includes("/") || sessionId.includes("\\") || sessionId.startsWith("."))
|
|
2064
1233
|
return null;
|
|
2065
1234
|
const root = this.sessionsRoot();
|
|
@@ -2088,32 +1257,10 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2088
1257
|
this.config.agents = this.config.agents.filter((a) => a.id !== id);
|
|
2089
1258
|
this.saveConfig();
|
|
2090
1259
|
}
|
|
2091
|
-
/**
|
|
2092
|
-
* Put an agent away without losing it: stop it, drop it from the running set, and
|
|
2093
|
-
* mark its config entry archived. The entry and its session history stay on disk,
|
|
2094
|
-
* so `restoreAgent` can bring it back byte-identically.
|
|
2095
|
-
*
|
|
2096
|
-
* This exists because the old "remove" conflated two very different wishes —
|
|
2097
|
-
* "I am done with this for now" and "destroy it" — and implemented the first as
|
|
2098
|
-
* the second: it deleted the config entry, which orphaned the session directory
|
|
2099
|
-
* (the files survived, but nothing in the UI could reach them again).
|
|
2100
|
-
*/
|
|
2101
|
-
/**
|
|
2102
|
-
* Rename a session by hand.
|
|
2103
|
-
*
|
|
2104
|
-
* The stored title is what every surface shows (the sidebar row, the header, the tab), and
|
|
2105
|
-
* `ensureSessionTitle` deliberately never overwrites an existing value — its own comment says so
|
|
2106
|
-
* ("which is also what will let the operator rename a session by hand"). This is that path.
|
|
2107
|
-
*
|
|
2108
|
-
* Refuses an empty name rather than storing one: an empty `sessionTitle` is indistinguishable
|
|
2109
|
-
* from "never derived", so the next `ensureSessionTitle` would silently re-derive and the
|
|
2110
|
-
* operator's intent would vanish. Clearing to the derived default is what `reset` is for.
|
|
2111
|
-
*/
|
|
2112
1260
|
async renameSession(agentId, sessionId, title) {
|
|
2113
1261
|
const a = this.config.agents.find((x) => x.id === agentId);
|
|
2114
1262
|
if (!a)
|
|
2115
1263
|
throw new Error(`no such agent: ${agentId}`);
|
|
2116
|
-
// the title names THAT session, so it may only be set on the one the agent is on
|
|
2117
1264
|
if (a.session !== sessionId)
|
|
2118
1265
|
throw new Error(`agent ${agentId} is not on session ${sessionId}`);
|
|
2119
1266
|
const clean = title.replace(/\s+/g, " ").trim().slice(0, 80);
|
|
@@ -2121,10 +1268,7 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2121
1268
|
throw new Error("a session title cannot be empty");
|
|
2122
1269
|
a.sessionTitle = clean;
|
|
2123
1270
|
this.saveConfig();
|
|
2124
|
-
// the live agent derives its own title from the log, so it has to be told — otherwise the header
|
|
2125
|
-
// and sidebar keep the old label until a restart re-reads the config
|
|
2126
1271
|
this.agents.get(agentId)?.setSessionTitle(clean);
|
|
2127
|
-
// the sidebar caches titles by session id, so it has to learn about this one
|
|
2128
1272
|
bus.emit("update", { kind: "agent-update", agentId });
|
|
2129
1273
|
return { title: clean };
|
|
2130
1274
|
}
|
|
@@ -2132,11 +1276,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2132
1276
|
const root = this.config.agents.find((a) => a.id === id);
|
|
2133
1277
|
if (!root)
|
|
2134
1278
|
throw new Error(`no such agent: ${id}`);
|
|
2135
|
-
// The whole SUBTREE goes with it. A sub-agent whose parent is archived can never
|
|
2136
|
-
// run again — its parent is what spawns and messages it — so leaving it un-archived
|
|
2137
|
-
// put orphans in the list that look usable and are not. The descendants are found
|
|
2138
|
-
// from the config (not the live map), so sub-agents that are already stopped or
|
|
2139
|
-
// suspended are included.
|
|
2140
1279
|
const ids = [id, ...this.subtreeOf(id).map((s) => s.id)];
|
|
2141
1280
|
const now = Date.now();
|
|
2142
1281
|
for (const one of ids) {
|
|
@@ -2145,8 +1284,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2145
1284
|
this.agents.delete(one);
|
|
2146
1285
|
await live.dispose();
|
|
2147
1286
|
}
|
|
2148
|
-
// an archived agent's shells are unreachable from the UI (its tabs go with it), so
|
|
2149
|
-
// leaving the ptys running would strand them until the idle reaper noticed
|
|
2150
1287
|
closeTerminalsFor(one);
|
|
2151
1288
|
const ac = this.config.agents.find((a) => a.id === one);
|
|
2152
1289
|
if (!ac)
|
|
@@ -2157,13 +1294,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2157
1294
|
this.saveConfig();
|
|
2158
1295
|
return { archived: true, ids };
|
|
2159
1296
|
}
|
|
2160
|
-
/**
|
|
2161
|
-
* Bring an archived agent back. Its session pointer was never touched.
|
|
2162
|
-
*
|
|
2163
|
-
* Restores the SUBTREE too, because archiving took it: a parent restored without its
|
|
2164
|
-
* children would come back unable to reach the work it delegated, and the children
|
|
2165
|
-
* would sit invisibly in the archive with no way to know why.
|
|
2166
|
-
*/
|
|
2167
1297
|
async restoreAgent(id) {
|
|
2168
1298
|
const ac = this.config.agents.find((a) => a.id === id);
|
|
2169
1299
|
if (!ac)
|
|
@@ -2177,32 +1307,18 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2177
1307
|
delete e.archivedAt;
|
|
2178
1308
|
}
|
|
2179
1309
|
this.saveConfig();
|
|
2180
|
-
// start it again unless it was never configured to run (it is in the list, so it
|
|
2181
|
-
// was) — a restore is the operator asking for it back, so it comes back LIVE
|
|
2182
1310
|
try {
|
|
2183
1311
|
await this.addAgent(ac);
|
|
2184
1312
|
return { restored: true, live: true, ids };
|
|
2185
1313
|
}
|
|
2186
1314
|
catch (err) {
|
|
2187
|
-
// the entry is restored either way; starting it can fail on a provider/auth
|
|
2188
|
-
// problem, and reporting that is more useful than undoing the restore
|
|
2189
1315
|
console.error(`[teapot] restored "${id}" but could not start it: ${err.message}`);
|
|
2190
1316
|
return { restored: true, live: false, ids };
|
|
2191
1317
|
}
|
|
2192
1318
|
}
|
|
2193
|
-
/** Archived entries, newest first — what the settings panel lists. */
|
|
2194
1319
|
archivedAgents() {
|
|
2195
1320
|
const archived = this.config.agents.filter((a) => a.archived);
|
|
2196
1321
|
const ids = new Set(archived.map((a) => a.id));
|
|
2197
|
-
/**
|
|
2198
|
-
* ONE session scan for the whole list.
|
|
2199
|
-
*
|
|
2200
|
-
* `sessionsOf(id)` calls `allSessions()`, which reads every session directory, stats each log
|
|
2201
|
-
* and backfills a manifest for any that lacks one — so mapping over the archived entries made
|
|
2202
|
-
* this O(archived × sessions). Reported as "🗄 archive のロードが遅すぎる". Counting from a
|
|
2203
|
-
* single scan makes it O(sessions) once, which is what the list actually needs: how many
|
|
2204
|
-
* sessions each entry has.
|
|
2205
|
-
*/
|
|
2206
1322
|
const sessionCounts = new Map();
|
|
2207
1323
|
for (const s of this.allSessions())
|
|
2208
1324
|
sessionCounts.set(s.agentId, (sessionCounts.get(s.agentId) ?? 0) + 1);
|
|
@@ -2212,20 +1328,11 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2212
1328
|
workspace: a.workspace,
|
|
2213
1329
|
...(a.archivedAt !== undefined ? { archivedAt: a.archivedAt } : {}),
|
|
2214
1330
|
sessions: sessionCounts.get(a.id) ?? 0,
|
|
2215
|
-
// A sub-agent is listed under its parent rather than as an equal entry: the
|
|
2216
|
-
// operator archived ONE thing, and a flat list of every descendant makes it
|
|
2217
|
-
// look like they did several. Only an ARCHIVED parent counts — otherwise a
|
|
2218
|
-
// live parent would hide a genuinely standalone entry.
|
|
2219
1331
|
...(a.parent && ids.has(a.parent) ? { parent: a.parent } : {}),
|
|
2220
1332
|
subs: this.subtreeOf(a.id).filter((s) => ids.has(s.id)).length,
|
|
2221
1333
|
}))
|
|
2222
1334
|
.sort((a, b) => (b.archivedAt ?? 0) - (a.archivedAt ?? 0));
|
|
2223
1335
|
}
|
|
2224
|
-
/**
|
|
2225
|
-
* Delete an agent FOR REAL: config entry, live instance, and its session
|
|
2226
|
-
* directories on disk. Irreversible — the UI asks first and says how much is
|
|
2227
|
-
* being destroyed.
|
|
2228
|
-
*/
|
|
2229
1336
|
async purgeAgent(id) {
|
|
2230
1337
|
const ac = this.config.agents.find((a) => a.id === id);
|
|
2231
1338
|
if (!ac)
|
|
@@ -2235,8 +1342,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2235
1342
|
this.agents.delete(id);
|
|
2236
1343
|
await live.dispose();
|
|
2237
1344
|
}
|
|
2238
|
-
// collect the directories BEFORE dropping the config entry, because
|
|
2239
|
-
// `sessionsOf` resolves ownership through the config + live map
|
|
2240
1345
|
const dirs = this.sessionsOf(id);
|
|
2241
1346
|
this.config.agents = this.config.agents.filter((a) => a.id !== id);
|
|
2242
1347
|
this.saveConfig();
|
|
@@ -2252,12 +1357,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2252
1357
|
}
|
|
2253
1358
|
return { purged: true, dirs: removed };
|
|
2254
1359
|
}
|
|
2255
|
-
/**
|
|
2256
|
-
* Switch a running agent's model/provider mid-session.
|
|
2257
|
-
* With provider: use that named provider's baseUrl/key (+ model override).
|
|
2258
|
-
* Without: keep the current endpoint, swap only the model name.
|
|
2259
|
-
* The choice is persisted on the agent config so it survives restarts.
|
|
2260
|
-
*/
|
|
2261
1360
|
async setAgentModel(id, providerName, model, contextWindowTokens, reasoningEffort) {
|
|
2262
1361
|
const agent = this.agents.get(id);
|
|
2263
1362
|
if (!agent)
|
|
@@ -2282,20 +1381,15 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2282
1381
|
const ac = this.config.agents.find((a) => a.id === id);
|
|
2283
1382
|
effectiveProvider = ac?.provider ?? this.config.defaultProvider ?? "";
|
|
2284
1383
|
}
|
|
2285
|
-
// re-resolve the catalogue for the (possibly new) model so the
|
|
2286
|
-
// effort gate reflects THIS model's advertised capabilities
|
|
2287
1384
|
const effortList = await fetchModelList(llm.baseUrl, llm.apiKey).catch(() => []);
|
|
2288
1385
|
const meta = effortList.find((m) => m.id === (model || llm.model));
|
|
2289
1386
|
const opts = agent;
|
|
2290
1387
|
if (reasoningEffort !== undefined) {
|
|
2291
|
-
// validated here so an unsupported value never reaches the request body;
|
|
2292
|
-
// "" or an unrecognised level clears it (provider default)
|
|
2293
1388
|
opts.opts.reasoningEffort = isReasoningEffort(reasoningEffort) ? reasoningEffort : "";
|
|
2294
1389
|
const cfgEntry = this.config.agents.find((a) => a.id === id);
|
|
2295
1390
|
if (cfgEntry)
|
|
2296
1391
|
cfgEntry.reasoningEffort = opts.opts.reasoningEffort || undefined;
|
|
2297
1392
|
}
|
|
2298
|
-
// keep `undefined` for "published nothing" — see the note on the option
|
|
2299
1393
|
opts.opts.supportedParameters = meta?.supportedParameters;
|
|
2300
1394
|
opts.opts.supportedEfforts = meta?.supportedEfforts;
|
|
2301
1395
|
if (!llm.model)
|
|
@@ -2303,17 +1397,12 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2303
1397
|
if (!llm.baseUrl)
|
|
2304
1398
|
throw new Error("no baseUrl resolved");
|
|
2305
1399
|
agent.setLlmConfig(llm);
|
|
2306
|
-
// keep the snapshot's provider in sync — the UI's model switcher reads
|
|
2307
|
-
// sel().provider, and the old code only updated the config entry (ac)
|
|
2308
|
-
// so the live snapshot kept the stale provider (the "right panel model
|
|
2309
|
-
// switching doesn't work" backend half).
|
|
2310
1400
|
agent.opts.provider = effectiveProvider;
|
|
2311
1401
|
const ac = this.config.agents.find((a) => a.id === id);
|
|
2312
1402
|
if (ac && providerName)
|
|
2313
1403
|
ac.provider = providerName;
|
|
2314
1404
|
if (ac)
|
|
2315
1405
|
ac.model = llm.model;
|
|
2316
|
-
// learn the new model's window (metadata lookup) unless explicitly given
|
|
2317
1406
|
let win = contextWindowTokens;
|
|
2318
1407
|
if (win === undefined && ac && !ac.contextWindowTokens && !this.config.contextWindowTokens) {
|
|
2319
1408
|
try {
|
|
@@ -2321,7 +1410,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2321
1410
|
win = contextLengthFor(list, llm.model);
|
|
2322
1411
|
}
|
|
2323
1412
|
catch {
|
|
2324
|
-
/* metadata is best-effort */
|
|
2325
1413
|
}
|
|
2326
1414
|
}
|
|
2327
1415
|
if (win !== undefined) {
|
|
@@ -2329,18 +1417,14 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2329
1417
|
ac.contextWindowTokens = win;
|
|
2330
1418
|
const opts = agent;
|
|
2331
1419
|
opts.opts.contextWindowTokens = win;
|
|
2332
|
-
// re-derive the compaction budget for the NEW window — unless the
|
|
2333
|
-
// operator pinned one. Without this, a model switch kept the previous
|
|
2334
|
-
// budget and the panel showed "manual override" out of nowhere.
|
|
2335
1420
|
if (!agent.compactBudgetIsManual)
|
|
2336
1421
|
opts.opts.contextTokenBudget = Math.round(win * 0.75);
|
|
2337
1422
|
}
|
|
2338
|
-
// re-price for the NEW model (cost estimate follows the model switch)
|
|
2339
1423
|
try {
|
|
2340
1424
|
const list = await fetchModelList(llm.baseUrl, llm.apiKey);
|
|
2341
1425
|
await agent.setModelPricing(list.find((m) => m.id === llm.model)?.pricing);
|
|
2342
1426
|
}
|
|
2343
|
-
catch {
|
|
1427
|
+
catch { }
|
|
2344
1428
|
this.saveConfig();
|
|
2345
1429
|
void agent.log.append("system_note", agent.currentSession, agent.currentBranch, {
|
|
2346
1430
|
event: "model-changed",
|
|
@@ -2349,18 +1433,9 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2349
1433
|
});
|
|
2350
1434
|
return { provider: effectiveProvider, model: llm.model };
|
|
2351
1435
|
}
|
|
2352
|
-
/** 4 ticks/min; each tick is a few integer compares per task. */
|
|
2353
1436
|
tick() {
|
|
2354
1437
|
const now = new Date();
|
|
2355
1438
|
const minuteKey = Math.floor(now.getTime() / 60_000);
|
|
2356
|
-
/**
|
|
2357
|
-
* Push the runtime numbers instead of making every tab poll them.
|
|
2358
|
-
*
|
|
2359
|
-
* `/api/metrics` was fetched on a 30s timer by each connected client, which is a GET whose
|
|
2360
|
-
* ANSWER the server already has — the one shape a push socket makes redundant. It rides this
|
|
2361
|
-
* existing 15s tick (the same "no extra timers" rule as the rest of the master) and a client that
|
|
2362
|
-
* was asleep simply re-reads over HTTP on wake, which is what the recovery path is for.
|
|
2363
|
-
*/
|
|
2364
1439
|
if (bus.listenerCount("update") > 0)
|
|
2365
1440
|
bus.emit("update", { kind: "metrics", metrics: this.metrics() });
|
|
2366
1441
|
for (const t of this.tasks) {
|
|
@@ -2368,17 +1443,15 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2368
1443
|
if (!matches(t.schedule, now))
|
|
2369
1444
|
continue;
|
|
2370
1445
|
if (t.lastRunMin === minuteKey)
|
|
2371
|
-
continue;
|
|
1446
|
+
continue;
|
|
2372
1447
|
t.lastRunMin = minuteKey;
|
|
2373
1448
|
t.lastRunAt = Date.now();
|
|
2374
|
-
// persist so a master restart doesn't re-fire the same minute
|
|
2375
1449
|
t.task.lastRunMin = minuteKey;
|
|
2376
1450
|
this.saveConfig();
|
|
2377
1451
|
const agent = this.agents.get(t.task.agent);
|
|
2378
1452
|
if (!agent)
|
|
2379
1453
|
continue;
|
|
2380
1454
|
console.log(`[teapot] scheduled task "${t.task.id}" -> agent ${t.task.agent}`);
|
|
2381
|
-
// "last"/"next" changed, so the schedule table the UI shows is now stale
|
|
2382
1455
|
bus.emit("update", { kind: "tasks", tasks: this.tasksView() });
|
|
2383
1456
|
void (async () => {
|
|
2384
1457
|
if (t.task.forked)
|
|
@@ -2393,7 +1466,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2393
1466
|
}
|
|
2394
1467
|
}
|
|
2395
1468
|
}
|
|
2396
|
-
/** Everything the UI needs to make cron schedules legible. */
|
|
2397
1469
|
tasksView() {
|
|
2398
1470
|
return this.tasks.map((t) => ({
|
|
2399
1471
|
id: t.task.id,
|
|
@@ -2410,36 +1482,10 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2410
1482
|
const cu = process.cpuUsage();
|
|
2411
1483
|
return {
|
|
2412
1484
|
uptimeSec: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
2413
|
-
/**
|
|
2414
|
-
* `rssMb` is the process's RESIDENT size, which is not a leak gauge.
|
|
2415
|
-
*
|
|
2416
|
-
* V8 keeps freed pages in its arenas instead of returning them, so RSS sits well above the
|
|
2417
|
-
* live set and only falls on a major GC — reported as "メモリ使用量が多い ... さっきはheapが
|
|
2418
|
-
* 400MBあったのでGCされた?". MEASURED on this machine (442 agents, 836 sessions): a fresh boot
|
|
2419
|
-
* was 37MB heap, and 490 `/api/sessions` calls took it to 50MB heap / 203MB RSS and no
|
|
2420
|
-
* further — flat, GC-reclaimable, not a leak. `heapUsedMb` is the number that answers "is
|
|
2421
|
-
* something growing"; `rssMb` alone cannot, which is why both are reported.
|
|
2422
|
-
*/
|
|
2423
1485
|
rssMb: +(mu.rss / 1048576).toFixed(1),
|
|
2424
1486
|
heapUsedMb: +(mu.heapUsed / 1048576).toFixed(1),
|
|
2425
|
-
/**
|
|
2426
|
-
* What V8 has actually committed to the heap.
|
|
2427
|
-
*
|
|
2428
|
-
* `heapUsedMb` includes garbage that has not been collected yet, so it can read 400MB one
|
|
2429
|
-
* minute and 200MB the next without anything having been freed to the OS. `heapTotal` after a
|
|
2430
|
-
* GC is the live-set answer, reported by node's own counters so it exists on every platform.
|
|
2431
|
-
*/
|
|
2432
1487
|
heapTotalMb: +(mu.heapTotal / 1048576).toFixed(1),
|
|
2433
|
-
/** non-heap memory: buffers, native strings, the compiled bundle — the usual RSS/heap gap */
|
|
2434
1488
|
externalMb: +(mu.external / 1048576).toFixed(1),
|
|
2435
|
-
/**
|
|
2436
|
-
* `process.cpuUsage()` reports MICROseconds, so this is divided by 1000.
|
|
2437
|
-
*
|
|
2438
|
-
* The field is named `cpuMsTotal` and was not converted, so it read 1000x too high — a
|
|
2439
|
-
* 1900-second-old process reported 18,313,198 "ms" against a real 18,310ms of CPU, which
|
|
2440
|
-
* looks like 9.6 cores burning continuously. That is how a healthy idle server got
|
|
2441
|
-
* investigated as a runaway loop.
|
|
2442
|
-
*/
|
|
2443
1489
|
cpuMsTotal: Math.round((cu.user + cu.system) / 1000),
|
|
2444
1490
|
loadavg1: +(os.loadavg()[0]).toFixed(2),
|
|
2445
1491
|
agents: [...this.agents.values()].map((a) => ({
|
|
@@ -2451,21 +1497,10 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2451
1497
|
})),
|
|
2452
1498
|
};
|
|
2453
1499
|
}
|
|
2454
|
-
/**
|
|
2455
|
-
* Current application version.
|
|
2456
|
-
*
|
|
2457
|
-
* Read from package.json at runtime rather than via a `"__APP_VERSION__"`
|
|
2458
|
-
* literal: `__APP_VERSION__` is substituted by *vite* only, so the tsc-built
|
|
2459
|
-
* backend used to ship the raw placeholder and the settings panel rendered
|
|
2460
|
-
* "current version: __APP_VERSION__". A runtime read is correct in the
|
|
2461
|
-
* built app, in `pnpm dev` and under `node --test`, which never run vite.
|
|
2462
|
-
*/
|
|
2463
1500
|
static VERSION = readAppVersion();
|
|
2464
|
-
/** Return the current server version. */
|
|
2465
1501
|
getVersion() {
|
|
2466
1502
|
return Master.VERSION;
|
|
2467
1503
|
}
|
|
2468
|
-
/** Gracefully stop all agents with a timeout. Returns when all are stopped or timeout. */
|
|
2469
1504
|
async stopAllAgents(timeoutMs = 30_000) {
|
|
2470
1505
|
console.log(`[teapot] stopping all ${this.agents.size} agents...`);
|
|
2471
1506
|
const ids = [...this.agents.keys()];
|
|
@@ -2479,21 +1514,8 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2479
1514
|
catch (err) {
|
|
2480
1515
|
console.error("[teapot] dispose error:", err);
|
|
2481
1516
|
}
|
|
2482
|
-
// remove from map so a re-spawn after restart doesn't carry state
|
|
2483
1517
|
this.agents.delete(id);
|
|
2484
1518
|
});
|
|
2485
|
-
// two problems with racing a timeout against the real work.
|
|
2486
|
-
//
|
|
2487
|
-
// 1. The timer was never cleared, so it kept the event loop alive for the
|
|
2488
|
-
// FULL timeout after a fast, successful stop — a 30s handle on a stop
|
|
2489
|
-
// that took milliseconds.
|
|
2490
|
-
// 2. On timeout, the promises that HAD completed had already deleted their
|
|
2491
|
-
// agents from `this.agents`, while their peers were still mid-dispose.
|
|
2492
|
-
// `restartServer` then threw BEFORE spawning its replacement, so the
|
|
2493
|
-
// process was left serving with a half-empty agent map and no successor.
|
|
2494
|
-
//
|
|
2495
|
-
// Fixed by clearing the timer, and by reporting which agents did NOT stop
|
|
2496
|
-
// so the caller can decide rather than being left half-dead.
|
|
2497
1519
|
let timer;
|
|
2498
1520
|
const timeout = new Promise((_, reject) => {
|
|
2499
1521
|
timer = setTimeout(() => reject(new Error("agent stop timeout")), timeoutMs);
|
|
@@ -2505,84 +1527,14 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2505
1527
|
finally {
|
|
2506
1528
|
if (timer)
|
|
2507
1529
|
clearTimeout(timer);
|
|
2508
|
-
/**
|
|
2509
|
-
* Close the human terminals too.
|
|
2510
|
-
*
|
|
2511
|
-
* Their ptys are spawned `detached` so one close can signal the whole process group (see
|
|
2512
|
-
* terminals.ts). The cost of that is that they do NOT die with this process — a restart
|
|
2513
|
-
* would leave every open shell orphaned and unreachable, still holding the operator's
|
|
2514
|
-
* workspace. Closing here is what makes `detached` safe.
|
|
2515
|
-
*/
|
|
2516
1530
|
const n = closeAllTerminals();
|
|
2517
1531
|
if (n)
|
|
2518
1532
|
console.log(`[teapot] ⌨ closed ${n} terminal(s)`);
|
|
2519
1533
|
}
|
|
2520
1534
|
}
|
|
2521
|
-
/**
|
|
2522
|
-
* Live update: stop at a turn boundary, write the resume set, then REPLACE THIS PROCESS IN PLACE.
|
|
2523
|
-
*
|
|
2524
|
-
* ## Why exec instead of spawning a successor
|
|
2525
|
-
*
|
|
2526
|
-
* The previous design spawned a child and kept the old process alive as its supervisor, polling
|
|
2527
|
-
* `pidAlive(replacement)` forever. That is the reported memory leak — and it was structural, not
|
|
2528
|
-
* a stray handle:
|
|
2529
|
-
*
|
|
2530
|
-
* - the OLD process stayed resident for the ENTIRE LIFETIME of the new one, holding its agent
|
|
2531
|
-
* objects, its log file handles, every `bus` listener and its 15s tick;
|
|
2532
|
-
* - `supervise()` added three MORE signal listeners (`SIGINT`/`SIGTERM`/`SIGHUP`) to a process
|
|
2533
|
-
* that had already registered the same three for `shutdown()`, so each update left a process
|
|
2534
|
-
* with doubled handlers;
|
|
2535
|
-
* - and the handover needed a 1.5s sleep plus a port-polling dance in the new process, because
|
|
2536
|
-
* two processes cannot hold one port.
|
|
2537
|
-
*
|
|
2538
|
-
* `process.execve()` (Node 26+) removes the whole category: the image is replaced under the SAME
|
|
2539
|
-
* pid, so the process group, the controlling terminal and therefore the operator's foreground job
|
|
2540
|
-
* all stay put — the daemonization problem #35 fixed by hand cannot occur. The port is released
|
|
2541
|
-
* as part of the exec (verified: a listener bound before `execve` is free immediately after), so
|
|
2542
|
-
* the successor binds with no polling and no window in which both are half-served.
|
|
2543
|
-
*
|
|
2544
|
-
* `execveSync` does not exist; this is the async form. It does not return on success.
|
|
2545
|
-
*/
|
|
2546
|
-
/**
|
|
2547
|
-
* How long a live update waits for agents to reach a turn boundary.
|
|
2548
|
-
*
|
|
2549
|
-
* It must be comparable to a TOOL's own ceiling, not to a human's patience. MEASURED: a live update
|
|
2550
|
-
* waited 180s while a `bash` call was in flight, gave up, and killed it — the timeline then showed
|
|
2551
|
-
* `✖ fail … not completed — the agent restarted while this tool was running` for work that was
|
|
2552
|
-
* about to return (that call had been running ~300s, well within the 3600s a `bash` call may take).
|
|
2553
|
-
* Waiting too SHORT is what turns a clean update into a truncated turn.
|
|
2554
|
-
*
|
|
2555
|
-
* 15 minutes: three times the longest call seen in the wild (a ~300s `bash`) and still short of a
|
|
2556
|
-
* `bash` call's own 3600s ceiling. Waiting an entire tool ceiling would block the update for an
|
|
2557
|
-
* hour, which defeats the purpose of updating; waiting 180s interrupted work that was about to
|
|
2558
|
-
* finish. The interrupted case is not lost — the resumed session is told exactly what happened.
|
|
2559
|
-
*/
|
|
2560
1535
|
updateWindDownMs = 900_000;
|
|
2561
|
-
/**
|
|
2562
|
-
* True from the moment a live update starts until the process is replaced.
|
|
2563
|
-
*
|
|
2564
|
-
* A Ctrl-C during the wind-down used to run the ordinary shutdown path — dispose every agent, then
|
|
2565
|
-
* exit — which discarded the resume set that had just been written and left the operator's work
|
|
2566
|
-
* stopped with no explanation.
|
|
2567
|
-
*/
|
|
2568
1536
|
updateInFlight = false;
|
|
2569
|
-
/**
|
|
2570
|
-
* Ask every agent to stop at its next turn boundary; returns the ids that did not make it.
|
|
2571
|
-
*
|
|
2572
|
-
* Distinct from `stopAllAgents` (which DISPOSES): disposal aborts in-flight work, and the whole
|
|
2573
|
-
* point here is to leave the log on a completed round. A live update must not truncate the turn
|
|
2574
|
-
* it interrupted.
|
|
2575
|
-
*/
|
|
2576
1537
|
async windDownAllAgents(timeoutMs = this.updateWindDownMs) {
|
|
2577
|
-
/**
|
|
2578
|
-
* Only agents with WORK IN FLIGHT need to be waited for.
|
|
2579
|
-
*
|
|
2580
|
-
* This asked every loaded agent, and the log then said "waiting for 440 agent(s)" on a machine
|
|
2581
|
-
* with one working session (MEASURED: 511 configured, 71 archived, 440 loaded). The count was
|
|
2582
|
-
* false in the most alarming direction — it read as 440 interrupted jobs — and the call itself is
|
|
2583
|
-
* not free: each idle agent gets a `windDownRequested` flag set and a `wake()`, which is
|
|
2584
|
-
* pointless state churn on agents that are doing nothing.
|
|
2585
|
-
*/
|
|
2586
1538
|
const ids = [...this.agents.entries()]
|
|
2587
1539
|
.filter(([, a]) => a.isLive())
|
|
2588
1540
|
.map(([id]) => id);
|
|
@@ -2605,28 +1557,14 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2605
1557
|
}));
|
|
2606
1558
|
return stuck.filter((v) => v !== null);
|
|
2607
1559
|
}
|
|
2608
|
-
/**
|
|
2609
|
-
* The sessions the successor must resume: everything that was WORKING at this moment.
|
|
2610
|
-
*
|
|
2611
|
-
* Captured here (not derived on boot) because after the exec the log cannot distinguish "stopped
|
|
2612
|
-
* by a live update mid-round" from "finished" or "stopped by the operator".
|
|
2613
|
-
*/
|
|
2614
1560
|
takeResumeSet() {
|
|
2615
1561
|
const out = [];
|
|
2616
1562
|
for (const [id, a] of this.agents) {
|
|
2617
|
-
// `live` covers running, parked on a tool, and needs_operator — all states where the
|
|
2618
|
-
// operator's mental model is "this thing is in the middle of something".
|
|
2619
1563
|
if (a.isLive())
|
|
2620
1564
|
out.push(id);
|
|
2621
1565
|
}
|
|
2622
1566
|
return out;
|
|
2623
1567
|
}
|
|
2624
|
-
/**
|
|
2625
|
-
* Resume what the previous image was running, then FORGET the set.
|
|
2626
|
-
*
|
|
2627
|
-
* Called once at boot. Clearing it immediately means a crash before the agents start cannot make
|
|
2628
|
-
* the next boot resume work that has been idle for hours: the set describes one handover.
|
|
2629
|
-
*/
|
|
2630
1568
|
async resumeAfterUpdate() {
|
|
2631
1569
|
const ids = this.config.resumeAfterUpdate ?? [];
|
|
2632
1570
|
if (!ids.length)
|
|
@@ -2637,7 +1575,7 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2637
1575
|
for (const id of ids) {
|
|
2638
1576
|
const a = this.agents.get(id);
|
|
2639
1577
|
if (!a)
|
|
2640
|
-
continue;
|
|
1578
|
+
continue;
|
|
2641
1579
|
try {
|
|
2642
1580
|
a.start("resumed after a live update");
|
|
2643
1581
|
started.push(id);
|
|
@@ -2655,122 +1593,39 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2655
1593
|
}
|
|
2656
1594
|
async restartServer() {
|
|
2657
1595
|
console.log("[teapot] live update: restarting...");
|
|
2658
|
-
/**
|
|
2659
|
-
* A Ctrl-C from here until the exec is ambiguous; signal handlers consult this.
|
|
2660
|
-
*
|
|
2661
|
-
* Every early RETURN below clears it again. `execve` only resolves if it FAILED, and the
|
|
2662
|
-
* `!execve` branch throws — on either path the process keeps running, so leaving the flag set
|
|
2663
|
-
* would make the FIRST ^C refuse forever with a message that promises a second one works. The
|
|
2664
|
-
* flag is therefore a promise about the CURRENT moment, not a one-way switch.
|
|
2665
|
-
*/
|
|
2666
1596
|
this.updateInFlight = true;
|
|
2667
1597
|
try {
|
|
2668
1598
|
await this.handoverToNewImage();
|
|
2669
1599
|
}
|
|
2670
1600
|
finally {
|
|
2671
|
-
// only reached when the handover did NOT replace the process
|
|
2672
1601
|
this.updateInFlight = false;
|
|
2673
1602
|
}
|
|
2674
1603
|
}
|
|
2675
|
-
/** wind down, record what to resume, and exec. Never returns on success. */
|
|
2676
1604
|
async handoverToNewImage() {
|
|
2677
|
-
/**
|
|
2678
|
-
* 1. WIND DOWN — do not abort.
|
|
2679
|
-
*
|
|
2680
|
-
* `stopAllAgents` calls `dispose()`, which aborts the in-flight LLM call and the running tool.
|
|
2681
|
-
* For an operator pressing ■ that is correct; for a live update it cuts a tool off mid-write
|
|
2682
|
-
* and leaves a call whose result never arrives, so the resumed session starts from a state that
|
|
2683
|
-
* never existed. `windDownForUpdate()` instead lets each agent finish its current round.
|
|
2684
|
-
*/
|
|
2685
|
-
/**
|
|
2686
|
-
* 2. REMEMBER WHAT TO RESUME — before winding down, not after.
|
|
2687
|
-
*
|
|
2688
|
-
* `takeResumeSet()` asks `isLive()`, and `windDownForUpdate()` SETS `status = "idle"` as its
|
|
2689
|
-
* success condition. Taking the set afterwards therefore collected nothing: MEASURED in a real
|
|
2690
|
-
* update whose log reads
|
|
2691
|
-
*
|
|
2692
|
-
* waiting for 1 agent(s) to reach a turn boundary...
|
|
2693
|
-
* 0 session(s) will resume
|
|
2694
|
-
* resumed 0 session(s)
|
|
2695
|
-
*
|
|
2696
|
-
* — the one agent that was working was recorded as "not working" by the very step that had just
|
|
2697
|
-
* made it stop, so the work never came back.
|
|
2698
|
-
*
|
|
2699
|
-
* Captured first, the set describes what the operator saw when they pressed update.
|
|
2700
|
-
*/
|
|
2701
1605
|
this.config.resumeAfterUpdate = this.takeResumeSet();
|
|
2702
1606
|
const interrupted = await this.windDownAllAgents(this.updateWindDownMs);
|
|
2703
1607
|
if (interrupted.length)
|
|
2704
1608
|
console.log(`[teapot] live update: ${interrupted.length} agent(s) did not reach a turn boundary in ` +
|
|
2705
1609
|
`time and were stopped: ${interrupted.join(", ")}`);
|
|
2706
|
-
/**
|
|
2707
|
-
* Written to the config BEFORE the exec, because after it this process no longer exists. Only
|
|
2708
|
-
* agents that were actually working go in — an idle agent must NOT be started by the new
|
|
2709
|
-
* process, or a live update would begin doing work the operator never asked for.
|
|
2710
|
-
*/
|
|
2711
1610
|
this.saveConfig();
|
|
2712
1611
|
console.log(`[teapot] live update: ${this.config.resumeAfterUpdate.length} session(s) will resume`);
|
|
2713
|
-
// 3. terminals: closing them is what makes their `detached` spawns safe (see stopAllAgents)
|
|
2714
1612
|
const closed = closeAllTerminals();
|
|
2715
1613
|
if (closed)
|
|
2716
1614
|
console.log(`[teapot] ⌻ closed ${closed} terminal(s)`);
|
|
2717
|
-
/**
|
|
2718
|
-
* 4. EXEC.
|
|
2719
|
-
*
|
|
2720
|
-
* `TEAPOT_RESUMED` tells the new image this was a live update, so it resumes rather than
|
|
2721
|
-
* treating the sessions as merely idle. It also suppresses the "config was edited" banner in
|
|
2722
|
-
* cases where the operator did not touch anything.
|
|
2723
|
-
*/
|
|
2724
1615
|
const env = { ...process.env, TEAPOT_RESUMED: "1" };
|
|
2725
1616
|
const execve = process.execve;
|
|
2726
1617
|
if (!execve) {
|
|
2727
1618
|
throw new Error("live update needs process.execve (Node 22+); restart the server by hand on this runtime");
|
|
2728
1619
|
}
|
|
2729
1620
|
console.log("[teapot] live update: replacing this process in place (same pid, same terminal)");
|
|
2730
|
-
/**
|
|
2731
|
-
* Give the heap back BEFORE the new image allocates its own.
|
|
2732
|
-
*
|
|
2733
|
-
* `execve` replaces the image in place, so the outgoing heap is still resident while the incoming
|
|
2734
|
-
* one grows. MEASURED on a long-lived server: `VmHWM` reached 1092MB after an update while the
|
|
2735
|
-
* steady state was 323MB, and the boot peak was visible in the status banner as
|
|
2736
|
-
* `602.4MB / 423.3MB`. A sandbox boot of the same 832 sessions measured only 250MB, so the extra
|
|
2737
|
-
* gigabyte is not the data — it is two heaps overlapping.
|
|
2738
|
-
*
|
|
2739
|
-
* `global.gc` exists only with `--expose-gc`; without it this is a cheap no-op and the peak simply
|
|
2740
|
-
* stays as it was, which is the old behaviour.
|
|
2741
|
-
*/
|
|
2742
1621
|
const gc = globalThis.gc;
|
|
2743
1622
|
if (typeof gc === "function") {
|
|
2744
|
-
// twice: the first pass frees what is unreachable, the second collects what the first freed
|
|
2745
1623
|
gc();
|
|
2746
1624
|
gc();
|
|
2747
1625
|
}
|
|
2748
|
-
/**
|
|
2749
|
-
* Build the argv for the replacement image.
|
|
2750
|
-
*
|
|
2751
|
-
* `process.execArgv` carries runtime flags that must precede the entry file — but under
|
|
2752
|
-
* `node --test` it is polluted with the RUNNER's own flags (`--test-concurrency`,
|
|
2753
|
-
* `--test-isolation=process`, dozens of tuning knobs). Re-passing those made the new image
|
|
2754
|
-
* re-enter test-runner mode instead of booting: MEASURED as
|
|
2755
|
-
* `src/index.ts: bad option: --config`, i.e. the new image never started.
|
|
2756
|
-
*
|
|
2757
|
-
* So only the flags that make a TypeScript entry runnable are forwarded. A flag missing here
|
|
2758
|
-
* simply means the new image boots with node's defaults, which is what a live update wants;
|
|
2759
|
-
* anything else the operator set lives in `env` and the shell.
|
|
2760
|
-
*/
|
|
2761
1626
|
const FORWARDABLE = /^--(experimental-strip-types|experimental-transform-types|no-warnings|enable-source-maps|import|require|conditions|expose-gc)$/;
|
|
2762
1627
|
const forwardable = process.execArgv.filter((a) => FORWARDABLE.test(a.split("=")[0] ?? ""));
|
|
2763
|
-
/**
|
|
2764
|
-
* `args[0]` MUST be the program, exactly as a shell would pass it.
|
|
2765
|
-
*
|
|
2766
|
-
* `execve` does not use the `file` argument as `argv[0]` — node then reads what it thinks is
|
|
2767
|
-
* the first ARGUMENT as the entry point and hands the rest to its own option parser. Verified
|
|
2768
|
-
* in isolation: passing `[script, "--config", …]` yields `script: bad option: --config`, while
|
|
2769
|
-
* `[execPath, script, "--config", …]` boots. The new image would otherwise never start, leaving
|
|
2770
|
-
* the port dark after a live update.
|
|
2771
|
-
*/
|
|
2772
1628
|
await execve(process.execPath, [process.execPath, ...forwardable, ...process.argv.slice(1)], env);
|
|
2773
|
-
// execve only resolves if it FAILED to exec
|
|
2774
1629
|
throw new Error("execve returned without replacing the process");
|
|
2775
1630
|
}
|
|
2776
1631
|
}
|