teapot-coding-agent 0.37.57 → 0.37.59

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.
@@ -610,6 +610,8 @@ export class Agent {
610
610
  // so a background job records WHICH agent started it: sibling agents share a workspace,
611
611
  // and `isLive()` must not count their jobs as this one's work in flight
612
612
  agentId: opts.id,
613
+ // the job RECORD lives beside the session, not in a global dir: it is this agent's history
614
+ stateDir: opts.sessionDir,
613
615
  defaultTimeoutMs: 120_000,
614
616
  maxOutputBytes: 60_000,
615
617
  skillRoots: this.skillRoots,
@@ -3,7 +3,7 @@
3
3
  * Tool specs are plain JSON-schema function definitions — provider-agnostic.
4
4
  */
5
5
  import { spawn } from "node:child_process";
6
- import { existsSync, promises as fs, readlinkSync, realpathSync } from "node:fs";
6
+ import { existsSync, promises as fs, mkdirSync, readFileSync, readlinkSync, realpathSync, writeFileSync } from "node:fs";
7
7
  import path from "node:path";
8
8
  import { discoverSkills, isValidSkillName, readSkillFile, saveSkill, SKILL_FILE, } from "./skills.js";
9
9
  import { isReasoningEffort } from "../model-meta.js";
@@ -338,6 +338,62 @@ function toolShell() {
338
338
  export function elapsed(t0, now = Date.now()) {
339
339
  return `\n[took ${((now - t0) / 1000).toFixed(1)}s]`;
340
340
  }
341
+ const jobsFile = (dir) => path.join(dir, "bg-jobs.json");
342
+ /** read the persisted records, tolerating a missing or corrupt file (never throws) */
343
+ function readJobRecords(dir) {
344
+ try {
345
+ const raw = readFileSync(jobsFile(dir), "utf8");
346
+ const parsed = JSON.parse(raw);
347
+ return Array.isArray(parsed) ? parsed.filter((r) => r && typeof r.id === "string") : [];
348
+ }
349
+ catch {
350
+ return [];
351
+ }
352
+ }
353
+ /**
354
+ * Write the records atomically enough for this purpose.
355
+ *
356
+ * A torn write here is not worth a fsync: the file is a hint for the NEXT boot, and the worst case is
357
+ * losing one restart's worth of "this was running". Losing a job's identity entirely (the shipped
358
+ * behaviour) is what this exists to stop.
359
+ */
360
+ function writeJobRecords(dir, records) {
361
+ try {
362
+ mkdirSync(dir, { recursive: true });
363
+ writeFileSync(jobsFile(dir), JSON.stringify(records, null, 1));
364
+ }
365
+ catch {
366
+ /* a read-only session dir must not break the tool */
367
+ }
368
+ }
369
+ /** add or update one record (by id) */
370
+ function rememberJob(dir, rec) {
371
+ const all = readJobRecords(dir).filter((r) => r.id !== rec.id);
372
+ all.push(rec);
373
+ writeJobRecords(dir, all);
374
+ }
375
+ /** drop a record — the job finished AND was reported, or was killed */
376
+ function forgetJob(dir, id) {
377
+ writeJobRecords(dir, readJobRecords(dir).filter((r) => r.id !== id));
378
+ }
379
+ /**
380
+ * Jobs a PREVIOUS process started and whose processes may still be alive.
381
+ *
382
+ * `alive` is answered by the caller (pidExists) rather than here, so this module stays free of the
383
+ * signal-plumbing and the test can drive either answer. A record whose pid is gone is stale and is
384
+ * dropped: reporting a finished job as running is the same class of lie as forgetting a live one.
385
+ */
386
+ export function recoverableJobs(dir, alive) {
387
+ const all = readJobRecords(dir);
388
+ const live = [];
389
+ for (const r of all) {
390
+ if (r.pid !== null && alive(r.pid))
391
+ live.push(r);
392
+ }
393
+ if (live.length !== all.length)
394
+ writeJobRecords(dir, live);
395
+ return live;
396
+ }
341
397
  function startBackgroundShell(cmd, ctx) {
342
398
  const map = bgMapFor(ctx.cwd);
343
399
  // opportunistic cleanup of long-dead jobs so the map can't grow forever
@@ -367,6 +423,20 @@ function startBackgroundShell(cmd, ctx) {
367
423
  else
368
424
  sh.truncated = true;
369
425
  };
426
+ /**
427
+ * Persist the RECORD, so a later process can at least say this job exists.
428
+ *
429
+ * The `pid` is what makes it checkable on boot: a job whose pid is gone is dropped, one whose pid
430
+ * is alive is reported as "still running, output no longer reachable". That is the honest answer
431
+ * after a restart — see `ToolContext.stateDir`.
432
+ */
433
+ if (ctx.stateDir) {
434
+ rememberJob(ctx.stateDir, {
435
+ id, cmd, owner: ctx.agentId ?? "", workspace: ctx.cwd,
436
+ pid: child.pid ?? null,
437
+ startedAt: sh.startedAt,
438
+ });
439
+ }
370
440
  child.stdout?.on("data", collect);
371
441
  child.stderr?.on("data", collect);
372
442
  // "exit" (process gone) and "close" (all stdio drained) are DIFFERENT events.
@@ -389,6 +459,9 @@ function startBackgroundShell(cmd, ctx) {
389
459
  if (exitNotified)
390
460
  return;
391
461
  exitNotified = true;
462
+ // the job is over: its record must not claim otherwise on the next boot
463
+ if (ctx.stateDir)
464
+ forgetJob(ctx.stateDir, id);
392
465
  // tell the harness so the agent learns the outcome WITHOUT polling
393
466
  try {
394
467
  ctx.onBackgroundExit?.({
@@ -434,7 +507,48 @@ export function isContextOverflow(err) {
434
507
  * `owner` is included because several agents share one workspace (every sub-agent inherits its
435
508
  * parent's), and "whose job is this" is the first thing an operator asks when deciding what to kill.
436
509
  */
437
- export function listBgShells(cwd) {
510
+ export function listBgShells(cwd, stateDir) {
511
+ const live = liveShells(cwd);
512
+ /**
513
+ * Records from a previous process are appended, marked `detached`.
514
+ *
515
+ * Without them a job that survived a restart is INVISIBLE — MEASURED: a 3-hour GPU sweep kept
516
+ * writing while the panel showed nothing and `bash_output` said "forgotten on harness restart".
517
+ * The process cannot be re-attached (no fd survives the parent), so `detached` is the whole truth
518
+ * the UI can be given: it is running, and its output lives wherever the command redirected it.
519
+ */
520
+ if (!stateDir)
521
+ return live;
522
+ const known = new Set(live.map((j) => j.id));
523
+ const recovered = recoverableJobs(stateDir, pidExists)
524
+ .filter((r) => !known.has(r.id) && r.workspace === cwd)
525
+ .map((r) => ({
526
+ id: r.id,
527
+ cmd: r.cmd,
528
+ owner: r.owner,
529
+ running: true,
530
+ detached: true,
531
+ pid: r.pid,
532
+ code: null,
533
+ startedAt: r.startedAt,
534
+ tail: "",
535
+ bytes: 0,
536
+ truncated: false,
537
+ }));
538
+ return [...live, ...recovered].sort((a, b) => b.startedAt - a.startedAt);
539
+ }
540
+ /** does this pid exist? (EPERM means it exists but is not ours to signal) */
541
+ function pidExists(pid) {
542
+ try {
543
+ process.kill(pid, 0);
544
+ return true;
545
+ }
546
+ catch (err) {
547
+ return err.code === "EPERM";
548
+ }
549
+ }
550
+ /** the jobs THIS process is running, as the UI sees them */
551
+ function liveShells(cwd) {
438
552
  const m = bgShellsByWs.get(cwd);
439
553
  if (!m)
440
554
  return [];
@@ -454,20 +568,38 @@ export function listBgShells(cwd) {
454
568
  }))
455
569
  .sort((a, b) => b.startedAt - a.startedAt);
456
570
  }
457
- /** kill ONE background job; returns whether it existed */
458
- export function killBgShell(cwd, id) {
571
+ /**
572
+ * Kill ONE background job; returns whether it existed.
573
+ *
574
+ * A DETACHED job (a record from a previous process) is handled differently on purpose: the harness no
575
+ * longer owns that process, so this drops the RECORD and does NOT signal the pid. Killing a pid we
576
+ * merely remember is how an unrelated process gets killed after pid reuse — and the operator's own
577
+ * instruction was to never touch a job from an earlier session. The caller reports what happened.
578
+ */
579
+ export function killBgShell(cwd, id, stateDir) {
459
580
  const m = bgShellsByWs.get(cwd);
460
581
  const b = m?.get(id);
461
- if (!b)
462
- return false;
463
- try {
464
- killTree(b.child);
582
+ if (b) {
583
+ try {
584
+ killTree(b.child);
585
+ }
586
+ catch {
587
+ /* already gone */
588
+ }
589
+ m.delete(id);
590
+ if (stateDir)
591
+ forgetJob(stateDir, id);
592
+ return true;
465
593
  }
466
- catch {
467
- /* already gone */
594
+ // not ours any more: forget it, leave the process alone
595
+ if (stateDir) {
596
+ const rec = readJobRecords(stateDir).find((r) => r.id === id);
597
+ if (rec) {
598
+ forgetJob(stateDir, id);
599
+ return true;
600
+ }
468
601
  }
469
- m.delete(id);
470
- return true;
602
+ return false;
471
603
  }
472
604
  export function hasRunningBgShells(cwd) {
473
605
  const m = bgShellsByWs.get(cwd);
@@ -626,13 +626,15 @@ export function buildApp(master) {
626
626
  const agent = master.agents.get(c.req.param("id"));
627
627
  if (!agent)
628
628
  return c.json({ error: "no such agent" }, 404);
629
- return c.json({ jobs: listBgShells(agent.snapshot().workspace ?? "") });
629
+ const snap = agent.snapshot();
630
+ return c.json({ jobs: listBgShells(snap.workspace ?? "", snap.sessionDir) });
630
631
  });
631
632
  app.delete("/api/agents/:id/bg-jobs/:job", (c) => {
632
633
  const agent = master.agents.get(c.req.param("id"));
633
634
  if (!agent)
634
635
  return c.json({ error: "no such agent" }, 404);
635
- const ok = killBgShell(agent.snapshot().workspace ?? "", c.req.param("job"));
636
+ const snap = agent.snapshot();
637
+ const ok = killBgShell(snap.workspace ?? "", c.req.param("job"), snap.sessionDir);
636
638
  return c.json({ ok }, ok ? 200 : 404);
637
639
  });
638
640
  app.get("/api/archived", (c) => c.json({ agents: master.archivedAgents() }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.37.57",
3
+ "version": "0.37.59",
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",