@indexnetwork/protocol 6.2.1-rc.378.1 → 6.2.3-rc.380.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 (33) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/dist/negotiation/negotiation.graph.d.ts +7 -0
  3. package/dist/negotiation/negotiation.graph.d.ts.map +1 -1
  4. package/dist/negotiation/negotiation.graph.js +18 -3
  5. package/dist/negotiation/negotiation.graph.js.map +1 -1
  6. package/dist/negotiation/negotiation.state.d.ts +4 -0
  7. package/dist/negotiation/negotiation.state.d.ts.map +1 -1
  8. package/dist/negotiation/negotiation.state.js +5 -0
  9. package/dist/negotiation/negotiation.state.js.map +1 -1
  10. package/dist/opportunity/opportunity.actor.d.ts +16 -0
  11. package/dist/opportunity/opportunity.actor.d.ts.map +1 -0
  12. package/dist/opportunity/opportunity.actor.js +40 -0
  13. package/dist/opportunity/opportunity.actor.js.map +1 -0
  14. package/dist/opportunity/opportunity.discover.d.ts.map +1 -1
  15. package/dist/opportunity/opportunity.discover.js +15 -5
  16. package/dist/opportunity/opportunity.discover.js.map +1 -1
  17. package/dist/opportunity/opportunity.evaluator.d.ts +3 -3
  18. package/dist/opportunity/opportunity.evaluator.d.ts.map +1 -1
  19. package/dist/opportunity/opportunity.evaluator.js +4 -1
  20. package/dist/opportunity/opportunity.evaluator.js.map +1 -1
  21. package/dist/opportunity/opportunity.graph.d.ts.map +1 -1
  22. package/dist/opportunity/opportunity.graph.js +125 -71
  23. package/dist/opportunity/opportunity.graph.js.map +1 -1
  24. package/dist/opportunity/opportunity.persist.d.ts.map +1 -1
  25. package/dist/opportunity/opportunity.persist.js +4 -2
  26. package/dist/opportunity/opportunity.persist.js.map +1 -1
  27. package/dist/opportunity/opportunity.tools.d.ts.map +1 -1
  28. package/dist/opportunity/opportunity.tools.js +34 -3
  29. package/dist/opportunity/opportunity.tools.js.map +1 -1
  30. package/dist/shared/interfaces/database.interface.d.ts +39 -5
  31. package/dist/shared/interfaces/database.interface.d.ts.map +1 -1
  32. package/dist/shared/interfaces/database.interface.js.map +1 -1
  33. package/package.json +1 -1
@@ -25,6 +25,7 @@ import { hasUnsupportedOpportunityClaim } from './opportunity.claim-safety.js';
25
25
  import { persistOpportunities } from './opportunity.persist.js';
26
26
  import { INTRODUCER_DISCOVERY_SOURCE } from './opportunity.introducer.js';
27
27
  import { negotiateCandidates } from "../negotiation/negotiation.graph.js";
28
+ import { ASK_USER_LOCK_SLACK_MS, askUserAnswerWindowMs } from "../negotiation/negotiation.protocol.js";
28
29
  import { AMBIENT_PARK_WINDOW_MS } from "../negotiation/negotiation.tools.js";
29
30
  import { buildDiscoverySummary, toDiscoveryNegotiation } from "./negotiation-summary.builder.js";
30
31
  import { protocolLogger, withCallLogging } from '../shared/observability/protocol.logger.js';
@@ -32,6 +33,7 @@ import { timed } from '../shared/observability/performance.js';
32
33
  import { renderNetworkContext } from '../shared/network/metadata.renderer.js';
33
34
  import { requestContext } from "../shared/observability/request-context.js";
34
35
  import { mergeOpportunityEvidence, withCandidateEvidence, withMatchedStrategies } from './opportunity.evidence.js';
