teapot-coding-agent 0.37.59 → 0.37.61
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 +61 -2247
- 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 +9 -310
- 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 -1180
- 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 +166 -142
- 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-KzHw4Sqo.js → index-C1eqcSbW.js} +4 -4
- package/public/assets/index-C1eqcSbW.js.map +1 -0
- package/public/assets/{shiki-BwDjoZdu.js → shiki-DaLn_bCe.js} +2 -2
- package/public/assets/shiki-DaLn_bCe.js.map +1 -0
- package/public/index.html +1 -1
- package/public/assets/index-KzHw4Sqo.js.map +0 -1
- package/public/assets/shiki-BwDjoZdu.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,24 @@ 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
|
-
...(modelMeta?.supportedParameters
|
|
1135
|
-
? { supportedParameters: modelMeta.supportedParameters }
|
|
1136
|
-
: {}),
|
|
1137
|
-
// the model's own reasoning levels, so the picker offers exactly those
|
|
1138
662
|
...(modelMeta?.supportedEfforts?.length ? { supportedEfforts: modelMeta.supportedEfforts } : {}),
|
|
1139
663
|
});
|
|
1140
|
-
// pricing metadata (USD/token) for the runtime cost estimate — best
|
|
1141
|
-
// effort: providers without pricing in /models simply show no cost
|
|
1142
664
|
void fetchModelList(llm.baseUrl, llm.apiKey)
|
|
1143
665
|
.then((list) => agent.setModelPricing(list.find((m) => m.id === llm.model)?.pricing))
|
|
1144
666
|
.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
667
|
agent.log.onEvent = (e) => {
|
|
1149
668
|
printAgentEvent(e);
|
|
1150
669
|
bus.emit("update", { kind: "event", agentId: e.agent, event: e });
|
|
1151
670
|
this.onChildEvent(ac, e);
|
|
1152
671
|
};
|
|
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
672
|
agent.onControlStatusChange = (agentId, status) => {
|
|
1164
673
|
if (!ac.parent)
|
|
1165
674
|
return;
|
|
1166
675
|
const parent = this.agents.get(ac.parent);
|
|
1167
676
|
if (!parent)
|
|
1168
677
|
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
678
|
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
679
|
if (this.reportedRunning.has(agentId) && SETTLED.has(status))
|
|
1184
680
|
parent.enqueuePrompt(`[harness] Sub-agent @${agentId} is now ${status} without having called finish(), so it has not reported a result.` +
|
|
1185
681
|
(status === "error"
|
|
@@ -1192,24 +688,15 @@ export class Master {
|
|
|
1192
688
|
else if (SETTLED.has(status))
|
|
1193
689
|
this.reportedRunning.delete(agentId);
|
|
1194
690
|
};
|
|
1195
|
-
// sub-agent management hooks — only when this agent can legally spawn
|
|
1196
691
|
if (myDepth < this.maxSpawnDepth()) {
|
|
1197
692
|
const self = agent;
|
|
1198
693
|
const hooks = {
|
|
1199
694
|
depth: myDepth,
|
|
1200
695
|
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 }) => {
|
|
696
|
+
list: () => this.subtreeOf(ac.id).flatMap(({ id, depth, parent }) => {
|
|
1210
697
|
const inst = this.agents.get(id);
|
|
1211
698
|
if (!inst)
|
|
1212
|
-
return [];
|
|
699
|
+
return [];
|
|
1213
700
|
return [
|
|
1214
701
|
{
|
|
1215
702
|
id,
|
|
@@ -1222,11 +709,7 @@ export class Master {
|
|
|
1222
709
|
}),
|
|
1223
710
|
stop: (ids) => this.stopChildrenFor(ac.id, ids),
|
|
1224
711
|
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),
|
|
712
|
+
wait: (ids, ms) => this.waitChildren(ac.id, ids, ms, self.toolCtx.signal),
|
|
1230
713
|
};
|
|
1231
714
|
agent.toolCtx.subAgents = hooks;
|
|
1232
715
|
}
|
|
@@ -1236,39 +719,23 @@ export class Master {
|
|
|
1236
719
|
this.config.agents.push(ac);
|
|
1237
720
|
this.saveConfig();
|
|
1238
721
|
}
|
|
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
722
|
this.recordSession(ac, path.basename(sessionDir));
|
|
1244
723
|
return agent;
|
|
1245
724
|
}
|
|
1246
|
-
/** Persist an agent's current session id into the config, if it changed. */
|
|
1247
725
|
recordSession(ac, sessionId) {
|
|
1248
726
|
if (!sessionId || ac.session === sessionId)
|
|
1249
727
|
return;
|
|
1250
728
|
ac.session = sessionId;
|
|
1251
729
|
const entry = this.config.agents.find((a) => a.id === ac.id);
|
|
1252
730
|
if (!entry)
|
|
1253
|
-
return;
|
|
731
|
+
return;
|
|
1254
732
|
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
733
|
delete entry.sessionTitle;
|
|
1263
734
|
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
735
|
if (this.sessionsReadOnly)
|
|
1268
736
|
return;
|
|
1269
737
|
this.saveConfig();
|
|
1270
738
|
}
|
|
1271
|
-
/** spawn-tree depth of an agent (0 = top level); unknown → 0 */
|
|
1272
739
|
depthOf(id, seen = new Set()) {
|
|
1273
740
|
const cap = this.maxSpawnDepth();
|
|
1274
741
|
let depth = 0;
|
|
@@ -1280,12 +747,10 @@ export class Master {
|
|
|
1280
747
|
}
|
|
1281
748
|
return depth;
|
|
1282
749
|
}
|
|
1283
|
-
/** configured nesting limit (config.maxSpawnDepth, default 3) */
|
|
1284
750
|
maxSpawnDepth() {
|
|
1285
751
|
const v = this.config.maxSpawnDepth;
|
|
1286
752
|
return typeof v === "number" && v >= 0 ? v : MAX_SPAWN_DEPTH;
|
|
1287
753
|
}
|
|
1288
|
-
/** direct children of an agent, with their Agent instances */
|
|
1289
754
|
childrenOf(id) {
|
|
1290
755
|
const kids = this.config.agents.filter((a) => a.parent === id);
|
|
1291
756
|
const out = [];
|
|
@@ -1296,19 +761,6 @@ export class Master {
|
|
|
1296
761
|
}
|
|
1297
762
|
return out;
|
|
1298
763
|
}
|
|
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
764
|
subtreeOf(id, depth = 1, seen = new Set()) {
|
|
1313
765
|
const out = [];
|
|
1314
766
|
for (const a of this.config.agents) {
|
|
@@ -1321,16 +773,9 @@ export class Master {
|
|
|
1321
773
|
}
|
|
1322
774
|
return out;
|
|
1323
775
|
}
|
|
1324
|
-
/** the ids of every descendant of `id`, at any depth */
|
|
1325
776
|
descendantsOf(id, seen = new Set()) {
|
|
1326
777
|
return this.subtreeOf(id, 1, seen).map((x) => x.id);
|
|
1327
778
|
}
|
|
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
779
|
async spawnChildFor(parent, o) {
|
|
1335
780
|
const parentId = parent.opts_id();
|
|
1336
781
|
const persona = o.persona && Object.prototype.hasOwnProperty.call(SUB_PERSONAS, o.persona)
|
|
@@ -1341,34 +786,14 @@ export class Master {
|
|
|
1341
786
|
let n = 2;
|
|
1342
787
|
while (this.agents.has(id) || this.config.agents.some((a) => a.id === id))
|
|
1343
788
|
id = `${base.slice(0, 56)}-${n++}`;
|
|
1344
|
-
// persona directives shape how the sub approaches the task
|
|
1345
789
|
const directive = persona ? `${SUB_PERSONAS[persona].label}\n${SUB_PERSONAS[persona].directive}\n\n` : "";
|
|
1346
790
|
const pcfg = this.config.agents.find((a) => a.id === parentId);
|
|
1347
791
|
let forkTip;
|
|
1348
792
|
let forkBranch;
|
|
1349
793
|
if (o.context === "fork") {
|
|
1350
|
-
// capture the fork tip BEFORE creating the child (parent keeps running)
|
|
1351
794
|
forkTip = parent.log.lastEventId(parent.currentBranch);
|
|
1352
795
|
forkBranch = parent.currentBranch;
|
|
1353
796
|
}
|
|
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
797
|
const childEffort = o.reasoning_effort || this.config.defaultSubagentReasoningEffort || pcfg?.reasoningEffort;
|
|
1373
798
|
const child = await this.addAgent({
|
|
1374
799
|
id,
|
|
@@ -1376,8 +801,6 @@ export class Master {
|
|
|
1376
801
|
provider: pcfg?.provider,
|
|
1377
802
|
model: pcfg?.model,
|
|
1378
803
|
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
804
|
...(childEffort ? { reasoningEffort: childEffort } : {}),
|
|
1382
805
|
parent: parentId,
|
|
1383
806
|
...(persona && SUB_PERSONAS[persona]?.readOnly ? { readOnly: true } : {}),
|
|
@@ -1389,15 +812,9 @@ export class Master {
|
|
|
1389
812
|
parentBranch: forkBranch,
|
|
1390
813
|
upToEvent: forkTip,
|
|
1391
814
|
});
|
|
1392
|
-
// the inherited prefix becomes visible history for the child's loop
|
|
1393
815
|
child.importMessages(parent.exportMessages());
|
|
1394
816
|
}
|
|
1395
817
|
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
818
|
await child.enqueuePrompt((o.context === "fork"
|
|
1402
819
|
? `[harness] You are sub-agent ${id}, spawned by @${parentId}.
|
|
1403
820
|
|
|
@@ -1416,68 +833,28 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1416
833
|
console.log(`[teapot] sub-agent ${id} spawned by ${parentId} (context: ${o.context ?? "none"}${persona ? `, persona: ${persona}` : ""})`);
|
|
1417
834
|
return { id };
|
|
1418
835
|
}
|
|
1419
|
-
/** Stop direct children (and their descendants by default) of an agent. */
|
|
1420
836
|
async stopChildrenFor(parentId, ids) {
|
|
1421
837
|
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
838
|
const walk = (pid, inScope) => {
|
|
1430
839
|
for (const { id, agent } of this.childrenOf(pid)) {
|
|
1431
840
|
const take = inScope || !ids || ids.includes(id);
|
|
1432
841
|
if (!take) {
|
|
1433
|
-
// out of scope, but its own subtree may hold something in scope
|
|
1434
842
|
walk(id, false);
|
|
1435
843
|
continue;
|
|
1436
844
|
}
|
|
1437
845
|
agent.stop("stopped by parent agent");
|
|
1438
846
|
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
847
|
walk(id, true);
|
|
1455
848
|
}
|
|
1456
849
|
};
|
|
1457
850
|
walk(parentId, false);
|
|
1458
851
|
return { stopped };
|
|
1459
852
|
}
|
|
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
853
|
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
854
|
const named = ids && ids.length ? ids : this.descendantsOf(parentId);
|
|
1472
855
|
const targets = [...new Set(named.flatMap((id) => [id, ...this.descendantsOf(id)]))].filter((id) => this.agents.has(id));
|
|
1473
856
|
if (targets.length === 0)
|
|
1474
857
|
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
858
|
const active = () => targets.filter((id) => this.agents.get(id)?.isBusy());
|
|
1482
859
|
const first = active();
|
|
1483
860
|
if (first.length === 0)
|
|
@@ -1502,7 +879,7 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1502
879
|
const wake = (_note) => {
|
|
1503
880
|
const left = active();
|
|
1504
881
|
if (left.length > 0)
|
|
1505
|
-
return;
|
|
882
|
+
return;
|
|
1506
883
|
clearTimeout(timer);
|
|
1507
884
|
finish(`all sub-agents settled: ${targets.join(", ")}`);
|
|
1508
885
|
};
|
|
@@ -1510,12 +887,10 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1510
887
|
if (!this.childWaiters.has(parentId))
|
|
1511
888
|
this.childWaiters.set(parentId, set);
|
|
1512
889
|
set.add(wake);
|
|
1513
|
-
// re-check immediately: children may have settled between snapshot & register
|
|
1514
890
|
if (active().length === 0)
|
|
1515
891
|
wake();
|
|
1516
892
|
});
|
|
1517
893
|
}
|
|
1518
|
-
/** wake every waiter of a parent (child lifecycle event happened) */
|
|
1519
894
|
wakeParentWaiters(parentId) {
|
|
1520
895
|
const set = this.childWaiters.get(parentId);
|
|
1521
896
|
if (!set)
|
|
@@ -1524,22 +899,9 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1524
899
|
try {
|
|
1525
900
|
fn("child event");
|
|
1526
901
|
}
|
|
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
|
-
*/
|
|
902
|
+
catch { }
|
|
903
|
+
}
|
|
904
|
+
}
|
|
1543
905
|
wakeAncestorWaiters(parentId) {
|
|
1544
906
|
const seen = new Set();
|
|
1545
907
|
let cur = parentId;
|
|
@@ -1550,24 +912,10 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1550
912
|
}
|
|
1551
913
|
}
|
|
1552
914
|
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
915
|
let targetId = childId;
|
|
1557
916
|
const owned = this.sessionById(childId);
|
|
1558
917
|
if (owned)
|
|
1559
918
|
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
919
|
const caller = this.config.agents.find((a) => a.id === parentId);
|
|
1572
920
|
const target = this.config.agents.find((a) => a.id === targetId);
|
|
1573
921
|
const childToParent = target?.parent === parentId;
|
|
@@ -1575,7 +923,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1575
923
|
const p = this.agents.get(targetId);
|
|
1576
924
|
if (!p)
|
|
1577
925
|
throw new Error(`sub-agent not running: ${targetId}`);
|
|
1578
|
-
// mirrored wording, so the recipient knows who is writing
|
|
1579
926
|
p.enqueuePrompt(`[harness] Message from sub-agent @${parentId}:\n\n${text}`, "harness");
|
|
1580
927
|
if (p.status !== "running")
|
|
1581
928
|
p.start(`message from sub-agent ${parentId}`);
|
|
@@ -1583,10 +930,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1583
930
|
}
|
|
1584
931
|
const cfg = target;
|
|
1585
932
|
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
933
|
const actual = cfg?.parent;
|
|
1591
934
|
const live = actual ? this.agents.get(actual) : undefined;
|
|
1592
935
|
const detail = actual
|
|
@@ -1601,96 +944,45 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1601
944
|
if (child.status !== "running")
|
|
1602
945
|
child.start(`message from ${parentId}`);
|
|
1603
946
|
}
|
|
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
947
|
onChildEvent(ac, e) {
|
|
1609
948
|
const parentId = ac.parent;
|
|
1610
949
|
if (!parentId)
|
|
1611
950
|
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
951
|
this.wakeAncestorWaiters(parentId);
|
|
1616
952
|
const parent = this.agents.get(parentId);
|
|
1617
953
|
if (!parent)
|
|
1618
954
|
return;
|
|
1619
|
-
// forward outcomes so the parent hears the result without polling
|
|
1620
955
|
if (e.type === "message" && e.data.final) {
|
|
1621
956
|
const d = e.data;
|
|
1622
957
|
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
958
|
let report = summary;
|
|
1627
959
|
if (Array.isArray(d.recentContext) && d.recentContext.length) {
|
|
1628
960
|
report +=
|
|
1629
961
|
"\n\n[Last conversation turns before finishing]\n" +
|
|
1630
962
|
d.recentContext.map((r) => r).join("\n\n");
|
|
1631
963
|
}
|
|
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.
|
|
964
|
+
parent.enqueuePrompt(`[harness] Sub-agent ${ac.id} finished. Final report:\n${report || "(no summary)"}`, "harness");
|
|
1642
965
|
if (parent.status !== "running")
|
|
1643
966
|
parent.start(`sub-agent ${ac.id} finished`);
|
|
1644
967
|
}
|
|
1645
968
|
else if (e.type === "error") {
|
|
1646
969
|
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
970
|
if (parent.status !== "running")
|
|
1649
971
|
parent.start(`sub-agent ${ac.id} error`);
|
|
1650
972
|
}
|
|
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
|
-
*/
|
|
973
|
+
}
|
|
1666
974
|
sessionsRoot() {
|
|
1667
975
|
return path.join(this.config.dataDir, "sessions");
|
|
1668
976
|
}
|
|
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
977
|
readSessionOwner(dirName) {
|
|
1681
978
|
try {
|
|
1682
979
|
const raw = JSON.parse(readFileSync(path.join(this.sessionsRoot(), dirName, "session.json"), "utf8"));
|
|
1683
980
|
return typeof raw.agentId === "string" ? raw.agentId : null;
|
|
1684
981
|
}
|
|
1685
982
|
catch {
|
|
1686
|
-
return null;
|
|
983
|
+
return null;
|
|
1687
984
|
}
|
|
1688
985
|
}
|
|
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
986
|
inferSessionOwner(dirName, candidateIds) {
|
|
1695
987
|
const fromManifest = this.readSessionOwner(dirName);
|
|
1696
988
|
if (fromManifest)
|
|
@@ -1704,37 +996,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1704
996
|
}
|
|
1705
997
|
return { owner: best?.id ?? null, hadManifest: false };
|
|
1706
998
|
}
|
|
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
999
|
logOwner(dirName) {
|
|
1739
1000
|
let fd;
|
|
1740
1001
|
try {
|
|
@@ -1743,7 +1004,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1743
1004
|
const n = readSync(fd, buf, 0, buf.length, 0);
|
|
1744
1005
|
if (n <= 0)
|
|
1745
1006
|
return null;
|
|
1746
|
-
// a torn final line cannot be the one we want: take the first COMPLETE line
|
|
1747
1007
|
const head = buf.subarray(0, n).toString("utf8");
|
|
1748
1008
|
const nl = head.indexOf("\n");
|
|
1749
1009
|
const line = (nl >= 0 ? head.slice(0, nl) : head).trim();
|
|
@@ -1753,7 +1013,7 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1753
1013
|
return typeof parsed.agent === "string" && parsed.agent ? parsed.agent : null;
|
|
1754
1014
|
}
|
|
1755
1015
|
catch {
|
|
1756
|
-
return null;
|
|
1016
|
+
return null;
|
|
1757
1017
|
}
|
|
1758
1018
|
finally {
|
|
1759
1019
|
if (fd !== undefined) {
|
|
@@ -1761,39 +1021,17 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1761
1021
|
closeSync(fd);
|
|
1762
1022
|
}
|
|
1763
1023
|
catch {
|
|
1764
|
-
/* already closed */
|
|
1765
1024
|
}
|
|
1766
1025
|
}
|
|
1767
1026
|
}
|
|
1768
1027
|
}
|
|
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
1028
|
titleFor(agentId, sessionId) {
|
|
1784
1029
|
const a = this.config.agents.find((x) => x.id === agentId);
|
|
1785
1030
|
if (!a || a.session !== sessionId)
|
|
1786
|
-
return undefined;
|
|
1031
|
+
return undefined;
|
|
1787
1032
|
const t = typeof a.sessionTitle === "string" ? a.sessionTitle.trim() : "";
|
|
1788
1033
|
return t ? t : undefined;
|
|
1789
1034
|
}
|
|
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
1035
|
async ensureSessionTitle(agentId, sessionId) {
|
|
1798
1036
|
const a = this.config.agents.find((x) => x.id === agentId);
|
|
1799
1037
|
if (!a || a.session !== sessionId)
|
|
@@ -1801,8 +1039,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1801
1039
|
if (a.sessionTitle?.trim())
|
|
1802
1040
|
return;
|
|
1803
1041
|
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
1042
|
a.sessionTitle = this.sessionTitleOf(dir) ?? sessionId;
|
|
1807
1043
|
this.saveConfig();
|
|
1808
1044
|
}
|
|
@@ -1811,7 +1047,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1811
1047
|
try {
|
|
1812
1048
|
fd = openSync(path.join(dirPath, "chat.jsonl"), "r");
|
|
1813
1049
|
const CHUNK = 16 * 1024;
|
|
1814
|
-
// cap per session so one enormous log cannot dominate boot
|
|
1815
1050
|
const CAP = 256 * 1024;
|
|
1816
1051
|
const buf = Buffer.alloc(CHUNK);
|
|
1817
1052
|
let offset = 0;
|
|
@@ -1831,12 +1066,10 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1831
1066
|
e = JSON.parse(l);
|
|
1832
1067
|
}
|
|
1833
1068
|
catch {
|
|
1834
|
-
continue;
|
|
1069
|
+
continue;
|
|
1835
1070
|
}
|
|
1836
1071
|
if (e.type !== "prompt")
|
|
1837
1072
|
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
1073
|
if (e.data?.source !== "user")
|
|
1841
1074
|
return null;
|
|
1842
1075
|
return sessionTitleFrom(typeof e.data.text === "string" ? e.data.text : null);
|
|
@@ -1845,7 +1078,7 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1845
1078
|
return null;
|
|
1846
1079
|
}
|
|
1847
1080
|
catch {
|
|
1848
|
-
return null;
|
|
1081
|
+
return null;
|
|
1849
1082
|
}
|
|
1850
1083
|
finally {
|
|
1851
1084
|
if (fd !== undefined)
|
|
@@ -1853,7 +1086,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1853
1086
|
closeSync(fd);
|
|
1854
1087
|
}
|
|
1855
1088
|
catch {
|
|
1856
|
-
/* already closed */
|
|
1857
1089
|
}
|
|
1858
1090
|
}
|
|
1859
1091
|
}
|
|
@@ -1861,31 +1093,22 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1861
1093
|
try {
|
|
1862
1094
|
const file = path.join(dirPath, "session.json");
|
|
1863
1095
|
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
1096
|
const current = this.readSessionOwner(path.basename(dirPath));
|
|
1868
1097
|
if (current === agentId)
|
|
1869
1098
|
return;
|
|
1870
1099
|
const proven = this.logOwner(path.basename(dirPath));
|
|
1871
1100
|
if (proven !== agentId)
|
|
1872
|
-
return;
|
|
1101
|
+
return;
|
|
1873
1102
|
console.log(`[teapot] repairing session manifest for ${path.basename(dirPath)}: ` +
|
|
1874
1103
|
`${JSON.stringify(current)} → ${JSON.stringify(agentId)} (the log says ${JSON.stringify(agentId)})`);
|
|
1875
1104
|
}
|
|
1876
1105
|
writeFileSync(file, `${JSON.stringify({ v: 1, agentId }, null, 2)}\n`);
|
|
1877
1106
|
}
|
|
1878
1107
|
catch {
|
|
1879
|
-
/* best-effort metadata */
|
|
1880
1108
|
}
|
|
1881
1109
|
}
|
|
1882
1110
|
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
|
|
1111
|
+
const candidateIds = new Set([agentId]);
|
|
1889
1112
|
for (const a of this.config.agents)
|
|
1890
1113
|
if (a.id)
|
|
1891
1114
|
candidateIds.add(a.id);
|
|
@@ -1897,10 +1120,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1897
1120
|
const empty = [];
|
|
1898
1121
|
for (const d of readdirSync(this.sessionsRoot())) {
|
|
1899
1122
|
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
1123
|
const mineByLog = owner !== agentId && this.logOwner(d) === agentId;
|
|
1905
1124
|
if (owner !== agentId && !mineByLog)
|
|
1906
1125
|
continue;
|
|
@@ -1908,20 +1127,14 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1908
1127
|
this.writeSessionManifest(path.join(this.sessionsRoot(), d), agentId);
|
|
1909
1128
|
const dirPath = path.join(this.sessionsRoot(), d);
|
|
1910
1129
|
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
1130
|
let size = 0;
|
|
1916
1131
|
try {
|
|
1917
1132
|
size = statSync(path.join(dirPath, "chat.jsonl")).size;
|
|
1918
1133
|
}
|
|
1919
|
-
catch {
|
|
1134
|
+
catch { }
|
|
1920
1135
|
(size > 0 ? withHistory : empty).push({ d, m });
|
|
1921
1136
|
}
|
|
1922
1137
|
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
1138
|
dirs = [pick(withHistory) ?? pick(empty)].filter((x) => !!x);
|
|
1926
1139
|
}
|
|
1927
1140
|
catch {
|
|
@@ -1940,54 +1153,34 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1940
1153
|
resolveSessionDir(agentId, fresh) {
|
|
1941
1154
|
const root = this.sessionsRoot();
|
|
1942
1155
|
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
1156
|
const recorded = this.config.agents.find((a) => a.id === agentId)?.session;
|
|
1950
1157
|
if (!fresh && recorded) {
|
|
1951
1158
|
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
1159
|
const owner = this.readSessionOwner(recorded);
|
|
1956
1160
|
const mineByManifest = owner === null || owner === agentId;
|
|
1957
1161
|
const mineByLog = owner !== agentId && this.logOwner(recorded) === agentId;
|
|
1958
1162
|
if (existsSync(dir) && (mineByManifest || mineByLog)) {
|
|
1959
1163
|
if (mineByLog)
|
|
1960
|
-
this.writeSessionManifest(dir, agentId);
|
|
1164
|
+
this.writeSessionManifest(dir, agentId);
|
|
1961
1165
|
return dir;
|
|
1962
1166
|
}
|
|
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
1167
|
console.log(`[teapot] session ${recorded} for ${agentId} is unusable ` +
|
|
1967
1168
|
`(${existsSync(dir) ? `manifest owner ${JSON.stringify(owner)}` : "directory missing"}); ` +
|
|
1968
1169
|
`starting a new one`);
|
|
1969
1170
|
}
|
|
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
1171
|
if (!fresh) {
|
|
1975
1172
|
const existing = this.findLatestSessionDir(agentId);
|
|
1976
1173
|
if (existing)
|
|
1977
|
-
return existing;
|
|
1174
|
+
return existing;
|
|
1978
1175
|
}
|
|
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
1176
|
const selfDir = path.join(root, agentId);
|
|
1983
1177
|
const selfOwner = this.readSessionOwner(agentId);
|
|
1984
1178
|
const selfUsable = selfOwner === null || selfOwner === agentId;
|
|
1985
1179
|
if (!fresh &&
|
|
1986
1180
|
selfUsable &&
|
|
1987
1181
|
statSync(path.join(selfDir, "chat.jsonl"), { throwIfNoEntry: false })?.size) {
|
|
1988
|
-
return selfDir;
|
|
1182
|
+
return selfDir;
|
|
1989
1183
|
}
|
|
1990
|
-
// fresh incarnation: guaranteed-unique directory
|
|
1991
1184
|
let sid = "";
|
|
1992
1185
|
do {
|
|
1993
1186
|
sid = `${agentId}-${randomUUID().slice(0, 8)}`;
|
|
@@ -1995,18 +1188,10 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1995
1188
|
this.writeSessionManifest(path.join(root, sid), agentId);
|
|
1996
1189
|
return path.join(root, sid);
|
|
1997
1190
|
}
|
|
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
1191
|
allSessions() {
|
|
2004
1192
|
const candidateIds = new Set(this.config.agents.map((a) => a.id).filter(Boolean));
|
|
2005
1193
|
for (const [liveId] of this.agents)
|
|
2006
1194
|
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
1195
|
let names = [];
|
|
2011
1196
|
try {
|
|
2012
1197
|
names = readdirSync(this.sessionsRoot());
|
|
@@ -2019,8 +1204,7 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2019
1204
|
const dirPath = path.join(this.sessionsRoot(), d);
|
|
2020
1205
|
const { owner, hadManifest } = this.inferSessionOwner(d, candidateIds);
|
|
2021
1206
|
if (!owner)
|
|
2022
|
-
continue;
|
|
2023
|
-
// backfill legacy dirs so the inference result becomes durable
|
|
1207
|
+
continue;
|
|
2024
1208
|
if (!hadManifest)
|
|
2025
1209
|
this.writeSessionManifest(dirPath, owner);
|
|
2026
1210
|
let m = 0;
|
|
@@ -2030,36 +1214,18 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2030
1214
|
m = st.mtimeMs;
|
|
2031
1215
|
size = st.size;
|
|
2032
1216
|
}
|
|
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
|
-
*/
|
|
1217
|
+
catch { }
|
|
2045
1218
|
const title = owner ? this.titleFor(owner, d) : undefined;
|
|
2046
1219
|
out.push({ id: d, agentId: owner, mtimeMs: m, sizeBytes: size, ...(title ? { title } : {}) });
|
|
2047
1220
|
}
|
|
2048
1221
|
return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
2049
1222
|
}
|
|
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
1223
|
sessionsOf(agentId) {
|
|
2056
1224
|
return this.allSessions()
|
|
2057
1225
|
.filter((s) => s.agentId === agentId)
|
|
2058
1226
|
.map(({ id, mtimeMs, sizeBytes }) => ({ id, mtimeMs, sizeBytes }));
|
|
2059
1227
|
}
|
|
2060
|
-
/** resolve an internal session id to (ownerAgentId, absolute dir) — null when it doesn't exist */
|
|
2061
1228
|
sessionById(sessionId) {
|
|
2062
|
-
// internal ids are directory names: refuse anything path-like up front
|
|
2063
1229
|
if (!sessionId || sessionId.includes("/") || sessionId.includes("\\") || sessionId.startsWith("."))
|
|
2064
1230
|
return null;
|
|
2065
1231
|
const root = this.sessionsRoot();
|
|
@@ -2088,32 +1254,10 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2088
1254
|
this.config.agents = this.config.agents.filter((a) => a.id !== id);
|
|
2089
1255
|
this.saveConfig();
|
|
2090
1256
|
}
|
|
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
1257
|
async renameSession(agentId, sessionId, title) {
|
|
2113
1258
|
const a = this.config.agents.find((x) => x.id === agentId);
|
|
2114
1259
|
if (!a)
|
|
2115
1260
|
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
1261
|
if (a.session !== sessionId)
|
|
2118
1262
|
throw new Error(`agent ${agentId} is not on session ${sessionId}`);
|
|
2119
1263
|
const clean = title.replace(/\s+/g, " ").trim().slice(0, 80);
|
|
@@ -2121,10 +1265,7 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2121
1265
|
throw new Error("a session title cannot be empty");
|
|
2122
1266
|
a.sessionTitle = clean;
|
|
2123
1267
|
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
1268
|
this.agents.get(agentId)?.setSessionTitle(clean);
|
|
2127
|
-
// the sidebar caches titles by session id, so it has to learn about this one
|
|
2128
1269
|
bus.emit("update", { kind: "agent-update", agentId });
|
|
2129
1270
|
return { title: clean };
|
|
2130
1271
|
}
|
|
@@ -2132,11 +1273,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2132
1273
|
const root = this.config.agents.find((a) => a.id === id);
|
|
2133
1274
|
if (!root)
|
|
2134
1275
|
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
1276
|
const ids = [id, ...this.subtreeOf(id).map((s) => s.id)];
|
|
2141
1277
|
const now = Date.now();
|
|
2142
1278
|
for (const one of ids) {
|
|
@@ -2145,8 +1281,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2145
1281
|
this.agents.delete(one);
|
|
2146
1282
|
await live.dispose();
|
|
2147
1283
|
}
|
|
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
1284
|
closeTerminalsFor(one);
|
|
2151
1285
|
const ac = this.config.agents.find((a) => a.id === one);
|
|
2152
1286
|
if (!ac)
|
|
@@ -2157,13 +1291,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2157
1291
|
this.saveConfig();
|
|
2158
1292
|
return { archived: true, ids };
|
|
2159
1293
|
}
|
|
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
1294
|
async restoreAgent(id) {
|
|
2168
1295
|
const ac = this.config.agents.find((a) => a.id === id);
|
|
2169
1296
|
if (!ac)
|
|
@@ -2177,32 +1304,18 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2177
1304
|
delete e.archivedAt;
|
|
2178
1305
|
}
|
|
2179
1306
|
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
1307
|
try {
|
|
2183
1308
|
await this.addAgent(ac);
|
|
2184
1309
|
return { restored: true, live: true, ids };
|
|
2185
1310
|
}
|
|
2186
1311
|
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
1312
|
console.error(`[teapot] restored "${id}" but could not start it: ${err.message}`);
|
|
2190
1313
|
return { restored: true, live: false, ids };
|
|
2191
1314
|
}
|
|
2192
1315
|
}
|
|
2193
|
-
/** Archived entries, newest first — what the settings panel lists. */
|
|
2194
1316
|
archivedAgents() {
|
|
2195
1317
|
const archived = this.config.agents.filter((a) => a.archived);
|
|
2196
1318
|
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
1319
|
const sessionCounts = new Map();
|
|
2207
1320
|
for (const s of this.allSessions())
|
|
2208
1321
|
sessionCounts.set(s.agentId, (sessionCounts.get(s.agentId) ?? 0) + 1);
|
|
@@ -2212,20 +1325,11 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2212
1325
|
workspace: a.workspace,
|
|
2213
1326
|
...(a.archivedAt !== undefined ? { archivedAt: a.archivedAt } : {}),
|
|
2214
1327
|
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
1328
|
...(a.parent && ids.has(a.parent) ? { parent: a.parent } : {}),
|
|
2220
1329
|
subs: this.subtreeOf(a.id).filter((s) => ids.has(s.id)).length,
|
|
2221
1330
|
}))
|
|
2222
1331
|
.sort((a, b) => (b.archivedAt ?? 0) - (a.archivedAt ?? 0));
|
|
2223
1332
|
}
|
|
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
1333
|
async purgeAgent(id) {
|
|
2230
1334
|
const ac = this.config.agents.find((a) => a.id === id);
|
|
2231
1335
|
if (!ac)
|
|
@@ -2235,8 +1339,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2235
1339
|
this.agents.delete(id);
|
|
2236
1340
|
await live.dispose();
|
|
2237
1341
|
}
|
|
2238
|
-
// collect the directories BEFORE dropping the config entry, because
|
|
2239
|
-
// `sessionsOf` resolves ownership through the config + live map
|
|
2240
1342
|
const dirs = this.sessionsOf(id);
|
|
2241
1343
|
this.config.agents = this.config.agents.filter((a) => a.id !== id);
|
|
2242
1344
|
this.saveConfig();
|
|
@@ -2252,12 +1354,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2252
1354
|
}
|
|
2253
1355
|
return { purged: true, dirs: removed };
|
|
2254
1356
|
}
|
|
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
1357
|
async setAgentModel(id, providerName, model, contextWindowTokens, reasoningEffort) {
|
|
2262
1358
|
const agent = this.agents.get(id);
|
|
2263
1359
|
if (!agent)
|
|
@@ -2282,38 +1378,27 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2282
1378
|
const ac = this.config.agents.find((a) => a.id === id);
|
|
2283
1379
|
effectiveProvider = ac?.provider ?? this.config.defaultProvider ?? "";
|
|
2284
1380
|
}
|
|
2285
|
-
// re-resolve the catalogue for the (possibly new) model so the
|
|
2286
|
-
// effort gate reflects THIS model's advertised capabilities
|
|
2287
1381
|
const effortList = await fetchModelList(llm.baseUrl, llm.apiKey).catch(() => []);
|
|
2288
1382
|
const meta = effortList.find((m) => m.id === (model || llm.model));
|
|
2289
1383
|
const opts = agent;
|
|
2290
1384
|
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
1385
|
opts.opts.reasoningEffort = isReasoningEffort(reasoningEffort) ? reasoningEffort : "";
|
|
2294
1386
|
const cfgEntry = this.config.agents.find((a) => a.id === id);
|
|
2295
1387
|
if (cfgEntry)
|
|
2296
1388
|
cfgEntry.reasoningEffort = opts.opts.reasoningEffort || undefined;
|
|
2297
1389
|
}
|
|
2298
|
-
// keep `undefined` for "published nothing" — see the note on the option
|
|
2299
|
-
opts.opts.supportedParameters = meta?.supportedParameters;
|
|
2300
1390
|
opts.opts.supportedEfforts = meta?.supportedEfforts;
|
|
2301
1391
|
if (!llm.model)
|
|
2302
1392
|
throw new Error("no model resolved (pass model or set one on the provider)");
|
|
2303
1393
|
if (!llm.baseUrl)
|
|
2304
1394
|
throw new Error("no baseUrl resolved");
|
|
2305
1395
|
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
1396
|
agent.opts.provider = effectiveProvider;
|
|
2311
1397
|
const ac = this.config.agents.find((a) => a.id === id);
|
|
2312
1398
|
if (ac && providerName)
|
|
2313
1399
|
ac.provider = providerName;
|
|
2314
1400
|
if (ac)
|
|
2315
1401
|
ac.model = llm.model;
|
|
2316
|
-
// learn the new model's window (metadata lookup) unless explicitly given
|
|
2317
1402
|
let win = contextWindowTokens;
|
|
2318
1403
|
if (win === undefined && ac && !ac.contextWindowTokens && !this.config.contextWindowTokens) {
|
|
2319
1404
|
try {
|
|
@@ -2321,7 +1406,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2321
1406
|
win = contextLengthFor(list, llm.model);
|
|
2322
1407
|
}
|
|
2323
1408
|
catch {
|
|
2324
|
-
/* metadata is best-effort */
|
|
2325
1409
|
}
|
|
2326
1410
|
}
|
|
2327
1411
|
if (win !== undefined) {
|
|
@@ -2329,18 +1413,14 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2329
1413
|
ac.contextWindowTokens = win;
|
|
2330
1414
|
const opts = agent;
|
|
2331
1415
|
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
1416
|
if (!agent.compactBudgetIsManual)
|
|
2336
1417
|
opts.opts.contextTokenBudget = Math.round(win * 0.75);
|
|
2337
1418
|
}
|
|
2338
|
-
// re-price for the NEW model (cost estimate follows the model switch)
|
|
2339
1419
|
try {
|
|
2340
1420
|
const list = await fetchModelList(llm.baseUrl, llm.apiKey);
|
|
2341
1421
|
await agent.setModelPricing(list.find((m) => m.id === llm.model)?.pricing);
|
|
2342
1422
|
}
|
|
2343
|
-
catch {
|
|
1423
|
+
catch { }
|
|
2344
1424
|
this.saveConfig();
|
|
2345
1425
|
void agent.log.append("system_note", agent.currentSession, agent.currentBranch, {
|
|
2346
1426
|
event: "model-changed",
|
|
@@ -2349,18 +1429,9 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2349
1429
|
});
|
|
2350
1430
|
return { provider: effectiveProvider, model: llm.model };
|
|
2351
1431
|
}
|
|
2352
|
-
/** 4 ticks/min; each tick is a few integer compares per task. */
|
|
2353
1432
|
tick() {
|
|
2354
1433
|
const now = new Date();
|
|
2355
1434
|
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
1435
|
if (bus.listenerCount("update") > 0)
|
|
2365
1436
|
bus.emit("update", { kind: "metrics", metrics: this.metrics() });
|
|
2366
1437
|
for (const t of this.tasks) {
|
|
@@ -2368,17 +1439,15 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2368
1439
|
if (!matches(t.schedule, now))
|
|
2369
1440
|
continue;
|
|
2370
1441
|
if (t.lastRunMin === minuteKey)
|
|
2371
|
-
continue;
|
|
1442
|
+
continue;
|
|
2372
1443
|
t.lastRunMin = minuteKey;
|
|
2373
1444
|
t.lastRunAt = Date.now();
|
|
2374
|
-
// persist so a master restart doesn't re-fire the same minute
|
|
2375
1445
|
t.task.lastRunMin = minuteKey;
|
|
2376
1446
|
this.saveConfig();
|
|
2377
1447
|
const agent = this.agents.get(t.task.agent);
|
|
2378
1448
|
if (!agent)
|
|
2379
1449
|
continue;
|
|
2380
1450
|
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
1451
|
bus.emit("update", { kind: "tasks", tasks: this.tasksView() });
|
|
2383
1452
|
void (async () => {
|
|
2384
1453
|
if (t.task.forked)
|
|
@@ -2393,7 +1462,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2393
1462
|
}
|
|
2394
1463
|
}
|
|
2395
1464
|
}
|
|
2396
|
-
/** Everything the UI needs to make cron schedules legible. */
|
|
2397
1465
|
tasksView() {
|
|
2398
1466
|
return this.tasks.map((t) => ({
|
|
2399
1467
|
id: t.task.id,
|
|
@@ -2410,36 +1478,10 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2410
1478
|
const cu = process.cpuUsage();
|
|
2411
1479
|
return {
|
|
2412
1480
|
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
1481
|
rssMb: +(mu.rss / 1048576).toFixed(1),
|
|
2424
1482
|
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
1483
|
heapTotalMb: +(mu.heapTotal / 1048576).toFixed(1),
|
|
2433
|
-
/** non-heap memory: buffers, native strings, the compiled bundle — the usual RSS/heap gap */
|
|
2434
1484
|
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
1485
|
cpuMsTotal: Math.round((cu.user + cu.system) / 1000),
|
|
2444
1486
|
loadavg1: +(os.loadavg()[0]).toFixed(2),
|
|
2445
1487
|
agents: [...this.agents.values()].map((a) => ({
|
|
@@ -2451,21 +1493,10 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2451
1493
|
})),
|
|
2452
1494
|
};
|
|
2453
1495
|
}
|
|
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
1496
|
static VERSION = readAppVersion();
|
|
2464
|
-
/** Return the current server version. */
|
|
2465
1497
|
getVersion() {
|
|
2466
1498
|
return Master.VERSION;
|
|
2467
1499
|
}
|
|
2468
|
-
/** Gracefully stop all agents with a timeout. Returns when all are stopped or timeout. */
|
|
2469
1500
|
async stopAllAgents(timeoutMs = 30_000) {
|
|
2470
1501
|
console.log(`[teapot] stopping all ${this.agents.size} agents...`);
|
|
2471
1502
|
const ids = [...this.agents.keys()];
|
|
@@ -2479,21 +1510,8 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2479
1510
|
catch (err) {
|
|
2480
1511
|
console.error("[teapot] dispose error:", err);
|
|
2481
1512
|
}
|
|
2482
|
-
// remove from map so a re-spawn after restart doesn't carry state
|
|
2483
1513
|
this.agents.delete(id);
|
|
2484
1514
|
});
|
|
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
1515
|
let timer;
|
|
2498
1516
|
const timeout = new Promise((_, reject) => {
|
|
2499
1517
|
timer = setTimeout(() => reject(new Error("agent stop timeout")), timeoutMs);
|
|
@@ -2505,84 +1523,14 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2505
1523
|
finally {
|
|
2506
1524
|
if (timer)
|
|
2507
1525
|
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
1526
|
const n = closeAllTerminals();
|
|
2517
1527
|
if (n)
|
|
2518
1528
|
console.log(`[teapot] ⌨ closed ${n} terminal(s)`);
|
|
2519
1529
|
}
|
|
2520
1530
|
}
|
|
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
1531
|
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
1532
|
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
1533
|
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
1534
|
const ids = [...this.agents.entries()]
|
|
2587
1535
|
.filter(([, a]) => a.isLive())
|
|
2588
1536
|
.map(([id]) => id);
|
|
@@ -2605,28 +1553,14 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2605
1553
|
}));
|
|
2606
1554
|
return stuck.filter((v) => v !== null);
|
|
2607
1555
|
}
|
|
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
1556
|
takeResumeSet() {
|
|
2615
1557
|
const out = [];
|
|
2616
1558
|
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
1559
|
if (a.isLive())
|
|
2620
1560
|
out.push(id);
|
|
2621
1561
|
}
|
|
2622
1562
|
return out;
|
|
2623
1563
|
}
|
|
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
1564
|
async resumeAfterUpdate() {
|
|
2631
1565
|
const ids = this.config.resumeAfterUpdate ?? [];
|
|
2632
1566
|
if (!ids.length)
|
|
@@ -2637,7 +1571,7 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2637
1571
|
for (const id of ids) {
|
|
2638
1572
|
const a = this.agents.get(id);
|
|
2639
1573
|
if (!a)
|
|
2640
|
-
continue;
|
|
1574
|
+
continue;
|
|
2641
1575
|
try {
|
|
2642
1576
|
a.start("resumed after a live update");
|
|
2643
1577
|
started.push(id);
|
|
@@ -2655,122 +1589,39 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
2655
1589
|
}
|
|
2656
1590
|
async restartServer() {
|
|
2657
1591
|
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
1592
|
this.updateInFlight = true;
|
|
2667
1593
|
try {
|
|
2668
1594
|
await this.handoverToNewImage();
|
|
2669
1595
|
}
|
|
2670
1596
|
finally {
|
|
2671
|
-
// only reached when the handover did NOT replace the process
|
|
2672
1597
|
this.updateInFlight = false;
|
|
2673
1598
|
}
|
|
2674
1599
|
}
|
|
2675
|
-
/** wind down, record what to resume, and exec. Never returns on success. */
|
|
2676
1600
|
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
1601
|
this.config.resumeAfterUpdate = this.takeResumeSet();
|
|
2702
1602
|
const interrupted = await this.windDownAllAgents(this.updateWindDownMs);
|
|
2703
1603
|
if (interrupted.length)
|
|
2704
1604
|
console.log(`[teapot] live update: ${interrupted.length} agent(s) did not reach a turn boundary in ` +
|
|
2705
1605
|
`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
1606
|
this.saveConfig();
|
|
2712
1607
|
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
1608
|
const closed = closeAllTerminals();
|
|
2715
1609
|
if (closed)
|
|
2716
1610
|
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
1611
|
const env = { ...process.env, TEAPOT_RESUMED: "1" };
|
|
2725
1612
|
const execve = process.execve;
|
|
2726
1613
|
if (!execve) {
|
|
2727
1614
|
throw new Error("live update needs process.execve (Node 22+); restart the server by hand on this runtime");
|
|
2728
1615
|
}
|
|
2729
1616
|
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
1617
|
const gc = globalThis.gc;
|
|
2743
1618
|
if (typeof gc === "function") {
|
|
2744
|
-
// twice: the first pass frees what is unreachable, the second collects what the first freed
|
|
2745
1619
|
gc();
|
|
2746
1620
|
gc();
|
|
2747
1621
|
}
|
|
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
1622
|
const FORWARDABLE = /^--(experimental-strip-types|experimental-transform-types|no-warnings|enable-source-maps|import|require|conditions|expose-gc)$/;
|
|
2762
1623
|
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
1624
|
await execve(process.execPath, [process.execPath, ...forwardable, ...process.argv.slice(1)], env);
|
|
2773
|
-
// execve only resolves if it FAILED to exec
|
|
2774
1625
|
throw new Error("execve returned without replacing the process");
|
|
2775
1626
|
}
|
|
2776
1627
|
}
|