@yeaft/webchat-agent 1.0.641 → 1.0.643

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.
@@ -82,6 +82,7 @@ import {
82
82
  SessionCrudError,
83
83
  createSessionFromSpec,
84
84
  copySession,
85
+ mergeSessions,
85
86
  renameSession,
86
87
  updateSessionAnnouncement,
87
88
  updateSessionWorkDir,
@@ -498,6 +499,90 @@ const vpEngineConfigKeys = new Map();
498
499
  /** @type {Map<string, AbortController>} */
499
500
  const vpAborts = new Map();
500
501
 
502
+ /**
503
+ * A conservative Session-wide activity epoch. Its durable prefix is captured
504
+ * synchronously before ingress can await boot/classification or persist a user
505
+ * row. Overlapping inputs, fan-out, handoffs and task reentry share that prefix;
506
+ * no active-turn boundary is reconstructed from the latest transcript row.
507
+ */
508
+ const sessionCopyEpochs = new Map();
509
+ const modelReentryActivity = new Map();
510
+
511
+ function hasSessionRuntimeWork(sessionId) {
512
+ const prefix = `${sessionId}::`;
513
+ if ([...vpDrivers.keys(), ...vpAborts.keys()].some(key => key.startsWith(prefix))) return true;
514
+ for (const [key, inbox] of vpInboxes) {
515
+ if (key.startsWith(prefix) && inbox?.length) return true;
516
+ }
517
+ for (const [key, threads] of vpThreads) {
518
+ if (key.startsWith(prefix) && [...threads.values()].some(thread => thread.pendingQueries?.length)) return true;
519
+ }
520
+ for (const engine of asyncTaskOwners.values()) {
521
+ if (engine?.sessionId === sessionId) return true;
522
+ }
523
+ for (const task of session?.taskManager?.listActiveTasks?.(sessionId) || []) {
524
+ if (task.ownerVpId && taskResultDeliveryFor(task) === TASK_RESULT_DELIVERY.MODEL_REENTRY) return true;
525
+ }
526
+ return false;
527
+ }
528
+
529
+ function settleSessionCopyEpoch(sessionId) {
530
+ const epoch = sessionCopyEpochs.get(sessionId);
531
+ if (epoch && epoch.pending === 0 && !hasSessionRuntimeWork(sessionId)) sessionCopyEpochs.delete(sessionId);
532
+ }
533
+
534
+ function beginSessionActivity(sessionId, { unknownBoundary = false } = {}) {
535
+ settleSessionCopyEpoch(sessionId);
536
+ let epoch = sessionCopyEpochs.get(sessionId);
537
+ if (!epoch) {
538
+ let boundary = null;
539
+ // Legacy/untracked running work has no trustworthy pre-ingress boundary.
540
+ if (!unknownBoundary && !hasSessionRuntimeWork(sessionId)) {
541
+ try {
542
+ const root = resolveSessionYeaftDir(ctx.CONFIG?.yeaftDir, sessionId);
543
+ const store = new ConversationStore(root);
544
+ boundary = JSON.stringify(store.captureSessionCopyBoundary(sessionId));
545
+ } catch { /* fail closed for running copies if capture was unavailable */ }
546
+ }
547
+ epoch = { boundary, pending: 0 };
548
+ sessionCopyEpochs.set(sessionId, epoch);
549
+ }
550
+ epoch.pending += 1;
551
+ let released = false;
552
+ return () => {
553
+ if (released) return;
554
+ released = true;
555
+ epoch.pending -= 1;
556
+ if (sessionCopyEpochs.get(sessionId) === epoch) settleSessionCopyEpoch(sessionId);
557
+ };
558
+ }
559
+
560
+ function sessionHasActiveWork(sessionId) {
561
+ settleSessionCopyEpoch(sessionId);
562
+ return sessionCopyEpochs.has(sessionId) || hasSessionRuntimeWork(sessionId);
563
+ }
564
+
565
+ function trackModelReentryActivity(event) {
566
+ const task = event?.task;
567
+ if (!task?.sessionId || !task?.ownerVpId || taskResultDeliveryFor(task) !== TASK_RESULT_DELIVERY.MODEL_REENTRY) return null;
568
+ const key = `${task.sessionId}::${task.id}`;
569
+ let release = modelReentryActivity.get(key);
570
+ // Sub-agent final logging refreshes terminal tasks after completion. Such
571
+ // updates must never reacquire a lease: there is no later completion to free
572
+ // it. Still release an existing lease for any terminal-state notification.
573
+ if (isTerminalTaskStatus(task.status) && event.event !== 'completed') {
574
+ modelReentryActivity.delete(key);
575
+ return release || null;
576
+ }
577
+ if (!release) {
578
+ release = beginSessionActivity(task.sessionId, { unknownBoundary: event.event !== 'started' });
579
+ modelReentryActivity.set(key, release);
580
+ }
581
+ if (event.event !== 'completed') return null;
582
+ modelReentryActivity.delete(key);
583
+ return release;
584
+ }
585
+
501
586
  /**
502
587
  * Owner index for background tasks currently parked on a running engine.
503
588
  * Populated by the per-engine async-task coordinator at register time;
@@ -594,32 +679,51 @@ function buildAsyncTaskCoordinator() {
594
679
  deleteOwnerIfMatch(taskId, engine);
595
680
  },
596
681
  onUndelivered(taskId, delivery, engine) {
597
- if (!deleteOwnerIfMatch(taskId, engine)) return;
598
- scheduleTaskResultRescue({
599
- taskId,
600
- sessionId: delivery?.sessionId || engine?.sessionId || null,
601
- vpId: delivery?.vpId || engine?.vpId || null,
602
- threadId: delivery?.threadId || engine?.currentThreadId || 'main',
603
- content: delivery?.content,
604
- taskKind: delivery?.taskKind,
605
- taskStatus: delivery?.taskStatus,
606
- });
682
+ if (asyncTaskOwners.get(taskId) !== engine) return;
683
+ const sessionId = delivery?.sessionId || engine?.sessionId || null;
684
+ // Bridge ownership to the rescue before deleting the last live owner.
685
+ const releaseActivity = beginSessionActivity(sessionId);
686
+ try {
687
+ if (!deleteOwnerIfMatch(taskId, engine)) return;
688
+ let causalRootId = delivery?.causalRootId || null;
689
+ if (!causalRootId && sessionId) {
690
+ try { causalRootId = session?.taskManager?.getTask?.(sessionId, taskId)?.source?.causalRootId || null; } catch { /* legacy fallback is best-effort */ }
691
+ }
692
+ scheduleTaskResultRescue({
693
+ taskId,
694
+ sessionId,
695
+ vpId: delivery?.vpId || engine?.vpId || null,
696
+ threadId: delivery?.threadId || engine?.currentThreadId || 'main',
697
+ content: delivery?.content,
698
+ taskKind: delivery?.taskKind,
699
+ taskStatus: delivery?.taskStatus,
700
+ causalRootId,
701
+ });
702
+ } finally {
703
+ releaseActivity();
704
+ }
607
705
  },
