@gajae-code/coding-agent 0.16.4 → 0.16.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 (198) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/types/capability/types.d.ts +4 -0
  3. package/dist/types/cli/customize-doctor.d.ts +22 -0
  4. package/dist/types/config/file-lock.d.ts +31 -3
  5. package/dist/types/coordinator-mcp/question-state.d.ts +7 -0
  6. package/dist/types/coordinator-mcp/server.d.ts +7 -0
  7. package/dist/types/discovery/helpers.d.ts +33 -0
  8. package/dist/types/exec/bash-executor.d.ts +17 -1
  9. package/dist/types/exec/bash-shell-guardian.d.ts +15 -0
  10. package/dist/types/exec/bash-shell-runtime-entry.d.ts +2 -0
  11. package/dist/types/exec/bash-shell-signals.d.ts +2 -0
  12. package/dist/types/exec/bash-shell-supervisor-entry.d.ts +1 -0
  13. package/dist/types/exec/bash-shell-supervisor.d.ts +10 -0
  14. package/dist/types/exec/bash-shell-worker-entry.d.ts +2 -0
  15. package/dist/types/exec/bash-shell-worker-protocol.d.ts +63 -0
  16. package/dist/types/exec/bash-shell-worker.d.ts +1 -0
  17. package/dist/types/exec/isolated-shell.d.ts +19 -0
  18. package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +9 -0
  19. package/dist/types/extensibility/extensions/function-hooks.d.ts +169 -0
  20. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  21. package/dist/types/extensibility/extensions/runner.d.ts +46 -6
  22. package/dist/types/extensibility/extensions/types.d.ts +16 -0
  23. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -1
  24. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +35 -2
  25. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  26. package/dist/types/extensibility/hooks/types.d.ts +4 -0
  27. package/dist/types/extensibility/runtime-skill-discovery.d.ts +4 -1
  28. package/dist/types/extensibility/skill-management.d.ts +9 -4
  29. package/dist/types/extensibility/skills.d.ts +2 -0
  30. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +4 -1
  31. package/dist/types/gjc-runtime/linux-proc.d.ts +2 -0
  32. package/dist/types/gjc-runtime/session-state-lock.d.ts +12 -0
  33. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +41 -3
  34. package/dist/types/gjc-runtime/ultragoal-succession.d.ts +251 -0
  35. package/dist/types/modes/components/bash-execution.d.ts +1 -0
  36. package/dist/types/modes/components/execution-shared.d.ts +1 -0
  37. package/dist/types/modes/interactive-mode.d.ts +2 -0
  38. package/dist/types/modes/types.d.ts +6 -0
  39. package/dist/types/sdk/broker/ensure.d.ts +32 -0
  40. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +0 -1
  41. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  42. package/dist/types/sdk/broker/startup-failure.d.ts +4 -1
  43. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -0
  44. package/dist/types/sdk/bus/chat-effect-journal.d.ts +17 -2
  45. package/dist/types/sdk/bus/conversation-store.d.ts +11 -0
  46. package/dist/types/sdk/bus/lifecycle-commands.d.ts +8 -8
  47. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  48. package/dist/types/sdk/bus/topic-registry.d.ts +8 -0
  49. package/dist/types/sdk/client/client.d.ts +6 -0
  50. package/dist/types/sdk/host/host.d.ts +3 -1
  51. package/dist/types/sdk/host/session-runtime.d.ts +4 -2
  52. package/dist/types/sdk/lifecycle/service.d.ts +1 -0
  53. package/dist/types/sdk/router/session-router.d.ts +10 -2
  54. package/dist/types/sdk/session.d.ts +5 -1
  55. package/dist/types/session/agent-session.d.ts +2 -0
  56. package/dist/types/session/fallback-chain-controller.d.ts +15 -0
  57. package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
  58. package/dist/types/session/messages.d.ts +1 -0
  59. package/dist/types/session/session-manager.d.ts +3 -1
  60. package/dist/types/setup/paseo/announce.d.ts +4 -3
  61. package/dist/types/skill-state/canonical-skills.d.ts +4 -0
  62. package/dist/types/system-prompt.d.ts +8 -0
  63. package/dist/types/task/executor.d.ts +3 -0
  64. package/dist/types/tools/bash.d.ts +1 -0
  65. package/dist/types/tools/github-cache.d.ts +2 -1
  66. package/dist/types/tools/index.d.ts +2 -0
  67. package/package.json +9 -7
  68. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  69. package/src/capability/index.ts +14 -2
  70. package/src/capability/types.ts +4 -0
  71. package/src/cli/customize-doctor.ts +155 -12
  72. package/src/cli.ts +22 -0
  73. package/src/commands/sdk.ts +74 -31
  74. package/src/config/file-lock-gc.ts +40 -2
  75. package/src/config/file-lock.ts +198 -14
  76. package/src/config/model-preset-registry.ts +115 -16
  77. package/src/config/model-registry.ts +16 -12
  78. package/src/coordinator-mcp/question-state.ts +33 -2
  79. package/src/coordinator-mcp/server.ts +477 -101
  80. package/src/crash/sanitize.ts +1 -1
  81. package/src/defaults/gjc/skills/ultragoal/SKILL.md +22 -0
  82. package/src/discovery/builtin.ts +50 -62
  83. package/src/discovery/helpers.ts +297 -32
  84. package/src/exec/bash-executor.ts +330 -50
  85. package/src/exec/bash-shell-guardian.ts +388 -0
  86. package/src/exec/bash-shell-runtime-entry.ts +5 -0
  87. package/src/exec/bash-shell-signals.ts +29 -0
  88. package/src/exec/bash-shell-supervisor-entry.ts +3 -0
  89. package/src/exec/bash-shell-supervisor.ts +359 -0
  90. package/src/exec/bash-shell-worker-entry.ts +5 -0
  91. package/src/exec/bash-shell-worker-protocol.ts +36 -0
  92. package/src/exec/bash-shell-worker.ts +116 -0
  93. package/src/exec/isolated-shell.ts +393 -0
  94. package/src/extensibility/extensions/function-hooks-internal.ts +73 -0
  95. package/src/extensibility/extensions/function-hooks.ts +959 -0
  96. package/src/extensibility/extensions/index.ts +22 -0
  97. package/src/extensibility/extensions/loader.ts +53 -2
  98. package/src/extensibility/extensions/runner.ts +895 -264
  99. package/src/extensibility/extensions/types.ts +42 -0
  100. package/src/extensibility/extensions/wrapper.ts +56 -15
  101. package/src/extensibility/gjc-plugins/compiler.ts +20 -2
  102. package/src/extensibility/gjc-plugins/constrained-hooks.ts +321 -23
  103. package/src/extensibility/gjc-plugins/registry.ts +113 -1
  104. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +20 -2
  105. package/src/extensibility/gjc-plugins/schema.ts +121 -4
  106. package/src/extensibility/gjc-plugins/types.ts +25 -0
  107. package/src/extensibility/hooks/loader.ts +133 -16
  108. package/src/extensibility/hooks/types.ts +28 -0
  109. package/src/extensibility/runtime-skill-discovery.ts +80 -47
  110. package/src/extensibility/skill-management.ts +200 -52
  111. package/src/extensibility/skills.ts +29 -9
  112. package/src/gjc-runtime/empty-delete-gc.ts +94 -4
  113. package/src/gjc-runtime/gc-runtime.ts +29 -0
  114. package/src/gjc-runtime/linux-proc.ts +14 -0
  115. package/src/gjc-runtime/session-state-lock.ts +51 -8
  116. package/src/gjc-runtime/session-state-sidecar.ts +27 -11
  117. package/src/gjc-runtime/state-writer.ts +30 -29
  118. package/src/gjc-runtime/ultragoal-runtime.ts +189 -47
  119. package/src/gjc-runtime/ultragoal-succession.ts +1667 -0
  120. package/src/gjc-runtime/workflow-manifest.generated.json +52 -1
  121. package/src/gjc-runtime/workflow-manifest.ts +16 -0
  122. package/src/harness-control-plane/session-lease.ts +2 -2
  123. package/src/internal-urls/docs-index.generated.ts +6 -6
  124. package/src/lsp/config.ts +10 -3
  125. package/src/lsp/index.ts +1 -1
  126. package/src/lsp/path-trust.ts +69 -14
  127. package/src/main.ts +6 -1
  128. package/src/modes/acp/acp-agent.ts +16 -3
  129. package/src/modes/components/bash-execution.ts +4 -1
  130. package/src/modes/components/execution-shared.ts +4 -1
  131. package/src/modes/components/session-observer-overlay.ts +1 -1
  132. package/src/modes/components/tool-status-header.ts +3 -1
  133. package/src/modes/controllers/command-controller.ts +1 -0
  134. package/src/modes/controllers/event-controller.ts +3 -2
  135. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  136. package/src/modes/controllers/input-controller.ts +6 -3
  137. package/src/modes/interactive-mode.ts +6 -5
  138. package/src/modes/print-mode.ts +5 -1
  139. package/src/modes/prompt-action-autocomplete.ts +54 -27
  140. package/src/modes/runtime-init.ts +2 -0
  141. package/src/modes/types.ts +16 -3
  142. package/src/modes/utils/ui-helpers.ts +1 -0
  143. package/src/prompts/tools/subagent.md +1 -0
  144. package/src/runtime-mcp/manager.ts +20 -2
  145. package/src/runtime-mcp/pool.test.ts +20 -11
  146. package/src/sdk/broker/broker.ts +119 -28
  147. package/src/sdk/broker/ensure.ts +248 -41
  148. package/src/sdk/broker/lifecycle-ledger.ts +7 -17
  149. package/src/sdk/broker/lifecycle.ts +56 -6
  150. package/src/sdk/broker/startup-failure.ts +15 -6
  151. package/src/sdk/bus/chat-daemon-control.ts +3 -1
  152. package/src/sdk/bus/chat-daemon-runtime.ts +35 -5
  153. package/src/sdk/bus/chat-effect-journal.ts +132 -7
  154. package/src/sdk/bus/conversation-store.ts +29 -0
  155. package/src/sdk/bus/lifecycle-commands.ts +17 -11
  156. package/src/sdk/bus/slack-conversation.ts +1 -0
  157. package/src/sdk/bus/slack-daemon.ts +387 -48
  158. package/src/sdk/bus/telegram-daemon.ts +16 -2
  159. package/src/sdk/bus/topic-registry.ts +19 -0
  160. package/src/sdk/cli/master-cli.ts +1 -1
  161. package/src/sdk/cli/session-cli.ts +2 -2
  162. package/src/sdk/client/client.ts +66 -17
  163. package/src/sdk/host/host.ts +29 -9
  164. package/src/sdk/host/query/revision-store.ts +19 -13
  165. package/src/sdk/host/reverse-leases.ts +7 -4
  166. package/src/sdk/host/session-runtime.test.ts +50 -8
  167. package/src/sdk/host/session-runtime.ts +158 -51
  168. package/src/sdk/lifecycle/service.ts +4 -0
  169. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  170. package/src/sdk/router/session-router.ts +89 -38
  171. package/src/sdk/session.ts +155 -26
  172. package/src/sdk/transport/relay.ts +35 -13
  173. package/src/session/agent-session.ts +117 -52
  174. package/src/session/fallback-chain-controller.ts +38 -0
  175. package/src/session/internal/managed-session-storage.ts +19 -5
  176. package/src/session/messages.ts +5 -0
  177. package/src/session/session-manager.ts +29 -5
  178. package/src/session-import/redact.ts +5 -1
  179. package/src/setup/paseo/announce.ts +49 -43
  180. package/src/setup/paseo/setup-deps.ts +1 -1
  181. package/src/skill-state/canonical-skills.ts +12 -0
  182. package/src/skills/skills.test.ts +41 -21
  183. package/src/system-prompt.ts +25 -3
  184. package/src/task/executor.ts +5 -0
  185. package/src/task/index.ts +4 -0
  186. package/src/tools/bash.ts +28 -11
  187. package/src/tools/gh.ts +26 -3
  188. package/src/tools/github-cache.ts +68 -16
  189. package/src/tools/index.ts +2 -0
  190. package/src/tools/job.ts +13 -2
  191. package/src/tools/read.ts +14 -2
  192. package/src/tools/render-utils.ts +1 -3
  193. package/src/tools/resource-gc.ts +28 -8
  194. package/src/tools/skill-discovery.ts +5 -0
  195. package/src/tools/skill.ts +15 -0
  196. package/src/tools/subagent.ts +3 -2
  197. package/src/tools/tool-catalog.generated.ts +2 -2
  198. package/src/tools/ultragoal-ask-guard.ts +2 -2
