@indexnetwork/protocol 14.3.2-rc.477.1 → 16.1.1-rc.478.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 (140) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/IMPLEMENTATION.md +50 -52
  3. package/STABILITY.md +3 -3
  4. package/dist/chat/chat.agent.js +2 -2
  5. package/dist/chat/chat.graph.d.ts +41 -1
  6. package/dist/chat/chat.graph.js +108 -127
  7. package/dist/discovery/hyde.frame.d.ts +4 -4
  8. package/dist/discovery/hyde.graph.d.ts +71 -12
  9. package/dist/discovery/hyde.graph.js +337 -335
  10. package/dist/discovery/lens.inferrer.d.ts +6 -6
  11. package/dist/enrichment/enrichment.graph.d.ts +127 -9
  12. package/dist/enrichment/enrichment.graph.js +633 -655
  13. package/dist/enrichment/enrichment.state.d.ts +1 -1
  14. package/dist/enrichment/enrichment.tools.context-read.d.ts +10 -0
  15. package/dist/enrichment/enrichment.tools.context-read.js +353 -0
  16. package/dist/enrichment/enrichment.tools.context-write.d.ts +9 -0
  17. package/dist/enrichment/enrichment.tools.context-write.js +417 -0
  18. package/dist/enrichment/enrichment.tools.d.ts +11 -1
  19. package/dist/enrichment/enrichment.tools.helpers.d.ts +139 -0
  20. package/dist/enrichment/enrichment.tools.helpers.js +234 -0
  21. package/dist/enrichment/enrichment.tools.js +16 -975
  22. package/dist/index.d.ts +27 -48
  23. package/dist/index.js +17 -27
  24. package/dist/intents/application/intent.graph.d.ts +71 -67
  25. package/dist/intents/application/intent.graph.execute.d.ts +59 -0
  26. package/dist/intents/application/intent.graph.execute.js +301 -0
  27. package/dist/intents/application/intent.graph.infer.d.ts +44 -0
  28. package/dist/intents/application/intent.graph.infer.js +97 -0
  29. package/dist/intents/application/intent.graph.js +96 -888
  30. package/dist/intents/application/intent.graph.reconcile.d.ts +71 -0
  31. package/dist/intents/application/intent.graph.reconcile.js +274 -0
  32. package/dist/intents/application/intent.graph.shared.d.ts +70 -0
  33. package/dist/intents/application/intent.graph.shared.js +153 -0
  34. package/dist/intents/domain/intent.state.d.ts +1 -1
  35. package/dist/maintenance/maintenance.graph.d.ts +66 -3
  36. package/dist/maintenance/maintenance.graph.js +155 -156
  37. package/dist/mcp/mcp.authorization-policy.d.ts +24 -28
  38. package/dist/mcp/mcp.authorization-policy.js +14 -34
  39. package/dist/mcp/mcp.server.d.ts +3 -3
  40. package/dist/mcp/mcp.server.js +12 -16
  41. package/dist/negotiations/application/negotiation.candidates.d.ts +83 -0
  42. package/dist/negotiations/application/negotiation.candidates.js +162 -0
  43. package/dist/negotiations/application/negotiation.graph.d.ts +91 -157
  44. package/dist/negotiations/application/negotiation.graph.finalize.d.ts +5 -0
  45. package/dist/negotiations/application/negotiation.graph.finalize.js +280 -0
  46. package/dist/negotiations/application/negotiation.graph.init.d.ts +54 -0
  47. package/dist/negotiations/application/negotiation.graph.init.js +227 -0
  48. package/dist/negotiations/application/negotiation.graph.js +69 -1388
  49. package/dist/negotiations/application/negotiation.graph.screen.d.ts +23 -0
  50. package/dist/negotiations/application/negotiation.graph.screen.js +108 -0
  51. package/dist/negotiations/application/negotiation.graph.shared.d.ts +75 -0
  52. package/dist/negotiations/application/negotiation.graph.shared.js +125 -0
  53. package/dist/negotiations/application/negotiation.graph.turn.d.ts +105 -0
  54. package/dist/negotiations/application/negotiation.graph.turn.js +484 -0
  55. package/dist/negotiations/domain/negotiation.state.d.ts +1 -1
  56. package/dist/negotiations/domain/negotiation.state.js +0 -1
  57. package/dist/networks/application/indexer.graph.d.ts +165 -7
  58. package/dist/networks/application/indexer.graph.js +339 -388
  59. package/dist/networks/application/indexer.state.d.ts +1 -1
  60. package/dist/networks/application/membership.graph.d.ts +83 -5
  61. package/dist/networks/application/membership.graph.js +177 -207
  62. package/dist/networks/application/network.graph.d.ts +148 -5
  63. package/dist/networks/application/network.graph.js +249 -278
  64. package/dist/networks/domain/membership.state.d.ts +1 -1
  65. package/dist/networks/domain/network.state.d.ts +1 -1
  66. package/dist/opportunities/application/delivery-card.cache.d.ts +1 -1
  67. package/dist/opportunities/application/delivery-card.cache.js +2 -2
  68. package/dist/opportunities/application/index.d.ts +2 -2
  69. package/dist/opportunities/application/index.js +2 -2
  70. package/dist/opportunities/application/opportunity.evaluator.js +1 -1
  71. package/dist/opportunities/application/opportunity.graph.d.ts +686 -473
  72. package/dist/opportunities/application/opportunity.graph.discovery-strategies.d.ts +109 -0
  73. package/dist/opportunities/application/opportunity.graph.discovery-strategies.js +451 -0
  74. package/dist/opportunities/application/opportunity.graph.discovery.d.ts +69 -0
  75. package/dist/opportunities/application/opportunity.graph.discovery.js +397 -0
  76. package/dist/opportunities/application/opportunity.graph.evaluation.d.ts +82 -0
  77. package/dist/opportunities/application/opportunity.graph.evaluation.js +608 -0
  78. package/dist/opportunities/application/opportunity.graph.js +120 -3690
  79. package/dist/opportunities/application/opportunity.graph.modes.d.ts +538 -0
  80. package/dist/opportunities/application/opportunity.graph.modes.js +538 -0
  81. package/dist/opportunities/application/opportunity.graph.negotiate.d.ts +109 -0
  82. package/dist/opportunities/application/opportunity.graph.negotiate.js +392 -0
  83. package/dist/opportunities/application/opportunity.graph.persist-node.d.ts +93 -0
  84. package/dist/opportunities/application/opportunity.graph.persist-node.js +765 -0
  85. package/dist/opportunities/application/opportunity.graph.prep.d.ts +150 -0
  86. package/dist/opportunities/application/opportunity.graph.prep.js +381 -0
  87. package/dist/opportunities/application/opportunity.graph.shared.d.ts +173 -0
  88. package/dist/opportunities/application/opportunity.graph.shared.js +199 -0
  89. package/dist/opportunities/application/opportunity.presentation.d.ts +399 -0
  90. package/dist/opportunities/application/{opportunity.presenter.js → opportunity.presentation.js} +735 -9
  91. package/dist/opportunities/application/opportunity.tools.cards.d.ts +152 -0
  92. package/dist/opportunities/application/opportunity.tools.cards.js +235 -0
  93. package/dist/opportunities/application/opportunity.tools.d.ts +8 -114
  94. package/dist/opportunities/application/opportunity.tools.js +28 -711
  95. package/dist/opportunities/application/opportunity.tools.list.d.ts +10 -0
  96. package/dist/opportunities/application/opportunity.tools.list.js +493 -0
  97. package/dist/opportunities/domain/index.d.ts +3 -3
  98. package/dist/opportunities/domain/index.js +3 -3
  99. package/dist/opportunities/domain/opportunity.state.d.ts +19 -19
  100. package/dist/opportunities/index.d.ts +6 -6
  101. package/dist/opportunities/index.js +4 -4
  102. package/dist/opportunities/ports/opportunity.tools.port.d.ts +1 -1
  103. package/dist/opportunities/radar/radar.graph.d.ts +57 -13
  104. package/dist/opportunities/radar/radar.graph.js +470 -471
  105. package/dist/premises/premise.graph.d.ts +88 -20
  106. package/dist/premises/premise.graph.js +207 -218
  107. package/dist/questions/domain/question.schema.d.ts +40 -40
  108. package/dist/shared/agent/tool.helpers.d.ts +21 -0
  109. package/dist/shared/agent/tool.registry.d.ts +4 -3
  110. package/dist/shared/agent/tool.registry.js +1 -1
  111. package/dist/shared/interfaces/database.capabilities.d.ts +100 -0
  112. package/dist/shared/interfaces/database.capabilities.js +7 -0
  113. package/dist/shared/interfaces/database.entities.d.ts +533 -0
  114. package/dist/shared/interfaces/database.entities.js +10 -0
  115. package/dist/shared/interfaces/database.identity-queries.d.ts +294 -0
  116. package/dist/shared/interfaces/database.identity-queries.js +4 -0
  117. package/dist/shared/interfaces/database.interface.d.ts +15 -2277
  118. package/dist/shared/interfaces/database.interface.js +8 -0
  119. package/dist/shared/interfaces/database.member-queries.d.ts +228 -0
  120. package/dist/shared/interfaces/database.member-queries.js +4 -0
  121. package/dist/shared/interfaces/database.negotiation.d.ts +305 -0
  122. package/dist/shared/interfaces/database.negotiation.js +26 -0
  123. package/dist/shared/interfaces/database.network-queries.d.ts +277 -0
  124. package/dist/shared/interfaces/database.network-queries.js +4 -0
  125. package/dist/shared/interfaces/database.opportunity-queries.d.ts +285 -0
  126. package/dist/shared/interfaces/database.opportunity-queries.js +4 -0
  127. package/dist/shared/interfaces/database.port.d.ts +322 -0
  128. package/dist/shared/interfaces/database.port.js +29 -0
  129. package/dist/shared/schemas/discovery-question.schema.d.ts +14 -14
  130. package/dist/shared/schemas/negotiation-digest.schema.d.ts +2 -2
  131. package/package.json +2 -15
  132. package/dist/opportunities/application/opportunity.card-presentation.d.ts +0 -44
  133. package/dist/opportunities/application/opportunity.card-presentation.js +0 -93
  134. package/dist/opportunities/application/opportunity.presenter.d.ts +0 -156
  135. package/dist/opportunities/domain/opportunity.presentation-cache.d.ts +0 -5
  136. package/dist/opportunities/domain/opportunity.presentation-cache.js +0 -11
  137. package/dist/opportunities/domain/opportunity.presentation.d.ts +0 -76
  138. package/dist/opportunities/domain/opportunity.presentation.js +0 -516
  139. package/dist/opportunities/domain/opportunity.safe-presentation.d.ts +0 -104
  140. package/dist/opportunities/domain/opportunity.safe-presentation.js +0 -102
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Discovery pipeline, stage 7: post-persist negotiation.
3
+ *
4
+ * Runs bilateral negotiation per persisted opportunity, passing opportunityId so the
5
+ * negotiation graph's finalize node updates each opportunity's status:
6
+ * accept → 'pending' (sender notification follows the pending → notification path)
7
+ * reject → 'rejected'
8
+ * timeout/turn_cap → 'stalled'
9
+ * Status updates land in the DB; in-memory state.opportunities is not mutated.
10
+ */
11
+ import { requestContext } from '../../shared/observability/request-context.js';
12
+ import { AMBIENT_PARK_WINDOW_MS, negotiateCandidates } from '../../negotiations/index.js';
13
+ import { buildDiscoverySummary, toDiscoveryNegotiation } from '../domain/negotiation-summary.builder.js';
14
+ import { resolveOpportunityActorIntent } from '../domain/opportunity.actor.js';
15
+ import { buildPrioritizedNegotiationIntents, negotiationIncludesOtherIntents } from './opportunity.existing-negotiation.js';
16
+ import { logger, negotiateLog } from './opportunity.graph.shared.js';
17
+ /** Distinguishes "the budget timer won the race" from a real negotiation result. */
18
+ const NEGOTIATE_TIMER_SENTINEL = Symbol('negotiate-timer-sentinel');
19
+ /**
20
+ * Node 3b: Negotiate (post-persist)
21
+ */
22
+ export async function negotiateNode(state, deps) {
23
+ if (!deps.negotiationGraph)
24
+ return {};
25
+ if (!state.opportunities || state.opportunities.length === 0)
26
+ return {};
27
+ const traceEmitter = requestContext.getStore()?.traceEmitter;
28
+ const graphStart = Date.now();
29
+ const persistedById = new Map(state.opportunities.map((opportunity) => [opportunity.id, opportunity]));
30
+ const attemptBoundaryById = new Map(state.opportunities.map((opportunity) => [opportunity.id, opportunity.updatedAt]));
31
+ const compensateTasklessNegotiatingOpportunity = async (opportunityId) => {
32
+ const opportunity = persistedById.get(opportunityId);
33
+ const expectedUpdatedAt = attemptBoundaryById.get(opportunityId);
34
+ if (opportunity?.status !== 'negotiating' || !expectedUpdatedAt)
35
+ return;
36
+ const fallbackStatus = opportunity.actors.some((actor) => actor.role === 'introducer')
37
+ ? 'latent'
38
+ : 'draft';
39
+ await deps.database
40
+ .compensateTasklessNegotiatingOpportunity(opportunityId, expectedUpdatedAt, fallbackStatus)
41
+ .catch((error) => {
42
+ negotiateLog.warn('Failed to compensate taskless negotiating opportunity', {
43
+ opportunityId,
44
+ expectedUpdatedAt,
45
+ fallbackStatus,
46
+ error,
47
+ });
48
+ });
49
+ };
50
+ traceEmitter?.({ type: "graph_start", name: "Negotiation graph" });
51
+ try {
52
+ // Use the same discoveryUserId pattern as evaluationNode
53
+ const discoveryUserId = (state.onBehalfOfUserId ?? state.userId);
54
+ const includeOtherIntents = negotiationIncludesOtherIntents();
55
+ const sourceAccount = await deps.database.getUser(discoveryUserId).catch(() => null);
56
+ const sourceIntentInputs = (state.indexedIntents ?? []).map((intent) => ({
57
+ id: intent.intentId,
58
+ summary: intent.summary ?? null,
59
+ payload: intent.payload ?? null,
60
+ }));
61
+ const sourceHasExactIntent = sourceIntentInputs.some((intent) => intent.id === state.triggerIntentId);
62
+ const sourceFallbackIntent = state.triggerIntentId && !sourceHasExactIntent
63
+ ? await deps.database.getIntent(state.triggerIntentId).catch(() => null)
64
+ : null;
65
+ const ownedSourceFallback = sourceFallbackIntent?.userId === discoveryUserId
66
+ ? sourceFallbackIntent
67
+ : null;
68
+ const sourceUser = {
69
+ id: discoveryUserId,
70
+ intents: buildPrioritizedNegotiationIntents(sourceIntentInputs, state.triggerIntentId, ownedSourceFallback, includeOtherIntents),
71
+ profile: {
72
+ name: state.sourceProfile?.identity?.name ?? sourceAccount?.name,
73
+ bio: state.sourceProfile?.identity?.bio ?? sourceAccount?.intro ?? undefined,
74
+ location: state.sourceProfile?.identity?.location ?? sourceAccount?.location ?? undefined,
75
+ },
76
+ };
77
+ // Build candidates from persisted opportunities. Each opportunity carries its DB id
78
+ // so the negotiation graph's finalize node can update its status from the outcome.
79
+ negotiateLog.verbose('Building candidates from opportunities', {
80
+ opportunityCount: state.opportunities.length,
81
+ discoveryUserId,
82
+ });
83
+ const filteredBeforeInvocation = [];
84
+ const candidateEntries = state.opportunities
85
+ .map(opp => {
86
+ // Skip opportunities where any introducer exists but has not yet approved.
87
+ const introducerActors = opp.actors
88
+ .filter(a => a.role === 'introducer');
89
+ if (introducerActors.length > 0 && !introducerActors.every(a => a.approved === true)) {
90
+ negotiateLog.verbose('Skipping opportunity: introducer not approved', {
91
+ opportunityId: opp.id,
92
+ introducerCount: introducerActors.length,
93
+ approvedCount: introducerActors.filter(a => a.approved === true).length,
94
+ });
95
+ filteredBeforeInvocation.push(opp.id);
96
+ return null;
97
+ }
98
+ const opportunityActors = opp.actors;
99
+ const sourceActor = opportunityActors.find(a => a.userId === discoveryUserId && a.role !== 'introducer');
100
+ const candidateActor = opportunityActors.find(a => a.userId !== discoveryUserId && a.role !== 'introducer');
101
+ if (!sourceActor || !candidateActor) {
102
+ negotiateLog.verbose('Skipping opportunity: no candidateActor found', {
103
+ opportunityId: opp.id,
104
+ discoveryUserId,
105
+ actors: opp.actors?.map(a => ({ userId: a.userId, role: a.role })) ?? [],
106
+ });
107
+ filteredBeforeInvocation.push(opp.id);
108
+ return null;
109
+ }
110
+ return { opp, sourceActor, candidateActor };
111
+ })
112
+ .filter((e) => e !== null);
113
+ await Promise.all(filteredBeforeInvocation.map(compensateTasklessNegotiatingOpportunity));
114
+ negotiateLog.verbose('Candidate filtering complete', {
115
+ inputOpportunities: state.opportunities.length,
116
+ outputCandidates: candidateEntries.length,
117
+ });
118
+ const candidates = await Promise.all(candidateEntries.map(async ({ opp, sourceActor, candidateActor }) => {
119
+ const userId = candidateActor.userId;
120
+ const sourceIntentId = resolveOpportunityActorIntent(sourceActor);
121
+ const candidateIntentId = resolveOpportunityActorIntent(candidateActor);
122
+ const sourceExactActive = sourceIntentId
123
+ ? sourceIntentInputs.find((sourceIntent) => sourceIntent.id === sourceIntentId)
124
+ : undefined;
125
+ const [profile, user, activeIntents, intent, sourceIntent] = await Promise.all([
126
+ deps.database.getProfile(userId).catch(() => null),
127
+ deps.database.getUser(userId).catch(() => null),
128
+ includeOtherIntents
129
+ ? deps.database.getActiveIntents(userId).catch(() => [])
130
+ : Promise.resolve([]),
131
+ candidateIntentId
132
+ ? deps.database.getIntent(candidateIntentId).catch(() => null)
133
+ : null,
134
+ sourceIntentId && !sourceExactActive
135
+ ? deps.database.getIntent(sourceIntentId).catch(() => null)
136
+ : null,
137
+ ]);
138
+ const ownedFallbackIntent = intent?.userId === userId ? intent : null;
139
+ const ownedSourceIntent = sourceIntent?.userId === discoveryUserId ? sourceIntent : null;
140
+ const candidateIntents = buildPrioritizedNegotiationIntents(activeIntents, candidateIntentId, ownedFallbackIntent, includeOtherIntents);
141
+ return {
142
+ userId,
143
+ ...(sourceIntentId ? { sourceIntentId } : {}),
144
+ ...(candidateIntentId ? { candidateIntentId } : {}),
145
+ sourceUser: {
146
+ ...sourceUser,
147
+ intents: buildPrioritizedNegotiationIntents(sourceIntentInputs, sourceIntentId, ownedSourceIntent, includeOtherIntents),
148
+ },
149
+ opportunityId: opp.id,
150
+ opportunityStatus: opp.status,
151
+ opportunityUpdatedAt: opp.updatedAt,
152
+ reasoning: opp.interpretation?.reasoning ?? '',
153
+ valencyRole: candidateActor.role ?? 'peer',
154
+ networkId: candidateActor.networkId,
155
+ ...(state.searchQuery?.trim() && { discoveryQuery: state.searchQuery.trim() }),
156
+ candidateUser: {
157
+ id: userId,
158
+ intents: candidateIntents,
159
+ profile: {
160
+ name: profile?.identity?.name ?? user?.name,
161
+ bio: profile?.identity?.bio ?? user?.intro ?? undefined,
162
+ location: profile?.identity?.location ?? user?.location ?? undefined,
163
+ },
164
+ },
165
+ };
166
+ }));
167
+ const isChatPath = !!state.options?.conversationId;
168
+ const maxTurns = isChatPath
169
+ ? Number(process.env.NEGOTIATION_MAX_TURNS_CHAT) || 4
170
+ : Number(process.env.NEGOTIATION_MAX_TURNS_AMBIENT) || 6;
171
+ // Fetch per-candidate index context (group by networkId to avoid duplicate lookups)
172
+ const uniqueIndexIds = [...new Set(candidates.map(c => c.networkId).filter((id) => !!id))];
173
+ const indexContextMap = new Map();
174
+ await Promise.all(uniqueIndexIds.map(async (networkId) => {
175
+ const ctx = await deps.database.getNetworkMemberContext(networkId, discoveryUserId).catch(() => null);
176
+ const prompt = [ctx?.indexPrompt, ctx?.memberPrompt]
177
+ .filter((v) => !!v?.trim())
178
+ .join('\n\n');
179
+ if (prompt)
180
+ indexContextMap.set(networkId, prompt);
181
+ }));
182
+ const timeoutMs = await resolveTurnTimeout(isChatPath, uniqueIndexIds, discoveryUserId, deps, candidates.length);
183
+ // Per-candidate hook accumulates negotiation resolutions for discovery
184
+ // question generation, preserving candidate-list order below.
185
+ // Build a stable order index so that resolutions accumulated via the
186
+ // per-candidate async hook can be re-sorted to candidate-list order
187
+ // before being handed to buildQuestionPrompt. Without this the LLM
188
+ // sees negotiations in completion-time order (non-deterministic).
189
+ const candidateOrderById = new Map();
190
+ candidates.forEach((c, i) => candidateOrderById.set(c.userId, i));
191
+ const resolutions = [];
192
+ const resolvedOpportunityIds = new Set();
193
+ const onCandidateResolved = async ({ candidate, turns, outcome }) => {
194
+ if (candidate.opportunityId)
195
+ resolvedOpportunityIds.add(candidate.opportunityId);
196
+ resolutions.push({
197
+ __order: candidateOrderById.get(candidate.userId) ?? Number.MAX_SAFE_INTEGER,
198
+ candidateUserId: candidate.userId,
199
+ counterpartyHint: (() => {
200
+ const bio = candidate.candidateUser.profile?.bio?.trim();
201
+ if (bio)
202
+ return bio;
203
+ return (candidate.candidateUser.profile?.interests ?? []).join(", ");
204
+ })(),
205
+ indexContext: candidate.networkId
206
+ ? indexContextMap.get(candidate.networkId) ?? ""
207
+ : "",
208
+ turns,
209
+ outcome,
210
+ });
211
+ if (candidate.opportunityId) {
212
+ await compensateTasklessNegotiatingOpportunity(candidate.opportunityId);
213
+ }
214
+ };
215
+ const negotiationWork = negotiateCandidates(deps.negotiationGraph, sourceUser, candidates, { networkId: '', prompt: '' }, // base context, overridden per-candidate below
216
+ { maxTurns, traceEmitter: traceEmitter ?? undefined,
217
+ indexContextOverrides: indexContextMap,
218
+ timeoutMs,
219
+ // v2 initiator stamp: every fresh-discovery origin resolves to the
220
+ // discovery user — querying user (chat/tool), intent owner
221
+ // (from-intent), enriched user (from-enrichment/discovery-run), or
222
+ // represented user (from-introducer, via onBehalfOfUserId).
223
+ initiatorUserId: discoveryUserId,
224
+ onCandidateResolved });
225
+ /** Order accumulated resolutions back to candidate-list order. */
226
+ const orderResolutions = () => [...resolutions]
227
+ .sort((a, b) => a.__order - b.__order)
228
+ .map(({ __order: _o, ...r }) => r);
229
+ // Optionally bound this opportunity-matching continuation phase. When
230
+ // the timer wins, return a `timed_out` trace while the unresolved
231
+ // continuation keeps running in the Bun event loop; each candidate's
232
+ // finalize node persists its opportunity status in the DB. Deliberately
233
+ // do NOT await it, abort it, or add unfinished results to
234
+ // state.opportunities: later opportunity reads observe the persisted
235
+ // continuation state. The budget limits this invocation's wait, not
236
+ // background matching work; orphans heal via maintenance scripts or
237
+ // IND-279 when it lands.
238
+ const budgetMs = state.options.negotiateTimeoutMs;
239
+ let acceptedResults;
240
+ if (budgetMs !== undefined) {
241
+ let timerId;
242
+ const timerWork = new Promise((resolve) => {
243
+ timerId = setTimeout(() => resolve(NEGOTIATE_TIMER_SENTINEL), budgetMs);
244
+ });
245
+ // try/finally ensures the timer is cleared on every exit path —
246
+ // sentinel-win, work-win, AND `negotiationWork` rejection. Without
247
+ // this, a rejected negotiation would leave the timer pending and
248
+ // keep the event loop alive until `budgetMs` elapses.
249
+ let raced;
250
+ try {
251
+ raced = await Promise.race([negotiationWork, timerWork]);
252
+ }
253
+ finally {
254
+ if (timerId !== undefined)
255
+ clearTimeout(timerId);
256
+ }
257
+ if (raced === NEGOTIATE_TIMER_SENTINEL) {
258
+ // Restore any attempt that is still before its task boundary. A running or
259
+ // parked negotiation already has a task and makes the CAS a no-op; a hung
260
+ // pre-task init becomes owner-actionable while its floating work may retry.
261
+ await Promise.all(candidates
262
+ .filter((candidate) => candidate.opportunityId && !resolvedOpportunityIds.has(candidate.opportunityId))
263
+ .map((candidate) => compensateTasklessNegotiatingOpportunity(candidate.opportunityId)));
264
+ // Floating promise is intentional — see comment above.
265
+ void negotiationWork.catch((err) => {
266
+ negotiateLog.warn('background negotiation failed after timer fired', { error: err });
267
+ });
268
+ negotiateLog.warn('timed out — returning partial results to caller', {
269
+ discoveryUserId,
270
+ candidateCount: candidates.length,
271
+ negotiateTimeoutMs: budgetMs,
272
+ });
273
+ traceEmitter?.({ type: "graph_end", name: "Negotiation graph", durationMs: Date.now() - graphStart });
274
+ const orderedResolutionsPartial = orderResolutions();
275
+ return {
276
+ trace: [{
277
+ node: 'negotiate',
278
+ detail: 'timed_out',
279
+ data: {
280
+ negotiateTimeoutMs: budgetMs,
281
+ candidateCount: candidates.length,
282
+ durationMs: Date.now() - graphStart,
283
+ },
284
+ }],
285
+ discoveryNegotiations: orderedResolutionsPartial.map(toDiscoveryNegotiation),
286
+ discoverySummary: buildDiscoverySummary(orderedResolutionsPartial),
287
+ };
288
+ }
289
+ acceptedResults = raced;
290
+ }
291
+ else {
292
+ acceptedResults = await negotiationWork;
293
+ }
294
+ // No filtering: every candidate's outcome (accept/reject/stalled) was applied to its
295
+ // opportunity row by the negotiation graph's finalize node via the opportunityId we
296
+ // passed. state.opportunities stays as it was at persist time; DB has the new statuses.
297
+ const acceptedUserIds = new Set(acceptedResults.map(r => r.userId));
298
+ const negotiationDurationMs = Date.now() - graphStart;
299
+ const candidateTraceEntries = candidates.map(c => {
300
+ const accepted = acceptedUserIds.has(c.userId);
301
+ const result = accepted ? acceptedResults.find(r => r.userId === c.userId) : null;
302
+ const name = c.candidateUser.profile?.name ?? c.userId;
303
+ const outcome = accepted ? 'accepted' : 'rejected_or_stalled';
304
+ return {
305
+ node: 'negotiate_candidate',
306
+ detail: `${name}: ${outcome}`,
307
+ data: {
308
+ userId: c.userId,
309
+ opportunityId: c.opportunityId,
310
+ name,
311
+ outcome,
312
+ turns: result?.turnCount ?? 0,
313
+ },
314
+ };
315
+ });
316
+ const acceptedCount = acceptedResults.length;
317
+ const otherCount = candidates.length - acceptedCount;
318
+ const negotiateTrace = [
319
+ {
320
+ node: 'negotiate',
321
+ detail: `${candidates.length} candidate(s) -> ${acceptedCount} accepted, ${otherCount} rejected/stalled`,
322
+ data: {
323
+ durationMs: negotiationDurationMs,
324
+ candidateCount: candidates.length,
325
+ acceptedCount,
326
+ otherCount,
327
+ },
328
+ },
329
+ ...candidateTraceEntries,
330
+ ];
331
+ traceEmitter?.({ type: "graph_end", name: "Negotiation graph", durationMs: Date.now() - graphStart });
332
+ const orderedResolutions = orderResolutions();
333
+ return {
334
+ trace: negotiateTrace,
335
+ discoveryNegotiations: orderedResolutions.map(toDiscoveryNegotiation),
336
+ discoverySummary: buildDiscoverySummary(orderedResolutions),
337
+ };
338
+ }
339
+ catch (err) {
340
+ await Promise.all(state.opportunities.map((opportunity) => compensateTasklessNegotiatingOpportunity(opportunity.id)));
341
+ negotiateLog.error("Negotiation stage failed", { error: err });
342
+ traceEmitter?.({ type: "graph_end", name: "Negotiation graph", durationMs: Date.now() - graphStart });
343
+ return {
344
+ trace: [{
345
+ node: 'negotiate',
346
+ detail: 'Negotiation failed',
347
+ data: { durationMs: Date.now() - graphStart, error: true },
348
+ }],
349
+ discoveryNegotiations: [],
350
+ discoverySummary: buildDiscoverySummary([]),
351
+ };
352
+ }
353
+ }
354
+ /**
355
+ * Decide the per-turn timeout.
356
+ * - Background/queue path (no conversationId): always the park-window
357
+ * budget (AMBIENT_PARK_WINDOW_MS, 5 min). Turns park in
358
+ * `waiting_for_agent` and are picked up via polling; the dispatcher
359
+ * additionally short-circuits to the system agent when no personal
360
+ * agent has a fresh heartbeat (see AgentDispatcherImpl).
361
+ * - Chat path with a personal agent authorized: use the same park-window
362
+ * so the dispatcher parks the turn and the user's personal agent can
363
+ * pick it up via polling.
364
+ * - Chat path with no personal agent: use a short timeout (30s) so the
365
+ * system `Index Negotiator` kicks in without stalling the chat.
366
+ *
367
+ * Check the personal agent per unique candidate network so cross-network
368
+ * chat runs don't get a single authorized agent deciding the timeout for
369
+ * every candidate. Only use the long (polling) timeout when an external
370
+ * (poller) agent is authorized on ALL candidate networks; otherwise fall
371
+ * back to the short timeout so chats don't stall on a network where only
372
+ * the system negotiator is allowed.
373
+ */
374
+ async function resolveTurnTimeout(isChatPath, uniqueIndexIds, discoveryUserId, deps, candidateCount) {
375
+ const hasExternalAgent = isChatPath && deps.agentDispatcher
376
+ ? (uniqueIndexIds.length > 0
377
+ ? (await Promise.all(uniqueIndexIds.map((networkId) => deps.agentDispatcher.hasExternalAgent(discoveryUserId, { action: 'manage:negotiations', scopeType: 'network', scopeId: networkId }).catch(() => false)))).every(Boolean)
378
+ : false)
379
+ : false;
380
+ const useLongTimeout = !isChatPath || hasExternalAgent;
381
+ const timeoutMs = useLongTimeout ? AMBIENT_PARK_WINDOW_MS : 30000;
382
+ logger.info('negotiateNode timeout decision', {
383
+ discoveryUserId,
384
+ isChatPath,
385
+ hasDispatcher: !!deps.agentDispatcher,
386
+ hasExternalAgent,
387
+ useLongTimeout,
388
+ timeoutMs,
389
+ candidateCount,
390
+ });
391
+ return timeoutMs;
392
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Discovery pipeline, stage 6: persistence.
3
+ *
4
+ * Three persistence paths share the node — chat introductions, introducer
5
+ * discovery (`onBehalfOfUserId`), and plain discovery. Each one decides whether
6
+ * an existing opportunity between the same people should be reactivated,
7
+ * upgraded, or suppressed before any row is written; only what survives that
8
+ * gate reaches `persistOpportunities`.
9
+ */
10
+ import type { Id, Opportunity, OpportunityStatus } from '../../shared/interfaces/database.interface.js';
11
+ import { type OpportunityGraphDeps, type OpportunityState } from './opportunity.graph.shared.js';
12
+ /** A pairing that was not persisted because an opportunity already covers it. */
13
+ type ExistingBetweenActor = {
14
+ candidateUserId: Id<'users'>;
15
+ networkId: Id<'networks'>;
16
+ existingOpportunityId?: Id<'opportunities'>;
17
+ existingStatus?: OpportunityStatus;
18
+ reason?: 'same_trigger_recent_duplicate' | 'pair_active_negotiation' | 'final_atomic_conflict';
19
+ existingTriggerIntentId?: string;
20
+ };
21
+ /**
22
+ * Node 5: Persist
23
+ * Creates opportunities from evaluator-proposed actors (networkId, userId, role, optional intent).
24
+ */
25
+ export declare function persistNode(state: OpportunityState, deps: OpportunityGraphDeps): Promise<{
26
+ opportunities: never[];
27
+ persistenceOutcome: {
28
+ evaluatedCount: number;
29
+ createdCount: number;
30
+ reactivatedCount: number;
31
+ sameTriggerDuplicateSuppressions: number;
32
+ pairActiveNegotiationSuppressions: number;
33
+ crossTriggerAllowedCount: number;
34
+ finalAtomicConflictCount: number;
35
+ };
36
+ existingBetweenActors?: undefined;
37
+ trace?: undefined;
38
+ error?: undefined;
39
+ } | {
40
+ opportunities: never[];
41
+ persistenceOutcome?: undefined;
42
+ existingBetweenActors?: undefined;
43
+ trace?: undefined;
44
+ error?: undefined;
45
+ } | {
46
+ opportunities: Opportunity[];
47
+ existingBetweenActors: ExistingBetweenActor[];
48
+ persistenceOutcome: {
49
+ evaluatedCount: number;
50
+ createdCount: number;
51
+ reactivatedCount: number;
52
+ sameTriggerDuplicateSuppressions: number;
53
+ pairActiveNegotiationSuppressions: number;
54
+ crossTriggerAllowedCount: number;
55
+ finalAtomicConflictCount: number;
56
+ };
57
+ trace: {
58
+ node: string;
59
+ detail: string;
60
+ data: {
61
+ created: number;
62
+ reactivated: number;
63
+ existingSkipped: number;
64
+ totalOutput: number;
65
+ persistenceOutcome: {
66
+ evaluatedCount: number;
67
+ createdCount: number;
68
+ reactivatedCount: number;
69
+ sameTriggerDuplicateSuppressions: number;
70
+ pairActiveNegotiationSuppressions: number;
71
+ crossTriggerAllowedCount: number;
72
+ finalAtomicConflictCount: number;
73
+ };
74
+ durationMs: number;
75
+ };
76
+ }[];
77
+ error?: undefined;
78
+ } | {
79
+ opportunities: never[];
80
+ existingBetweenActors: never[];
81
+ error: string;
82
+ trace: {
83
+ node: string;
84
+ detail: string;
85
+ data: {
86
+ error: string;
87
+ };
88
+ }[];
89
+ persistenceOutcome?: undefined;
90
+ }>;
91
+ /** Trace summary for {@link persistNode}. */
92
+ export declare function persistTraceSummary(result: unknown): string | undefined;
93
+ export {};