608
706
  onDeferred(taskId, engine) {
609
- if (!engine?.ownsPendingAsyncTask?.(taskId)) return;
610
- if (!deleteOwnerIfMatch(taskId, engine)) return;
707
+ if (!engine?.ownsPendingAsyncTask?.(taskId) || asyncTaskOwners.get(taskId) !== engine) return;
611
708
  const sessionId = engine?.sessionId || null;
612
- const task = sessionId && session?.taskManager?.getTask?.(sessionId, taskId);
613
- if (!task || !isTerminalTaskStatus(task.status)) return;
614
- scheduleTaskResultRescue({
615
- taskId,
616
- sessionId: task.sessionId || sessionId,
617
- vpId: task.ownerVpId || engine?.vpId || null,
618
- threadId: task.source?.threadId || task.runtime?.threadId || engine?.currentThreadId || 'main',
619
- content: formatTaskResultForVp(task),
620
- taskKind: task.kind,
621
- taskStatus: task.status,
622
- });
709
+ const releaseActivity = beginSessionActivity(sessionId);
710
+ try {
711
+ if (!deleteOwnerIfMatch(taskId, engine)) return;
712
+ const task = sessionId && session?.taskManager?.getTask?.(sessionId, taskId);
713
+ if (!task || !isTerminalTaskStatus(task.status)) return;
714
+ scheduleTaskResultRescue({
715
+ taskId,
716
+ sessionId: task.sessionId || sessionId,
717
+ vpId: task.ownerVpId || engine?.vpId || null,
718
+ threadId: task.source?.threadId || task.runtime?.threadId || engine?.currentThreadId || 'main',
719
+ content: formatTaskResultForVp(task),
720
+ taskKind: task.kind,
721
+ taskStatus: task.status,
722
+ causalRootId: task.source?.causalRootId || null,
723
+ });
724
+ } finally {
725
+ releaseActivity();
726
+ }
623
727
  },
