teapot-coding-agent 0.29.4 → 0.29.6

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.
@@ -628,9 +628,25 @@ export class Agent {
628
628
  this.goal = stored ?? { text: "", status: "active", updatedAt: new Date().toISOString() };
629
629
  // operator-maintained task list lives beside goal.md
630
630
  this.todo = await fs.readFile(this.todoFile, "utf8").catch(() => "");
631
- await this.refreshSkills();
632
- // the conversation is NOT restored here: boot cost stays O(agents), not
633
- // O(history). It is rebuilt lazily by ensureReady() on first interaction.
631
+ // #143: NO skill scan here. It was the single largest boot cost — 493 agents
632
+ // each walking their roots (readdir + stat, recursively) measured 2120 ms of
633
+ // a 4.7 s boot. Every one of those walks was redundant:
634
+ //
635
+ // - the turn loop calls `refreshSkills()` at the TOP of every turn, before
636
+ // the system prompt (which embeds the catalogue) is built, so the scan is
637
+ // always current before any LLM request
638
+ // - the `list_skills` tool calls it itself before reading the cache
639
+ // - `/api/agents/:id/skills` (what the panel uses) does its own fresh
640
+ // `currentSkills()` walk and never reads this cache
641
+ //
642
+ // Caching the walk was the other option and was WORSE: the fingerprint exists
643
+ // to notice a NESTED skill's add/edit, which does not change its root's mtime,
644
+ // so any TTL/mtime key reintroduces the staleness the walk was written to fix
645
+ // (the skills-fingerprint tests pin exactly that). Skipping boot's copy costs
646
+ // no freshness at all.
647
+ //
648
+ // The conversation is likewise not restored here: boot cost stays O(agents),
649
+ // not O(history). Both are rebuilt lazily by ensureReady()/the turn loop.
634
650
  if (this.opts.restoreSession) {
635
651
  this.status = "stopped";
636
652
  this.statusReason = "session not loaded — select it or send a prompt";
@@ -57,11 +57,51 @@ export class EventLog {
57
57
  this.filePath = filePath;
58
58
  this.agentId = agentId;
59
59
  }
60
+ /**
61
+ * Bytes read from the END of the log to rebuild the in-memory head.
62
+ *
63
+ * `load()` used to `readFile()` the ENTIRE log and `JSON.parse` every event,
64
+ * only to learn two things: the highest `seq`, and the last event id per
65
+ * branch. MEASURED on a real 813-session data dir (369 MB of logs): that read
66
+ * was the single largest boot cost — 1603 ms of `load()` against 493 agents,
67
+ * plus the GC pressure of holding one whole log as a string and its `split`
68
+ * array (~50 MB of temporary garbage for a 23 MB log).
69
+ *
70
+ * Both facts live at the END of an append-only file:
71
+ * - `seq` is assigned monotonically, so the last complete line holds the max
72
+ * - `lastEventId()` is only ever called for the CURRENT branch (fork/spawn),
73
+ * which is by definition the most recently written one
74
+ *
75
+ * So the tail is both sufficient and exact. A branch that has been dormant for
76
+ * more than WINDOW bytes loses its in-memory head; that only affects
77
+ * `parent:` on the next append to that stale branch, and the paths that matter
78
+ * (`fork`, child spawn) re-seed explicitly through `seedBranch()`.
79
+ */
80
+ static LOAD_TAIL_BYTES = 512 * 1024;
60
81
  async load() {
61
82
  mkdirSync(path.dirname(this.filePath), { recursive: true });
62
83
  try {
63
- const { readFile } = await import("node:fs/promises");
64
- const text = await readFile(this.filePath, "utf8");
84
+ const { readFile, stat, open } = await import("node:fs/promises");
85
+ const st = await stat(this.filePath);
86
+ let text;
87
+ if (st.size <= EventLog.LOAD_TAIL_BYTES) {
88
+ text = await readFile(this.filePath, "utf8"); // small log: read it all
89
+ }
90
+ else {
91
+ const start = st.size - EventLog.LOAD_TAIL_BYTES;
92
+ const fh = await open(this.filePath, "r");
93
+ try {
94
+ const buf = Buffer.alloc(EventLog.LOAD_TAIL_BYTES);
95
+ const { bytesRead } = await fh.read(buf, 0, buf.length, start);
96
+ text = buf.subarray(0, bytesRead).toString("utf8");
97
+ }
98
+ finally {
99
+ await fh.close();
100
+ }
101
+ // the window starts mid-line: drop the partial first line
102
+ const nl = text.indexOf("\n");
103
+ text = nl === -1 ? "" : text.slice(nl + 1);
104
+ }
65
105
  for (const line of text.split("\n")) {
66
106
  if (!line.trim())
67
107
  continue;
package/dist/master.js CHANGED
@@ -13,9 +13,19 @@ import { spawn } from "node:child_process";
13
13
  import { Agent } from "./agent/agent.js";
14
14
  import { parseSchedule, matches, nextFireAt } from "./scheduler/cron.js";
15
15
  import { bus } from "./bus.js";
16
- import { fetchModelList, contextLengthFor, isReasoningEffort, } from "./model-meta.js";
16
+ import { fetchModelList, peekModelList, contextLengthFor, isReasoningEffort, } from "./model-meta.js";
17
17
  /** how deep sub-agent spawning may nest (parent=0, its subs=1, …) */
18
18
  const MAX_SPAWN_DEPTH = 3;
19
+ /**
20
+ * How long boot waits for an endpoint's model catalogue (#143).
21
+ *
22
+ * The window and the reasoning-effort gate both come from `/models`, so a boot
23
+ * that waits on it is waiting for metadata, not for anything it needs to start.
24
+ * The lookup is fired concurrently for every distinct endpoint before the agent
25
+ * loop (see prefetchModelLists), so this bound is paid once per ENDPOINT rather
26
+ * than once per agent — and only when the endpoint is slow or dead.
27
+ */
28
+ const MODEL_META_BOOT_MS = 1_500;
19
29
  /** statuses that mean a child has finished working, for #130 */
20
30
  const SETTLED = new Set(["idle", "stopped", "waiting", "error"]);
21
31
  /**
@@ -362,6 +372,16 @@ export class Master {
362
372
  */
363
373
  deferConfigWrites = false;
364
374
  configDirty = false;
375
+ /**
376
+ * Endpoints whose catalogue boot has already WAITED for (#143).
377
+ *
378
+ * The wait must be paid once per endpoint, not once per agent. `Promise.race`
379
+ * against a timeout is per-CALL: with a dead endpoint every agent on it paid
380
+ * the full timeout again, because the shared request was still pending. 493
381
+ * agents over one unreachable provider measured 4.4s of boot spent purely
382
+ * idle, for a single request.
383
+ */
384
+ metaWaited = new Set();
365
385
  /** write once and clear the dirty flag — used at the end of a batched section */
366
386
  flushConfig() {
367
387
  this.configDirty = false;
@@ -495,6 +515,14 @@ export class Master {
495
515
  // boot, which is its own silent data loss.
496
516
  this.deferConfigWrites = true;
497
517
  this.configDirty = false;
518
+ // #143: start every endpoint's model-catalogue lookup NOW, concurrently.
519
+ //
520
+ // `addAgent` awaits this per agent, and the endpoints are shared by many
521
+ // agents (493 agents over 6 providers here). Fired up front, the requests
522
+ // overlap and each subsequent agent finds the promise already in flight or
523
+ // resolved — instead of the requests being serialised behind the agent loop,
524
+ // where a slow endpoint's timeout is paid again and again.
525
+ this.prefetchModelLists();
498
526
  try {
499
527
  for (const ac of this.config.agents) {
500
528
  try {
@@ -596,6 +624,38 @@ export class Master {
596
624
  * Restarts reuse the latest existing session; fresh creations never touch
597
625
  * an older one's history.
598
626
  */
627
+ /**
628
+ * Fire the model-catalogue lookup for every distinct endpoint, concurrently.
629
+ *
630
+ * `fetchModelList` already shares an in-flight request per baseUrl, so this is
631
+ * about STARTING them together: without it the requests begin one at a time, as
632
+ * the agent loop reaches each provider, and a slow endpoint's timeout is paid
633
+ * serially by every agent on it.
634
+ *
635
+ * Best-effort by design — nothing awaits this. A failure is cached briefly by
636
+ * `fetchModelList`, and an agent whose lookup is not ready simply gets no
637
+ * inferred window, which is the same outcome as today's timeout.
638
+ */
639
+ prefetchModelLists() {
640
+ const endpoints = new Set();
641
+ const add = (baseUrl, apiKey) => {
642
+ if (!baseUrl)
643
+ return;
644
+ const key = baseUrl.replace(/\/+$/, "");
645
+ if (endpoints.has(key))
646
+ return;
647
+ endpoints.add(key);
648
+ void fetchModelList(baseUrl, apiKey).catch(() => []);
649
+ };
650
+ for (const ac of this.config.agents) {
651
+ const prov = this.config.providers?.[ac.provider ?? this.config.defaultProvider ?? ""];
652
+ add(ac.baseUrl ?? prov?.baseUrl ?? this.config.llm.baseUrl, ac.apiKey ?? prov?.apiKey ?? this.config.llm.apiKey);
653
+ }
654
+ // no configured agents yet (first-run wizard) — still warm the default so the
655
+ // first created agent does not wait
656
+ if (endpoints.size === 0)
657
+ add(this.config.llm.baseUrl, this.config.llm.apiKey);
658
+ }
599
659
  async addAgent(ac, opts = {}) {
600
660
  // #72: an agent id becomes a DIRECTORY name (sessions/<id>-<hash>), so it
601
661
  // must be a safe path segment. Every caller sanitised its own id — the HTTP
@@ -631,24 +691,33 @@ export class Master {
631
691
  // spawn-tree depth: computed from the config chain (ac may not be
632
692
  // registered yet when this runs — spawnChildFor persists AFTER creation)
633
693
  const myDepth = ac.parent ? this.depthOf(ac.parent) + 1 : 0;
634
- // learn the model's real context window from provider metadata (once per
635
- // endpoint, cached) unless the config already pins one explicitly
636
- let inferredWindow;
637
- if (!ac.contextWindowTokens && !this.config.contextWindowTokens) {
638
- // don't block the event loop on provider metadata: fire the lookup but
639
- // keep boot moving. The agent's gauge fills in when it resolves.
640
- const listPromise = fetchModelList(llm.baseUrl, llm.apiKey);
641
- inferredWindow = await Promise.race([
642
- listPromise.then((l) => contextLengthFor(l, llm.model)),
643
- new Promise((r) => setTimeout(() => r(undefined), 1_500)),
694
+ // Model metadata: the context window (for the compaction budget) and the
695
+ // catalogue entry that gates reasoning_effort support (#47) come from the
696
+ // SAME `/models` list.
697
+ //
698
+ // #143: boot must not WAIT for this. It only tunes the compaction budget and
699
+ // the effort controls, so a missing list costs nothing that matters; what it
700
+ // cost was boot time — measured at 4.4s of pure idle on 493 agents pointed at
701
+ // one unreachable endpoint, because the `Promise.race` timeout below is
702
+ // per-CALL and the shared request stayed pending for every agent.
703
+ //
704
+ // So: use the catalogue if `prefetchModelLists()` has already produced it
705
+ // (the common case — the lookups were fired when boot started), and wait only
706
+ // ONCE per endpoint for the stragglers, where the wait overlaps a request
707
+ // that is already in flight.
708
+ const needsWindow = !ac.contextWindowTokens && !this.config.contextWindowTokens;
709
+ const root = llm.baseUrl.replace(/\/+$/, "");
710
+ let list = peekModelList(llm.baseUrl);
711
+ if (list === undefined && !this.metaWaited.has(root)) {
712
+ this.metaWaited.add(root);
713
+ list = await Promise.race([
714
+ fetchModelList(llm.baseUrl, llm.apiKey),
715
+ // a placeholder so the race can settle without the list
716
+ new Promise((r) => setTimeout(() => r(undefined), MODEL_META_BOOT_MS)),
644
717
  ]);
645
- void listPromise.catch(() => []);
646
718
  }
647
- // catalogue entry for this model — gates reasoning_effort support (#47)
648
- const modelMeta = (await Promise.race([
649
- fetchModelList(llm.baseUrl, llm.apiKey),
650
- new Promise((r) => setTimeout(() => r(undefined), 1_500)),
651
- ]))?.find((m) => m.id === llm.model);
719
+ const inferredWindow = needsWindow && list ? contextLengthFor(list, llm.model) : undefined;
720
+ const modelMeta = list?.find((m) => m.id === llm.model);
652
721
  const sessionDir = this.resolveSessionDir(ac.id, opts.fresh === true);
653
722
  await mkdirSync(sessionDir, { recursive: true });
654
723
  // every session dir this master hands out carries an ownership manifest —
@@ -1200,11 +1269,54 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1200
1269
  }
1201
1270
  return { owner: best?.id ?? null, hadManifest: false };
1202
1271
  }
1272
+ /**
1273
+ * Who does this session directory really belong to, according to its LOG?
1274
+ *
1275
+ * The manifest is a CACHE; the log is the truth. Every event carries the
1276
+ * `agent` that wrote it, and only that agent ever appends — so the first
1277
+ * event's value cannot be stale or wrong.
1278
+ *
1279
+ * This matters because ownership decided from the manifest ALONE is how a
1280
+ * whole data directory gets abandoned. A manifest that is missing, unreadable,
1281
+ * or simply wrong made `inferSessionOwner` reject every directory the agent
1282
+ * owned, `findLatestSessionDir` return null, and the boot mint a fresh empty
1283
+ * session — REWRITING the agent's pointer in config.json. Nothing warned, and
1284
+ * the real history was left on disk, orphaned and unreachable.
1285
+ *
1286
+ * Measured after an accidental in-place `addAgent()` run had rewritten 813
1287
+ * manifests: 493 agents were silently moved to empty sessions, and the
1288
+ * mapping back was only recoverable from these `agent` fields.
1289
+ */
1290
+ logOwner(dirName) {
1291
+ try {
1292
+ const first = readFileSync(path.join(this.sessionsRoot(), dirName, "chat.jsonl"), "utf8")
1293
+ .split("\n")
1294
+ .find((l) => l.trim());
1295
+ if (!first)
1296
+ return null;
1297
+ const parsed = JSON.parse(first);
1298
+ return typeof parsed.agent === "string" && parsed.agent ? parsed.agent : null;
1299
+ }
1300
+ catch {
1301
+ return null; // no log / unreadable head — cannot prove anything
1302
+ }
1303
+ }
1203
1304
  writeSessionManifest(dirPath, agentId) {
1204
1305
  try {
1205
1306
  const file = path.join(dirPath, "session.json");
1206
- if (existsSync(file))
1207
- return; // never clobber an existing manifest
1307
+ if (existsSync(file)) {
1308
+ // A WRONG manifest must be repairable, or the mistake is permanent: the
1309
+ // old code returned here unconditionally, so one bad value kept every
1310
+ // later boot from recognising the directory (see logOwner()).
1311
+ const current = this.readSessionOwner(path.basename(dirPath));
1312
+ if (current === agentId)
1313
+ return;
1314
+ const proven = this.logOwner(path.basename(dirPath));
1315
+ if (proven !== agentId)
1316
+ return; // not provably ours — leave it alone
1317
+ console.log(`[teapot] repairing session manifest for ${path.basename(dirPath)}: ` +
1318
+ `${JSON.stringify(current)} → ${JSON.stringify(agentId)} (the log says ${JSON.stringify(agentId)})`);
1319
+ }
1208
1320
  writeFileSync(file, `${JSON.stringify({ v: 1, agentId }, null, 2)}\n`);
1209
1321
  }
1210
1322
  catch {
@@ -1229,8 +1341,15 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1229
1341
  const empty = [];
1230
1342
  for (const d of readdirSync(this.sessionsRoot())) {
1231
1343
  const { owner } = this.inferSessionOwner(d, candidateIds);
1232
- if (owner !== agentId)
1344
+ // A wrong manifest must not hide a directory: consult the LOG before
1345
+ // rejecting. Without this, one bad manifest value made every dir the
1346
+ // agent owned invisible here, so the caller minted a fresh session and
1347
+ // rewrote config — silently orphaning the real history.
1348
+ const mineByLog = owner !== agentId && this.logOwner(d) === agentId;
1349
+ if (owner !== agentId && !mineByLog)
1233
1350
  continue;
1351
+ if (mineByLog)
1352
+ this.writeSessionManifest(path.join(this.sessionsRoot(), d), agentId);
1234
1353
  const dirPath = path.join(this.sessionsRoot(), d);
1235
1354
  const m = this.sessionMtime(dirPath);
1236
1355
  // an EMPTY chat.jsonl means the session was never really used — but it
@@ -1285,13 +1404,23 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1285
1404
  const recorded = this.config.agents.find((a) => a.id === agentId)?.session;
1286
1405
  if (!fresh && recorded) {
1287
1406
  const dir = path.join(root, recorded);
1288
- // only trust it when it is really THIS agent's dir (ownership comes from
1289
- // the manifest; a renamed/reused dir must not resurrect a ghost)
1407
+ // Trust it when it exists and is really THIS agent's dir. Ownership comes
1408
+ // from the manifest, but the LOG is consulted too: a wrong/absent manifest
1409
+ // must not make teapot abandon history it can plainly see (see logOwner).
1290
1410
  const owner = this.readSessionOwner(recorded);
1291
- if (existsSync(dir) && (owner === null || owner === agentId))
1411
+ const mineByManifest = owner === null || owner === agentId;
1412
+ const mineByLog = owner !== agentId && this.logOwner(recorded) === agentId;
1413
+ if (existsSync(dir) && (mineByManifest || mineByLog)) {
1414
+ if (mineByLog)
1415
+ this.writeSessionManifest(dir, agentId); // repair it
1292
1416
  return dir;
1293
- // the recorded dir is gone (deleted) — fall through and mint a new one
1294
- console.log(`[teapot] session ${recorded} for ${agentId} is gone; starting a new one`);
1417
+ }
1418
+ // the recorded dir is gone, or provably belongs to someone else — fall
1419
+ // through and mint a new one. Say WHICH, and do not do it quietly: this
1420
+ // line is the only trace that an agent changed session.
1421
+ console.log(`[teapot] session ${recorded} for ${agentId} is unusable ` +
1422
+ `(${existsSync(dir) ? `manifest owner ${JSON.stringify(owner)}` : "directory missing"}); ` +
1423
+ `starting a new one`);
1295
1424
  }
1296
1425
  // Prefer the LATEST session directory that actually carries history.
1297
1426
  // This handles the case where a bare-name directory (e.g. from legacy
@@ -8,11 +8,47 @@ import { providerHeaders } from "./agent/llm.js";
8
8
  const cache = new Map();
9
9
  const inFlight = new Map();
10
10
  const TTL = 10 * 60_000;
11
+ /**
12
+ * How long a FAILED lookup is remembered.
13
+ *
14
+ * A failure used to be dropped on the floor (the `catch` returned `[]` without
15
+ * caching), so a dead, unauthorised, or simply slow endpoint was re-fetched for
16
+ * EVERY agent pointed at it. `addAgent` awaits this twice per agent and races it
17
+ * against a 1.5 s timeout, so with 493 agents across a handful of providers the
18
+ * same failing request — and its timeout — repeated hundreds of times. MEASURED
19
+ * at boot: 1883 ms of the 4.6 s was idle, waiting on exactly this.
20
+ *
21
+ * Negative results expire much sooner than positive ones: a provider coming back
22
+ * up should be usable within a minute, not locked out for ten.
23
+ */
24
+ const NEGATIVE_TTL = 30_000;
25
+ /** is this cached entry still valid? (failures expire sooner) */
26
+ function fresh(entry) {
27
+ return Date.now() - entry.at < (entry.negative ? NEGATIVE_TTL : TTL);
28
+ }
29
+ /**
30
+ * The catalogue if it is ALREADY known, without starting a fetch (#143).
31
+ *
32
+ * Boot uses this so it never waits for metadata it can do without: the context
33
+ * window only tunes the compaction budget, and `supportedParameters` only gates
34
+ * the reasoning-effort controls (#47). `prefetchModelLists()` starts the real
35
+ * lookups in parallel, so an endpoint that answers quickly is usually warm by the
36
+ * time its first agent is created, and a slow or dead one costs no boot time.
37
+ */
38
+ export function peekModelList(baseUrl) {
39
+ const root = baseUrl.replace(/\/+$/, "");
40
+ const hit = cache.get(root);
41
+ if (!hit)
42
+ return undefined;
43
+ if (!fresh(hit))
44
+ return undefined;
45
+ return hit.negative ? [] : hit.list;
46
+ }
11
47
  /** Fetch GET /models from an OpenAI-compatible endpoint (cached ~10 min). */
12
48
  export async function fetchModelList(baseUrl, apiKey) {
13
49
  const root = baseUrl.replace(/\/+$/, "");
14
50
  const hit = cache.get(root);
15
- if (hit && Date.now() - hit.at < TTL)
51
+ if (hit && fresh(hit))
16
52
  return hit.list;
17
53
  // An in-flight lookup is SHARED: without this, two callers racing for the
18
54
  // same endpoint each paid the full timeout, and `newSession` in ACP mode
@@ -63,6 +99,9 @@ export async function fetchModelList(baseUrl, apiKey) {
63
99
  return list;
64
100
  }
65
101
  catch {
102
+ // Remember the FAILURE (briefly). Dropping it meant every agent on this
103
+ // endpoint paid the fetch — and up to the 1.5 s race timeout — again.
104
+ cache.set(root, { at: Date.now(), list: [], negative: true });
66
105
  return []; // unreachable provider — caller treats as "unknown"
67
106
  }
68
107
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.29.4",
3
+ "version": "0.29.6",
4
4
  "description": "A lightweight, always-on multi-agent harness for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",