clay-server 4.2.0 → 4.3.0-beta.10

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 (84) hide show
  1. package/lib/driver-continuation-access.js +54 -0
  2. package/lib/driver-continuation-lease.js +52 -0
  3. package/lib/driver-continuation-pair-transfer.js +86 -0
  4. package/lib/driver-continuation-pair.js +86 -0
  5. package/lib/driver-continuation-record.js +40 -0
  6. package/lib/driver-continuation-startup.js +68 -0
  7. package/lib/driver-continuation-transaction.js +61 -0
  8. package/lib/driver-continuation-trigger.js +110 -0
  9. package/lib/multi-worker-feature.js +11 -0
  10. package/lib/project-connection.js +3 -1
  11. package/lib/project-driver-continuation.js +499 -0
  12. package/lib/project-message-delivery.js +20 -0
  13. package/lib/project-pair-autonomous-stop.js +14 -8
  14. package/lib/project-pair-close-transaction.js +59 -0
  15. package/lib/project-pair-global-stop.js +18 -0
  16. package/lib/project-pair-lifecycle-status.js +34 -0
  17. package/lib/project-pair-lifecycle.js +117 -135
  18. package/lib/project-pair-message.js +1 -1
  19. package/lib/project-pair-owned-stop.js +7 -4
  20. package/lib/project-pair-replacement-state.js +5 -1
  21. package/lib/project-pair-result-capture.js +116 -0
  22. package/lib/project-pair-result-delivery.js +140 -0
  23. package/lib/project-pair-result-outbox.js +400 -0
  24. package/lib/project-pair-result-pipeline.js +16 -0
  25. package/lib/project-pair-result-recovery.js +229 -0
  26. package/lib/project-pair-structural-close.js +20 -0
  27. package/lib/project-pair-task-control.js +45 -18
  28. package/lib/project-pair-worker-close.js +68 -0
  29. package/lib/project-session-handoff.js +48 -55
  30. package/lib/project-session-pair-prompt.js +26 -0
  31. package/lib/project-session-pair.js +87 -106
  32. package/lib/project-sessions.js +4 -0
  33. package/lib/project-user-message.js +21 -0
  34. package/lib/project-worker-creation-proposal.js +176 -0
  35. package/lib/project-worker-permission.js +7 -8
  36. package/lib/project-worker-proposal-runner.js +45 -0
  37. package/lib/project-worker-proposal.js +79 -115
  38. package/lib/project.js +87 -3
  39. package/lib/public/app.js +1 -0
  40. package/lib/public/css/driver-continuation.css +56 -0
  41. package/lib/public/css/messages.css +12 -0
  42. package/lib/public/css/mobile-nav.css +29 -14
  43. package/lib/public/css/overlays.css +54 -25
  44. package/lib/public/css/pane.css +66 -12
  45. package/lib/public/css/sidebar.css +62 -7
  46. package/lib/public/index.html +3 -3
  47. package/lib/public/modules/app-connection.js +165 -74
  48. package/lib/public/modules/app-messages.js +31 -3
  49. package/lib/public/modules/driver-continuation-state.js +76 -0
  50. package/lib/public/modules/driver-continuation.js +260 -0
  51. package/lib/public/modules/message-delivery-ui.js +63 -0
  52. package/lib/public/modules/message-delivery.js +163 -6
  53. package/lib/public/modules/pair-result-status.js +87 -0
  54. package/lib/public/modules/permission-control.js +10 -5
  55. package/lib/public/modules/project-activation.js +10 -7
  56. package/lib/public/modules/session-creation-cta.js +46 -0
  57. package/lib/public/modules/sidebar-mobile.js +42 -30
  58. package/lib/public/modules/sidebar-sessions.js +77 -52
  59. package/lib/public/modules/split-group-helpers.js +74 -2
  60. package/lib/public/modules/split-pair-ui.js +28 -6
  61. package/lib/public/modules/split-pane-reconciler.js +28 -0
  62. package/lib/public/modules/split-pane-renderer.js +107 -0
  63. package/lib/public/modules/split-view.js +58 -188
  64. package/lib/public/modules/websocket-lifecycle.js +178 -0
  65. package/lib/public/modules/websocket-watchdog.js +126 -0
  66. package/lib/public/modules/worker-pane-lock.js +13 -3
  67. package/lib/public/style.css +1 -0
  68. package/lib/sdk-bridge.js +77 -0
  69. package/lib/server.js +1 -0
  70. package/lib/session-handoff-discovery.js +443 -0
  71. package/lib/session-handoff-mcp-server.js +32 -4
  72. package/lib/session-pair-factory.js +66 -0
  73. package/lib/session-pair-mcp-server.js +10 -5
  74. package/lib/session-pair-prompts.js +46 -25
  75. package/lib/session-pair-target.js +42 -0
  76. package/lib/session-pair-turn-control.js +30 -9
  77. package/lib/session-split-group-anchors.js +126 -0
  78. package/lib/session-split-group-persistence.js +136 -0
  79. package/lib/session-split-group-roles.js +164 -0
  80. package/lib/session-split-group-v2-store.js +146 -0
  81. package/lib/session-split-groups.js +293 -133
  82. package/lib/sessions.js +89 -22
  83. package/lib/ws-schema.js +8 -0
  84. package/package.json +1 -1
