@gajae-code/coding-agent 0.18.1 → 0.18.6

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 (128) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/dist/types/cli/stats-cli.d.ts +0 -5
  3. package/dist/types/cli-commands.d.ts +10 -0
  4. package/dist/types/cli-ordinary.d.ts +13 -0
  5. package/dist/types/cli.d.ts +12 -3
  6. package/dist/types/commands/contribution-prep.d.ts +0 -3
  7. package/dist/types/commands/sdk.d.ts +3 -0
  8. package/dist/types/commands/update.d.ts +1 -0
  9. package/dist/types/config/autorouting-tier-map.d.ts +29 -2
  10. package/dist/types/config/model-profile-activation.d.ts +19 -0
  11. package/dist/types/config/model-registry.d.ts +16 -1
  12. package/dist/types/coordinator-mcp/session-reaper.d.ts +5 -0
  13. package/dist/types/edit/modes/replace.d.ts +3 -3
  14. package/dist/types/edit/streaming.d.ts +2 -2
  15. package/dist/types/exa/mcp-client.d.ts +1 -5
  16. package/dist/types/exec/bash-shell-guardian.d.ts +26 -0
  17. package/dist/types/exec/bash-shell-supervisor.d.ts +12 -0
  18. package/dist/types/hashline/parser.d.ts +28 -5
  19. package/dist/types/modes/acp/acp-agent.d.ts +6 -1
  20. package/dist/types/sdk/broker/discovery.d.ts +8 -0
  21. package/dist/types/sdk/broker/ensure.d.ts +1 -0
  22. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +4 -0
  23. package/dist/types/sdk/broker/lifecycle.d.ts +6 -1
  24. package/dist/types/sdk/broker/recovery-backoff.d.ts +62 -0
  25. package/dist/types/sdk/broker/runtime.d.ts +21 -1
  26. package/dist/types/sdk/broker/startup-budget.d.ts +5 -1
  27. package/dist/types/sdk/bus/index.d.ts +1 -0
  28. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  29. package/dist/types/sdk/bus/telegram-daemon.d.ts +1 -0
  30. package/dist/types/sdk/cli/diagnostics-cli.d.ts +18 -0
  31. package/dist/types/sdk/diagnostics/observe-broker.d.ts +185 -0
  32. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  33. package/dist/types/sdk/host/session-runtime.d.ts +7 -0
  34. package/dist/types/sdk/index.d.ts +1 -0
  35. package/dist/types/sdk/prompt-watchdog.d.ts +1 -1
  36. package/dist/types/sdk/router/session-router.d.ts +2 -0
  37. package/dist/types/sdk/session.d.ts +13 -0
  38. package/dist/types/session/agent-session.d.ts +5 -1
  39. package/dist/types/tools/ask-contract.d.ts +75 -254
  40. package/dist/types/tools/bash.d.ts +1 -0
  41. package/package.json +7 -7
  42. package/scripts/build-sdk-package-smoke.ts +5 -0
  43. package/src/cli/public-command-entry.ts +5 -0
  44. package/src/cli/ssh-cli.ts +6 -3
  45. package/src/cli/stats-cli.ts +4 -36
  46. package/src/cli/web-search-cli.ts +9 -3
  47. package/src/cli-commands.ts +14 -0
  48. package/src/cli-main.ts +4 -8
  49. package/src/cli-ordinary.ts +197 -0
  50. package/src/cli.ts +25 -186
  51. package/src/commands/commit.ts +2 -2
  52. package/src/commands/contribution-prep.ts +1 -3
  53. package/src/commands/sdk.ts +60 -2
  54. package/src/commands/update.ts +15 -1
  55. package/src/config/autorouting-tier-map.ts +20 -2
  56. package/src/config/file-lock.ts +91 -2
  57. package/src/config/model-preset-registry.ts +17 -2
  58. package/src/config/model-profile-activation.ts +73 -5
  59. package/src/config/model-profile-contract.ts +4 -1
  60. package/src/config/model-profiles.ts +29 -29
  61. package/src/config/model-registry.ts +45 -17
  62. package/src/config/model-resolver.ts +9 -1
  63. package/src/coordinator-mcp/event-webhook.ts +19 -14
  64. package/src/coordinator-mcp/policy.ts +14 -4
  65. package/src/coordinator-mcp/server.ts +142 -7
  66. package/src/coordinator-mcp/session-reaper.ts +9 -1
  67. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  68. package/src/edit/modes/replace.ts +6 -6
  69. package/src/edit/streaming.ts +15 -15
  70. package/src/exa/mcp-client.ts +41 -8
  71. package/src/exec/bash-shell-guardian.ts +184 -34
  72. package/src/exec/bash-shell-supervisor.ts +60 -3
  73. package/src/hashline/execute.ts +51 -3
  74. package/src/hashline/parser.ts +63 -16
  75. package/src/internal-urls/docs-index.generated.ts +29 -28
  76. package/src/memories/index.ts +8 -2
  77. package/src/modes/acp/acp-agent.ts +688 -371
  78. package/src/modes/components/tool-execution.ts +1 -1
  79. package/src/modes/controllers/ssh-command-controller.ts +3 -1
  80. package/src/prompts/tools/ask.md +2 -0
  81. package/src/sdk/broker/broker.ts +46 -0
  82. package/src/sdk/broker/daemon-entry.ts +67 -6
  83. package/src/sdk/broker/discovery.ts +8 -0
  84. package/src/sdk/broker/ensure.ts +156 -20
  85. package/src/sdk/broker/lifecycle-ledger.ts +110 -12
  86. package/src/sdk/broker/lifecycle.ts +108 -34
  87. package/src/sdk/broker/recovery-backoff.ts +135 -0
  88. package/src/sdk/broker/runtime.ts +88 -1
  89. package/src/sdk/broker/startup-budget.ts +6 -1
  90. package/src/sdk/broker/transport.ts +5 -1
  91. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  92. package/src/sdk/bus/index.ts +305 -19
  93. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  94. package/src/sdk/bus/telegram-daemon-control.ts +15 -12
  95. package/src/sdk/bus/telegram-daemon.ts +34 -1
  96. package/src/sdk/cli/diagnostics-cli.ts +247 -0
  97. package/src/sdk/diagnostics/observe-broker.ts +654 -0
  98. package/src/sdk/host/control/dispatch.ts +52 -4
  99. package/src/sdk/host/control/operations.ts +1 -1
  100. package/src/sdk/host/session-runtime.test.ts +40 -2
  101. package/src/sdk/host/session-runtime.ts +112 -8
  102. package/src/sdk/index.ts +1 -0
  103. package/src/sdk/lifecycle-session.ts +1 -0
  104. package/src/sdk/prompt-watchdog.ts +17 -4
  105. package/src/sdk/protocol/operation-registry.ts +1 -1
  106. package/src/sdk/router/session-router.ts +7 -1
  107. package/src/sdk/session.ts +37 -8
  108. package/src/session/agent-session.ts +128 -41
  109. package/src/session/blob-store.ts +78 -33
  110. package/src/session/internal/managed-session-storage.ts +119 -3
  111. package/src/setup/hermes/templates/operator-instructions.v1.md +19 -1
  112. package/src/setup/hermes-setup.ts +4 -1
  113. package/src/slash-commands/builtin-registry.ts +8 -5
  114. package/src/tools/ask-contract.test.ts +76 -1
  115. package/src/tools/ask-contract.ts +57 -6
  116. package/src/tools/ask.ts +16 -7
  117. package/src/tools/bash.ts +77 -29
  118. package/src/tools/descriptors.test.ts +29 -0
  119. package/src/tools/tool-catalog.generated.ts +7 -4
  120. package/src/web/search/index.ts +2 -2
  121. package/dist/types/cli/grep-cli.d.ts +0 -17
  122. package/dist/types/cli/shell-cli.d.ts +0 -8
  123. package/dist/types/commands/grep.d.ts +0 -42
  124. package/dist/types/commands/shell.d.ts +0 -21
  125. package/src/cli/grep-cli.ts +0 -160
  126. package/src/cli/shell-cli.ts +0 -176
  127. package/src/commands/grep.ts +0 -48
  128. package/src/commands/shell.ts +0 -29
