@gajae-code/coding-agent 0.12.18 → 0.12.20

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.
Files changed (100) hide show
  1. package/CHANGELOG.md +26 -9
  2. package/dist/types/commands/gc.d.ts +4 -0
  3. package/dist/types/commands/sdk.d.ts +66 -0
  4. package/dist/types/config/settings-schema.d.ts +34 -0
  5. package/dist/types/gjc-runtime/gc-runtime.d.ts +97 -1
  6. package/dist/types/modes/acp/acp-agent.d.ts +33 -1
  7. package/dist/types/modes/components/bash-execution.d.ts +7 -0
  8. package/dist/types/modes/components/eval-execution.d.ts +7 -0
  9. package/dist/types/modes/utils/ui-helpers.d.ts +10 -1
  10. package/dist/types/sdk/acp/adapter.d.ts +9 -17
  11. package/dist/types/sdk/acp/index.d.ts +1 -1
  12. package/dist/types/sdk/broker/broker.d.ts +93 -1
  13. package/dist/types/sdk/broker/ensure.d.ts +17 -0
  14. package/dist/types/sdk/broker/index.d.ts +1 -0
  15. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +10 -1
  16. package/dist/types/sdk/broker/lifecycle.d.ts +81 -2
  17. package/dist/types/sdk/broker/startup-budget.d.ts +44 -0
  18. package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
  19. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +11 -1
  20. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -0
  21. package/dist/types/sdk/bus/index.d.ts +20 -9
  22. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -6
  23. package/dist/types/sdk/bus/slack-daemon.d.ts +2 -2
  24. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -2
  25. package/dist/types/sdk/host/query/revision-store.d.ts +5 -0
  26. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  27. package/dist/types/sdk/lifecycle-session.d.ts +13 -2
  28. package/dist/types/sdk/prompt-failure.d.ts +13 -0
  29. package/dist/types/sdk/prompt-watchdog.d.ts +82 -0
  30. package/dist/types/sdk/session-reconnect.d.ts +20 -0
  31. package/dist/types/sdk/startup-capability.d.ts +1 -1
  32. package/dist/types/session/agent-session.d.ts +14 -4
  33. package/dist/types/session/blob-store.d.ts +32 -0
  34. package/dist/types/session/internal/managed-session-storage.d.ts +40 -2
  35. package/dist/types/session/internal/session-memory-sidecar.d.ts +881 -0
  36. package/dist/types/session/session-manager.d.ts +301 -27
  37. package/dist/types/session/session-storage.d.ts +170 -0
  38. package/dist/types/task/executor.d.ts +2 -1
  39. package/dist/types/tools/bash.d.ts +1 -0
  40. package/dist/types/tui/output-block.d.ts +6 -0
  41. package/package.json +8 -8
  42. package/src/cli/args.ts +75 -34
  43. package/src/commands/acp.ts +7 -1
  44. package/src/commands/gc.ts +31 -2
  45. package/src/commands/sdk.ts +141 -7
  46. package/src/config/settings-schema.ts +43 -0
  47. package/src/coordinator-mcp/server.ts +6 -1
  48. package/src/export/html/index.ts +83 -39
  49. package/src/gjc-runtime/gc-runtime.ts +1283 -4
  50. package/src/internal-urls/docs-index.generated.ts +1 -1
  51. package/src/internal-urls/local-protocol.ts +14 -0
  52. package/src/main.ts +40 -9
  53. package/src/modes/acp/acp-agent.ts +564 -37
  54. package/src/modes/acp/terminal-auth.ts +2 -2
  55. package/src/modes/components/bash-execution.ts +14 -0
  56. package/src/modes/components/eval-execution.ts +14 -0
  57. package/src/modes/controllers/command-controller.ts +63 -8
  58. package/src/modes/utils/ui-helpers.ts +81 -11
  59. package/src/sdk/acp/adapter.ts +46 -45
  60. package/src/sdk/acp/index.ts +1 -1
  61. package/src/sdk/broker/broker.ts +392 -11
  62. package/src/sdk/broker/ensure.ts +131 -61
  63. package/src/sdk/broker/index.ts +1 -0
  64. package/src/sdk/broker/lifecycle-ledger.ts +59 -5
  65. package/src/sdk/broker/lifecycle.ts +278 -63
  66. package/src/sdk/broker/startup-budget.ts +103 -0
  67. package/src/sdk/bus/chat-daemon-control.ts +9 -3
  68. package/src/sdk/bus/chat-daemon-runtime.ts +565 -29
  69. package/src/sdk/bus/conversation-store.ts +1 -1
  70. package/src/sdk/bus/discord-daemon.ts +2 -1
  71. package/src/sdk/bus/discord-live-provider.ts +34 -6
  72. package/src/sdk/bus/index.ts +118 -20
  73. package/src/sdk/bus/kind-aware-reconciliation.ts +2 -1
  74. package/src/sdk/bus/prompt-reconciliation.ts +4 -9
  75. package/src/sdk/bus/slack-daemon.ts +91 -18
  76. package/src/sdk/bus/telegram-daemon-contract.ts +4 -2
  77. package/src/sdk/bus/topic-registry.ts +7 -1
  78. package/src/sdk/cli/session-cli.ts +8 -1
  79. package/src/sdk/host/query/handlers.ts +1 -1
  80. package/src/sdk/host/query/revision-store.ts +48 -12
  81. package/src/sdk/host/session-runtime.test.ts +50 -0
  82. package/src/sdk/host/session-runtime.ts +12 -2
  83. package/src/sdk/lifecycle-session.ts +26 -3
  84. package/src/sdk/mcp/server.ts +6 -1
  85. package/src/sdk/prompt-failure.ts +42 -0
  86. package/src/sdk/prompt-watchdog.ts +162 -0
  87. package/src/sdk/session-reconnect.ts +43 -0
  88. package/src/sdk/startup-capability.ts +13 -4
  89. package/src/session/agent-session.ts +525 -222
  90. package/src/session/blob-store.ts +89 -0
  91. package/src/session/internal/managed-session-scope.ts +36 -26
  92. package/src/session/internal/managed-session-storage.ts +303 -32
  93. package/src/session/internal/session-memory-sidecar.ts +2160 -0
  94. package/src/session/session-manager.ts +7027 -394
  95. package/src/session/session-storage.ts +1134 -11
  96. package/src/task/executor.ts +17 -4
  97. package/src/tools/bash.ts +109 -7
  98. package/src/tools/find.ts +55 -26
  99. package/src/tools/todo-contract.ts +40 -8
  100. package/src/tui/output-block.ts +11 -1