36
+ import { normalizeOpportunityActorIntent, resolveOpportunityActorIntent } from './opportunity.actor.js';
35
37
  const logger = protocolLogger('OpportunityGraph');
36
38
  const prepLog = protocolLogger('OpportunityGraph:Prep');
37
39
  const scopeLog = protocolLogger('OpportunityGraph:Scope');
@@ -54,6 +56,21 @@ const routingLog = protocolLogger('OpportunityGraph:Routing');
54
56
  /** Time window for persist-node dedup. Suppresses a second opportunity with the same person while a recent one (within 30 days) is still in flight, so a person is not re-surfaced multiple times within a month (EDG-23). */
55
57
  const DEDUP_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
56
58
  const NEGOTIATION_INTENT_LIMIT = 5;
59
+ const ACTIVE_NEGOTIATION_TASK_STATES = new Set([
60
+ 'submitted',
61
+ 'working',
62
+ 'input_required',
63
+ 'waiting_for_agent',
64
+ 'claimed',
65
+ ]);
66
+ function isActiveNegotiationTaskFresh(task) {
67
+ if (!ACTIVE_NEGOTIATION_TASK_STATES.has(task.state))
68
+ return false;
69
+ const freshnessMs = task.state === 'input_required'
70
+ ? askUserAnswerWindowMs() + ASK_USER_LOCK_SLACK_MS
71
+ : 5 * 60 * 1000;
72
+ return Date.now() - new Date(task.updatedAt).getTime() < freshnessMs;
73
+ }
57
74
  /** Put an opportunity actor's exact intent first, then fill the bounded context without duplicates. */
58
75
  export function buildPrioritizedNegotiationIntents(activeIntents, exactIntentId, fallbackIntent) {
59
76
  const exactId = typeof exactIntentId === 'string' && exactIntentId.trim().length > 0
@@ -1827,6 +1844,27 @@ export class OpportunityGraphFactory {
1827
1844
  return {};
1828
1845
  const traceEmitter = requestContext.getStore()?.traceEmitter;
1829
1846
  const graphStart = Date.now();
1847
+ const persistedById = new Map(state.opportunities.map((opportunity) => [opportunity.id, opportunity]));
1848
+ const attemptBoundaryById = new Map(state.opportunities.map((opportunity) => [opportunity.id, opportunity.updatedAt]));
1849
+ const compensateTasklessNegotiatingOpportunity = async (opportunityId) => {
1850
+ const opportunity = persistedById.get(opportunityId);
1851
+ const expectedUpdatedAt = attemptBoundaryById.get(opportunityId);
1852
+ if (opportunity?.status !== 'negotiating' || !expectedUpdatedAt)
1853
+ return;
1854
+ const fallbackStatus = opportunity.actors.some((actor) => actor.role === 'introducer')
1855
+ ? 'latent'
1856
+ : 'draft';
1857
+ await this.database
1858
+ .compensateTasklessNegotiatingOpportunity(opportunityId, expectedUpdatedAt, fallbackStatus)
1859
+ .catch((error) => {
1860
+ negotiateLog.warn('Failed to compensate taskless negotiating opportunity', {
1861
+ opportunityId,
1862
+ expectedUpdatedAt,
1863
+ fallbackStatus,
1864
+ error,
1865
+ });
1866
+ });
1867
+ };
1830
1868
  traceEmitter?.({ type: "graph_start", name: "Negotiation graph" });
1831
1869
  try {
1832
1870
  // Use the same discoveryUserId pattern as evaluationNode
@@ -1859,6 +1897,7 @@ export class OpportunityGraphFactory {
1859
1897
  opportunityCount: state.opportunities.length,
1860
1898
  discoveryUserId,
1861
1899
  });
1900
+ const filteredBeforeInvocation = [];
1862
1901
  const candidateEntries = state.opportunities
1863
1902
  .map(opp => {
1864
1903
  // Skip opportunities where any introducer exists but has not yet approved.
@@ -1870,6 +1909,7 @@ export class OpportunityGraphFactory {
1870
1909
  introducerCount: introducerActors.length,
1871
1910
  approvedCount: introducerActors.filter(a => a.approved === true).length,
1872
1911
  });
1912
+ filteredBeforeInvocation.push(opp.id);
1873
1913
  return null;
1874
1914
  }
1875
1915
  const candidateActor = opp.actors.find(a => a.userId !== discoveryUserId);
@@ -1879,18 +1919,20 @@ export class OpportunityGraphFactory {
1879
1919
  discoveryUserId,
1880
1920
  actors: opp.actors?.map(a => ({ userId: a.userId, role: a.role })) ?? [],
1881
1921
  });
1922
+ filteredBeforeInvocation.push(opp.id);
1882
1923
  return null;
1883
1924
  }
