@juspay/neurolink 12.32.1 → 12.32.3

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.
@@ -450,6 +450,21 @@ export class BaseProvider {
450
450
  const sourceIterator = source[Symbol.asyncIterator]();
451
451
  let activeStream = source;
452
452
  let activeIterator = sourceIterator;
453
+ // The StreamResult whose non-stream fields (model, provider, metadata,
454
+ // analytics, tool*) the caller should see. Starts as the first attempt
455
+ // and is reassigned to a fallback candidate's own result the moment that
456
+ // candidate actually commits real output (see the retry loop below) —
457
+ // never to a candidate that failed before producing anything. Read by
458
+ // the live getters built at the bottom of this function, so a caller
459
+ // that reads a field AFTER a fallback takes over sees the attempt that
460
+ // actually served the stream, not the abandoned one.
461
+ let activeResult = result;
462
+ // Some provider results expose getter-only properties. The forwarding
463
+ // result below must still be writable because NeuroLink's stream pipeline
464
+ // assigns final fields after constructing it. Keep assignment overrides
465
+ // scoped to the result that was active when the assignment happened, so a
466
+ // value written to a rejected attempt cannot shadow a later retry.
467
+ const assignedValues = new WeakMap();
453
468
  const iterableOf = (iterator) => ({
454
469
  [Symbol.asyncIterator]: () => iterator,
455
470
  });
@@ -526,7 +541,13 @@ export class BaseProvider {
526
541
  retryHeld.push(chunk);
527
542
  continue;
528
543
  }
529
- retryCommitted = true;
544
+ if (!retryCommitted) {
545
+ retryCommitted = true;
546
+ // This candidate is now the attempt serving the stream:
547
+ // every metadata/analytics/tool field the caller reads must
548
+ // resolve from here, never from the abandoned first attempt.
549
+ activeResult = retry;
550
+ }
530
551
  while (retryHeld.length > 0) {
531
552
  yield retryHeld.shift();
532
553
  }
@@ -567,14 +588,65 @@ export class BaseProvider {
567
588
  cancelStream(activeStream);
568
589
  releaseIterator(activeIterator);
569
590
  });
570
- // A naked spread reads (and thereby snapshots) every enumerable getter
571
- // on `result` — including a provider's lazily-populated `toolsUsed` /
572
- // `toolExecutions` — before the consumer has pulled a single chunk.
573
- // Re-applying the original accessor descriptors keeps those fields live.
574
- return preserveLiveStreamAccessors(result, {
575
- ...result,
591
+ // A naked spread would read (and thereby snapshot) every field on
592
+ // `result` immediately — before the generator above has even started —
593
+ // permanently freezing the first attempt's values. Define every declared
594
+ // StreamResult field instead of only the keys present on `result`: retry
595
+ // providers may expose optional fields the rejected attempt omitted.
596
+ const target = {
576
597
  stream: wrapped,
577
- });
598
+ };
599
+ const resultKeys = [
600
+ "knowledge",
601
+ "agentModeVersion",
602
+ "provider",
603
+ "model",
604
+ "usage",
605
+ "finishReason",
606
+ "stopReason",
607
+ "rawFinishReason",
608
+ "toolCalls",
609
+ "toolResults",
610
+ "toolEvents",
611
+ "toolExecutions",
612
+ "toolsUsed",
613
+ "metadata",
614
+ "analytics",
615
+ "evaluation",
616
+ "events",
617
+ "workflow",
618
+ "transcription",
619
+ "audio",
620
+ "ttsMetadata",
621
+ ];
622
+ for (const key of resultKeys) {
623
+ Object.defineProperty(target, key, {
624
+ enumerable: true,
625
+ configurable: true,
626
+ get: () => {
627
+ const assigned = assignedValues.get(activeResult);
628
+ return assigned?.has(key) ? assigned.get(key) : activeResult[key];
629
+ },
630
+ set: (value) => {
631
+ const descriptor = Object.getOwnPropertyDescriptor(activeResult, key);
632
+ if (descriptor?.set) {
633
+ descriptor.set.call(activeResult, value);
634
+ return;
635
+ }
636
+ if (!descriptor || "value" in descriptor) {
637
+ Reflect.set(activeResult, key, value);
638
+ return;
639
+ }
640
+ let assigned = assignedValues.get(activeResult);
641
+ if (!assigned) {
642
+ assigned = new Map();
643
+ assignedValues.set(activeResult, assigned);
644
+ }
645
+ assigned.set(key, value);
646
+ },
647
+ });
648
+ }
649
+ return target;
578
650
  }
