@gajae-code/coding-agent 0.18.10 → 0.18.12

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 (270) hide show
  1. package/CHANGELOG.md +295 -0
  2. package/dist/types/async/job-manager.d.ts +7 -3
  3. package/dist/types/async/result-formatting.d.ts +2 -0
  4. package/dist/types/autoresearch/git.d.ts +1 -1
  5. package/dist/types/capability/fs.d.ts +2 -2
  6. package/dist/types/cli/accounts-cli.d.ts +2 -2
  7. package/dist/types/cli/doctor/install-repairs.d.ts +4 -1
  8. package/dist/types/cli/install-activation.d.ts +10 -4
  9. package/dist/types/cli/public-command-errors.d.ts +1 -0
  10. package/dist/types/cli/update-cli.d.ts +14 -1
  11. package/dist/types/cli-ordinary.d.ts +2 -2
  12. package/dist/types/commands/accounts.d.ts +2 -1
  13. package/dist/types/commands/migrate.d.ts +1 -1
  14. package/dist/types/commands/sdk.d.ts +5 -0
  15. package/dist/types/commit/changelog/index.d.ts +1 -0
  16. package/dist/types/config/autorouting-tier-map.d.ts +30 -0
  17. package/dist/types/config/config-hot-reload-trust.d.ts +12 -0
  18. package/dist/types/config/config-hot-reload.d.ts +39 -0
  19. package/dist/types/config/file-lock.d.ts +8 -1
  20. package/dist/types/config/model-discovery-manager.d.ts +2 -0
  21. package/dist/types/config/model-profile-activation.d.ts +62 -6
  22. package/dist/types/config/model-registry.d.ts +56 -5
  23. package/dist/types/config/model-resolver.d.ts +2 -0
  24. package/dist/types/config/models-config-schema.d.ts +10 -0
  25. package/dist/types/config/settings-schema.d.ts +46 -3
  26. package/dist/types/config/settings.d.ts +33 -1
  27. package/dist/types/dap/config.d.ts +8 -4
  28. package/dist/types/debug/system-info.d.ts +6 -1
  29. package/dist/types/discovery/mcp-compat.d.ts +3 -1
  30. package/dist/types/edit/modes/patch.d.ts +5 -0
  31. package/dist/types/edit/modes/replace.d.ts +3 -0
  32. package/dist/types/exec/bash-executor.d.ts +12 -0
  33. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +3 -0
  34. package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
  35. package/dist/types/gjc-runtime/state-runtime.d.ts +0 -2
  36. package/dist/types/migrate/adapters/codex.d.ts +2 -1
  37. package/dist/types/migrate/adapters/index.d.ts +10 -1
  38. package/dist/types/migrate/executor.d.ts +3 -1
  39. package/dist/types/migrate/types.d.ts +15 -3
  40. package/dist/types/modes/components/settings-selector.d.ts +6 -0
  41. package/dist/types/modes/controllers/selector-controller.d.ts +2 -0
  42. package/dist/types/modes/theme/theme.d.ts +1 -0
  43. package/dist/types/runtime-mcp/content-limits.d.ts +4 -0
  44. package/dist/types/runtime-mcp/manager.d.ts +7 -0
  45. package/dist/types/runtime-mcp/plugin-mcp-name-filter.d.ts +22 -0
  46. package/dist/types/runtime-mcp/tool-bridge.d.ts +4 -0
  47. package/dist/types/sdk/broker/broker-exit.d.ts +2 -2
  48. package/dist/types/sdk/broker/broker.d.ts +7 -1
  49. package/dist/types/sdk/broker/daemon-entry.d.ts +11 -1
  50. package/dist/types/sdk/broker/doctor-restart.d.ts +1 -1
  51. package/dist/types/sdk/broker/ensure.d.ts +136 -7
  52. package/dist/types/sdk/broker/hop.d.ts +36 -0
  53. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +18 -10
  54. package/dist/types/sdk/broker/lock-host-identity.d.ts +5 -0
  55. package/dist/types/sdk/broker/session-index.d.ts +4 -1
  56. package/dist/types/sdk/bus/chat-command-policy.d.ts +2 -1
  57. package/dist/types/sdk/bus/index.d.ts +23 -0
  58. package/dist/types/sdk/bus/reconciliation-store.d.ts +10 -0
  59. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +3 -1
  60. package/dist/types/sdk/cli/session-cli.d.ts +0 -1
  61. package/dist/types/sdk/host/session-runtime.d.ts +32 -0
  62. package/dist/types/sdk/prompt-deadline-flush.d.ts +42 -4
  63. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -0
  64. package/dist/types/sdk/protocol/adapter-validation.d.ts +10 -4
  65. package/dist/types/sdk/reconciliation-extensions.d.ts +1 -1
  66. package/dist/types/sdk/session-list.d.ts +1 -1
  67. package/dist/types/session/agent-session.d.ts +58 -2
  68. package/dist/types/session/internal/managed-session-scope.d.ts +2 -2
  69. package/dist/types/session/internal/managed-session-storage.d.ts +1 -1
  70. package/dist/types/session/internal/native-publish-outcome.d.ts +1 -1
  71. package/dist/types/session/messages.d.ts +5 -1
  72. package/dist/types/session/session-manager.d.ts +13 -3
  73. package/dist/types/session/startup-auth-config.d.ts +24 -2
  74. package/dist/types/session/streaming-output.d.ts +11 -0
  75. package/dist/types/session/terminal-abort.d.ts +3 -1
  76. package/dist/types/session-import/service.d.ts +1 -1
  77. package/dist/types/skill-state/workflow-mutation-guard.d.ts +7 -3
  78. package/dist/types/slash-commands/helpers/provider-auth-status.d.ts +8 -0
  79. package/dist/types/task/executor.d.ts +19 -2
  80. package/dist/types/tools/ast-edit-write-path.d.ts +10 -0
  81. package/dist/types/tools/atomic-file-write.d.ts +9 -7
  82. package/dist/types/tools/bash-allowed-prefixes.d.ts +11 -0
  83. package/dist/types/tools/descriptors.d.ts +2 -0
  84. package/dist/types/tools/fetch.d.ts +1 -1
  85. package/dist/types/tools/index.d.ts +2 -0
  86. package/dist/types/tools/python.d.ts +1 -1
  87. package/dist/types/tools/report-issue.d.ts +41 -0
  88. package/dist/types/tools/subagent.d.ts +23 -0
  89. package/dist/types/web/scrapers/types.d.ts +8 -1
  90. package/dist/types/web/search/providers/insane.d.ts +7 -1
  91. package/dist/types/web/search/providers/searxng.d.ts +5 -0
  92. package/package.json +8 -8
  93. package/scripts/build-binary.ts +2 -1
  94. package/scripts/capture-subagent-compact-panel-showcase.ts +105 -0
  95. package/scripts/generate-docs-index.ts +8 -1
  96. package/scripts/generate-sdk-operation-inventory.ts +22 -4
  97. package/src/async/job-manager.ts +356 -56
  98. package/src/async/result-formatting.ts +40 -0
  99. package/src/autoresearch/git.ts +9 -2
  100. package/src/autoresearch/prompts/prompt-setup.md +1 -1
  101. package/src/capability/fs.ts +27 -19
  102. package/src/capability/index.ts +2 -2
  103. package/src/cli/accounts-cli.ts +176 -8
  104. package/src/cli/auth-broker-cli.ts +21 -11
  105. package/src/cli/doctor/install-repairs.ts +35 -5
  106. package/src/cli/fast-help.ts +1 -1
  107. package/src/cli/install-activation.ts +47 -4
  108. package/src/cli/migrate-cli.ts +7 -5
  109. package/src/cli/public-command-errors.ts +4 -0
  110. package/src/cli/root-flags.ts +2 -2
  111. package/src/cli/stats-cli.ts +9 -5
  112. package/src/cli/update-cli.ts +224 -7
  113. package/src/cli-ordinary.ts +7 -2
  114. package/src/commands/accounts.ts +10 -5
  115. package/src/commands/migrate.ts +3 -3
  116. package/src/commands/sdk.ts +199 -42
  117. package/src/commands/ultragoal.ts +16 -8
  118. package/src/commit/changelog/index.ts +39 -1
  119. package/src/commit/message.ts +10 -3
  120. package/src/config/autorouting-tier-map.ts +30 -0
  121. package/src/config/config-hot-reload-trust.ts +148 -0
  122. package/src/config/config-hot-reload.ts +734 -0
  123. package/src/config/file-lock.ts +216 -56
  124. package/src/config/model-bindings-applier.ts +85 -47
  125. package/src/config/model-discovery-manager.ts +10 -0
  126. package/src/config/model-profile-activation.ts +716 -113
  127. package/src/config/model-registry.ts +1334 -158
  128. package/src/config/model-resolver.ts +3 -1
  129. package/src/config/models-config-schema.ts +6 -0
  130. package/src/config/settings-schema.ts +60 -3
  131. package/src/config/settings.ts +458 -24
  132. package/src/coordinator-mcp/policy.ts +26 -1
  133. package/src/crash/sanitize.ts +4 -0
  134. package/src/customization/import.ts +3 -1
  135. package/src/dap/config.ts +31 -11
  136. package/src/dap/defaults.json +1 -1
  137. package/src/debug/crash-diagnostics.ts +2 -1
  138. package/src/debug/report-bundle.ts +19 -0
  139. package/src/debug/system-info.ts +8 -3
  140. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +21 -7
  141. package/src/discovery/agents-md.ts +1 -1
  142. package/src/discovery/helpers.ts +1 -2
  143. package/src/discovery/mcp-compat.ts +120 -17
  144. package/src/edit/apply-patch/index.ts +12 -1
  145. package/src/edit/index.ts +13 -17
  146. package/src/edit/modes/patch.ts +187 -8
  147. package/src/edit/modes/replace.ts +136 -0
  148. package/src/eval/js/context-manager.ts +29 -3
  149. package/src/eval/js/worker-entry.ts +51 -18
  150. package/src/eval/py/executor.ts +14 -4
  151. package/src/eval/py/runner.py +144 -5
  152. package/src/exec/bash-executor.ts +81 -4
  153. package/src/exec/bash-shell-guardian.ts +4 -1
  154. package/src/exec/bash-shell-supervisor.ts +8 -4
  155. package/src/exec/isolated-shell.ts +5 -1
  156. package/src/export/html/index.ts +46 -6
  157. package/src/extensibility/custom-tools/wrapper.ts +3 -1
  158. package/src/extensibility/extensions/loader.ts +8 -1
  159. package/src/extensibility/plugins/marketplace/fetcher.ts +2 -2
  160. package/src/gjc-runtime/gc-runtime.ts +33 -6
  161. package/src/gjc-runtime/launch-tmux.ts +7 -2
  162. package/src/gjc-runtime/launch-worktree.test.ts +148 -0
  163. package/src/gjc-runtime/launch-worktree.ts +101 -14
  164. package/src/gjc-runtime/managed-owner-admission.ts +46 -2
  165. package/src/gjc-runtime/session-resolution.ts +38 -6
  166. package/src/gjc-runtime/session-state-lock.ts +59 -14
  167. package/src/gjc-runtime/state-runtime.ts +23 -19
  168. package/src/hooks/native-skill-hook.ts +14 -11
  169. package/src/internal-urls/docs-index.generated.ts +14 -13
  170. package/src/internal-urls/local-protocol.ts +142 -8
  171. package/src/lsp/index.ts +29 -0
  172. package/src/lsp/workspace-path.ts +23 -0
  173. package/src/migrate/action-planner.ts +57 -0
  174. package/src/migrate/adapters/claude-code.ts +23 -4
  175. package/src/migrate/adapters/codex.ts +22 -4
  176. package/src/migrate/adapters/index.ts +22 -4
  177. package/src/migrate/adapters/opencode.ts +6 -1
  178. package/src/migrate/executor.ts +84 -7
  179. package/src/migrate/report.ts +19 -2
  180. package/src/migrate/types.ts +14 -4
  181. package/src/modes/DESIGN.md +51 -0
  182. package/src/modes/acp/permission-mode.ts +18 -1
  183. package/src/modes/components/assistant-message.ts +55 -1
  184. package/src/modes/components/settings-selector.ts +103 -37
  185. package/src/modes/controllers/command-controller.ts +38 -2
  186. package/src/modes/controllers/plan-mode-controller.ts +17 -7
  187. package/src/modes/controllers/selector-controller.ts +62 -6
  188. package/src/modes/interactive-mode.ts +68 -0
  189. package/src/modes/theme/theme.ts +15 -0
  190. package/src/prompts/tools/debug.md +1 -1
  191. package/src/prompts/tools/read.md +1 -1
  192. package/src/prompts/tools/report-issue.md +13 -0
  193. package/src/prompts/tools/task.md +1 -1
  194. package/src/runtime-credential-selector.ts +2 -2
  195. package/src/runtime-mcp/content-limits.ts +7 -0
  196. package/src/runtime-mcp/manager-pool.test.ts +5 -4
  197. package/src/runtime-mcp/manager.ts +32 -0
  198. package/src/runtime-mcp/oauth-flow.ts +32 -6
  199. package/src/runtime-mcp/plugin-mcp-name-filter.ts +62 -0
  200. package/src/runtime-mcp/render.ts +16 -1
  201. package/src/runtime-mcp/tool-bridge.ts +126 -17
  202. package/src/sdk/broker/broker-exit.ts +8 -5
  203. package/src/sdk/broker/broker.ts +342 -82
  204. package/src/sdk/broker/daemon-entry.ts +338 -116
  205. package/src/sdk/broker/doctor-restart.ts +2 -1
  206. package/src/sdk/broker/ensure.ts +1143 -265
  207. package/src/sdk/broker/hop.ts +274 -0
  208. package/src/sdk/broker/lifecycle-ledger.ts +35 -11
  209. package/src/sdk/broker/lock-host-identity.ts +29 -0
  210. package/src/sdk/broker/session-index.ts +62 -12
  211. package/src/sdk/bus/chat-command-policy.ts +16 -3
  212. package/src/sdk/bus/index.ts +52 -21
  213. package/src/sdk/bus/reconciliation-store.ts +201 -8
  214. package/src/sdk/bus/telegram-daemon-contract.ts +3 -1
  215. package/src/sdk/cli/session-cli.ts +21 -6
  216. package/src/sdk/host/session-runtime.test.ts +118 -23
  217. package/src/sdk/host/session-runtime.ts +352 -47
  218. package/src/sdk/lifecycle/service.ts +1 -0
  219. package/src/sdk/prompt-deadline-flush.ts +78 -5
  220. package/src/sdk/prompt-deadline-manager.ts +68 -14
  221. package/src/sdk/protocol/adapter-validation.ts +53 -11
  222. package/src/sdk/protocol/operation-inventory.generated.json +101 -2
  223. package/src/sdk/reconciliation-extensions.ts +1 -0
  224. package/src/sdk/session-list.ts +1 -0
  225. package/src/sdk/session.ts +114 -61
  226. package/src/session/agent-session.ts +1043 -81
  227. package/src/session/internal/managed-session-scope.ts +126 -39
  228. package/src/session/internal/managed-session-storage.ts +14 -11
  229. package/src/session/internal/native-publish-outcome.ts +5 -1
  230. package/src/session/messages.ts +47 -6
  231. package/src/session/session-manager.ts +236 -14
  232. package/src/session/startup-auth-config.ts +116 -8
  233. package/src/session/streaming-output.ts +262 -4
  234. package/src/session/terminal-abort.ts +14 -2
  235. package/src/session-import/service.ts +1 -0
  236. package/src/skill-state/workflow-mutation-guard.ts +491 -32
  237. package/src/slash-commands/builtin-registry.ts +15 -3
  238. package/src/slash-commands/helpers/credential-switch.ts +10 -5
  239. package/src/slash-commands/helpers/provider-auth-status.ts +456 -0
  240. package/src/task/executor.ts +103 -12
  241. package/src/task/index.ts +389 -175
  242. package/src/tools/ast-edit-write-path.ts +64 -0
  243. package/src/tools/ast-edit.ts +18 -1
  244. package/src/tools/atomic-file-write.ts +46 -15
  245. package/src/tools/auto-generated-guard.ts +3 -0
  246. package/src/tools/bash-allowed-prefixes.ts +153 -5
  247. package/src/tools/bash-interactive.ts +14 -2
  248. package/src/tools/bash.ts +14 -0
  249. package/src/tools/debug.ts +7 -6
  250. package/src/tools/descriptors.test.ts +44 -0
  251. package/src/tools/descriptors.ts +34 -2
  252. package/src/tools/fetch.ts +3 -2
  253. package/src/tools/index.ts +2 -0
  254. package/src/tools/output-meta.ts +23 -2
  255. package/src/tools/python.ts +1 -1
  256. package/src/tools/read.ts +17 -35
  257. package/src/tools/report-issue.ts +184 -0
  258. package/src/tools/subagent-render.ts +114 -12
  259. package/src/tools/subagent.ts +108 -1
  260. package/src/tools/todo-write.ts +41 -3
  261. package/src/tools/tool-catalog.generated.ts +31 -3
  262. package/src/tools/write.ts +35 -3
  263. package/src/utils/commit-message-generator.ts +9 -2
  264. package/src/utils/file-mentions.ts +29 -1
  265. package/src/utils/title-generator.ts +7 -2
  266. package/src/web/scrapers/index.ts +3 -1
  267. package/src/web/scrapers/types.ts +8 -0
  268. package/src/web/scrapers/youtube.ts +3 -2
  269. package/src/web/search/providers/insane.ts +91 -28
  270. package/src/web/search/providers/searxng.ts +62 -10
