teapot-coding-agent 0.28.1 → 0.28.2

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/master.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * Agents are in-process async loops (I/O bound only); all CPU-heavy work is
4
4
  * delegated to subprocesses managed by the bash tool with hard timeouts.
5
5
  */
6
- import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSync, renameSync } from "node:fs";
6
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSync, renameSync, unlinkSync, openSync, closeSync, fsyncSync, } from "node:fs";
7
7
  import { randomUUID } from "node:crypto";
8
8
  import path from "node:path";
9
9
  import os from "node:os";
@@ -168,6 +168,24 @@ export function loadConfig(configPath) {
168
168
  };
169
169
  }
170
170
  /** Raw parsed user config (for lossless persistence of web edits). */
171
+ /**
172
+ * Can this path be replaced by an atomic rename?
173
+ *
174
+ * #144: `/dev/null`, a fifo, or a directory are all legitimate values for
175
+ * `configPath`, and `rename` over them either fails outright (EACCES for a temp
176
+ * beside `/dev/null`) or is not what the caller wants. Only a regular file gets
177
+ * the write-temp-then-rename treatment.
178
+ */
179
+ function isRegularFileTarget(p) {
180
+ try {
181
+ const st = statSync(p);
182
+ return st.isFile();
183
+ }
184
+ catch {
185
+ // does not exist yet — the parent decides; a real config path will be created
186
+ return true;
187
+ }
188
+ }
171
189
  export function loadedRaw() {
172
190
  return masterRawConfig;
173
191
  }
@@ -289,8 +307,30 @@ export class Master {
289
307
  this.config = config;
290
308
  this.configPath = configPath;
291
309
  }