624
728
  };
625
729
  }
@@ -1037,12 +1141,19 @@ function buildPendingRescueEnvelope({ sessionId, taskId = null, threadId = 'main
1037
1141
  sessionId,
1038
1142
  taskId,
1039
1143
  trigger: 'pending_rescue',
1144
+ ...(leftover?.persistedMessage ? {
1145
+ _persistedUserMessage: leftover.persistedMessage,
1146
+ _inboundAlreadyPersisted: true,
1147
+ _causalRootId: leftover.causalRootId || leftover.persistedMessage.id,
1148
+ } : {}),
1040
1149
  msg: {
1041
- id: followUpId,
1150
+ ...leftover?.sourceMessage,
1151
+ id: leftover?.sourceMessage?.id || followUpId,
1042
1152
  from: leftoverIsInternal && leftover.senderVpId ? leftover.senderVpId : 'user',
1043
1153
  role: leftoverIsInternal ? 'assistant' : 'user',
1044
1154
  text: replayText,
1045
1155
  meta: {
1156
+ ...leftover?.sourceMessage?.meta,
1046
1157
  rescuedFrom: 'pendingQueries',
1047
1158
  threadId,
1048
1159
  ...(leftoverInjectedBy ? { injectedBy: leftoverInjectedBy } : {}),
@@ -2067,33 +2178,39 @@ function enqueueForVp(sessionId, vpId, envelope) {
2067
2178
  registerRoutePromise(envelope?.msg?.id, routePromise);
2068
2179
  }
2069
2180
 
2070
- function scheduleTaskResultRescue({ taskId, sessionId, vpId, threadId = 'main', content, taskKind, taskStatus }) {
2181
+ function scheduleTaskResultRescue({ taskId, sessionId, vpId, threadId = 'main', content, taskKind, taskStatus, causalRootId = null }) {
2071
2182
  if (!sessionId || !vpId || !taskId) return false;
2072
2183
  const text = typeof content === 'string'
2073
2184
  ? content
2074
2185
  : (() => { try { return JSON.stringify(content); } catch { return String(content); } })();
2075
2186
  if (!text.trim()) return false;
2076
2187
  const msgId = `task_result_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
2188
+ const releaseActivity = beginSessionActivity(sessionId);
2077
2189
  queueMicrotask(() => {
2078
- enqueueForVp(sessionId, vpId, {
2079
- sessionId,
2080
- taskId,
2081
- trigger: 'task_result',
2082
- _promptSuffix: '',
2083
- msg: {
2084
- id: msgId,
2085
- from: 'tool',
2086
- role: 'assistant',
2087
- text,
2088
- meta: {
2089
- injectedBy: 'task_result',
2090
- taskId,
2091
- taskKind,
2092
- taskStatus,
2093
- sourceThreadId: threadId,
2190
+ try {
2191
+ enqueueForVp(sessionId, vpId, {
2192
+ sessionId,
2193
+ taskId,
2194
+ trigger: 'task_result',
2195
+ _causalRootId: causalRootId,
2196
+ _promptSuffix: '',
2197
+ msg: {
2198
+ id: msgId,
2199
+ from: 'tool',
2200
+ role: 'assistant',
2201
+ text,
2202
+ meta: {
2203
+ injectedBy: 'task_result',
2204
+ taskId,
2205
+ taskKind,
2206
+ taskStatus,
2207
+ sourceThreadId: threadId,
2208
+ },
2094
2209
  },
2095
- },
2096
- });
2210
+ });
2211
+ } finally {
2212
+ releaseActivity();
2213
+ }
2097
2214
  });
2098
2215
  return true;
2099
2216
  }
@@ -2130,6 +2247,7 @@ function scheduleTaskResultReentry(event) {
2130
2247
  threadId,
2131
2248
  taskKind: task.kind,
2132
2249
  taskStatus: task.status,
2250
+ causalRootId: task.source?.causalRootId || null,
2133
2251
  });
2134
2252
  if (accepted) return;
2135
2253
  } catch {
@@ -2146,10 +2264,20 @@ function scheduleTaskResultReentry(event) {
2146
2264
  content: formatted,
2147
2265
  taskKind: task.kind,
2148
2266
  taskStatus: task.status,
2267
+ causalRootId: task.source?.causalRootId || null,
2149
2268
  });
2150
2269
  }
2151
2270
 
2152
2271
  async function routeEnvelopeToVpThread(sessionId, vpId, envelope) {
2272
+ const releaseActivity = beginSessionActivity(sessionId);
2273
+ try {
2274
+ return await routeActiveEnvelopeToVpThread(sessionId, vpId, envelope);
2275
+ } finally {
2276
+ releaseActivity();
2277
+ }
2278
+ }
2279
+
2280
+ async function routeActiveEnvelopeToVpThread(sessionId, vpId, envelope) {
2153
2281
  const routeStart = perfNowMs();
2154
2282
  const { text, prompt, promptParts } = buildVpPromptPayload(vpId, envelope);
2155
2283
  const runningThreads = getRunningThreads(sessionId, vpId);
@@ -2225,7 +2353,7 @@ async function routeEnvelopeToVpThread(sessionId, vpId, envelope) {
2225
2353
  const content = promptParts || prompt;
2226
2354
  const injectedBy = envelope?.msg?.meta?.injectedBy;
2227
2355
  const isInternalAppend = injectedBy === 'route_forward' || injectedBy === 'task_result';
2228
- thread.pendingQueries.push({
2356
+ const pending = {
2229
2357
  content,
2230
2358
  preview: prompt,
2231
2359
  originalText: text,
@@ -2234,7 +2362,9 @@ async function routeEnvelopeToVpThread(sessionId, vpId, envelope) {
2234
2362
  injectedBy: isInternalAppend ? injectedBy : null,
2235
2363
  senderVpId: isInternalAppend ? (envelope?.msg?.meta?.senderVpId || envelope?.msg?.from || null) : null,
2236
2364
  sourceThreadId: isInternalAppend ? visibleInboundThreadId(envelope, thread.threadId) : null,
2237
- });
2365
+ sourceMessage: envelope?.msg,
2366
+ };
2367
+ thread.pendingQueries.push(pending);
2238
2368
  // A thread parked on a background task has no adapter activity to poll this
2239
2369
  // queue. Wake its Engine explicitly so the new user message is consumed
2240
2370
  // immediately while the task remains tracked in TaskManager. If the Engine
@@ -2260,6 +2390,9 @@ async function routeEnvelopeToVpThread(sessionId, vpId, envelope) {
2260
2390
  ts: envelope?.msg?.ts || null,
2261
2391
  clientMessageId: envelope?.msg?.meta?.clientMessageId || null,
2262
2392
  });
2393
+ pending.persistedMessage = envelope?._persistedUserMessage || null;
2394
+ pending.persisted = Boolean(pending.persistedMessage);
2395
+ pending.causalRootId = envelope?._causalRootId || pending.persistedMessage?.id || null;
2263
2396
  thread.updatedAt = Date.now();
2264
2397
  try {
2265
2398
  sendSessionEvent({
@@ -2318,6 +2451,7 @@ async function routeEnvelopeToVpThread(sessionId, vpId, envelope) {
2318
2451
  function ensureDriverRunning(sessionId, vpId, threadId = 'main') {
2319
2452
  const key = threadKey(sessionId, vpId, threadId);
2320
2453
  if (vpDrivers.has(key)) return;
2454
+ const releaseActivity = beginSessionActivity(sessionId);
2321
2455
  const promise = (async () => {
2322
2456
  while (true) {
2323
2457
  const inbox = vpInboxes.get(key);
@@ -2475,10 +2609,12 @@ function ensureDriverRunning(sessionId, vpId, threadId = 'main') {
2475
2609
  }
2476
2610
  }
2477
2611
  }
2612
+ })().finally(() => {
2478
2613
  vpDrivers.delete(key);
2479
2614
  const tail = vpInboxes.get(key);
2480
2615
  if (tail && tail.length > 0) ensureDriverRunning(sessionId, vpId, threadId);
2481
- })();
2616
+ releaseActivity();
2617
+ });
2482
2618
  vpDrivers.set(key, promise);
2483
2619
  }
2484
2620
 
@@ -2522,6 +2658,8 @@ export async function __testResetVpState() {
2522
2658
  vpEngines.clear();
2523
2659
  vpEngineConfigKeys.clear();
2524
2660
  asyncTaskOwners.clear();
2661
+ sessionCopyEpochs.clear();
2662
+ modelReentryActivity.clear();
2525
2663
  vpAborts.clear();
2526
2664
  sessionContexts.clear();
2527
2665
  projectContextBySession.clear();
@@ -3294,11 +3432,12 @@ function decorateSessionsWithRuntimeState(sessions) {
3294
3432
  return rows.map(session => {
3295
3433
  if (!session || !session.id) return session;
3296
3434
  const runtime = bySession.get(session.id);
3297
- if (!runtime) return { ...session, running: false, active: false };
3435
+ const hasWork = sessionHasActiveWork(session.id);
3436
+ if (!runtime) return { ...session, running: hasWork, active: hasWork };
3298
3437
  return {
3299
3438
  ...session,
3300
- running: !!runtime.running,
3301
- active: !!runtime.running,
3439
+ running: hasWork || !!runtime.running,
3440
+ active: hasWork || !!runtime.running,
3302
3441
  runningVpCount: runtime.runningVpCount || 0,
3303
3442
  latestActivityAt: runtime.latestActivityAt || null,
3304
3443
  };
@@ -3677,10 +3816,15 @@ export function handleYeaftCopySession(msg) {
3677
3816
  const source = decorateSessionsWithRuntimeState(snapshotSessions(yeaftDir))
3678
3817
  .find(session => session?.id === sessionId);
3679
3818
  if (!source) throw new SessionCrudError('not_found', sessionId);
3680
- if (source.running) throw new SessionCrudError('session_running', sessionId, 'Cannot copy a running Session');
3819
+ const active = sessionHasActiveWork(sessionId);
3820
+ const epoch = sessionCopyEpochs.get(sessionId);
3821
+ if ((active || source.running) && !epoch?.boundary) {
3822
+ throw new SessionCrudError('session_running', sessionId, 'No safe copy boundary for this running Session');
3823
+ }
3681
3824
  const session = copySession(yeaftDir, sessionId, {
3682
3825
  ...configuredVpPaths(),
3683
3826
  name: msg && msg.name,
3827
+ ...(epoch?.boundary ? { throughBoundary: JSON.parse(epoch.boundary) } : {}),
3684
3828
  ...(msg && Object.prototype.hasOwnProperty.call(msg, 'throughTurnId')
3685
3829
  ? { throughTurnId: msg.throughTurnId }
3686
3830
  : {}),
@@ -3694,6 +3838,33 @@ export function handleYeaftCopySession(msg) {
3694
3838
  }
3695
3839
  }
3696
3840
 
3841
+ /** Merge creates a new Session; neither source runtime is stopped or retired. */
3842
+ export function handleYeaftMergeSessions(msg) {
3843
+ const requestId = msg?.requestId;
3844
+ const sessionId = msg?.sessionId;
3845
+ const otherSessionId = msg?.otherSessionId;
3846
+ try {
3847
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
3848
+ const sources = decorateSessionsWithRuntimeState(snapshotSessions(yeaftDir));
3849
+ for (const id of [sessionId, otherSessionId]) {
3850
+ const source = sources.find(row => row?.id === id);
3851
+ if (!source) throw new SessionCrudError('not_found', id);
3852
+ if (sessionHasActiveWork(id) || source.running) {
3853
+ throw new SessionCrudError('session_running', id, 'Cannot merge a running Session');
3854
+ }
3855
+ }
3856
+ const session = mergeSessions(yeaftDir, sessionId, otherSessionId, {
3857
+ ...configuredVpPaths(), name: msg?.name,
3858
+ });
3859
+ recordAgentSessionCreated();
3860
+ session.config = loadSessionConfig(yeaftDir, session.id);
3861
+ sendSessionCrudResult({ op: 'merge', requestId, ok: true, sourceSessionId: sessionId, otherSessionId, session });
3862
+ sendSessionSnapshotBroadcast();
3863
+ } catch (err) {
3864
+ sendSessionCrudResult({ op: 'merge', requestId, ok: false, sourceSessionId: sessionId, otherSessionId, error: sessionErrorPayload(err) });
3865
+ }
3866
+ }
3867
+
3697
3868
  /**
3698
3869
  * `yeaft_scan_workdir_sessions` — compatibility endpoint for the retired
3699
3870
  * workdir Session scan flow. Session data now lives under the user-level
@@ -4008,11 +4179,14 @@ export function installYeaftRuntimeBridge(s) {
4008
4179
 
4009
4180
  if (s.taskManager && typeof s.taskManager.setEventSink === 'function') {
4010
4181
  s.taskManager.setEventSink((event) => {
4182
+ const releaseActivity = trackModelReentryActivity(event);
4011
4183
  try {
4012
4184
  const sessionId = event?.task?.sessionId || event?.sessionId || null;
4013
4185
  sendSessionEvent(event, { sessionId });
4014
4186
  scheduleTaskResultReentry(event);
4015
- } catch { /* never let task event delivery throw */ }
4187
+ } catch { /* never let task event delivery throw */ } finally {
4188
+ releaseActivity?.();
4189
+ }
4016
4190
  });
4017
4191
  }
4018
4192
 
@@ -4932,6 +5106,21 @@ async function runYeaftSessionSend(msg) {
4932
5106
  }
4933
5107
  traceDuration('session_send.open_session', openSessionStart, { ok: !!sessionHandle });
4934
5108
 
5109
+ const releaseActivity = beginSessionActivity(sessionId);
5110
+ try {
5111
+ return await dispatchAcceptedSessionSend({
5112
+ msg, text, mentions, quote, sessionId, yeaftDir, perfTraceId, perfStart,
5113
+ tracePerf, traceDuration, turnConfig, sessionHandle, sessionRoot, sessionMetaForRuntime,
5114
+ });
5115
+ } finally {
5116
+ releaseActivity();
5117
+ }
5118
+ }
5119
+
5120
+ async function dispatchAcceptedSessionSend({
5121
+ msg, text, mentions, quote, sessionId, yeaftDir, perfTraceId, perfStart,
5122
+ tracePerf, traceDuration, turnConfig, sessionHandle, sessionRoot, sessionMetaForRuntime,
5123
+ }) {
4935
5124
  const ensureSessionStart = perfNowMs();
4936
5125
  await ensureSessionLoaded({ sessionMeta: sessionMetaForRuntime, perfTraceId });
4937
5126
  if (!sessionHandle) {
@@ -5145,7 +5334,6 @@ async function runYeaftSessionSend(msg) {
5145
5334
  turnId: report?.message?.id || null,
5146
5335
  ok: true,
5147
5336
  });
5148
-
5149
5337
  }
5150
5338
 
5151
5339
  /**
@@ -5454,7 +5642,9 @@ async function runVpTurnWithEscalation(args) {
5454
5642
  const { sessionId, vpId, turnId, threadId, thread, vpAbort } = args;
5455
5643
  const escalationState = { escalated: false, terminalEmitted: false };
5456
5644
 
5457
- await raceWithEscalation(runVpTurn({ ...args, escalationState }), {
5645
+ const releaseActivity = beginSessionActivity(sessionId);
5646
+ const inner = runVpTurn({ ...args, escalationState }).finally(releaseActivity);
5647
+ await raceWithEscalation(inner, {
5458
5648
  signal: vpAbort?.signal,
5459
5649
  graceMs: ESCALATE_AFTER_ABORT_MS,
5460
5650
  onEscalate: () => {
@@ -5802,6 +5992,9 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
5802
5992
  // would render the user's prompt N times.
5803
5993
  userAlreadyPersisted: true,
5804
5994
  currentUserMessage: inboundEnvelope?._persistedUserMessage || null,
5995
+ // Persist exact human ownership, not the most recently written prompt:
5996
+ // other VP threads can accept a new request before this one responds.
5997
+ causalRootId: inboundEnvelope?._causalRootId || inboundEnvelope?._persistedUserMessage?.id || null,
5805
5998
  askUser: ({ question, options }, toolCall = null) => new Promise((resolve, reject) => {
5806
5999
  const requestId = `ask_${randomUUID()}`;
5807
6000
  const signal = vpAbort.signal;
@@ -6204,6 +6397,9 @@ function appendTurnToSessionHistory(sessionId, threadId, vpId, prompts, assistan
6204
6397
  */
6205
6398
  function persistInboundMessageOnceByMsgId({ envelope = null, msgId, text, sessionId, threadId = 'main', role, speakerVpId, attachments, quote, internal = false, ts = null, clientMessageId = null }) {
6206
6399
  if (!session?.conversationStore) return false;
6400
+ // A related input is already durable on its original thread. Rescuing it
6401
+ // must retain that exact row even if routing chooses another thread.
6402
+ if (envelope?._inboundAlreadyPersisted && envelope._persistedUserMessage?.sessionId === sessionId) return false;
6207
6403
  // No msgId means no dedup key — caller is responsible for guarding.
6208
6404
  // Both call sites already do (`if (envMsgId && text)` and
6209
6405
  // `if (persistedMsgId)`); refusing here keeps the helper's contract
@@ -6263,7 +6459,10 @@ function persistInboundMessageOnceByMsgId({ envelope = null, msgId, text, sessio
6263
6459
  if (persistRole === 'assistant' && speakerVpId && typeof speakerVpId === 'string') {
6264
6460
  record.speakerVpId = speakerVpId;
6265
6461
  }
6266
- if (internal) record.internal = true;
6462
+ if (internal) {
6463
+ record.internal = true;
6464
+ if (envelope?._causalRootId) record.causalRootId = envelope._causalRootId;
6465
+ }
6267
6466
  if (persistRole === 'user' && Array.isArray(attachments) && attachments.length > 0) {
6268
6467
  record.attachments = attachments;
6269
6468
  }
@@ -8054,6 +8253,8 @@ export function handleYeaftMcpReload(msg = {}) {
8054
8253
  }
8055
8254
 
8056
8255
  export const __testHooks = {
8256
+ sessionHasActiveWork,
8257
+ scheduleTaskResultRescue,
8057
8258
  loadProjects,
8058
8259
  sharedProjectContext,
8059
8260
  normalizeProjectContext,