579
651
  /**
580
652
  * The eager half of the retired-model stream fallback, for providers whose
package/dist/neurolink.js CHANGED
@@ -83,7 +83,7 @@ import { hasLifecycleErrorFired, markLifecycleErrorFired, } from "./utils/lifecy
83
83
  import { interleaveTTSStream } from "./utils/ttsStream.js";
84
84
  import { resolveLifecycleTimeoutMs } from "./utils/lifecycleTimeout.js";
85
85
  import { cloneOptionsForCallIsolation } from "./utils/cloneOptions.js";
86
- import { pickLiveStreamDescriptors, preserveLiveStreamAccessors, } from "./utils/streamResultAccessors.js";
86
+ import { preserveLiveStreamAccessors } from "./utils/streamResultAccessors.js";
87
87
  import { coerceJsonToSchema, recoverScalarRoot, schemaAccepts, } from "./utils/json/coerce.js";
88
88
  // Factory processing imports
89
89
  import { createCleanStreamOptions, enhanceTextGenerationOptions, processFactoryOptions, processStreamingFactoryOptions, validateFactoryConfig, } from "./utils/factoryProcessing.js";
@@ -7828,7 +7828,8 @@ Current user's request: ${currentInput}`;
7828
7828
  // bottom of this method, without ever reading — and thereby freezing —
7829
7829
  // a getter-based provider's value here.
7830
7830
  const mcpStreamOutcome = await this.createMCPStream(enhancedOptions);
7831
- const { stream: mcpStream, provider: providerName, usage: streamUsage, model: streamModel, finishReason: streamFinishReason, toolCalls: streamToolCalls, toolResults: streamToolResults, analytics: streamAnalytics, metadata: providerStreamMetadata, } = mcpStreamOutcome;
7831
+ const { stream: mcpStream } = mcpStreamOutcome;
7832
+ const providerName = mcpStreamOutcome.provider ?? "unknown";
7832
7833
  let streamedTTSResult;
7833
7834
  const { stream: incrementalStream, ttsMetadata: streamTtsMetadata } = await this.createIncrementalTTSStream({
7834
7835
  stream: mcpStream,
@@ -7841,9 +7842,9 @@ Current user's request: ${currentInput}`;
7841
7842
  });
7842
7843
  ttsMetadataSink?.(streamTtsMetadata);
7843
7844
  const streamState = {
7844
- finishReason: streamFinishReason ?? "stop",
7845
- toolCalls: streamToolCalls,
7846
- toolResults: streamToolResults,
7845
+ finishReason: mcpStreamOutcome.finishReason ?? "stop",
7846
+ toolCalls: mcpStreamOutcome.toolCalls ?? [],
7847
+ toolResults: mcpStreamOutcome.toolResults ?? [],
7847
7848
  };
7848
7849
  streamSpan.setAttribute(ATTR.NL_PROVIDER, providerName || "unknown");
7849
7850
  let accumulatedContent = "";
@@ -7926,7 +7927,7 @@ Current user's request: ${currentInput}`;
7926
7927
  // not a provider failure, and retrying it on another provider
7927
7928
  // spends that provider's tokens to exceed a budget the caller set.
7928
7929
  const cappedByCallerBudget = enhancedOptions.fallbackOnMaxSteps === false &&
7929
- providerStreamMetadata?.stopReason === "step-cap";
7930
+ mcpStreamOutcome.metadata?.stopReason === "step-cap";
7930
7931
  if (realOutputChunks === 0 &&
7931
7932
  !cappedByCallerBudget &&
7932
7933
  !metadata.fallbackAttempted &&
@@ -7949,10 +7950,10 @@ Current user's request: ${currentInput}`;
7949
7950
  yield* incrementalFallback;
7950
7951
  }
7951
7952
  ttsResolver?.(streamedTTSResult);
7952
- resolvedUsage = streamUsage;
7953
- if (!resolvedUsage && streamAnalytics) {
7953
+ resolvedUsage = mcpStreamOutcome.usage;
7954
+ if (!resolvedUsage && mcpStreamOutcome.analytics) {
7954
7955
  try {
7955
- const resolved = await Promise.resolve(streamAnalytics);
7956
+ const resolved = await Promise.resolve(mcpStreamOutcome.analytics);
7956
7957
  if (resolved?.tokenUsage) {
7957
7958
  resolvedUsage = resolved.tokenUsage;
7958
7959
  }
@@ -7961,11 +7962,16 @@ Current user's request: ${currentInput}`;
7961
7962
  // non-blocking
7962
7963
  }
7963
7964
  }
7965
+ if (resolvedUsage) {
7966
+ mcpStreamOutcome.usage = resolvedUsage;
7967
+ }
7964
7968
  self.emitter.emit("stream:complete", {
7965
7969
  type: "stream:complete",
7966
7970
  content: accumulatedContent,
7967
7971
  provider: metadata.fallbackProvider ?? providerName,
7968
- model: metadata.fallbackModel ?? streamModel ?? enhancedOptions.model,
7972
+ model: metadata.fallbackModel ??
7973
+ mcpStreamOutcome.model ??
7974
+ enhancedOptions.model,
7969
7975
  finishReason: streamState.finishReason ?? "stop",
7970
7976
  prompt: enhancedOptions.input?.text ||
7971
7977
  enhancedOptions.prompt,
@@ -8047,7 +8053,7 @@ Current user's request: ${currentInput}`;
8047
8053
  try {
8048
8054
  const finalProvider = metadata.fallbackProvider ?? providerName ?? "unknown";
8049
8055
  const finalModel = metadata.fallbackModel ??
8050
- streamModel ??
8056
+ mcpStreamOutcome.model ??
8051
8057
  enhancedOptions.model ??
8052
8058
  "unknown";
8053
8059
  const finalFinishReason = streamError
@@ -8139,7 +8145,7 @@ Current user's request: ${currentInput}`;
8139
8145
  streamResult.toolCalls = streamState.toolCalls;
8140
8146
  streamResult.toolResults = streamState.toolResults;
8141
8147
  if (!streamResult.usage) {
8142
- streamResult.usage = streamUsage;
8148
+ streamResult.usage = mcpStreamOutcome.usage;
8143
8149
  }
8144
8150
  if (!streamResult.analytics) {
8145
8151
  // CRITICAL: do NOT `await` a Promise-typed analytics here. Bedrock
@@ -8155,8 +8161,7 @@ Current user's request: ${currentInput}`;
8155
8161
  // Promise<AnalyticsData>` — the cast widens the local
8156
8162
  // `processStreamResult` return type which only declares the
8157
8163
  // resolved shape.
8158
- streamResult.analytics =
8159
- streamAnalytics;
8164
+ streamResult.analytics = mcpStreamOutcome.analytics;
8160
8165
  }
8161
8166
  // The cost listener wants a resolved cost, but if the provider gave
8162
8167
  // us a Promise (Bedrock) we can't tax the stream's return path on it.
@@ -8183,8 +8188,7 @@ Current user's request: ${currentInput}`;
8183
8188
  guardrailsBlocked: metadata.guardrailsBlocked,
8184
8189
  error: metadata.error,
8185
8190
  events: eventSequence,
8186
- providerMetadata: providerStreamMetadata,
8187
- toolTelemetrySource: mcpStreamOutcome,
8191
+ sourceResult: mcpStreamOutcome,
8188
8192
  });
8189
8193
  }
8190
8194
  catch (error) {
@@ -8986,24 +8990,14 @@ Current user's request: ${currentInput}`;
8986
8990
  throw streamConsumeErr;
8987
8991
  }
8988
8992
  })();
8989
- // toolsUsed/toolExecutions are attached via defineProperties (not
8990
- // listed as plain fields above) so a getter-based provider result
8991
- // keeps resolving lazily through this boundary instead of being
8992
- // read — and frozen — once, right here, before any chunk is drained.
8993
- return Object.defineProperties({
8993
+ return preserveLiveStreamAccessors(poolStreamResult, {
8994
+ ...poolStreamResult,
8994
8995
  stream: wrappedStream,
8995
8996
  provider: poolStreamProviderName,
8996
- usage: poolStreamResult.usage,
8997
8997
  model: poolStreamResult.model || poolStreamModel,
8998
- finishReason: poolStreamResult.finishReason,
8999
8998
  toolCalls: poolStreamResult.toolCalls ?? [],
9000
8999
  toolResults: poolStreamResult.toolResults ?? [],
9001
- analytics: poolStreamResult.analytics,
9002
- metadata: poolStreamResult.metadata,
9003
- }, pickLiveStreamDescriptors(poolStreamResult, [
9004
- "toolsUsed",
9005
- "toolExecutions",
9006
- ]));
9000
+ });
9007
9001
  }
9008
9002
  catch (poolStreamError) {
9009
9003
  if (isAbortError(poolStreamError)) {
@@ -9048,26 +9042,17 @@ Current user's request: ${currentInput}`;
9048
9042
  provider: providerName,
9049
9043
  systemPromptPassedLength: enhancedSystemPrompt.length,
9050
9044
  });
9051
- // toolsUsed/toolExecutions are attached via defineProperties, not listed
9052
- // as plain fields: see the identical comment on the ModelPool branch
9053
- // above — a plain `toolsUsed: streamResult.toolsUsed` here would invoke
9054
- // and freeze a getter-based provider's value before any chunk is drained.
9055
- return Object.defineProperties({
9056
- stream: streamResult.stream,
9045
+ // Preserve every live descriptor from the provider result. Internal model
9046
+ // fallback can switch the descriptor's backing result only after iteration
9047
+ // starts, so eagerly rebuilding selected fields here would re-freeze the
9048
+ // rejected attempt before the caller drains a chunk.
9049
+ return preserveLiveStreamAccessors(streamResult, {
9050
+ ...streamResult,
9057
9051
  provider: providerName,
9058
- usage: streamResult.usage,
9059
9052
  model: streamResult.model || options.model,
9060
- finishReason: streamResult.finishReason,
9061
9053
  toolCalls: streamResult.toolCalls ?? [],
9062
9054
  toolResults: streamResult.toolResults ?? [],
9063
- analytics: streamResult.analytics,
9064
- // Pass the provider's metadata object THROUGH BY REFERENCE: native
9065
- // background-loop streams (Vertex Gemini/Claude) resolve
9066
- // finishReason/stopReason/rawFinishReason/stepsUsed onto it only when
9067
- // the loop finishes — snapshotting fields here would freeze them as
9068
- // undefined before the stream is drained.
9069
- metadata: streamResult.metadata,
9070
- }, pickLiveStreamDescriptors(streamResult, ["toolsUsed", "toolExecutions"]));
9055
+ });
9071
9056
  }
9072
9057
  /**
9073
9058
  * Process stream result
@@ -9098,10 +9083,20 @@ Current user's request: ${currentInput}`;
9098
9083
  * Create stream response
9099
9084
  */