@@ -0,0 +1,34 @@
1
+ var pairUsage = require("./project-pair-usage");
2
+
3
+ var MAX_TASK_PREVIEW_CHARS = 200;
4
+
5
+ function clampText(value, max) {
6
+ var text = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
7
+ if (text.length > max) text = text.slice(0, max - 1) + "…";
8
+ return text;
9
+ }
10
+
11
+ function continuityStatus(session) {
12
+ var history = session.history || [];
13
+ var userTurns = 0;
14
+ var errors = 0;
15
+ for (var i = 0; i < history.length; i++) {
16
+ if (!history[i]) continue;
17
+ if (history[i].type === "user_message") userTurns++;
18
+ else if (history[i].type === "error") errors++;
19
+ }
20
+ var lastActivity = typeof session.lastActivity === "number" ? session.lastActivity : null;
21
+ return { historyEntries: history.length, userTurns: userTurns, errorEntries: errors,
22
+ idleSeconds: lastActivity ? Math.max(0, Math.round((Date.now() - lastActivity) / 1000)) : null };
23
+ }
24
+
25
+ function activityStatus(session) {
26
+ var token = session._pairDelegation || null;
27
+ return { isProcessing: !!(session.isProcessing || session._queryStarting), delegated: !!token,
28
+ currentTask: token ? clampText(token.message, MAX_TASK_PREVIEW_CHARS) : "",
29
+ currentTaskId: token && token.taskId || null, interruption: session._pairInterruption || null,
30
+ lastTurnInterrupted: !!session._lastTurnInterrupted };
31
+ }
32
+
33
+ module.exports = { activityStatus: activityStatus, clampText: clampText, continuityStatus: continuityStatus,
34
+ contextStatus: pairUsage.contextStatus, firstNumber: pairUsage.firstNumber };
@@ -1,27 +1,16 @@
1
- // Split Worker lifecycle internals: bounded status, transactional replacement,
2
- // and the per-generation evaluation ledger. The Driver-facing tool posts a
3
- // user-controlled runtime card before accepted replacement reaches this module.
4
- //
5
- // Everything is bound to the exact live pair. The Driver is re-resolved from
6
- // the split store on every call, its structural capability is re-checked every time, and
7
- // ownership must match on both sessions. No active-tab or global-session
8
- // fallback exists in this module.
9
- //
10
- // What replacement deliberately does NOT do: delete history. Dissolving a pair
11
- // leaves both sessions in the project exactly as close_partner already does, so
12
- // the previous Worker's conversation stays browsable and recoverable under the
13
- // existing session semantics. There is no archive concept in the repo to hook
14
- // into, and inventing a destructive one would lose work.
15
-
16
1
  var eligibility = require("./session-driver-eligibility");
17
2
  var replacementState = require("./project-pair-replacement-state");
18
- var pairUsage = require("./project-pair-usage");
19
- var contextStatus = pairUsage.contextStatus;
20
- var firstNumber = pairUsage.firstNumber;
3
+ var lifecycleStatus = require("./project-pair-lifecycle-status");
4
+ var contextStatus = lifecycleStatus.contextStatus;
5
+ var firstNumber = lifecycleStatus.firstNumber;
6
+ var continuityStatus = lifecycleStatus.continuityStatus;
7
+ var activityStatus = lifecycleStatus.activityStatus;
8
+ var clampText = lifecycleStatus.clampText;
9
+ var pairTarget = require("./session-pair-target");
10
+ var splitRoles = require("./session-split-group-roles");
21
11
 
22
12
  var MAX_GENERATIONS = 5;
23
13
  var MAX_NOTE_CHARS = 400;
24
- var MAX_TASK_PREVIEW_CHARS = 200;
25
14
 
26
15
  var EVALUATION_OUTCOMES = ["succeeded", "partial", "failed", "abandoned"];
27
16
 
@@ -36,60 +25,23 @@ function toolError(message) {
36
25
  });
37
26
  }
38
27
 
39
- function clampText(value, max) {
40
- var text = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
41
- if (text.length > max) text = text.slice(0, max - 1) + "…";
42
- return text;
43
- }
44
-
45
28
  function replacementFingerprint(args, worker) {
46
29
  return JSON.stringify({ sourceWorkerId: worker.localId, interrupt: args.interrupt === true,
47
30
  vendor: args.workerVendor || "", model: args.workerModel || "", effort: args.workerEffort || "",
48
31
  message: args.message || "", evaluation: args.evaluation || null });
49
32
  }
50
33
 
