@indexnetwork/protocol 6.8.0-rc.395.1 → 6.9.0-rc.396.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/dist/index.d.ts +6 -3
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +4 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/negotiation/negotiation.agent.d.ts +3 -1
  7. package/dist/negotiation/negotiation.agent.d.ts.map +1 -1
  8. package/dist/negotiation/negotiation.agent.js +4 -1
  9. package/dist/negotiation/negotiation.agent.js.map +1 -1
  10. package/dist/negotiation/negotiation.graph.d.ts +53 -22
  11. package/dist/negotiation/negotiation.graph.d.ts.map +1 -1
  12. package/dist/negotiation/negotiation.graph.js +235 -71
  13. package/dist/negotiation/negotiation.graph.js.map +1 -1
  14. package/dist/negotiation/negotiation.question-safety.d.ts +26 -0
  15. package/dist/negotiation/negotiation.question-safety.d.ts.map +1 -0
  16. package/dist/negotiation/negotiation.question-safety.js +56 -0
  17. package/dist/negotiation/negotiation.question-safety.js.map +1 -0
  18. package/dist/negotiation/negotiation.state.d.ts +22 -2
  19. package/dist/negotiation/negotiation.state.d.ts.map +1 -1
  20. package/dist/negotiation/negotiation.state.js +30 -0
  21. package/dist/negotiation/negotiation.state.js.map +1 -1
  22. package/dist/opportunity/opportunity.graph.d.ts +12 -1
  23. package/dist/opportunity/opportunity.graph.d.ts.map +1 -1
  24. package/dist/opportunity/opportunity.graph.js +43 -5
  25. package/dist/opportunity/opportunity.graph.js.map +1 -1
  26. package/dist/opportunity/opportunity.state.d.ts +5 -2
  27. package/dist/opportunity/opportunity.state.d.ts.map +1 -1
  28. package/dist/opportunity/opportunity.state.js +5 -0
  29. package/dist/opportunity/opportunity.state.js.map +1 -1
  30. package/dist/questioner/questioner.agent.d.ts +1 -1
  31. package/dist/questioner/questioner.agent.d.ts.map +1 -1
  32. package/dist/questioner/questioner.agent.js +5 -0
  33. package/dist/questioner/questioner.agent.js.map +1 -1
  34. package/dist/questioner/questioner.presets.d.ts.map +1 -1
  35. package/dist/questioner/questioner.presets.js +9 -13
  36. package/dist/questioner/questioner.presets.js.map +1 -1
  37. package/dist/questioner/questioner.types.d.ts +44 -10
  38. package/dist/questioner/questioner.types.d.ts.map +1 -1
  39. package/dist/questioner/questioner.types.js +56 -1
  40. package/dist/questioner/questioner.types.js.map +1 -1
  41. package/dist/shared/agent/tool.factory.d.ts.map +1 -1
  42. package/dist/shared/agent/tool.factory.js +1 -0
  43. package/dist/shared/agent/tool.factory.js.map +1 -1
  44. package/dist/shared/interfaces/agent-dispatcher.interface.d.ts +3 -0
  45. package/dist/shared/interfaces/agent-dispatcher.interface.d.ts.map +1 -1
  46. package/dist/shared/interfaces/agent-dispatcher.interface.js.map +1 -1
  47. package/dist/shared/interfaces/database.interface.d.ts +81 -6
  48. package/dist/shared/interfaces/database.interface.d.ts.map +1 -1
  49. package/dist/shared/interfaces/database.interface.js.map +1 -1
  50. package/dist/shared/interfaces/negotiation-events.interface.d.ts +14 -2
  51. package/dist/shared/interfaces/negotiation-events.interface.d.ts.map +1 -1
  52. package/dist/shared/interfaces/negotiation-events.interface.js.map +1 -1
  53. package/dist/shared/schemas/question.schema.d.ts +347 -28
  54. package/dist/shared/schemas/question.schema.d.ts.map +1 -1
  55. package/dist/shared/schemas/question.schema.js +132 -2
  56. package/dist/shared/schemas/question.schema.js.map +1 -1
  57. package/package.json +1 -1
