@staix/agent-hub 0.12.5 → 0.12.7

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.
@@ -2,7 +2,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
2
  import { createInterface } from "node:readline";
3
3
  import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
4
4
  import { BasePeer } from "../hub/peers.ts";
5
- import { childEnv, stopOwnedProcess } from "../hub/child-process.ts";
5
+ import { childEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
6
6
 
7
7
  export interface PermissionOption {
8
8
  optionId: string;
@@ -85,8 +85,10 @@ export class AcpPeer extends BasePeer {
85
85
 
86
86
  async start(): Promise<void> {
87
87
  const [bin, ...args] = this.opts.cmd;
88
- const proc = spawn(bin!, args, { cwd: this.opts.cwd, env: childEnv({ ...process.env, ...(this.opts.env ?? {}) }), stdio: ["pipe", "pipe", "pipe"] });
88
+ // Its own process group, stopped as a whole (#115, as Codex's in #113): an agent CLI may be a launcher with a native child.
89
+ const proc = spawn(bin!, args, { cwd: this.opts.cwd, env: childEnv({ ...process.env, ...(this.opts.env ?? {}) }), stdio: ["pipe", "pipe", "pipe"], detached: true });
89
90
  this.proc = proc;
91
+ trackGroup(proc);
90
92
  proc.on("error", (e) => this.down(`spawn failed: ${e.message}`));
91
93
  proc.on("exit", (code) => this.down(`exited with code ${code}`));
92
94
  proc.stdin.on("error", () => {}); // EPIPE from a child that died; `exit` / `error` already report it
@@ -112,7 +114,7 @@ export class AcpPeer extends BasePeer {
112
114
  try {
113
115
  this.sessionId = (await Promise.race([handshake(), timeout])).sessionId;
114
116
  } catch (e) {
115
- await stopOwnedProcess(proc);
117
+ await stopOwnedProcess(proc, { group: true }).catch((stop: Error) => this.opts.log?.(`[${this.id}] ${stop.message}`));
116
118
  throw e;
117
119
  }
118
120
  this.setState("idle");
@@ -122,8 +124,8 @@ export class AcpPeer extends BasePeer {
122
124
  if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "ACP session stopped before settlement" });
123
125
  this.activeDeliveryId = undefined;
124
126
  const proc = this.proc;
125
- if (!proc || proc.exitCode !== null) return;
126
- await stopOwnedProcess(proc);
127
+ if (!proc) return;
128
+ await stopOwnedProcess(proc, { group: true }); // also when it exited: what it left in its group fails the stop
127
129
  if (this.proc === proc) this.proc = undefined;
128
130
  }
129
131
 
@@ -139,14 +139,17 @@ export class CodexPeer extends BasePeer {
139
139
  });
140
140
  } catch (error) {
141
141
  const proc = this.proc;
142
- if (proc) {
143
- await stopOwnedProcess(proc, { group: true });
144
- if (this.proc === proc) this.proc = undefined;
145
- }
142
+ // The start's own error is the one reported: a stop that fails too is logged beside it.
143
+ if (proc && (await this.stopQuietly(proc)) && this.proc === proc) this.proc = undefined;
146
144
  throw error;
147
145
  }
148
146
  }
149
147
 
148
+ /** Stops `proc` as a group; a failure is logged and reported as false, for paths that throw an error of their own. */
149
+ private stopQuietly(proc: ChildProcess): Promise<boolean> {
150
+ return stopOwnedProcess(proc, { group: true }).then(() => true, (error: Error) => { this.opts.log?.(`[${this.id}] ${error.message}`); return false; });
151
+ }
152
+
150
153
  async stop(): Promise<void> {
151
154
  this.server?.stop(true);
152
155
  this.claimedTui?.tui.close(1001, "hub shutting down");
@@ -318,8 +321,7 @@ export class CodexPeer extends BasePeer {
318
321
  }
319
322
  if (gone) throw new Error(gone);
320
323
  const proc = this.proc;
321
- await stopOwnedProcess(proc, { group: true });
322
- if (this.proc === proc) this.proc = undefined;
324
+ if (proc && (await this.stopQuietly(proc)) && this.proc === proc) this.proc = undefined;
323
325
  throw new Error("codex app-server did not become healthy within 10 s");
324
326
  }
325
327
 
@@ -4,7 +4,7 @@ import { join, resolve, relative } from "node:path";
4
4
  import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
