teapot-coding-agent 0.37.87 → 0.37.88

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.
@@ -508,26 +508,71 @@ export class Agent {
508
508
  this.opts.llm = llm;
509
509
  }
510
510
  async init() {
511
- await this.log.load();
511
+ /**
512
+ * NO `log.load()` here.
513
+ *
514
+ * It read up to 512KB of `chat.jsonl` for EVERY agent at boot — 445 of them on the operator's
515
+ * machine, MEASURED at 3.5s of a 7.8s start plus the parsed events on the heap (rss 1GB / heap
516
+ * 800MB at start-up). An agent that is never used needs none of it: the first `append` awaits
517
+ * `ensureLoaded()`, and `ensureReady()` reads it before restoring a session.
518
+ */
519
+ /**
520
+ * …and no SESSION-DIRECTORY reads at boot either.
521
+ *
522
+ * This block read goal.json, todo.md and stat()ed the workspace for every agent, four calls each —
523
+ * MEASURED 1,780 `readFile`s costing 4.5s of the same boot. They are now done in `ensureReady()`,
524
+ * i.e. when the session is actually loaded or used. `goal` and `todo` already have empty defaults
525
+ * (declared above), so a panel that reads them before the load shows "nothing yet" rather than
526
+ * waiting — which is the right trade for a boot that touches only config.json and skills.
527
+ */
528
+ if (!this.opts.deferSessionReads)
529
+ await this.loadSessionState();
530
+ if (this.opts.restoreSession) {
531
+ this.status = "stopped";
532
+ this.statusReason = "session not loaded — select it or send a prompt";
533
+ bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
534
+ }
535
+ }
536
+ /**
537
+ * Everything in the session directory that the panel needs: goal, todo, workspace presence.
538
+ *
539
+ * Idempotent, so `init()` and `ensureReady()` can both ask without reading twice.
540
+ */
541
+ sessionStateLoaded = false;
542
+ async loadSessionState() {
543
+ if (this.sessionStateLoaded)
544
+ return;
545
+ this.sessionStateLoaded = true;
512
546
  await this.migrateMemoryToAgentScope();
513
547
  this.workspaceMissing = !(await fs
514
548
  .stat(this.workspace)
515
549
  .then(() => true)
516
550
  .catch(() => false));
517
551
  await this.migrateGoalFromWorkspace();
518
- const stored = await this.readGoalStore();
519
- this.goal = stored ?? { text: "", status: "active", updatedAt: new Date().toISOString() };
552
+ this.goal = (await this.readGoalStore()) ?? this.goal;
520
553
  this.todo = await fs.readFile(this.todoFile, "utf8").catch(() => "");
521
- if (this.opts.restoreSession) {
522
- this.status = "stopped";
523
- this.statusReason = "session not loaded — select it or send a prompt";
524
- bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
525
- }
526
554
  }