@@ -3,10 +3,15 @@ import type { BigIntStats } from "node:fs";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as os from "node:os";
5
5
  import path from "node:path";
6
- import type { NativeBrokerRestartIntent, NativeDirectoryTreeSnapshot } from "@gajae-code/natives";
6
+ import {
7
+ exactRemoveDirectoryTree,
8
+ type NativeBrokerRestartIntent,
9
+ type NativeDirectoryTreeSnapshot,
10
+ snapshotDirectoryTree,
11
+ } from "@gajae-code/natives";
7
12
  import { logger, resolveEquivalentPath } from "@gajae-code/utils";
8
13
  import packageJson from "../../../package.json" with { type: "json" };
9
- import { FileLockAcquireError } from "../../config/file-lock";
14
+ import { FileLockAcquireError, withFileLock } from "../../config/file-lock";
10
15
  import type { ModelProfileErrorDetails } from "../../config/model-profile-contract";
11
16
  import { planLaunchWorktree } from "../../gjc-runtime/launch-worktree";
12
17
  import { readExistingStateForMutation, withWorkflowStateLock } from "../../gjc-runtime/state-writer";
@@ -80,6 +85,7 @@ import {
80
85
  type LifecycleState,
81
86
  type TerminalReadBack,
82
87
  } from "./lifecycle-ledger";