@@ -7,6 +7,7 @@ import { ASK_USER_LOCK_SLACK_MS, allowedActionsFor, askUserAnswerWindowMs, confi
7
7
  import { NegotiationScreener, configuredScreenMode } from "./negotiation.screen.js";
8
8
  import { assessDeadlock, configuredDeadlockShiftEnabled, configuredDeadlockThreshold } from "./negotiation.deadlock.js";
9
9
  import { protocolLogger } from "../shared/observability/protocol.logger.js";
10
+ import { NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY, NEGOTIATION_QUESTION_GENERIC_NETWORK, negotiationQuestionSettlementId, validateInflightAskUserFields } from './negotiation.question-safety.js';
10
11
  const logger = protocolLogger("NegotiationGraph");
11
12
  const initLog = protocolLogger("NegotiationGraph:Init");
12
13
  const screenNodeLog = protocolLogger("NegotiationGraph:Screen");
@@ -108,10 +109,14 @@ export class NegotiationGraphFactory {
108
109
  ].filter(Boolean).join("\n");
109
110
  const initNode = async (state) => {
110
111
  try {
111
- // Find-or-create the DM conversation for this agent pair (same as user DMs)
112
+ // Exact continuations reuse the prior conversation and preclaimed
113
+ // successor; they must not create any mutable state before the fence.
112
114
  const agentIdA = `agent:${state.sourceUser.id}`;
113
115
  const agentIdB = `agent:${state.candidateUser.id}`;
114
- const conversation = await database.getOrCreateDM(agentIdA, agentIdB, 'agent');
116
+ const execution = state.continuationExecution;
117
+ const conversation = execution
118
+ ? { id: execution.conversationId }
119
+ : await database.getOrCreateDM(agentIdA, agentIdB, 'agent');
115
120
  // --- Lock gate: check for an active task on this conversation ---
116
121
  const priorMessages = await database.getMessagesForConversation(conversation.id);
117
122
  const activeStates = ['submitted', 'working', 'input_required', 'waiting_for_agent', 'claimed'];
@@ -128,10 +133,37 @@ export class NegotiationGraphFactory {
128
133
  : 5 * 60 * 1000;
129
134
  return (Date.now() - new Date(t.updatedAt).getTime()) < freshnessMs;
130
135
  };
131
- const priorTask = state.opportunityId
132
- ? await database.getNegotiationTaskForOpportunity(state.opportunityId)
136
+ if (Boolean(state.resumeFromTaskId) !== Boolean(state.continuationSettlementId)
137
+ || Boolean(state.resumeFromTaskId) !== Boolean(execution))
138
+ return { error: 'invalid continuation correlation' };
139
+ const exactContinuation = state.resumeFromTaskId && state.continuationSettlementId && execution
140
+ ? { taskId: state.resumeFromTaskId, settlementId: state.continuationSettlementId, execution }
133
141
  : null;
134
- const isLocked = !!priorTask && isActiveAndFresh(priorTask);
142
+ const priorTask = exactContinuation
143
+ ? await database.getTask(exactContinuation.taskId)
144
+ : state.opportunityId
145
+ ? await database.getNegotiationTaskForOpportunity(state.opportunityId)
146
+ : null;
147
+ const claimedSuccessor = exactContinuation
148
+ ? await database.getTask(exactContinuation.execution.successorTaskId)
149
+ : null;
150
+ if (exactContinuation) {
151
+ const settlement = priorTask?.metadata?.questionSettlement;
152
+ const storedExecution = claimedSuccessor?.metadata?.continuationExecution;
153
+ if (!priorTask
154
+ || priorTask.conversationId !== conversation.id
155
+ || priorTask.state !== 'canceled'
156
+ || priorTask.metadata?.opportunityId !== state.opportunityId
157
+ || settlement?.settlementId !== exactContinuation.settlementId
158
+ || settlement?.taskId !== exactContinuation.taskId
159
+ || !claimedSuccessor
160
+ || claimedSuccessor.conversationId !== conversation.id
161
+ || storedExecution?.token !== exactContinuation.execution.token
162
+ || storedExecution?.fence !== exactContinuation.execution.fence
163
+ || storedExecution?.status !== 'claimed')
164
+ return { error: 'invalid exact continuation task' };
165
+ }
166
+ const isLocked = !exactContinuation && !!priorTask && isActiveAndFresh(priorTask);
135
167
  if (isLocked) {
136
168
  initLog.info('Conversation locked by active task, returning busy', {
137
169
  conversationId: conversation.id,
@@ -186,7 +218,7 @@ export class NegotiationGraphFactory {
186
218
  // Conversation-scoped prior task: reused for both the initiator tie-break
187
219
  // (only when active+fresh) and protocol-version inheritance (any prior
188
220
  // task on the conversation pins the version).
189
- const convTask = (!readInitiator(priorTask?.metadata) || !readProtocolVersion(priorTask?.metadata))
221
+ const convTask = !exactContinuation && (!readInitiator(priorTask?.metadata) || !readProtocolVersion(priorTask?.metadata))
190
222
  ? await database.getLatestNegotiationTaskForConversation?.(conversation.id).catch(() => null)
191
223
  : null;
192
224
  if (!readInitiator(priorTask?.metadata)) {
@@ -227,24 +259,36 @@ export class NegotiationGraphFactory {
227
259
  protocolVersion,
228
260
  candidateUserId: state.candidateUser.id,
229
261
  networkId: state.indexContext.networkId,
262
+ sourceIntentId: state.sourceIntentId,
263
+ candidateIntentId: state.candidateIntentId,
264
+ participantBindings: [
265
+ ...(state.sourceIntentId ? [{ userId: state.sourceUser.id, intentId: state.sourceIntentId, networkId: state.indexContext.networkId }] : []),
266
+ ...(state.candidateIntentId ? [{ userId: state.candidateUser.id, intentId: state.candidateIntentId, networkId: state.indexContext.networkId }] : []),
267
+ ],
230
268
  intentSnapshots: buildIntentSnapshots(state.sourceUser, state.candidateUser),
231
269
  ...(state.opportunityId && { opportunityId: state.opportunityId }),
232
270
  maxTurns,
233
271
  isContinuation,
234
272
  priorTurnCount: priorTurns.length,
273
+ ...(exactContinuation ? {
274
+ resumeFromTaskId: exactContinuation.taskId,
275
+ continuationSettlementId: exactContinuation.settlementId,
276
+ } : {}),
235
277
  };
236
278
  if (state.opportunityId && Boolean(state.opportunityStatus) !== Boolean(state.opportunityUpdatedAt)) {
237
279
  throw new Error('Negotiation attempt requires both opportunity status and updatedAt');
238
280
  }
239
- const task = state.opportunityId && state.opportunityStatus && state.opportunityUpdatedAt
240
- ? await database.createNegotiationTaskForAttempt({
241
- conversationId: conversation.id,
242
- opportunityId: state.opportunityId,
243
- expectedStatus: state.opportunityStatus,
244
- expectedUpdatedAt: state.opportunityUpdatedAt,
245
- metadata: taskMetadata,
246
- })
247
- : await database.createTask(conversation.id, taskMetadata);
281
+ const task = exactContinuation
282
+ ? claimedSuccessor
283
+ : state.opportunityId && state.opportunityStatus && state.opportunityUpdatedAt
284
+ ? await database.createNegotiationTaskForAttempt({
285
+ conversationId: conversation.id,
286
+ opportunityId: state.opportunityId,
287
+ expectedStatus: state.opportunityStatus,
288
+ expectedUpdatedAt: state.opportunityUpdatedAt,
289
+ metadata: taskMetadata,
290
+ })
291
+ : await database.createTask(conversation.id, taskMetadata);
248
292
  if (!task) {
249
293
  throw new Error('Negotiation attempt is stale or already claimed');
250
294
  }
@@ -282,6 +326,9 @@ export class NegotiationGraphFactory {
282
326
  protocolVersion,
283
327
  priorTurnCount: priorTurns.length,
284
328
  ...(userAnswers.length > 0 && { userAnswers }),
329
+ ...(exactContinuation?.execution.consultation
330
+ ? { privateConsultation: exactContinuation.execution.consultation }
331
+ : {}),
285
332
  ...(seedMessages.length > 0 && { messages: seedMessages }),
286
333
  };
287
334
  }
@@ -360,7 +407,7 @@ export class NegotiationGraphFactory {
360
407
  screenedAt: new Date().toISOString(),
361
408
  durationMs,
362
409
  };
363
- await database.setTaskScreenDecision?.(state.taskId, record).catch((err) => {
410
+ await database.setTaskScreenDecision?.(state.taskId, record, state.continuationExecution).catch((err) => {
364
411
  screenNodeLog.error("Failed to persist screen decision", { taskId: state.taskId, error: err });
365
412
  });
366
413
  screenNodeLog.info("negotiation_screen", {
@@ -410,6 +457,7 @@ export class NegotiationGraphFactory {
410
457
  const isSource = state.currentSpeaker === "source";
411
458
  const ownUser = isSource ? state.sourceUser : state.candidateUser;
412
459
  const otherUser = isSource ? state.candidateUser : state.sourceUser;
460
+ const ownIntentId = isSource ? state.sourceIntentId : state.candidateIntentId;
413
461
  // Determine if this is the system agent's final allowed turn
414
462
  const maxTurns = state.maxTurns ?? 0;
415
463
  const isFinalTurn = maxTurns > 0 && (state.turnCount + 1) >= maxTurns;
@@ -433,6 +481,8 @@ export class NegotiationGraphFactory {
433
481
  && !!questionerEnqueue
434
482
  && !!timeoutQueue?.enqueueAskUserExpiry
435
483
  && !!state.opportunityId
484
+ && !!ownIntentId
485
+ && !!state.indexContext.networkId
436
486
  && !(state.turnCount === 0 && !state.isContinuation)
437
487
  && !hasPriorAskUser(state.messages, ownUser.id);
438
488
  // ─── Deadlock detection → persuasion→bargaining stance (IND-428) ──────
@@ -479,6 +529,9 @@ export class NegotiationGraphFactory {
479
529
  allowedActions: [...allowedActionsFor(version, seat, isFinalTurn, { askUser: askUserAvailable })],
480
530
  ...(state.discoveryQuery && isSource && { discoveryQuery: state.discoveryQuery }),
481
531
  ...(ownMemory.length > 0 && { negotiatorMemory: ownMemory }),
532
+ ...(state.privateConsultation?.recipientUserId === ownUser.id
533
+ ? { privateConsultation: state.privateConsultation }
534
+ : {}),
482
535
  };
483
536
  const scope = { action: 'manage:negotiations', scopeType: 'network', scopeId: state.indexContext.networkId };
484
537
  const dispatchResult = await dispatcher.dispatch(ownUser.id, scope, payload, { timeoutMs: state.timeoutMs });
@@ -512,8 +565,11 @@ export class NegotiationGraphFactory {
512
565
  // candidate-side turns would make the pickup prompt frame the
513
566
  // search as "your user searched for X" for the wrong user.
514
567
  ...(isSource && state.discoveryQuery && { discoveryQuery: state.discoveryQuery }),
515
- });
516
- await database.updateTaskState(state.taskId, "waiting_for_agent");
568
+ ...(state.privateConsultation && state.privateConsultation.recipientUserId === ownUser.id
569
+ ? { privateConsultation: state.privateConsultation }
570
+ : {}),
571
+ }, state.continuationExecution);
572
+ await database.updateTaskState(state.taskId, "waiting_for_agent", undefined, state.continuationExecution);
517
573
  return { status: 'waiting_for_agent' };
518
574
  }
519
575
  else {
@@ -534,6 +590,9 @@ export class NegotiationGraphFactory {
534
590
  ...(askUserAvailable && { canAskUser: true }),
535
591
  ...(bargainingMode && { bargaining: { consecutiveNonConvergent: deadlock.consecutiveNonConvergent } }),
536
592
  ...(ownMemory.length > 0 && { memory: ownMemory }),
593
+ ...(state.privateConsultation?.recipientUserId === ownUser.id
594
+ ? { privateConsultation: state.privateConsultation }
595
+ : {}),
537
596
  });
538
597
  }
539
598
  traceEmitter?.({ type: "agent_end", name: agentName, durationMs: Date.now() - agentStart, summary: `${turn.action}` });
@@ -576,7 +635,7 @@ export class NegotiationGraphFactory {
576
635
  seat,
577
636
  detectedAt: new Date().toISOString(),
578
637
  };
579
- await database.setTaskDeadlockShift?.(state.taskId, deadlockShiftRecord).catch((err) => {
638
+ await database.setTaskDeadlockShift?.(state.taskId, deadlockShiftRecord, state.continuationExecution).catch((err) => {
580
639
  turnLog.error('Failed to persist deadlock shift record', { taskId: state.taskId, error: err });
581
640
  });
582
641
  turnLog.info('negotiation_deadlock_shift', {
@@ -606,6 +665,7 @@ export class NegotiationGraphFactory {
606
665
  role: "agent",
607
666
  parts,
608
667
  taskId: state.taskId,
668
+ ...(state.continuationExecution ? { continuationExecution: state.continuationExecution } : {}),
609
669
  });
610
670
  // ─── ask_user pause (P3.2) ────────────────────────────────────────────
611
671
  // The negotiator consults its OWN client: persist the turn (done above),
@@ -615,50 +675,101 @@ export class NegotiationGraphFactory {
615
675
  // like the waiting_for_agent suspend; the answer (or window expiry)
616
676
  // resumes via the run-existing continuation path.
617
677
  if (turn.action === 'ask_user') {
618
- const disclosureSubject = turn.askUser?.disclosureSubject?.trim()
619
- || turn.message
620
- || turn.assessment.reasoning;
621
- const draftQuestion = turn.askUser?.draftQuestion ?? turn.message ?? undefined;
622
- await database.setTaskTurnContext(state.taskId, {
623
- sourceUser: state.sourceUser,
624
- candidateUser: state.candidateUser,
625
- indexContext: state.indexContext,
626
- seedAssessment: state.seedAssessment,
627
- ...(isSource && state.discoveryQuery && { discoveryQuery: state.discoveryQuery }),
678
+ const counterparty = isSource ? state.candidateUser : state.sourceUser;
679
+ const safeAskUser = validateInflightAskUserFields({
680
+ disclosureSubject: turn.askUser?.disclosureSubject,
681
+ draftQuestion: turn.askUser?.draftQuestion,
682
+ forbiddenIdentifiers: [
683
+ counterparty.id,
684
+ counterparty.profile.name ?? '',
685
+ state.opportunityId,
686
+ state.taskId,
687
+ state.indexContext.networkId,
688
+ isSource ? state.candidateIntentId ?? '' : state.sourceIntentId ?? '',
689
+ ],
690
+ forbiddenSourceText: [
691
+ counterparty.profile.bio ?? '',
692
+ counterparty.profile.location ?? '',
693
+ ...counterparty.intents.flatMap((intent) => [intent.title, intent.description]),
694
+ state.seedAssessment.reasoning,
695
+ state.indexContext.prompt,
696
+ state.discoveryQuery ?? '',
697
+ ],
628
698
  });
629
- // Arm the timer BEFORE flipping state: a timer against a task that
630
- // never reaches input_required no-ops harmlessly at fire time, while
631
- // an input_required task without a timer would strand until the lock
632
- // slack expires.
699
+ const settlementId = negotiationQuestionSettlementId(state.taskId);
700
+ const askUserBinding = await database.captureNegotiationAskUserBinding({
701
+ taskId: state.taskId,
702
+ settlementId,
703
+ recipientUserId: ownUser.id,
704
+ recipientIntentId: ownIntentId,
705
+ opportunityId: state.opportunityId,
706
+ networkId: state.indexContext.networkId,
707
+ turnContext: {
708
+ sourceUser: state.sourceUser,
709
+ candidateUser: state.candidateUser,
710
+ indexContext: state.indexContext,
711
+ seedAssessment: state.seedAssessment,
712
+ ...(isSource && state.discoveryQuery && { discoveryQuery: state.discoveryQuery }),
713
+ },
714
+ ...(state.continuationExecution ? { continuationExecution: state.continuationExecution } : {}),
715
+ });
716
+ // Arm the timer BEFORE flipping state: it is the durable recovery
717
+ // trigger even when generation enqueues no job or persists no row.
633
718
  const windowMs = askUserAnswerWindowMs();
634
719
  await timeoutQueue.enqueueAskUserExpiry(state.taskId, {
720
+ settlementId,
635
721
  opportunityId: state.opportunityId,
636
722
  userId: ownUser.id,
637
- disclosureSubject,
723
+ recipientIntentId: ownIntentId,
724
+ networkId: state.indexContext.networkId,
725
+ intentFingerprint: askUserBinding.intentFingerprint,
726
+ opportunityStatus: askUserBinding.opportunityStatus,
727
+ opportunityUpdatedAt: askUserBinding.opportunityUpdatedAt,
728
+ counterpartyUserId: askUserBinding.counterpartyUserId,
729
+ counterpartyIntentId: askUserBinding.counterpartyIntentId,
638
730
  }, windowMs);
639
- // Counterparty referenced by attributes, never identity the
640
- // negotiation_inflight preset's referential-closure contract.
641
- const counterpartyHint = [
642
- otherUser.profile.bio,
643
- otherUser.profile.location,
644
- otherUser.profile.skills?.length ? `skills: ${otherUser.profile.skills.join(', ')}` : undefined,
645
- ].filter(Boolean).join('; ') || 'a potential match on the network';
646
- const userContext = (await database.getUserContext(ownUser.id, null).catch(() => null))?.text ?? '';
647
- await questionerEnqueue({
648
- mode: 'negotiation_inflight',
649
- userId: ownUser.id,
650
- sourceType: 'opportunity',
651
- sourceId: state.opportunityId,
652
- context: {
653
- negotiationId: state.taskId,
654
- counterpartyHint,
655
- disclosureSubject,
656
- ...(draftQuestion && { draftQuestion }),
657
- indexContext: state.indexContext.prompt,
658
- ...(userContext && { userContext }),
659
- },
660
- });
661
- await database.updateTaskState(state.taskId, 'input_required');
731
+ // Persistence admission requires the exact task to be input_required.
732
+ // Flip before enqueue; if the structured ask_user fields fail the
733
+ // deterministic privacy gate, the timer alone closes the exact task.
734
+ await database.updateTaskState(state.taskId, 'input_required', undefined, state.continuationExecution);
735
+ if (safeAskUser) {
736
+ const userContext = (await database.getUserContext(ownUser.id, null).catch(() => null))?.text ?? '';
737
+ await questionerEnqueue({
738
+ mode: 'negotiation_inflight',
739
+ purpose: 'inflight_consultation',
740
+ userId: ownUser.id,
741
+ sourceType: 'opportunity',
742
+ sourceId: state.opportunityId,
743
+ negotiation: {
744
+ purpose: 'inflight_consultation',
745
+ recipientUserId: ownUser.id,
746
+ recipientIntentId: ownIntentId,
747
+ opportunityId: state.opportunityId,
748
+ taskId: state.taskId,
749
+ networkId: state.indexContext.networkId,
750
+ },
751
+ context: {
752
+ negotiationId: state.taskId,
753
+ counterpartyHint: NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY,
754
+ disclosureSubject: safeAskUser.disclosureSubject,
755
+ ...(safeAskUser.draftQuestion && { draftQuestion: safeAskUser.draftQuestion }),
756
+ indexContext: NEGOTIATION_QUESTION_GENERIC_NETWORK,
757
+ ...(userContext && { userContext }),
758
+ },
759
+ }).catch((error) => {
760
+ turnLog.error('Failed to enqueue safe ask_user question; timeout recovery remains armed', {
761
+ taskId: state.taskId,
762
+ opportunityId: state.opportunityId,
763
+ error,
764
+ });
765
+ });
766
+ }
767
+ else {
768
+ turnLog.warn('Skipping unsafe or incomplete ask_user question generation', {
769
+ taskId: state.taskId,
770
+ opportunityId: state.opportunityId,
771
+ });
772
+ }
662
773
  turnLog.info('negotiation_ask_user_pause', {
663
774
  taskId: state.taskId,
664
775
  opportunityId: state.opportunityId,
@@ -673,7 +784,7 @@ export class NegotiationGraphFactory {
673
784
  negotiationConversationId: state.conversationId,
674
785
  turnIndex: state.turnCount,
675
786
  actor: isSource ? 'source' : 'candidate',
676
- disclosureSubject,
787
+ questionGenerationSafe: Boolean(safeAskUser),
677
788
  windowMs,
678
789
  });
679
790
  return {
@@ -690,7 +801,7 @@ export class NegotiationGraphFactory {
690
801
  ...(deadlockShiftRecord && { deadlockShift: deadlockShiftRecord }),
691
802
  };
692
803
  }
693
- await database.updateTaskState(state.taskId, "working");
804
+ await database.updateTaskState(state.taskId, "working", undefined, state.continuationExecution);
694
805
  if (state.opportunityId) {
695
806
  emitWide({
696
807
  type: "negotiation_turn",
@@ -757,6 +868,17 @@ export class NegotiationGraphFactory {
757
868
  const finalizeNode = async (state) => {
758
869
  const traceEmitter = requestContext.getStore()?.traceEmitter;
759
870
  const emitWide = (event) => traceEmitter?.(event);
871
+ const pauseReceipt = (outcome) => state.continuationExecution
872
+ ? {
873
+ continuationReceipt: {
874
+ priorTaskId: state.continuationExecution.taskId,
875
+ settlementId: state.continuationExecution.settlementId,
876
+ successorTaskId: state.continuationExecution.successorTaskId,
877
+ fence: state.continuationExecution.fence,
878
+ outcome,
879
+ },
880
+ }
881
+ : undefined;
760
882
  if (state.status === 'waiting_for_agent') {
761
883
  if (state.opportunityId) {
762
884
  emitWide({
@@ -767,7 +889,7 @@ export class NegotiationGraphFactory {
767
889
  isContinuation: state.isContinuation,
768
890
  });
769
891
  }
770
- return {};
892
+ return pauseReceipt('waiting_for_agent') ?? {};
771
893
  }
772
894
  // ask_user pause: no outcome, no completed state — the task stays
773
895
  // input_required until the client answers or the window expires.
@@ -781,7 +903,7 @@ export class NegotiationGraphFactory {
781
903
  isContinuation: state.isContinuation,
782
904
  });
783
905
  }
784
- return {};
906
+ return pauseReceipt('input_required') ?? {};
785
907
  }
786
908
  // Init can fail closed before an attempt owns a task. Such a rejection is
787
909
  // not a completed negotiation and must not write through an empty task ID,
@@ -827,12 +949,21 @@ export class NegotiationGraphFactory {
827
949
  : {}),
828
950
  };
829
951
  try {
830
- await database.updateTaskState(state.taskId, "completed");
952
+ await database.updateTaskState(state.taskId, "completed", undefined, state.continuationExecution);
831
953
  await database.createArtifact({
832
954
  taskId: state.taskId,
833
955
  name: "negotiation-outcome",
834
956
  parts: [{ kind: "data", data: outcome }],
835
- metadata: { hasOpportunity, turnCount: state.turnCount },
957
+ metadata: {
958
+ hasOpportunity,
959
+ turnCount: state.turnCount,
960
+ ...(state.continuationExecution ? {
961
+ continuationOutcome: hasOpportunity
962
+ ? 'accepted'
963
+ : (screenedOut || isRejectLikeAction(lastTurn?.action)) ? 'rejected' : 'stalled',
964
+ } : {}),
965
+ },
966
+ ...(state.continuationExecution ? { continuationExecution: state.continuationExecution } : {}),
836
967
  });
837
968
  finalizeLog.info('Session complete', {
838
969
  conversationId: state.conversationId,
@@ -852,7 +983,7 @@ export class NegotiationGraphFactory {
852
983
  : (screenedOut || isRejectLikeAction(lastTurn?.action))
853
984
  ? 'rejected'
854
985
  : 'stalled';
855
- await database.updateOpportunityStatus(state.opportunityId, nextStatus).catch((err) => {
986
+ await database.updateOpportunityStatus(state.opportunityId, nextStatus, undefined, state.continuationExecution).catch((err) => {
856
987
  finalizeLog.error("Failed to update opportunity status", { opportunityId: state.opportunityId, nextStatus, error: err });
857
988
  });
858
989
  }
@@ -891,7 +1022,7 @@ export class NegotiationGraphFactory {
891
1022
  // and forget: a reflection failure must never affect the outcome. Only
892
1023
  // sessions that actually exchanged turns teach anything; init/turn
893
1024
  // errors with turnCount 0 are skipped.
894
- if (reflectEnqueue && state.turnCount > 0) {
1025
+ if (reflectEnqueue && state.turnCount > 0 && !state.continuationExecution) {
895
1026
  reflectEnqueue({
896
1027
  negotiationId: state.taskId,
897
1028
  conversationId: state.conversationId,
@@ -915,24 +1046,36 @@ export class NegotiationGraphFactory {
915
1046
  }
916
1047
  // Enqueue question generation for stalled/capped negotiations (not accepted or explicitly rejected).
917
1048
  // Require turnCount > 0 so early init/turn errors don't enqueue with empty context.
918
- if (!hasOpportunity && !isRejectLikeAction(lastTurn?.action) && state.turnCount > 0 && state.opportunityId && questionerEnqueue) {
1049
+ if (!hasOpportunity && !isRejectLikeAction(lastTurn?.action) && state.turnCount > 0 && state.opportunityId && state.sourceIntentId && state.indexContext.networkId && questionerEnqueue && !state.continuationExecution) {
919
1050
  const stallReason = atCap
920
1051
  ? 'turn_cap'
921
1052
  : (state.error && /timeout/i.test(state.error))
922
1053
  ? 'timeout'
923
1054
  : 'stalled';
924
1055
  const userContext = (await database.getUserContext(state.sourceUser.id, null))?.text ?? '';
1056
+ const sourceIntent = state.sourceUser.intents.find((intent) => intent.id === state.sourceIntentId);
925
1057
  questionerEnqueue({
926
1058
  mode: 'negotiation',
1059
+ purpose: 'stalled_followup',
927
1060
  userId: state.sourceUser.id,
928
1061
  sourceType: 'opportunity',
929
1062
  sourceId: state.opportunityId,
1063
+ negotiation: {
1064
+ purpose: 'stalled_followup',
1065
+ recipientUserId: state.sourceUser.id,
1066
+ recipientIntentId: state.sourceIntentId,
1067
+ opportunityId: state.opportunityId,
1068
+ taskId: state.taskId,
1069
+ networkId: state.indexContext.networkId,
1070
+ },
930
1071
  context: {
931
1072
  negotiationId: state.taskId,
932
- counterpartyHint: `${state.candidateUser.profile.name ?? 'Unknown'}${state.candidateUser.profile.bio ? ', ' + state.candidateUser.profile.bio : ''}`,
933
- indexContext: state.indexContext.prompt,
1073
+ counterpartyHint: NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY,
1074
+ indexContext: NEGOTIATION_QUESTION_GENERIC_NETWORK,
934
1075
  outcomeReason: stallReason,
935
- keyTake: outcome.reasoning,
1076
+ recipientIntent: sourceIntent
1077
+ ? `${sourceIntent.title}: ${sourceIntent.description}`
1078
+ : 'the signal attached to this match',
936
1079
  userContext,
937
1080
  },
938
1081
  }).catch((err) => finalizeLog.error('Failed to enqueue negotiation question generation', {
@@ -940,7 +1083,20 @@ export class NegotiationGraphFactory {
940
1083
  error: err,
941
1084
  }));
942
1085
  }
943
- return { outcome, status: 'completed' };
1086
+ const terminalReceipt = state.continuationExecution
1087
+ ? {
1088
+ continuationReceipt: {
1089
+ priorTaskId: state.continuationExecution.taskId,
1090
+ settlementId: state.continuationExecution.settlementId,
1091
+ successorTaskId: state.continuationExecution.successorTaskId,
1092
+ fence: state.continuationExecution.fence,
1093
+ outcome: hasOpportunity
1094
+ ? 'accepted'
1095
+ : (screenedOut || isRejectLikeAction(lastTurn?.action)) ? 'rejected' : 'stalled',
1096
+ },
1097
+ }
1098
+ : undefined;
1099
+ return { outcome, status: 'completed', ...terminalReceipt };
944
1100
  };
945
1101
  const workflow = new StateGraph(NegotiationGraphState)
946
1102
  .addNode("init", initNode)
@@ -972,7 +1128,7 @@ export class NegotiationGraphFactory {
972
1128
  * @returns Only candidates that produced an opportunity
973
1129
  */
974
1130
  export async function negotiateCandidates(negotiationGraph, sourceUser, candidates, indexContext, opts) {
975
- const { maxTurns, traceEmitter, indexContextOverrides, timeoutMs, onCandidateResolved, trigger, initiatorUserId } = opts ?? {};
1131
+ const { maxTurns, traceEmitter, indexContextOverrides, timeoutMs, onCandidateResolved, trigger, initiatorUserId, resumeFromTaskId, continuationSettlementId, continuationExecution, } = opts ?? {};
976
1132
  // Local helper to emit events whose shape is wider than the declared
977
1133
  // `TraceEmitter` union (mirrors the cast used in chat.agent at the relay sink
978
1134
  // and inside turn/finalize nodes above).
@@ -1001,6 +1157,8 @@ export async function negotiateCandidates(negotiationGraph, sourceUser, candidat
1001
1157
  const result = await invokeWithAbortSignal(negotiationGraph, {
1002
1158
  sourceUser,
1003
1159
  candidateUser: candidate.candidateUser,
1160
+ ...(candidate.sourceIntentId && { sourceIntentId: candidate.sourceIntentId }),
1161
+ ...(candidate.candidateIntentId && { candidateIntentId: candidate.candidateIntentId }),
1004
1162
  indexContext: candidateIndexContext,
1005
1163
  seedAssessment: {
1006
1164
  reasoning: candidate.reasoning,
@@ -1011,6 +1169,11 @@ export async function negotiateCandidates(negotiationGraph, sourceUser, candidat
1011
1169
  ...(candidate.opportunityStatus && { opportunityStatus: candidate.opportunityStatus }),
1012
1170
  ...(candidate.opportunityUpdatedAt && { opportunityUpdatedAt: candidate.opportunityUpdatedAt }),
1013
1171
  ...(initiatorUserId && { initiatorUserId }),
1172
+ ...(resumeFromTaskId && continuationSettlementId && continuationExecution ? {
1173
+ resumeFromTaskId,
1174
+ continuationSettlementId,
1175
+ continuationExecution,
1176
+ } : {}),
1014
1177
  ...(maxTurns !== undefined && { maxTurns }),
1015
1178
  ...(timeoutMs !== undefined && { timeoutMs }),
1016
1179
  });
@@ -1064,6 +1227,7 @@ export async function negotiateCandidates(negotiationGraph, sourceUser, candidat
1064
1227
  accepted,
1065
1228
  turns: turnHistory,
1066
1229
  outcome: resolvedOutcome,
1230
+ ...(result.continuationReceipt ? { continuationReceipt: result.continuationReceipt } : {}),
1067
1231
  });
1068
1232
  }
1069
1233
  catch (hookErr) {