teapot-coding-agent 0.29.5 → 0.29.7

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";
package/dist/master.js CHANGED
@@ -13,9 +13,53 @@ 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
+ /**
19
+ * Did the effective data dir come from an ENV OVERRIDE that disagrees with the
20
+ * config file's own `dataDir`? (#143)
21
+ *
22
+ * When it does, the config's session pointers are meaningless: they name
23
+ * directories under the OLD root, and this process is looking at a different
24
+ * one. Every agent therefore mints a fresh session — and `recordSession` writes
25
+ * those new pointers back into the config, destroying the real mapping. The
26
+ * caller then throws the new root away (a test's temp dir), leaving a config that
27
+ * points at sessions that never existed.
28
+ *
29
+ * That is not hypothetical: `test/cli-boot-and-host.test.ts` spawns the built CLI
30
+ * with `TEAPOT_DATA_DIR=<temp>` but NO config override, so `pnpm test` on a
31
+ * developer machine rewrote their real config and orphaned 493 sessions' pointers
32
+ * — twice, before this guard. On CI it is invisible, because CI has no config
33
+ * file to damage.
34
+ *
35
+ * So: a mismatched root makes session resolution READ-ONLY. The boot still works
36
+ * (agents get working sessions in the new root) — it just does not rewrite a
37
+ * config that is describing somebody else's data dir.
38
+ */
39
+ function dataDirOverrideMismatch(configFilePath, effective) {
40
+ if (!process.env.TEAPOT_DATA_DIR)
41
+ return false; // not an override: the file is the truth
42
+ try {
43
+ const raw = JSON.parse(readFileSync(configFilePath, "utf8"));
44
+ if (typeof raw.dataDir !== "string" || !raw.dataDir)
45
+ return false;
46
+ return path.resolve(raw.dataDir) !== path.resolve(effective);
47
+ }
48
+ catch {
49
+ return false; // unreadable/new config — nothing to protect
50
+ }
51
+ }
18
52
  const MAX_SPAWN_DEPTH = 3;
53
+ /**
54
+ * How long boot waits for an endpoint's model catalogue (#143).
55
+ *
56
+ * The window and the reasoning-effort gate both come from `/models`, so a boot
57
+ * that waits on it is waiting for metadata, not for anything it needs to start.
58
+ * The lookup is fired concurrently for every distinct endpoint before the agent
59
+ * loop (see prefetchModelLists), so this bound is paid once per ENDPOINT rather
60
+ * than once per agent — and only when the endpoint is slow or dead.
61
+ */
62
+ const MODEL_META_BOOT_MS = 1_500;
19
63
  /** statuses that mean a child has finished working, for #130 */
20
64
  const SETTLED = new Set(["idle", "stopped", "waiting", "error"]);
21
65
  /**
@@ -312,6 +356,16 @@ export class Master {
312
356
  constructor(config, configPath) {
313
357
  this.config = config;
314
358
  this.configPath = configPath;
359
+ // #143: an env data-dir override that disagrees with the config file means
360
+ // this config describes a DIFFERENT data dir — its session pointers cannot be
361
+ // valid here, so rewriting them would destroy the real mapping. See
362
+ // dataDirOverrideMismatch().
363
+ this.sessionsReadOnly = dataDirOverrideMismatch(configPath, config.dataDir);
364
+ if (this.sessionsReadOnly) {
365
+ console.warn(`[teapot] TEAPOT_DATA_DIR (${config.dataDir}) disagrees with the config's own dataDir; ` +
366
+ `sessions will be resolved in the override but NOT written back to ${configPath}. ` +
367
+ `Set TEAPOT_CONFIG to an isolated config to silence this.`);
368
+ }
315
369
  }
316
370
  /**
317
371
  * Persist current logical config back to disk (lossless via raw user config).
@@ -362,6 +416,21 @@ export class Master {
362
416
  */
363
417
  deferConfigWrites = false;
364
418
  configDirty = false;
419
+ /**
420
+ * Endpoints whose catalogue boot has already WAITED for (#143).
421
+ *
422
+ * The wait must be paid once per endpoint, not once per agent. `Promise.race`
423
+ * against a timeout is per-CALL: with a dead endpoint every agent on it paid
424
+ * the full timeout again, because the shared request was still pending. 493
425
+ * agents over one unreachable provider measured 4.4s of boot spent purely
426
+ * idle, for a single request.
427
+ */
428
+ metaWaited = new Set();
429
+ /**
430
+ * #143: `TEAPOT_DATA_DIR` points somewhere other than the config's own dataDir,
431
+ * so session pointers must not be written back (see dataDirOverrideMismatch).
432
+ */
433
+ sessionsReadOnly = false;
365
434
  /** write once and clear the dirty flag — used at the end of a batched section */