1884
1925
  return { opp, candidateActor };
1885
1926
  })
1886
1927
  .filter((e) => e !== null);
1928
+ await Promise.all(filteredBeforeInvocation.map(compensateTasklessNegotiatingOpportunity));
1887
1929
  negotiateLog.verbose('Candidate filtering complete', {
1888
1930
  inputOpportunities: state.opportunities.length,
1889
1931
  outputCandidates: candidateEntries.length,
1890
1932
  });
1891
1933
  const candidates = await Promise.all(candidateEntries.map(async ({ opp, candidateActor }) => {
1892
1934
  const userId = candidateActor.userId;
1893
- const candidateIntentId = candidateActor.intentId ?? candidateActor.intent;
1935
+ const candidateIntentId = resolveOpportunityActorIntent(candidateActor);
1894
1936
  const [profile, user, activeIntents, intent] = await Promise.all([
1895
1937
  this.database.getProfile(userId).catch(() => null),
1896
1938
  this.database.getUser(userId).catch(() => null),
@@ -1904,6 +1946,7 @@ export class OpportunityGraphFactory {
1904
1946
  return {
1905
1947
  userId,
1906
1948
  opportunityId: opp.id,
1949
+ opportunityUpdatedAt: opp.updatedAt,
1907
1950
  reasoning: opp.interpretation?.reasoning ?? '',
1908
1951
  valencyRole: candidateActor.role ?? 'peer',
1909
1952
  networkId: candidateActor.networkId,
@@ -1991,7 +2034,10 @@ export class OpportunityGraphFactory {
1991
2034
  const candidateOrderById = new Map();
1992
2035
  candidates.forEach((c, i) => candidateOrderById.set(c.userId, i));
1993
2036
  const resolutions = [];
2037
+ const resolvedOpportunityIds = new Set();
1994
2038
  const onCandidateResolved = async ({ candidate, accepted, turns, outcome }) => {
2039
+ if (candidate.opportunityId)
2040
+ resolvedOpportunityIds.add(candidate.opportunityId);
1995
2041
  resolutions.push({
1996
2042
  __order: candidateOrderById.get(candidate.userId) ?? Number.MAX_SAFE_INTEGER,
1997
2043
  candidateUserId: candidate.userId,
@@ -2007,6 +2053,9 @@ export class OpportunityGraphFactory {
2007
2053
  turns,
2008
2054
  outcome,
2009
2055
  });
2056
+ if (candidate.opportunityId) {
2057
+ await compensateTasklessNegotiatingOpportunity(candidate.opportunityId);
2058
+ }
2010
2059
  if (state.trigger !== 'orchestrator')
2011
2060
  return;
2012
2061
  // ─── orchestrator streaming body ───
@@ -2098,6 +2147,12 @@ export class OpportunityGraphFactory {
2098
2147
  clearTimeout(timerId);
2099
2148
  }
2100
2149
  if (raced === NEGOTIATE_TIMER_SENTINEL) {
2150
+ // Restore any attempt that is still before its task boundary. A running or
2151
+ // parked negotiation already has a task and makes the CAS a no-op; a hung
2152
+ // pre-task init becomes owner-actionable while its floating work may retry.
2153
+ await Promise.all(candidates
2154
+ .filter((candidate) => candidate.opportunityId && !resolvedOpportunityIds.has(candidate.opportunityId))
2155
+ .map((candidate) => compensateTasklessNegotiatingOpportunity(candidate.opportunityId)));
2101
2156
  // Floating promise is intentional — see comment above.
2102
2157
  void negotiationWork.catch((err) => {
2103
2158
  negotiateLog.warn('background negotiation failed after timer fired', { error: err });
@@ -2182,6 +2237,7 @@ export class OpportunityGraphFactory {
2182
2237
  };
2183
2238
  }
2184
2239
  catch (err) {
2240
+ await Promise.all(state.opportunities.map((opportunity) => compensateTasklessNegotiatingOpportunity(opportunity.id)));
2185
2241
  negotiateLog.error("Negotiation stage failed", { error: err });
2186
2242
  traceEmitter?.({ type: "graph_end", name: "Negotiation graph", durationMs: Date.now() - graphStart });
2187
2243
  return {
@@ -2507,7 +2563,7 @@ export class OpportunityGraphFactory {
2507
2563
  }
2508
2564
  if (evaluatedToPersist.length === 0)
2509
2565
  return { opportunities: [] };
2510
- const updateStatusIfStillEligible = async (opportunityId, status, existingActors) => {
2566
+ const updateStatusIfStillEligible = async (opportunityId, status, existingActors, expectedStatus) => {
2511
2567
  // Reactivation preserves the existing opportunity row, so lock the
2512
2568
  // existing participant anchors rather than the evaluator's current
2513
2569
  // (often network-less) actor output. Introducers do not participate
@@ -2520,7 +2576,7 @@ export class OpportunityGraphFactory {
2520
2576
  persistLog.error('Network-eligible status update adapter is unavailable; failing closed');
2521
2577
  return null;
2522
2578
  }
2523
- return this.database.updateOpportunityStatusIfNetworkEligible(opportunityId, status, anchors, networkEligibility);
2579
+ return this.database.updateOpportunityStatusIfNetworkEligible(opportunityId, status, anchors, networkEligibility, expectedStatus);
2524
2580
  };
2525
2581
  const itemsToPersist = [];
2526
2582
  const reactivatedOpportunities = [];
@@ -2588,12 +2644,15 @@ export class OpportunityGraphFactory {
2588
2644
  continue;
2589
2645
  }
2590
2646
  // Introduction path: manual detection, introducer actor, curator_judgment signal.
2591
- const evaluatorActors = evaluated.actors.map((a) => ({
2592
- networkId: a.networkId ?? indexIdForActors,
2593
- userId: a.userId,
2594
- role: a.role,
2595
- ...(a.intentId ? { intent: a.intentId } : {}),
2596
- }));
2647
+ const evaluatorActors = evaluated.actors.map((a) => {
2648
+ const intent = normalizeOpportunityActorIntent(a.intentId);
2649
+ return {
2650
+ networkId: a.networkId ?? indexIdForActors,
2651
+ userId: a.userId,
2652
+ role: a.role,
2653
+ ...(intent ? { intent: intent } : {}),
2654
+ };
2655
+ });
2597
2656
  const viewerAlreadyInActors = evaluatorActors.some(a => a.userId === state.userId);
2598
2657
  actors = viewerAlreadyInActors
2599
2658
  ? evaluatorActors
@@ -2638,12 +2697,15 @@ export class OpportunityGraphFactory {
2638
2697
  continue;
2639
2698
  }
2640
2699
  // Introducer discovery path: introducer is state.userId, target is onBehalfOfUserId.
2641
- const evaluatorActors = evaluated.actors.map((a) => ({
2642
- networkId: a.networkId ?? indexIdForActors,
2643
- userId: a.userId,
2644
- role: a.role,
2645
- ...(a.intentId ? { intent: a.intentId } : {}),
2646
- }));
2700
+ const evaluatorActors = evaluated.actors.map((a) => {
2701
+ const intent = normalizeOpportunityActorIntent(a.intentId);
2702
+ return {
2703
+ networkId: a.networkId ?? indexIdForActors,
2704
+ userId: a.userId,
2705
+ role: a.role,
2706
+ ...(intent ? { intent: intent } : {}),
2707
+ };
2708
+ });
2647
2709
  const viewerAlreadyInActors = evaluatorActors.some(a => a.userId === state.userId);
2648
2710
  actors = viewerAlreadyInActors
2649
2711
  ? evaluatorActors
@@ -2668,7 +2730,7 @@ export class OpportunityGraphFactory {
2668
2730
  if (existing.status === 'stalled' || sameIntroducer) {
2669
2731
  // Introduction path always targets 'draft' (chat-only surface) rather than using
2670
2732
  // initialStatus, because introductions are always chat-initiated, not background-discovered.
2671
- const reactivated = await updateStatusIfStillEligible(existing.id, 'draft', existing.actors);
2733
+ const reactivated = await updateStatusIfStillEligible(existing.id, 'draft', existing.actors, existing.status);
2672
2734
  if (reactivated) {
2673
2735
  persistLog.verbose('Reactivated opportunity (introduction path)', {
2674
2736
  opportunityId: existing.id,
@@ -2683,25 +2745,21 @@ export class OpportunityGraphFactory {
2683
2745
  else if (existing.status === 'negotiating') {
2684
2746
  // Orphan heal (introduction path): same logic as discovery path
2685
2747
  const priorTask = await this.database.getNegotiationTaskForOpportunity(existing.id);
2686
- if (priorTask) {
2687
- const activeStates = ['submitted', 'working', 'input_required', 'waiting_for_agent', 'claimed'];
2688
- const isFresh = (Date.now() - new Date(priorTask.updatedAt).getTime()) < 5 * 60 * 1000;
2689
- if (activeStates.includes(priorTask.state) && isFresh) {
2690
- existingBetweenActors.push({
2691
- candidateUserId: candidateUserId,
2692
- networkId: (state.networkId ?? indexIdForActors ?? ''),
2693
- existingOpportunityId: existing.id,
2694
- existingStatus: existing.status,
2695
- });
2696
- persistLog.verbose('Skipping negotiating opportunity with active task (introduction path)', {
2697
- opportunityId: existing.id,
2698
- candidateUserId,
2699
- taskState: priorTask.state,
2700
- });
2701
- continue;
2702
- }
2748
+ if (priorTask && isActiveNegotiationTaskFresh(priorTask)) {
2749
+ existingBetweenActors.push({
2750
+ candidateUserId: candidateUserId,
2751
+ networkId: (state.networkId ?? indexIdForActors ?? ''),
2752
+ existingOpportunityId: existing.id,
2753
+ existingStatus: existing.status,
2754
+ });
2755
+ persistLog.verbose('Skipping negotiating opportunity with active task (introduction path)', {
2756
+ opportunityId: existing.id,
2757
+ candidateUserId,
2758
+ taskState: priorTask.state,
2759
+ });
2760
+ continue;
2703
2761
  }
2704
- const reactivated = await updateStatusIfStillEligible(existing.id, 'draft', existing.actors);
2762
+ const reactivated = await updateStatusIfStillEligible(existing.id, 'draft', existing.actors, existing.status);
2705
2763
  if (reactivated) {
2706
2764
  persistLog.info('Resuming orphaned negotiating opportunity (introduction path)', {
2707
2765
  opportunityId: existing.id,
@@ -2714,7 +2772,7 @@ export class OpportunityGraphFactory {
2714
2772
  }
2715
2773
  else if (existing.status === 'latent') {
2716
2774
  // Upgrade latent to draft for introduction path
2717
- const upgraded = await updateStatusIfStillEligible(existing.id, 'draft', existing.actors);
2775
+ const upgraded = await updateStatusIfStillEligible(existing.id, 'draft', existing.actors, existing.status);
2718
2776
  if (upgraded) {
2719
2777
  persistLog.verbose('Upgraded latent opportunity to draft (introduction path)', {
2720
2778
  opportunityId: existing.id,
@@ -2785,13 +2843,16 @@ export class OpportunityGraphFactory {
2785
2843
  }
2786
2844
  }
2787
2845
  }
2788
- const evaluatorActors = evaluated.actors.map((a) => ({
2789
- networkId: a.networkId ?? indexIdForActors,
2790
- userId: a.userId,
2791
- role: a.role,
2792
- ...(a.intentId ? { intent: a.intentId } : {}),
2793
- ...(premiseLookup.has(a.userId) ? { premise: premiseLookup.get(a.userId).premiseId } : {}),
2794
- }));
2846
+ const evaluatorActors = evaluated.actors.map((a) => {
2847
+ const intent = normalizeOpportunityActorIntent(a.intentId);
2848
+ return {
2849
+ networkId: a.networkId ?? indexIdForActors,
2850
+ userId: a.userId,
2851
+ role: a.role,
2852
+ ...(intent ? { intent: intent } : {}),
2853
+ ...(premiseLookup.has(a.userId) ? { premise: premiseLookup.get(a.userId).premiseId } : {}),
2854
+ };
2855
+ });
2795
2856
  actors = evaluatorActors;
2796
2857
  const hasIntroducerActor = actors.some(a => a.role === 'introducer');
2797
2858
  if (!hasIntroducerActor) {
@@ -2830,7 +2891,7 @@ export class OpportunityGraphFactory {
2830
2891
  // Reactivate expired or stalled opportunities.
2831
2892
  // Stalled opportunities are reactivated regardless of age: a stalled negotiation
2832
2893
  // is still in-flight for this pair, so we resume it rather than create a parallel one.
2833
- const reactivated = await updateStatusIfStillEligible(existing.id, initialStatus, existing.actors);
2894
+ const reactivated = await updateStatusIfStillEligible(existing.id, initialStatus, existing.actors, existing.status);
2834
2895
  if (reactivated) {
2835
2896
  persistLog.verbose('Reactivated opportunity', {
2836
2897
  opportunityId: existing.id,
@@ -2846,27 +2907,23 @@ export class OpportunityGraphFactory {
2846
2907
  // Orphan heal: if a prior opportunity is stuck in 'negotiating' with a stale task,
2847
2908
  // reactivate it so the new discovery run can reuse it instead of creating a duplicate.
2848
2909
  const priorTask = await this.database.getNegotiationTaskForOpportunity(existing.id);
2849
- if (priorTask) {
2850
- const activeStates = ['submitted', 'working', 'input_required', 'waiting_for_agent', 'claimed'];
2851
- const isFresh = (Date.now() - new Date(priorTask.updatedAt).getTime()) < 5 * 60 * 1000;
2852
- if (activeStates.includes(priorTask.state) && isFresh) {
2853
- // Still active — skip (lock gate in init node will handle)
2854
- existingBetweenActors.push({
2855
- candidateUserId: candidateUserId,
2856
- networkId: existingIndexId,
2857
- existingOpportunityId: existing.id,
2858
- existingStatus: existing.status,
2859
- });
2860
- persistLog.verbose('Skipping negotiating opportunity with active task', {
2861
- opportunityId: existing.id,
2862
- candidateUserId,
2863
- taskState: priorTask.state,
2864
- });
2865
- continue;
2866
- }
2910
+ if (priorTask && isActiveNegotiationTaskFresh(priorTask)) {
2911
+ // Still active skip (lock gate in init node will handle)
2912
+ existingBetweenActors.push({
2913
+ candidateUserId: candidateUserId,
2914
+ networkId: existingIndexId,
2915
+ existingOpportunityId: existing.id,
2916
+ existingStatus: existing.status,
2917
+ });
2918
+ persistLog.verbose('Skipping negotiating opportunity with active task', {
2919
+ opportunityId: existing.id,
2920
+ candidateUserId,
2921
+ taskState: priorTask.state,
2922
+ });
2923
+ continue;
2867
2924
  }
2868
2925
  // Task is stale or missing — reactivate the orphaned negotiating opportunity
2869
- const reactivated = await updateStatusIfStillEligible(existing.id, initialStatus, existing.actors);
2926
+ const reactivated = await updateStatusIfStillEligible(existing.id, initialStatus, existing.actors, existing.status);
2870
2927
  if (reactivated) {
2871
2928
  persistLog.info('Resuming orphaned negotiating opportunity', {
2872
2929
  opportunityId: existing.id,
@@ -2879,7 +2936,7 @@ export class OpportunityGraphFactory {
2879
2936
  }
2880
2937
  else if (existing.status === 'latent' && initialStatus !== 'latent') {
2881
2938
  // Upgrade latent (background-discovered) to the higher-priority status (e.g. pending)
2882
- const upgraded = await updateStatusIfStillEligible(existing.id, initialStatus, existing.actors);
2939
+ const upgraded = await updateStatusIfStillEligible(existing.id, initialStatus, existing.actors, existing.status);
2883
2940
  if (upgraded) {
2884
2941
  persistLog.verbose('Upgraded latent opportunity to higher-priority status', {
2885
2942
  opportunityId: existing.id,
@@ -3412,8 +3469,8 @@ export class OpportunityGraphFactory {
3412
3469
  negotiateExistingLog.warn('No candidate actor found', { opportunityId: state.opportunityId });
3413
3470
  return {};
3414
3471
  }
3415
- const sourceIntentId = sourceActor.intentId ?? sourceActor.intent;
3416
- const candidateIntentId = candidateActor.intentId ?? candidateActor.intent;
3472
+ const sourceIntentId = resolveOpportunityActorIntent(sourceActor);
3473
+ const candidateIntentId = resolveOpportunityActorIntent(candidateActor);
3417
3474
  // Load user data for both actors in parallel
3418
3475
  const [sourceUserAccount, sourceProfile, sourceIntents, candidateAccount, candidateProfile, candidateIntents] = await Promise.all([
3419
3476
  this.database.getUser(sourceActor.userId).catch(() => null),
@@ -3673,16 +3730,13 @@ export class OpportunityGraphFactory {
3673
3730
  [END]: END,
3674
3731
  })
3675
3732
  // Discovery → Ranking → Persist → Negotiate (post-persist).
3676
- // Negotiation runs against persisted opportunities so each negotiation receives the
3677
- // opportunity's id and its outcome flips status (pending|stalled|rejected) via the
3678
- // negotiation graph's finalize node. Skipped for continue_discovery and when no
3679
- // negotiation graph or no opportunities were persisted (negotiateNode returns early).
3733
+ // Fresh and continuation discovery both negotiate newly created/reactivated
3734
+ // opportunities. The stage is skipped only when no negotiation graph is wired or
3735
+ // persistence produced no negotiation targets (negotiateNode also guards both cases).
3680
3736
  .addNode('negotiate', negotiateNode)
3681
3737
  .addEdge('evaluation', 'ranking')
3682
3738
  .addEdge('ranking', 'persist')
3683
3739
  .addConditionalEdges('persist', (state) => {
3684
- if (state.operationMode === 'continue_discovery')
3685
- return END;
3686
3740
  if (!this.negotiationGraph)
3687
3741
  return END;
3688
3742
  if (!state.opportunities || state.opportunities.length === 0)