@@ -1,8 +1,10 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import type { BigIntStats } from "node:fs";
3
3
  import * as fs from "node:fs/promises";
4
+ import * as os from "node:os";
4
5
  import path from "node:path";
5
6
  import type { NativeDirectoryTreeSnapshot } from "@gajae-code/natives";
7
+ import { logger } from "@gajae-code/utils";
6
8
  import type { ModelProfileErrorDetails } from "../../config/model-profile-contract";
7
9
  import {
8
10
  type DirectoryMigrationPolicy,
@@ -62,6 +64,8 @@ export type BrokerErrorCode =
62
64
  | "resource_gone"
63
65
  | "invalid_input"
64
66
  | "spawn_failed"
67
+ | "startup_admission_timeout"
68
+ | "startup_admission_refused"
65
69
  | "readiness_timeout"
66
70
  | "close_refused"
67
71
  | "not_found"
@@ -395,6 +399,148 @@ type BrokerLockSnapshot = {
395
399
  lockIdentity: string;
396
400
  };
397
401
 
402
+ /** Tombstone prefix used by {@link Broker.reclaimStaleLock} when a dead owner's lock is renamed aside. */
403
+ export const BROKER_LOCK_TOMBSTONE_PREFIX = ".broker.lock.stale-";
404
+
405
+ /**
406
+ * Recovery directories left beside the lock by manual and older automated broker
407
+ * restarts. Nothing writes them today, but installs that ever recovered by hand
408
+ * still carry them, so the reaper owns them alongside its own tombstones.
409
+ */
410
+ export const BROKER_LOCK_BACKUP_PREFIXES = ["broker-restart-backup-", "broker-stale-backup-"] as const;
411
+
412
+ /**
413
+ * Age bound before a reclaimed lock artifact may be removed. Generous enough
414
+ * that a broker still settling after a reclaim can never have its own successor
415
+ * state deleted underneath it.
416
+ */
417
+ export const BROKER_LOCK_ARTIFACT_GRACE_MS = 24 * 60 * 60 * 1_000;
418
+
419
+ /** Why a candidate lock artifact survived a reap pass. */
420
+ export type BrokerLockArtifactRetentionReason =
421
+ | "within-grace"
422
+ | "owner-alive"
423
+ | "owner-record-unreadable"
424
+ | "owner-record-missing"
425
+ | "not-a-directory"
426
+ | "removal-failed";
427
+
428
+ export interface BrokerLockArtifactRetention {
429
+ path: string;
430
+ reason: BrokerLockArtifactRetentionReason;
431
+ }
432
+
433
+ export interface BrokerLockArtifactReapResult {
434
+ removed: string[];
435
+ retained: BrokerLockArtifactRetention[];
436
+ }
437
+
438
+ function isBrokerLockArtifactName(name: string): boolean {
439
+ return (
440
+ name.startsWith(BROKER_LOCK_TOMBSTONE_PREFIX) ||
441
+ BROKER_LOCK_BACKUP_PREFIXES.some(prefix => name.startsWith(prefix))
442
+ );
443
+ }
444
+
445
+ /**
446
+ * Decide whether one candidate directory is provably abandoned.
447
+ *
448
+ * Fail-closed by construction: every branch that cannot prove abandonment
449
+ * returns a retention reason. A tombstone is only abandoned when its owner
450
+ * record parses and names a dead PID — an unreadable, permission-denied, or
451
+ * absent record keeps it forever. Backup directories carry no owner contract,
452
+ * so an absent record there is not ambiguity and age alone governs.
453
+ */
454
+ async function classifyBrokerLockArtifact(
455
+ directory: string,
456
+ name: string,
457
+ now: number,
458
+ graceMs: number,
459
+ pidAlive: (pid: number) => boolean,
460
+ ): Promise<BrokerLockArtifactRetentionReason | "abandoned"> {
461
+ const target = path.join(directory, name);
462
+ // lstat, never stat: a symlink pointing at live state must never be followed
463
+ // into a recursive removal.
464
+ const stat = await fs.lstat(target);
465
+ if (!stat.isDirectory()) return "not-a-directory";
466
+ if (!Number.isFinite(stat.mtimeMs) || now - stat.mtimeMs < graceMs) return "within-grace";
467
+ let raw: string;
468
+ try {
469
+ raw = await fs.readFile(path.join(target, BROKER_LOCK_RECORD), "utf8");
470
+ } catch (error) {
471
+ const code = (error as NodeJS.ErrnoException).code;
472
+ if (code !== "ENOENT" && code !== "ENOTDIR") return "owner-record-unreadable";
473
+ return name.startsWith(BROKER_LOCK_TOMBSTONE_PREFIX) ? "owner-record-missing" : "abandoned";
474
+ }
475
+ let pid: unknown;
476
+ try {
477
+ pid = (JSON.parse(raw) as { pid?: unknown }).pid;
478
+ } catch {
479
+ return "owner-record-unreadable";
480
+ }
481
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return "owner-record-unreadable";
482
+ return pidAlive(pid) ? "owner-alive" : "abandoned";
483
+ }
484
+
485
+ /**
486
+ * Remove reclaimed broker lock tombstones and legacy restart backups older than
487
+ * the grace window.
488
+ *
489
+ * `#reclaimStaleLock` renames a dead owner's lock to a tombstone named by a hash
490
+ * of the lock's dev+ino, so a machine accrues one directory per dead owner and
491
+ * nothing ever removed them (54 on the install in #3963). Reaping is
492
+ * best-effort and fail-closed: anything live, unreadable, permission-denied, or
493
+ * otherwise ambiguous is kept and the reason is logged.
494
+ */
495
+ export async function reapStaleBrokerLockArtifacts(input: {
496
+ agentDir: string;
497
+ now?: number;
498
+ graceMs?: number;
499
+ pidAlive?: (pid: number) => boolean;
500
+ }): Promise<BrokerLockArtifactReapResult> {
501
+ const directory = path.join(input.agentDir, "sdk");
502
+ const now = input.now ?? Date.now();
503
+ const graceMs = input.graceMs ?? BROKER_LOCK_ARTIFACT_GRACE_MS;
504
+ const pidAlive = input.pidAlive ?? isPidAlive;
505
+ const removed: string[] = [];
506
+ const retained: BrokerLockArtifactRetention[] = [];
507
+ let names: string[];
508
+ try {
509
+ names = await fs.readdir(directory);
510
+ } catch (error) {
511
+ const code = (error as NodeJS.ErrnoException).code;
512
+ if (code === "ENOENT" || code === "ENOTDIR") return { removed, retained };
513
+ throw error;
514
+ }
515
+ for (const name of names) {
516
+ if (!isBrokerLockArtifactName(name)) continue;
517
+ const target = path.join(directory, name);
518
+ let verdict: BrokerLockArtifactRetentionReason | "abandoned";
519
+ try {
520
+ verdict = await classifyBrokerLockArtifact(directory, name, now, graceMs, pidAlive);
521
+ } catch (error) {
522
+ // A vanished candidate needs no decision; anything else is ambiguous.
523
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
524
+ verdict = "owner-record-unreadable";
525
+ }
526
+ if (verdict !== "abandoned") {
527
+ retained.push({ path: target, reason: verdict });
528
+ if (verdict !== "within-grace") logger.warn(`sdk broker: retained stale lock artifact ${name} (${verdict})`);
529
+ continue;
530
+ }
531
+ try {
532
+ await fs.rm(target, { recursive: true });
533
+ removed.push(target);
534
+ } catch (error) {
535
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
536
+ retained.push({ path: target, reason: "removal-failed" });
537
+ logger.warn(`sdk broker: retained stale lock artifact ${name} (removal-failed)`);
538
+ }
539
+ }
540
+ if (removed.length > 0) logger.info(`sdk broker: reaped ${removed.length} stale lock artifact(s)`);
541
+ return { removed, retained };
542
+ }
543
+
398
544
  const BROKER_PUBLICATION_CADENCE_MS = 5_000;
399
545
  const BROKER_PUBLICATION_GRACE_MS = 15_000;
400
546
  // A broker that cannot observe its own publication is not provably the root, but
@@ -406,6 +552,138 @@ const BROKER_PUBLICATION_GRACE_MS = 15_000;
406
552
  // longer than the loss grace.
407
553
  const BROKER_AMBIGUITY_GRACE_MS = 120_000;
408
554
  const BROKER_SETTLEMENT_MS = 2_000;
555
+
556
+ export interface StartupAdmissionTiming {
557
+ now(): number;
558
+ sleep(ms: number, signal?: AbortSignal): Promise<void>;
559
+ }
560
+
561
+ export type StartupAdmissionResult<T> =
562
+ | { status: "completed"; admittedAt: number; value: T }
563
+ | { status: "admission_timeout"; reason: "admission_timeout" }
564
+ | { status: "admission_refused"; reason: "admission_refused" };
565
+
566
+ interface StartupAdmissionWaiter {
567
+ state: "waiting" | "admitted" | "timed_out" | "refused";
568
+ admissionEpoch?: number;
569
+ ready: PromiseWithResolvers<void>;
570
+ }
571
+
572
+ // Host startup is CPU/IO bursty, so scale with the machine without allowing a full-core launch stampede.
573
+ export function sdkHostStartupConcurrency(availableParallelism = os.availableParallelism()): number {
574
+ if (!Number.isSafeInteger(availableParallelism) || availableParallelism < 1)
575
+ throw new Error("SDK host startup parallelism must be a positive safe integer.");
576
+ return Math.max(1, Math.floor(Math.sqrt(availableParallelism)));
577
+ }
578
+
579
+ export class StartupAdmissionQueue {
580
+ #inFlight = 0;
581
+ #closed = false;
582
+ #epoch = 0;
583
+ #waiters: StartupAdmissionWaiter[] = [];
584
+
585
+ constructor(readonly limit: number) {
586
+ if (!Number.isSafeInteger(limit) || limit < 1)
587
+ throw new Error("SDK host startup concurrency must be a positive safe integer.");
588
+ }
589
+
590
+ async run<T>(
591
+ queueWaitMs: number,
592
+ timing: StartupAdmissionTiming,
593
+ task: (admittedAt: number) => Promise<T>,
594
+ ): Promise<StartupAdmissionResult<T>> {
595
+ if (!Number.isSafeInteger(queueWaitMs) || queueWaitMs < 1)
596
+ throw new Error("SDK host startup queue wait must be a positive safe integer.");
597
+ if (this.#closed) return { status: "admission_refused", reason: "admission_refused" };
598
+ if (this.#inFlight < this.limit) return this.#runAdmitted(timing, task);
599
+
600
+ const ready = Promise.withResolvers<void>();
601
+ const waiter: StartupAdmissionWaiter = { state: "waiting", ready };
602
+ this.#waiters.push(waiter);
603
+ const cutoff = new AbortController();
604
+ let outcome: "admitted" | "timed_out" | "refused";
605
+ try {
606
+ outcome = await Promise.race([
607
+ ready.promise.then(() => (waiter.state === "refused" ? ("refused" as const) : ("admitted" as const))),
608
+ timing.sleep(queueWaitMs, cutoff.signal).then(() => {
609
+ if (waiter.state === "admitted") return "admitted" as const;
610
+ if (waiter.state === "refused") return "refused" as const;
611
+ if (waiter.state === "timed_out") return "timed_out" as const;
612
+ waiter.state = "timed_out";
613
+ const index = this.#waiters.indexOf(waiter);
614
+ if (index >= 0) this.#waiters.splice(index, 1);
615
+ return "timed_out" as const;
616
+ }),
617
+ ]);
618
+ } finally {
619
+ cutoff.abort();
620
+ }
621
+ if (outcome === "timed_out") return { status: "admission_timeout", reason: "admission_timeout" };
622
+ if (outcome === "refused") return { status: "admission_refused", reason: "admission_refused" };
623
+ return this.#runGranted(waiter.admissionEpoch!, timing, task);
624
+ }
625
+
626
+ async #runAdmitted<T>(
627
+ timing: StartupAdmissionTiming,
628
+ task: (admittedAt: number) => Promise<T>,
629
+ ): Promise<StartupAdmissionResult<T>> {
630
+ const admissionEpoch = this.#epoch;
631
+ this.#inFlight += 1;
632
+ return this.#runGranted(admissionEpoch, timing, task);
633
+ }
634
+
635
+ async #runGranted<T>(
636
+ admissionEpoch: number,
637
+ timing: StartupAdmissionTiming,
638
+ task: (admittedAt: number) => Promise<T>,
639
+ ): Promise<StartupAdmissionResult<T>> {
640
+ try {
641
+ const admittedAt = timing.now();
642
+ if (this.#closed || admissionEpoch !== this.#epoch)
643
+ return { status: "admission_refused", reason: "admission_refused" };
644
+ return { status: "completed", admittedAt, value: await task(admittedAt) };
645
+ } finally {
646
+ this.#inFlight -= 1;
647
+ this.#grantNext();
648
+ }
649
+ }
650
+
651
+ #grantNext(): void {
652
+ if (this.#closed) return;
653
+ while (this.#inFlight < this.limit) {
654
+ const waiter = this.#waiters.shift();
655
+ if (!waiter) return;
656
+ if (waiter.state !== "waiting") continue;
657
+ waiter.state = "admitted";
658
+ waiter.admissionEpoch = this.#epoch;
659
+ this.#inFlight += 1;
660
+ waiter.ready.resolve();
661
+ }
662
+ }
663
+
664
+ /**
665
+ * Refuse every queued startup and every later one. A broker that can no longer
666
+ * prove it owns the published root must not spawn children through slots that
667
+ * free up while it is fenced. The epoch also invalidates a waiter that was
668
+ * granted but has not crossed the task execution boundary yet.
669
+ */
670
+ close(): void {
671
+ if (this.#closed) return;
672
+ this.#closed = true;
673
+ this.#epoch += 1;
674
+ for (const waiter of this.#waiters.splice(0)) {
675
+ if (waiter.state !== "waiting") continue;
676
+ waiter.state = "refused";
677
+ waiter.ready.resolve();
678
+ }
679
+ }
680
+
681
+ /** Accept later startups after fresh publication ownership has been proven. */
682
+ reopen(): void {
683
+ this.#closed = false;
684
+ this.#grantNext();
685
+ }
686
+ }
409
687
  type BrokerPublicationState =
410
688
  | "healthy-owned"
411
689
  | "suspect-unpublished"
@@ -417,6 +695,7 @@ type BrokerStopMode = "owned-root" | "lost-root";
417
695
  const terminalPersistenceHooksForTest = new WeakMap<Broker, () => void>();
418
696
  const ambiguityGraceOverridesForTest = new WeakMap<Broker, number>();
419
697
  const publicationObservationOverridesForTest = new WeakMap<Broker, BrokerPublicationObservation>();
698
+ const lockArtifactGraceOverridesForTest = new WeakMap<Broker, number>();
420
699
 
421
700
  export class Broker {
422
701
  readonly settings: ResolvedBrokerSettings;
@@ -427,6 +706,7 @@ export class Broker {
427
706
  #owner = randomBytes(12).toString("hex");
428
707
  #chains = new Map<string, Promise<void>>();
429
708
  #admitted = new Set<Promise<void>>();
709
+ #startupAdmissions = new StartupAdmissionQueue(sdkHostStartupConcurrency());
430
710
  #publication: RetainedBrokerDiscovery | null = null;
431
711
  #publicationState: BrokerPublicationState = "healthy-owned";
432
712
  #lossAt: bigint | null = null;
@@ -454,6 +734,13 @@ export class Broker {
454
734
  this.#resolveCompletion = completion.resolve;
455
735
  this.#rejectCompletion = completion.reject;
456
736
  }
737
+ runStartup<T>(
738
+ queueWaitMs: number,
739
+ timing: StartupAdmissionTiming,
740
+ task: (admittedAt: number) => Promise<T>,
741
+ ): Promise<StartupAdmissionResult<T>> {
742
+ return this.#startupAdmissions.run(queueWaitMs, timing, task);
743
+ }
457
744
  #lockRecordPath(): string {
458
745
  return path.join(this.#lock, BROKER_LOCK_RECORD);
459
746
  }
@@ -565,6 +852,22 @@ export class Broker {
565
852
  }
566
853
  }
567
854
 
855
+ /**
856
+ * Best-effort startup reap of reclaimed lock tombstones and legacy restart
857
+ * backups. Cleanup debris must never fail an otherwise healthy startup, so a
858
+ * fault here is logged and swallowed.
859
+ */
860
+ async #reapLockArtifacts(): Promise<void> {
861
+ try {
862
+ await reapStaleBrokerLockArtifacts({
863
+ agentDir: this.settings.agentDir,
864
+ graceMs: lockArtifactGraceOverridesForTest.get(this),
865
+ });
866
+ } catch (error) {
867
+ logger.warn(`sdk broker: stale lock artifact reap failed: ${String(error)}`);
868
+ }
869
+ }
870
+
568
871
  async start(): Promise<BrokerDiscovery> {
569
872
  if (this.#completionTask) {
570
873
  await this.#completionTask;
@@ -573,6 +876,9 @@ export class Broker {
573
876
  this.#resolveCompletion = completion.resolve;
574
877
  this.#rejectCompletion = completion.reject;
575
878
  this.#completionTask = null;
879
+ // A drained queue refuses every later startup by design, so a restarted broker
880
+ // needs a new one or it would admit nothing for the rest of the process.
881
+ this.#startupAdmissions = new StartupAdmissionQueue(sdkHostStartupConcurrency());
576
882
  }
577
883
  this.#stopping = false;
578
884
  this.#publicationState = "healthy-owned";
@@ -590,6 +896,11 @@ export class Broker {
590
896
 
591
897
  const live = await readBrokerDiscovery(this.settings.agentDir, this.settings.heartbeatTtlMs);
592
898
  if (live) {
899
+ // This process loses the ownership race and its caller only ever sees a
900
+ // clean exit, so name the reason here (#3963).
901
+ logger.info(
902
+ `sdk broker: lock contention, yielding to the live broker owner (ownerId=${live.ownerId}, pid=${live.pid}); this process exits without owning discovery`,
903
+ );
593
904
  this.discovery = live;
594
905
  return live;
595
906
  }
@@ -598,16 +909,25 @@ export class Broker {
598
909
  if (snapshot.pid > 0 && isPidAlive(snapshot.pid)) {
599
910
  const starting = await this.#waitForBrokerDiscovery();
600
911
  if (starting) {
912
+ logger.info(
913
+ `sdk broker: lock contention, yielding to the broker that just started (ownerId=${starting.ownerId}, pid=${starting.pid}); this process exits without owning discovery`,
914
+ );
601
915
  this.discovery = starting;
602
916
  return starting;
603
917
  }
604
918
  const current = await this.#readLock();
605
- if (current && current.identity === snapshot.identity && current.pid > 0 && isPidAlive(current.pid))
606
- throw new Error("Broker lock is held by a live owner");
919
+ if (current && current.identity === snapshot.identity && current.pid > 0 && isPidAlive(current.pid)) {
920
+ logger.warn(
921
+ `sdk broker: lock contention, refusing to start because ${this.#lock} is held by live pid ${current.pid} that published no discovery record`,
922
+ );
923
+ throw new Error(`Broker lock is held by a live owner (pid ${current.pid})`);
924
+ }
607
925
  continue;
608
926
  }
609
927
  await this.#reclaimStaleLock(snapshot);
610
928
  }
929
+ // Only the lock holder reaps, so concurrent brokers cannot race the removal.
930
+ await this.#reapLockArtifacts();
611
931
  try {
612
932
  await this.index.open();
613
933
  await this.ledger.open();
@@ -661,6 +981,7 @@ export class Broker {
661
981
  #fence(kind: "suspect-unpublished" | "observation-ambiguous" | "heartbeat-ambiguous"): void {
662
982
  if (this.#publicationState === "stopping") return;
663
983
  this.#publicationState = kind;
984
+ this.#startupAdmissions.close();
664
985
  if (kind === "suspect-unpublished") {
665
986
  this.#lossAt ??= process.hrtime.bigint();
666
987
  this.#ambiguousAt = null;
@@ -693,11 +1014,13 @@ export class Broker {
693
1014
  return;
694
1015
  }
695
1016
  if (observation === "owned") {
696
- if (this.#publicationState === "heartbeat-ambiguous") {
697
- if (writeHeartbeat) await this.#writeHeartbeat();
698
- return;
699
- }
1017
+ // Recover the cached publication state synchronously with the observation
1018
+ // so request admission does not lag behind the awaited heartbeat IO.
1019
+ // The heartbeat write that follows re-checks fresh publication authority
1020
+ // and fences (downgrading this optimistic recovery) if ownership changed
1021
+ // between the observation and the write.
700
1022
  this.#publicationState = "healthy-owned";
1023
+ this.#startupAdmissions.reopen();
701
1024
  this.#lossAt = null;
702
1025
  this.#ambiguousAt = null;
703
1026
  if (writeHeartbeat) await this.#writeHeartbeat();
@@ -718,10 +1041,10 @@ export class Broker {
718
1041
  this.#fence("heartbeat-ambiguous");
719
1042
  return;
720
1043
  }
721
- this.#publicationState = "healthy-owned";
722
- this.#lossAt = null;
723
- this.#ambiguousAt = null;
724
- this.discovery = { ...this.discovery, heartbeatAt };
1044
+ const recovery = this.runSynchronousEffectWithFreshPublicationAuthority(() => {
1045
+ this.discovery = { ...this.discovery!, heartbeatAt };
1046
+ });
1047
+ if (!recovery.authorized) return;
725
1048
  }
726
1049
  async heartbeat(): Promise<void> {
727
1050
  if (this.#publicationState !== "healthy-owned") return;
@@ -731,6 +1054,13 @@ export class Broker {
731
1054
  if (this.#completionTask) return this.#completionTask;
732
1055
  this.#stopping = true;
733
1056
  this.#publicationState = "stopping";
1057
+ // A lost-root broker has been fenced: it no longer owns the published root, and
1058
+ // its settlement is bounded, so any startup still queued behind it would be
1059
+ // granted after completion and spawn a child the broker has no authority over.
1060
+ // An owned-root stop keeps the queue open on purpose: the broker still owns
1061
+ // everything it admitted, and completion waits unbounded for those startups, so
1062
+ // draining would abandon work that is about to finish correctly.
1063
+ if (mode === "lost-root") this.#startupAdmissions.close();
734
1064
  if (this.#heartbeatTimer) clearInterval(this.#heartbeatTimer);
735
1065
  this.#heartbeatTimer = null;
736
1066
  this.#completionTask = (async () => {
@@ -757,8 +1087,53 @@ export class Broker {
757
1087
  void this.#completionTask.then(this.#resolveCompletion, this.#rejectCompletion);
758
1088
  return this.#completionTask;
759
1089
  }
1090
+ /**
1091
+ * Fresh, uncached proof that this broker still publishes the discovery root.
1092
+ * The cached state is not proof: the watchdog observes on a cadence, so a
1093
+ * replacement can already be on disk without having been seen yet.
1094
+ */
1095
+ #provenOwnedRoot(): boolean {
1096
+ if (!this.#publication || this.#publicationState !== "healthy-owned") return false;
1097
+ try {
1098
+ return (publicationObservationOverridesForTest.get(this) ?? this.#publication.observe()) === "owned";
1099
+ } catch {
1100
+ return false;
1101
+ }
1102
+ }
1103
+ /**
1104
+ * Revalidate retained publication ownership and begin one synchronous effect in
1105
+ * the same stack. The callback is the authority boundary: callers must perform
1106
+ * the authorized effect inside it, so no awaited work can separate proof from
1107
+ * the effect it authorizes.
1108
+ */
1109
+ runSynchronousEffectWithFreshPublicationAuthority<T>(
1110
+ effect: () => T,
1111
+ ..._synchronousOnly: T extends PromiseLike<unknown> ? [never] : []
1112
+ ): { authorized: true; value: T } | { authorized: false } {
1113
+ if (!this.#publication || this.#publicationState === "stopping") return { authorized: false };
1114
+ let observation: BrokerPublicationObservation;
1115
+ try {
1116
+ observation = publicationObservationOverridesForTest.get(this) ?? this.#publication.observe();
1117
+ } catch {
1118
+ this.#fence("observation-ambiguous");
1119
+ if (this.#fencedBeyondDeadline()) void this.#complete("lost-root");
1120
+ return { authorized: false };
1121
+ }
1122
+ if (observation !== "owned") {
1123
+ this.#fence(observation === "ambiguous" ? "observation-ambiguous" : "suspect-unpublished");
1124
+ if (this.#fencedBeyondDeadline()) void this.#complete("lost-root");
1125
+ return { authorized: false };
1126
+ }
1127
+ return { authorized: true, value: effect() };
1128
+ }
1129
+ /**
1130
+ * A stop may take the owning path only while it can prove it still owns the root.
1131
+ * Claiming ownership it cannot prove keeps the admission queue open, so a startup
1132
+ * queued behind this broker is granted a slot that frees after completion and
1133
+ * spawns a child the broker has no authority over.
1134
+ */
760
1135
  async stop(): Promise<void> {
761
- await this.#complete("owned-root");
1136
+ await this.#complete(this.#provenOwnedRoot() ? "owned-root" : "lost-root");
762
1137
  }
763
1138
  async #endpoint(input: Record<string, unknown>): Promise<BrokerResponse> {
764
1139
  const sessionId = input.sessionId;
@@ -1008,6 +1383,12 @@ export function setAmbiguityGraceForTest(broker: Broker, graceMs: number | undef
1008
1383
  else ambiguityGraceOverridesForTest.set(broker, graceMs);
1009
1384
  }
1010
1385
 
1386
+ /** Test-only hook for shortening the startup lock-artifact reap bound. */
1387
+ export function setLockArtifactGraceForTest(broker: Broker, graceMs: number | undefined): void {
1388
+ if (graceMs === undefined) lockArtifactGraceOverridesForTest.delete(broker);
1389
+ else lockArtifactGraceOverridesForTest.set(broker, graceMs);
1390
+ }
1391
+
1011
1392
  /** Test-only hook for forcing the observation the publication watchdog sees. */
1012
1393
  export function setPublicationObservationForTest(
1013
1394
  broker: Broker,