9100
9085
  createStreamResponse(streamResult, stream, config) {
9101
- return Object.defineProperties({
9086
+ const source = config.sourceResult;
9087
+ const responseMetadata = {
9088
+ streamId: config.streamId,
9089
+ startTime: config.startTime,
9090
+ responseTime: config.responseTime,
9091
+ fallback: config.fallback || false,
9092
+ guardrailsBlocked: config.guardrailsBlocked,
9093
+ error: config.error,
9094
+ };
9095
+ const response = {
9096
+ ...(source ?? {}),
9102
9097
  stream,
9103
- provider: config.providerName,
9104
- model: config.options.model,
9098
+ provider: source?.provider ?? config.providerName,
9099
+ model: source?.model ?? config.options.model,
9105
9100
  usage: streamResult.usage,
9106
9101
  finishReason: streamResult.finishReason,
9107
9102
  toolCalls: streamResult.toolCalls,
@@ -9109,20 +9104,37 @@ Current user's request: ${currentInput}`;
9109
9104
  analytics: streamResult.analytics,
9110
9105
  evaluation: streamResult.evaluation,
9111
9106
  events: config.events && config.events.length > 0 ? config.events : undefined,
9112
- metadata: Object.assign(config.providerMetadata ?? {}, {
9113
- streamId: config.streamId,
9114
- startTime: config.startTime,
9115
- responseTime: config.responseTime,
9116
- fallback: config.fallback || false,
9117
- guardrailsBlocked: config.guardrailsBlocked,
9118
- error: config.error,
9119
- }),
9120
- }, config.toolTelemetrySource
9121
- ? pickLiveStreamDescriptors(config.toolTelemetrySource, [
9122
- "toolsUsed",
9123
- "toolExecutions",
9124
- ])
9125
- : {});
9107
+ metadata: source ? undefined : Object.assign({}, responseMetadata),
9108
+ };
9109
+ if (!source) {
9110
+ return response;
9111
+ }
9112
+ // NeuroLink grades the turn's terminal state itself (a normalized
9113
+ // finishReason, stopReason for aborts and time limits), so those fields
9114
+ // stay plain values here. Copying the provider's getter for them would
9115
+ // report the raw vendor reason, e.g. Anthropic's end_turn, instead.
9116
+ const gradedTerminalFields = new Set([
9117
+ "finishReason",
9118
+ "stopReason",
9119
+ "rawFinishReason",
9120
+ ]);
9121
+ for (const key of Object.getOwnPropertyNames(source)) {
9122
+ const descriptor = Object.getOwnPropertyDescriptor(source, key);
9123
+ if (!gradedTerminalFields.has(key) &&
9124
+ descriptor &&
9125
+ typeof descriptor.get === "function") {
9126
+ Object.defineProperty(response, key, descriptor);
9127
+ }
9128
+ }
9129
+ Object.defineProperty(response, "metadata", {
9130
+ enumerable: true,
9131
+ configurable: true,
9132
+ get: () => Object.assign(source.metadata ?? {}, responseMetadata),
9133
+ set: (value) => {
9134
+ source.metadata = value;
9135
+ },
9136
+ });
9137
+ return response;
9126
9138
  }
