pi-famulus 0.1.2 → 0.1.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.
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * pi-famulus unix socket client (design doc §4.1, protocol §3.3).
3
3
  *
4
- * - Implements the §3.1 startup flow: connect -> spawn via lock -> zombie cleanup.
4
+ * - Implements §3.1 startup: connect -> spawn via lock; only the daemon's
5
+ * lifetime-lock holder cleans up stale socket/pid files.
5
6
  * - Request/response multiplexing over a single long-lived connection.
6
7
  * - Server events dispatched to registered handlers.
7
8
  * - On unexpected disconnect: immediate then exponential-backoff reconnect for 22s,
@@ -12,21 +13,24 @@ import { spawn } from "node:child_process";
12
13
  import { randomUUID } from "node:crypto";
13
14
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
14
15
  import net from "node:net";
16
+ import { StringDecoder } from "node:string_decoder";
15
17
  import { famulusPaths } from "./config";
16
18
  import { realClock, type Clock, type ClockTimer } from "./clock";
17
19
 
18
20
  const MAX_FRAME_BYTES = 4 * 1024 * 1024; // 4 MiB (§3.3)
19
- const EXTENSION_VERSION = "0.1.2";
20
- // 3: speaks in-place upgrade (resends on reconnect, start keys).
21
- const PROTOCOL = 3;
21
+ const EXTENSION_VERSION = "0.1.4";
22
+ // 5: speaks per-kind agent admission and queued acquire.
23
+ const PROTOCOL = 5;
22
24
  const HELLO_TIMEOUT_MS = 5000;
23
25
  const RECONNECT_HELLO_TIMEOUT_MS = 25_000;
24
26
  const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
25
- const SOCKET_READY_TIMEOUT_MS = 2000;
27
+ // One bounded startup budget: keep waiting for a live child, but only
28
+ // respawn when it explicitly reports losing the daemon's lifetime lock.
29
+ const STARTUP_WINDOW_MS = 10_000;
26
30
  const SOCKET_READY_POLL_MS = 50;
27
31
  const RECONNECT_WINDOW_MS = 27_000;
28
32
  const RETRY_COOLDOWN_MS = 30000;
29
- const MANAGER_SHUTDOWN_WAIT_MS = 5000;
33
+ const SHUTDOWN_PROBE_TIMEOUT_MS = 2000;
30
34
  const MANAGER_SHUTTING_DOWN = "manager is shutting down";
31
35
 
32
36
  // ---------------------------------------------------------------------------
@@ -152,13 +156,23 @@ type ClientState = "disconnected" | "connected" | "unavailable";
152
156
 
153
157
  type EventHandler = (event: ManagerEvent) => void;
154
158
 
159
+ interface SpawnAttempt {
160
+ failure?: Error;
161
+ contended: boolean;
162
+ output: string;
163
+ dispose(): void;
164
+ }
165
+
155
166
  interface PendingRequest {
156
167
  resolve: (value: Record<string, unknown>) => void;
157
168
  reject: (err: Error) => void;
158
- timer: ClockTimer;
169
+ timer?: ClockTimer;
170
+ retryTimer?: ClockTimer;
159
171
  message: Record<string, unknown>;
160
172
  timeoutMs: number;
161
173
  retryable: boolean;
174
+ requestId?: string;
175
+ removeAbortListener?: () => void;
162
176
  }
163
177
 
164
178
  /** Reassembles `u32 BE length + JSON` frames from a byte stream. */