51
- function continuityStatus(session) {
52
- var history = session.history || [];
53
- var userTurns = 0;
54
- var errors = 0;
55
- for (var i = 0; i < history.length; i++) {
56
- if (!history[i]) continue;
57
- if (history[i].type === "user_message") userTurns++;
58
- else if (history[i].type === "error") errors++;
59
- }
60
- var lastActivity = typeof session.lastActivity === "number" ? session.lastActivity : null;
61
- return {
62
- historyEntries: history.length,
63
- userTurns: userTurns,
64
- errorEntries: errors,
65
- idleSeconds: lastActivity ? Math.max(0, Math.round((Date.now() - lastActivity) / 1000)) : null,
66
- };
67
- }
68
-
69
- // The current task, as a bounded preview of the delegated instruction only.
70
- // Never the transcript: a Driver deciding reuse needs to know what the Worker
71
- // is on, not to re-read its conversation.
72
- function activityStatus(session) {
73
- var token = session._pairDelegation || null;
74
- return {
75
- isProcessing: !!(session.isProcessing || session._queryStarting),
76
- delegated: !!token,
77
- currentTask: token ? clampText(token.message, MAX_TASK_PREVIEW_CHARS) : "",
78
- currentTaskId: token && token.taskId || null,
79
- interruption: session._pairInterruption || null,
80
- lastTurnInterrupted: !!session._lastTurnInterrupted,
81
- };
82
- }
83
-
84
34
  function attachPairLifecycle(ctx) {
85
35
  var sm = ctx.sm;
86
36
  var store = ctx.splitStore;
87
37
  var turnControl = ctx.turnControl;
88
38
 
89
- function ledgerFor(driver) {
39
+ function ledgerFor(driver, selectedWorker) {
90
40
  if (!Array.isArray(driver._workerGenerations)) driver._workerGenerations = [];
91
41
  var group = store.groupForMember(driver.localId);
92
- var worker = group && group.pair && sm.sessions.get(group.pair.workerId);
42
+ var normalized = splitRoles.normalizePair(group && group.pair, group && group.members);
43
+ var fallbackId = normalized.ok && normalized.kind !== "adhoc" && normalized.workerIds.length === 1 ? normalized.workerIds[0] : null;
44
+ var worker = selectedWorker || fallbackId != null && sm.sessions.get(fallbackId);
93
45
  var generation = worker && (worker._pairGeneration || worker.sessionProvenance && worker.sessionProvenance.generation);
94
46
  if (worker && Number.isInteger(generation) && generation > 0 && !findGeneration(driver, worker)) {
95
47
  driver._workerGenerations.push({
@@ -109,7 +61,7 @@ function attachPairLifecycle(ctx) {
109
61
  return driver._workerGenerations;
110
62
  }
111
63
  function recordGenerationStart(driver, worker) {
112
- var ledger = ledgerFor(driver);
64
+ var ledger = ledgerFor(driver, worker);
113
65
  var existing = findGeneration(driver, worker);
114
66
  if (existing && !existing.endedAt) {
115
67
  worker._pairGeneration = existing.generation;
@@ -184,11 +136,9 @@ function attachPairLifecycle(ctx) {
184
136
  return record;
185
137
  }
186
138
  function resolveDriverPair(caller) {
139
+ var args = arguments[1];
140
+ var options = arguments[2];
187
141
  if (!caller) throw new Error("pair lifecycle tools require a session-bound tool server");
188
- // Exact live object identity, before anything is read off the caller. A
189
- // tool handler captured by a query outlives the session it was bound to, so
190
- // a stale object — or a different object that happens to carry the same
191
- // localId — must not be able to act as the Driver.
192
142
  if (sm.sessions.get(caller.localId) !== caller) {
193
143
  throw new Error("this session is no longer live; the pair tools are bound to an exact session");
194
144
  }
@@ -198,24 +148,25 @@ function attachPairLifecycle(ctx) {
198
148
  if (!group) throw new Error("this session is not in a split group");
199
149
  if (!group.pair) throw new Error("this split group has no Driver/Split Worker roles");
200
150
  if (group.pair.driverId !== caller.localId) throw new Error("only the configured Driver can direct this pair");
201
- var worker = sm.sessions.get(group.pair.workerId);
202
- if (!worker) throw new Error("split partner session was not found");
151
+ var targetOptions = Object.assign({}, options || { mutation: true }, {
152
+ multiWorkerFeature: ctx.multiWorkerFeature,
153
+ });
154
+ var resolved = pairTarget.resolveGroupTarget(sm, group, caller, args, targetOptions);
155
+ var worker = resolved.partner;
203
156
  if ((caller.ownerId || null) !== (worker.ownerId || null)) throw new Error("split partner access denied");
204
- return { group: group, worker: worker };
157
+ return { group: resolved.group, worker: resolved.partner, role: resolved.role, workerId: resolved.workerId };
205
158
  }
206
-
207
159
  function replaceBlockedReason(worker) {
208
160
  if (worker.isProcessing || worker._queryStarting) return "the Split Worker is mid-turn";
209
161
  if (worker._pairDelegation) return "a delegated task is still open";
210
162
  return null;
211
163
  }
212
-
213
- function partnerStatus(caller) {
214
- var resolved = resolveDriverPair(caller);
164
+ function partnerStatus(caller, args) {
165
+ var resolved = resolveDriverPair(caller, args, { readOnly: true });
215
166
  var worker = resolved.worker;
216
167
  var blocked = turnControl.blockedReason(caller) || replaceBlockedReason(worker);
217
168
  var resumeBlocked = turnControl.blockedReason(caller) || (worker.isProcessing || worker._queryStarting ? "the Split Worker is mid-turn" : (!worker._pairInterruption ? "the Split Worker has no interrupted task" : null));
218
- var ledger = ledgerFor(caller).map(function (record) {
169
+ var ledger = ledgerFor(caller, worker).map(function (record) {
219
170
  return {
220
171
  generation: record.generation,
221
172
  vendor: record.vendor,
@@ -267,21 +218,49 @@ function attachPairLifecycle(ctx) {
267
218
  generations: ledger,
268
219
  };
269
220
  }
270
-
221
+ function sourcePairRegistered(caller, worker) {
222
+ try {
223
+ if (sm.sessions.get(caller.localId) !== caller || sm.sessions.get(worker.localId) !== worker) return false;
224
+ if ((caller.ownerId || null) !== (worker.ownerId || null)) return false;
225
+ var group = store.groupForMember(caller.localId);
226
+ var normalized = splitRoles.normalizePair(group && group.pair, group && group.members);
227
+ return !!(normalized.ok && normalized.kind !== "adhoc" && normalized.driverId === caller.localId &&
228
+ normalized.workerIds.indexOf(worker.localId) !== -1);
229
+ } catch (e) { return false; }
230
+ }
231
+ function restoreSourcePair(ws, caller, worker) {
232
+ try {
233
+ return store.create(ws, {
234
+ members: [caller.localId, worker.localId],
235
+ pair: { driverId: caller.localId, workerId: worker.localId },
236
+ });
237
+ } catch (e) { return { ok: false, error: e.message || String(e) }; }
238
+ }
239
+ function replacementTargetRegistered(caller, oldWorker, sourceWorkerIds) {
240
+ try {
241
+ if (sm.sessions.get(caller.localId) !== caller || sm.sessions.get(oldWorker.localId) !== oldWorker) return null;
242
+ var group = store.groupForMember(caller.localId);
243
+ var normalized = splitRoles.normalizePair(group && group.pair, group && group.members);
244
+ if (!normalized.ok || normalized.kind === "adhoc" || normalized.driverId !== caller.localId) return null;
245
+ var candidates = normalized.workerIds.filter(function (id) { return sourceWorkerIds.indexOf(id) === -1; });
246
+ if (candidates.length !== 1) return null;
247
+ var target = sm.sessions.get(candidates[0]);
248
+ if (!target || sm.sessions.get(target.localId) !== target) return null;
249
+ return (caller.ownerId || null) === (target.ownerId || null) ? target : null;
250
+ } catch (e) { return null; }
251
+ }
271
252
  function replacePartner(args, caller) {
272
- var resolved = resolveDriverPair(caller);
253
+ var resolved = resolveDriverPair(caller, args, { mutation: true });
273
254
  var group = resolved.group;
274
255
  var oldWorker = resolved.worker;
256
+ var versioned = resolved.role.kind === "versioned";
257
+ var sourceWorkerIds = resolved.role.workerIds.slice();
275
258
  ledgerFor(caller);
276
259
  turnControl.assertWorkerAction(caller);
277
260
  var replacementEntry = replacementState.begin(caller, args.transactionId, oldWorker, replacementFingerprint(args, oldWorker));
278
261
  var replay = replacementState.replayValue(replacementEntry);
279
262
  if (replay) return Promise.resolve(replay);
280
263
 
281
- // Validate the replacement while the old pair is still fully intact. An
282
- // uninstalled vendor, an unavailable model or an unsupported effort must
283
- // never cost the user their running Worker, so this precedes the interrupt,
284
- // the permission cancellation and the dissolve.
285
264
  try {
286
265
  ctx.preflightWorkerForDriver(caller, {
287
266
  workerVendor: args.workerVendor,
@@ -309,72 +288,77 @@ function attachPairLifecycle(ctx) {
309
288
  throw validationError;
310
289
  }
311
290
 
312
- if (blocked) {
313
- replacementState.stage(replacementEntry.transaction, "stopping_source");
314
- replacementEntry.transaction.sourceStopped = true;
315
- if (ctx.markInterruption) ctx.markInterruption(oldWorker, "driver", "The Driver accepted replacement of this Worker generation.");
316
- oldWorker.taskStopRequested = true;
317
- if (oldWorker.abortController) {
318
- try { oldWorker.abortController.abort(); } catch (e) {}
319
- }
320
- if (oldWorker._pairDelegation && typeof ctx.finishDelegation === "function") {
321
- if (typeof ctx.completeInterruptedTask === "function") ctx.completeInterruptedTask(oldWorker, caller, oldWorker._pairDelegation);
322
- ctx.finishDelegation(group, caller, oldWorker, oldWorker._pairDelegation);
291
+ var ws = { _clayUser: caller.ownerId ? { id: caller.ownerId } : null };
292
+ try {
293
+ if (blocked) {
294
+ replacementState.stage(replacementEntry.transaction, "stopping_source");
295
+ replacementEntry.transaction.sourceStopped = true;
296
+ if (ctx.markInterruption) ctx.markInterruption(oldWorker, "driver", "The Driver accepted replacement of this Worker generation.");
297
+ oldWorker.taskStopRequested = true;
298
+ if (oldWorker.abortController) {
299
+ try { oldWorker.abortController.abort(); } catch (e) {}
300
+ }
301
+ if (oldWorker._pairDelegation && typeof ctx.finishDelegation === "function") {
302
+ if (typeof ctx.completeInterruptedTask === "function") ctx.completeInterruptedTask(oldWorker, caller, oldWorker._pairDelegation);
303
+ ctx.finishDelegation(group, caller, oldWorker, oldWorker._pairDelegation);
304
+ }
323
305
  }
324
- }
325
306
 
326
- // Any permission decision the old Worker was waiting on dies with the pair.
327
- replacementState.stage(replacementEntry.transaction, "cancelling_source_waits");
328
- if (typeof ctx.cancelWorkerPermissions === "function") {
329
- ctx.cancelWorkerPermissions(oldWorker, "The Driver replaced this Split Worker.");
330
- }
307
+ replacementState.stage(replacementEntry.transaction, "cancelling_source_waits");
308
+ if (typeof ctx.cancelWorkerPermissions === "function") {
309
+ ctx.cancelWorkerPermissions(oldWorker, "The Driver replaced this Split Worker.");
310
+ }
331
311
 
332
- var ws = { _clayUser: caller.ownerId ? { id: caller.ownerId } : null };
333
- replacementState.stage(replacementEntry.transaction, "dissolving_source_pair");
334
- var dissolved = store.dissolve(ws, { id: group.id });
335
- if (!dissolved.ok) {
312
+ if (!versioned) {
313
+ replacementState.stage(replacementEntry.transaction, "dissolving_source_pair");
314
+ var dissolved = store.dissolve(ws, { id: group.id });
315
+ if (!dissolved.ok) throw new Error(dissolved.error || "could not dissolve the existing pair");
316
+ }
317
+ } catch (precreateError) {
318
+ var sourceIntact = sourcePairRegistered(caller, oldWorker);
319
+ var precreateRestored = sourceIntact ? { ok: true } : restoreSourcePair(ws, caller, oldWorker);
320
+ var rollback = precreateRestored && precreateRestored.ok ? (sourceIntact ? "safe_retry" : "source_pair_restored") : "source_pair_restore_failed";
336
321
  turnControl.releaseCreation(creationTicket);
337
- replacementState.fail(replacementEntry.transaction, "dissolve_failed", new Error(dissolved.error || "could not dissolve"), "not_required");
338
- throw new Error(dissolved.error || "could not dissolve the existing pair");
322
+ replacementState.fail(replacementEntry.transaction, "precreate_failed", precreateError, rollback);
323
+ throw precreateError;
339
324
  }
340
325
 
341
- // History is preserved: the old Worker session stays in the project.
342
- //
343
- // Preflight already cleared every input, but the group write itself can
344
- // still fail, so the dissolve is rolled back rather than leaving the Driver
345
- // with no pair at all. The restored group is an equivalent record with the
346
- // same members and roles; its group id is newly issued.
347
- //
348
- // What rollback can and cannot undo:
349
- // - Idle replacement failure is fully recoverable. The Worker session,
350
- // its history and its still-open ledger generation are all preserved,
351
- // and the pair is restored.
352
- // - An explicit interrupt=true is NOT reversible. Stopping a mid-turn
353
- // Worker aborts its query and cancels the permission decisions it was
354
- // waiting on; a later creation failure cannot resume that turn. The
355
- // session and its history survive, but the interrupted work does not
356
- // come back. The error says so rather than implying a clean restore.
357
326
  var created;
358
327
  try {
359
328
  replacementState.stage(replacementEntry.transaction, "creating_target");
360
- created = ctx.createWorkerForDriver(caller, {
329
+ var createReplacement = versioned ? ctx.replaceWorkerForDriver : ctx.createWorkerForDriver;
330
+ created = createReplacement(caller, versioned ? oldWorker : {
361
331
  workerVendor: typeof args.workerVendor === "string" ? args.workerVendor : "",
362
332
  workerModel: typeof args.workerModel === "string" ? args.workerModel : "",
363
333
  workerEffort: typeof args.workerEffort === "string" ? args.workerEffort : "",
364
- });
334
+ }, versioned ? {
335
+ workerVendor: typeof args.workerVendor === "string" ? args.workerVendor : "",
336
+ workerModel: typeof args.workerModel === "string" ? args.workerModel : "",
337
+ workerEffort: typeof args.workerEffort === "string" ? args.workerEffort : "",
338
+ expectedGroup: group,
339
+ expectedWorkerIds: sourceWorkerIds,
340
+ } : undefined);
365
341
  } catch (e) {
366
- var restored = store.create(ws, {
367
- members: [caller.localId, oldWorker.localId],
368
- pair: { driverId: caller.localId, workerId: oldWorker.localId },
369
- });
370
- var restoreNote = restored && restored.ok
342
+ var target = replacementTargetRegistered(caller, oldWorker, sourceWorkerIds);
343
+ if (target) {
344
+ var targetClosed = closeGeneration(caller, oldWorker);
345
+ var targetGeneration = recordGenerationStart(caller, target);
346
+ replacementEntry.transaction.targetWorkerId = target.localId;
347
+ replacementEntry.transaction.targetGeneration = targetGeneration;
348
+ replacementEntry.transaction.previousGeneration = targetClosed ? targetClosed.generation : null;
349
+ replacementState.fail(replacementEntry.transaction, "postcommit_delivery_failed", e, "target_created");
350
+ throw new Error("the replacement Split Worker was created, but its delivery confirmation failed. Reuse Worker " + target.localId + " generation " + targetGeneration + " with send_to_partner; do not replace it again. " + (e.message || String(e)));
351
+ }
352
+ var restored = restoreSourcePair(ws, caller, oldWorker);
353
+ var restoredLive = restored && restored.ok && sourcePairRegistered(caller, oldWorker);
354
+ var restoreNote = restoredLive
371
355
  ? "The previous pair was restored with its session, history and open generation intact."
372
356
  : "The previous pair could not be restored; both sessions are intact and unpaired.";
373
357
  var interruptNote = blocked
374
358
  ? " Its interrupted turn cannot be resumed, because stopping it was explicitly requested."
375
359
  : "";
376
360
  turnControl.releaseCreation(creationTicket);
377
- replacementState.fail(replacementEntry.transaction, "creation_failed", e, restored && restored.ok ? "source_pair_restored" : "source_pair_restore_failed");
361
+ replacementState.fail(replacementEntry.transaction, "creation_failed", e, restoredLive ? "source_pair_restored" : "source_pair_restore_failed");
378
362
  throw new Error("could not create the replacement Split Worker: " + (e.message || String(e)) +
379
363
  ". " + restoreNote + interruptNote);
380
364
  }
@@ -384,7 +368,6 @@ function attachPairLifecycle(ctx) {
384
368
  if (typeof sm.saveSessionFile === "function") sm.saveSessionFile(caller);
385
369
  }
386
370
  var generation = recordGenerationStart(caller, created.worker);
387
-
388
371
  var result = {
389
372
  status: "replaced",
390
373
  previousWorkerSessionId: oldWorker.localId,
@@ -403,14 +386,13 @@ function attachPairLifecycle(ctx) {
403
386
 
404
387
  var message = typeof args.message === "string" ? args.message.trim() : "";
405
388
  if (!message) return Promise.resolve(result);
406
- return Promise.resolve(ctx.sendToPartner({ message: message, wait: args.wait, timeoutSeconds: args.timeoutSeconds }, caller))
389
+ return Promise.resolve(ctx.sendToPartner({ message: message, wait: args.wait, timeoutSeconds: args.timeoutSeconds, workerId: created.worker.localId }, caller))
407
390
  .then(function (delivered) {
408
391
  result.delivery = delivered;
409
392
  return result;
410
393
  });
411
394
  }
412
395
 
413
- // No global or cross-user ranking is formed from Worker evaluations.
414
396
  function validateEvaluation(raw) {
415
397
  var input = typeof raw === "string" ? { outcome: raw } : (raw && typeof raw === "object" ? raw : {});
416
398
  var outcome = typeof input.outcome === "string" ? input.outcome.trim().toLowerCase() : "";
@@ -430,8 +412,8 @@ function attachPairLifecycle(ctx) {
430
412
  }
431
413
 
432
414
  function recordEvaluation(args, caller) {
433
- var resolved = resolveDriverPair(caller);
434
- var ledger = ledgerFor(caller);
415
+ var resolved = resolveDriverPair(caller, args, { mutation: true });
416
+ var ledger = ledgerFor(caller, resolved.worker);
435
417
  var target = Number.isInteger(args.generation)
436
418
  ? (function () {
437
419
  for (var i = 0; i < ledger.length; i++) {
@@ -453,14 +435,14 @@ function attachPairLifecycle(ctx) {
453
435
  };
454
436
  }
455
437
 
456
- function optionalStatus(boundSession) {
457
- try { return partnerStatus(boundSession); } catch (e) { return null; }
438
+ function optionalStatus(boundSession, args) {
439
+ try { return partnerStatus(boundSession, args); } catch (e) { return null; }
458
440
  }
459
441
 
460
442
  function toolHandlers(boundSession) {
461
443
  return {
462
- status: function () {
463
- try { return toolResult(partnerStatus(boundSession)); }
444
+ status: function (args) {
445
+ try { return toolResult(partnerStatus(boundSession, args || {})); }
464
446
  catch (e) { return toolError(e.message || String(e)); }
465
447
  },
466
448
  replace: function (args) {
@@ -17,7 +17,7 @@ function attachPairMessage(ctx) {
17
17
  if (!id) return toolResult({ status: "rejected", requestId: null, reason: "requestId is invalid" });
18
18
  try {
19
19
  ctx.turnControl.assertWorkerAction(caller);
20
- var resolved = ctx.resolvePair(caller);
20
+ var resolved = ctx.resolvePair(caller, args, { mutation: true });
21
21
  var worker = resolved.partner;
22
22
  var message = typeof args.message === "string" ? args.message.trim() : "";
23
23
  if (!message) return toolResult({ status: "rejected", requestId: id, partnerId: worker.localId, reason: "message is required" });
@@ -1,10 +1,13 @@
1
1
  function stopOwnedPair(session, roles, turnControl, taskControl, workerPermission, reason) {
2
2
  if (!roles) return false;
3
3
  turnControl.markHumanStop(session);
4
- taskControl.cancelAll(roles.worker);
5
- taskControl.markInterruption(roles.worker, "user", reason || "The human stopped this scheduled run.");
6
- workerPermission.cancelForSession(roles.worker, reason || "The human stopped this scheduled run.");
7
- var members = [roles.driver, roles.worker];
4
+ var workers = roles.workers || [roles.worker];
5
+ for (var wi = 0; wi < workers.length; wi++) {
6
+ taskControl.cancelAll(workers[wi]);
7
+ taskControl.markInterruption(workers[wi], "user", reason || "The human stopped this scheduled run.");
8
+ workerPermission.cancelForSession(workers[wi], reason || "The human stopped this scheduled run.");
9
+ }
10
+ var members = [roles.driver].concat(workers);
8
11
  for (var i = 0; i < members.length; i++) {
9
12
  members[i].taskStopRequested = true;
10
13
  if (members[i].queryInstance && typeof members[i].queryInstance.close === "function") {
@@ -46,7 +46,11 @@ function fail(transaction, stageName, error, rollback) {
46
46
  transaction.stage = stageName;
47
47
  transaction.updatedAt = Date.now();
48
48
  transaction.completedAt = transaction.updatedAt;
49
- transaction.failure = { message: error && (error.message || String(error)) || "Unknown replacement failure", rollback: rollback || "not_required" };
49
+ transaction.failure = {
50
+ message: error && (error.message || String(error)) || "Unknown replacement failure",
51
+ rollback: rollback || "not_required",
52
+ retryable: rollback === "safe_retry" || (stageName === "creation_failed" && rollback === "source_pair_restored"),
53
+ };
50
54
  transaction.sourcePairRestored = rollback === "source_pair_restored" ? true : (rollback === "source_pair_restore_failed" ? false : null);
51
55
  return transaction;
52
56
  }
@@ -0,0 +1,116 @@
1
+ function attachPairResultCapture(ctx) {
2
+ var splitRoles = require("./session-split-group-roles");
3
+ function current(caller, partner, token) {
4
+ var group = ctx.store.groupForMember(partner.localId);
5
+ return !!(group && group.id === token.groupId && partner._pairDelegation === token &&
6
+ splitRoles.matchesLiveTarget(ctx.sm, group, caller, partner) && splitRoles.tokenMatchesWorker(partner, token));
7
+ }
8
+ function begin(caller, partner, token, message, projectSlug) {
9
+ if (!ctx.resultOutbox) return { ok: true };
10
+ var driverOriginId = ctx.resultOutbox.stableOrigin(caller), workerOriginId = ctx.resultOutbox.stableOrigin(partner);
11
+ if (!driverOriginId || !workerOriginId) return { ok: false, error: "durable session origins are required before dispatch" };
12
+ var started = ctx.resultOutbox.begin({ ownerId: caller.ownerId, projectSlug: projectSlug,
13
+ driverOriginId: driverOriginId, workerOriginId: workerOriginId,
14
+ taskId: token.taskId, generation: token.generation, driverSessionId: caller.localId,
15
+ workerSessionId: partner.localId, groupId: token.groupId,
16
+ historyStartIndex: token.startIndex, message: message,
17
+ deliveryRoute: token.wait === true ? "blocking" : "callback" });
18
+ if (started.ok) token.outboxKey = started.key;
19
+ return started;
20
+ }
21
+ function finish(caller, partner, token, delivered) {
22
+ var group = ctx.store.groupForMember(partner.localId);
23
+ if (token._captureFinished || !current(caller, partner, token)) return false;
24
+ token._captureFinished = true;
25
+ try {
26
+ if (typeof ctx.onPartnerResult === "function") ctx.onPartnerResult(caller, partner, token);
27
+ if (token.outboxKey && ctx.resultOutbox) {
28
+ var finalized = ctx.resultOutbox.markFinalized(token.outboxKey);
29
+ if (!finalized.ok) throw new Error(finalized.error || "completed result finalization could not be saved");
30
+ }
31
+ ctx.finishDelegation(group, caller, partner, token);
32
+ if (typeof ctx.onStateChange === "function" && token.outboxKey) ctx.onStateChange(caller, ctx.resultOutbox.get(token.outboxKey));
33
+ } catch (error) {
34
+ token._captureFinished = false;
35
+ token._capturePending = true;
36
+ token._captureHookError = error.message || String(error);
37
+ if (token.outboxKey && ctx.resultOutbox) {
38
+ var failed = ctx.resultOutbox.markFinalizationFailure(token.outboxKey, token._captureHookError);
39
+ if (typeof ctx.onStateChange === "function") ctx.onStateChange(caller, Object.assign({}, failed.record || ctx.resultOutbox.get(token.outboxKey), {
40
+ finalizationState: "failed", finalizationError: token._captureHookError, persistenceError: failed.ok ? null : failed.error,
41
+ }));
42
+ }
43
+ return false;
44
+ }
45
+ delete token._capturePending;
46
+ var resumed = delivered ? true : ctx.resumeDriverWithResult(caller, partner, token);
47
+ if (!partner._pairClosing) ctx.drain(caller, partner);
48
+ return resumed;
49
+ }
50
+ function capture(caller, partner, token, outcome) {
51
+ if (!ctx.resultOutbox || !token.outboxKey) return finish(caller, partner, token);
52
+ if (token._captureInProgress || token._captureComplete) return false;
53
+ token._captureInProgress = true;
54
+ var saved;
55
+ try {
56
+ saved = ctx.resultOutbox.capture(token.outboxKey, outcome, function (retryResult) {
57
+ token._captureInProgress = false;
58
+ if (retryResult && retryResult.ok) {
59
+ token._captureComplete = true;
60
+ var capturedRecord = ctx.resultOutbox.get(token.outboxKey);
61
+ if (typeof ctx.onStateChange === "function") ctx.onStateChange(caller, capturedRecord);
62
+ if (capturedRecord && capturedRecord.deliveryRoute === "callback") deliver(caller, partner, token);
63
+ else finish(caller, partner, token);
64
+ }
65
+ }, function () { return current(caller, partner, token); });
66
+ } catch (error) {
67
+ token._captureInProgress = false;
68
+ token._capturePending = true;
69
+ token._captureHookError = error.message || String(error);
70
+ return false;
71
+ }
72
+ if (saved.ok) { token._captureInProgress = false; token._captureComplete = true; }
73
+ if (saved.ok) {
74
+ var capturedRecord = ctx.resultOutbox.get(token.outboxKey);
75
+ if (typeof ctx.onStateChange === "function") ctx.onStateChange(caller, capturedRecord);
76
+ var delivered = capturedRecord && capturedRecord.deliveryRoute === "callback" ? deliver(caller, partner, token) : finish(caller, partner, token);
77
+ return delivered === false && token._capturePending ? false : delivered;
78
+ }
79
+ token._capturePending = true;
80
+ if (!token._captureFailureReported) {
81
+ token._captureFailureReported = true;
82
+ try { ctx.sm.sendAndRecord(caller, { type: "error", text: "The Split Worker completed, but its result could not be durably captured yet. The task remains retained for bounded server retry." }); } catch (error) { token._captureNoticeError = error.message || String(error); }
83
+ }
84
+ return false;
85
+ }
86
+ function prepare(caller, partner, token, outcome) {
87
+ if (!ctx.resultOutbox || !token.outboxKey) return { ok: true, prepared: false };
88
+ var saved = ctx.resultOutbox.prepareCapture(token.outboxKey, outcome);
89
+ return saved.ok ? { ok: true, prepared: true, duplicate: !!saved.duplicate } : saved;
90
+ }
91
+ function rollbackPrepared(token, outcome) {
92
+ if (!ctx.resultOutbox || !token.outboxKey) return { ok: true };
93
+ return ctx.resultOutbox.rollbackPreparedCapture(token.outboxKey, outcome);
94
+ }
95
+ function finalizePrepared(token, outcome) {
96
+ if (!ctx.resultOutbox || !token.outboxKey) return { ok: true, record: null };
97
+ return ctx.resultOutbox.finalizePreparedCapture(token.outboxKey, outcome);
98
+ }
99
+ function acceptPrepared(caller, partner, token) {
100
+ if (ctx.resultOutbox && token.outboxKey) {
101
+ token._captureComplete = true;
102
+ var record = ctx.resultOutbox.get(token.outboxKey);
103
+ if (typeof ctx.onStateChange === "function") ctx.onStateChange(caller, record);
104
+ return record && record.deliveryRoute === "callback" ? deliver(caller, partner, token) : finish(caller, partner, token);
105
+ }
106
+ return finish(caller, partner, token);
107
+ }
108
+ function deliver(caller, partner, token) {
109
+ if (!ctx.delivery) return finish(caller, partner, token);
110
+ return ctx.delivery.wake(caller, partner, token);
111
+ }
112
+ return { begin: begin, capture: capture, prepare: prepare, finalizePrepared: finalizePrepared, rollbackPrepared: rollbackPrepared,
113
+ acceptPrepared: acceptPrepared, deliver: deliver, finish: finish };
114
+ }
115
+
116
+ module.exports = { attachPairResultCapture: attachPairResultCapture };