newmark-agent 0.6.2 → 0.6.4

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
  }
@@ -215,6 +388,23 @@ class ConversationKernel {
215
388
  const runtime = this.findRuntime(target);
216
389
  if (!runtime)
217
390
  return [];
391
+ // dev-0.6.4 hotfix: 把账本里的受阻原因带进队列投影,界面才能显示
392
+ // “被失败前置阻断”而不是无声卡住。
393
+ let waitingByBuildId = new Map();
394
+ const continuation = this.continuationStore(runtime);
395
+ if (continuation) {
396
+ try {
397
+ const snapshot = continuation.store.snapshot({
398
+ workspaceId: continuation.workspaceId,
399
+ rootId: continuation.rootId,
400
+ branchId: continuation.branchId,
401
+ });
402
+ waitingByBuildId = new Map(snapshot.builds
403
+ .filter(build => !!build.waitingReason)
404
+ .map(build => [build.buildId, String(build.waitingReason)]));
405
+ }
406
+ catch { /* the projection stays usable without ledger diagnostics */ }
407
+ }
218
408
  return runtime.pendingNextTurn.flatMap(item => {
219
409
  if (item.queueMode !== 'followUp')
220
410
  return [];
@@ -223,8 +413,10 @@ class ConversationKernel {
223
413
  const id = String(item.message.clientMessageId || '');
224
414
  if (!id)
225
415
  return [];
416
+ const buildId = String(item.message.buildId || '');
226
417
  return [{
227
418
  id,
419
+ buildId: item.message.buildId,
228
420
  text: String(item.message.visibleUserInput || item.message.text || '').replace(/^\[Next queued while current turn is running\]\n/, ''),
229
421
  queueMode: item.queueMode,
230
422
  requestedMode: item.message.visibleMode,
@@ -232,9 +424,58 @@ class ConversationKernel {
232
424
  runId: item.message.runId,
233
425
  createdAt: String(item.message.createdAt || ''),
234
426
  images: item.message.images?.map(image => ({ ...image })),
427
+ branchNodeId: item.message.branchNodeId,
428
+ branchPath: item.message.branchPath?.slice(),
429
+ modelSelection: item.message.modelSelection,
430
+ waitingReason: waitingByBuildId.get(buildId),
431
+ blocked: waitingByBuildId.has(buildId),
235
432
  }];
236
433
  });
237
434
  }
435
+ /**
436
+ * Capture the exact runtime branch that owns a queued user turn. A queued
437
+ * Next is a new user turn, but it must still land on the branch the user was
438
+ * viewing when they enqueued it, not on whatever branch happens to be active
439
+ * when the previous Build settles.
440
+ */
441
+ runtimeBranchIdentity(runtime) {
442
+ try {
443
+ const snapshot = runtime.runner.getConversationSnapshot(runtime.id);
444
+ const path = Array.isArray(snapshot.runtimeBranchNodePath)
445
+ ? snapshot.runtimeBranchNodePath.map(id => String(id || '')).filter(Boolean)
446
+ : [];
447
+ const branchNodeId = String(snapshot.runtimeBranchId || snapshot.activeBranchId || path[path.length - 1] || '');
448
+ return {
449
+ branchNodeId,
450
+ branchPath: path.length ? path : (branchNodeId ? [branchNodeId] : []),
451
+ };
452
+ }
453
+ catch {
454
+ return { branchNodeId: '', branchPath: [] };
455
+ }
456
+ }
457
+ /**
458
+ * Map the current conversation/branch model onto the continuation-v1
459
+ * Root/Branch identity. The ledger is workspace-scoped and shared by the
460
+ * PC GUI, hosted mobile API and isolated runtimes.
461
+ */
462
+ continuationStore(runtime, branchNodeIdOverride = '') {
463
+ const workspace = runtime.target.workspace;
464
+ if (!workspace?.path)
465
+ return null;
466
+ const workspaceId = String(workspace.id || workspace.path);
467
+ const branchIdentity = this.runtimeBranchIdentity(runtime);
468
+ const rootId = runtime.target.conversationId;
469
+ // Ledger branch ids are workspace-global, while a runtime branch node id
470
+ // is only unique inside its conversation/root. Namespace it by root so two
471
+ // conversations cannot share one authoritative branch record.
472
+ const branchId = branchNodeIdOverride.includes('::')
473
+ ? branchNodeIdOverride
474
+ : `${rootId}::${branchNodeIdOverride || branchIdentity.branchNodeId || 'main'}`;
475
+ const store = new store_1.GuardedContinuationStore(workspace.path, workspaceId);
476
+ store.ensureRoot(rootId, branchId);
477
+ return { store, workspaceId, rootId, branchId };
478
+ }
238
479
  enqueueNext(target, input) {
239
480
  const runtime = this.findRuntime(target);
240
481
  if (!runtime)
@@ -249,8 +490,56 @@ class ConversationKernel {
249
490
  return existing;
250
491
  const prompt = `[Next queued while current turn is running]\n${text}`;
251
492
  const createdAt = String(input.createdAt || new Date().toISOString());
493
+ const capturedBranch = this.runtimeBranchIdentity(runtime);
494
+ const branchPath = Array.isArray(input.branchPath) && input.branchPath.length
495
+ ? input.branchPath.map(id => String(id || '')).filter(Boolean)
496
+ : capturedBranch.branchPath;
497
+ const branchNodeId = String(input.branchNodeId || branchPath[branchPath.length - 1] || capturedBranch.branchNodeId || '');
498
+ const targetRuntimeKey = String(input.targetRuntimeKey || runtime.target.runtimeKey).trim() || runtime.target.runtimeKey;
499
+ const workspaceKey = runtime.target.workspaceKey;
500
+ const modelSelection = String(input.modelSelection || runtime.options.model || '').trim() || undefined;
501
+ // Gate B/C: admission is an authoritative CAS transaction. The in-memory
502
+ // pendingNextTurn list is only a projection of this durable build.
503
+ const continuation = this.continuationStore(runtime, branchNodeId);
504
+ const ledgerBranchId = continuation?.branchId;
505
+ let buildId = '';
506
+ if (continuation) {
507
+ // 入队严格保持 0.6.3 语义:新命令接在当前队尾之后。被失败前置阻断的行不会
508
+ // 被静默跳过;用户可通过界面上的“修复受阻队列”显式动作重建可执行链。
509
+ const authoritative = continuation.store.snapshot({
510
+ workspaceId: continuation.workspaceId,
511
+ rootId: continuation.rootId,
512
+ branchId: continuation.branchId,
513
+ });
514
+ const receipt = continuation.store.enqueueBuild({
515
+ schemaVersion: 1,
516
+ type: 'ENQUEUE_BUILD',
517
+ commandId: id,
518
+ actorId: 'kernel',
519
+ target: {
520
+ workspaceId: continuation.workspaceId,
521
+ expectedRootId: continuation.rootId,
522
+ branchId: continuation.branchId,
523
+ },
524
+ expectedTailBuildId: authoritative.tailBuildId,
525
+ expectedQueueRevision: authoritative.queueRevision,
526
+ input: {
527
+ text,
528
+ images: input.images,
529
+ requestedMode: input.requestedMode,
530
+ goalObjective: input.goalObjective,
531
+ modelSelection,
532
+ branchNodeId: branchNodeId || undefined,
533
+ branchPath: branchPath.length ? branchPath : undefined,
534
+ },
535
+ recipeSnapshotId: `${continuation.rootId}:${continuation.branchId}:${modelSelection || 'auto'}`,
536
+ createdAt,
537
+ });
538
+ buildId = receipt.buildId;
539
+ }
252
540
  runtime.pendingNextTurn.push({
253
541
  message: {
542
+ buildId: buildId || undefined,
254
543
  text: prompt,
255
544
  visibleUserInput: text,
256
545
  visibleMode: String(input.requestedMode || 'build'),
@@ -258,11 +547,18 @@ class ConversationKernel {
258
547
  images: Array.isArray(input.images) ? input.images.slice(0, 6).map(image => ({ ...image })) : undefined,
259
548
  clientMessageId: id,
260
549
  runId: runtime.runId,
550
+ branchNodeId: branchNodeId || undefined,
551
+ branchPath: branchPath.length ? branchPath : undefined,
552
+ ledgerBranchId,
553
+ targetRuntimeKey,
554
+ workspaceKey,
555
+ modelSelection,
261
556
  createdAt,
262
557
  },
263
558
  queueMode: 'followUp',
264
559
  });
265
560
  runtime.runner.retainConversationContinuations([{
561
+ buildId: buildId || undefined,
266
562
  content: prompt,
267
563
  queueMode: 'followUp',
268
564
  clientMessageId: id,
@@ -272,6 +568,12 @@ class ConversationKernel {
272
568
  visibleUserInput: text,
273
569
  visibleMode: String(input.requestedMode || 'build'),
274
570
  goalObjective: input.goalObjective,
571
+ branchNodeId: branchNodeId || undefined,
572
+ branchPath: branchPath.length ? branchPath : undefined,
573
+ ledgerBranchId,
574
+ targetRuntimeKey,
575
+ workspaceKey,
576
+ modelSelection,
275
577
  }]);
276
578
  this.trackQueuedMessage(runtime, prompt, 'followUp');
277
579
  this.emitQueueUpdate(runtime);
@@ -291,14 +593,35 @@ class ConversationKernel {
291
593
  if (!pending || typeof pending.message === 'string')
292
594
  throw new Error('Queue item is no longer editable');
293
595
  const previous = runtime.runner.conversationContinuations().find(item => item.clientMessageId === id);
294
- pending.message.text = `[Next queued while current turn is running]\n${text}`;
596
+ const nextText = `[Next queued while current turn is running]\n${text}`;
597
+ const nextVisibleMode = input.requestedMode !== undefined ? input.requestedMode : pending.message.visibleMode;
598
+ const nextGoalObjective = input.goalObjective !== undefined ? input.goalObjective : pending.message.goalObjective;
599
+ const nextImages = input.images !== undefined
600
+ ? input.images.map(image => ({ ...image }))
601
+ : pending.message.images?.map(image => ({ ...image }));
602
+ const continuation = this.continuationStore(runtime, pending.message.ledgerBranchId || pending.message.branchNodeId);
603
+ if (continuation && pending.message.buildId) {
604
+ continuation.store.updateQueuedBuild({
605
+ workspaceId: continuation.workspaceId,
606
+ rootId: continuation.rootId,
607
+ branchId: continuation.branchId,
608
+ buildId: pending.message.buildId,
609
+ input: {
610
+ text,
611
+ images: nextImages,
612
+ requestedMode: nextVisibleMode,
613
+ goalObjective: nextGoalObjective,
614
+ modelSelection: pending.message.modelSelection,
615
+ branchNodeId: pending.message.branchNodeId,
616
+ branchPath: pending.message.branchPath,
617
+ },
618
+ });
619
+ }
620
+ pending.message.text = nextText;
295
621
  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 }));
622
+ pending.message.visibleMode = nextVisibleMode;
623
+ pending.message.goalObjective = nextGoalObjective;
624
+ pending.message.images = nextImages;
302
625
  runtime.runner.retainConversationContinuations([{
303
626
  ...previous,
304
627
  content: pending.message.text,
@@ -309,6 +632,11 @@ class ConversationKernel {
309
632
  visibleUserInput: text,
310
633
  visibleMode: pending.message.visibleMode,
311
634
  goalObjective: pending.message.goalObjective,
635
+ branchNodeId: pending.message.branchNodeId,
636
+ branchPath: pending.message.branchPath,
637
+ targetRuntimeKey: pending.message.targetRuntimeKey,
638
+ workspaceKey: pending.message.workspaceKey,
639
+ modelSelection: pending.message.modelSelection,
312
640
  }]);
313
641
  this.emitQueueUpdate(runtime);
314
642
  return this.queueItems(runtime.target).find(item => item.id === id);
@@ -321,7 +649,19 @@ class ConversationKernel {
321
649
  const index = runtime.pendingNextTurn.findIndex(item => typeof item.message !== 'string' && item.message.clientMessageId === id);
322
650
  if (index < 0)
323
651
  return false;
324
- const [removed] = runtime.pendingNextTurn.splice(index, 1);
652
+ const removed = runtime.pendingNextTurn[index];
653
+ if (typeof removed.message !== 'string') {
654
+ const continuation = this.continuationStore(runtime, removed.message.ledgerBranchId || removed.message.branchNodeId);
655
+ if (continuation && removed.message.buildId) {
656
+ continuation.store.deleteQueuedBuild({
657
+ workspaceId: continuation.workspaceId,
658
+ rootId: continuation.rootId,
659
+ branchId: continuation.branchId,
660
+ buildId: removed.message.buildId,
661
+ });
662
+ }
663
+ }
664
+ runtime.pendingNextTurn.splice(index, 1);
325
665
  if (typeof removed.message !== 'string') {
326
666
  runtime.runner.consumeConversationContinuation({ content: removed.message.text, queueMode: removed.queueMode, clientMessageId: id });
327
667
  }
@@ -355,6 +695,27 @@ class ConversationKernel {
355
695
  return [];
356
696
  return [[String(item.message.clientMessageId), item]];
357
697
  }));
698
+ const firstOrdered = pendingById.get(orderedIds[0] || '');
699
+ const continuation = this.continuationStore(runtime, firstOrdered && typeof firstOrdered.message !== 'string'
700
+ ? (firstOrdered.message.ledgerBranchId || firstOrdered.message.branchNodeId || '')
701
+ : '');
702
+ if (continuation) {
703
+ const orderedBuildIds = orderedIds
704
+ .map(id => {
705
+ const item = pendingById.get(id);
706
+ return item && typeof item.message !== 'string' ? item.message.buildId : undefined;
707
+ })
708
+ .filter((id) => !!id);
709
+ if (orderedBuildIds.length !== orderedIds.length) {
710
+ throw new Error('A queued build id is missing from the authoritative ledger projection');
711
+ }
712
+ continuation.store.reorderQueuedBuilds({
713
+ workspaceId: continuation.workspaceId,
714
+ rootId: continuation.rootId,
715
+ branchId: continuation.branchId,
716
+ orderedBuildIds,
717
+ });
718
+ }
358
719
  const reorderedPending = orderedIds.map(id => pendingById.get(id));
359
720
  let nextIndex = 0;
360
721
  runtime.pendingNextTurn = runtime.pendingNextTurn.map(item => {
@@ -369,6 +730,10 @@ class ConversationKernel {
369
730
  return this.queueItems(runtime.target);
370
731
  }
371
732
  setQueuePaused(target, paused) {
733
+ // 队列策略只作用于已经存在的 runtime:在这里按需重建 runtime 会让空闲会话
734
+ // 重新变成“持有排队工作”的所有者,破坏工作区清理契约
735
+ // (`Cannot release a conversation with running or queued work`)。界面路径统一
736
+ // 走 queueAction,它已经负责按需创建 runtime。
372
737
  const runtime = this.findRuntime(target);
373
738
  if (!runtime)
374
739
  throw new Error('Target conversation runtime is unavailable');
@@ -381,6 +746,35 @@ class ConversationKernel {
381
746
  }
382
747
  return runtime.queuePaused;
383
748
  }
749
+ /**
750
+ * dev-0.6.4 hotfix: 显式修复被失败前置阻断的排队行。
751
+ *
752
+ * 0.6.3 的合约是「失败/取消阻断后继,且不静默跳过」,所以这里不挂在恢复动作上
753
+ * 自动执行;它必须由用户的显式动作触发(界面上的“修复受阻队列”按钮)。每一次
754
+ * 改挂都写入 `BuildQueueRepaired` 事件并发布可见状态,触发者与原因可审计。
755
+ */
756
+ repairBlockedQueueNow(runtime) {
757
+ const continuation = this.continuationStore(runtime);
758
+ if (!continuation)
759
+ return 0;
760
+ try {
761
+ const result = continuation.store.repairBlockedQueue({
762
+ workspaceId: continuation.workspaceId,
763
+ rootId: continuation.rootId,
764
+ branchId: continuation.branchId,
765
+ reason: 'explicit-user-repair',
766
+ });
767
+ if (result.repaired.length) {
768
+ runtime.runner.recordWorkStatus(`${result.repaired.length} queued row(s) blocked by a failed predecessor were re-anchored to the last committed build; the queue resumes in order.`);
769
+ this.schedulePendingRuntimeContinuation(runtime, runtime.runId);
770
+ }
771
+ return result.repaired.length;
772
+ }
773
+ catch (error) {
774
+ runtime.runner.recordWorkStatus(`Queue repair failed: ${error instanceof Error ? error.message : String(error)}`);
775
+ return 0;
776
+ }
777
+ }
384
778
  queueAction(target, action, input = {}) {
385
779
  const normalized = this.normalizeTarget(target);
386
780
  const runtime = this.findRuntime(normalized) || this.runtime(normalized, {
@@ -397,6 +791,10 @@ class ConversationKernel {
397
791
  goalObjective: input.goalObjective,
398
792
  createdAt: input.createdAt,
399
793
  images: input.images,
794
+ branchNodeId: input.branchNodeId,
795
+ branchPath: input.branchPath,
796
+ modelSelection: input.modelSelection,
797
+ targetRuntimeKey: input.targetRuntimeKey,
400
798
  });
401
799
  }
402
800
  else if (action === 'update') {
@@ -415,6 +813,10 @@ class ConversationKernel {
415
813
  else if (action === 'set_pause') {
416
814
  this.setQueuePaused(runtime.target, input.paused === true);
417
815
  }
816
+ else if (action === 'repair_blocked') {
817
+ // 显式修复:把被失败前置阻断的排队行重新挂到最后一个已提交 Build。
818
+ this.repairBlockedQueueNow(runtime);
819
+ }
418
820
  else if (action === 'guide') {
419
821
  if (input.text !== undefined)
420
822
  this.updateQueueItem(runtime.target, String(input.id || ''), input.text, input);
@@ -948,7 +1350,7 @@ class ConversationKernel {
948
1350
  // carry no send-time model/mode; the running block keeps its settings and
949
1351
  // the next dequeue follows the current conversation selection (which
950
1352
  // setModel/setMode already recorded on runtime.options).
951
- this.enqueueSameSession(active, message, queueMode);
1353
+ this.enqueueSameSession(active, message, queueMode, options.model);
952
1354
  this.activateAcceptedGoal(active, typeof message === 'string' ? '' : message.goalObjective);
953
1355
  return active.activePromise;
954
1356
  }
@@ -1071,11 +1473,17 @@ class ConversationKernel {
1071
1473
  return this.result(runtime, lastTokens);
1072
1474
  }
1073
1475
  for (;;) {
1074
- while (!runtime.queuePaused && runtime.pendingNextTurn.length > 0) {
1476
+ while (!runtime.queuePaused) {
1075
1477
  if (runtime.stopRequestedRunId === runtime.runId)
1076
1478
  return this.result(runtime, lastTokens);
1077
- const next = runtime.pendingNextTurn.shift();
1078
- if (next.queueMode === 'steer' && typeof next.message !== 'string' && !!next.message.clientMessageId) {
1479
+ // Only intervening Guides run inside the current Build. A Next item
1480
+ // is a new user turn and stays exactly where the user placed it; the
1481
+ // run finalizer starts a fresh Build for it after this one settles.
1482
+ const steerIndex = runtime.pendingNextTurn.findIndex(item => item.queueMode === 'steer');
1483
+ if (steerIndex < 0)
1484
+ break;
1485
+ const next = runtime.pendingNextTurn.splice(steerIndex, 1)[0];
1486
+ if (typeof next.message !== 'string' && !!next.message.clientMessageId) {
1079
1487
  const batchGuides = [];
1080
1488
  const pushGuide = (message) => {
1081
1489
  batchGuides.push({
@@ -1087,11 +1495,13 @@ class ConversationKernel {
1087
1495
  });
1088
1496
  };
1089
1497
  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();
1498
+ while (true) {
1499
+ const guideIndex = runtime.pendingNextTurn.findIndex(item => item.queueMode === 'steer'
1500
+ && typeof item.message !== 'string'
1501
+ && !!item.message.clientMessageId);
1502
+ if (guideIndex < 0)
1503
+ break;
1504
+ const guide = runtime.pendingNextTurn.splice(guideIndex, 1)[0];
1095
1505
  pushGuide(guide.message);
1096
1506
  }
1097
1507
  if (batchGuides.length === 1) {
@@ -1111,17 +1521,10 @@ class ConversationKernel {
1111
1521
  break;
1112
1522
  }
1113
1523
  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))
1524
+ lastTokens = await this.runSingle(runtime, next.message, next.queueMode);
1525
+ if (this.repeatedAutomaticAssistant(runtime, next.message, next.queueMode))
1124
1526
  break;
1527
+ continue;
1125
1528
  }
1126
1529
  }
1127
1530
  const rootMessage = runtime.runner.subagents.readRootInbox()[0];
@@ -1144,7 +1547,7 @@ class ConversationKernel {
1144
1547
  if (runtime.runId === completedRunId
1145
1548
  && runtime.stopRequestedRunId !== completedRunId
1146
1549
  && !runtime.queuePaused
1147
- && runtime.pendingNextTurn.length > 0) {
1550
+ && runtime.pendingNextTurn.some(item => item.queueMode === 'steer')) {
1148
1551
  runtime.guideAcceptanceClosedRunId = '';
1149
1552
  if (!runtime.runner.resumeConversationWorkRun(completedRunId)) {
1150
1553
  throw new Error(`Unable to resume deferred Guide work run ${completedRunId}`);
@@ -1331,6 +1734,9 @@ class ConversationKernel {
1331
1734
  && runtime.queued.followUp.length === 0
1332
1735
  && runner.subagents.readRootInbox().length === 0;
1333
1736
  });
1737
+ // 执行投影严格跟随当前激活分支节点:新分支绝不接管另一条分页的排队输入
1738
+ // (dev-0.6.3 的既有契约)。界面上跨分页的排队行可见性由渲染层负责,
1739
+ // 执行始终属于受理时捕获的那条分支。
1334
1740
  for (const continuation of runner.conversationContinuations()) {
1335
1741
  runtime.pendingNextTurn.push({
1336
1742
  message: continuation.clientMessageId || continuation.images?.length || continuation.hiddenUserInput
@@ -1343,6 +1749,12 @@ class ConversationKernel {
1343
1749
  visibleUserInput: continuation.visibleUserInput,
1344
1750
  visibleMode: continuation.visibleMode,
1345
1751
  goalObjective: continuation.goalObjective,
1752
+ buildId: continuation.buildId,
1753
+ modelSelection: continuation.modelSelection,
1754
+ branchNodeId: continuation.branchNodeId,
1755
+ branchPath: continuation.branchPath?.slice(),
1756
+ targetRuntimeKey: continuation.targetRuntimeKey,
1757
+ workspaceKey: continuation.workspaceKey,
1346
1758
  createdAt: continuation.createdAt,
1347
1759
  hiddenUserInput: continuation.hiddenUserInput,
1348
1760
  }
@@ -1454,13 +1866,110 @@ class ConversationKernel {
1454
1866
  runtime.pendingContinuationRunId = undefined;
1455
1867
  if (runtime.preparingArchive || runtime.runId !== runId || runtime.activePromise || runtime.externalOwner || runtime.stopRequestedRunId === runId || runtime.queuePaused)
1456
1868
  return;
1457
- const next = runtime.pendingNextTurn.shift();
1869
+ const next = runtime.pendingNextTurn[0];
1458
1870
  if (!next)
1459
1871
  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 };
1872
+ const queued = typeof next.message === 'string' ? null : next.message;
1873
+ if (queued?.targetRuntimeKey && queued.targetRuntimeKey !== runtime.target.runtimeKey) {
1874
+ runtime.queuePaused = true;
1875
+ runtime.runner.recordWorkStatus('Queued turn target mismatch; the queue is paused instead of running in another conversation.');
1876
+ this.emitQueueUpdate(runtime);
1877
+ return;
1878
+ }
1879
+ runtime.pendingNextTurn.shift();
1880
+ if (next.queueMode === 'followUp') {
1881
+ // A Next item is a fresh user turn. It must start a new Build in this
1882
+ // exact conversation after the previous one has fully settled, and it
1883
+ // must land on the branch that owned it when the user enqueued it.
1884
+ // Never carry the completed runId into the new turn.
1885
+ const delivery = this.drainQueuedFollowUpMessage(next.message);
1886
+ const message = typeof delivery === 'string'
1887
+ ? { text: delivery }
1888
+ : { ...delivery, runId: undefined };
1889
+ const continuation = this.continuationStore(runtime);
1890
+ let scope = null;
1891
+ void this.runPendingQueueItem(runtime, next, async () => {
1892
+ this.bindQueuedTurnBranch(runtime, queued);
1893
+ if (queued?.modelSelection)
1894
+ runtime.options.model = queued.modelSelection;
1895
+ if (message.visibleMode && ['build', 'chat', 'plan', 'goal'].includes(message.visibleMode)) {
1896
+ this.setMode(runtime.target, message.visibleMode);
1897
+ }
1898
+ this.activateAcceptedGoal(runtime, message.goalObjective || '');
1899
+ if (continuation && queued?.buildId) {
1900
+ const authoritative = continuation.store.snapshot({
1901
+ workspaceId: continuation.workspaceId,
1902
+ rootId: continuation.rootId,
1903
+ branchId: continuation.branchId,
1904
+ });
1905
+ const ready = authoritative.builds
1906
+ .filter(build => build.status === 'QUEUED')
1907
+ .sort((a, b) => a.queueSequence - b.queueSequence)[0];
1908
+ if (ready && ready.buildId !== queued.buildId) {
1909
+ // The in-memory projection is not the authoritative next build.
1910
+ // Keep this row queued instead of executing out of order.
1911
+ runtime.pendingNextTurn.unshift(next);
1912
+ runtime.runner.recordWorkStatus('Queued turn is waiting for the authoritative predecessor.');
1913
+ this.emitQueueUpdate(runtime);
1914
+ return this.result(runtime, []);
1915
+ }
1916
+ scope = continuation.store.claimBuild({
1917
+ workspaceId: continuation.workspaceId,
1918
+ rootId: continuation.rootId,
1919
+ branchId: continuation.branchId,
1920
+ workerCapability: `kernel:${process.pid}`,
1921
+ });
1922
+ if (scope && scope.buildId !== queued.buildId) {
1923
+ continuation.store.failBuild({ scope, reason: 'Authoritative build mismatch during claim' });
1924
+ scope = null;
1925
+ }
1926
+ if (!scope) {
1927
+ runtime.pendingNextTurn.unshift(next);
1928
+ runtime.runner.recordWorkStatus('Queued turn is not ready for execution yet.');
1929
+ this.emitQueueUpdate(runtime);
1930
+ return this.result(runtime, []);
1931
+ }
1932
+ }
1933
+ const result = await this.prompt(message, runtime.target, runtime.options, 'followUp');
1934
+ if (continuation && scope) {
1935
+ continuation.store.commitFinal({
1936
+ scope,
1937
+ text: result.tokens.map(token => token.text || '').join(''),
1938
+ });
1939
+ }
1940
+ return result;
1941
+ }, (accepted, error) => {
1942
+ if (!continuation || !scope)
1943
+ return;
1944
+ const reason = error instanceof Error ? error.message : String(error);
1945
+ if (accepted) {
1946
+ try {
1947
+ continuation.store.failBuild({ scope, reason });
1948
+ }
1949
+ catch { /* stop/archive may have already released the guard */ }
1950
+ }
1951
+ else {
1952
+ // The input was never accepted. Stop/archive may already have
1953
+ // released the guard, so use the idempotent requeue path: the same
1954
+ // durable build id stays available for an explicit resume.
1955
+ try {
1956
+ continuation.store.requeueBuild({
1957
+ workspaceId: continuation.workspaceId,
1958
+ rootId: continuation.rootId,
1959
+ branchId: continuation.branchId,
1960
+ buildId: scope.buildId,
1961
+ });
1962
+ }
1963
+ catch { /* the build may already be committed or explicitly cancelled */ }
1964
+ }
1965
+ }).catch(() => {
1966
+ // Agent.process and the work-run finalizer already publish the error.
1967
+ });
1968
+ return;
1969
+ }
1970
+ const message = typeof next.message === 'string'
1971
+ ? { text: next.message, runId }
1972
+ : { ...next.message, runId: next.message.runId || runId };
1464
1973
  void this.runPendingQueueItem(runtime, next, async () => {
1465
1974
  if (message.visibleMode && ['build', 'chat', 'plan', 'goal'].includes(message.visibleMode)) {
1466
1975
  this.setMode(runtime.target, message.visibleMode);
@@ -1471,7 +1980,27 @@ class ConversationKernel {
1471
1980
  });
1472
1981
  });
1473
1982
  }
1474
- async runPendingQueueItem(runtime, next, execute) {
1983
+ /**
1984
+ * Restore the runtime branch captured by a queued user turn. If the branch
1985
+ * disappeared (manual deletion/rewind), fail the queued item into the
1986
+ * existing pause/retry path instead of appending it to a different branch.
1987
+ */
1988
+ bindQueuedTurnBranch(runtime, message) {
1989
+ const branchNodeId = String(message?.branchNodeId || '').trim();
1990
+ if (!branchNodeId)
1991
+ return;
1992
+ const snapshot = runtime.runner.getConversationSnapshot(runtime.id);
1993
+ const currentBranch = String(snapshot.runtimeBranchId || snapshot.activeBranchId || '');
1994
+ if (currentBranch === branchNodeId)
1995
+ return;
1996
+ try {
1997
+ runtime.runner.switchConversationBranch(runtime.id, branchNodeId);
1998
+ }
1999
+ catch (error) {
2000
+ throw new Error(`Queued turn branch ${branchNodeId} is unavailable: ${error instanceof Error ? error.message : String(error)}`);
2001
+ }
2002
+ }
2003
+ async runPendingQueueItem(runtime, next, execute, onFailure) {
1475
2004
  const queuedMessage = typeof next.message === 'string' ? undefined : next.message;
1476
2005
  const id = queuedMessage?.clientMessageId;
1477
2006
  if (next.queueMode !== 'followUp' || !id)
@@ -1491,6 +2020,10 @@ class ConversationKernel {
1491
2020
  // the same row manageable. A provider failure after acceptance must
1492
2021
  // never replay the already submitted user input after restart/resume.
1493
2022
  runtime.queuePaused = true;
2023
+ try {
2024
+ onFailure?.(accepted, error);
2025
+ }
2026
+ catch { /* the original failure is authoritative */ }
1494
2027
  throw error;
1495
2028
  }
1496
2029
  finally {
@@ -1669,13 +2202,26 @@ class ConversationKernel {
1669
2202
  guides: (run.guides || []).map(bindGuide),
1670
2203
  }));
1671
2204
  }
1672
- enqueueSameSession(runtime, message, queueMode) {
2205
+ enqueueSameSession(runtime, message, queueMode, requestedModel = '') {
1673
2206
  const isSteer = queueMode === 'steer';
1674
2207
  const text = typeof message === 'string' ? message : message.text;
1675
2208
  const prompt = isSteer ? text : `[Next queued while current turn is running]\n${text}`;
1676
2209
  if (!isSteer) {
1677
2210
  const structured = typeof message === 'string' ? null : message;
2211
+ const requestedRuntimeKey = String(structured?.targetRuntimeKey || '').trim();
2212
+ if (requestedRuntimeKey && requestedRuntimeKey !== runtime.target.runtimeKey) {
2213
+ throw new Error(`Queued message target mismatch: expected ${requestedRuntimeKey}, received ${runtime.target.runtimeKey}`);
2214
+ }
1678
2215
  const clientMessageId = String(structured?.clientMessageId || (0, crypto_1.randomUUID)());
2216
+ const capturedBranch = this.runtimeBranchIdentity(runtime);
2217
+ const branchPath = Array.isArray(structured?.branchPath) && structured.branchPath.length
2218
+ ? structured.branchPath.map(id => String(id || '')).filter(Boolean)
2219
+ : capturedBranch.branchPath;
2220
+ const branchNodeId = String(structured?.branchNodeId || branchPath[branchPath.length - 1] || capturedBranch.branchNodeId || '');
2221
+ const targetRuntimeKey = runtime.target.runtimeKey;
2222
+ const workspaceKey = runtime.target.workspaceKey;
2223
+ const modelSelection = String((structured?.modelSelection) || requestedModel || runtime.options.model || '').trim() || undefined;
2224
+ const ledgerBranchId = this.continuationStore(runtime, branchNodeId)?.branchId;
1679
2225
  const queuedMessage = {
1680
2226
  ...(structured || {}),
1681
2227
  text: prompt,
@@ -1683,6 +2229,12 @@ class ConversationKernel {
1683
2229
  visibleMode: structured?.visibleMode || runtime.options.mode,
1684
2230
  clientMessageId,
1685
2231
  runId: structured?.runId || runtime.runId,
2232
+ branchNodeId: branchNodeId || undefined,
2233
+ branchPath: branchPath.length ? branchPath : undefined,
2234
+ ledgerBranchId,
2235
+ targetRuntimeKey,
2236
+ workspaceKey,
2237
+ modelSelection,
1686
2238
  createdAt: String(structured?.createdAt || new Date().toISOString()),
1687
2239
  };
1688
2240
  if (!runtime.pendingNextTurn.some(item => typeof item.message !== 'string' && item.message.clientMessageId === clientMessageId)) {
@@ -1699,6 +2251,12 @@ class ConversationKernel {
1699
2251
  visibleUserInput: queuedMessage.visibleUserInput,
1700
2252
  visibleMode: queuedMessage.visibleMode,
1701
2253
  goalObjective: queuedMessage.goalObjective,
2254
+ branchNodeId: queuedMessage.branchNodeId,
2255
+ branchPath: queuedMessage.branchPath,
2256
+ ledgerBranchId: queuedMessage.ledgerBranchId,
2257
+ targetRuntimeKey,
2258
+ workspaceKey,
2259
+ modelSelection,
1702
2260
  }]);
1703
2261
  this.trackQueuedMessage(runtime, prompt, 'followUp');
1704
2262
  runtime.runner.recordWorkStatus(runtime.stopRequestedRunId === runtime.runId
@@ -1799,6 +2357,60 @@ class ConversationKernel {
1799
2357
  .map(item => typeof item.message === 'string' ? item.message : item.message.text);
1800
2358
  return runtime;
1801
2359
  }
2360
+ /**
2361
+ * Merge queue continuations persisted by another owner/kernel into this
2362
+ * runtime's in-memory projection. Flow takeover, cold restore and a pool
2363
+ * hand-off must not lose an already-admitted Next row. Deduplication is by
2364
+ * the stable clientMessageId, never by text.
2365
+ */
2366
+ reconcilePendingContinuations(runtime) {
2367
+ // The other owner (pool/flow/cold restore) commits queue continuations to
2368
+ // the workspace state file. Reload this runner's durable view before
2369
+ // merging so a takeover cannot miss a row persisted after the runner was
2370
+ // first created.
2371
+ runtime.runner.setConversationFromStorage(runtime.id);
2372
+ const persisted = runtime.runner.conversationContinuations();
2373
+ const existingIds = new Set();
2374
+ for (const item of runtime.pendingNextTurn) {
2375
+ if (typeof item.message === 'string')
2376
+ continue;
2377
+ const id = String(item.message.clientMessageId || '').trim();
2378
+ if (id)
2379
+ existingIds.add(id);
2380
+ }
2381
+ let changed = false;
2382
+ for (const continuation of persisted) {
2383
+ if (continuation.queueMode !== 'followUp' || !continuation.clientMessageId)
2384
+ continue;
2385
+ if (existingIds.has(continuation.clientMessageId))
2386
+ continue;
2387
+ runtime.pendingNextTurn.push({
2388
+ message: {
2389
+ text: continuation.content,
2390
+ visibleUserInput: continuation.visibleUserInput,
2391
+ visibleMode: continuation.visibleMode,
2392
+ goalObjective: continuation.goalObjective,
2393
+ images: continuation.images?.map(image => ({ ...image })),
2394
+ attachments: continuation.attachments?.map(attachment => ({ ...attachment })),
2395
+ clientMessageId: continuation.clientMessageId,
2396
+ buildId: continuation.buildId,
2397
+ branchNodeId: continuation.branchNodeId,
2398
+ branchPath: continuation.branchPath?.slice(),
2399
+ targetRuntimeKey: continuation.targetRuntimeKey,
2400
+ workspaceKey: continuation.workspaceKey,
2401
+ modelSelection: continuation.modelSelection,
2402
+ createdAt: continuation.createdAt,
2403
+ },
2404
+ queueMode: 'followUp',
2405
+ });
2406
+ existingIds.add(continuation.clientMessageId);
2407
+ changed = true;
2408
+ }
2409
+ if (changed) {
2410
+ this.queueState(runtime);
2411
+ this.emitQueueUpdate(runtime);
2412
+ }
2413
+ }
1802
2414
  normalizeTarget(input) {
1803
2415
  if (typeof input !== 'string') {
1804
2416
  const withWorkspace = input.workspace ? input : { ...input, workspace: this.workspaceForId(input.workspaceId) };