teapot-coding-agent 0.29.2 → 0.29.4

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.
@@ -16,6 +16,25 @@
16
16
  import { createWriteStream } from "node:fs";
17
17
  import { mkdirSync } from "node:fs";
18
18
  import path from "node:path";
19
+ /**
20
+ * How long an idle write stream is kept open (#143).
21
+ *
22
+ * The stream used to be opened in `load()` and closed only by `close()`, which
23
+ * runs on dispose and shutdown. A loaded agent therefore held a file descriptor
24
+ * for the whole process lifetime — MEASURED on a real master: 493 agents in
25
+ * config meant 493 open `chat.jsonl` descriptors, ~300 MB RSS, growing with
26
+ * every session the operator creates.
27
+ *
28
+ * The log is append-only, so a stream is never needed to keep state: reopening
29
+ * with `flags: "a"` continues exactly where the last write stopped. Closing it
30
+ * is therefore free, and re-opening on the next append costs one `open()` per
31
+ * burst of activity rather than one per session forever.
32
+ *
33
+ * 15s, because a working agent appends many times per second — holding the
34
+ * stream across that activity means file rotation / inspection outside the app
35
+ * still sees a quiet, closed file, and a burst of tool calls costs ONE open.
36
+ */
37
+ const IDLE_CLOSE_MS = 15_000;
19
38
  export class EventLog {
20
39
  stream = null;
21
40
  seq = 0;
@@ -24,6 +43,14 @@ export class EventLog {
24
43
  lastByBranch = new Map();
25
44
  /** optional observer (e.g. console logger wired by the master) */
26
45
  onEvent = null;
46
+ /** #143: timer that closes the stream once this log goes quiet */
47
+ idleTimer = null;
48
+ /**
49
+ * #143: set by close() so an append that resumes afterwards knows the log was
50
+ * DISPOSED rather than merely idle — an idle log must reopen, a disposed one
51
+ * must keep dropping (and saying so), which is the pre-existing contract.
52
+ */
53
+ disposed = false;
27
54
  filePath;
28
55
  agentId;
29
56
  constructor(filePath, agentId) {
@@ -69,10 +96,66 @@ export class EventLog {
69
96
  catch {
70
97
  /* file may not exist yet */
71
98
  }
72
- this.stream = createWriteStream(this.filePath, { flags: "a" });
73
- this.stream.on("error", (err) => {
99
+ // #143: NO stream here. Opening it in load() is what held a descriptor per
100
+ // loaded agent for the lifetime of the process (measured: 493 of them).
101
+ // `load()` still reads and repairs the file — it just does not keep it open.
102
+ }
103
+ /**
104
+ * Open the append stream if it is not already open (#143).
105
+ *
106
+ * Called from the write chain so an append cannot race a close: both run
107
+ * inside `this.chain`, which is a promise queue.
108
+ */
109
+ openStream() {
110
+ if (this.stream)
111
+ return this.stream;
112
+ const s = createWriteStream(this.filePath, { flags: "a" });
113
+ s.on("error", (err) => {
74
114
  console.error(`[teapot] log write error (${this.filePath}):`, err.message);
75
115
  });
116
+ this.stream = s;
117
+ return s;
118
+ }
119
+ /** #143: close the stream once this log has been quiet for IDLE_CLOSE_MS. */
120
+ scheduleIdleClose() {
121
+ if (this.idleTimer)
122
+ return; // already armed; the existing deadline stands
123
+ this.idleTimer = setTimeout(() => this.fireIdleClose(), IDLE_CLOSE_MS);
124
+ // the timer must never be the reason a process cannot exit
125
+ this.idleTimer.unref?.();
126
+ }
127
+ /**
128
+ * What the idle timer does — separated so it can be driven directly.
129
+ *
130
+ * Clears the deadline FIRST so `releaseIdleStream` can tell "nothing new
131
+ * arrived" (close) from "an append re-armed it while I waited" (defer).
132
+ */
133
+ fireIdleClose() {
134
+ this.idleTimer = null;
135
+ void this.releaseIdleStream();
136
+ }
137
+ /**
138
+ * Close the stream after a quiet period.
139
+ *
140
+ * Runs behind `chain`, so it cannot interleave with a queued write. An append
141
+ * that arrived while this was pending re-armed the timer, and that newer
142
+ * deadline owns the close — hence the check on `idleTimer` AFTER draining,
143
+ * not before.
144
+ */
145
+ async releaseIdleStream() {
146
+ await this.chain.catch(() => { });
147
+ if (this.disposed)
148
+ return;
149
+ if (!this.stream)
150
+ return;
151
+ // a later append re-armed the timer — let that deadline own the close
152
+ if (this.idleTimer)
153
+ return;
154
+ const s = this.stream;
155
+ this.stream = null;
156
+ await new Promise((res) => s.end(res)).catch(() => {
157
+ /* the write error handler already reported it */
158
+ });
76
159
  }
77
160
  /** Append an event; resolves when it is handed to the OS (write flushed). */
78
161
  append(type, session, branch, data) {
@@ -110,13 +193,16 @@ export class EventLog {
110
193
  let written = false;
111
194
  const p = new Promise((resolve, reject) => {
112
195
  this.chain = this.chain.then(() => {
113
- if (!this.stream) {
114
- // log closed (dispose) — resolve so callers don't await forever,
115
- // but say so: silent drops made post-shutdown writes look logged
196
+ if (this.disposed) {
197
+ // log DISPOSED (not merely idle) — resolve so callers don't await
198
+ // forever, but say so: silent drops made post-shutdown writes look
199
+ // logged. #143: this is a distinct state from an idle stream, which
200
+ // must reopen instead of dropping.
116
201
  console.error(`[teapot] event dropped after log close (${this.agentId}): ${type}`);
117
202
  return resolve(evt);
118
203
  }
119
- this.stream.write(JSON.stringify(evt) + "\n", "utf8", (err) => {
204
+ // #143: open on demand — an idle log keeps no descriptor
205
+ this.openStream().write(JSON.stringify(evt) + "\n", "utf8", (err) => {
120
206
  if (err)
121
207
  return reject(err);
122
208
  written = true;
@@ -124,6 +210,10 @@ export class EventLog {
124
210
  });
125
211
  }, () => resolve(evt));
126
212
  });
213
+ // #143: re-arm the idle close for every append. Set here rather than inside
214
+ // the write callback: the timer measures quiet time from the last APPEND, and
215
+ // a long write must not count as activity after the fact.
216
+ this.scheduleIdleClose();
127
217
  this.chain = this.chain.then(() => { }, () => { });
128
218
  // notify once the write is handed to the OS, so a reload the observer
129
219
  // triggers can actually see this event
@@ -161,6 +251,14 @@ export class EventLog {
161
251
  this.lastByBranch.set(branch, parentEventId ?? "");
162
252
  }
163
253
  async close() {
254
+ // #143: mark DISPOSED before draining, so an append queued behind us drops
255
+ // loudly instead of reopening the log we are shutting down. This is the
256
+ // difference between "idle" (reopen on the next write) and "closed".
257
+ this.disposed = true;
258
+ if (this.idleTimer) {
259
+ clearTimeout(this.idleTimer);
260
+ this.idleTimer = null;
261
+ }
164
262
  await this.chain.catch(() => { });
165
263
  if (!this.stream)
166
264
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.29.2",
3
+ "version": "0.29.4",
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",