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 +150 -22
- package/package.json +1 -1
- package/public/assets/{index-DTHHmNVF.js → index-j-byVujL.js} +5 -5
- package/public/assets/index-j-byVujL.js.map +1 -0
- package/public/assets/{shiki-B3arOyuY.js → shiki-DCpmqeFE.js} +2 -2
- package/public/assets/{shiki-B3arOyuY.js.map → shiki-DCpmqeFE.js.map} +1 -1
- package/public/index.html +1 -1
- package/public/assets/index-DTHHmNVF.js.map +0 -1
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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();
|