@@ -74,6 +74,8 @@ export interface LifecycleDurableEffectsReceipt {
74
74
  export interface LifecycleLedgerEntry {
75
75
  operationKey?: string;
76
76
  fingerprint?: string;
77
+ /** True only when a session.close request supplied immutable generation authority. */
78
+ closeAuthorityBound?: boolean;
77
79
  version: typeof SDK_STATE_VERSION;
78
80
  identity: string;
79
81
  requestHash: string;
@@ -213,6 +215,20 @@ function pendingCleanupSessionId(response: unknown): string | undefined {
213
215
  return canonicalCleanupSessionId(cleanup?.sessionId) ? cleanup.sessionId : undefined;
214
216
  }
215
217
 
218
+ function retirementCreateIdentity(response: unknown): string | undefined {
219
+ if (!response || typeof response !== "object") return undefined;
220
+ const error = (response as { error?: unknown }).error;
221
+ if (!error || typeof error !== "object") return undefined;
222
+ const cleanup = (error as { cleanup?: unknown }).cleanup;
223
+ if (!cleanup || typeof cleanup !== "object") return undefined;
224
+ const receipt = (cleanup as { uncertainRetirement?: unknown }).uncertainRetirement;
225
+ if (!receipt || typeof receipt !== "object") return undefined;
226
+ const identity = (receipt as { identity?: unknown }).identity;
227
+ if (!identity || typeof identity !== "object") return undefined;
228
+ const createIdentity = (identity as { createIdentity?: unknown }).createIdentity;
229
+ return typeof createIdentity === "string" && createIdentity.length > 0 ? createIdentity : undefined;
230
+ }
231
+
216
232
  function hasValidTerminalDigests(entry: LifecycleLedgerEntry): boolean {
217
233
  const response = entry.response as { ok?: unknown; error?: { code?: unknown; cleanup?: unknown } } | undefined;
218
234
  const cleanupPendingResponse =
@@ -646,23 +662,91 @@ export class LifecycleLedger {
646
662
  await directory.close();
647
663
  }
648
664
  }
649
- async #compact(replacement?: LifecycleLedgerEntry): Promise<boolean> {
665
+ async #compact(
666
+ replacement?: LifecycleLedgerEntry,
667
+ incoming?: { readonly bytes: number; readonly rows: number },
668
+ ): Promise<boolean> {
650
669
  const anchors = new Map<string, LifecycleLedgerEntry>();
651
670
  for (const entry of this.#entries) {
652
671
  const latest = replacement?.identity === entry.identity ? replacement : this.#byIdentity.get(entry.identity);
653
672
  if (entry.state === "accepted" && !anchors.has(entry.identity) && entry.requestHash === latest?.requestHash)
654
673
  anchors.set(entry.identity, entry);
655
674
  }
656
- const snapshot: LifecycleLedgerEntry[] = [];
657
675
  const compacted = new Map(this.#byIdentity);
658
676
  if (replacement) compacted.set(replacement.identity, replacement);
659
- for (const [identity, latest] of compacted) {
660
- const anchor = anchors.get(identity);
661
- if (!anchor) throw new Error("Lifecycle ledger compaction requires an accepted identity anchor.");
662
- snapshot.push(replacement?.identity === identity && latest.state === "accepted" ? latest : anchor);
663
- if (latest.state !== "accepted") snapshot.push(latest);
677
+ const buildSnapshot = (): LifecycleLedgerEntry[] => {
678
+ const next: LifecycleLedgerEntry[] = [];
679
+ for (const [identity, latest] of compacted) {
680
+ const anchor = anchors.get(identity);
681
+ if (!anchor) throw new Error("Lifecycle ledger compaction requires an accepted identity anchor.");
682
+ next.push(replacement?.identity === identity && latest.state === "accepted" ? latest : anchor);
683
+ if (latest.state !== "accepted") next.push(latest);
684
+ }
685
+ return next;
686
+ };
687
+ let snapshot = buildSnapshot();
688
+ let contents = Buffer.from(snapshot.map(entry => `${JSON.stringify(entry)}\n`).join(""));
689
+ const needsEviction = incoming
690
+ ? snapshot.length + incoming.rows > this.#limits.maxRows ||
691
+ contents.length + incoming.bytes > this.#limits.maxBytes
692
+ : replacement === undefined
693
+ ? snapshot.length >= this.#limits.maxRows || contents.length >= this.#limits.maxBytes
694
+ : snapshot.length > this.#limits.maxRows || contents.length > this.#limits.maxBytes;
695
+ if (needsEviction) {
696
+ const protectedCreateIdentities = new Set<string>();
697
+ for (const entry of compacted.values()) {
698
+ const createIdentity =
699
+ retirementCreateIdentity(entry.response) ?? retirementCreateIdentity(entry.unresolvedCleanupResponse);
700
+ if (createIdentity !== undefined) protectedCreateIdentities.add(createIdentity);
701
+ }
702
+ const evictable = [...compacted.values()]
703
+ .filter(
704
+ latest =>
705
+ latest.identity !== replacement?.identity &&
706
+ terminal(latest.state) &&
707
+ latest.operationKey !== undefined &&
708
+ latest.fingerprint !== undefined &&
709
+ (!latest.operationKey.startsWith("session.close\u0000") || latest.closeAuthorityBound === true) &&
710
+ !protectedCreateIdentities.has(latest.identity) &&
711
+ pendingCleanupSessionId(latest.response) === undefined &&
712
+ pendingCleanupSessionId(latest.unresolvedCleanupResponse) === undefined,
713
+ )
714
+ .sort((left, right) => left.ts - right.ts);
715
+ const rowsByIdentity = new Map<string, number>();
716
+ const bytesByIdentity = new Map<string, number>();
717
+ for (const entry of snapshot) {
718
+ rowsByIdentity.set(entry.identity, (rowsByIdentity.get(entry.identity) ?? 0) + 1);
719
+ bytesByIdentity.set(
720
+ entry.identity,
721
+ (bytesByIdentity.get(entry.identity) ?? 0) + Buffer.byteLength(`${JSON.stringify(entry)}\n`),
722
+ );
723
+ }
724
+ let remainingRows = snapshot.length;
725
+ let remainingBytes = contents.length;
726
+ const evicted = new Set<string>();
727
+ for (const latest of evictable) {
728
+ if (
729
+ remainingRows <= this.#limits.maxRows / 2 &&
730
+ remainingBytes <= this.#limits.maxBytes / 2 &&
731
+ remainingRows + (incoming?.rows ?? 0) <= this.#limits.maxRows &&
732
+ remainingBytes + (incoming?.bytes ?? 0) <= this.#limits.maxBytes
733
+ )
734
+ break;
735
+ evicted.add(latest.identity);
736
+ remainingRows -= rowsByIdentity.get(latest.identity) ?? 0;
737
+ remainingBytes -= bytesByIdentity.get(latest.identity) ?? 0;
738
+ }
739
+ for (const identity of evicted) compacted.delete(identity);
740
+ snapshot = buildSnapshot();
741
+ contents = Buffer.from(snapshot.map(entry => `${JSON.stringify(entry)}\n`).join(""));
742
+ // Replay is guaranteed only for identities retained in the compacted ledger.
664
743
  }
665
- const contents = Buffer.from(snapshot.map(entry => `${JSON.stringify(entry)}\n`).join(""));
744
+ if (
745
+ incoming &&
746
+ (snapshot.length + incoming.rows > this.#limits.maxRows ||
747
+ contents.length + incoming.bytes > this.#limits.maxBytes)
748
+ )
749
+ throw new Error("Lifecycle ledger append exceeds configured bounds.");
666
750
  if (
667
751
  snapshot.length > this.#limits.maxRows ||
668
752
  contents.length > this.#limits.maxBytes ||
@@ -735,7 +819,7 @@ export class LifecycleLedger {
735
819
  async migrateIdentity(
736
820
  from: string,
737
821
  to: string,
738
- metadata: { operationKey: string; fingerprint: string },
822
+ metadata: { operationKey: string; fingerprint: string; closeAuthorityBound?: boolean },
739
823
  ): Promise<LifecycleLedgerEntry | undefined> {
740
824
  return this.#mutate(async () => {
741
825
  const existing = this.#byIdentity.get(to);
@@ -776,9 +860,12 @@ export class LifecycleLedger {
776
860
  );
777
861
  if (line.length - 1 > this.#limits.maxLineBytes)
778
862
  throw new Error("Lifecycle ledger row exceeds the maximum byte length.");
863
+ if (line.length > this.#limits.maxBytes) throw new Error("Lifecycle ledger append exceeds configured bounds.");
779
864
  let replacementCompacted = false;
780
865
  if (this.#rowCount + 1 > this.#limits.maxRows || this.#byteCount + line.length > this.#limits.maxBytes)
781
- replacementCompacted = await this.#compact(this.#byIdentity.has(entry.identity) ? entry : undefined);
866
+ replacementCompacted = this.#byIdentity.has(entry.identity)
867
+ ? await this.#compact(entry)
868
+ : await this.#compact(undefined, { bytes: line.length, rows: 1 });
782
869
  if (replacementCompacted) return entry;
783
870
  if (this.#rowCount + 1 > this.#limits.maxRows || this.#byteCount + line.length > this.#limits.maxBytes)
784
871
  throw new Error("Lifecycle ledger append exceeds configured bounds.");
@@ -798,7 +885,12 @@ export class LifecycleLedger {
798
885
  async begin(
799
886
  identity: string,
800
887
  requestHash: string,
801
- metadata: { operationKey?: string; fingerprint?: string; intendedSessionId?: string } = {},
888
+ metadata: {
889
+ operationKey?: string;
890
+ fingerprint?: string;
891
+ intendedSessionId?: string;
892
+ closeAuthorityBound?: boolean;
893
+ } = {},
802
894
  ): Promise<BeginResult> {
803
895
  return this.#mutate(async () => this.#begin(identity, requestHash, metadata));
804
896
  }
@@ -806,7 +898,12 @@ export class LifecycleLedger {
806
898
  async #begin(
807
899
  identity: string,
808
900
  requestHash: string,
809
- metadata: { operationKey?: string; fingerprint?: string; intendedSessionId?: string },
901
+ metadata: {
902
+ operationKey?: string;
903
+ fingerprint?: string;
904
+ intendedSessionId?: string;
905
+ closeAuthorityBound?: boolean;
906
+ },
810
907
  ): Promise<BeginResult> {
811
908
  const prior = this.#byIdentity.get(identity);
812
909
  if (!prior)
@@ -819,6 +916,7 @@ export class LifecycleLedger {
819
916
  operationKey: metadata.operationKey,
820
917
  fingerprint: metadata.fingerprint,
821
918
  intendedSessionId: metadata.intendedSessionId,
919
+ closeAuthorityBound: metadata.closeAuthorityBound,
822
920
  state: "accepted",
823
921
  ts: Date.now(),
824
922
  }),
@@ -97,6 +97,7 @@ import {
97
97
  } from "./session-index";
98
98
  import {
99
99
  cancellableSleep,
100
+ DEFAULT_BROKER_PRESPAWN_PREPARATION_TIMEOUT_MS,
100
101
  DEFAULT_READINESS_TIMEOUT_MS,
101
102
  deriveLifecycleOuterDeadlines,
102
103
  isValidReadinessTimeoutMs,
@@ -517,6 +518,7 @@ type LifecycleCommandResolver = () => LifecycleCommand;
517
518
  const lifecycleCommandResolversForTest = new WeakMap<Broker, LifecycleCommandResolver>();
518
519
  const lifecycleCleanupHooksForTest = new WeakMap<Broker, () => void>();
519
520
  const startupAdmittedInputs = new WeakSet<Input>();
521
+ const startupBrokerDerivedAdmissions = new WeakMap<Input, { admittedAt: number; preSpawnDeadlineAt: number }>();
520
522
  const startupLaunchInputs = new WeakMap<Input, SessionLaunch>();
521
523
  type EnsureLaunchWorktreeForTest = (
522
524
  plan: GjcLaunchWorktreePlan,
@@ -627,7 +629,10 @@ export function hasValidLifecycleDeadlines(value: LifecycleDeadlines, now = Date
627
629
  type Input = Record<string, unknown>;
628
630
  // The admitted launch deadline must survive the response phase: executeLifecycle
629
631
  // receives the caller's original input after startup admission has expanded it.
630
- type LifecycleEffectIntentWithDeadline = LifecycleEffectIntent & { lifecycleCleanupDeadlineAt?: number };
632
+ type LifecycleEffectIntentWithDeadline = LifecycleEffectIntent & {
633
+ lifecycleCleanupDeadlineAt?: number;
634
+ admissionCleanupDeadlineAt?: number;
635
+ };
631
636
  export const isCanonicalSessionId = (value: string): boolean => /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value);
632
637
  const defaultStateRoot = (cwd: string) => path.join(path.resolve(cwd), ".gjc", "state");
633
638
  const hasDefaultStateRoot = (cwd: string, root: string) => path.resolve(root) === defaultStateRoot(cwd);
@@ -6414,6 +6419,7 @@ async function executeLifecycleResponse(
6414
6419
  const timing = lifecycleTiming(broker);
6415
6420
  const admissionGranted = startupAdmittedInputs.has(input);
6416
6421
  if (!admissionGranted) {
6422
+ const startupReceivedAt = timing.now();
6417
6423
  const suppliedDeadlineFields = [
6418
6424
  input.receivedAt,
6419
6425
  input.requestedReadinessTimeoutMs,
@@ -6441,15 +6447,26 @@ async function executeLifecycleResponse(
6441
6447
  );
6442
6448
  }
6443
6449
  const admitted = await broker.runStartup(queueWaitMs, timing, async admittedAt => {
6444
- const admittedInput = launch.worktreePlan
6445
- ? { ...input, admittedAt, requestedReadinessTimeoutMs }
6446
- : { ...input, ...deriveLifecycleDeadlines(admittedAt, requestedReadinessTimeoutMs) };
6450
+ const callerSuppliedDeadlines = suppliedDeadlineFields.some(value => value !== undefined);
6451
+ let admittedInput: Input;
6452
+ if (launch.worktreePlan) admittedInput = { ...input, admittedAt, requestedReadinessTimeoutMs };
6453
+ else if (callerSuppliedDeadlines) admittedInput = { ...input };
6454
+ else admittedInput = { ...input, ...deriveLifecycleDeadlines(admittedAt, requestedReadinessTimeoutMs) };
6447
6455
  startupAdmittedInputs.add(admittedInput);
6456
+ if (!launch.worktreePlan && !callerSuppliedDeadlines)
6457
+ startupBrokerDerivedAdmissions.set(admittedInput, {
6458
+ admittedAt,
6459
+ preSpawnDeadlineAt: Math.min(
6460
+ admittedAt + DEFAULT_BROKER_PRESPAWN_PREPARATION_TIMEOUT_MS,
6461
+ startupReceivedAt + queueWaitMs,
6462
+ ),
6463
+ });
6448
6464
  startupLaunchInputs.set(admittedInput, launch);
6449
6465
  try {
6450
6466
  return await executeLifecycleResponse(broker, operation, admittedInput, identity, cleanup);
6451
6467
  } finally {
6452
6468
  startupLaunchInputs.delete(admittedInput);
6469
+ startupBrokerDerivedAdmissions.delete(admittedInput);
6453
6470
  startupAdmittedInputs.delete(admittedInput);
6454
6471
  }
6455
6472
  });
@@ -6477,6 +6494,8 @@ async function executeLifecycleResponse(
6477
6494
  let readinessDeadline: number;
6478
6495
  let terminationStartDeadline: number;
6479
6496
  let outerCleanupDeadlineAt: number | undefined;
6497
+ const brokerAdmission = startupBrokerDerivedAdmissions.get(input);
6498
+ const preSpawnDeadlineAt = brokerAdmission?.preSpawnDeadlineAt;
6480
6499
  if (launch.worktreePlan) {
6481
6500
  const prepTimeouts = readPreparationTimeouts(input);
6482
6501
  if (!prepTimeouts.ok) return fail("invalid_input", PREPARATION_TIMEOUT_INVALID_MESSAGE);
@@ -6507,6 +6526,10 @@ async function executeLifecycleResponse(
6507
6526
  lifecycleDeadline = deadlines.lifecycleCleanupDeadlineAt;
6508
6527
  readinessDeadline = deadlines.semanticReadyDeadlineAt;
6509
6528
  terminationStartDeadline = deadlines.terminationStartDeadlineAt;
6529
+ if (preSpawnDeadlineAt !== undefined) {
6530
+ outerCleanupDeadlineAt = preSpawnDeadlineAt + deadlines.requestedReadinessTimeoutMs;
6531
+ lifecycleDeadline = outerCleanupDeadlineAt;
6532
+ }
6510
6533
  }
6511
6534
 
6512
6535
  if (!hasProcessIncarnationAuthority())
@@ -6530,6 +6553,9 @@ async function executeLifecycleResponse(
6530
6553
  childOwnershipEstablished: false,
6531
6554
  lifecycleCleanupDeadlineAt:
6532
6555
  outerCleanupDeadlineAt ?? (childDeadlines as LifecycleDeadlines).lifecycleCleanupDeadlineAt,
6556
+ ...(brokerAdmission
6557
+ ? { admissionCleanupDeadlineAt: (childDeadlines as LifecycleDeadlines).lifecycleCleanupDeadlineAt }
6558
+ : {}),
6533
6559
  ...(plannedWorktreeIntent ? { worktree: plannedWorktreeIntent } : {}),
6534
6560
  };
6535
6561
 
@@ -6631,7 +6657,22 @@ async function executeLifecycleResponse(
6631
6657
  }
6632
6658
  return mapPreparationFailure(error);
6633
6659
  }
6634
- if (!launch.worktreePlan && timing.now() >= readinessDeadline)
6660
+ if (preSpawnDeadlineAt !== undefined && brokerAdmission !== undefined) {
6661
+ const prepFinishedAt = timing.now();
6662
+ if (prepFinishedAt >= preSpawnDeadlineAt)
6663
+ return fail(
6664
+ "readiness_timeout",
6665
+ `Broker pre-spawn preparation exceeded its ${Math.max(0, preSpawnDeadlineAt - brokerAdmission.admittedAt)} ms allowance after ${Math.max(0, prepFinishedAt - brokerAdmission.admittedAt)} ms.`,
6666
+ );
6667
+ // Only broker-derived tuples restart here; exact caller deadlines remain unchanged.
6668
+ childDeadlines = deriveLifecycleDeadlines(
6669
+ prepFinishedAt,
6670
+ (childDeadlines as LifecycleDeadlines).requestedReadinessTimeoutMs,
6671
+ );
6672
+ readinessDeadline = childDeadlines.semanticReadyDeadlineAt;
6673
+ terminationStartDeadline = childDeadlines.terminationStartDeadlineAt;
6674
+ lifecycleDeadline = childDeadlines.lifecycleCleanupDeadlineAt;
6675
+ } else if (!launch.worktreePlan && timing.now() >= readinessDeadline)
6635
6676
  return fail(
6636
6677
  "readiness_timeout",
6637
6678
  "Lifecycle preparation exhausted the semantic readiness deadline before spawning.",
@@ -7991,6 +8032,26 @@ export interface LifecycleExecutionOutcome {
7991
8032
  deferredArtifactCleanup?: () => Promise<void>;
7992
8033
  }
7993
8034
 
8035
+ export function classifyLateAdmissionSpawnFailure(
8036
+ response: BrokerResponse,
8037
+ effectIntent: LifecycleEffectIntentWithDeadline | undefined,
8038
+ now: number,
8039
+ messageSource: BrokerResponse,
8040
+ ): BrokerResponse {
8041
+ if (
8042
+ response.ok ||
8043
+ response.error.code !== "spawn_failed" ||
8044
+ effectIntent?.childOwnershipEstablished !== true ||
8045
+ effectIntent.admissionCleanupDeadlineAt === undefined ||
8046
+ now < effectIntent.admissionCleanupDeadlineAt
8047
+ )
8048
+ return response;
8049
+ return {
8050
+ ...response,
8051
+ error: { code: "terminal_uncertain", message: terminalUncertainStartupMessage(messageSource) },
8052
+ };
8053
+ }
8054
+
7994
8055
  /** Returns the response together with every durable lifecycle fact needed for truthful replay. */
7995
8056
  export async function executeLifecycle(
7996
8057
  broker: Broker,
@@ -7999,6 +8060,7 @@ export async function executeLifecycle(
7999
8060
  identity: string,
8000
8061
  cleanup?: CleanupEvidence,
8001
8062
  ): Promise<LifecycleExecutionOutcome> {
8063
+ const timing = lifecycleTiming(broker);
8002
8064
  let proofBudget = lifecycleProofBudgetFromInput(broker, input);
8003
8065
  if (!proofBudget)
8004
8066
  proofBudget = lifecycleProofBudgetFromEffectIntent(broker, broker.ledger.get(identity)?.effectIntent);
@@ -8052,16 +8114,25 @@ export async function executeLifecycle(
8052
8114
  const binding = validateLifecycleDeleteMetadataBinding(broker, operation, input, identity, cleanup);
8053
8115
  if (binding) return { response: binding };
8054
8116
  }
8117
+ const reconciled = await reconcileLifecycleCleanup(
8118
+ broker,
8119
+ identity,
8120
+ cleanup,
8121
+ operation === "session.delete"
8122
+ ? { ok: true, result: { sessionId: cleanup.sessionId } }
8123
+ : fail("spawn_failed", "No ready SDK endpoint remains available."),
8124
+ proofBudget,
8125
+ );
8055
8126
  return {
8056
- response: await reconcileLifecycleCleanup(
8057
- broker,
8058
- identity,
8059
- cleanup,
8127
+ response:
8060
8128
  operation === "session.delete"
8061
- ? { ok: true, result: { sessionId: cleanup.sessionId } }
8062
- : fail("spawn_failed", "No ready SDK endpoint remains available."),
8063
- proofBudget,
8064
- ),
8129
+ ? reconciled
8130
+ : classifyLateAdmissionSpawnFailure(
8131
+ reconciled,
8132
+ broker.ledger.get(identity)?.effectIntent,
8133
+ timing.now(),
8134
+ reconciled,
8135
+ ),
8065
8136
  };
8066
8137
  }
8067
8138
  const response = await executeLifecycleResponse(broker, operation, input, identity, cleanup);
@@ -8089,27 +8160,28 @@ export async function executeLifecycle(
8089
8160
  expected,
8090
8161
  evidence,
8091
8162
  proofBudget,
8092
- !response.ok && response.error.code === "ready_then_exited",
8163
+ !response.ok ? response.error.code === "ready_then_exited" : false,
8093
8164
  );
8094
- const startupFailure: LifecycleStartupFailureReceipt | undefined = evidence
8095
- ? {
8096
- artifactDigest: evidence.digest,
8097
- phase: evidence.artifact.phase,
8098
- reason: evidence.artifact.reason,
8099
- message: evidence.artifact.message,
8100
- ...(evidence.artifact.code === undefined
8101
- ? {}
8102
- : { code: evidence.artifact.code, details: evidence.artifact.details }),
8103
- rollback: {
8104
- endpointGeneration: evidence.artifact.rollback.endpointGeneration,
8105
- fenced: evidence.artifact.rollback.fenced,
8106
- runtimeRemoved: evidence.artifact.rollback.runtimeRemoved,
8107
- hostStopped: evidence.artifact.rollback.hostStopped,
8108
- brokerRegistrationReleased: evidence.artifact.rollback.brokerRegistrationReleased,
8109
- },
8110
- ...(cleanupProof ? { cleanupProof } : {}),
8111
- }
8112
- : undefined;
8165
+ const startupFailure: LifecycleStartupFailureReceipt | undefined =
8166
+ evidence && !response.ok
8167
+ ? {
8168
+ artifactDigest: evidence.digest,
8169
+ message: evidence.artifact.message,
8170
+ phase: evidence.artifact.phase,
8171
+ reason: evidence.artifact.reason,
8172
+ ...(evidence.artifact.code === undefined
8173
+ ? {}
8174
+ : { code: evidence.artifact.code, details: evidence.artifact.details }),
8175
+ rollback: {
8176
+ endpointGeneration: evidence.artifact.rollback.endpointGeneration,
8177
+ fenced: evidence.artifact.rollback.fenced,
8178
+ runtimeRemoved: evidence.artifact.rollback.runtimeRemoved,
8179
+ hostStopped: evidence.artifact.rollback.hostStopped,
8180
+ brokerRegistrationReleased: evidence.artifact.rollback.brokerRegistrationReleased,
8181
+ },
8182
+ ...(cleanupProof ? { cleanupProof } : {}),
8183
+ }
8184
+ : undefined;
8113
8185
  const durableEffectsBody: Omit<LifecycleDurableEffectsReceipt, "digest"> = {
8114
8186
  ...(priorDurableEffects?.worktree ? { worktree: priorDurableEffects.worktree } : {}),
8115
8187
  ...(evidence?.artifact.transcript
@@ -8228,8 +8300,10 @@ export async function executeLifecycle(
8228
8300
  ...(startupFailure ? { startupFailure } : {}),
8229
8301
  }
8230
8302
  : response;
8303
+ // The extended proof window may reconcile artifacts after the original
8304
+ // admission deadline, but cannot retroactively prove a timely spawn failure.
8231
8305
  return {
8232
- response: terminalResponse,
8306
+ response: classifyLateAdmissionSpawnFailure(terminalResponse, entry?.effectIntent, timing.now(), response),
8233
8307
  ...(durableEffects ? { durableEffects } : {}),
8234
8308
  ...(startupFailure ? { startupFailure } : {}),
8235
8309
  };
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Bounded exponential backoff tracker for broker recovery failures per agent dir.
3
+ * Tracks repeated recovery failures and implements exponential backoff with a cap
4
+ * to prevent repeated spawns from destabilizing the system.
5
+ */
6
+
7
+ /** Configuration for recovery backoff behavior. */
8
+ export interface RecoveryBackoffConfig {
9
+ /** Initial backoff delay in milliseconds (default: 1000ms). */
10
+ initialDelayMs: number;
11
+ /** Maximum backoff delay in milliseconds (default: 30000ms = 30s). */
12
+ maxDelayMs: number;
13
+ /** Backoff multiplier applied each retry (default: 2). */
14
+ multiplier: number;
15
+ /** Maximum number of recovery attempts per session (default: 5). */
16
+ maxAttempts: number;
17
+ }
18
+
19
+ /** State for a single agent dir's recovery backoff. */
20
+ export interface RecoveryBackoffState {
21
+ /** Timestamp of the last recovery failure. */
22
+ lastFailureAt: number;
23
+ /** Current backoff delay in milliseconds. */
24
+ currentDelayMs: number;
25
+ /** Number of consecutive failures. */
26
+ failureCount: number;
27
+ /** Whether recovery has been permanently stopped due to max attempts. */
28
+ stopped: boolean;
29
+ }
30
+
31
+ /**
32
+ * Session broker recovery ticks every 30 s (`SESSION_BROKER_RECOVERY_INTERVAL_MS`), so the
33
+ * first retry waits two ticks and the delay doubles up to 30 minutes: a broker that keeps
34
+ * failing costs about two spawn attempts per hour at steady state instead of one per tick.
35
+ * Recovery never stops for good on failures alone -- a broker outage must still heal once
36
+ * the broker comes back. Only a replaced runtime image ends recovery (session-runtime.ts).
37
+ */
38
+ const DEFAULTS: RecoveryBackoffConfig = {
39
+ initialDelayMs: 60_000,
40
+ maxDelayMs: 30 * 60_000,
41
+ multiplier: 2,
42
+ maxAttempts: Number.POSITIVE_INFINITY,
43
+ };
44
+
45
+ /** Manages per-agent-dir recovery backoff state. */
46
+ export class RecoveryBackoffTracker {
47
+ #state = new Map<string, RecoveryBackoffState>();
48
+ #config: RecoveryBackoffConfig;
49
+ #clock: { now(): number } = { now: Date.now };
50
+
51
+ constructor(config: Partial<RecoveryBackoffConfig> = {}) {
52
+ this.#config = { ...DEFAULTS, ...config };
53
+ }
54
+
55
+ /** Test hook: inject a custom clock. */
56
+ setClockForTest(clock: { now(): number }): void {
57
+ this.#clock = clock;
58
+ }
59
+
60
+ /**
61
+ * Check if recovery should proceed now, considering backoff.
62
+ * Returns true if sufficient time has elapsed since the last failure.
63
+ */
64
+ canAttemptRecovery(agentDir: string): boolean {
65
+ const state = this.#state.get(agentDir);
66
+ if (!state) return true;
67
+ if (state.stopped) return false;
68
+
69
+ const now = this.#clock.now();
70
+ return now >= state.lastFailureAt + state.currentDelayMs;
71
+ }
72
+
73
+ /**
74
+ * Record a recovery failure and update backoff state.
75
+ * Returns true if recovery should continue to be attempted, false if max attempts reached.
76
+ */
77
+ recordFailure(agentDir: string): boolean {
78
+ let state = this.#state.get(agentDir);
79
+ if (!state) {
80
+ state = {
81
+ lastFailureAt: this.#clock.now(),
82
+ currentDelayMs: this.#config.initialDelayMs,
83
+ failureCount: 1,
84
+ stopped: false,
85
+ };
86
+ this.#state.set(agentDir, state);
87
+ } else {
88
+ state.lastFailureAt = this.#clock.now();
89
+ state.failureCount += 1;
90
+
91
+ // Apply exponential backoff
92
+ state.currentDelayMs = Math.min(state.currentDelayMs * this.#config.multiplier, this.#config.maxDelayMs);
93
+
94
+ // Stop if we've hit max attempts
95
+ if (state.failureCount >= this.#config.maxAttempts) {
96
+ state.stopped = true;
97
+ }
98
+ }
99
+
100
+ return !state.stopped;
101
+ }
102
+
103
+ /**
104
+ * Record a successful recovery and reset backoff state.
105
+ */
106
+ recordSuccess(agentDir: string): void {
107
+ this.#state.delete(agentDir);
108
+ }
109
+
110
+ /**
111
+ * Get the current backoff state for an agent dir (for testing/diagnostics).
112
+ */
113
+ getState(agentDir: string): RecoveryBackoffState | undefined {
114
+ return this.#state.get(agentDir);
115
+ }
116
+
117
+ /**
118
+ * Get milliseconds until the next recovery attempt can proceed.
119
+ */
120
+ getBackoffWaitMs(agentDir: string): number {
121
+ const state = this.#state.get(agentDir);
122
+ if (!state) return 0;
123
+
124
+ const now = this.#clock.now();
125
+ const nextAttemptAt = state.lastFailureAt + state.currentDelayMs;
126
+ return Math.max(0, nextAttemptAt - now);
127
+ }
128
+
129
+ /**
130
+ * Reset all backoff state (for testing).
131
+ */
132
+ resetForTest(): void {
133
+ this.#state.clear();
134
+ }
135
+ }
@@ -6,7 +6,11 @@ import { currentExecutablePath } from "@gajae-code/natives";
6
6
 
7
7
  import internalSourceMarker from "./internal-source-marker-2178.txt" with { type: "file" };
8
8
 
9
- export type SdkInternalAction = "broker-internal" | "session-host-internal" | "stderr-drain-internal";
9
+ export type SdkInternalAction =
10
+ | "broker-internal"
11
+ | "broker-trampoline-internal"
12
+ | "session-host-internal"
13
+ | "stderr-drain-internal";
10
14
 
11
15
  export type SdkInternalSpawnCommand =
12
16
  | {
@@ -271,6 +275,15 @@ export function sdkInternalRuntimeImage(): string | undefined {
271
275
  return publishedRuntimeImage({});
272
276
  }
273
277
 
278
+ /** Identity of a runtime image: enough to detect replacement at the same path. */
279
+ export interface SdkInternalRuntimeImageIdentity {
280
+ path: string;
281
+ dev: number;
282
+ ino: number;
283
+ mtimeMs: number;
284
+ size: number;
285
+ }
286
+
274
287
  /** A stat that has not answered by here is inconclusive, never proof of absence. */
275
288
  const RUNTIME_IMAGE_PROBE_TIMEOUT_MS = 1_000;
276
289
 
@@ -308,6 +321,80 @@ export async function isSdkInternalRuntimeImagePresent(file: string): Promise<bo
308
321
  }
309
322
  }
310
323
 
324
+ const RUNTIME_IMAGE_PROBE_TIMED_OUT = Symbol("runtime-image-probe-timed-out");
325
+
326
+ /**
327
+ * `fsp.stat` bounded by the runtime-image probe timeout. A metadata request on a
328
+ * stalled mount can never settle; the timeout sentinel lets callers treat that as
329
+ * inconclusive instead of pending forever.
330
+ */
331
+ async function statRuntimeImageBounded(file: string): Promise<fs.Stats | typeof RUNTIME_IMAGE_PROBE_TIMED_OUT> {
332
+ const timedOut = Promise.withResolvers<typeof RUNTIME_IMAGE_PROBE_TIMED_OUT>();
333
+ const timer: NodeJS.Timeout = setTimeout(
334
+ () => timedOut.resolve(RUNTIME_IMAGE_PROBE_TIMED_OUT),
335
+ RUNTIME_IMAGE_PROBE_TIMEOUT_MS,
336
+ );
337
+ try {
338
+ return await Promise.race([fsp.stat(file), timedOut.promise]);
339
+ } finally {
340
+ clearTimeout(timer);
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Capture the runtime image identity (dev, ino, mtimeMs, size) at startup.
346
+ * Used to detect if the binary at the same path has been replaced.
347
+ */
348
+ export async function captureRuntimeImageIdentity(file: string): Promise<SdkInternalRuntimeImageIdentity | undefined> {
349
+ try {
350
+ const resolved = path.resolve(file);
351
+ const stats = await statRuntimeImageBounded(resolved);
352
+ // A timed-out probe leaves the identity unknown, like any other failure.
353
+ if (stats === RUNTIME_IMAGE_PROBE_TIMED_OUT || !stats.isFile()) return undefined;
354
+ return {
355
+ path: resolved,
356
+ dev: stats.dev,
357
+ ino: stats.ino,
358
+ mtimeMs: stats.mtimeMs,
359
+ size: stats.size,
360
+ };
361
+ } catch {
362
+ return undefined;
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Check if the runtime image has been replaced since startup.
368
+ * Returns `true` if the path is gone, its dev/ino differ from the startup identity,
369
+ * or its size or modification time changed (an in-place rewrite keeps the inode).
370
+ * Returns `false` (inconclusive) if the file exists and matches, or on any error.
371
+ */
372
+ export async function isSdkInternalRuntimeImageReplaced(
373
+ startupIdentity: SdkInternalRuntimeImageIdentity | undefined,
374
+ ): Promise<boolean> {
375
+ if (startupIdentity === undefined) return false;
376
+
377
+ try {
378
+ const stats = await statRuntimeImageBounded(startupIdentity.path);
379
+ // A timed-out probe is inconclusive, not proof of replacement.
380
+ if (stats === RUNTIME_IMAGE_PROBE_TIMED_OUT) return false;
381
+ if (!stats.isFile()) return false; // Not a file, but not proven gone
382
+ // dev/ino catch a replacement at the same path; size and mtime catch an
383
+ // in-place rewrite that keeps the inode (mtime also covers same-size bytes).
384
+ return (
385
+ stats.dev !== startupIdentity.dev ||
386
+ stats.ino !== startupIdentity.ino ||
387
+ stats.size !== startupIdentity.size ||
388
+ stats.mtimeMs !== startupIdentity.mtimeMs
389
+ );
390
+ } catch (error) {
391
+ // Only ENOENT/ENOTDIR prove absence
392
+ if (isProvenRuntimeImageAbsence(error)) return true;
393
+ // Inconclusive on other errors
394
+ return false;
395
+ }
396
+ }
397
+
311
398
  /** Resolve the production descriptor from the statically imported marker and current Bun runtime evidence. */
312
399
  export function resolveSdkInternalSpawnCommand(action: SdkInternalAction): SdkInternalSpawnCommand {
313
400
  return resolveSdkInternalSpawnCommandWithEvidence(action, {});