@superblocksteam/vite-plugin-file-sync 2.0.155 → 2.0.156-next.1

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 (177) hide show
  1. package/dist/ai-service/agent/prompts/build-base-system-prompt.d.ts +9 -2
  2. package/dist/ai-service/agent/prompts/build-base-system-prompt.d.ts.map +1 -1
  3. package/dist/ai-service/agent/prompts/build-base-system-prompt.js +34 -6
  4. package/dist/ai-service/agent/prompts/build-base-system-prompt.js.map +1 -1
  5. package/dist/ai-service/agent/prompts/build-security-scan-prompt.d.ts +2 -1
  6. package/dist/ai-service/agent/prompts/build-security-scan-prompt.d.ts.map +1 -1
  7. package/dist/ai-service/agent/prompts/build-security-scan-prompt.js +52 -9
  8. package/dist/ai-service/agent/prompts/build-security-scan-prompt.js.map +1 -1
  9. package/dist/ai-service/agent/tool-permission-delivery-metrics.d.ts +3 -0
  10. package/dist/ai-service/agent/tool-permission-delivery-metrics.d.ts.map +1 -0
  11. package/dist/ai-service/agent/tool-permission-delivery-metrics.js +23 -0
  12. package/dist/ai-service/agent/tool-permission-delivery-metrics.js.map +1 -0
  13. package/dist/ai-service/agent/tool-permission-delivery.d.ts +25 -16
  14. package/dist/ai-service/agent/tool-permission-delivery.d.ts.map +1 -1
  15. package/dist/ai-service/agent/tool-permission-delivery.js +384 -38
  16. package/dist/ai-service/agent/tool-permission-delivery.js.map +1 -1
  17. package/dist/ai-service/agent/tools/apis/get-sdk-api-docs.d.ts +9 -35
  18. package/dist/ai-service/agent/tools/apis/get-sdk-api-docs.d.ts.map +1 -1
  19. package/dist/ai-service/agent/tools/apis/get-sdk-api-docs.js +71 -98
  20. package/dist/ai-service/agent/tools/apis/get-sdk-api-docs.js.map +1 -1
  21. package/dist/ai-service/agent/tools/apis/test-api.d.ts.map +1 -1
  22. package/dist/ai-service/agent/tools/apis/test-api.js +3 -2
  23. package/dist/ai-service/agent/tools/apis/test-api.js.map +1 -1
  24. package/dist/ai-service/agent/tools/app-environment/update-app-environment.d.ts +1 -1
  25. package/dist/ai-service/agent/tools/app-environment/update-app-environment.d.ts.map +1 -1
  26. package/dist/ai-service/agent/tools/app-environment/update-app-environment.js +13 -1
  27. package/dist/ai-service/agent/tools/app-environment/update-app-environment.js.map +1 -1
  28. package/dist/ai-service/agent/tools/build-capture-screenshot.d.ts.map +1 -1
  29. package/dist/ai-service/agent/tools/build-capture-screenshot.js +8 -3
  30. package/dist/ai-service/agent/tools/build-capture-screenshot.js.map +1 -1
  31. package/dist/ai-service/agent/tools/build-navigate-preview.d.ts.map +1 -1
  32. package/dist/ai-service/agent/tools/build-navigate-preview.js +4 -2
  33. package/dist/ai-service/agent/tools/build-navigate-preview.js.map +1 -1
  34. package/dist/ai-service/agent/tools/build-reload-file.d.ts.map +1 -1
  35. package/dist/ai-service/agent/tools/build-reload-file.js +4 -2
  36. package/dist/ai-service/agent/tools/build-reload-file.js.map +1 -1
  37. package/dist/ai-service/agent/tools/get-logs.d.ts +2 -2
  38. package/dist/ai-service/agent/tools/integrations/delete-integration.d.ts.map +1 -1
  39. package/dist/ai-service/agent/tools/integrations/delete-integration.js +6 -3
  40. package/dist/ai-service/agent/tools/integrations/delete-integration.js.map +1 -1
  41. package/dist/ai-service/agent/tools/integrations/execute-request.d.ts +13 -13
  42. package/dist/ai-service/agent/tools/integrations/integration-setup.d.ts +18 -6
  43. package/dist/ai-service/agent/tools/integrations/integration-setup.d.ts.map +1 -1
  44. package/dist/ai-service/agent/tools/integrations/integration-setup.js +41 -15
  45. package/dist/ai-service/agent/tools/integrations/integration-setup.js.map +1 -1
  46. package/dist/ai-service/agent/tools/integrations/internal.d.ts.map +1 -1
  47. package/dist/ai-service/agent/tools/integrations/internal.js +4 -2
  48. package/dist/ai-service/agent/tools/integrations/internal.js.map +1 -1
  49. package/dist/ai-service/agent/tools/report-security-findings.d.ts +23 -23
  50. package/dist/ai-service/agent/tools.d.ts +4 -2
  51. package/dist/ai-service/agent/tools.d.ts.map +1 -1
  52. package/dist/ai-service/agent/tools.js +21 -14
  53. package/dist/ai-service/agent/tools.js.map +1 -1
  54. package/dist/ai-service/agent/tools2/tools/git.d.ts +2 -2
  55. package/dist/ai-service/index.d.ts +26 -25
  56. package/dist/ai-service/index.d.ts.map +1 -1
  57. package/dist/ai-service/index.js +340 -121
  58. package/dist/ai-service/index.js.map +1 -1
  59. package/dist/ai-service/integrations/store.d.ts +2 -1
  60. package/dist/ai-service/integrations/store.d.ts.map +1 -1
  61. package/dist/ai-service/integrations/store.js +3 -2
  62. package/dist/ai-service/integrations/store.js.map +1 -1
  63. package/dist/ai-service/judge/tools/playwright-action.d.ts +1 -1
  64. package/dist/ai-service/judge/tools/submit-feedback.d.ts +1 -1
  65. package/dist/ai-service/llm/client.d.ts +9 -3
  66. package/dist/ai-service/llm/client.d.ts.map +1 -1
  67. package/dist/ai-service/llm/client.js +10 -3
  68. package/dist/ai-service/llm/client.js.map +1 -1
  69. package/dist/ai-service/llm/context-v2/context-metrics.d.ts.map +1 -1
  70. package/dist/ai-service/llm/context-v2/context-metrics.js +3 -0
  71. package/dist/ai-service/llm/context-v2/context-metrics.js.map +1 -1
  72. package/dist/ai-service/llm/stream/observers/llmobs.d.ts +6 -6
  73. package/dist/ai-service/llm/stream/observers/llmobs.d.ts.map +1 -1
  74. package/dist/ai-service/llm/stream/observers/llmobs.js +10 -9
  75. package/dist/ai-service/llm/stream/observers/llmobs.js.map +1 -1
  76. package/dist/ai-service/llm/stream/observers/step-token-usage.d.ts +15 -0
  77. package/dist/ai-service/llm/stream/observers/step-token-usage.d.ts.map +1 -0
  78. package/dist/ai-service/llm/stream/observers/step-token-usage.js +88 -0
  79. package/dist/ai-service/llm/stream/observers/step-token-usage.js.map +1 -0
  80. package/dist/ai-service/llm/stream/orchestrator.d.ts +4 -0
  81. package/dist/ai-service/llm/stream/orchestrator.d.ts.map +1 -1
  82. package/dist/ai-service/llm/stream/orchestrator.js +19 -41
  83. package/dist/ai-service/llm/stream/orchestrator.js.map +1 -1
  84. package/dist/ai-service/llm/stream/retry-engine.d.ts +1 -1
  85. package/dist/ai-service/llm/stream/retry-engine.d.ts.map +1 -1
  86. package/dist/ai-service/llm/stream/retry-engine.js +26 -1
  87. package/dist/ai-service/llm/stream/retry-engine.js.map +1 -1
  88. package/dist/ai-service/llm/stream/terminal-reason.d.ts +6 -0
  89. package/dist/ai-service/llm/stream/terminal-reason.d.ts.map +1 -0
  90. package/dist/ai-service/llm/stream/terminal-reason.js +73 -0
  91. package/dist/ai-service/llm/stream/terminal-reason.js.map +1 -0
  92. package/dist/ai-service/policy-agent-metrics.d.ts +10 -0
  93. package/dist/ai-service/policy-agent-metrics.d.ts.map +1 -1
  94. package/dist/ai-service/policy-agent-metrics.js +33 -0
  95. package/dist/ai-service/policy-agent-metrics.js.map +1 -1
  96. package/dist/ai-service/state-machine/clark-fsm.d.ts +29 -1
  97. package/dist/ai-service/state-machine/clark-fsm.d.ts.map +1 -1
  98. package/dist/ai-service/state-machine/clark-fsm.js +3 -1
  99. package/dist/ai-service/state-machine/clark-fsm.js.map +1 -1
  100. package/dist/ai-service/state-machine/handlers/agent-planning.d.ts.map +1 -1
  101. package/dist/ai-service/state-machine/handlers/agent-planning.js +7 -6
  102. package/dist/ai-service/state-machine/handlers/agent-planning.js.map +1 -1
  103. package/dist/ai-service/state-machine/handlers/llm-generating.d.ts.map +1 -1
  104. package/dist/ai-service/state-machine/handlers/llm-generating.js +18 -5
  105. package/dist/ai-service/state-machine/handlers/llm-generating.js.map +1 -1
  106. package/dist/ai-service/state-machine/helpers/fetch-with-reconnect-retry.d.ts.map +1 -1
  107. package/dist/ai-service/state-machine/helpers/fetch-with-reconnect-retry.js +8 -6
  108. package/dist/ai-service/state-machine/helpers/fetch-with-reconnect-retry.js.map +1 -1
  109. package/dist/ai-service/state-machine/helpers/policy-gate-usage-metrics.d.ts +0 -14
  110. package/dist/ai-service/state-machine/helpers/policy-gate-usage-metrics.d.ts.map +1 -1
  111. package/dist/ai-service/state-machine/helpers/policy-gate-usage-metrics.js +0 -36
  112. package/dist/ai-service/state-machine/helpers/policy-gate-usage-metrics.js.map +1 -1
  113. package/dist/ai-service/state-machine/helpers/pre-session-metrics.d.ts +2 -0
  114. package/dist/ai-service/state-machine/helpers/pre-session-metrics.d.ts.map +1 -1
  115. package/dist/ai-service/state-machine/helpers/pre-session-metrics.js +3 -0
  116. package/dist/ai-service/state-machine/helpers/pre-session-metrics.js.map +1 -1
  117. package/dist/ai-service/state-machine/helpers/prompt-degradation-metrics.d.ts +28 -0
  118. package/dist/ai-service/state-machine/helpers/prompt-degradation-metrics.d.ts.map +1 -0
  119. package/dist/ai-service/state-machine/helpers/prompt-degradation-metrics.js +254 -0
  120. package/dist/ai-service/state-machine/helpers/prompt-degradation-metrics.js.map +1 -0
  121. package/dist/ai-service/state-machine/helpers/prompt-request-metrics.d.ts +1 -0
  122. package/dist/ai-service/state-machine/helpers/prompt-request-metrics.d.ts.map +1 -1
  123. package/dist/ai-service/state-machine/helpers/prompt-request-metrics.js +60 -27
  124. package/dist/ai-service/state-machine/helpers/prompt-request-metrics.js.map +1 -1
  125. package/dist/ai-service/state-machine/helpers/stable-peer.d.ts +12 -1
  126. package/dist/ai-service/state-machine/helpers/stable-peer.d.ts.map +1 -1
  127. package/dist/ai-service/state-machine/helpers/stable-peer.js +42 -9
  128. package/dist/ai-service/state-machine/helpers/stable-peer.js.map +1 -1
  129. package/dist/ai-service/state-machine/mocks.d.ts.map +1 -1
  130. package/dist/ai-service/state-machine/mocks.js +6 -0
  131. package/dist/ai-service/state-machine/mocks.js.map +1 -1
  132. package/dist/ai-service/util/call-peer.d.ts +11 -0
  133. package/dist/ai-service/util/call-peer.d.ts.map +1 -0
  134. package/dist/ai-service/util/call-peer.js +47 -0
  135. package/dist/ai-service/util/call-peer.js.map +1 -0
  136. package/dist/ai-service/util/peer-liveness.d.ts +23 -0
  137. package/dist/ai-service/util/peer-liveness.d.ts.map +1 -0
  138. package/dist/ai-service/util/peer-liveness.js +42 -0
  139. package/dist/ai-service/util/peer-liveness.js.map +1 -0
  140. package/dist/ai-service/util/rpc-timeout.d.ts +12 -5
  141. package/dist/ai-service/util/rpc-timeout.d.ts.map +1 -1
  142. package/dist/ai-service/util/rpc-timeout.js +12 -5
  143. package/dist/ai-service/util/rpc-timeout.js.map +1 -1
  144. package/dist/file-sync-vite-plugin.d.ts.map +1 -1
  145. package/dist/file-sync-vite-plugin.js +8 -21
  146. package/dist/file-sync-vite-plugin.js.map +1 -1
  147. package/dist/lock-service/activity-tracker.d.ts +0 -5
  148. package/dist/lock-service/activity-tracker.d.ts.map +1 -1
  149. package/dist/lock-service/activity-tracker.js +0 -13
  150. package/dist/lock-service/activity-tracker.js.map +1 -1
  151. package/dist/lock-service/index.d.ts +308 -27
  152. package/dist/lock-service/index.d.ts.map +1 -1
  153. package/dist/lock-service/index.js +680 -156
  154. package/dist/lock-service/index.js.map +1 -1
  155. package/dist/policy-gate-callback-mapper.d.ts +3 -2
  156. package/dist/policy-gate-callback-mapper.d.ts.map +1 -1
  157. package/dist/policy-gate-callback-mapper.js.map +1 -1
  158. package/dist/policy-gate-runner.d.ts +4 -2
  159. package/dist/policy-gate-runner.d.ts.map +1 -1
  160. package/dist/policy-gate-runner.js +31 -8
  161. package/dist/policy-gate-runner.js.map +1 -1
  162. package/dist/scan-scope-merge.d.ts +16 -0
  163. package/dist/scan-scope-merge.d.ts.map +1 -0
  164. package/dist/scan-scope-merge.js +90 -0
  165. package/dist/scan-scope-merge.js.map +1 -0
  166. package/dist/socket-manager.d.ts +47 -6
  167. package/dist/socket-manager.d.ts.map +1 -1
  168. package/dist/socket-manager.js +283 -66
  169. package/dist/socket-manager.js.map +1 -1
  170. package/dist/sync-service/index.d.ts.map +1 -1
  171. package/dist/sync-service/index.js +3 -3
  172. package/dist/sync-service/index.js.map +1 -1
  173. package/dist/test-setup.d.ts +2 -0
  174. package/dist/test-setup.d.ts.map +1 -0
  175. package/dist/test-setup.js +21 -0
  176. package/dist/test-setup.js.map +1 -0
  177. package/package.json +8 -8