9127
9139
  /**
9128
9140
  * Handle stream error with fallback
@@ -905,6 +905,12 @@ export class GoogleVertexProvider extends BaseProvider {
905
905
  ...processedOptions,
906
906
  tools: optionTools,
907
907
  };
908
+ // Captured up-front (mirrors generate()'s inputPrompt) so the
909
+ // failure branch below can label a `generation:end` it emits before
910
+ // any content exists.
911
+ const inputPrompt = mergedOptions.input?.text ||
912
+ mergedOptions.prompt ||
913
+ "";
908
914
  try {
909
915
  // Route Claude models to native Anthropic SDK
910
916
  let result;
@@ -937,6 +943,17 @@ export class GoogleVertexProvider extends BaseProvider {
937
943
  catch (error) {
938
944
  this.fireGenerateOnError(options, error, streamStartTime);
939
945
  this.emitStreamEnd(modelName, streamStartTime, false, error);
946
+ // Unlike Google AI Studio's native stream, Vertex's turn loop is
947
+ // awaited to completion INSIDE executeNativeGemini3Stream /
948
+ // executeNativeAnthropicStream before this method ever returns a
949
+ // StreamResult — a first-call failure (e.g. a 500 on the initial
950
+ // request) rejects here, before the generic per-stream dedup
951
+ // machinery in NeuroLink.stream() (which only wraps an already
952
+ // -returned stream) ever runs. Without this call a failed Vertex
953
+ // stream reported zero `generation:end` events instead of one:
954
+ // `emitStreamEnd` above only ever emits `stream:end`, a different
955
+ // event Pipeline B and analytics don't listen for.
956
+ this.emitGenerationEnd(modelName, null, streamStartTime, false, error, inputPrompt);
940
957
  throw error;
941
958
  }
942
959
  }, (r) => r.stream,
@@ -5682,6 +5699,26 @@ export class GoogleVertexProvider extends BaseProvider {
5682
5699
  const usage = result?.usage && typeof result.usage === "object"
5683
5700
  ? result.usage
5684
5701
  : { input: 0, output: 0, total: 0 };
5702
+ // Mark on the result so the SDK-level runStandardGenerateRequest knows
5703
+ // this provider already emitted `generation:end` itself and skips its
5704
+ // own duplicate emission. Without this flag the public event listener
5705
+ // (and the observability test) would see two events per generate call.
5706
+ if (result && typeof result === "object") {
5707
+ result._generationEndEmitted =
5708
+ true;
5709
+ }
5710
+ // The failure path has no result to carry that flag — `result` is null —
5711
+ // so mark the error instead. Without this, `generateTextInternal`'s
5712
+ // wrapProviderError (which replaces this error with a new one further up
5713
+ // the stack) has nothing to copy forward, and emitGenerateErrorEvent goes
5714
+ // on to emit its own `generation:end` for a failure this provider already
5715
+ // reported — double-counting every failed native Vertex generate in
5716
+ // analytics and as two Pipeline B spans. Same shape as the AI Studio
5717
+ // (#1745) and Bedrock (#1762) fixes for the same gap.
5718
+ if (!success && error && typeof error === "object") {
5719
+ error._generationEndEmitted =
5720
+ true;
5721
+ }
5685
5722
  emitter.emit("generation:end", {
5686
5723
  provider: this.providerName,
5687
5724
  responseTime: Date.now() - startTime,
@@ -5702,14 +5739,6 @@ export class GoogleVertexProvider extends BaseProvider {
5702
5739
  ? { error: error instanceof Error ? error.message : String(error) }
5703
5740
  : {}),
5704
5741
  });
5705
- // Mark on the result so the SDK-level runStandardGenerateRequest knows
5706
- // this provider already emitted `generation:end` itself and skips its
5707
- // own duplicate emission. Without this flag the public event listener
5708
- // (and the observability test) would see two events per generate call.
5709
- if (result && typeof result === "object") {
5710
- result._generationEndEmitted =
5711
- true;
5712
- }
5713
5742
  }
5714
5743
  /**
5715
5744
  * Turn-clock-shaped adapter over the shared {@link buildLoopExitMessage}.
@@ -60,22 +60,27 @@ function sanitizePersistedCooldown(accountKey, entry) {
60
60
  `${hours(ceiling)}h — the stored wait exceeded what "${entry.reason}" can mean`);
61
61
  return { ...entry, coolingUntil: latest };
62
62
  }
63
+ /**
64
+ * Read the cooldown file as it is now. A missing file is empty; any other
65
+ * failure (unreadable, corrupt) returns `fallback`, so a write never discards
66
+ * entries just because one read failed.
67
+ */
68
+ async function readCooldownsFromDisk(fallback) {
69
+ try {
70
+ const parsed = JSON.parse(await readFile(getCooldownFilePath(), "utf8"));
71
+ return Object.fromEntries(Object.entries(parsed)
72
+ .filter((entry) => isPersistedCooldown(entry[1]))
73
+ .map(([key, entry]) => [key, sanitizePersistedCooldown(key, entry)]));
74
+ }
75
+ catch (error) {
76
+ return error.code === "ENOENT" ? {} : fallback;
77
+ }
78
+ }
63
79
  async function ensureAccountCooldownsLoaded() {
64
80
  if (!cacheLoaded) {
65
81
  if (!cacheLoadPromise) {
66
82
  cacheLoadPromise = (async () => {
67
- try {
68
- const parsed = JSON.parse(await readFile(getCooldownFilePath(), "utf8"));
69
- memoryCache = Object.fromEntries(Object.entries(parsed)
70
- .filter((entry) => isPersistedCooldown(entry[1]))
71
- .map(([key, entry]) => [
72
- key,
73
- sanitizePersistedCooldown(key, entry),
74
- ]));
75
- }
76
- catch {
77
- memoryCache = {};
78
- }
83
+ memoryCache = await readCooldownsFromDisk({});
79
84
  cacheLoaded = true;
80
85
  })().finally(() => {
81
86
  cacheLoadPromise = null;
@@ -91,14 +96,18 @@ export async function loadAccountCooldowns() {
91
96
  export async function saveAccountCooldown(accountKey, coolingUntil, reason) {
92
97
  await mutationMutex.runExclusive(async () => {
93
98
  await ensureAccountCooldownsLoaded();
94
- const current = memoryCache[accountKey];
99
+ // Another worker (a draining one during a rolling restart) may have written
100
+ // the file since this one loaded it, so the change folds onto the file as
101
+ // it is now rather than onto this worker's copy.
102
+ const onDisk = await readCooldownsFromDisk(memoryCache);
103
+ memoryCache = onDisk;
104
+ const current = onDisk[accountKey];
95
105
  if (current && current.coolingUntil > coolingUntil) {
96
106
  return;
97
107
  }
98
- memoryCache[accountKey] = {
99
- coolingUntil,
100
- reason,
101
- updatedAt: Date.now(),
108
+ memoryCache = {
109
+ ...onDisk,
110
+ [accountKey]: { coolingUntil, reason, updatedAt: Date.now() },
102
111
  };
103
112
  await writeJsonSnapshotAtomically(getCooldownFilePath(), memoryCache);
104
113
  });
@@ -106,7 +115,11 @@ export async function saveAccountCooldown(accountKey, coolingUntil, reason) {
106
115
  export async function clearAccountCooldown(accountKey, expectedCoolingUntil) {
107
116
  await mutationMutex.runExclusive(async () => {
108
117
  await ensureAccountCooldownsLoaded();
109
- const current = memoryCache[accountKey];
118
+ // Compare against the file, not this worker's copy: another worker may
119
+ // have extended this cooldown since, and that one must survive.
120
+ const onDisk = await readCooldownsFromDisk(memoryCache);
121
+ memoryCache = onDisk;
122
+ const current = onDisk[accountKey];
110
123
  if (!current) {
111
124
  return;
112
125
  }
@@ -114,7 +127,9 @@ export async function clearAccountCooldown(accountKey, expectedCoolingUntil) {
114
127
  current.coolingUntil !== expectedCoolingUntil) {
115
128
  return;
116
129
  }
117
- delete memoryCache[accountKey];
130
+ const next = { ...onDisk };
131
+ delete next[accountKey];
132
+ memoryCache = next;
118
133
  await writeJsonSnapshotAtomically(getCooldownFilePath(), memoryCache);
119
134
  });
120
135
  }
@@ -308,8 +308,8 @@ const FLUSH_INTERVAL_MS = 5_000; // write to disk at most every 5 seconds
308
308
  let memoryCache = {};
309
309
  let cacheLoaded = false;
310
310
  let cacheLoadPromise = null;
311
- let dirty = false;
312
- let cacheVersion = 0;
311
+ /** Accounts this worker changed since its last flush. */
312
+ let dirtyKeys = new Set();
313
313
  let flushTimer = null;
314
314
  const stateMutex = new AsyncMutex();
315
315
  const flushMutex = new AsyncMutex();
@@ -332,38 +332,86 @@ export function initAccountQuota(quotaFilePath) {
332
332
  memoryCache = {};
333
333
  cacheLoaded = false;
334
334
  cacheLoadPromise = null;
335
- dirty = false;
336
- cacheVersion = 0;
335
+ dirtyKeys = new Set();
337
336
  }
338
337
  function getQuotaFilePath() {
339
338
  return customQuotaFilePath ?? join(homedir(), ".neurolink", QUOTA_FILE);
340
339
  }
341
- /** Flush the in-memory cache to disk (async, non-blocking). */
340
+ /**
341
+ * Read the quota file as it is now. A missing file is empty; any other failure
342
+ * (unreadable, corrupt) returns `fallback`, so a flush never discards entries
343
+ * just because one read failed.
344
+ */
345
+ async function readQuotasFromDisk(filePath, fallback) {
346
+ try {
347
+ const parsed = JSON.parse(await fs.readFile(filePath, "utf-8"));
348
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
349
+ return fallback;
350
+ }
351
+ return parsed;
352
+ }
353
+ catch (error) {
354
+ return error.code === "ENOENT" ? {} : fallback;
355
+ }
356
+ }
357
+ /** Fold two snapshots of one account so the more recently updated one wins,
358
+ * whichever worker wrote it. */
359
+ function foldNewerQuota(onDisk, ours) {
360
+ if (!onDisk) {
361
+ return ours;
362
+ }
363
+ return onDisk.lastUpdated > ours.lastUpdated
364
+ ? mergeQuotaSnapshot(ours, onDisk)
365
+ : mergeQuotaSnapshot(onDisk, ours);
366
+ }
367
+ /** Flush this worker's changed accounts to disk (async, non-blocking). */
342
368
  async function flushToDisk() {
343
369
  await flushMutex.runExclusive(async () => {
344
- let snapshot;
345
- let snapshotVersion = 0;
370
+ let changed;
371
+ let fallback = {};
346
372
  let filePath = "";
347
373
  await stateMutex.runExclusive(async () => {
348
- if (!dirty) {
374
+ if (dirtyKeys.size === 0) {
349
375
  return;
350
376
  }
351
- snapshot = Object.fromEntries(Object.entries(memoryCache).map(([key, quota]) => [key, { ...quota }]));
352
- snapshotVersion = cacheVersion;
377
+ changed = Object.fromEntries([...dirtyKeys].map((key) => [key, { ...memoryCache[key] }]));
378
+ fallback = Object.fromEntries(Object.entries(memoryCache).map(([key, quota]) => [key, { ...quota }]));
379
+ dirtyKeys = new Set();
353
380
  filePath = getQuotaFilePath();
354
381
  });
355
- if (!snapshot) {
382
+ const flushing = changed;
383
+ if (!flushing) {
356
384
  return;
357
385
  }
358
386
  try {
359
- await writeJsonSnapshotAtomically(filePath, snapshot, 0o600);
387
+ // Another worker (a draining one during a rolling restart) may have
388
+ // written the file since this one loaded it: fold only this worker's
389
+ // changes onto the file as it is now, never rewrite it from this copy.
390
+ const onDisk = await readQuotasFromDisk(filePath, fallback);
391
+ const merged = { ...onDisk };
392
+ for (const [key, quota] of Object.entries(flushing)) {
393
+ merged[key] = foldNewerQuota(onDisk[key], quota);
394
+ }
395
+ await writeJsonSnapshotAtomically(filePath, merged, 0o600);
360
396
  await stateMutex.runExclusive(async () => {
361
- if (cacheVersion === snapshotVersion) {
362
- dirty = false;
397
+ if (getQuotaFilePath() !== filePath) {
398
+ return;
363
399
  }
400
+ // Adopt what other workers wrote, keeping changes saved meanwhile.
401
+ memoryCache = {
402
+ ...merged,
403
+ ...Object.fromEntries([...dirtyKeys].map((key) => [key, memoryCache[key]])),
404
+ };
364
405
  });
365
406
  }
366
407
  catch {
408
+ await stateMutex.runExclusive(async () => {
409
+ if (getQuotaFilePath() === filePath) {
410
+ for (const key of Object.keys(flushing)) {
411
+ dirtyKeys.add(key);
412
+ }
413
+ }
414
+ });
367
415
  // Non-fatal — quota is best-effort telemetry
368
416
  }
369
417
  });
@@ -394,13 +442,7 @@ async function ensureAccountQuotasLoaded() {
394
442
  if (!cacheLoaded) {
395
443
  if (!cacheLoadPromise) {
396
444
  cacheLoadPromise = (async () => {
397
- try {
398
- const raw = await fs.readFile(getQuotaFilePath(), "utf-8");
399
- memoryCache = JSON.parse(raw);
400
- }
401
- catch {
402
- memoryCache = {};
403
- }
445
+ memoryCache = await readQuotasFromDisk(getQuotaFilePath(), {});
404
446
  cacheLoaded = true;
405
447
  })().finally(() => {
406
448
  cacheLoadPromise = null;
@@ -436,8 +478,7 @@ export async function saveAccountQuota(accountKey, quota) {
436
478
  // A header capture reports only the windows the served request touched, so
437
479
  // it must fold onto the existing snapshot rather than replace it.
438
480
  memoryCache[accountKey] = mergeQuotaSnapshot(memoryCache[accountKey], quota);
439
- dirty = true;
440
- cacheVersion += 1;
481
+ dirtyKeys.add(accountKey);
441
482
  });
442
483
  scheduleFlush();
443
484
  }