366
435
  flushConfig() {
367
436
  this.configDirty = false;
@@ -495,6 +564,14 @@ export class Master {
495
564
  // boot, which is its own silent data loss.
496
565
  this.deferConfigWrites = true;
497
566
  this.configDirty = false;
567
+ // #143: start every endpoint's model-catalogue lookup NOW, concurrently.
568
+ //
569
+ // `addAgent` awaits this per agent, and the endpoints are shared by many
570
+ // agents (493 agents over 6 providers here). Fired up front, the requests
571
+ // overlap and each subsequent agent finds the promise already in flight or
572
+ // resolved — instead of the requests being serialised behind the agent loop,
573
+ // where a slow endpoint's timeout is paid again and again.
574
+ this.prefetchModelLists();
498
575
  try {
499
576
  for (const ac of this.config.agents) {
500
577
  try {
@@ -596,6 +673,38 @@ export class Master {
596
673
  * Restarts reuse the latest existing session; fresh creations never touch
597
674
  * an older one's history.
598
675
  */
676
+ /**
677
+ * Fire the model-catalogue lookup for every distinct endpoint, concurrently.
678
+ *
679
+ * `fetchModelList` already shares an in-flight request per baseUrl, so this is
680
+ * about STARTING them together: without it the requests begin one at a time, as
681
+ * the agent loop reaches each provider, and a slow endpoint's timeout is paid
682
+ * serially by every agent on it.
683
+ *
684
+ * Best-effort by design — nothing awaits this. A failure is cached briefly by
685
+ * `fetchModelList`, and an agent whose lookup is not ready simply gets no
686
+ * inferred window, which is the same outcome as today's timeout.
687
+ */
688
+ prefetchModelLists() {
689
+ const endpoints = new Set();
690
+ const add = (baseUrl, apiKey) => {
691
+ if (!baseUrl)
692
+ return;
693
+ const key = baseUrl.replace(/\/+$/, "");
694
+ if (endpoints.has(key))
695
+ return;
696
+ endpoints.add(key);
697
+ void fetchModelList(baseUrl, apiKey).catch(() => []);
698
+ };
699
+ for (const ac of this.config.agents) {
700
+ const prov = this.config.providers?.[ac.provider ?? this.config.defaultProvider ?? ""];
701
+ add(ac.baseUrl ?? prov?.baseUrl ?? this.config.llm.baseUrl, ac.apiKey ?? prov?.apiKey ?? this.config.llm.apiKey);
702
+ }
703
+ // no configured agents yet (first-run wizard) — still warm the default so the
704
+ // first created agent does not wait
705
+ if (endpoints.size === 0)
706
+ add(this.config.llm.baseUrl, this.config.llm.apiKey);
707
+ }
599
708
  async addAgent(ac, opts = {}) {
600
709
  // #72: an agent id becomes a DIRECTORY name (sessions/<id>-<hash>), so it
601
710
  // must be a safe path segment. Every caller sanitised its own id — the HTTP
@@ -631,24 +740,33 @@ export class Master {
631
740
  // spawn-tree depth: computed from the config chain (ac may not be
632
741
  // registered yet when this runs — spawnChildFor persists AFTER creation)
633
742
  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)),
743
+ // Model metadata: the context window (for the compaction budget) and the
744
+ // catalogue entry that gates reasoning_effort support (#47) come from the
745
+ // SAME `/models` list.
746
+ //
747
+ // #143: boot must not WAIT for this. It only tunes the compaction budget and
748
+ // the effort controls, so a missing list costs nothing that matters; what it
749
+ // cost was boot time — measured at 4.4s of pure idle on 493 agents pointed at
750
+ // one unreachable endpoint, because the `Promise.race` timeout below is
751
+ // per-CALL and the shared request stayed pending for every agent.
752
+ //
753
+ // So: use the catalogue if `prefetchModelLists()` has already produced it
754
+ // (the common case — the lookups were fired when boot started), and wait only
755
+ // ONCE per endpoint for the stragglers, where the wait overlaps a request
756
+ // that is already in flight.
757
+ const needsWindow = !ac.contextWindowTokens && !this.config.contextWindowTokens;
758
+ const root = llm.baseUrl.replace(/\/+$/, "");
759
+ let list = peekModelList(llm.baseUrl);
760
+ if (list === undefined && !this.metaWaited.has(root)) {
761
+ this.metaWaited.add(root);
762
+ list = await Promise.race([
763
+ fetchModelList(llm.baseUrl, llm.apiKey),
764
+ // a placeholder so the race can settle without the list
765
+ new Promise((r) => setTimeout(() => r(undefined), MODEL_META_BOOT_MS)),
644
766
  ]);
645
- void listPromise.catch(() => []);
646
767
  }
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);
768
+ const inferredWindow = needsWindow && list ? contextLengthFor(list, llm.model) : undefined;
769
+ const modelMeta = list?.find((m) => m.id === llm.model);
652
770
  const sessionDir = this.resolveSessionDir(ac.id, opts.fresh === true);
653
771
  await mkdirSync(sessionDir, { recursive: true });
654
772
  // every session dir this master hands out carries an ownership manifest —
@@ -806,6 +924,11 @@ export class Master {
806
924
  if (!entry)
807
925
  return; // not a configured agent — nothing to persist yet
808
926
  entry.session = sessionId;
927
+ // #143: never write session pointers into a config that is describing another
928
+ // data dir — every one of them would be wrong, and the correct mapping is
929
+ // destroyed. The in-memory session above still works for this process.
930
+ if (this.sessionsReadOnly)
931
+ return;
809
932
  this.saveConfig();
810
933
  }
811
934
  /** spawn-tree depth of an agent (0 = top level); unknown → 0 */
@@ -26,6 +26,24 @@ const NEGATIVE_TTL = 30_000;
26
26
  function fresh(entry) {
27
27
  return Date.now() - entry.at < (entry.negative ? NEGATIVE_TTL : TTL);
28
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
+ }
29
47
  /** Fetch GET /models from an OpenAI-compatible endpoint (cached ~10 min). */
30
48
  export async function fetchModelList(baseUrl, apiKey) {
31
49
  const root = baseUrl.replace(/\/+$/, "");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.29.5",
3
+ "version": "0.29.7",
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",