@@ -6,6 +6,7 @@ import { createISocketClient, DEFAULT_NON_GIT_BRANCH, MAX_SOCKET_PAYLOAD_BYTES,
6
6
  import { uploadContextToBucketeer } from "./ai-service/context-upload.js";
7
7
  import { createSocketRecordingMiddleware } from "./ai-service/recording/recorders/socket-recorder.js";
8
8
  import { parseJwt } from "./ai-service/util/parse-jwt.js";
9
+ import { PeerLiveness, } from "./ai-service/util/peer-liveness.js";
9
10
  import { clearGitAuthFailed, isGitAuthFailed, markGitAuthFailed, } from "./git-auth-state.js";
10
11
  import { GIT_LOG_FORMAT, parseGitLog } from "./git-log.js";
11
12
  import { GIT_OPERATION_BUSY_MESSAGE, } from "./git-operation-lock.js";
@@ -15,6 +16,7 @@ import { doesRemoteLiveBranchExist, ensureLiveBranchCheckedOut, pushLiveBranchWi
15
16
  import { ensureProviderWorkflowFiles } from "./git-service/provider-workflow-files.js";
16
17
  import { getSyncCountsAgainstDefaultBranch } from "./git-service/status-sync.js";
17
18
  import { getErrorMeta, getLogger } from "./util/logger.js";
19
+ import { OperationQueue } from "./util/operation-queue.js";
18
20
  const SB_WORKTREE_SNAPSHOTS_DIR = join(tmpdir(), "sb-worktree-snapshots");
19
21
  /**
20
22
  * Pulls the JWT-derived `userId` out of an `Authorization: Bearer <jwt>`
@@ -127,7 +129,28 @@ export function disposeSocketManager() {
127
129
  _socketManager = undefined;
128
130
  }
129
131
  class SocketManager extends TracedEventEmitter {
132
+ peerLiveness = new PeerLiveness();
133
+ socketLiveness = new WeakMap();
134
+ /**
135
+ * Survives `editorSockets.delete` so a just-closed socket can still tell
136
+ * {@link getSurvivingEditorClient} which peerId to prefer among leftovers.
137
+ */
138
+ editorPeerIdsBySocket = new WeakMap();
130
139
  peerIds = new Map();
140
+ /**
141
+ * Editor peers whose presence is registered with the lock service, and the
142
+ * user each was registered for.
143
+ *
144
+ * Presence is per *peer*, not per socket: one tab reconnecting produces a new
145
+ * socket under the same peer id. Remembering "did I register?" on the socket
146
+ * therefore breaks the pair, because the socket that takes presence down need
147
+ * not be the one that put it up — a replacement that never makes an
148
+ * authorized call would close without removing anything, leaving the lock
149
+ * claimed for a peer that is gone.
150
+ */
151
+ editorPresenceUserIdByPeerId = new Map();
152
+ /** Editor peers that have already triggered post-auth git activation. */
153
+ gitActivatedPeerIds = new Set();
131
154
  /** Attached editor sockets, each mapped to the peer that opened it. */
132
155
  editorSockets = new Map();
133
156
  /**
@@ -158,6 +181,73 @@ class SocketManager extends TracedEventEmitter {
158
181
  });
159
182
  this.socketServer = wss;
160
183
  let activeGitService = gitService;
184
+ const headlessLockOwners = [];
185
+ const headlessLockOperationQueue = new OperationQueue();
186
+ /**
187
+ * Drop every turn this peer owned, and hand the lock back if that was the
188
+ * last of them.
189
+ *
190
+ * Dropping the rows alone leaves the lock stranded. The lock service only
191
+ * hands back when *no* gateway is left, so with a replacement socket
192
+ * already attached that never fires — and once the owner row is gone, a
193
+ * later `editor.releaseTurnLock` finds nothing to release and does
194
+ * nothing. The AppBranchLock would then stay held for the rest of that
195
+ * socket's life, with no turn behind it.
196
+ *
197
+ * Queued with the release path so the two cannot both decide they are
198
+ * last.
199
+ */
200
+ const dropHeadlessLockOwners = async (peerId) => {
201
+ await headlessLockOperationQueue.enqueue(async () => {
202
+ const ownedAny = headlessLockOwners.some((owner) => owner.peerId === peerId);
203
+ if (!ownedAny) {
204
+ return;
205
+ }
206
+ for (let index = headlessLockOwners.length - 1; index >= 0; index--) {
207
+ if (headlessLockOwners[index]?.peerId === peerId) {
208
+ headlessLockOwners.splice(index, 1);
209
+ }
210
+ }
211
+ if (headlessLockOwners.length > 0) {
212
+ return;
213
+ }
214
+ // Re-read after the handback's generation wait: a replacement gateway
215
+ // can claim a turn while the old one's write is still finishing.
216
+ await lockService?.relinquishLockForTurn(() => headlessLockOwners.length > 0);
217
+ });
218
+ };
219
+ const dropHeadlessLockOwnersInBackground = (peerId, occasion) => {
220
+ void dropHeadlessLockOwners(peerId).catch((err) => {
221
+ getLogger().warn(`[socket-manager] failed to hand the turn lock back after ${occasion}: ${err instanceof Error ? err.message : String(err)}`);
222
+ });
223
+ };
224
+ lockService?.setOnGatewayLivenessLapsed?.((peerId) => {
225
+ dropHeadlessLockOwnersInBackground(peerId, "a gateway's liveness lapsed");
226
+ aiService?.endHeadlessTurn(peerId);
227
+ });
228
+ const releaseHeadlessLock = async (peerId, turnId) => {
229
+ return await headlessLockOperationQueue.enqueue(async () => {
230
+ const ownerIndex = headlessLockOwners.findIndex((owner) => owner.peerId === peerId &&
231
+ (turnId ? owner.turnId === turnId : owner.turnId === undefined));
232
+ if (ownerIndex === -1) {
233
+ return false;
234
+ }
235
+ const [owner] = headlessLockOwners.splice(ownerIndex, 1);
236
+ if (headlessLockOwners.length > 0) {
237
+ return true;
238
+ }
239
+ try {
240
+ await lockService?.relinquishLockForTurn(() => headlessLockOwners.length > 0);
241
+ return true;
242
+ }
243
+ catch (error) {
244
+ if (owner) {
245
+ headlessLockOwners.splice(ownerIndex, 0, owner);
246
+ }
247
+ throw error;
248
+ }
249
+ });
250
+ };
161
251
  // Provide sync context (branchName, lastCommitId) to the AI service
162
252
  // for remote context restore fallback from Bucketeer on fresh pods.
163
253
  if (aiService && syncService) {
@@ -325,11 +415,54 @@ class SocketManager extends TracedEventEmitter {
325
415
  };
326
416
  wss.on("connection", (ws, kind, peerId, userId) => {
327
417
  const isEditor = kind === "editor";
418
+ const connectionLiveness = kind === "library" ? undefined : this.peerLiveness.bindConnection();
419
+ if (connectionLiveness) {
420
+ ws.on("message", () => connectionLiveness.markInbound());
421
+ }
422
+ /**
423
+ * When an editor authenticates after git was enabled, the lock may
424
+ * still be on 'main'. This calls
425
+ * `ensureRuntimeDbfsBranchConsistency` -> `lockService.switchBranch`
426
+ * to move it to superblocks/live.
427
+ */
428
+ const activateGitForAuthedEditor = () => {
429
+ if (!activateGitService) {
430
+ return;
431
+ }
432
+ void activateGitService().catch((err) => {
433
+ getLogger().warn(`[socket-manager] post-auth git activation failed: ${err instanceof Error ? err.message : String(err)}`);
434
+ });
435
+ };
328
436
  const authMiddleware = async (params, ctx, _peer, next) => {
329
437
  const result = await checkAuthorization(ctx.peerAuthorization);
330
438
  if (!result.success) {
331
439
  throw new Error(result.error);
332
440
  }
441
+ if (isEditor &&
442
+ userId &&
443
+ !this.editorPresenceUserIdByPeerId.has(peerId) &&
444
+ // The authorization above is awaited, so the socket that asked for
445
+ // it can be gone by now. `peerIds` loses the peer when its last
446
+ // socket closes, so this is what keeps a late answer from
447
+ // registering presence nothing is left to remove.
448
+ this.peerIds.has(peerId)) {
449
+ this.editorPresenceUserIdByPeerId.set(peerId, userId);
450
+ lockService?.addConnectedPeerIdToUser(userId, peerId);
451
+ }
452
+ // After presence, and only for a peer that has proved itself.
453
+ //
454
+ // Git activation moves the DBFS branch, which asks the lock service
455
+ // whether this pod holds the lock. Presence registration is what
456
+ // starts the attach-time acquisition, so running this first — as it
457
+ // did from the connect handler — asks that question before there is
458
+ // an acquisition to observe: `isLockedWhenSettled()` settles what is
459
+ // in flight, and nothing is. The switch then answers "no lock",
460
+ // releases the one that lands moments later, and does not take it
461
+ // back. Ordering them here is what keeps the two consistent.
462
+ if (isEditor && !this.gitActivatedPeerIds.has(peerId)) {
463
+ this.gitActivatedPeerIds.add(peerId);
464
+ activateGitForAuthedEditor();
465
+ }
333
466
  // Notify AiService that this peer has just produced a
334
467
  // proof-of-auth. AiService deduplicates internally and uses
335
468
  // this signal to drain state pushes that were deferred at
@@ -362,7 +495,16 @@ class SocketManager extends TracedEventEmitter {
362
495
  // it follows the peer rather than reporting into the socket it
363
496
  // lost. Naming it is required — otherwise any authorized gateway
364
497
  // session could take over another one's turn.
365
- aiService?.claimHeadlessTurn(socket, peerId, readHeadlessTurnId(params));
498
+ const headlessTurnId = readHeadlessTurnId(params);
499
+ if (ctx.method !== "editor.releaseTurnLock") {
500
+ aiService?.claimHeadlessTurn(socket, peerId, headlessTurnId, connectionLiveness);
501
+ if (headlessTurnId) {
502
+ const owner = headlessLockOwners.find((candidate) => candidate.turnId === headlessTurnId);
503
+ if (owner) {
504
+ owner.peerId = peerId;
505
+ }
506
+ }
507
+ }
366
508
  }
367
509
  return await next();
368
510
  };
@@ -410,12 +552,33 @@ class SocketManager extends TracedEventEmitter {
410
552
  if (kind !== "gateway") {
411
553
  return await aiService.handleAiGenerateWithQueue(aiRequest, queueMode);
412
554
  }
413
- const turnId = aiService.beginHeadlessTurn(socket, peerId, typeof request.requestId === "string"
555
+ // Write access is taken per turn, not held for the whole
556
+ // session, so the app is unlocked between prompts and a
557
+ // person can open it. Before the turn is bound, and
558
+ // awaited: a turn that cannot write must fail here carrying
559
+ // the server's own reason — somebody is editing in the
560
+ // browser — rather than bind routing and then unwind it.
561
+ const requestedTurnId = typeof request.requestId === "string"
414
562
  ? request.requestId
415
- : undefined);
563
+ : undefined;
564
+ const lockOwner = {
565
+ peerId,
566
+ turnId: requestedTurnId,
567
+ };
568
+ const turnId = await headlessLockOperationQueue.enqueue(async () => {
569
+ await lockService?.acquireLockForTurn();
570
+ headlessLockOwners.push(lockOwner);
571
+ return aiService.beginHeadlessTurn(socket, peerId, requestedTurnId, connectionLiveness);
572
+ });
416
573
  let result;
417
574
  try {
418
575
  result = await aiService.handleAiGenerateWithQueue(aiRequest, queueMode);
576
+ const ownerIndex = headlessLockOwners.indexOf(lockOwner);
577
+ for (let index = ownerIndex - 1; index >= 0; index--) {
578
+ if (headlessLockOwners[index]?.peerId === peerId) {
579
+ headlessLockOwners.splice(index, 1);
580
+ }
581
+ }
419
582
  }
420
583
  catch (err) {
421
584
  // The turn is bound before the prompt is handed over, so
@@ -423,6 +586,12 @@ class SocketManager extends TracedEventEmitter {
423
586
  // it. Left bound, it would capture the next turn a user
424
587
  // starts in the browser.
425
588
  aiService.abandonHeadlessTurn(peerId, turnId);
589
+ try {
590
+ await releaseHeadlessLock(peerId, turnId);
591
+ }
592
+ catch (releaseErr) {
593
+ getLogger().warn("[socket-manager] failed to release the lock for a rejected headless turn", getErrorMeta(releaseErr));
594
+ }
426
595
  throw err;
427
596
  }
428
597
  // Answering with the turn handle tells the caller two
@@ -432,6 +601,16 @@ class SocketManager extends TracedEventEmitter {
432
601
  return { ...result, headlessTurn: { turnId } };
433
602
  },
434
603
  ],
604
+ releaseTurnLock: [
605
+ async (request) => {
606
+ if (kind !== "gateway") {
607
+ throw new Error("Only gateway peers can release a headless turn lock");
608
+ }
609
+ if (await releaseHeadlessLock(peerId, request.headlessTurnId)) {
610
+ aiService?.endHeadlessTurn(peerId, request.headlessTurnId);
611
+ }
612
+ },
613
+ ],
435
614
  aiTriggerMigrationTurn: [
436
615
  async (request) => {
437
616
  if (!aiService) {
@@ -1020,17 +1199,20 @@ class SocketManager extends TracedEventEmitter {
1020
1199
  activeGitService = undefined;
1021
1200
  disconnectInProgress = true;
1022
1201
  clearGitAuthFailed();
1202
+ // Keep file-sync / lock wiring; drop only the Git
1203
+ // service so the next Clark turn sees unconnected.
1204
+ aiService?.setRuntimeGitServices(undefined, syncService, gitOperationLock);
1023
1205
  // Ensure DBFS sync context is no longer tied to
1024
1206
  // `superblocks/live` immediately after native git disconnect.
1025
1207
  syncService?.setBranchName(DEFAULT_NON_GIT_BRANCH);
1026
1208
  // Native git disconnect should move runtime lock context back
1027
1209
  // to default branch before clients reconnect.
1028
1210
  if (lockService) {
1029
- const wasLockedBeforeDisconnect = lockService.isLocked;
1211
+ // Retain the pre-switch state for the explicit fallback
1212
+ // if an atomic branch transfer fails.
1213
+ const wasLockedBeforeDisconnect = await lockService.isLockedWhenSettled();
1030
1214
  try {
1031
- await lockService.switchBranch(DEFAULT_NON_GIT_BRANCH, {
1032
- reacquireLock: wasLockedBeforeDisconnect,
1033
- });
1215
+ await lockService.switchBranch(DEFAULT_NON_GIT_BRANCH);
1034
1216
  }
1035
1217
  catch (err) {
1036
1218
  getLogger().warn("[git] gitDisconnect failed to switch lock branch back to main", {
@@ -1282,6 +1464,9 @@ class SocketManager extends TracedEventEmitter {
1282
1464
  },
1283
1465
  },
1284
1466
  }));
1467
+ if (connectionLiveness) {
1468
+ this.socketLiveness.set(socket, connectionLiveness);
1469
+ }
1285
1470
  // we are storing all of the sockets for a given peerId (editor+library pair of connections)
1286
1471
  // which lets us handle synchronization of operations between the two
1287
1472
  let existingSockets = this.peerIds.get(peerId);
@@ -1294,23 +1479,14 @@ class SocketManager extends TracedEventEmitter {
1294
1479
  // Liveness is granted in the auth middleware, not here: the upgrade
1295
1480
  // path only checks `applicationId`, and holding the sandbox open is
1296
1481
  // not something an unproven socket gets to do.
1297
- this.emit("connect:gateway", socket, peerId);
1482
+ this.emit("connect:gateway", socket, peerId, connectionLiveness);
1298
1483
  }
1299
1484
  else if (isEditor) {
1300
1485
  this.editorSockets.set(socket, peerId);
1301
- this.emit("connect:editor", socket, peerId);
1302
- if (userId) {
1303
- lockService?.addConnectedPeerIdToUser(userId, peerId);
1304
- }
1305
- // When a new editor connects after git was enabled, the lock may
1306
- // still be on 'main'. Trigger git activation which calls
1307
- // ensureRuntimeDbfsBranchConsistency → lockService.switchBranch
1308
- // to transfer the lock to superblocks/live.
1309
- if (activateGitService) {
1310
- void activateGitService().catch((err) => {
1311
- getLogger().warn(`[socket-manager] post-connect git activation failed: ${err instanceof Error ? err.message : String(err)}`);
1312
- });
1313
- }
1486
+ this.editorPeerIdsBySocket.set(socket, peerId);
1487
+ this.emit("connect:editor", socket, peerId, connectionLiveness);
1488
+ // Git activation is deliberately NOT started here — it runs from the
1489
+ // auth middleware, after presence. See `activateGitForAuthedEditor`.
1314
1490
  }
1315
1491
  else {
1316
1492
  this.librarySockets.add(socket);
@@ -1318,61 +1494,73 @@ class SocketManager extends TracedEventEmitter {
1318
1494
  }
1319
1495
  existingSockets.add(socket);
1320
1496
  ws.on("close", (code, reason) => {
1497
+ connectionLiveness?.close();
1321
1498
  const peerSockets = this.peerIds.get(peerId);
1322
1499
  peerSockets?.delete(socket);
1323
1500
  if (peerSockets?.size === 0) {
1324
1501
  this.peerIds.delete(peerId);
1502
+ // The peer is gone, so a later reconnect under the same id is a
1503
+ // new arrival and activates git again.
1504
+ this.gitActivatedPeerIds.delete(peerId);
1325
1505
  }
1326
1506
  getLogger().info(`Socket from ${kind} closed code: ${code} reason: ${reason}`);
1327
1507
  if (kind === "gateway") {
1328
1508
  this.gatewaySockets.delete(socket);
1329
1509
  lockService?.markGatewayDetached(peerId);
1330
- aiService?.endHeadlessTurn(peerId);
1331
- this.emit("disconnect:gateway", peerId);
1510
+ // No blanket clear when the last socket goes: every peer's rows are
1511
+ // dropped by its own close, and the one that empties the list is
1512
+ // what hands the lock back.
1513
+ dropHeadlessLockOwnersInBackground(peerId, "a gateway socket closed");
1514
+ aiService?.endHeadlessTurn(peerId, connectionLiveness);
1515
+ this.emit("disconnect:gateway", peerId, connectionLiveness);
1332
1516
  }
1333
1517
  else if (isEditor) {
1334
1518
  this.editorSockets.delete(socket);
1335
1519
  const hasOtherEditorSocket = Array.from(peerSockets ?? []).some((peerSocket) => this.editorSockets.has(peerSocket));
1336
- if (hasOtherEditorSocket) {
1337
- return;
1338
- }
1339
- if (userId) {
1340
- lockService?.removeConnectedPeerIdFromUser(userId, peerId);
1341
- if (lockService?.connectedUsers.length === 0) {
1342
- if (aiService?.isBusy()) {
1343
- getLogger().info("[socket-manager] All users disconnected but AI service is busy, deferring cache cleanup until generation completes");
1344
- let cleaned = false;
1345
- const deferredCleanup = () => {
1346
- if (cleaned)
1347
- return;
1348
- cleaned = true;
1349
- // Only clean up if no users have reconnected in the meantime
1350
- if (lockService?.connectedUsers.length === 0) {
1351
- getLogger().info("[socket-manager] Generation ended with no connected users, cleaning up AI service cache");
1352
- aiService
1353
- .removeIntegrationCache()
1354
- .catch((error) => {
1355
- getLogger().warn(`Error removing integration cache after deferred cleanup: ${error}`);
1356
- });
1357
- }
1358
- else {
1359
- getLogger().info("[socket-manager] Generation ended but users have reconnected, skipping cache cleanup");
1360
- }
1361
- };
1362
- aiService.once("generationCompleted", deferredCleanup);
1363
- aiService.once("generationFailed", deferredCleanup);
1364
- }
1365
- else {
1366
- getLogger().info("[socket-manager] All users disconnected, cleaning up AI service cache");
1367
- aiService
1368
- ?.removeIntegrationCache()
1369
- .catch((error) => {
1370
- getLogger().warn(`Error removing integration cache after disconnect: ${error}`);
1371
- });
1520
+ if (!hasOtherEditorSocket) {
1521
+ // Keyed by peer, so whichever of the peer's sockets is last out
1522
+ // takes the presence down — including one that never authenticated
1523
+ // itself.
1524
+ const presenceUserId = this.editorPresenceUserIdByPeerId.get(peerId);
1525
+ if (presenceUserId) {
1526
+ this.editorPresenceUserIdByPeerId.delete(peerId);
1527
+ lockService?.removeConnectedPeerIdFromUser(presenceUserId, peerId);
1528
+ if (lockService?.connectedUsers.length === 0) {
1529
+ if (aiService?.isBusy()) {
1530
+ getLogger().info("[socket-manager] All users disconnected but AI service is busy, deferring cache cleanup until generation completes");
1531
+ let cleaned = false;
1532
+ const deferredCleanup = () => {
1533
+ if (cleaned)
1534
+ return;
1535
+ cleaned = true;
1536
+ // Only clean up if no users have reconnected in the meantime
1537
+ if (lockService?.connectedUsers.length === 0) {
1538
+ getLogger().info("[socket-manager] Generation ended with no connected users, cleaning up AI service cache");
1539
+ aiService
1540
+ .removeIntegrationCache()
1541
+ .catch((error) => {
1542
+ getLogger().warn(`Error removing integration cache after deferred cleanup: ${error}`);
1543
+ });
1544
+ }
1545
+ else {
1546
+ getLogger().info("[socket-manager] Generation ended but users have reconnected, skipping cache cleanup");
1547
+ }
1548
+ };
1549
+ aiService.once("generationCompleted", deferredCleanup);
1550
+ aiService.once("generationFailed", deferredCleanup);
1551
+ }
1552
+ else {
1553
+ getLogger().info("[socket-manager] All users disconnected, cleaning up AI service cache");
1554
+ aiService
1555
+ ?.removeIntegrationCache()
1556
+ .catch((error) => {
1557
+ getLogger().warn(`Error removing integration cache after disconnect: ${error}`);
1558
+ });
1559
+ }
1372
1560
  }
1373
1561
  }
1374
1562
  }
1375
- this.emit("disconnect:editor", peerId);
1563
+ this.emit("disconnect:editor", peerId, socket, connectionLiveness);
1376
1564
  }
1377
1565
  else {
1378
1566
  this.librarySockets.delete(socket);
@@ -1404,19 +1592,48 @@ class SocketManager extends TracedEventEmitter {
1404
1592
  }
1405
1593
  }
1406
1594
  /**
1407
- * Any editor socket that does not belong to `excludePeerId`.
1595
+ * Any editor socket that does not belong to `exclude`.
1408
1596
  *
1409
1597
  * Used when a peer disconnects: Clark's RPCs are bound to a single editor,
1410
1598
  * so a survivor is what keeps API execution alive for a user whose tab is
1411
1599
  * still open after a headless build's socket closes.
1600
+ *
1601
+ * Same-id sockets are preferred when `exclude` is the departing socket: a
1602
+ * tab that reconnected keeps the bound Clark context instead of handing it
1603
+ * (and in-flight generation / tool-permission prompts) to whichever other
1604
+ * editor happened to connect first. A string `exclude` still means "this
1605
+ * peer is gone" and skips every socket with that id.
1412
1606
  */
1413
- getSurvivingEditorClient(excludePeerId) {
1414
- for (const [peer, peerId] of this.editorSockets) {
1415
- if (peerId !== excludePeerId) {
1416
- return { peer, peerId };
1607
+ getSurvivingEditorClient(exclude) {
1608
+ const excludedPeerId = typeof exclude === "string" ? exclude : undefined;
1609
+ const preferredPeerId = typeof exclude === "string"
1610
+ ? undefined
1611
+ : this.editorPeerIdsBySocket.get(exclude);
1612
+ const pick = (onlyPreferred) => {
1613
+ for (const [peer, peerId] of this.editorSockets) {
1614
+ const excluded = excludedPeerId !== undefined
1615
+ ? peerId === excludedPeerId
1616
+ : peer === exclude;
1617
+ if (excluded) {
1618
+ continue;
1619
+ }
1620
+ if (onlyPreferred && peerId !== preferredPeerId) {
1621
+ continue;
1622
+ }
1623
+ const liveness = this.socketLiveness.get(peer);
1624
+ if (liveness) {
1625
+ return { liveness, peer, peerId };
1626
+ }
1627
+ }
1628
+ return undefined;
1629
+ };
1630
+ if (preferredPeerId !== undefined) {
1631
+ const sameId = pick(true);
1632
+ if (sameId) {
1633
+ return sameId;
1417
1634
  }
1418
1635
  }
1419
- return undefined;
1636
+ return pick(false);
1420
1637
  }
1421
1638
  callLibraryClients(fn) {
1422
1639
  for (const socket of this.librarySockets) {