292
- /** Persist current logical config back to disk (lossless via raw user config). */
310
+ /**
311
+ * Persist current logical config back to disk (lossless via raw user config).
312
+ *
313
+ * #144: this was a bare `writeFileSync`, which TRUNCATES the target before it
314
+ * writes. config.json holds every agent, provider and scheduled task, so any
315
+ * interruption during the write — SIGINT, a crash, a full disk — left a ZERO-LENGTH
316
+ * file, and the next boot read `{}` and came up empty.
317
+ *
318
+ * Boot made this far more likely than it sounds: every agent that mints a
319
+ * session calls `recordSession` → `saveConfig`, so a machine with 30 configured
320
+ * agents rewrites the whole file 30 times before the server listens. A user
321
+ * pressing Ctrl-C during that window lost the lot.
322
+ *
323
+ * Now: write a sibling temp file, fsync it, then `rename` over the target.
324
+ * `rename` is atomic within a filesystem, so the file is either the old content
325
+ * or the new — never empty. A crash mid-write leaves the temp file behind and
326
+ * the real config untouched.
327
+ */
293
328
  saveConfig() {
329
+ // #144: during boot, mark dirty instead of writing; flushed once at the end
330
+ if (this.deferConfigWrites) {
331
+ this.configDirty = true;
332
+ return;
333
+ }
294
334
  this.raw.agents = this.config.agents;
295
335
  this.raw.providers = this.config.providers;
296
336
  this.raw.defaultProvider = this.config.defaultProvider;
@@ -299,7 +339,75 @@ export class Master {
299
339
  this.raw.progressIntervalMs = this.config.progressIntervalMs;
300
340
  if (this.raw.progressMinChars === undefined && this.config.progressMinChars !== undefined)
301
341
  this.raw.progressMinChars = this.config.progressMinChars;
302
- writeFileSync(this.configPath, JSON.stringify(this.raw, null, 2) + "\n");
342
+ this.writeConfigAtomic(JSON.stringify(this.raw, null, 2) + "\n");
343
+ }
344
+ /**
345
+ * Coalesce config writes. #144.
346
+ *
347
+ * Boot calls `addAgent` for every configured agent, and any agent whose recorded
348
+ * session is gone mints a new one — which calls `recordSession` → `saveConfig`.
349
+ * A machine with 30 agents therefore rewrote the whole file 30 times before the
350
+ * server listened, and each was a window in which the file could be truncated.
351
+ *
352
+ * With the write now atomic that is no longer dangerous, but it is still 30
353
+ * unnecessary writes of a 148KB file. During boot, saves are marked dirty and
354
+ * flushed ONCE at the end. Outside boot (an edit, a spawn, a rename) behaviour is
355
+ * unchanged: synchronous and immediate.
356
+ */
357
+ deferConfigWrites = false;
358
+ configDirty = false;
359
+ /** write once and clear the dirty flag — used at the end of a batched section */
360
+ flushConfig() {
361
+ this.configDirty = false;
362
+ this.saveConfig();
363
+ }
364
+ /** write-and-rename, so config.json is never observed empty or half-written */
365
+ writeConfigAtomic(body) {
366
+ // #144: `/dev/null` is a real, intentional value here — many callers (and
367
+ // every test that does not care about persistence) pass it as "no config
368
+ // file". The old `writeFileSync("/dev/null", …)` succeeded and discarded.
369
+ //
370
+ // Atomic writing cannot honour that: the temp file would be
371
+ // `/dev/.config.json.tmp-…`, which is EACCES for a normal user, so 22 tests
372
+ // failed with a bare 400 from POST /api/agents. Keep the old behaviour for a
373
+ // non-regular target, and only do write-rename where rename means something.
374
+ if (!isRegularFileTarget(this.configPath)) {
375
+ writeFileSync(this.configPath, body);
376
+ return;
377
+ }
378
+ const dir = path.dirname(this.configPath);
379
+ // same directory as the target, so rename() stays within one filesystem
380
+ const tmp = path.join(dir, `.config.json.tmp-${process.pid}-${Date.now()}`);
381
+ let fd;
382
+ try {
383
+ fd = openSync(tmp, "w");
384
+ writeFileSync(fd, body);
385
+ // fsync BEFORE the rename: without it the rename can land while the data is
386
+ // still in the page cache, which is the same data-loss window on a crash
387
+ fsyncSync(fd);
388
+ closeSync(fd);
389
+ fd = undefined;
390
+ renameSync(tmp, this.configPath);
391
+ }
392
+ catch (err) {
393
+ if (fd !== undefined) {
394
+ try {
395
+ closeSync(fd);
396
+ }
397
+ catch {
398
+ /* already closed */
399
+ }
400
+ }
401
+ try {
402
+ unlinkSync(tmp);
403
+ }
404
+ catch {
405
+ /* nothing to clean up */
406
+ }
407
+ // Losing the new state is survivable; losing the file is not, so rethrow
408
+ // rather than pretending the save succeeded
409
+ throw err;
410
+ }
303
411
  }
304
412
  raw = loadedRaw();
305
413
  /** Apply partial config edits from the web UI and persist them. */
@@ -347,28 +455,48 @@ export class Master {
347
455
  }
348
456
  async start() {
349
457
  mkdirSync(this.config.dataDir, { recursive: true });
350
- for (const ac of this.config.agents) {
351
- try {
352
- await this.addAgent(ac);
458
+ // #144: ONE config write for the whole boot instead of one per agent.
459
+ //
460
+ // Every agent whose recorded session is gone mints a new one, and minting
461
+ // calls recordSession → saveConfig. With ~30 configured agents the user's log
462
+ // showed ~30 "session … is gone" lines, and therefore ~30 full rewrites of a
463
+ // 148KB config.json — all before the server ever listened. That is the window
464
+ // in which Ctrl-C emptied the file.
465
+ //
466
+ // `finally` so a throw mid-boot still flushes what it already changed: a
467
+ // deferred write that never flushes would leave config.json describing the PREVIOUS
468
+ // boot, which is its own silent data loss.
469
+ this.deferConfigWrites = true;
470
+ this.configDirty = false;
471
+ try {
472
+ for (const ac of this.config.agents) {
473
+ try {
474
+ await this.addAgent(ac);
475
+ }
476
+ catch (err) {
477
+ // one bad entry (duplicate id, unknown provider, …) must not take the
478
+ // whole master down — skip it loudly and keep serving the rest
479
+ console.error(`[teapot] skipping agent "${ac.id}": ${err.message}`);
480
+ }
353
481
  }
354
- catch (err) {
355
- // one bad entry (duplicate id, unknown provider, …) must not take the
356
- // whole master down — skip it loudly and keep serving the rest
357
- console.error(`[teapot] skipping agent "${ac.id}": ${err.message}`);
482
+ for (const t of this.config.tasks ?? []) {
483
+ try {
484
+ this.tasks.push({
485
+ task: t,
486
+ schedule: parseSchedule(t.schedule),
487
+ lastRunMin: t.lastRunMin ?? -1,
488
+ });
489
+ }
490
+ catch (err) {
491
+ // a hand-edited schedule typo must not stop the server from listening
492
+ console.error(`[teapot] skipping task "${t.id}": ${err.message}`);
493
+ }
358
494
  }
359
495
  }
360
- for (const t of this.config.tasks ?? []) {
361
- try {
362
- this.tasks.push({
363
- task: t,
364
- schedule: parseSchedule(t.schedule),
365
- lastRunMin: t.lastRunMin ?? -1,
366
- });
367
- }
368
- catch (err) {
369
- // a hand-edited schedule typo must not stop the server from listening
370
- console.error(`[teapot] skipping task "${t.id}": ${err.message}`);
371
- }
496
+ finally {
497
+ this.deferConfigWrites = false;
498
+ if (this.configDirty)
499
+ this.flushConfig();
372
500
  }
373
501
  // single low-frequency tick for everything periodic (idle cost ≈ 0)
374
502
  setInterval(() => void this.tick(), 15_000).unref();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.28.1",
3
+ "version": "0.28.2",
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",