5
5
  import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
6
6
  import { BasePeer } from "../hub/peers.ts";
7
- import { stopOwnedProcess } from "../hub/child-process.ts";
7
+ import { stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
8
8
  import { realPath } from "../hub/project.ts";
9
9
  import type { ExecutionBudgetDecision, ExecutionUnit } from "../hub/execution-budget.ts";
10
10
 
@@ -266,7 +266,9 @@ export class PiPeer extends BasePeer {
266
266
  const command = this.opts.cmd ?? ["pi"];
267
267
  this._tuiLaunch = { cmd: command[0]!, args: [...command.slice(1), ...args], env };
268
268
  if (this.opts.mode === "tui") { return; }
269
- this.proc = spawn(command[0]!, [...command.slice(1), ...args], { cwd: this.opts.cwd, env, stdio: ["pipe", "pipe", "pipe"] });
269
+ // Its own process group, stopped as a whole (#115, as Codex's in #113).
270
+ this.proc = spawn(command[0]!, [...command.slice(1), ...args], { cwd: this.opts.cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
271
+ trackGroup(this.proc);
270
272
  this.proc.stdout.on("data", (chunk) => this.onOutput(String(chunk)));
271
273
  this.proc.stderr.on("data", (chunk) => this.opts.log?.(`[${this.id}] ${String(chunk).trimEnd()}`));
272
274
  this.proc.on("error", (error) => this.fail(error));
@@ -311,8 +313,10 @@ export class PiPeer extends BasePeer {
311
313
  this.resolveTuiExit?.(); this.resolveTuiExit = undefined;
312
314
  }
313
315
  const proc = this.proc;
314
- if (proc && proc.exitCode === null) await stopOwnedProcess(proc);
315
- this.proc = undefined;
316
+ // Also when it exited: what it left in its group fails the stop. The rest of the teardown runs either way; a process
317
+ // that could not be stopped stays recorded, so a later stop tries it again.
318
+ let failed: unknown;
319
+ if (proc) await stopOwnedProcess(proc, { group: true }).then(() => { if (this.proc === proc) this.proc = undefined; }, (error: unknown) => { failed = error; });
316
320
  for (const waiter of this.tuiWaiters) { clearTimeout(waiter.timer); waiter.resolve(undefined); }
317
321
  this.tuiWaiters = [];
318
322
  for (const pending of this.tuiCommands.values()) pending.reject(new Error("Pi owner stopped"));
@@ -320,6 +324,7 @@ export class PiPeer extends BasePeer {
320
324
  for (const pending of this.pending.values()) pending.reject(new Error("Pi owner stopped"));
321
325
  this.pending.clear();
322
326
  this.server?.stop(true); this.server = undefined; this.setState("offline");
327
+ if (failed) throw failed;
323
328
  }
324
329
 
325
330
  /**
@@ -102,18 +102,31 @@ export async function stopOwnedProcess(proc: ChildProcess, { termMs = 1_000, kil
102
102
  // ponytail: a leader that exited before the stop (its launcher killed from outside) is not swept, as its pid may be
103
103
  // reused once its group empties; its pipes are dropped so a survivor cannot keep the hub alive, and a group that
104
104
  // still has members fails the stop instead of reading as done. Record the leader's start time at spawn to sweep it.
105
- if (!group || proc.pid === undefined) return;
106
- dropPipes(proc);
105
+ if (proc.pid === undefined) return;
106
+ dropPipes(proc); // whatever it left cannot keep the hub alive through them
107
+ if (!group) return;
107
108
  if (emptied.has(proc)) return; // its group was seen gone since: the id may be someone else's now
108
109
  // A pid is not given out while a group with that id exists: a live process with the leader's pid means the group was
109
110
  // emptied and the id is someone else's now. Members without it are what the leader left (zombies are not listed).
110
- const rows = await table();
111
- const members = rows ? (rows.some((r) => r.pid === proc.pid) ? [] : rows.filter((r) => r.pgid === proc.pid)) : undefined;
112
- if (members ? members.length : !groupGone(proc.pid)) throw new Error(`owned child ${proc.pid} exited before the stop and its process group still has members${members ? ` (${members.map((r) => r.pid).join(", ")})` : ""}: not signalled; stop them to restart it`);
113
- return;
111
+ // Members still finishing their own exit get up to `killMs` before the stop fails.
112
+ for (const end = Date.now() + killMs; ; await Bun.sleep(100)) {
113
+ const rows = await table();
114
+ const reused = rows?.some((r) => r.pid === proc.pid);
115
+ const members = reused ? [] : rows?.filter((r) => r.pgid === proc.pid);
116
+ if (members ? !members.length : groupGone(proc.pid)) return;
117
+ if (Date.now() >= end) throw new Error(`owned child ${proc.pid} exited before the stop and its process group still has members${members ? ` (${members.map((r) => r.pid).join(", ")})` : ""}: not signalled; stop them to restart it`);
118
+ }
114
119
  }
115
120
  if (group) return stopGroup(proc, proc.pid, termMs, killMs, table);
121
+ try {
122
+ await stopAlone(proc, termMs, killMs);
123
+ } finally {
124
+ dropPipes(proc); // a process it started and left may still hold them: it must not keep the hub alive
125
+ }
126
+ }
116
127
 
128
+ /** SIGTERM, then SIGKILL, to the child alone, each confirmed by its exit. */
129
+ async function stopAlone(proc: ChildProcess, termMs: number, killMs: number): Promise<void> {
117
130
  const waitForExit = (timeoutMs: number): Promise<boolean> =>
118
131
  new Promise((resolve) => {
119
132
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -235,11 +248,16 @@ async function stopGroup(proc: ChildProcess, pid: number, termMs: number, killMs
235
248
  term(first);
236
249
  // The grace period, for the leader and for what it started: what they start meanwhile is recorded while its parent
237
250
  // still runs.
251
+ const leaderGone = new Promise<void>((resolve) => (exited() ? resolve() : proc.once("exit", () => resolve())));
238
252
  for (const end = Date.now() + termMs; Date.now() < end; ) {
239
- await Bun.sleep(100);
253
+ // The leader's exit ends a wait at once (a stop is usually that quick); after it, the table is read every 100 ms.
254
+ await (exited() ? Bun.sleep(100) : Promise.race([leaderGone, Bun.sleep(100)]));
240
255
  const rows = await look();
241
256
  term(rows);
242
- if (exited() && rows && !left(rows).length) break;
257
+ if (exited() && rows && !left(rows).length) {
258
+ if (!unproven(rows).length) return; // done: this read shows none of it
259
+ break;
260
+ }
243
261
  }
244
262
  // Freeze, enumerate, kill: a stopped process starts nothing, so a read after the freeze sees all of it.
245
263
  for (const end = Date.now() + killMs; ; ) {
package/src/hub/daemon.ts CHANGED
@@ -1023,6 +1023,7 @@ export async function startDaemon(opts: DaemonOptions) {
1023
1023
  * opened without blocking and only as a regular file, and the last version found is kept for a tail of big rows.
1024
1024
  */
1025
1025
  const claudeVersions = new Map<string, string>();
1026
+ const claudeStamps = new Map<string, string>(); // the transcript's size and mtime when its version was read (#115)
1026
1027
  const claudeVersion = (): string | undefined => {
1027
1028
  const path = claudeSession().transcriptPath;
1028
1029
  if (!path) return undefined;
@@ -1031,8 +1032,12 @@ export async function startDaemon(opts: DaemonOptions) {
1031
1032
  fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK);
1032
1033
  const st = fstatSync(fd);
1033
1034
  if (!st.isFile()) return claudeVersions.get(path);
1035
+ // Asked several times per assignment: the last MiB is read again only once the transcript changed.
1036
+ const stamp = `${st.size}:${st.mtimeMs}`;
1037
+ if (claudeStamps.get(path) === stamp) return claudeVersions.get(path); // no version in it is an answer too
1034
1038
  const buf = Buffer.alloc(Math.min(st.size, 1024 * 1024));
1035
1039
  readSync(fd, buf, 0, buf.length, st.size - buf.length);
1040
+ claudeStamps.set(path, stamp); // after the read: one that failed is tried again
1036
1041
  for (const line of buf.toString("utf8").split("\n").reverse()) {
1037
1042
  try {
1038
1043
  const version = JSON.parse(line)?.version;
@@ -1697,7 +1702,8 @@ export async function startDaemon(opts: DaemonOptions) {
1697
1702
  recoveryTaskPreface("pi");
1698
1703
  await ensurePreface("pi");
1699
1704
  bus.add(pi);
1700
- try { await pi.start(); } catch (error) { await pi.stop(); throw error; }
1705
+ // The start's own error is the one reported; a stop that fails too is logged beside it (#115).
1706
+ try { await pi.start(); } catch (error) { await pi.stop().catch((stop: Error) => log(`pi stop after a failed start: ${stop.message}`)); throw error; }
1701
1707
  return { ok: true, ...(mode === "tui" ? { launch: pi.tuiLaunch } : {}) };
1702
1708
  }
1703
1709
  if (peer === "local") {
@@ -18,10 +18,11 @@ export function canonicalPath(path: string): string {
18
18
  }
19
19
 
20
20
  /**
21
- * realpathSync, except that Bun 1.3.14 throws ENOENT for an existing path containing a backslash (issue #26). Such a
22
- * path goes to the system realpath, which handles it and returns every component as stored on disk: guardPath checks
23
- * names, and on a case-insensitive disk `.GIT/config` or `id_rſa` would otherwise pass for another file. A missing
24
- * path or a dangling symlink still throws, which guardPath relies on. Other paths take realpathSync's answer unchanged.
21
+ * realpathSync, except that Bun (1.3.14, and 1.4.2 still: #121) throws ENOENT for an existing path containing a
22
+ * backslash (issue #26). Such a path goes to the system realpath, which handles it and returns every component as
23
+ * stored on disk: guardPath checks names, and on a case-insensitive disk `.GIT/config` or `id_rſa` would otherwise
24
+ * pass for another file. A missing path or a dangling symlink still throws, which guardPath relies on. Other paths take
25
+ * realpathSync's answer unchanged.
25
26
  */
26
27
  export function realPath(path: string): string {
27
28
  try {
package/src/hub/tasks.ts CHANGED
@@ -97,6 +97,8 @@ const OWNERSHIP_EVENTS = new Set(["assigned", "escalated", "reassigned", "unassi
97
97
  const WITH_DONE = "with its done";
98
98
  /** How many times a task changed hands: a cohort member's generation (issue #107). */
99
99
  const ownerGen = (t: Task) => t.history.filter((h) => OWNERSHIP_EVENTS.has(h.event)).length;
100
+ /** One hand-over of a task to an owner: a task handed back to a peer that had it before is a new one (#115). */
101
+ const handOver = (t: Task, owner: PeerId) => `${t.id}@${owner}#${ownerGen(t)}`;
100
102
  /** When the task was handed to its current owner (its creation, if it never changed hands). */
101
103
  const handedAt = (t: Task) => [...t.history].reverse().find((h) => OWNERSHIP_EVENTS.has(h.event) && h.event !== "unassigned")?.at ?? t.history[0]?.at ?? Date.now();
102
104
 
@@ -141,6 +143,9 @@ export class Tasks {
141
143
  // What the on-prem worker says about a PII task is private on the bus (console tail and log show a stub), so the
142
144
  // board keeps the text: `ahub task show <id>` is where the console user reads it, a refusal included.
143
145
  d.bus.tap((e) => {
146
+ // A turn that ends takes its hand-overs with it: busy later is another turn (#115). The adapter's own state, since a
147
+ // pause shows as `paused` while the turn goes on.
148
+ if (e.t === "state" && d.bus.peers.get(e.peer)?.state !== "busy") for (const [k, p] of this.sent) if (p === e.peer) this.sent.delete(k);
144
149
  if (e.t !== "envelope" || !e.env.private || e.env.from === HUB || !e.env.refs?.task) return;
145
150
  try {
146
151
  d.board.update(Number(e.env.refs.task), e.env.from, "answer", {}, e.env.body.slice(0, 4000));
@@ -308,15 +313,18 @@ export class Tasks {
308
313
  const open = this.d.board.list().filter((t) => OPEN.includes(t.state) && t.id !== task.id && (t.id !== other.id || t.state !== "proposed"));
309
314
  // One task of a class is one unit: the only normalization the board supports, so another class is unknown.
310
315
  const unit = task.class === other.class ? 1 : undefined;
316
+ const taking = (p: PeerId) => states[p] === "busy" && (p === candidate ? this.sent.has(handOver(task, p)) : other.state === "proposed" && this.sent.has(handOver(other, p)));
311
317
  return predictSplit({
312
318
  peers,
313
319
  observations: Object.fromEntries(peers.map((p) => [p, this.splitObservations(task.class, p, task.id)])),
314
320
  units: [unit, unit],
315
321
  profiles: Object.fromEntries(peers.map((p) => [p, this.d.splitProfile?.(p)])),
316
322
  backlog: Object.fromEntries(peers.map((p) => [p, open.filter((t) => t.owner === p).length])),
317
- // The routed peer may be busy taking this very task, and the other owner taking the overlapped task while it is not
318
- // started yet (an owner goes busy as its task is delivered); busy otherwise, it is at work on something already.
319
- available: Object.fromEntries(peers.map((p) => [p, !failing[p] && (states[p] === "idle" || (states[p] === "busy" && (p === candidate || other.state === "proposed")))])),
323
+ // Busy is taking the task in question only in the turn that task started (an owner goes busy as its task is
324
+ // delivered) or in which it claimed it: the routed peer this very task, the other owner the overlapped one while it
325
+ // is not started. Busy otherwise, it is at work on something else (#109, #115; a routing or cohort record is taken
326
+ // before the task is sent, so a busy candidate is not available then).
327
+ available: Object.fromEntries(peers.map((p) => [p, !failing[p] && (states[p] === "idle" || taking(p))])),
320
328
  });
321
329
  }
322
330
 
@@ -650,6 +658,9 @@ export class Tasks {
650
658
  if (shadow) this.d.recordSplit?.(next.id, shadow, "routing");
651
659
  } catch { /* shadow only: never between the board write and the delivery */ }
652
660
  }
661
+ // A claim is its own hand-over: the claimant took the task in the turn it is in, so it is taking it, not busy elsewhere.
662
+ // The cohort record formed just below reads it, and so does `route explain` while that turn lasts.
663
+ if (opts.claim && a.owner === by && this.d.bus.peers.get(by)?.state === "busy") this.sent.set(handOver(next, by), by);
653
664
  const hits = this.overlapHits(next);
654
665
  this.formCohort(next, hits);
655
666
  if (hits.length) {
@@ -684,7 +695,14 @@ export class Tasks {
684
695
  context && !pii ? `Handoff from the previous owner:\n${this.screen(task, context, "handoff", a.owner).slice(0, 3000)}` : "",
685
696
  `Take it with hub_task_accept {id: ${task.id}, plan: {paths, symbols, signatures, insertion_points}} (what you will change, before you start${pii ? "" : "; owners of overlapping tasks see it"}) or pass with hub_task_decline. When finished: hub_task_done {id: ${task.id}, summary: what changed, why, and the check you ran with its result, refs}.`,
686
697
  ].filter(Boolean).join("\n\n");
687
- this.d.bus.publish(newEnvelope(HUB, body, { to: [task.owner!], kind: "task", priority: "important", refs: { ...task.refs, task: String(task.id) }, ...(pii ? { private: true } : {}) }));
698
+ // A hand-over the owner takes in a turn of its own (#109, #115): idle before, busy after and not queued, this envelope
699
+ // was in the delivery that started its turn. Busy before, it is queued, or steered into a turn about something else
700
+ // (Codex, Pi); held, it starts no turn yet: neither is taking it.
701
+ const owner = task.owner!;
702
+ const idle = this.d.bus.stateOf(owner) === "idle";
703
+ const env = newEnvelope(HUB, body, { to: [owner], kind: "task", priority: "important", refs: { ...task.refs, task: String(task.id) }, ...(pii ? { private: true } : {}) });
704
+ this.d.bus.publish(env);
705
+ if (idle && this.d.bus.stateOf(owner) === "busy" && !this.d.bus.queueIds(owner).includes(env.id)) this.sent.set(handOver(task, owner), owner);
688
706
  }
689
707
 
690
708
  /** Nobody, the console user included, works on a task before what it waits for is approved. */
@@ -702,6 +720,7 @@ export class Tasks {
702
720
  }
703
721
 
704
722
  private readonly offered = new Set<number>(); // ready tasks offered in this hub run
723
+ private readonly sent = new Map<string, PeerId>(); // hand-over (`handOver`) -> owner, for the owner's current turn: its task envelope started it, or it claimed the task in it
705
724
 
706
725
  /**
707
726
  * A stop between an approval and the assignment of its dependents (both are saved on their own) leaves them ownerless