newmark-agent 0.6.1 → 0.6.3

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.
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ConversationKernel = void 0;
4
4
  const agent_1 = require("./agent");
5
+ const store_1 = require("./continuation/store");
5
6
  const crypto_1 = require("crypto");
6
7
  const conversationTarget_1 = require("./conversationTarget");
7
8
  function changedLineCount(value) {
@@ -73,20 +74,131 @@ class ConversationKernel {
73
74
  return runtimes.map(runtime => runtime.runtimeKey);
74
75
  }
75
76
  /** A Flow and its queue share one Agent; no second history writer is created. */
76
- beginExternalRun(targetInput, options, createOwner, previousQueuePaused) {
77
+ beginExternalRun(targetInput, options, createOwner, previousQueuePaused, buildInput) {
77
78
  const target = this.normalizeTarget(targetInput);
78
79
  const existing = this.findRuntime(target);
79
80
  if (existing?.activePromise || existing?.externalOwner?.active)
80
81
  throw new Error('Target conversation is already running');
81
82
  const runtime = existing || this.runtime(target, options, createOwner());
83
+ this.reconcilePendingContinuations(runtime);
82
84
  runtime.options = { ...options };
83
85
  runtime.externalOwner = { active: true, wasPaused: runtime.externalOwner?.wasPaused ?? previousQueuePaused ?? runtime.queuePaused };
84
86
  runtime.queuePaused = true;
85
87
  runtime.runId ||= (0, crypto_1.randomUUID)();
86
88
  runtime.stopRequestedRunId = '';
89
+ if (buildInput) {
90
+ const continuation = this.continuationStore(runtime);
91
+ if (continuation) {
92
+ if (buildInput.existingBuildId) {
93
+ runtime.externalBuildId = buildInput.existingBuildId;
94
+ runtime.externalScope = continuation.store.claimExternalBuild({
95
+ workspaceId: continuation.workspaceId,
96
+ rootId: continuation.rootId,
97
+ branchId: continuation.branchId,
98
+ buildId: buildInput.existingBuildId,
99
+ workerCapability: 'flow-external',
100
+ });
101
+ }
102
+ else if (buildInput.resume && runtime.externalBuildId) {
103
+ runtime.externalScope = continuation.store.claimExternalBuild({
104
+ workspaceId: continuation.workspaceId,
105
+ rootId: continuation.rootId,
106
+ branchId: continuation.branchId,
107
+ buildId: runtime.externalBuildId,
108
+ workerCapability: 'flow-external',
109
+ });
110
+ }
111
+ else {
112
+ const authoritative = continuation.store.snapshot({
113
+ workspaceId: continuation.workspaceId,
114
+ rootId: continuation.rootId,
115
+ branchId: continuation.branchId,
116
+ });
117
+ const receipt = continuation.store.enqueueBuild({
118
+ schemaVersion: 1,
119
+ type: 'ENQUEUE_BUILD',
120
+ commandId: buildInput.commandId || (0, crypto_1.randomUUID)(),
121
+ actorId: 'flow',
122
+ target: {
123
+ workspaceId: continuation.workspaceId,
124
+ expectedRootId: continuation.rootId,
125
+ branchId: continuation.branchId,
126
+ },
127
+ expectedTailBuildId: authoritative.tailBuildId,
128
+ expectedQueueRevision: authoritative.queueRevision,
129
+ input: {
130
+ text: buildInput.text,
131
+ requestedMode: buildInput.requestedMode || 'flow',
132
+ goalObjective: buildInput.goalObjective,
133
+ modelSelection: buildInput.modelSelection || runtime.options.model,
134
+ branchNodeId: buildInput.branchNodeId,
135
+ branchPath: buildInput.branchPath,
136
+ },
137
+ recipeSnapshotId: `${continuation.rootId}:${continuation.branchId}:flow`,
138
+ createdAt: new Date().toISOString(),
139
+ });
140
+ runtime.externalBuildId = receipt.buildId;
141
+ runtime.externalScope = continuation.store.claimExternalBuild({
142
+ workspaceId: continuation.workspaceId,
143
+ rootId: continuation.rootId,
144
+ branchId: continuation.branchId,
145
+ buildId: receipt.buildId,
146
+ workerCapability: 'flow-external',
147
+ });
148
+ }
149
+ }
150
+ }
87
151
  this.emitQueueUpdate(runtime);
88
152
  return runtime.runner;
89
153
  }
154
+ /**
155
+ * Admit a Flow build at command-acceptance time. The Flow may wait behind a
156
+ * running Build, but its queue position (and therefore the parent of every
157
+ * later Next item) is fixed now, not when the Flow finally starts.
158
+ */
159
+ admitExternalBuild(targetInput, buildInput) {
160
+ const target = this.normalizeTarget(targetInput);
161
+ const existing = this.findRuntime(target);
162
+ const runtime = existing || this.runtime(target, {
163
+ mode: 'flow',
164
+ model: buildInput.modelSelection || this.host.modelSelectionValue(),
165
+ intelligence: this.host.intelligence,
166
+ inputMode: this.host.inputMode,
167
+ engine: this.host.engine,
168
+ }, this.createRunner(target));
169
+ const continuation = this.continuationStore(runtime);
170
+ if (!continuation)
171
+ return null;
172
+ const authoritative = continuation.store.snapshot({
173
+ workspaceId: continuation.workspaceId,
174
+ rootId: continuation.rootId,
175
+ branchId: continuation.branchId,
176
+ });
177
+ const receipt = continuation.store.enqueueBuild({
178
+ schemaVersion: 1,
179
+ type: 'ENQUEUE_BUILD',
180
+ commandId: buildInput.commandId,
181
+ actorId: 'flow',
182
+ target: {
183
+ workspaceId: continuation.workspaceId,
184
+ expectedRootId: continuation.rootId,
185
+ branchId: continuation.branchId,
186
+ },
187
+ expectedTailBuildId: authoritative.tailBuildId,
188
+ expectedQueueRevision: authoritative.queueRevision,
189
+ input: {
190
+ text: buildInput.text,
191
+ requestedMode: buildInput.requestedMode || 'flow',
192
+ goalObjective: buildInput.goalObjective,
193
+ modelSelection: buildInput.modelSelection || runtime.options.model,
194
+ branchNodeId: buildInput.branchNodeId,
195
+ branchPath: buildInput.branchPath,
196
+ },
197
+ recipeSnapshotId: `${continuation.rootId}:${continuation.branchId}:flow`,
198
+ createdAt: new Date().toISOString(),
199
+ });
200
+ return receipt.buildId;
201
+ }
90
202
  settleExternalRun(target, completed) {
91
203
  const runtime = this.findRuntime(target);
92
204
  if (!runtime?.externalOwner)
@@ -96,6 +208,46 @@ class ConversationKernel {
96
208
  runtime.externalOwner = undefined;
97
209
  else
98
210
  runtime.externalOwner.active = false;
211
+ if (runtime.externalBuildId) {
212
+ const continuation = this.continuationStore(runtime);
213
+ if (continuation) {
214
+ if (completed) {
215
+ if (runtime.externalScope) {
216
+ continuation.store.commitFinal({
217
+ scope: runtime.externalScope,
218
+ text: runtime.externalFinalText || '',
219
+ });
220
+ }
221
+ else {
222
+ continuation.store.commitExternalBuild({
223
+ workspaceId: continuation.workspaceId,
224
+ rootId: continuation.rootId,
225
+ branchId: continuation.branchId,
226
+ buildId: runtime.externalBuildId,
227
+ text: runtime.externalFinalText || '',
228
+ });
229
+ }
230
+ runtime.externalBuildId = undefined;
231
+ runtime.externalScope = undefined;
232
+ runtime.externalFinalText = undefined;
233
+ }
234
+ else {
235
+ // Suspend/resume keeps the same durable build identity. Release the
236
+ // current attempt back to QUEUED so the resume can claim a new
237
+ // attempt/fence without creating a second Flow build.
238
+ try {
239
+ continuation.store.requeueBuild({
240
+ workspaceId: continuation.workspaceId,
241
+ rootId: continuation.rootId,
242
+ branchId: continuation.branchId,
243
+ buildId: runtime.externalBuildId,
244
+ });
245
+ }
246
+ catch { /* an external failure may already have terminated the build */ }
247
+ runtime.externalScope = undefined;
248
+ }
249
+ }
250
+ }
99
251
  runtime.queuePaused = completed && !runtime.preparingArchive ? wasPaused : true;
100
252
  runtime.runner.saveWorkspaceConversationState(true);
101
253
  this.emitQueueUpdate(runtime);
@@ -108,6 +260,27 @@ class ConversationKernel {
108
260
  return;
109
261
  if (runtime.externalOwner?.active)
110
262
  throw new Error('Wait for the Flow to stop before releasing its queue');
263
+ if (runtime.externalBuildId) {
264
+ const continuation = this.continuationStore(runtime);
265
+ if (continuation) {
266
+ // The user explicitly exited the paused Flow and resumed the queue.
267
+ // Record that decision as the Flow build's final instead of leaving a
268
+ // cancelled parent that would permanently block its queued successor.
269
+ try {
270
+ continuation.store.commitExternalBuild({
271
+ workspaceId: continuation.workspaceId,
272
+ rootId: continuation.rootId,
273
+ branchId: continuation.branchId,
274
+ buildId: runtime.externalBuildId,
275
+ text: 'Flow exited by the user; the conversation queue resumed.',
276
+ });
277
+ }
278
+ catch { /* the Flow build may already be committed or explicitly cancelled */ }
279
+ }
280
+ runtime.externalBuildId = undefined;
281
+ runtime.externalScope = undefined;
282
+ runtime.externalFinalText = undefined;
283
+ }
111
284
  runtime.externalOwner = undefined;
112
285
  this.setQueuePaused(target, paused);
113
286
  }
@@ -225,6 +398,7 @@ class ConversationKernel {
225
398
  return [];
226
399
  return [{
227
400
  id,
401
+ buildId: item.message.buildId,
228
402
  text: String(item.message.visibleUserInput || item.message.text || '').replace(/^\[Next queued while current turn is running\]\n/, ''),
229
403
  queueMode: item.queueMode,
230
404
  requestedMode: item.message.visibleMode,
@@ -232,9 +406,56 @@ class ConversationKernel {
232
406
  runId: item.message.runId,
233
407
  createdAt: String(item.message.createdAt || ''),
234
408
  images: item.message.images?.map(image => ({ ...image })),
409
+ branchNodeId: item.message.branchNodeId,
410
+ branchPath: item.message.branchPath?.slice(),
411
+ modelSelection: item.message.modelSelection,
235
412
  }];
236
413
  });
237
414
  }
415
+ /**
416
+ * Capture the exact runtime branch that owns a queued user turn. A queued
417
+ * Next is a new user turn, but it must still land on the branch the user was
418
+ * viewing when they enqueued it, not on whatever branch happens to be active
419
+ * when the previous Build settles.
420
+ */
421
+ runtimeBranchIdentity(runtime) {
422
+ try {
423
+ const snapshot = runtime.runner.getConversationSnapshot(runtime.id);
424
+ const path = Array.isArray(snapshot.runtimeBranchNodePath)
425
+ ? snapshot.runtimeBranchNodePath.map(id => String(id || '')).filter(Boolean)
426
+ : [];
427
+ const branchNodeId = String(snapshot.runtimeBranchId || snapshot.activeBranchId || path[path.length - 1] || '');
428
+ return {
429
+ branchNodeId,
430
+ branchPath: path.length ? path : (branchNodeId ? [branchNodeId] : []),
431
+ };
432
+ }
433
+ catch {
434
+ return { branchNodeId: '', branchPath: [] };
435
+ }
436
+ }
437
+ /**
438
+ * Map the current conversation/branch model onto the continuation-v1
439
+ * Root/Branch identity. The ledger is workspace-scoped and shared by the
440
+ * PC GUI, hosted mobile API and isolated runtimes.
441
+ */
442
+ continuationStore(runtime, branchNodeIdOverride = '') {
443
+ const workspace = runtime.target.workspace;
444
+ if (!workspace?.path)
445
+ return null;
446
+ const workspaceId = String(workspace.id || workspace.path);
447
+ const branchIdentity = this.runtimeBranchIdentity(runtime);
448
+ const rootId = runtime.target.conversationId;
449
+ // Ledger branch ids are workspace-global, while a runtime branch node id
450
+ // is only unique inside its conversation/root. Namespace it by root so two
451
+ // conversations cannot share one authoritative branch record.
452
+ const branchId = branchNodeIdOverride.includes('::')
453
+ ? branchNodeIdOverride
454
+ : `${rootId}::${branchNodeIdOverride || branchIdentity.branchNodeId || 'main'}`;
455
+ const store = new store_1.GuardedContinuationStore(workspace.path, workspaceId);
456
+ store.ensureRoot(rootId, branchId);
457
+ return { store, workspaceId, rootId, branchId };
458
+ }
238
459
  enqueueNext(target, input) {
239
460
  const runtime = this.findRuntime(target);
240
461
  if (!runtime)
@@ -249,8 +470,54 @@ class ConversationKernel {
249
470
  return existing;
250
471
  const prompt = `[Next queued while current turn is running]\n${text}`;
251
472
  const createdAt = String(input.createdAt || new Date().toISOString());
473
+ const capturedBranch = this.runtimeBranchIdentity(runtime);
474
+ const branchPath = Array.isArray(input.branchPath) && input.branchPath.length
475
+ ? input.branchPath.map(id => String(id || '')).filter(Boolean)
476
+ : capturedBranch.branchPath;
477
+ const branchNodeId = String(input.branchNodeId || branchPath[branchPath.length - 1] || capturedBranch.branchNodeId || '');
478
+ const targetRuntimeKey = String(input.targetRuntimeKey || runtime.target.runtimeKey).trim() || runtime.target.runtimeKey;
479
+ const workspaceKey = runtime.target.workspaceKey;
480
+ const modelSelection = String(input.modelSelection || runtime.options.model || '').trim() || undefined;
481
+ // Gate B/C: admission is an authoritative CAS transaction. The in-memory
482
+ // pendingNextTurn list is only a projection of this durable build.
483
+ const continuation = this.continuationStore(runtime, branchNodeId);
484
+ const ledgerBranchId = continuation?.branchId;
485
+ let buildId = '';
486
+ if (continuation) {
487
+ const authoritative = continuation.store.snapshot({
488
+ workspaceId: continuation.workspaceId,
489
+ rootId: continuation.rootId,
490
+ branchId: continuation.branchId,
491
+ });
492
+ const receipt = continuation.store.enqueueBuild({
493
+ schemaVersion: 1,
494
+ type: 'ENQUEUE_BUILD',
495
+ commandId: id,
496
+ actorId: 'kernel',
497
+ target: {
498
+ workspaceId: continuation.workspaceId,
499
+ expectedRootId: continuation.rootId,
500
+ branchId: continuation.branchId,
501
+ },
502
+ expectedTailBuildId: authoritative.tailBuildId,
503
+ expectedQueueRevision: authoritative.queueRevision,
504
+ input: {
505
+ text,
506
+ images: input.images,
507
+ requestedMode: input.requestedMode,
508
+ goalObjective: input.goalObjective,
509
+ modelSelection,
510
+ branchNodeId: branchNodeId || undefined,
511
+ branchPath: branchPath.length ? branchPath : undefined,
512
+ },
513
+ recipeSnapshotId: `${continuation.rootId}:${continuation.branchId}:${modelSelection || 'auto'}`,
514
+ createdAt,
515
+ });
516
+ buildId = receipt.buildId;
517
+ }
252
518
  runtime.pendingNextTurn.push({
253
519
  message: {
520
+ buildId: buildId || undefined,
254
521
  text: prompt,
255
522
  visibleUserInput: text,
256
523
  visibleMode: String(input.requestedMode || 'build'),
@@ -258,11 +525,18 @@ class ConversationKernel {
258
525
  images: Array.isArray(input.images) ? input.images.slice(0, 6).map(image => ({ ...image })) : undefined,
259
526
  clientMessageId: id,
260
527
  runId: runtime.runId,
528
+ branchNodeId: branchNodeId || undefined,
529
+ branchPath: branchPath.length ? branchPath : undefined,
530
+ ledgerBranchId,
531
+ targetRuntimeKey,
532
+ workspaceKey,
533
+ modelSelection,
261
534
  createdAt,
262
535
  },
263
536
  queueMode: 'followUp',
264
537
  });
265
538
  runtime.runner.retainConversationContinuations([{
539
+ buildId: buildId || undefined,
266
540
  content: prompt,
267
541
  queueMode: 'followUp',
268
542
  clientMessageId: id,
@@ -272,6 +546,12 @@ class ConversationKernel {
272
546
  visibleUserInput: text,
273
547
  visibleMode: String(input.requestedMode || 'build'),
274
548
  goalObjective: input.goalObjective,
549
+ branchNodeId: branchNodeId || undefined,
550
+ branchPath: branchPath.length ? branchPath : undefined,
551
+ ledgerBranchId,
552
+ targetRuntimeKey,
553
+ workspaceKey,
554
+ modelSelection,
275
555
  }]);
276
556
  this.trackQueuedMessage(runtime, prompt, 'followUp');
277
557
  this.emitQueueUpdate(runtime);
@@ -291,14 +571,35 @@ class ConversationKernel {
291
571
  if (!pending || typeof pending.message === 'string')
292
572
  throw new Error('Queue item is no longer editable');
293
573
  const previous = runtime.runner.conversationContinuations().find(item => item.clientMessageId === id);
294
- pending.message.text = `[Next queued while current turn is running]\n${text}`;
574
+ const nextText = `[Next queued while current turn is running]\n${text}`;
575
+ const nextVisibleMode = input.requestedMode !== undefined ? input.requestedMode : pending.message.visibleMode;
576
+ const nextGoalObjective = input.goalObjective !== undefined ? input.goalObjective : pending.message.goalObjective;
577
+ const nextImages = input.images !== undefined
578
+ ? input.images.map(image => ({ ...image }))
579
+ : pending.message.images?.map(image => ({ ...image }));
580
+ const continuation = this.continuationStore(runtime, pending.message.ledgerBranchId || pending.message.branchNodeId);
581
+ if (continuation && pending.message.buildId) {
582
+ continuation.store.updateQueuedBuild({
583
+ workspaceId: continuation.workspaceId,
584
+ rootId: continuation.rootId,
585
+ branchId: continuation.branchId,
586
+ buildId: pending.message.buildId,
587
+ input: {
588
+ text,
589
+ images: nextImages,
590
+ requestedMode: nextVisibleMode,
591
+ goalObjective: nextGoalObjective,
592
+ modelSelection: pending.message.modelSelection,
593
+ branchNodeId: pending.message.branchNodeId,
594
+ branchPath: pending.message.branchPath,
595
+ },
596
+ });
597
+ }
598
+ pending.message.text = nextText;
295
599
  pending.message.visibleUserInput = text;
296
- if (input.requestedMode !== undefined)
297
- pending.message.visibleMode = input.requestedMode;
298
- if (input.goalObjective !== undefined)
299
- pending.message.goalObjective = input.goalObjective;
300
- if (input.images !== undefined)
301
- pending.message.images = input.images.map(image => ({ ...image }));
600
+ pending.message.visibleMode = nextVisibleMode;
601
+ pending.message.goalObjective = nextGoalObjective;
602
+ pending.message.images = nextImages;
302
603
  runtime.runner.retainConversationContinuations([{
303
604
  ...previous,
304
605
  content: pending.message.text,
@@ -309,6 +610,11 @@ class ConversationKernel {
309
610
  visibleUserInput: text,
310
611
  visibleMode: pending.message.visibleMode,
311
612
  goalObjective: pending.message.goalObjective,
613
+ branchNodeId: pending.message.branchNodeId,
614
+ branchPath: pending.message.branchPath,
615
+ targetRuntimeKey: pending.message.targetRuntimeKey,
616
+ workspaceKey: pending.message.workspaceKey,
617
+ modelSelection: pending.message.modelSelection,
312
618
  }]);
313
619
  this.emitQueueUpdate(runtime);
314
620
  return this.queueItems(runtime.target).find(item => item.id === id);
@@ -321,7 +627,19 @@ class ConversationKernel {
321
627
  const index = runtime.pendingNextTurn.findIndex(item => typeof item.message !== 'string' && item.message.clientMessageId === id);
322
628
  if (index < 0)
323
629
  return false;
324
- const [removed] = runtime.pendingNextTurn.splice(index, 1);
630
+ const removed = runtime.pendingNextTurn[index];
631
+ if (typeof removed.message !== 'string') {
632
+ const continuation = this.continuationStore(runtime, removed.message.ledgerBranchId || removed.message.branchNodeId);
633
+ if (continuation && removed.message.buildId) {
634
+ continuation.store.deleteQueuedBuild({
635
+ workspaceId: continuation.workspaceId,
636
+ rootId: continuation.rootId,
637
+ branchId: continuation.branchId,
638
+ buildId: removed.message.buildId,
639
+ });
640
+ }
641
+ }
642
+ runtime.pendingNextTurn.splice(index, 1);
325
643
  if (typeof removed.message !== 'string') {
326
644
  runtime.runner.consumeConversationContinuation({ content: removed.message.text, queueMode: removed.queueMode, clientMessageId: id });
327
645
  }
@@ -355,6 +673,27 @@ class ConversationKernel {
355
673
  return [];
356
674
  return [[String(item.message.clientMessageId), item]];
357
675
  }));
676
+ const firstOrdered = pendingById.get(orderedIds[0] || '');
677
+ const continuation = this.continuationStore(runtime, firstOrdered && typeof firstOrdered.message !== 'string'
678
+ ? (firstOrdered.message.ledgerBranchId || firstOrdered.message.branchNodeId || '')
679
+ : '');
680
+ if (continuation) {
681
+ const orderedBuildIds = orderedIds
682
+ .map(id => {
683
+ const item = pendingById.get(id);
684
+ return item && typeof item.message !== 'string' ? item.message.buildId : undefined;
685
+ })
686
+ .filter((id) => !!id);
687
+ if (orderedBuildIds.length !== orderedIds.length) {
688
+ throw new Error('A queued build id is missing from the authoritative ledger projection');
689
+ }
690
+ continuation.store.reorderQueuedBuilds({
691
+ workspaceId: continuation.workspaceId,
692
+ rootId: continuation.rootId,
693
+ branchId: continuation.branchId,
694
+ orderedBuildIds,
695
+ });
696
+ }
358
697
  const reorderedPending = orderedIds.map(id => pendingById.get(id));
359
698
  let nextIndex = 0;
360
699
  runtime.pendingNextTurn = runtime.pendingNextTurn.map(item => {
@@ -397,6 +736,10 @@ class ConversationKernel {
397
736
  goalObjective: input.goalObjective,
398
737
  createdAt: input.createdAt,
399
738
  images: input.images,
739
+ branchNodeId: input.branchNodeId,
740
+ branchPath: input.branchPath,
741
+ modelSelection: input.modelSelection,
742
+ targetRuntimeKey: input.targetRuntimeKey,
400
743
  });
401
744
  }
402
745
  else if (action === 'update') {
@@ -948,7 +1291,7 @@ class ConversationKernel {
948
1291
  // carry no send-time model/mode; the running block keeps its settings and
949
1292
  // the next dequeue follows the current conversation selection (which
950
1293
  // setModel/setMode already recorded on runtime.options).
951
- this.enqueueSameSession(active, message, queueMode);
1294
+ this.enqueueSameSession(active, message, queueMode, options.model);
952
1295
  this.activateAcceptedGoal(active, typeof message === 'string' ? '' : message.goalObjective);
953
1296
  return active.activePromise;
954
1297
  }
@@ -1071,11 +1414,17 @@ class ConversationKernel {
1071
1414
  return this.result(runtime, lastTokens);
1072
1415
  }
1073
1416
  for (;;) {
1074
- while (!runtime.queuePaused && runtime.pendingNextTurn.length > 0) {
1417
+ while (!runtime.queuePaused) {
1075
1418
  if (runtime.stopRequestedRunId === runtime.runId)
1076
1419
  return this.result(runtime, lastTokens);
1077
- const next = runtime.pendingNextTurn.shift();
1078
- if (next.queueMode === 'steer' && typeof next.message !== 'string' && !!next.message.clientMessageId) {
1420
+ // Only intervening Guides run inside the current Build. A Next item
1421
+ // is a new user turn and stays exactly where the user placed it; the
1422
+ // run finalizer starts a fresh Build for it after this one settles.
1423
+ const steerIndex = runtime.pendingNextTurn.findIndex(item => item.queueMode === 'steer');
1424
+ if (steerIndex < 0)
1425
+ break;
1426
+ const next = runtime.pendingNextTurn.splice(steerIndex, 1)[0];
1427
+ if (typeof next.message !== 'string' && !!next.message.clientMessageId) {
1079
1428
  const batchGuides = [];
1080
1429
  const pushGuide = (message) => {
1081
1430
  batchGuides.push({
@@ -1087,11 +1436,13 @@ class ConversationKernel {
1087
1436
  });
1088
1437
  };
1089
1438
  pushGuide(next.message);
1090
- while (runtime.pendingNextTurn.length > 0
1091
- && runtime.pendingNextTurn[0].queueMode === 'steer'
1092
- && typeof runtime.pendingNextTurn[0].message !== 'string'
1093
- && !!runtime.pendingNextTurn[0].message.clientMessageId) {
1094
- const guide = runtime.pendingNextTurn.shift();
1439
+ while (true) {
1440
+ const guideIndex = runtime.pendingNextTurn.findIndex(item => item.queueMode === 'steer'
1441
+ && typeof item.message !== 'string'
1442
+ && !!item.message.clientMessageId);
1443
+ if (guideIndex < 0)
1444
+ break;
1445
+ const guide = runtime.pendingNextTurn.splice(guideIndex, 1)[0];
1095
1446
  pushGuide(guide.message);
1096
1447
  }
1097
1448
  if (batchGuides.length === 1) {
@@ -1111,17 +1462,10 @@ class ConversationKernel {
1111
1462
  break;
1112
1463
  }
1113
1464
  else {
1114
- const drained = this.drainQueuedFollowUpMessage(next.message);
1115
- lastTokens = await this.runPendingQueueItem(runtime, next, async () => {
1116
- if (typeof drained !== 'string' && drained.visibleMode && ['build', 'chat', 'plan', 'goal'].includes(drained.visibleMode)) {
1117
- runtime.runner.setMode(drained.visibleMode);
1118
- runtime.options.mode = drained.visibleMode;
1119
- }
1120
- this.activateAcceptedGoal(runtime, typeof drained === 'string' ? '' : drained.goalObjective);
1121
- return this.runSingle(runtime, drained, next.queueMode, typeof next.message === 'string' ? undefined : next.message.clientMessageId);
1122
- });
1123
- if (this.repeatedAutomaticAssistant(runtime, drained, next.queueMode))
1465
+ lastTokens = await this.runSingle(runtime, next.message, next.queueMode);
1466
+ if (this.repeatedAutomaticAssistant(runtime, next.message, next.queueMode))
1124
1467
  break;
1468
+ continue;
1125
1469
  }
1126
1470
  }
1127
1471
  const rootMessage = runtime.runner.subagents.readRootInbox()[0];
@@ -1144,7 +1488,7 @@ class ConversationKernel {
1144
1488
  if (runtime.runId === completedRunId
1145
1489
  && runtime.stopRequestedRunId !== completedRunId
1146
1490
  && !runtime.queuePaused
1147
- && runtime.pendingNextTurn.length > 0) {
1491
+ && runtime.pendingNextTurn.some(item => item.queueMode === 'steer')) {
1148
1492
  runtime.guideAcceptanceClosedRunId = '';
1149
1493
  if (!runtime.runner.resumeConversationWorkRun(completedRunId)) {
1150
1494
  throw new Error(`Unable to resume deferred Guide work run ${completedRunId}`);
@@ -1343,6 +1687,12 @@ class ConversationKernel {
1343
1687
  visibleUserInput: continuation.visibleUserInput,
1344
1688
  visibleMode: continuation.visibleMode,
1345
1689
  goalObjective: continuation.goalObjective,
1690
+ buildId: continuation.buildId,
1691
+ modelSelection: continuation.modelSelection,
1692
+ branchNodeId: continuation.branchNodeId,
1693
+ branchPath: continuation.branchPath?.slice(),
1694
+ targetRuntimeKey: continuation.targetRuntimeKey,
1695
+ workspaceKey: continuation.workspaceKey,
1346
1696
  createdAt: continuation.createdAt,
1347
1697
  hiddenUserInput: continuation.hiddenUserInput,
1348
1698
  }
@@ -1454,13 +1804,110 @@ class ConversationKernel {
1454
1804
  runtime.pendingContinuationRunId = undefined;
1455
1805
  if (runtime.preparingArchive || runtime.runId !== runId || runtime.activePromise || runtime.externalOwner || runtime.stopRequestedRunId === runId || runtime.queuePaused)
1456
1806
  return;
1457
- const next = runtime.pendingNextTurn.shift();
1807
+ const next = runtime.pendingNextTurn[0];
1458
1808
  if (!next)
1459
1809
  return;
1460
- const delivery = next.queueMode === 'followUp' ? this.drainQueuedFollowUpMessage(next.message) : next.message;
1461
- const message = typeof delivery === 'string'
1462
- ? { text: delivery, runId }
1463
- : { ...delivery, runId: delivery.runId || runId };
1810
+ const queued = typeof next.message === 'string' ? null : next.message;
1811
+ if (queued?.targetRuntimeKey && queued.targetRuntimeKey !== runtime.target.runtimeKey) {
1812
+ runtime.queuePaused = true;
1813
+ runtime.runner.recordWorkStatus('Queued turn target mismatch; the queue is paused instead of running in another conversation.');
1814
+ this.emitQueueUpdate(runtime);
1815
+ return;
1816
+ }
1817
+ runtime.pendingNextTurn.shift();
1818
+ if (next.queueMode === 'followUp') {
1819
+ // A Next item is a fresh user turn. It must start a new Build in this
1820
+ // exact conversation after the previous one has fully settled, and it
1821
+ // must land on the branch that owned it when the user enqueued it.
1822
+ // Never carry the completed runId into the new turn.
1823
+ const delivery = this.drainQueuedFollowUpMessage(next.message);
1824
+ const message = typeof delivery === 'string'
1825
+ ? { text: delivery }
1826
+ : { ...delivery, runId: undefined };
1827
+ const continuation = this.continuationStore(runtime);
1828
+ let scope = null;
1829
+ void this.runPendingQueueItem(runtime, next, async () => {
1830
+ this.bindQueuedTurnBranch(runtime, queued);
1831
+ if (queued?.modelSelection)
1832
+ runtime.options.model = queued.modelSelection;
1833
+ if (message.visibleMode && ['build', 'chat', 'plan', 'goal'].includes(message.visibleMode)) {
1834
+ this.setMode(runtime.target, message.visibleMode);
1835
+ }
1836
+ this.activateAcceptedGoal(runtime, message.goalObjective || '');
1837
+ if (continuation && queued?.buildId) {
1838
+ const authoritative = continuation.store.snapshot({
1839
+ workspaceId: continuation.workspaceId,
1840
+ rootId: continuation.rootId,
1841
+ branchId: continuation.branchId,
1842
+ });
1843
+ const ready = authoritative.builds
1844
+ .filter(build => build.status === 'QUEUED')
1845
+ .sort((a, b) => a.queueSequence - b.queueSequence)[0];
1846
+ if (ready && ready.buildId !== queued.buildId) {
1847
+ // The in-memory projection is not the authoritative next build.
1848
+ // Keep this row queued instead of executing out of order.
1849
+ runtime.pendingNextTurn.unshift(next);
1850
+ runtime.runner.recordWorkStatus('Queued turn is waiting for the authoritative predecessor.');
1851
+ this.emitQueueUpdate(runtime);
1852
+ return this.result(runtime, []);
1853
+ }
1854
+ scope = continuation.store.claimBuild({
1855
+ workspaceId: continuation.workspaceId,
1856
+ rootId: continuation.rootId,
1857
+ branchId: continuation.branchId,
1858
+ workerCapability: `kernel:${process.pid}`,
1859
+ });
1860
+ if (scope && scope.buildId !== queued.buildId) {
1861
+ continuation.store.failBuild({ scope, reason: 'Authoritative build mismatch during claim' });
1862
+ scope = null;
1863
+ }
1864
+ if (!scope) {
1865
+ runtime.pendingNextTurn.unshift(next);
1866
+ runtime.runner.recordWorkStatus('Queued turn is not ready for execution yet.');
1867
+ this.emitQueueUpdate(runtime);
1868
+ return this.result(runtime, []);
1869
+ }
1870
+ }
1871
+ const result = await this.prompt(message, runtime.target, runtime.options, 'followUp');
1872
+ if (continuation && scope) {
1873
+ continuation.store.commitFinal({
1874
+ scope,
1875
+ text: result.tokens.map(token => token.text || '').join(''),
1876
+ });
1877
+ }
1878
+ return result;
1879
+ }, (accepted, error) => {
1880
+ if (!continuation || !scope)
1881
+ return;
1882
+ const reason = error instanceof Error ? error.message : String(error);
1883
+ if (accepted) {
1884
+ try {
1885
+ continuation.store.failBuild({ scope, reason });
1886
+ }
1887
+ catch { /* stop/archive may have already released the guard */ }
1888
+ }
1889
+ else {
1890
+ // The input was never accepted. Stop/archive may already have
1891
+ // released the guard, so use the idempotent requeue path: the same
1892
+ // durable build id stays available for an explicit resume.
1893
+ try {
1894
+ continuation.store.requeueBuild({
1895
+ workspaceId: continuation.workspaceId,
1896
+ rootId: continuation.rootId,
1897
+ branchId: continuation.branchId,
1898
+ buildId: scope.buildId,
1899
+ });
1900
+ }
1901
+ catch { /* the build may already be committed or explicitly cancelled */ }
1902
+ }
1903
+ }).catch(() => {
1904
+ // Agent.process and the work-run finalizer already publish the error.
1905
+ });
1906
+ return;
1907
+ }
1908
+ const message = typeof next.message === 'string'
1909
+ ? { text: next.message, runId }
1910
+ : { ...next.message, runId: next.message.runId || runId };
1464
1911
  void this.runPendingQueueItem(runtime, next, async () => {
1465
1912
  if (message.visibleMode && ['build', 'chat', 'plan', 'goal'].includes(message.visibleMode)) {
1466
1913
  this.setMode(runtime.target, message.visibleMode);
@@ -1471,7 +1918,27 @@ class ConversationKernel {
1471
1918
  });
1472
1919
  });
1473
1920
  }
1474
- async runPendingQueueItem(runtime, next, execute) {
1921
+ /**
1922
+ * Restore the runtime branch captured by a queued user turn. If the branch
1923
+ * disappeared (manual deletion/rewind), fail the queued item into the
1924
+ * existing pause/retry path instead of appending it to a different branch.
1925
+ */
1926
+ bindQueuedTurnBranch(runtime, message) {
1927
+ const branchNodeId = String(message?.branchNodeId || '').trim();
1928
+ if (!branchNodeId)
1929
+ return;
1930
+ const snapshot = runtime.runner.getConversationSnapshot(runtime.id);
1931
+ const currentBranch = String(snapshot.runtimeBranchId || snapshot.activeBranchId || '');
1932
+ if (currentBranch === branchNodeId)
1933
+ return;
1934
+ try {
1935
+ runtime.runner.switchConversationBranch(runtime.id, branchNodeId);
1936
+ }
1937
+ catch (error) {
1938
+ throw new Error(`Queued turn branch ${branchNodeId} is unavailable: ${error instanceof Error ? error.message : String(error)}`);
1939
+ }
1940
+ }
1941
+ async runPendingQueueItem(runtime, next, execute, onFailure) {
1475
1942
  const queuedMessage = typeof next.message === 'string' ? undefined : next.message;
1476
1943
  const id = queuedMessage?.clientMessageId;
1477
1944
  if (next.queueMode !== 'followUp' || !id)
@@ -1491,6 +1958,10 @@ class ConversationKernel {
1491
1958
  // the same row manageable. A provider failure after acceptance must
1492
1959
  // never replay the already submitted user input after restart/resume.
1493
1960
  runtime.queuePaused = true;
1961
+ try {
1962
+ onFailure?.(accepted, error);
1963
+ }
1964
+ catch { /* the original failure is authoritative */ }
1494
1965
  throw error;
1495
1966
  }
1496
1967
  finally {
@@ -1669,13 +2140,26 @@ class ConversationKernel {
1669
2140
  guides: (run.guides || []).map(bindGuide),
1670
2141
  }));
1671
2142
  }
1672
- enqueueSameSession(runtime, message, queueMode) {
2143
+ enqueueSameSession(runtime, message, queueMode, requestedModel = '') {
1673
2144
  const isSteer = queueMode === 'steer';
1674
2145
  const text = typeof message === 'string' ? message : message.text;
1675
2146
  const prompt = isSteer ? text : `[Next queued while current turn is running]\n${text}`;
1676
2147
  if (!isSteer) {
1677
2148
  const structured = typeof message === 'string' ? null : message;
2149
+ const requestedRuntimeKey = String(structured?.targetRuntimeKey || '').trim();
2150
+ if (requestedRuntimeKey && requestedRuntimeKey !== runtime.target.runtimeKey) {
2151
+ throw new Error(`Queued message target mismatch: expected ${requestedRuntimeKey}, received ${runtime.target.runtimeKey}`);
2152
+ }
1678
2153
  const clientMessageId = String(structured?.clientMessageId || (0, crypto_1.randomUUID)());
2154
+ const capturedBranch = this.runtimeBranchIdentity(runtime);
2155
+ const branchPath = Array.isArray(structured?.branchPath) && structured.branchPath.length
2156
+ ? structured.branchPath.map(id => String(id || '')).filter(Boolean)
2157
+ : capturedBranch.branchPath;
2158
+ const branchNodeId = String(structured?.branchNodeId || branchPath[branchPath.length - 1] || capturedBranch.branchNodeId || '');
2159
+ const targetRuntimeKey = runtime.target.runtimeKey;
2160
+ const workspaceKey = runtime.target.workspaceKey;
2161
+ const modelSelection = String((structured?.modelSelection) || requestedModel || runtime.options.model || '').trim() || undefined;
2162
+ const ledgerBranchId = this.continuationStore(runtime, branchNodeId)?.branchId;
1679
2163
  const queuedMessage = {
1680
2164
  ...(structured || {}),
1681
2165
  text: prompt,
@@ -1683,6 +2167,12 @@ class ConversationKernel {
1683
2167
  visibleMode: structured?.visibleMode || runtime.options.mode,
1684
2168
  clientMessageId,
1685
2169
  runId: structured?.runId || runtime.runId,
2170
+ branchNodeId: branchNodeId || undefined,
2171
+ branchPath: branchPath.length ? branchPath : undefined,
2172
+ ledgerBranchId,
2173
+ targetRuntimeKey,
2174
+ workspaceKey,
2175
+ modelSelection,
1686
2176
  createdAt: String(structured?.createdAt || new Date().toISOString()),
1687
2177
  };
1688
2178
  if (!runtime.pendingNextTurn.some(item => typeof item.message !== 'string' && item.message.clientMessageId === clientMessageId)) {
@@ -1699,6 +2189,12 @@ class ConversationKernel {
1699
2189
  visibleUserInput: queuedMessage.visibleUserInput,
1700
2190
  visibleMode: queuedMessage.visibleMode,
1701
2191
  goalObjective: queuedMessage.goalObjective,
2192
+ branchNodeId: queuedMessage.branchNodeId,
2193
+ branchPath: queuedMessage.branchPath,
2194
+ ledgerBranchId: queuedMessage.ledgerBranchId,
2195
+ targetRuntimeKey,
2196
+ workspaceKey,
2197
+ modelSelection,
1702
2198
  }]);
1703
2199
  this.trackQueuedMessage(runtime, prompt, 'followUp');
1704
2200
  runtime.runner.recordWorkStatus(runtime.stopRequestedRunId === runtime.runId
@@ -1799,6 +2295,60 @@ class ConversationKernel {
1799
2295
  .map(item => typeof item.message === 'string' ? item.message : item.message.text);
1800
2296
  return runtime;
1801
2297
  }
2298
+ /**
2299
+ * Merge queue continuations persisted by another owner/kernel into this
2300
+ * runtime's in-memory projection. Flow takeover, cold restore and a pool
2301
+ * hand-off must not lose an already-admitted Next row. Deduplication is by
2302
+ * the stable clientMessageId, never by text.
2303
+ */
2304
+ reconcilePendingContinuations(runtime) {
2305
+ // The other owner (pool/flow/cold restore) commits queue continuations to
2306
+ // the workspace state file. Reload this runner's durable view before
2307
+ // merging so a takeover cannot miss a row persisted after the runner was
2308
+ // first created.
2309
+ runtime.runner.setConversationFromStorage(runtime.id);
2310
+ const persisted = runtime.runner.conversationContinuations();
2311
+ const existingIds = new Set();
2312
+ for (const item of runtime.pendingNextTurn) {
2313
+ if (typeof item.message === 'string')
2314
+ continue;
2315
+ const id = String(item.message.clientMessageId || '').trim();
2316
+ if (id)
2317
+ existingIds.add(id);
2318
+ }
2319
+ let changed = false;
2320
+ for (const continuation of persisted) {
2321
+ if (continuation.queueMode !== 'followUp' || !continuation.clientMessageId)
2322
+ continue;
2323
+ if (existingIds.has(continuation.clientMessageId))
2324
+ continue;
2325
+ runtime.pendingNextTurn.push({
2326
+ message: {
2327
+ text: continuation.content,
2328
+ visibleUserInput: continuation.visibleUserInput,
2329
+ visibleMode: continuation.visibleMode,
2330
+ goalObjective: continuation.goalObjective,
2331
+ images: continuation.images?.map(image => ({ ...image })),
2332
+ attachments: continuation.attachments?.map(attachment => ({ ...attachment })),
2333
+ clientMessageId: continuation.clientMessageId,
2334
+ buildId: continuation.buildId,
2335
+ branchNodeId: continuation.branchNodeId,
2336
+ branchPath: continuation.branchPath?.slice(),
2337
+ targetRuntimeKey: continuation.targetRuntimeKey,
2338
+ workspaceKey: continuation.workspaceKey,
2339
+ modelSelection: continuation.modelSelection,
2340
+ createdAt: continuation.createdAt,
2341
+ },
2342
+ queueMode: 'followUp',
2343
+ });
2344
+ existingIds.add(continuation.clientMessageId);
2345
+ changed = true;
2346
+ }
2347
+ if (changed) {
2348
+ this.queueState(runtime);
2349
+ this.emitQueueUpdate(runtime);
2350
+ }
2351
+ }
1802
2352
  normalizeTarget(input) {
1803
2353
  if (typeof input !== 'string') {
1804
2354
  const withWorkspace = input.workspace ? input : { ...input, workspace: this.workspaceForId(input.workspaceId) };