527
555
  ensureReady() {
528
556
  if (!this.readyPromise) {
529
557
  this.readyPromise = (async () => {
558
+ /**
559
+ * The session directory is opened HERE, on demand — see the note in `init()`.
560
+ *
561
+ * When it was DEFERRED (a booted agent, and every spawned child) this must not hold up the
562
+ * "running" transition that follows: the panel's goal/todo are not needed to run a turn, and
563
+ * awaiting them here delayed `setStatus("running")` past the point where a parent's
564
+ * `wait_children` inspects its children — it then reported "all sub-agents already settled"
565
+ * while the child was about to work. So it loads in the background and announces itself.
566
+ */
567
+ const state = this
568
+ .loadSessionState()
569
+ .then(() => bus.emit("update", { kind: "agent-update", agentId: this.opts.id }))
570
+ .catch(() => { });
571
+ if (!this.opts.deferSessionReads)
572
+ await state;
530
573
  if (this.opts.restoreSession) {
574
+ // the restore reads the log itself, but `seq`/`lastByBranch` must be known for later appends
575
+ await this.log.ensureLoaded();
531
576
  await this.restoreFromLog();
532
577
  if (this.status === "stopped")
533
578
  this.setStatus("idle", "session loaded");
@@ -17,6 +17,23 @@ export class EventLog {
17
17
  this.agentId = agentId;
18
18
  }
19
19
  static LOAD_TAIL_BYTES = 512 * 1024;
20
+ /**
21
+ * Read the log TAIL to learn `seq` and the last event per branch.
22
+ *
23
+ * LAZY, and memoised. `Agent.init()` ran it for every agent at boot, which is the startup cost the
24
+ * operator reported: 445 agents × up to 512KB of `chat.jsonl` parsed before the first request —
25
+ * MEASURED 3.5s of a 7.8s boot, plus the parsed events on the heap ("起動直後のメモリバースト",
26
+ * rss 1GB / heap 800MB).
27
+ *
28
+ * Nothing needs it until something WRITES (`append` increments `seq`) or asks for a branch's last
29
+ * event, so it now happens then. The rule from the operator: "起動中に読んでいいのはconfig.jsonと
30
+ * skillsだけ / セッションディレクトリはセッションがロードされるまで開かない".
31
+ */
32
+ ensureLoaded() {
33
+ this.loadPromise ??= this.load();
34
+ return this.loadPromise;
35
+ }
36
+ loadPromise;
20
37
  async load() {
21
38
  mkdirSync(path.dirname(this.filePath), { recursive: true });
22
39
  try {
@@ -103,7 +120,21 @@ export class EventLog {
103
120
  await new Promise((res) => s.end(res)).catch(() => {
104
121
  });
105
122
  }
123
+ /**
124
+ * Queue a write, reading the log first if it has not been read yet.
125
+ *
126
+ * NOT `async` on purpose. An `async` function returns a NEW promise, so `void this.log.append(…)`
127
+ * — which is how most call sites fire a log write — would leave that wrapper promise unhandled when
128
+ * the write fails, and the failure surfaced as an `unhandledRejection` instead of the caller's
129
+ * problem. Returning the inner promise (which already has handlers attached below) keeps the old
130
+ * behaviour: awaiting still throws, ignoring stays silent.
131
+ */
106
132
  append(type, session, branch, data) {
133
+ const p = this.ensureLoaded().then(() => this.writeEvent(type, session, branch, data));
134
+ void p.catch(() => { });
135
+ return p;
136
+ }
137
+ async writeEvent(type, session, branch, data) {
107
138
  const evt = {
108
139
  v: 1,
109
140
  id: `e${++this.seq}`,
package/dist/master.js CHANGED
@@ -636,6 +636,8 @@ export class Master {
636
636
  },
637
637
  llm,
638
638
  sessionDir,
639
+ // boot must touch ONLY config.json and skills; this agent's directory is opened when it is used
640
+ deferSessionReads: true,
639
641
  progressIntervalMs: this.config.progressIntervalMs,
640
642
  ...(this.config.progressMinChars ? { progressMinChars: this.config.progressMinChars } : {}),
641
643
  ...(this.config.maxTurnsPerRound !== undefined ? { maxTurnsPerRound: this.config.maxTurnsPerRound } : {}),
@@ -732,14 +734,14 @@ export class Master {
732
734
  agent.setSessionTitle(seedTitle);
733
735
  else {
734
736
  /**
735
- * Derive a missing title at BOOT, once, and write it down.
737
+ * NOT derived here. Deriving a missing title reads `chat.jsonl`, and boot must not open session
738
+ * directories at all — "起動中に読んでいいのはconfig.jsonとskillsだけ / セッションディレクトリは
739
+ * セッションがロードされるまで開かない".
736
740
  *
737
- * Waiting for the session's next user prompt means an existing session shows no name until the
738
- * operator types into it, and every left-panel read has to fall back. Doing it here keeps the
739
- * derivation to one pass per session and off the read path.
741
+ * The session ID is a perfectly good fallback for the panel, and the real derivation happens
742
+ * when that session is actually loaded (`ensureSessionTitle`, via the agent's first prompt).
740
743
  */
741
744
  const id = path.basename(sessionDir);
742
- this.writeSessionTitle(id, this.sessionTitleOf(sessionDir) ?? id);
743
745
  agent.setSessionTitle(this.readSessionTitle(id) ?? id);
744
746
  }
745
747
  this.agents.set(ac.id, agent);
@@ -1,5 +1,4 @@
1
1
  import { closeSync, openSync, readSync } from "node:fs";
2
- import path from "node:path";
3
2
  export const sessionTitles = {
4
3
  order: 30,
5
4
  id: "agents-session-title",
@@ -31,9 +30,16 @@ export const sessionTitles = {
31
30
  if (session && !session.includes("/") && !session.includes("..")) {
32
31
  const already = typeof titles[session] === "string" ? String(titles[session]).trim() : "";
33
32
  if (!already) {
34
- const derived = legacy ||
35
- firstUserPromptText(path.join(ctx.dataDir, "sessions", session, "chat.jsonl")) ||
36
- session;
33
+ /**
34
+ * NOT derived from `chat.jsonl` here.
35
+ *
36
+ * A migration runs for EVERY agent at boot, and reading each session's log is exactly what the
37
+ * operator ruled out — "起動中に読んでいいのはconfig.jsonとskillsだけ / セッションディレクトリは
38
+ * セッションがロードされるまで開かない". A legacy `sessionTitle` is used when present; otherwise
39
+ * the session id is a perfectly good name, and a real title is derived once when that session is
40
+ * actually opened (`Master.ensureSessionTitle`).
41
+ */
42
+ const derived = legacy || session;
37
43
  titles[session] = derived;
38
44
  changed = true;
39
45
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.37.87",
3
+ "version": "0.37.88",
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",