88
+ import { brokerLockHostIdentity } from "./lock-host-identity";
83
89
  import {
84
90
  cancelManagedTasks,
85
91
  createManagedDomainBinding,
@@ -111,8 +117,15 @@ import {
111
117
  } from "./managed-task-dag";
112
118
  import { reconcileRunningManagedVerification, verifyManagedTaskAttempt } from "./managed-task-verification";
113
119
  import { createMasterCapabilityVerifier, readEndpoint } from "./master-capability";
120
+ import { isProcessIncarnation } from "./process-incarnation";
114
121
  import { sdkInternalRuntimeImage } from "./runtime";
115
- import { type IndexedSession, isSessionAuthorityEligible, SessionIndex, type SessionList } from "./session-index";
122
+ import {
123
+ type IndexedSession,
124
+ isSessionAuthorityEligible,
125
+ SessionIndex,
126
+ type SessionList,
127
+ sessionIndexStartupLockOptions,
128
+ } from "./session-index";
116
129
  import {
117
130
  type ResolvedScopeV1,
118
131
  resolveScopeRequest,
@@ -1088,6 +1101,13 @@ function sameSessionControlAuthority(
1088
1101
  );
1089
1102
  }
1090
1103
 
1104
+ const SESSION_TARGETED_LIFECYCLE_OPERATIONS: ReadonlySet<string> = new Set([
1105
+ "session.close",
1106
+ "session.resume",
1107
+ "session.delete",
1108
+ "session.reconcile_uncertain",
1109
+ ]);
1110
+
1091
1111
  function lifecycleTarget(operation: string, input: Record<string, unknown>): unknown {
1092
1112
  const target = input.target as Record<string, unknown> | undefined;
1093
1113
  const string = (...values: unknown[]): string | undefined =>
@@ -1163,10 +1183,29 @@ const BROKER_LOCK_RETRY_MS = 10;
1163
1183
  type BrokerLockSnapshot = {
1164
1184
  ownerId?: string;
1165
1185
  pid: number;
1186
+ incarnation?: string;
1187
+ ownerHostId?: string;
1166
1188
  identity: string;
1167
1189
  lockIdentity: string;
1168
1190
  };
1169
1191
 
1192
+ function isBrokerLockOwnerAlive(
1193
+ snapshot: Pick<BrokerLockSnapshot, "pid" | "incarnation" | "ownerHostId">,
1194
+ currentHostId: string | undefined,
1195
+ pidAlive: (pid: number) => boolean = isPidAlive,
1196
+ readProcessIncarnation: (pid: number) => string | undefined = brokerProcessIncarnation,
1197
+ ): boolean {
1198
+ if (snapshot.pid <= 0) return false;
1199
+ // Local PID evidence is meaningful only when the owner record identifies this host.
1200
+ // Foreign and legacy host-unqualified owners are never reclaimed from this process table.
1201
+ if (snapshot.ownerHostId === undefined || snapshot.ownerHostId !== currentHostId) return true;
1202
+ if (!pidAlive(snapshot.pid)) return false;
1203
+ // A live PID is retained unless its recorded incarnation proves reuse.
1204
+ if (!snapshot.incarnation) return true;
1205
+ const incarnation = readProcessIncarnation(snapshot.pid);
1206
+ return !incarnation || incarnation === snapshot.incarnation;
1207
+ }
1208
+
1170
1209
  /** Tombstone prefix used by {@link Broker.reclaimStaleLock} when a dead owner's lock is renamed aside. */
1171
1210
  export const BROKER_LOCK_TOMBSTONE_PREFIX = ".broker.lock.stale-";
1172
1211
 
@@ -1214,11 +1253,12 @@ function isBrokerLockArtifactName(name: string): boolean {
1214
1253
  * Decide whether one candidate directory is provably abandoned.
1215
1254
  *
1216
1255
  * Fail-closed by construction: every branch that cannot prove abandonment
1217
- * returns a retention reason. A tombstone with an owner record is only
1218
- * abandoned when that record names a dead PID. A missing record is abandoned
1219
- * only when the aged tombstone is empty; non-empty tombstones remain retained
1220
- * as ambiguous evidence. Backup directories carry no owner contract, so an
1221
- * absent record there is not ambiguity and age alone governs.
1256
+ * returns a retention reason. A tombstone with an owner record is abandoned
1257
+ * only when its PID is dead or validated incarnation evidence proves PID reuse;
1258
+ * missing or unavailable evidence remains retained. A missing record is
1259
+ * abandoned only when the aged tombstone is empty; non-empty tombstones remain
1260
+ * retained as ambiguous evidence. Backup directories carry no owner contract,
1261
+ * so an absent record there is not ambiguity and age alone governs.
1222
1262
  */
1223
1263
  async function classifyBrokerLockArtifact(
1224
1264
  directory: string,
@@ -1226,6 +1266,8 @@ async function classifyBrokerLockArtifact(
1226
1266
  now: number,
1227
1267
  graceMs: number,
1228
1268
  pidAlive: (pid: number) => boolean,
1269
+ readProcessIncarnation: (pid: number) => string | undefined,
1270
+ currentHostId: string | undefined,
1229
1271
  ): Promise<BrokerLockArtifactRetentionReason | "abandoned"> {
1230
1272
  const target = path.join(directory, name);
1231
1273
  // lstat, never stat: a symlink pointing at live state must never be followed
@@ -1248,14 +1290,26 @@ async function classifyBrokerLockArtifact(
1248
1290
  return "owner-record-unreadable";
1249
1291
  }
1250
1292
  }
1251
- let pid: unknown;
1293
+ let owner: { pid?: unknown; incarnation?: unknown; ownerHostId?: unknown };
1252
1294
  try {
1253
- pid = (JSON.parse(raw) as { pid?: unknown }).pid;
1295
+ owner = JSON.parse(raw) as { pid?: unknown; incarnation?: unknown; ownerHostId?: unknown };
1254
1296
  } catch {
1255
1297
  return "owner-record-unreadable";
1256
1298
  }
1257
- if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return "owner-record-unreadable";
1258
- return pidAlive(pid) ? "owner-alive" : "abandoned";
1299
+ if (typeof owner.pid !== "number" || !Number.isInteger(owner.pid) || owner.pid <= 0)
1300
+ return "owner-record-unreadable";
1301
+ return isBrokerLockOwnerAlive(
1302
+ {
1303
+ pid: owner.pid,
1304
+ incarnation: isProcessIncarnation(owner.incarnation) ? owner.incarnation : undefined,
1305
+ ownerHostId: typeof owner.ownerHostId === "string" ? owner.ownerHostId : undefined,
1306
+ },
1307
+ currentHostId,
1308
+ pidAlive,
1309
+ readProcessIncarnation,
1310
+ )
1311
+ ? "owner-alive"
1312
+ : "abandoned";
1259
1313
  }
1260
1314
 
1261
1315
  /**
@@ -1274,11 +1328,18 @@ export async function reapStaleBrokerLockArtifacts(input: {
1274
1328
  now?: number;
1275
1329
  graceMs?: number;
1276
1330
  pidAlive?: (pid: number) => boolean;
1331
+ processIncarnation?: (pid: number) => string | undefined;
1277
1332
  }): Promise<BrokerLockArtifactReapResult> {
1278
1333
  const directory = path.join(input.agentDir, "sdk");
1279
1334
  const now = input.now ?? Date.now();
1280
1335
  const graceMs = input.graceMs ?? BROKER_LOCK_ARTIFACT_GRACE_MS;
1281
1336
  const pidAlive = input.pidAlive ?? isPidAlive;
1337
+ const readProcessIncarnation = input.processIncarnation ?? brokerProcessIncarnation;
1338
+ // Without this host's identity no host-qualified owner can be matched, so all of them are retained.
1339
+ const currentHostId = await brokerLockHostIdentity(input.agentDir).then(
1340
+ identity => identity.ownerHostId,
1341
+ () => undefined,
1342
+ );
1282
1343
  const removed: string[] = [];
1283
1344
  const retained: BrokerLockArtifactRetention[] = [];
1284
1345
  const retainedByReason = new Map<BrokerLockArtifactRetentionReason, number>();
@@ -1295,7 +1356,15 @@ export async function reapStaleBrokerLockArtifacts(input: {
1295
1356
  const target = path.join(directory, name);
1296
1357
  let verdict: BrokerLockArtifactRetentionReason | "abandoned";
1297
1358
  try {
1298
- verdict = await classifyBrokerLockArtifact(directory, name, now, graceMs, pidAlive);
1359
+ verdict = await classifyBrokerLockArtifact(
1360
+ directory,
1361
+ name,
1362
+ now,
1363
+ graceMs,
1364
+ pidAlive,
1365
+ readProcessIncarnation,
1366
+ currentHostId,
1367
+ );
1299
1368
  } catch (error) {
1300
1369
  // A vanished candidate needs no decision; anything else is ambiguous.
1301
1370
  if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
@@ -1528,6 +1597,10 @@ const terminalPersistenceHooksForTest = new WeakMap<Broker, () => void>();
1528
1597
  const ambiguityGraceOverridesForTest = new WeakMap<Broker, number>();
1529
1598
  const publicationObservationOverridesForTest = new WeakMap<Broker, BrokerPublicationObservation>();
1530
1599
  const lockArtifactGraceOverridesForTest = new WeakMap<Broker, number>();
1600
+ const brokerLockReclaimHooksForTest = new WeakMap<
1601
+ Broker,
1602
+ { beforeQuarantine?: () => Promise<void>; onContended?: () => void }
1603
+ >();
1531
1604
  const livenessGraceOverridesForTest = new WeakMap<Broker, number>();
1532
1605
  const heartbeatStallOverridesForTest = new WeakMap<Broker, PromiseWithResolvers<void>>();
1533
1606
  const managedCloseWaitOverridesForTest = new WeakMap<Broker, number>();
@@ -1539,6 +1612,8 @@ export class Broker {
1539
1612
  discovery: BrokerDiscovery | null = null;
1540
1613
  #lock: string;
1541
1614
  #owner = randomBytes(12).toString("hex");
1615
+ #ownedLockIdentity: string | undefined;
1616
+ #ownedLockIncarnation: string | undefined;
1542
1617
  /** Public publication-incarnation id, fixed at startup; never the endpoint generation. */
1543
1618
  #diagnosticGeneration = randomBytes(16).toString("hex");
1544
1619
  /** Trusted build snapshot captured at startup; unknown buildId stays null. */
@@ -3663,7 +3738,12 @@ export class Broker {
3663
3738
  }
3664
3739
  async #lockSnapshot(raw: string, lockIdentity: string): Promise<BrokerLockSnapshot> {
3665
3740
  try {
3666
- const lock = JSON.parse(raw) as { ownerId?: unknown; pid?: unknown };
3741
+ const lock = JSON.parse(raw) as {
3742
+ ownerId?: unknown;
3743
+ pid?: unknown;
3744
+ incarnation?: unknown;
3745
+ ownerHostId?: unknown;
3746
+ };
3667
3747
  if (
3668
3748
  typeof lock.ownerId === "string" &&
3669
3749
  lock.ownerId.length > 0 &&
@@ -3671,7 +3751,14 @@ export class Broker {
3671
3751
  Number.isInteger(lock.pid) &&
3672
3752
  lock.pid > 0
3673
3753
  )
3674
- return { ownerId: lock.ownerId, pid: lock.pid, identity: `owner:${lock.ownerId}`, lockIdentity };
3754
+ return {
3755
+ ownerId: lock.ownerId,
3756
+ pid: lock.pid,
3757
+ incarnation: isProcessIncarnation(lock.incarnation) ? lock.incarnation : undefined,
3758
+ ownerHostId: typeof lock.ownerHostId === "string" ? lock.ownerHostId : undefined,
3759
+ identity: `owner:${lock.ownerId}`,
3760
+ lockIdentity,
3761
+ };
3675
3762
  } catch {}
3676
3763
  return { pid: 0, identity: `contents:${createHash("sha256").update(raw).digest("hex")}`, lockIdentity };
3677
3764
  }
@@ -3703,35 +3790,103 @@ export class Broker {
3703
3790
  }
3704
3791
  return this.#lockSnapshot(raw, lockIdentity);
3705
3792
  }
3706
- async #createLock(): Promise<void> {
3793
+ async #withLockReclaimGuard<T>(operation: () => Promise<T>): Promise<T> {
3794
+ const hostIdentity = await brokerLockHostIdentity(this.settings.agentDir);
3795
+ return withFileLock(`${this.#lock}.reclaim`, operation, {
3796
+ ...hostIdentity,
3797
+ onContended: () => brokerLockReclaimHooksForTest.get(this)?.onContended?.(),
3798
+ });
3799
+ }
3800
+ async #hasCanonicalLockOwnership(): Promise<boolean> {
3801
+ if (!this.#ownedLockIdentity || !this.#ownedLockIncarnation) return false;
3802
+ try {
3803
+ const current = await this.#readLock();
3804
+ return (
3805
+ current?.identity === `owner:${this.#owner}` &&
3806
+ current.lockIdentity === this.#ownedLockIdentity &&
3807
+ current.pid === process.pid &&
3808
+ current.incarnation === this.#ownedLockIncarnation
3809
+ );
3810
+ } catch {
3811
+ return false;
3812
+ }
3813
+ }
3814
+ #removeCreatedLockGeneration(
3815
+ lockIdentity: { dev: string; ino: string },
3816
+ parentIdentity: { dev: bigint; ino: bigint },
3817
+ ownerHash?: string,
3818
+ ): void {
3819
+ const captured = snapshotDirectoryTree(this.#lock);
3820
+ const snapshot = captured.snapshot;
3821
+ const root = snapshot?.entries.find(entry => entry.relativePath === "");
3822
+ if (
3823
+ !captured.ok ||
3824
+ !snapshot ||
3825
+ root?.kind !== "directory" ||
3826
+ snapshot.rootDev !== lockIdentity.dev ||
3827
+ snapshot.rootIno !== lockIdentity.ino
3828
+ )
3829
+ return;
3830
+
3831
+ const owner = snapshot.entries.find(entry => entry.relativePath === BROKER_LOCK_RECORD);
3832
+ const exactOwner =
3833
+ ownerHash !== undefined &&
3834
+ snapshot.entries.length === 2 &&
3835
+ owner?.kind === "file" &&
3836
+ owner.sha256 === ownerHash;
3837
+ if (!exactOwner && snapshot.entries.length !== 1) return;
3838
+
3839
+ exactRemoveDirectoryTree(this.#lock, snapshot, parentIdentity);
3840
+ }
3841
+ async #createLockInsideGuard(): Promise<void> {
3842
+ const rootDigest = await canonicalServiceRootDigest(this.settings.agentDir);
3843
+ const hostIdentity = await brokerLockHostIdentity(this.settings.agentDir);
3844
+ const parent = await fs.lstat(path.dirname(this.#lock), { bigint: true });
3707
3845
  await fs.mkdir(this.#lock, { mode: 0o700 });
3846
+ let createdLockIdentity: { dev: string; ino: string } | undefined;
3847
+ let createdOwnerHash: string | undefined;
3708
3848
  try {
3849
+ const lockStat = await fs.lstat(this.#lock, { bigint: true });
3850
+ if (!lockStat.isDirectory()) throw new Error("SDK broker lock is not a directory.");
3851
+ createdLockIdentity = { dev: String(lockStat.dev), ino: String(lockStat.ino) };
3709
3852
  const incarnation = brokerProcessIncarnation(process.pid);
3710
- // A minimal, non-secret receipt delta for D9 stale-artifact detach
3711
- // (sequenced follow-up): the OS incarnation and a digest of the exact
3712
- // resolved root this lock authorizes, so a later positive-death check can
3713
- // cross-bind this owner record against the discovery record it authored
3714
- // without re-deriving anything from file content or adding new identity.
3715
- const rootDigest = await canonicalServiceRootDigest(this.settings.agentDir);
3716
- await fs.writeFile(
3717
- this.#lockRecordPath(),
3718
- JSON.stringify({
3719
- version: 1,
3720
- ownerId: this.#owner,
3721
- pid: process.pid,
3722
- acquiredAt: Date.now(),
3723
- ...(incarnation ? { incarnation } : {}),
3724
- rootDigest,
3725
- }),
3726
- { flag: "wx", mode: 0o600 },
3727
- );
3728
- } catch (e) {
3853
+ if (!incarnation) throw new Error("Broker process incarnation is unavailable.");
3854
+ const ownerRecord = JSON.stringify({
3855
+ version: 1,
3856
+ ownerId: this.#owner,
3857
+ pid: process.pid,
3858
+ acquiredAt: Date.now(),
3859
+ incarnation,
3860
+ ownerHostId: hostIdentity.ownerHostId,
3861
+ rootDigest,
3862
+ });
3863
+ createdOwnerHash = createHash("sha256").update(ownerRecord).digest("hex");
3864
+ await fs.writeFile(this.#lockRecordPath(), ownerRecord, { flag: "wx", mode: 0o600 });
3865
+ const current = await this.#readLock();
3866
+ if (
3867
+ !current ||
3868
+ current.ownerId !== this.#owner ||
3869
+ current.pid !== process.pid ||
3870
+ current.incarnation !== incarnation
3871
+ )
3872
+ throw new Error("SDK broker lock ownership changed during startup.");
3873
+ this.#ownedLockIdentity = current.lockIdentity;
3874
+ this.#ownedLockIncarnation = incarnation;
3875
+ } catch (error) {
3729
3876
  try {
3730
- await fs.rmdir(this.#lock);
3877
+ if (createdLockIdentity)
3878
+ this.#removeCreatedLockGeneration(
3879
+ createdLockIdentity,
3880
+ { dev: parent.dev, ino: parent.ino },
3881
+ createdOwnerHash,
3882
+ );
3731
3883
  } catch {}
3732
- throw e;
3884
+ throw error;
3733
3885
  }
3734
3886
  }
3887
+ async #createLock(): Promise<void> {
3888
+ await this.#withLockReclaimGuard(() => this.#createLockInsideGuard());
3889
+ }
3735
3890
  async #waitForBrokerDiscovery(): Promise<BrokerDiscovery | null> {
3736
3891
  const deadline = Date.now() + BROKER_LOCK_STARTUP_WAIT_MS;
3737
3892
  while (Date.now() < deadline) {
@@ -3741,50 +3896,63 @@ export class Broker {
3741
3896
  }
3742
3897
  return readBrokerDiscovery(this.settings.agentDir, this.settings.heartbeatTtlMs);
3743
3898
  }
3744
- async #reclaimStaleLock(snapshot: BrokerLockSnapshot): Promise<void> {
3745
- const current = await this.#readLock();
3746
- if (
3747
- !current ||
3748
- current.identity !== snapshot.identity ||
3749
- current.lockIdentity !== snapshot.lockIdentity ||
3750
- (current.pid > 0 && isPidAlive(current.pid))
3751
- )
3752
- return;
3753
-
3754
- // Give every reclaimed generation a fresh quarantine name. Older brokers
3755
- // used only the lock inode hash; if that tombstone survived a crash, the
3756
- // deterministic name collided forever and left the dead canonical lock in
3757
- // place. The random suffix preserves no-replace rename semantics without
3758
- // letting retained cleanup debris block takeover.
3759
- for (let attempt = 0; attempt < 8; attempt++) {
3760
- const tombstone = path.join(
3761
- path.dirname(this.#lock),
3762
- `.broker.lock.stale-${createHash("sha256").update(snapshot.lockIdentity).digest("hex")}-${randomBytes(8).toString("hex")}`,
3763
- );
3764
- try {
3765
- await fs.rename(this.#lock, tombstone);
3766
- return;
3767
- } catch (e) {
3768
- const code = (e as NodeJS.ErrnoException).code;
3769
- if (code === "EEXIST" || code === "ENOTEMPTY") continue;
3770
- if (["ENOENT", "EISDIR", "ENOTDIR"].includes(code ?? "")) return;
3771
- if (code === "EPERM") {
3772
- try {
3773
- await fs.lstat(tombstone);
3774
- continue;
3775
- } catch (statError) {
3776
- if ((statError as NodeJS.ErrnoException).code !== "ENOENT") throw statError;
3899
+ async #reclaimStaleLock(snapshot: BrokerLockSnapshot): Promise<boolean> {
3900
+ return await this.#withLockReclaimGuard(async () => {
3901
+ const current = await this.#readLock();
3902
+ const hostIdentity = await brokerLockHostIdentity(this.settings.agentDir);
3903
+ if (
3904
+ !current ||
3905
+ current.identity !== snapshot.identity ||
3906
+ current.lockIdentity !== snapshot.lockIdentity ||
3907
+ isBrokerLockOwnerAlive(current, hostIdentity.ownerHostId)
3908
+ )
3909
+ return false;
3910
+ await brokerLockReclaimHooksForTest.get(this)?.beforeQuarantine?.();
3911
+
3912
+ // Keep validation, quarantine, and successor lock publication under one
3913
+ // cross-process fence so a stale snapshot can never move a newer lock.
3914
+ for (let attempt = 0; attempt < 8; attempt++) {
3915
+ const tombstone = path.join(
3916
+ path.dirname(this.#lock),
3917
+ `.broker.lock.stale-${createHash("sha256").update(snapshot.lockIdentity).digest("hex")}-${randomBytes(8).toString("hex")}`,
3918
+ );
3919
+ try {
3920
+ await fs.rename(this.#lock, tombstone);
3921
+ } catch (error) {
3922
+ const code = (error as NodeJS.ErrnoException).code;
3923
+ if (code === "EEXIST" || code === "ENOTEMPTY") continue;
3924
+ if (["ENOENT", "EISDIR", "ENOTDIR"].includes(code ?? "")) return false;
3925
+ if (code === "EPERM") {
3926
+ try {
3927
+ await fs.lstat(tombstone);
3928
+ continue;
3929
+ } catch (statError) {
3930
+ if ((statError as NodeJS.ErrnoException).code !== "ENOENT") throw statError;
3931
+ }
3777
3932
  }
3933
+ throw error;
3934
+ }
3935
+ try {
3936
+ await this.#createLockInsideGuard();
3937
+ return true;
3938
+ } catch (error) {
3939
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
3940
+ throw error;
3778
3941
  }
3779
- throw e;
3780
3942
  }
3781
- }
3782
- throw new Error(`Broker lock quarantine namespace is saturated for ${this.#lock}`);
3943
+ throw new Error(`Broker lock quarantine namespace is saturated for ${this.#lock}`);
3944
+ });
3783
3945
  }
3784
3946
  async #releaseOwnedLock(): Promise<void> {
3785
3947
  try {
3786
3948
  const lock = await this.#readLock();
3787
- if (lock?.ownerId !== this.#owner) return;
3949
+ if (
3950
+ !lock ||
3951
+ lock.ownerId !== this.#owner ||
3952
+ lock.lockIdentity !== this.#ownedLockIdentity ||
3953
+ lock.incarnation !== this.#ownedLockIncarnation
3954
+ )
3955
+ return;
3788
3956
  await fs.unlink(this.#lockRecordPath());
3789
3957
  await fs.rmdir(this.#lock);
3790
3958
  } catch (error) {
@@ -3833,6 +4001,8 @@ export class Broker {
3833
4001
  this.#ambiguousAt = null;
3834
4002
  this.#publishedAt = null;
3835
4003
  this.#startedAt = process.hrtime.bigint();
4004
+ this.#ownedLockIdentity = undefined;
4005
+ this.#ownedLockIncarnation = undefined;
3836
4006
  this.#watchInFlight = false;
3837
4007
  this.#checkpointInFlight = false;
3838
4008
  this.#throwIfStartupAborted();
@@ -3861,7 +4031,8 @@ export class Broker {
3861
4031
  }
3862
4032
  const snapshot = await this.#readLock();
3863
4033
  if (!snapshot) continue;
3864
- if (snapshot.pid > 0 && isPidAlive(snapshot.pid)) {
4034
+ const hostIdentity = await brokerLockHostIdentity(this.settings.agentDir);
4035
+ if (isBrokerLockOwnerAlive(snapshot, hostIdentity.ownerHostId)) {
3865
4036
  const starting = await this.#waitForBrokerDiscovery();
3866
4037
  if (starting) {
3867
4038
  logger.info(
@@ -3872,22 +4043,59 @@ export class Broker {
3872
4043
  return starting;
3873
4044
  }
3874
4045
  const current = await this.#readLock();
3875
- if (current && current.identity === snapshot.identity && current.pid > 0 && isPidAlive(current.pid)) {
4046
+ if (
4047
+ current &&
4048
+ current.identity === snapshot.identity &&
4049
+ isBrokerLockOwnerAlive(current, hostIdentity.ownerHostId)
4050
+ ) {
4051
+ const hostUnqualified = current.ownerHostId === undefined;
4052
+ const foreignHostOwner = !hostUnqualified && current.ownerHostId !== hostIdentity.ownerHostId;
4053
+ let refusal: string;
4054
+ let errorMessage: string;
4055
+ if (hostUnqualified) {
4056
+ refusal = `owner pid ${current.pid} has no host identity; automatic recovery is unsafe for shared agent directories. Stop every broker using this agent directory on all hosts, then follow https://github.com/Yeachan-Heo/gajae-code/blob/dev/docs/sdk.md#recovering-legacy-broker-locks`;
4057
+ errorMessage = `Broker lock owner is host-unqualified (pid ${current.pid}); refusing automatic recovery because its host cannot be verified. Stop every broker using this agent directory on all hosts and follow https://github.com/Yeachan-Heo/gajae-code/blob/dev/docs/sdk.md#recovering-legacy-broker-locks`;
4058
+ } else if (foreignHostOwner) {
4059
+ refusal = `owner pid ${current.pid} is attributed to another host; its liveness cannot be verified here`;
4060
+ errorMessage = `Broker lock owner is attributed to a different host (pid ${current.pid}); refusing automatic recovery because its liveness cannot be verified from this host.`;
4061
+ } else {
4062
+ refusal = `live pid ${current.pid} has published no discovery record`;
4063
+ errorMessage = `Broker lock is held by a live owner (pid ${current.pid})`;
4064
+ }
3876
4065
  logger.warn(
3877
- `sdk broker: lock contention, refusing to start because ${this.#lock} is held by live pid ${current.pid} that published no discovery record`,
4066
+ `sdk broker: lock contention, refusing to start because ${this.#lock} is held by ${refusal}`,
3878
4067
  );
3879
- throw new Error(`Broker lock is held by a live owner (pid ${current.pid})`);
4068
+ throw new Error(errorMessage);
3880
4069
  }
3881
4070
  continue;
3882
4071
  }
3883
- await this.#reclaimStaleLock(snapshot);
4072
+ if (await this.#reclaimStaleLock(snapshot)) break;
3884
4073
  }
3885
4074
  this.#throwIfStartupAborted();
3886
4075
  // Only the lock holder reaps, so concurrent brokers cannot race the removal.
3887
4076
  await this.#reapLockArtifacts();
3888
4077
  try {
3889
4078
  this.#throwIfStartupAborted();
3890
- await this.index.open();
4079
+ // Recheck under the broker ownership lock: a restart can be committed while
4080
+ // an ordinary startup is waiting to acquire this lock.
4081
+ const restartIntent = await readBrokerRestartIntent(this.settings.agentDir);
4082
+ const activeCommittedIntent =
4083
+ restartIntent?.phase === "committed" && restartIntent.expiresAt > Date.now() ? restartIntent : undefined;
4084
+ if (
4085
+ this.settings.restartRequestId === undefined
4086
+ ? activeCommittedIntent !== undefined
4087
+ : activeCommittedIntent?.requestId !== this.settings.restartRequestId
4088
+ )
4089
+ throw new Error("A committed SDK broker restart is active; startup requires its exact request ID.");
4090
+ await this.index.open(
4091
+ this.#startupCheckpointDeadline === undefined
4092
+ ? undefined
4093
+ : sessionIndexStartupLockOptions(
4094
+ this.#startupCheckpointDeadline,
4095
+ this.#startupAbortSignal,
4096
+ this.settings.restartRequestId === undefined ? "ordinary" : "authorized-successor",
4097
+ ),
4098
+ );
3891
4099
  await this.ledger.open();
3892
4100
  const brokerIdentityKey = await getBrokerIdentityKey(this.settings.agentDir);
3893
4101
  this.#spawnAuthority = new SpawnAuthorityStore(this.settings.agentDir, brokerIdentityKey);
@@ -3950,6 +4158,8 @@ export class Broker {
3950
4158
  await this.#startupPrePublicationTestHook?.();
3951
4159
  if (this.#startupPrePublicationDelayMs > 0) await Bun.sleep(this.#startupPrePublicationDelayMs);
3952
4160
  this.#throwIfStartupAborted();
4161
+ if (!(await this.#hasCanonicalLockOwnership()))
4162
+ throw new Error("SDK broker lock ownership changed before discovery publication.");
3953
4163
  this.#publication = await publishBrokerDiscovery(
3954
4164
  this.settings.agentDir,
3955
4165
  this.discovery,
@@ -4851,7 +5061,19 @@ export class Broker {
4851
5061
  }
4852
5062
 
4853
5063
  // Wire requests are readiness-gated by BrokerTransport before reaching this dispatcher.
4854
- handleRequest(operation: string, input: Record<string, unknown>, idempotencyKey?: string): Promise<BrokerResponse> {
5064
+ async handleRequest(
5065
+ operation: string,
5066
+ input: Record<string, unknown>,
5067
+ idempotencyKey?: string,
5068
+ ): Promise<BrokerResponse> {
5069
+ if (
5070
+ this.#publication !== null &&
5071
+ this.#publicationState === "healthy-owned" &&
5072
+ !(await this.#hasCanonicalLockOwnership())
5073
+ ) {
5074
+ this.#fence("suspect-unpublished");
5075
+ return error("unavailable", "broker lock ownership is not held");
5076
+ }
4855
5077
  if (operation === "broker.status") return Promise.resolve({ ok: true, result: this.status() });
4856
5078
  if (operation === "broker.prepare_restart")
4857
5079
  return this.prepareRestart(input as unknown as BrokerRestartPrepareOptions).then(
@@ -4876,6 +5098,32 @@ export class Broker {
4876
5098
  this.#admitted.delete(admission);
4877
5099
  });
4878
5100
  }
5101
+ /**
5102
+ * Session-targeted lifecycle requests may pass terminal legacy rows recorded
5103
+ * for other sessions. A row equal to this key's legacy target identity for its
5104
+ * recorded session proves cross-target key reuse and keeps the fence; close
5105
+ * targets are reconstructed sessionId-only because legacy close rows may lack
5106
+ * endpoint authority.
5107
+ */
5108
+ async #legacySessionTarget(
5109
+ operation: string,
5110
+ input: Record<string, unknown>,
5111
+ idempotencyKey: string,
5112
+ ): Promise<{ sessionId: string; keyReuseIdentities: ReadonlySet<string> } | undefined> {
5113
+ if (!SESSION_TARGETED_LIFECYCLE_OPERATIONS.has(operation)) return undefined;
5114
+ const { sessionId } = lifecycleTarget(operation, input) as { sessionId?: string };
5115
+ if (sessionId === undefined) return undefined;
5116
+ const keyReuseIdentities = new Set<string>();
5117
+ for (const recordedSessionId of this.ledger.terminalLegacySessionIds()) {
5118
+ const recordedTarget = createHash("sha256")
5119
+ .update(canonicalJson(lifecycleTarget(operation, { sessionId: recordedSessionId })))
5120
+ .digest("hex");
5121
+ keyReuseIdentities.add(
5122
+ await deriveLegacyTargetIdentity(this.settings.agentDir, operation, idempotencyKey, recordedTarget),
5123
+ );
5124
+ }
5125
+ return { sessionId, keyReuseIdentities };
5126
+ }
4879
5127
  async #handleRequest(
4880
5128
  operation: string,
4881
5129
  input: Record<string, unknown>,
@@ -5094,7 +5342,10 @@ export class Broker {
5094
5342
  } else if (
5095
5343
  this.ledger.hasLegacyIdentity(
5096
5344
  new Set(this.#spawnAuthority?.claims().map(claim => claim.lifecycleIdentity) ?? []),
5097
- { ignoreTerminalRows: operation === "session.create" },
5345
+ {
5346
+ ignoreFinalRows: operation === "session.create",
5347
+ sessionTarget: await this.#legacySessionTarget(operation, input, idempotencyKey),
5348
+ },
5098
5349
  )
5099
5350
  ) {
5100
5351
  return error("idempotency_conflict", "legacy lifecycle request has an ambiguous target");
@@ -5341,6 +5592,15 @@ export function setLockArtifactGraceForTest(broker: Broker, graceMs: number | un
5341
5592
  else lockArtifactGraceOverridesForTest.set(broker, graceMs);
5342
5593
  }
5343
5594
 
5595
+ /** Test-only barriers for exercising the cross-process broker-lock reclaim fence. */
5596
+ export function setBrokerLockReclaimHooksForTest(
5597
+ broker: Broker,
5598
+ hooks: { beforeQuarantine?: () => Promise<void>; onContended?: () => void } | undefined,
5599
+ ): void {
5600
+ if (hooks === undefined) brokerLockReclaimHooksForTest.delete(broker);
5601
+ else brokerLockReclaimHooksForTest.set(broker, hooks);
5602
+ }
5603
+
5344
5604
  /** Test-only hook for shortening the bounded liveness deadline. */
5345
5605
  export function setLivenessGraceForTest(broker: Broker, graceMs: number | undefined): void {
5346
5606
  if (graceMs === undefined) livenessGraceOverridesForTest.delete(broker);