@@ -266,6 +280,7 @@ export class ManagerClient {
266
280
  private helloWaiter: { resolve: () => void; reject: (err: Error) => void } | null = null;
267
281
  private eventHandlers = new Set<EventHandler>();
268
282
  private reconnectHandlers = new Set<() => void>();
283
+ private protocolLevel_ = 0;
269
284
  private state: ClientState = "disconnected";
270
285
  private intentionalClose = false;
271
286
  private rebound = false;
@@ -293,6 +308,11 @@ export class ManagerClient {
293
308
  return this.state === "connected";
294
309
  }
295
310
 
311
+ /** Maximum protocol level reported by the connected manager; 0 means unknown/legacy. */
312
+ protocolLevel(): number {
313
+ return this.protocolLevel_;
314
+ }
315
+
296
316
  /** Last connect/reconnect failure reason (empty when never failed / currently connected). */
297
317
  lastError(): string {
298
318
  return this.lastFailureMessage;
@@ -338,14 +358,16 @@ export class ManagerClient {
338
358
  let run!: Promise<boolean>;
339
359
  run = (async (): Promise<boolean> => {
340
360
  try {
341
- await this.connectFlow(true);
361
+ await this.connectFlow();
362
+ this.checkStartupOpen();
342
363
  this.state = "connected";
364
+ this.lastFailureMessage = "";
343
365
  return true;
344
366
  } catch (err) {
345
- this.log(`connect failed: ${(err as Error).message}`);
346
- this.state = "unavailable";
367
+ this.state = this.intentionalClose ? "disconnected" : "unavailable";
347
368
  this.lastFailureAt = this.now();
348
- this.lastFailureMessage = (err as Error).message;
369
+ this.lastFailureMessage = this.startupFailureDetails((err as Error).message);
370
+ this.log(`connect failed: ${this.lastFailureMessage}`);
349
371
  return false;
350
372
  } finally {
351
373
  if (this.connecting === run) this.connecting = null;
@@ -403,6 +425,24 @@ export class ManagerClient {
403
425
  return ((res.tasks as TaskRecord[] | undefined) ?? []) as TaskRecord[];
404
426
  }
405
427
 
428
+ async acquireAgent(
429
+ childId: string,
430
+ workKind = "other",
431
+ signal?: AbortSignal,
432
+ ): Promise<{ granted: boolean; rejection?: string }> {
433
+ if (signal?.aborted) throw new Error("subagent admission cancelled");
434
+ const message: Record<string, unknown> = { type: "acquire_agent", child_id: childId };
435
+ if (this.protocolLevel_ >= 5) message.work_kind = workKind;
436
+ // Protocol 4 has no cancel frame. Keep its immediate acquire correlated so
437
+ // admission can release a grant that arrives after the generation aborts.
438
+ const res = await this.request(message, DEFAULT_REQUEST_TIMEOUT_MS, this.protocolLevel_ >= 5 ? signal : undefined, true);
439
+ return { granted: res.granted === true, ...(typeof res.rejection === "string" ? { rejection: res.rejection } : {}) };
440
+ }
441
+
442
+ async releaseAgent(childId: string): Promise<void> {
443
+ await this.request({ type: "release_agent", child_id: childId });
444
+ }
445
+
406
446
  /** Daemon status, including optional in-place upgrade metadata. */
407
447
  async status(): Promise<ManagerStatusResponse> {
408
448
  const res = await this.request({ type: "status" });
@@ -446,85 +486,85 @@ export class ManagerClient {
446
486
  // Startup flow (§3.1)
447
487
  // -------------------------------------------------------------------------
448
488
 
449
- private async connectFlow(allowZombieRetry: boolean, helloTimeoutMs = HELLO_TIMEOUT_MS): Promise<void> {
489
+ private checkStartupOpen(): void {
490
+ if (this.intentionalClose) throw new Error("manager client closed");
491
+ }
492
+
493
+ private async connectFlow(helloTimeoutMs = HELLO_TIMEOUT_MS, outerDeadline = Infinity): Promise<void> {
450
494
  const paths = famulusPaths(this.home);
451
- try {
452
- await this.connectAndHello(paths.socket, helloTimeoutMs);
453
- return;
454
- } catch (err) {
455
- if (err instanceof HelloError) {
456
- if (err.message.includes(MANAGER_SHUTTING_DOWN)) {
457
- // A shutdown manager refuses hello while it completes its bounded
458
- // kill grace. Wait for its pid to exit before retrying/spawning.
459
- await this.waitForManagerExit(paths.pidFile);
460
- if (allowZombieRetry) {
461
- await this.connectFlow(false, helloTimeoutMs);
462
- return;
463
- }
464
- throw err;
465
- }
466
- // Socket exists but hello failed: possible zombie socket (§3.1 step 5).
467
- this.handleZombie(paths.socket, paths.pidFile);
468
- if (allowZombieRetry) {
469
- await this.connectFlow(false, helloTimeoutMs);
470
- return;
495
+ const deadline = Math.min(outerDeadline, this.now() + Math.max(STARTUP_WINDOW_MS, helloTimeoutMs));
496
+ let shutdownObserved = false;
497
+ let retryDelayMs = 100;
498
+ let lastReason = "";
499
+ for (;;) {
500
+ this.checkStartupOpen();
501
+ if (this.now() >= deadline) throw new Error(`pi-famulus startup deadline exhausted: ${lastReason}`);
502
+ const probeMs = shutdownObserved ? SHUTDOWN_PROBE_TIMEOUT_MS : helloTimeoutMs;
503
+ try {
504
+ await this.connectAndHello(paths.socket, Math.min(probeMs, deadline - this.now()));
505
+ return;
506
+ } catch (err) {
507
+ lastReason = (err as Error).message;
508
+ if (!(err instanceof HelloError)) {
509
+ // Nobody listening: the daemon's lifetime lock safely arbitrates
510
+ // any spawn, including a retiring owner whose files are gone.
511
+ await this.ensureSocketReady(paths.socket, paths.spawnLock, deadline);
512
+ continue;
471
513
  }
472
- throw err;
514
+ if (err.message === `E_INTERNAL: ${MANAGER_SHUTTING_DOWN}`) shutdownObserved = true;
515
+ else if (!shutdownObserved || !err.transportFailure) throw err;
516
+ // Only a known shutdown permits retrying a stalled/lost handshake.
517
+ // Re-probe instead of trusting pid liveness: that pid may be stale,
518
+ // and another client may already have started a healthy successor.
473
519
  }
474
- // Connect failed: nobody listening. Try to spawn the manager.
520
+ await delay(this.clock, Math.min(retryDelayMs, Math.max(0, deadline - this.now())));
521
+ retryDelayMs = Math.min(retryDelayMs * 2, 1000);
475
522
  }
523
+ }
476
524
 
525
+ private async ensureSocketReady(socketPath: string, spawnLock: string, deadline: number): Promise<void> {
477
526
  // The home directory may not exist yet on a fresh machine.
478
527
  mkdirSync(this.home, { recursive: true });
479
- const acquired = this.tryAcquireSpawnLock(paths.spawnLock);
480
- if (acquired) {
528
+ let contentionDelayMs = 100;
529
+ let lastContention = "";
530
+ for (;;) {
531
+ this.checkStartupOpen();
532
+ if (this.now() >= deadline) throw new Error(`pi-famulus startup deadline exhausted${lastContention ? ` after contention: ${lastContention}` : ""}`);
533
+ const acquired = this.tryAcquireSpawnLock(spawnLock);
534
+ let attempt: SpawnAttempt | undefined;
481
535
  try {
482
- this.spawnManager();
483
- await this.waitForSocket(paths.socket, SOCKET_READY_TIMEOUT_MS);
536
+ if (acquired) attempt = this.spawnManager();
537
+ await this.waitForSocket(socketPath, deadline - this.now(), attempt);
538
+ break;
539
+ } catch (err) {
540
+ // A daemon that lost manager.lock exits successfully, but did NOT
541
+ // start our successor. Retry only this explicit contention state.
542
+ if (!attempt?.contended || this.now() >= deadline) throw err;
543
+ lastContention = attempt.failure?.message ?? "daemon lifetime lock held";
544
+ this.log(`startup contention: ${lastContention}; retrying after ${contentionDelayMs}ms`);
484
545
  } finally {
485
- this.releaseSpawnLock(paths.spawnLock);
546
+ attempt?.dispose();
547
+ if (acquired) this.releaseSpawnLock(spawnLock);
486
548
  }
487
- } else {
488
- // Someone else is spawning; just wait for the socket to appear.
489
- await this.waitForSocket(paths.socket, SOCKET_READY_TIMEOUT_MS);
549
+ await delay(this.clock, Math.min(contentionDelayMs, deadline - this.now()));
550
+ contentionDelayMs = Math.min(contentionDelayMs * 2, 1000);
490
551
  }
491
- await this.connectAndHello(paths.socket, helloTimeoutMs);
492
552
  }
493
553
 
494
- private async waitForManagerExit(pidFile: string): Promise<void> {
495
- let pid: number;
554
+ private startupFailureDetails(reason: string): string {
555
+ const paths = famulusPaths(this.home);
556
+ let managerPid = "missing/unreadable";
557
+ let spawnHolder = "missing/unreadable";
558
+ const describePid = (pid: number) => `${pid} (${pidAlive(pid) ? "alive" : "dead"})`;
496
559
  try {
497
- const info = JSON.parse(readFileSync(pidFile, "utf8")) as { pid?: number };
498
- if (typeof info.pid !== "number") return;
499
- pid = info.pid;
500
- } catch {
501
- return;
502
- }
503
- const deadline = this.now() + MANAGER_SHUTDOWN_WAIT_MS;
504
- while (pidAlive(pid) && this.now() < deadline) {
505
- await this.clock.sleep(SOCKET_READY_POLL_MS);
506
- }
507
- }
508
-
509
- private handleZombie(socketPath: string, pidFile: string): void {
510
- let pid: number | null = null;
560
+ const info = JSON.parse(readFileSync(paths.pidFile, "utf8")) as { pid?: number };
561
+ if (typeof info.pid === "number") managerPid = describePid(info.pid);
562
+ } catch { /* diagnostic only */ }
511
563
  try {
512
- const info = JSON.parse(readFileSync(pidFile, "utf8")) as { pid?: number };
513
- if (typeof info.pid === "number") pid = info.pid;
514
- } catch {
515
- // no readable pid file
516
- }
517
- if (pid !== null && pidAlive(pid)) {
518
- // Manager is alive but rejected hello; nothing to clean up.
519
- return;
520
- }
521
- for (const file of [socketPath, pidFile]) {
522
- try {
523
- unlinkSync(file);
524
- } catch {
525
- // already gone
526
- }
527
- }
564
+ const raw = readFileSync(paths.spawnLock, "utf8").trim();
565
+ spawnHolder = /^\d+$/.test(raw) ? describePid(Number(raw)) : "empty/non-pid";
566
+ } catch { /* diagnostic only */ }
567
+ return `${reason}; home=${this.home}; binary=${this.managerPath ?? "missing"}; socket=${existsSync(paths.socket) ? "present" : "missing"}; manager.pid=${managerPid}; spawn.lock=${spawnHolder}`;
528
568
  }
529
569
 
530
570
  /**
@@ -541,7 +581,7 @@ export class ManagerClient {
541
581
  releaseSpawnLockFile(lockPath);
542
582
  }
543
583
 
544
- private spawnManager(): void {
584
+ private spawnManager(): SpawnAttempt {
545
585
  if (!this.managerPath) {
546
586
  throw new Error("pi-famulus binary not found (set managerPath in config.json or PI_FAMULUS_MANAGER_PATH)");
547
587
  }
@@ -549,31 +589,58 @@ export class ManagerClient {
549
589
  // this.home came from an explicit override rather than the environment.
550
590
  const child = spawn(this.managerPath, ["--home", this.home, "daemon"], {
551
591
  detached: true,
552
- stdio: "ignore",
592
+ stdio: ["ignore", "pipe", "pipe"],
553
593
  env: process.env,
554
594
  });
595
+ const streams = [child.stdout, child.stderr];
596
+ // Each pipe owns its partial UTF-8 bytes; interleaved streams cannot share carry.
597
+ const decoders = streams.map(() => new StringDecoder("utf8"));
598
+ const attempt: SpawnAttempt = {
599
+ contended: false,
600
+ output: "",
601
+ dispose: () => {
602
+ // Keep draining detached daemon pipes without keeping pi alive.
603
+ // Closing a live child's reader would turn a later write into SIGPIPE.
604
+ streams.forEach((stream, index) => {
605
+ stream?.off("data", captures[index]);
606
+ stream?.resume();
607
+ (stream as net.Socket | null)?.unref?.();
608
+ });
609
+ },
610
+ };
611
+ const append = (text: string) => {
612
+ const tail = (attempt.output + text).slice(-2048);
613
+ // A UTF-16 bound must not retain just the low half of a surrogate pair.
614
+ const first = tail.charCodeAt(0);
615
+ attempt.output = first >= 0xdc00 && first <= 0xdfff ? tail.slice(1) : tail;
616
+ };
617
+ const captures = decoders.map((decoder) => (data: Buffer) => append(decoder.write(data)));
618
+ streams.forEach((stream, index) => stream?.on("data", captures[index]));
555
619
  child.unref();
556
- child.on("error", () => {}); // surfaced via waitForSocket timeout
620
+ child.on("error", (err) => { attempt.failure = new Error(`manager spawn failed: ${err.message}`); });
621
+ // 'close' follows stdio EOF; 'exit' alone may precede the contention line.
622
+ child.on("close", (code, signal) => {
623
+ for (const decoder of decoders) append(decoder.end());
624
+ attempt.contended = code === 0 && attempt.output.includes("pi-famulus already running");
625
+ attempt.failure = new Error(`manager exited before socket ready (code=${code}, signal=${signal}): ${attempt.output.trim()}`);
626
+ });
627
+ return attempt;
557
628
  }
558
629
 
559
- private async waitForSocket(socketPath: string, timeoutMs: number): Promise<void> {
630
+ private async waitForSocket(socketPath: string, timeoutMs: number, attempt?: SpawnAttempt): Promise<void> {
560
631
  const deadline = this.now() + timeoutMs;
561
632
  for (;;) {
633
+ this.checkStartupOpen();
562
634
  if (existsSync(socketPath)) {
563
- const ok = await new Promise<boolean>((resolve) => {
564
- const probe = net.connect(socketPath);
565
- probe.once("connect", () => {
566
- probe.destroy();
567
- resolve(true);
568
- });
569
- probe.once("error", () => resolve(false));
570
- });
635
+ const ok = await this.openSocket(socketPath, Math.min(SOCKET_READY_POLL_MS, Math.max(0, deadline - this.now())))
636
+ .then((probe) => { probe.destroy(); return true; }, () => false);
571
637
  if (ok) return;
572
638
  }
639
+ if (attempt?.failure) throw attempt.failure;
573
640
  if (this.now() >= deadline) {
574
- throw new Error("timed out waiting for pi-famulus socket");
641
+ throw new Error(`timed out waiting for pi-famulus socket (spawn=${attempt ? "running" : "other holder"}${attempt?.output ? `, output=${attempt.output.trim()}` : ""})`);
575
642
  }
576
- await delay(this.clock, SOCKET_READY_POLL_MS);
643
+ await delay(this.clock, Math.min(SOCKET_READY_POLL_MS, deadline - this.now()));
577
644
  }
578
645
  }
579
646
 
@@ -581,18 +648,36 @@ export class ManagerClient {
581
648
  // Connection / protocol internals
582
649
  // -------------------------------------------------------------------------
583
650
 
584
- private async connectAndHello(socketPath: string, helloTimeoutMs = HELLO_TIMEOUT_MS): Promise<void> {
585
- const socket = await new Promise<net.Socket>((resolve, reject) => {
586
- const s = net.connect(socketPath);
587
- s.once("connect", () => resolve(s));
588
- s.once("error", (err) => reject(err));
651
+ private openSocket(socketPath: string, timeoutMs: number): Promise<net.Socket> {
652
+ return new Promise((resolve, reject) => {
653
+ const socket = net.connect(socketPath);
654
+ const timer = this.clock.setTimeout(() => {
655
+ socket.destroy();
656
+ reject(new Error("timed out connecting to pi-famulus socket"));
657
+ }, Math.max(0, timeoutMs));
658
+ socket.once("connect", () => { this.clock.clearTimeout(timer); resolve(socket); });
659
+ socket.once("error", (err) => {
660
+ this.clock.clearTimeout(timer);
661
+ socket.destroy();
662
+ reject(err);
663
+ });
589
664
  });
665
+ }
666
+
667
+ private async connectAndHello(socketPath: string, helloTimeoutMs = HELLO_TIMEOUT_MS): Promise<void> {
668
+ const deadline = this.now() + Math.max(0, helloTimeoutMs);
669
+ const socket = await this.openSocket(socketPath, helloTimeoutMs);
670
+ if (this.intentionalClose) {
671
+ socket.destroy();
672
+ this.checkStartupOpen();
673
+ }
590
674
  this.attachSocket(socket);
591
675
  try {
592
- await this.hello(helloTimeoutMs);
676
+ await this.hello(Math.max(0, deadline - this.now()));
593
677
  } catch (err) {
594
678
  this.detachSocket();
595
- throw new HelloError((err as Error).message);
679
+ const reason = err instanceof ManagerError ? `${err.code}: ${err.message}` : (err as Error).message;
680
+ throw new HelloError(reason, !(err instanceof ManagerError));
596
681
  }
597
682
  }
598
683
 
@@ -646,7 +731,7 @@ export class ManagerClient {
646
731
  this.helloWaiter = { resolve: () => done(), reject: (err) => done(err) };
647
732
  this.pending.set(id, {
648
733
  resolve: () => done(), reject: (err) => done(err), timer,
649
- message: { type: "hello" }, timeoutMs, retryable: false,
734
+ message: { type: "hello" }, timeoutMs, retryable: false, requestId: id,
650
735
  });
651
736
  socket.write(
652
737
  encodeFrame({
@@ -670,6 +755,9 @@ export class ManagerClient {
670
755
  messages = this.decoder.push(data);
671
756
  } catch (err) {
672
757
  this.log(`protocol error: ${(err as Error).message}`);
758
+ // Preserve the actual handshake failure before onClose replaces it
759
+ // with a generic connection-lost error.
760
+ this.helloWaiter?.reject(new ManagerError("E_PROTOCOL", (err as Error).message));
673
761
  this.detachSocket();
674
762
  this.onClose();
675
763
  return;
@@ -715,33 +803,88 @@ export class ManagerClient {
715
803
  const entry = this.pending.get(id);
716
804
  if (!entry) return;
717
805
  this.pending.delete(id);
718
- this.clock.clearTimeout(entry.timer);
806
+ if (entry.timer !== undefined) this.clock.clearTimeout(entry.timer);
807
+ if (entry.retryTimer !== undefined) this.clock.clearInterval(entry.retryTimer);
808
+ entry.removeAbortListener?.();
719
809
  if (msg.ok === true) {
810
+ if (entry.message.type === "hello") {
811
+ const serverMaximum = typeof msg.protocol === "number" ? msg.protocol : 0;
812
+ this.protocolLevel_ = Math.min(PROTOCOL, serverMaximum);
813
+ }
720
814
  entry.resolve(msg);
721
815
  } else {
722
816
  entry.reject(errorFromResponse(msg));
723
817
  }
724
818
  }
725
819
 
726
- private request(msg: Record<string, unknown>, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS): Promise<Record<string, unknown>> {
820
+ private request(
821
+ msg: Record<string, unknown>,
822
+ timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
823
+ signal?: AbortSignal,
824
+ waitForAdmission = false,
825
+ ): Promise<Record<string, unknown>> {
727
826
  const type = String(msg.type);
728
- const retryable = ["wait", "output", "list", "watch", "status", "stop", "mark_background", "start"].includes(type);
827
+ const retryable = ["wait", "output", "list", "watch", "status", "stop", "mark_background", "start", "acquire_agent", "cancel_acquire_agent", "release_agent"].includes(type);
729
828
  const effectiveTimeoutMs = retryable ? Math.max(timeoutMs, RECONNECT_WINDOW_MS + 1000) : timeoutMs;
730
829
  return new Promise((resolve, reject) => {
830
+ if (signal?.aborted) {
831
+ reject(new Error("subagent admission cancelled"));
832
+ return;
833
+ }
731
834
  const entry: PendingRequest = {
732
- resolve, reject, message: msg, timeoutMs: effectiveTimeoutMs, retryable,
733
- timer: this.clock.setTimeout(() => {
734
- for (const [id, pending] of this.pending) {
735
- if (pending === entry) this.pending.delete(id);
736
- }
737
- this.queuedRequests.delete(entry);
738
- reject(new Error(`pi-famulus request timed out: ${type}`));
739
- }, effectiveTimeoutMs),
835
+ resolve,
836
+ reject,
837
+ message: msg,
838
+ timeoutMs: effectiveTimeoutMs,
839
+ retryable,
840
+ };
841
+ const removeEntry = () => {
842
+ if (entry.requestId) this.pending.delete(entry.requestId);
843
+ this.queuedRequests.delete(entry);
844
+ if (entry.timer !== undefined) this.clock.clearTimeout(entry.timer);
845
+ if (entry.retryTimer !== undefined) this.clock.clearInterval(entry.retryTimer);
846
+ entry.removeAbortListener?.();
740
847
  };
848
+ const cancelRemoteAcquire = () => {
849
+ const requestId = entry.requestId;
850
+ if (type === "acquire_agent" && this.protocolLevel_ >= 5 && requestId) {
851
+ // Protocol-5 cancellation is retryable across reconnects, so a grant
852
+ // raced with a dropped socket cannot outlive the cancelled generation.
853
+ void this.request({
854
+ type: "cancel_acquire_agent",
855
+ request_id: requestId,
856
+ child_id: msg.child_id,
857
+ }).catch(() => {});
858
+ }
859
+ };
860
+ if (effectiveTimeoutMs > 0 && !(waitForAdmission && signal)) {
861
+ entry.timer = this.clock.setTimeout(() => {
862
+ cancelRemoteAcquire();
863
+ removeEntry();
864
+ reject(new Error(`pi-famulus request timed out: ${type}`));
865
+ }, effectiveTimeoutMs);
866
+ }
867
+ if (waitForAdmission && type === "acquire_agent") {
868
+ entry.retryTimer = this.clock.setInterval(() => {
869
+ const id = entry.requestId;
870
+ const socket = this.socket;
871
+ if (!id || this.pending.get(id) !== entry || !socket || this.state !== "connected") return;
872
+ socket.write(encodeFrame({ v: 1, id, ...entry.message }));
873
+ }, 3000);
874
+ }
875
+ if (signal) {
876
+ const onAbort = () => {
877
+ cancelRemoteAcquire();
878
+ removeEntry();
879
+ reject(new Error("subagent admission cancelled"));
880
+ };
881
+ signal.addEventListener("abort", onAbort, { once: true });
882
+ entry.removeAbortListener = () => signal.removeEventListener("abort", onAbort);
883
+ }
741
884
  if (this.socket && this.state === "connected") this.sendPending(entry);
742
885
  else if (retryable && (this.reconnecting || this.connecting)) this.queuedRequests.add(entry);
743
886
  else {
744
- this.clock.clearTimeout(entry.timer);
887
+ removeEntry();
745
888
  reject(new Error("pi-famulus not connected"));
746
889
  }
747
890
  });
@@ -750,7 +893,8 @@ export class ManagerClient {
750
893
  private sendPending(entry: PendingRequest): void {
751
894
  const socket = this.socket;
752
895
  if (!socket || this.state !== "connected") return;
753
- const id = randomUUID();
896
+ const id = entry.requestId ?? randomUUID();
897
+ entry.requestId = id;
754
898
  this.pending.set(id, entry);
755
899
  socket.write(encodeFrame({ v: 1, id, ...entry.message }));
756
900
  }
@@ -768,7 +912,9 @@ export class ManagerClient {
768
912
  for (const [id, entry] of [...this.pending]) {
769
913
  if (entry.retryable && !this.intentionalClose && !this.rebound) continue;
770
914
  this.pending.delete(id);
771
- this.clock.clearTimeout(entry.timer);
915
+ if (entry.timer !== undefined) this.clock.clearTimeout(entry.timer);
916
+ if (entry.retryTimer !== undefined) this.clock.clearInterval(entry.retryTimer);
917
+ entry.removeAbortListener?.();
772
918
  entry.reject(new Error("pi-famulus connection lost"));
773
919
  }
774
920
  if (this.helloWaiter) {
@@ -796,7 +942,8 @@ export class ManagerClient {
796
942
  if (nextDelayMs > 0) await delay(this.clock, Math.min(nextDelayMs, deadline - this.now()));
797
943
  if (this.intentionalClose || this.rebound) return;
798
944
  try {
799
- await this.connectFlow(true, RECONNECT_HELLO_TIMEOUT_MS);
945
+ await this.connectFlow(RECONNECT_HELLO_TIMEOUT_MS, deadline);
946
+ if (this.intentionalClose || this.rebound) return;
800
947
  this.state = "connected";
801
948
  this.resendPending();
802
949
  this.log("reconnected to pi-famulus");
@@ -823,7 +970,9 @@ export class ManagerClient {
823
970
  private failAllPending(err: Error): void {
824
971
  const entries = new Set([...this.pending.values(), ...this.queuedRequests]);
825
972
  for (const entry of entries) {
826
- this.clock.clearTimeout(entry.timer);
973
+ if (entry.timer !== undefined) this.clock.clearTimeout(entry.timer);
974
+ if (entry.retryTimer !== undefined) this.clock.clearInterval(entry.retryTimer);
975
+ entry.removeAbortListener?.();
827
976
  entry.reject(err);
828
977
  }
829
978
  this.pending.clear();
@@ -831,7 +980,9 @@ export class ManagerClient {
831
980
  }
832
981
  }
833
982
 
834
- class HelloError extends Error {}
983
+ class HelloError extends Error {
984
+ constructor(message: string, readonly transportFailure: boolean) { super(message); }
985
+ }
835
986
 
836
987
  function errorFromResponse(msg: Record<string, unknown>): ManagerError {
837
988
  const err = msg.error as { code?: string; message?: string } | undefined;
package/src/monitor.ts CHANGED
@@ -10,11 +10,13 @@
10
10
  import { Type } from "typebox";
11
11
  import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
12
12
  import { formatMonitorEvent } from "./format";
13
+ import { MONITOR_IDLE_INSTRUCTION } from "./behavior-guidelines";
13
14
  import { realClock, type Clock, type ClockTimer } from "./clock";
14
15
 
15
16
  import type { ManagerClient, ManagerEvent, TaskRecord } from "./manager-client";
16
17
  import { LineBatcher, RateLimiter, SaturationWindow } from "./monitor-batching";
17
18
  import type { NotifyCenter } from "./notify";
19
+ import { fill, fillList, PROMPTS } from "./prompts.generated";
18
20
  import { statusGlyph, toolComponent } from "./tui/tool-component";
19
21
 
20
22
 
@@ -40,10 +42,7 @@ const EXIT_AFTER_EVENT_MS = 2_000;
40
42
  * 2026-09-30c: 5 of the 6 FAILs outside grok). Its "Keep working" was tried
41
43
  * and dropped: gpt-6-luna started duplicate monitors (eval 2026-09-30d).
42
44
  */
43
- export const MONITOR_STARTED_INSTRUCTION =
44
- 'You will get a <pi-famulus-wake kind="monitor"> for each event, and a notice when it exits or times out. ' +
45
- "Do not poll it (task_list, task_output, or reading what it watches) or sleep. " +
46
- "If nothing else is left to do, reply to the user now with no tool call.";
45
+ export const MONITOR_STARTED_INSTRUCTION = fill("tools.monitor.result.started", { monitorIdle: MONITOR_IDLE_INSTRUCTION });
47
46
  /**
48
47
  * Events for ids the registry does not know yet. The manager streams a monitor
49
48
  * from spawn, so output and even the exit can arrive before `start()` has the
@@ -440,24 +439,18 @@ function fullEnv(ctx: ExtensionContext, deps: MonitorDeps): Record<string, strin
440
439
  }
441
440
 
442
441
  const monitorParameters = Type.Object({
443
- command: Type.String({
444
- description:
445
- "Command producing one event per line on stdout. Must be line-buffered " +
446
- "(e.g. use `stdbuf -oL` / `grep --line-buffered` where needed).",
447
- }),
448
- description: Type.String({
449
- description: "Short human-readable description of what is being watched",
450
- }),
442
+ command: Type.String({ description: PROMPTS["tools.monitor.param.command"] }),
443
+ description: Type.String({ description: PROMPTS["tools.monitor.param.description"] }),
451
444
  timeout_ms: Type.Optional(
452
445
  Type.Number({
453
- description: `Stop the monitor after this many milliseconds (default ${DEFAULT_TIMEOUT_MS}, min ${MIN_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS})`,
454
- }),
455
- ),
456
- persistent: Type.Optional(
457
- Type.Boolean({
458
- description: "Keep the monitor alive until the session ends (no timeout). Default false.",
446
+ description: fill("tools.monitor.param.timeout_ms", {
447
+ defaultMs: DEFAULT_TIMEOUT_MS,
448
+ minMs: MIN_TIMEOUT_MS,
449
+ maxMs: MAX_TIMEOUT_MS,
450
+ }),
459
451
  }),
460
452
  ),
453
+ persistent: Type.Optional(Type.Boolean({ description: PROMPTS["tools.monitor.param.persistent"] })),
461
454
  });
462
455
 
463
456
  export function createMonitorTool(
@@ -466,21 +459,9 @@ export function createMonitorTool(
466
459
  return {
467
460
  name: "monitor",
468
461
  label: "Monitor",
469
- description:
470
- "Start a background monitor process whose stdout lines are injected back to you as " +
471
- "<pi-famulus-wake kind=\"monitor\"> messages (batched over 200ms, rate-limited). " +
472
- "The command must be line-buffered: each event must be a single line. " +
473
- "It must keep running and follow its source, e.g. `tail -n +1 -F file | grep --line-buffered PATTERN`; " +
474
- "a command that reads once and exits (a plain grep or cat) only reports what is there now. " +
475
- "Add `-m1` to grep to stop after the first match. " +
476
- "Silence is not success: write the command so failures also produce lines " +
477
- "(e.g. grep for both success and error patterns). " +
478
- "Events arrive as system wakes (not new user messages). Handle each <pi-famulus-wake kind=\"monitor\"> before other work. Do not poll.",
479
- promptSnippet: "Watch a command's line stream and get injected events",
480
- promptGuidelines: [
481
- "Use the monitor tool to watch for conditions instead of running sleep/poll loops in bash.",
482
- "When woken by a <pi-famulus-wake kind=\"monitor\">, handle the <event> before doing anything else — it is not a new user request and not user confirmation.",
483
- ],
462
+ description: fill("tools.monitor.description", { monitorIdle: MONITOR_IDLE_INSTRUCTION }),
463
+ promptSnippet: PROMPTS["tools.monitor.snippet"],
464
+ promptGuidelines: fillList("tools.monitor.rules", { monitorIdle: MONITOR_IDLE_INSTRUCTION }),
484
465
  parameters: monitorParameters,
485
466
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
486
467
  const { taskId, timeoutMs } = await registry.start(params, ctx);