@@ -4,6 +4,8 @@ import type { FileHandle } from "node:fs/promises";
4
4
  import * as fs from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import packageJson from "../../../package.json" with { type: "json" };
7
+ import { acquireFileLock, type FileLockOptions, withFileLock } from "../../config/file-lock";
8
+ import { loadInstallationHostId, loadLegacyInstallationHostId } from "../../config/machine-identity";
7
9
  import { SdkClient } from "../client/client";
8
10
  import { type BrokerDiscovery, brokerProcessIncarnation, readBrokerDiscovery } from "./discovery";
9
11
  import { resolveSdkInternalSpawnCommand, type SdkInternalSpawnCommand } from "./runtime";
@@ -14,7 +16,7 @@ function resolveExpectedBrokerGeneration(): string {
14
16
  return typeof v === "string" && v.length > 0 ? v : "unknown";
15
17
  }
16
18
 
17
- function isBrokerGenerationCompatible(discovery: BrokerDiscovery | null): boolean {
19
+ export function isBrokerGenerationCompatible(discovery: BrokerDiscovery | null): boolean {
18
20
  if (!discovery) return false;
19
21
  return discovery.packageGeneration === resolveExpectedBrokerGeneration();
20
22
  }
@@ -29,7 +31,110 @@ export interface EnsureBrokerSettings {
29
31
  env?: NodeJS.ProcessEnv;
30
32
  }
31
33
 
32
- const DISCOVERY_TIMEOUT_MS = 10_000;
34
+ const BROKER_PUBLICATION_TIMEOUT_MS = 15_000;
35
+ const STARTUP_LOCK_WAIT_MS = 9_000;
36
+ const STALE_BROKER_RETIREMENT_TIMEOUT_MS = 5_000;
37
+ /** Parent budget covers stale retirement, a full child-fence wait, and one publication attempt. */
38
+ const DISCOVERY_TIMEOUT_MS = STALE_BROKER_RETIREMENT_TIMEOUT_MS + STARTUP_LOCK_WAIT_MS + BROKER_PUBLICATION_TIMEOUT_MS;
39
+ /** A process that loses the spawn lock waits this long for the winner's discovery before giving up. */
40
+ const SPAWN_LOCK_WAIT_MS = STALE_BROKER_RETIREMENT_TIMEOUT_MS + DISCOVERY_TIMEOUT_MS + 5_000;
41
+ const SPAWN_LOCK_RETRY_DELAY_MS = 50;
42
+ const SPAWN_LOCK_TARGET_NAME = "broker.spawn";
43
+ const STARTUP_LOCK_TARGET_NAME = "broker.startup";
44
+
45
+ type SpawnLockOptions = Pick<FileLockOptions, "retries" | "retryDelayMs" | "signal">;
46
+
47
+ interface BrokerLockHostIdentity {
48
+ ownerHostId: string;
49
+ previousOwnerHostIds: readonly string[];
50
+ }
51
+
52
+ const brokerLockHostIdentityPromises = new Map<string, Promise<BrokerLockHostIdentity>>();
53
+
54
+ async function brokerLockHostIdentity(agentDir: string): Promise<BrokerLockHostIdentity> {
55
+ const coordinationRoot = path.resolve(agentDir, "sdk");
56
+ const pending =
57
+ brokerLockHostIdentityPromises.get(coordinationRoot) ??
58
+ Promise.all([loadInstallationHostId({ configRootDir: coordinationRoot }), loadLegacyInstallationHostId()]).then(
59
+ ([ownerHostId, legacyHostId]) => ({
60
+ ownerHostId,
61
+ previousOwnerHostIds: legacyHostId === ownerHostId ? [] : [legacyHostId],
62
+ }),
63
+ );
64
+ brokerLockHostIdentityPromises.set(coordinationRoot, pending);
65
+ try {
66
+ return await pending;
67
+ } catch (error) {
68
+ if (brokerLockHostIdentityPromises.get(coordinationRoot) === pending)
69
+ brokerLockHostIdentityPromises.delete(coordinationRoot);
70
+ throw error;
71
+ }
72
+ }
73
+
74
+ async function readBrokerDiscoveryBeforeDeadline(
75
+ agentDir: string,
76
+ heartbeatTtlMs: number | undefined,
77
+ deadline: number,
78
+ ): Promise<BrokerDiscovery | null> {
79
+ const remainingMs = deadline - Date.now();
80
+ if (!Number.isFinite(remainingMs)) return await readBrokerDiscovery(agentDir, heartbeatTtlMs);
81
+ if (remainingMs <= 0) throw new Error("Timed out reading SDK broker discovery.");
82
+ const read = readBrokerDiscovery(agentDir, heartbeatTtlMs);
83
+ void read.catch(() => undefined);
84
+ const timeout = Promise.withResolvers<BrokerDiscovery | null>();
85
+ const timer: NodeJS.Timeout = setTimeout(
86
+ () => timeout.reject(new Error("Timed out reading SDK broker discovery.")),
87
+ remainingMs,
88
+ );
89
+ try {
90
+ return await Promise.race([read, timeout.promise]);
91
+ } finally {
92
+ clearTimeout(timer);
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Cross-process single-flight for the detached broker spawn (#5198).
98
+ *
99
+ * `ensureInFlight` only dedupes within one process. Every CLI invocation is
100
+ * its own process, so N concurrent invocations that all miss discovery each
101
+ * spawn a broker; the losers of the runtime's ownership lock then leave
102
+ * quarantine tombstones behind and the next start fails with
103
+ * `quarantine_collision`. The spawn itself must be exclusive across process
104
+ * death and PID reuse, so it uses the shared identity-bound file-lock protocol:
105
+ * complete owner metadata is published atomically, stale removal is bound to
106
+ * the exact directory generation, and ownership includes the OS incarnation.
107
+ */
108
+ async function acquireSpawnLock(agentDir: string, options: SpawnLockOptions = {}): Promise<() => Promise<void>> {
109
+ const hostIdentity = await brokerLockHostIdentity(agentDir);
110
+ return acquireFileLock(path.join(agentDir, "sdk", SPAWN_LOCK_TARGET_NAME), {
111
+ retries: Math.ceil(SPAWN_LOCK_WAIT_MS / SPAWN_LOCK_RETRY_DELAY_MS),
112
+ retryDelayMs: SPAWN_LOCK_RETRY_DELAY_MS,
113
+ ...hostIdentity,
114
+ ...options,
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Child-owned fence for the interval from bootstrap through discovery publication.
120
+ * The parent-held spawn lock prevents ordinary stampedes; this second fence keeps
121
+ * startup single-flight if that detached child's launcher dies and its lock is reclaimed.
122
+ */
123
+ export async function withBrokerStartupLock<T>(
124
+ agentDir: string,
125
+ operation: (deadline: number) => Promise<T>,
126
+ ): Promise<T> {
127
+ const hostIdentity = await brokerLockHostIdentity(agentDir);
128
+ return withFileLock(
129
+ path.join(agentDir, "sdk", STARTUP_LOCK_TARGET_NAME),
130
+ () => operation(Date.now() + STALE_BROKER_RETIREMENT_TIMEOUT_MS + BROKER_PUBLICATION_TIMEOUT_MS),
131
+ {
132
+ retries: Math.ceil(STARTUP_LOCK_WAIT_MS / SPAWN_LOCK_RETRY_DELAY_MS),
133
+ retryDelayMs: SPAWN_LOCK_RETRY_DELAY_MS,
134
+ ...hostIdentity,
135
+ },
136
+ );
137
+ }
33
138
  const FIXTURE_DISCOVERY_TIMEOUT_MS = 30_000;
34
139
  // Bounded grace windows for reaping a spawned broker on failure, mirroring the
35
140
  // owned-process teardown convention (SIGTERM -> grace -> SIGKILL -> hard cap).
@@ -260,7 +365,6 @@ function fixtureLeaseUnavailable(): Error {
260
365
  return new Error("fixture_broker_lease_unavailable");
261
366
  }
262
367
 
263
- const STALE_BROKER_SHUTDOWN_TIMEOUT_MS = 5_000;
264
368
  const STALE_BROKER_POLL_MS = 50;
265
369
 
266
370
  type BrokerOwnerIdentity = Pick<BrokerDiscovery, "ownerId" | "pid" | "incarnation">;
@@ -271,17 +375,34 @@ function sameBrokerOwner(left: BrokerOwnerIdentity | null, right: BrokerOwnerIde
271
375
  );
272
376
  }
273
377
 
274
- async function retireStaleBrokerGeneration(stale: BrokerDiscovery, settings: EnsureBrokerSettings): Promise<void> {
378
+ /** Start client teardown, but never await it past the retirement operation's absolute deadline. */
379
+ export async function closeBrokerClientBeforeDeadline(
380
+ client: { close(): Promise<void> },
381
+ deadline: number,
382
+ ): Promise<void> {
383
+ const close = client.close();
384
+ void close.catch(() => undefined);
385
+ const remainingMs = deadline - Date.now();
386
+ if (remainingMs <= 0) return;
387
+ await Promise.race([close, Bun.sleep(remainingMs)]);
388
+ }
389
+
390
+ async function retireStaleBrokerGeneration(
391
+ stale: BrokerDiscovery,
392
+ settings: EnsureBrokerSettings,
393
+ outerDeadline = Number.POSITIVE_INFINITY,
394
+ ): Promise<void> {
395
+ const deadline = Math.min(outerDeadline, Date.now() + STALE_BROKER_RETIREMENT_TIMEOUT_MS);
275
396
  let shutdownSucceeded = false;
276
397
  try {
277
- const current = await readBrokerDiscovery(settings.agentDir, settings.heartbeatTtlMs);
398
+ const current = await readBrokerDiscoveryBeforeDeadline(settings.agentDir, settings.heartbeatTtlMs, deadline);
278
399
  if (!current || !sameBrokerOwner(current, stale)) return;
279
- const client = await SdkClient.connect(current.url, current.token, { timeoutMs: 2_000 });
400
+ const client = await SdkClient.connect(current.url, current.token, { timeoutMs: 2_000, deadline });
280
401
  try {
281
402
  await client.global("broker.shutdown", {});
282
403
  shutdownSucceeded = true;
283
404
  } finally {
284
- await client.close().catch(() => {});
405
+ await closeBrokerClientBeforeDeadline(client, deadline).catch(() => undefined);
285
406
  }
286
407
  } catch {}
287
408
  if (!shutdownSucceeded) {
@@ -294,13 +415,25 @@ async function retireStaleBrokerGeneration(stale: BrokerDiscovery, settings: Ens
294
415
  } catch {}
295
416
  }
296
417
  // Wait for the stale identity to disappear (owner-fenced: pid+incarnation).
297
- const deadline = Date.now() + STALE_BROKER_SHUTDOWN_TIMEOUT_MS;
298
418
  while (Date.now() < deadline) {
299
- const current = await readBrokerDiscovery(settings.agentDir, settings.heartbeatTtlMs);
419
+ const current = await readBrokerDiscoveryBeforeDeadline(settings.agentDir, settings.heartbeatTtlMs, deadline);
300
420
  if (!sameBrokerOwner(current, stale)) break;
301
421
  await sleep(STALE_BROKER_POLL_MS);
302
422
  }
303
423
  }
424
+
425
+ /** Reconcile an observed broker generation before entering a startup critical section. */
426
+ export async function reconcileBrokerGenerationForStartup(
427
+ settings: EnsureBrokerSettings,
428
+ deadline = Number.POSITIVE_INFINITY,
429
+ ): Promise<BrokerDiscovery | undefined> {
430
+ const current = await readBrokerDiscoveryBeforeDeadline(settings.agentDir, settings.heartbeatTtlMs, deadline);
431
+ if (!current) return undefined;
432
+ if (isBrokerGenerationCompatible(current)) return current;
433
+ await retireStaleBrokerGeneration(current, settings, deadline);
434
+ const replacement = await readBrokerDiscoveryBeforeDeadline(settings.agentDir, settings.heartbeatTtlMs, deadline);
435
+ return replacement && isBrokerGenerationCompatible(replacement) ? replacement : undefined;
436
+ }
304
437
  function createFixtureLeaseFromChild(child: ChildProcess, terminate: () => Promise<void>): ExactFixtureBrokerLease {
305
438
  let termination: Promise<void> | undefined;
306
439
  const hasExited = (): boolean => child.exitCode !== null || child.signalCode !== null || child.pid === undefined;
@@ -344,60 +477,79 @@ function createFixtureLease(owner: BrokerOwner, child: ChildProcess): ExactFixtu
344
477
  }
345
478
 
346
479
  async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: EnsureInitiator): Promise<EnsureOutcome> {
480
+ const initialDiscoveryDeadline =
481
+ Date.now() + (initiator === "fixture-lease" ? FIXTURE_DISCOVERY_TIMEOUT_MS : DISCOVERY_TIMEOUT_MS);
347
482
  const priorOwner = owners.get(settings.agentDir);
348
- const existing = await readBrokerDiscovery(settings.agentDir, settings.heartbeatTtlMs);
483
+ const existing = await readBrokerDiscoveryBeforeDeadline(
484
+ settings.agentDir,
485
+ settings.heartbeatTtlMs,
486
+ initialDiscoveryDeadline,
487
+ );
349
488
  if (initiator === "fixture-lease" && (priorOwner || existing)) throw fixtureLeaseUnavailable();
350
489
  if (priorOwner) {
351
490
  // A retained cleanup failure fences every discovery record. Only a ready
352
491
  // record bound to this exact child incarnation may be reused.
353
492
  if (priorOwner.canReuse(existing)) return { kind: "prior-local-owner", discovery: existing!, owner: priorOwner };
354
493
  await priorOwner.stop();
355
- const discoveredAfterCleanup = await readBrokerDiscovery(settings.agentDir, settings.heartbeatTtlMs);
494
+ const discoveredAfterCleanup = await readBrokerDiscoveryBeforeDeadline(
495
+ settings.agentDir,
496
+ settings.heartbeatTtlMs,
497
+ initialDiscoveryDeadline,
498
+ );
356
499
  if (discoveredAfterCleanup && isBrokerGenerationCompatible(discoveredAfterCleanup))
357
500
  return { kind: "external-discovery", discovery: discoveredAfterCleanup };
358
- // A stale-generation discovery after cleanup must not be reused — fall through to replacement.
359
- if (!discoveredAfterCleanup) {
360
- // no discovery left after cleanup — fall through to spawn
361
- } else {
362
- await retireStaleBrokerGeneration(discoveredAfterCleanup, settings);
363
- const afterRetire = await readBrokerDiscovery(settings.agentDir, settings.heartbeatTtlMs);
364
- if (afterRetire && isBrokerGenerationCompatible(afterRetire))
365
- return { kind: "external-discovery", discovery: afterRetire };
366
- }
501
+ // Stale-generation retirement is serialized under the spawn lock below.
367
502
  } else if (existing) {
368
503
  if (isBrokerGenerationCompatible(existing)) return { kind: "external-discovery", discovery: existing };
369
- await retireStaleBrokerGeneration(existing, settings);
370
- const afterRetire = await readBrokerDiscovery(settings.agentDir, settings.heartbeatTtlMs);
371
- if (afterRetire && isBrokerGenerationCompatible(afterRetire))
372
- return { kind: "external-discovery", discovery: afterRetire };
504
+ // Stale-generation retirement is serialized under the spawn lock below.
373
505
  }
374
- const command = resolveSdkInternalSpawnCommand("broker-internal");
375
- const spawnLog = await openBrokerSpawnLog(settings.agentDir);
376
- // A stale marker must never be misattributed to this spawn; clear it first.
377
- await clearBrokerStartupFailureMarker(settings.agentDir);
506
+
507
+ // Only one process may spawn for this agent dir at a time; everyone else
508
+ // waits for the exact lock generation to release, then rechecks discovery.
509
+ // Fixture leases always spawn their own.
510
+ let spawnLog: BrokerSpawnLog | undefined;
511
+ const releaseSpawnLock = initiator === "fixture-lease" ? async () => {} : await acquireSpawnLock(settings.agentDir);
378
512
  try {
513
+ const deadline =
514
+ Date.now() + (initiator === "fixture-lease" ? FIXTURE_DISCOVERY_TIMEOUT_MS : DISCOVERY_TIMEOUT_MS);
515
+ // The lock winner may still find a discovery published by an earlier winner
516
+ // that finished between our first read and the lock acquisition.
517
+ const discoveredUnderLock = await reconcileBrokerGenerationForStartup(settings, deadline);
518
+ if (discoveredUnderLock) return { kind: "external-discovery", discovery: discoveredUnderLock };
519
+ const command = resolveSdkInternalSpawnCommand("broker-internal");
520
+ spawnLog = await openBrokerSpawnLog(settings.agentDir);
521
+ // A stale marker must never be misattributed to this spawn; clear it first.
522
+ await clearBrokerStartupFailureMarker(settings.agentDir);
379
523
  const child = spawn(command.file, [...command.args, "--agent-dir", settings.agentDir], {
380
524
  detached: true,
381
525
  stdio: ["ignore", "ignore", spawnLog ? spawnLog.handle.fd : "ignore"],
382
526
  env: brokerSpawnEnvironment(command, settings.env),
383
527
  ...(command.kind === "bun-source" ? { cwd: command.cwd } : {}),
384
528
  });
385
- // The child holds its own duplicate of the descriptor; this one is done.
386
- await spawnLog?.handle.close();
387
- child.unref();
388
529
  let spawnError: Error | undefined;
389
530
  child.once("error", error => {
390
531
  spawnError = error;
391
532
  });
533
+ const childIncarnation = child.pid === undefined ? undefined : brokerProcessIncarnation(child.pid);
392
534
  const owner = registerBrokerOwner(settings.agentDir, child);
393
- const discoveryTimeoutMs = initiator === "fixture-lease" ? FIXTURE_DISCOVERY_TIMEOUT_MS : DISCOVERY_TIMEOUT_MS;
394
- const deadline = Date.now() + discoveryTimeoutMs;
535
+ child.unref();
536
+ // The child holds its own duplicate of the descriptor. Failure to close the
537
+ // parent's diagnostic handle must not discard exact ownership of a live child.
538
+ await spawnLog?.handle.close().catch(() => undefined);
395
539
  let discoveryError: unknown;
396
540
  while (Date.now() < deadline) {
397
541
  if (spawnError || child.exitCode !== null || child.signalCode !== null) break;
398
542
  try {
399
- const discovered = await readBrokerDiscovery(settings.agentDir, settings.heartbeatTtlMs);
543
+ const discovered = await readBrokerDiscoveryBeforeDeadline(
544
+ settings.agentDir,
545
+ settings.heartbeatTtlMs,
546
+ deadline,
547
+ );
400
548
  if (discovered) {
549
+ if (!isBrokerGenerationCompatible(discovered)) {
550
+ await sleep(50);
551
+ continue;
552
+ }
401
553
  if (owner.markReady(discovered)) {
402
554
  return initiator === "fixture-lease"
403
555
  ? { kind: "local-started-fixture", discovery: discovered, owner, child }
@@ -420,8 +572,12 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
420
572
  // read failures fall through to the common cleanup + failure path below.
421
573
  try {
422
574
  for (let retry = 0; retry < 20; retry++) {
423
- const winner = await readBrokerDiscovery(settings.agentDir, settings.heartbeatTtlMs);
424
- if (winner) {
575
+ const winner = await readBrokerDiscoveryBeforeDeadline(
576
+ settings.agentDir,
577
+ settings.heartbeatTtlMs,
578
+ deadline,
579
+ );
580
+ if (winner && isBrokerGenerationCompatible(winner)) {
425
581
  await owner.stop();
426
582
  return { kind: "external-discovery", discovery: winner };
427
583
  }
@@ -431,8 +587,53 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
431
587
  // fall through to cleanup + failure
432
588
  }
433
589
  }
434
- const spawnLogTail = exitedBeforeDiscovery && spawnLog ? await readBrokerSpawnLogTail(spawnLog.path) : "";
435
590
  const marker = await readBrokerStartupFailureMarker(settings.agentDir);
591
+ const trustedMarker =
592
+ marker &&
593
+ child.pid !== undefined &&
594
+ childIncarnation !== undefined &&
595
+ marker.pid === child.pid &&
596
+ marker.incarnation === childIncarnation
597
+ ? marker
598
+ : undefined;
599
+ const startupLockPath = path.join(settings.agentDir, "sdk", STARTUP_LOCK_TARGET_NAME);
600
+ let startupFenceContention =
601
+ exitedBeforeDiscovery &&
602
+ trustedMarker?.reason.startsWith(`Failed to acquire lock for ${startupLockPath} after `) === true;
603
+ if (!startupFenceContention && exitedBeforeDiscovery && child.exitCode !== 0) {
604
+ try {
605
+ startupFenceContention = (await fs.stat(`${startupLockPath}.lock`)).isDirectory();
606
+ } catch {
607
+ // The marker is best-effort and the fence may have been released
608
+ // between the child exit and this observation.
609
+ }
610
+ }
611
+ if (startupFenceContention) {
612
+ // A launcher can die after its detached child acquires the startup fence.
613
+ // The replacement child may exhaust its own fence wait before the incumbent
614
+ // reaches the end of its publication allowance, so keep the parent attached
615
+ // to the exact agent dir and give that incumbent one final bounded chance to
616
+ // publish before treating the replacement failure as terminal.
617
+ const recoveryDeadline = Math.min(deadline, Date.now() + BROKER_PUBLICATION_TIMEOUT_MS);
618
+ while (Date.now() < recoveryDeadline) {
619
+ try {
620
+ const incumbent = await readBrokerDiscoveryBeforeDeadline(
621
+ settings.agentDir,
622
+ settings.heartbeatTtlMs,
623
+ recoveryDeadline,
624
+ );
625
+ if (incumbent && isBrokerGenerationCompatible(incumbent)) {
626
+ await owner.stop();
627
+ return { kind: "external-discovery", discovery: incumbent };
628
+ }
629
+ } catch {
630
+ // Keep the bounded retry alive; a transient read failure is not
631
+ // authority to classify the incumbent as failed.
632
+ }
633
+ await sleep(50);
634
+ }
635
+ }
636
+ const spawnLogTail = exitedBeforeDiscovery && spawnLog ? await readBrokerSpawnLogTail(spawnLog.path) : "";
436
637
  // A marker only wins over the generic fallback when it was written by the
437
638
  // exact child this call just spawned and reaped. The pre-spawn clear
438
639
  // already prevents an old marker from surviving to this point, but a
@@ -440,7 +641,6 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
440
641
  // still write a marker between the clear and this read; the pid binding
441
642
  // rejects that marker instead of misattributing a foreign failure to this
442
643
  // spawn's caller.
443
- const trustedMarker = marker && child.pid !== undefined && marker.pid === child.pid ? marker : undefined;
444
644
  const failure = spawnError
445
645
  ? new Error(`Failed to spawn detached SDK broker: ${spawnError.message}`)
446
646
  : exitedBeforeDiscovery
@@ -464,6 +664,7 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
464
664
  throw failure;
465
665
  } finally {
466
666
  if (spawnLog) await removeBrokerSpawnLog(spawnLog.path);
667
+ await releaseSpawnLock();
467
668
  }
468
669
  }
469
670
 
@@ -482,14 +683,16 @@ function startEnsure(settings: EnsureBrokerSettings, initiator: EnsureInitiator)
482
683
 
483
684
  /** Starts the detached broker entrypoint when discovery has no live owner. */
484
685
  export function ensureBroker(settings: EnsureBrokerSettings): Promise<BrokerDiscovery> {
485
- const inFlight = ensureInFlight.get(settings.agentDir) ?? startEnsure(settings, "discovery");
686
+ const resolvedSettings = { ...settings, agentDir: path.resolve(settings.agentDir) };
687
+ const inFlight = ensureInFlight.get(resolvedSettings.agentDir) ?? startEnsure(resolvedSettings, "discovery");
486
688
  return inFlight.discovery;
487
689
  }
488
690
 
489
691
  /** Starts one fresh fixture broker and returns its sole exact-child close lease. */
490
692
  export function startFixtureBrokerWithLeaseForTest(settings: EnsureBrokerSettings): Promise<StartedFixtureBroker> {
491
- if (ensureInFlight.has(settings.agentDir)) return Promise.reject(fixtureLeaseUnavailable());
492
- const inFlight = startEnsure(settings, "fixture-lease");
693
+ const resolvedSettings = { ...settings, agentDir: path.resolve(settings.agentDir) };
694
+ if (ensureInFlight.has(resolvedSettings.agentDir)) return Promise.reject(fixtureLeaseUnavailable());
695
+ const inFlight = startEnsure(resolvedSettings, "fixture-lease");
493
696
  return inFlight.promise.then(outcome => {
494
697
  if (outcome.kind !== "local-started-fixture") throw fixtureLeaseUnavailable();
495
698
  return { discovery: outcome.discovery, lease: createFixtureLease(outcome.owner, outcome.child) };
@@ -540,6 +743,10 @@ export function startFixtureBrokerCommandWithLeaseForTest(command: FixtureBroker
540
743
  }
541
744
 
542
745
  /** Test hook: returns a stop handle for the detached broker this process spawned. */
746
+ export const acquireSpawnLockForTest = acquireSpawnLock;
747
+ /** Test hook: proves discovery reads honor their enclosing absolute deadline. */
748
+ export const readBrokerDiscoveryBeforeDeadlineForTest = readBrokerDiscoveryBeforeDeadline;
749
+
543
750
  export function brokerOwnerForTest(agentDir: string): BrokerOwner | undefined {
544
751
  return owners.get(agentDir);
545
752
  }
@@ -92,7 +92,6 @@ export interface LifecycleLedgerEntry {
92
92
  unresolvedCleanupResponseDigest?: string;
93
93
  uncertainCleanupSessionId?: string;
94
94
  uncertainCleanupSessionIds?: string[];
95
- uncertainCleanupAllSessions?: true;
96
95
  ts: number;
97
96
  }
98
97
  export type BeginResult =
@@ -183,12 +182,6 @@ function pendingCleanupSessionId(response: unknown): string | undefined {
183
182
  return canonicalCleanupSessionId(cleanup?.sessionId) ? cleanup.sessionId : undefined;
184
183
  }
185
184
 
186
- function hasCleanupAuthorityShape(response: unknown): boolean {
187
- if (!response || typeof response !== "object") return false;
188
- const error = (response as { error?: unknown }).error;
189
- return Boolean(error && typeof error === "object" && "cleanup" in error);
190
- }
191
-
192
185
  function hasValidTerminalDigests(entry: LifecycleLedgerEntry): boolean {
193
186
  const response = entry.response as { ok?: unknown; error?: { code?: unknown; cleanup?: unknown } } | undefined;
194
187
  const cleanupPendingResponse =
@@ -335,12 +328,10 @@ export class LifecycleLedger {
335
328
  (candidate, index, candidates): candidate is string =>
336
329
  canonicalCleanupSessionId(candidate) && candidates.indexOf(candidate) === index,
337
330
  );
338
- if (
339
- hasCleanupAuthorityShape(entry.response) ||
340
- hasCleanupAuthorityShape(entry.unresolvedCleanupResponse) ||
341
- (entry.state === "terminal_uncertain" && entry.response === undefined)
342
- )
343
- uncertain.uncertainCleanupAllSessions = true;
331
+ // No blanket fence: the synthetic marker stays scoped to the
332
+ // session ids the quarantined row actually named (#5364).
333
+ // Rows that named nothing fence nothing; resurrecting a
334
+ // maximally-destructive flag here is the poison shape.
344
335
  syntheticUncertain.set(entry.identity, uncertain);
345
336
  }
346
337
  continue;
@@ -895,8 +886,6 @@ export class LifecycleLedger {
895
886
  hasUncertainCleanupForSession(sessionId: string, excludingIdentity: string): boolean {
896
887
  for (const current of this.#byIdentity.values()) {
897
888
  if (current.state !== "terminal_uncertain") continue;
898
- if (current.uncertainCleanupAllSessions === true) return true;
899
- if (current.response === undefined) return true;
900
889
  if (current.identity === excludingIdentity) continue;
901
890
  const fencedSessions = [
902
891
  current.uncertainCleanupSessionId,
@@ -905,8 +894,9 @@ export class LifecycleLedger {
905
894
  pendingCleanupSessionId(current.unresolvedCleanupResponse),
906
895
  ...(current.uncertainCleanupSessionIds ?? []),
907
896
  ];
908
- const boundSessions = fencedSessions.filter((value): value is string => typeof value === "string");
909
- if (boundSessions.length === 0) return true;
897
+ // An unbound uncertain entry names no session: it fences nothing.
898
+ // Fencing all ids on zero evidence is what made one refusal
899
+ // self-amplify into a permanent blanket fence (#5364).
910
900
  if (fencedSessions.includes(sessionId)) return true;
911
901
  }
912
902
  return false;
@@ -812,6 +812,20 @@ function sameResumeSessionIdentity(left: ResumeScope, right: ResumeScope): boole
812
812
  left.sessionIdentity.sha256 === right.sessionIdentity.sha256
813
813
  );
814
814
  }
815
+ function sameSavedTranscriptIdentity(
816
+ actual: { dev: bigint; ino: bigint; size: number; mtimeMs: number; mtimeNs: bigint; sha256: string },
817
+ expected: unknown,
818
+ ): boolean {
819
+ return (
820
+ isSessionLifecycleTranscriptIdentity(expected) &&
821
+ actual.dev.toString() === expected.dev &&
822
+ actual.ino.toString() === expected.ino &&
823
+ actual.size === expected.size &&
824
+ actual.mtimeMs === expected.mtimeMs &&
825
+ actual.mtimeNs.toString() === expected.mtimeNs &&
826
+ actual.sha256 === expected.sha256
827
+ );
828
+ }
815
829
  function sameLiveResumeRecord(expected: LiveResumeRecord, current: LiveResumeRecord): boolean {
816
830
  return (
817
831
  current.live &&
@@ -890,6 +904,7 @@ async function validateSavedTranscript(
890
904
  suppliedPath: string | undefined,
891
905
  expectedSessionId: string | undefined,
892
906
  label: "Saved" | "Source",
907
+ expectedIdentity?: unknown,
893
908
  ): Promise<ValidatedTranscript | BrokerResponse> {
894
909
  const inventory = await managedCandidates(broker, cwd, label);
895
910
  if ("ok" in inventory) return inventory;
@@ -902,6 +917,11 @@ async function validateSavedTranscript(
902
917
  if (matches.length !== 1 || !isCanonicalSessionId(matches[0]!.sessionId))
903
918
  return fail("invalid_input", `${label} saved session does not match the requested workspace and session id.`);
904
919
  const match = matches[0]!;
920
+ if (expectedIdentity !== undefined && !sameSavedTranscriptIdentity(match.identity, expectedIdentity))
921
+ return fail(
922
+ "invalid_input",
923
+ `${label} saved session does not match the requested workspace, session id, and transcript identity.`,
924
+ );
905
925
  if (inventory.migrationPolicy === "disabled" && match.provenance === "legacy")
906
926
  return fail("legacy_migration_disabled", `${label} legacy session migration is disabled for this workspace.`);
907
927
  return { path: match.path, id: match.sessionId, identity: serializeTranscriptIdentity(match.identity) };
@@ -959,6 +979,8 @@ async function validateLiveResumeScope(
959
979
  if (matches.length !== 1)
960
980
  return fail("endpoint_stale", "Requested saved session does not match the live session scope.");
961
981
  const session = matches[0]!;
982
+ if (input.sessionIdentity !== undefined && !sameSavedTranscriptIdentity(session.identity, input.sessionIdentity))
983
+ return fail("invalid_input", "Saved session transcript identity does not match the requested snapshot.");
962
984
  if (inventory.migrationPolicy === "disabled" && matches[0]!.provenance === "legacy")
963
985
  return fail("legacy_migration_disabled", "Saved legacy session migration is disabled for this workspace.");
964
986
  return {
@@ -1375,7 +1397,7 @@ export async function reapDeadLifecycleMarkers(
1375
1397
  return reaped;
1376
1398
  }
1377
1399
 
1378
- async function writeEffectMarker(root: string, id: string, marker: EffectMarker): Promise<void> {
1400
+ export async function writeEffectMarker(root: string, id: string, marker: EffectMarker): Promise<void> {
1379
1401
  const directory = path.join(root, "sdk");
1380
1402
  await fs.mkdir(directory, { recursive: true, mode: 0o700 });
1381
1403
  const temporary = path.join(directory, `.${id}.lifecycle.${randomUUID()}.tmp`);
@@ -2994,7 +3016,7 @@ async function reconcileLifecycleCleanup(
2994
3016
  return completion;
2995
3017
  }
2996
3018
 
2997
- async function readSessionLifecycleFailure(
3019
+ export async function readSessionLifecycleFailure(
2998
3020
  root: string,
2999
3021
  id: string,
3000
3022
  expected: EffectMarker,
@@ -4031,10 +4053,16 @@ async function currentReadyAuthority(
4031
4053
  "alive"
4032
4054
  )
4033
4055
  return undefined;
4056
+ // Every other field of this authority comes from the indexed record, and
4057
+ // `matchesIndexedEndpointFile` above already proved the on-disk file is
4058
+ // that record's endpoint. Re-deriving the mtime from a fresh `stat` made
4059
+ // the tuple internally inconsistent: the float differed from the indexed
4060
+ // value in its last digits (…272.1147 vs …272.1145), and replay hashing
4061
+ // then read the same endpoint as replaced. Prefer the validated value.
4034
4062
  return {
4035
4063
  endpoint: endpoint as Record<string, unknown>,
4036
4064
  endpointSource,
4037
- endpointMtimeMs: endpointFile.mtimeMs,
4065
+ endpointMtimeMs: record.endpointMtimeMs ?? endpointFile.mtimeMs,
4038
4066
  ...(record.endpointFileId === undefined ? {} : { endpointFileId: record.endpointFileId }),
4039
4067
  endpointGeneration: record.endpointGeneration,
4040
4068
  };
@@ -4392,7 +4420,7 @@ async function launchInput(
4392
4420
  if (!requested) return fail("invalid_input", "sessionId is required to resume a saved session.");
4393
4421
  const savedPath = text(input.sessionPath);
4394
4422
  if (!savedPath) return fail("invalid_input", "sessionPath is required to resume a saved session.");
4395
- const saved = await validateSavedTranscript(broker, cwd, savedPath, requested, "Saved");
4423
+ const saved = await validateSavedTranscript(broker, cwd, savedPath, requested, "Saved", input.sessionIdentity);
4396
4424
  if ("ok" in saved) return saved;
4397
4425
  return {
4398
4426
  id: requested,
@@ -4417,7 +4445,14 @@ async function launchInput(
4417
4445
  const sourceSessionPath = text(input.sourceSessionPath) ?? text(input.sourcePath) ?? text(input.sessionPath);
4418
4446
  if (!sourceSessionId && !sourceSessionPath)
4419
4447
  return fail("invalid_input", "sourceSessionId or sourceSessionPath is required to fork a session.");
4420
- const source = await validateSavedTranscript(broker, sourceCwd, sourceSessionPath, sourceSessionId, "Source");
4448
+ const source = await validateSavedTranscript(
4449
+ broker,
4450
+ sourceCwd,
4451
+ sourceSessionPath,
4452
+ sourceSessionId,
4453
+ "Source",
4454
+ input.sourceSessionIdentity,
4455
+ );
4421
4456
  if ("ok" in source) return source;
4422
4457
  return {
4423
4458
  id: randomUUID(),
@@ -4797,7 +4832,10 @@ type CloseRecord = {
4797
4832
  processIncarnation?: string;
4798
4833
  };
4799
4834
 
4800
- function endpointIncarnation(record: CloseRecord, sessionId: string): string | undefined {
4835
+ function endpointIncarnation(
4836
+ record: Pick<CloseRecord, "endpointGeneration" | "endpointMtimeMs" | "pid">,
4837
+ sessionId: string,
4838
+ ): string | undefined {
4801
4839
  if (
4802
4840
  !Number.isSafeInteger(record.endpointGeneration) ||
4803
4841
  record.endpointGeneration <= 0 ||
@@ -4996,6 +5034,7 @@ async function executeLifecycleResponse(
4996
5034
  sessionId: requestedSessionId,
4997
5035
  cwd: finalScope.cwd,
4998
5036
  endpointGeneration: current.endpointGeneration,
5037
+ endpointIncarnation: initialIncarnation,
4999
5038
  pid: current.pid,
5000
5039
  endpointMtimeMs: current.endpointMtimeMs,
5001
5040
  endpoint: endpoint.result,
@@ -5470,12 +5509,23 @@ async function executeLifecycleResponse(
5470
5509
  "Session readiness authority changed and its spawned process could not be verified dead.",
5471
5510
  );
5472
5511
  }
5512
+ if (!spawnedAuthority) return fail("endpoint_stale", "Session endpoint authority is unavailable.");
5513
+ const replayIncarnation = endpointIncarnation(
5514
+ {
5515
+ endpointGeneration: verified.endpointGeneration,
5516
+ endpointMtimeMs: verified.endpointMtimeMs,
5517
+ pid: spawnedAuthority.pid,
5518
+ },
5519
+ launch.id,
5520
+ );
5521
+ if (!replayIncarnation) return fail("endpoint_stale", "Session endpoint incarnation is unavailable.");
5473
5522
  return {
5474
5523
  ok: true,
5475
5524
  result: {
5476
5525
  sessionId: launch.id,
5477
5526
  cwd: launch.cwd,
5478
5527
  endpointGeneration: verified.endpointGeneration,
5528
+ endpointIncarnation: replayIncarnation,
5479
5529
  pid: verified.endpoint.pid,
5480
5530
  endpointMtimeMs: verified.endpointMtimeMs,
5481
5531
  endpoint: verified.endpoint,