@sentry/junior 0.70.0 → 0.71.0

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.
package/dist/app.js CHANGED
@@ -13951,6 +13951,7 @@ var CONVERSATION_WORK_MUTATION_RETRY_MS = 25;
13951
13951
  var CONVERSATION_WORK_LEASE_TTL_MS = 9e4;
13952
13952
  var CONVERSATION_WORK_CHECK_IN_INTERVAL_MS = 15e3;
13953
13953
  var CONVERSATION_WORK_STALE_ENQUEUE_MS = 6e4;
13954
+ var CONVERSATION_WORK_MAX_CONSECUTIVE_FAILURES = 5;
13954
13955
  function duplicateInboundNudgeIdempotencyKey(message, nowMs) {
13955
13956
  return `duplicate:${message.conversationId}:${message.inboundMessageId}:${nowMs}`;
13956
13957
  }
@@ -14072,14 +14073,18 @@ function normalizeWorkState(conversationId, value) {
14072
14073
  messages,
14073
14074
  needsRun: value.needsRun === true,
14074
14075
  updatedAtMs,
14076
+ consecutiveFailureCount: toOptionalNumber(value.consecutiveFailureCount) ?? 0,
14075
14077
  lastEnqueuedAtMs: toOptionalNumber(value.lastEnqueuedAtMs),
14076
- lease: normalizeLease(value.lease)
14078
+ lastFailureAtMs: toOptionalNumber(value.lastFailureAtMs),
14079
+ lease: normalizeLease(value.lease),
14080
+ terminallyFailedAtMs: toOptionalNumber(value.terminallyFailedAtMs)
14077
14081
  };
14078
14082
  }
14079
14083
  function emptyWorkState(args) {
14080
14084
  return {
14081
14085
  schemaVersion: CONVERSATION_WORK_SCHEMA_VERSION,
14082
14086
  conversationId: args.conversationId,
14087
+ consecutiveFailureCount: 0,
14083
14088
  destination: args.destination,
14084
14089
  messages: [],
14085
14090
  needsRun: false,
@@ -14093,6 +14098,9 @@ function pendingMessages(state) {
14093
14098
  return state.messages.filter((message) => message.injectedAtMs === void 0).sort(compareMessages);
14094
14099
  }
14095
14100
  function shouldKeepIndexed(state) {
14101
+ if (state.terminallyFailedAtMs !== void 0) {
14102
+ return false;
14103
+ }
14096
14104
  return state.needsRun || Boolean(state.lease) || pendingMessages(state).length > 0;
14097
14105
  }
14098
14106
  async function getConnectedState(stateAdapter) {
@@ -14217,6 +14225,9 @@ async function writeWorkState(state, work) {
14217
14225
  }
14218
14226
  }
14219
14227
  function hasRunnableWork(state) {
14228
+ if (state.terminallyFailedAtMs !== void 0) {
14229
+ return false;
14230
+ }
14220
14231
  return state.needsRun || pendingMessages(state).length > 0;
14221
14232
  }
14222
14233
  function assertSameConversationDestination(args) {
@@ -14266,8 +14277,11 @@ async function appendInboundMessage(args) {
14266
14277
  }
14267
14278
  const next = {
14268
14279
  ...current,
14280
+ consecutiveFailureCount: 0,
14281
+ lastFailureAtMs: void 0,
14269
14282
  messages: [...current.messages, args.message].sort(compareMessages),
14270
14283
  needsRun: true,
14284
+ terminallyFailedAtMs: void 0,
14271
14285
  updatedAtMs: nowMs
14272
14286
  };
14273
14287
  await writeWorkState(state, next);
@@ -14435,6 +14449,8 @@ async function drainConversationMailbox(args) {
14435
14449
  );
14436
14450
  await writeWorkState(state, {
14437
14451
  ...current,
14452
+ consecutiveFailureCount: 0,
14453
+ lastFailureAtMs: void 0,
14438
14454
  messages,
14439
14455
  needsRun: hasPending,
14440
14456
  updatedAtMs: nowMs
@@ -14466,6 +14482,8 @@ async function markConversationMessagesInjected(args) {
14466
14482
  }
14467
14483
  await writeWorkState(state, {
14468
14484
  ...current,
14485
+ consecutiveFailureCount: 0,
14486
+ lastFailureAtMs: void 0,
14469
14487
  messages,
14470
14488
  updatedAtMs: nowMs
14471
14489
  });
@@ -14518,6 +14536,8 @@ async function completeConversationWork(args) {
14518
14536
  const hasRunnableWork2 = current.needsRun || hasPending;
14519
14537
  await writeWorkState(state, {
14520
14538
  ...current,
14539
+ consecutiveFailureCount: 0,
14540
+ lastFailureAtMs: void 0,
14521
14541
  lease: void 0,
14522
14542
  needsRun: hasRunnableWork2,
14523
14543
  updatedAtMs: nowMs
@@ -14541,6 +14561,53 @@ async function clearExpiredConversationLease(args) {
14541
14561
  return true;
14542
14562
  });
14543
14563
  }
14564
+ async function recordConversationWorkFailure(args) {
14565
+ const nowMs = args.nowMs ?? now();
14566
+ return await withConversationMutation(args, async (state) => {
14567
+ const current = await readWorkState(state, args.conversationId);
14568
+ if (!current) {
14569
+ return {
14570
+ abandoned: false,
14571
+ consecutiveFailureCount: 0,
14572
+ releasedLease: false
14573
+ };
14574
+ }
14575
+ const consecutiveFailureCount = current.consecutiveFailureCount + 1;
14576
+ const abandoned = consecutiveFailureCount >= CONVERSATION_WORK_MAX_CONSECUTIVE_FAILURES;
14577
+ if (!abandoned) {
14578
+ await writeWorkState(state, {
14579
+ ...current,
14580
+ consecutiveFailureCount,
14581
+ lastFailureAtMs: nowMs,
14582
+ updatedAtMs: nowMs
14583
+ });
14584
+ return {
14585
+ abandoned: false,
14586
+ consecutiveFailureCount,
14587
+ releasedLease: false
14588
+ };
14589
+ }
14590
+ const releasedLease = Boolean(current.lease);
14591
+ const drainedMessages = current.messages.filter(
14592
+ (message) => message.injectedAtMs !== void 0
14593
+ );
14594
+ await writeWorkState(state, {
14595
+ ...current,
14596
+ consecutiveFailureCount,
14597
+ lastFailureAtMs: nowMs,
14598
+ lease: void 0,
14599
+ messages: drainedMessages,
14600
+ needsRun: false,
14601
+ terminallyFailedAtMs: nowMs,
14602
+ updatedAtMs: nowMs
14603
+ });
14604
+ return {
14605
+ abandoned: true,
14606
+ consecutiveFailureCount,
14607
+ releasedLease
14608
+ };
14609
+ });
14610
+ }
14544
14611
  async function listConversationWorkIds(args = {}) {
14545
14612
  const state = await getConnectedState(args.state);
14546
14613
  const ids = uniqueStrings(await state.get(indexKey()) ?? []);
@@ -23299,24 +23366,50 @@ async function processConversationWork(message, options) {
23299
23366
  );
23300
23367
  }
23301
23368
  const destination = initial.destination;
23302
- const lease = await startConversationWork({
23303
- conversationId,
23304
- nowMs: now2(options),
23305
- state: options.state
23306
- });
23369
+ let lease;
23370
+ try {
23371
+ lease = await startConversationWork({
23372
+ conversationId,
23373
+ nowMs: now2(options),
23374
+ state: options.state
23375
+ });
23376
+ } catch (error) {
23377
+ logException(
23378
+ error,
23379
+ "conversation_work_lease_acquire_failed",
23380
+ { conversationId },
23381
+ {},
23382
+ "Conversation work lease acquisition failed; heartbeat will recover"
23383
+ );
23384
+ return { status: "no_work" };
23385
+ }
23307
23386
  if (lease.status === "no_work") {
23308
23387
  return { status: "no_work" };
23309
23388
  }
23310
23389
  if (lease.status === "active") {
23311
23390
  const nudgeNowMs = now2(options);
23312
- await sendWakeNudge({
23313
- conversationId,
23314
- destination,
23315
- delayMs: CONVERSATION_WORK_DEFER_DELAY_MS,
23316
- idempotencyKey: nudgeIdempotencyKey("active", conversationId, nudgeNowMs),
23317
- nowMs: nudgeNowMs,
23318
- options
23319
- });
23391
+ try {
23392
+ await sendWakeNudge({
23393
+ conversationId,
23394
+ destination,
23395
+ delayMs: CONVERSATION_WORK_DEFER_DELAY_MS,
23396
+ idempotencyKey: nudgeIdempotencyKey(
23397
+ "active",
23398
+ conversationId,
23399
+ nudgeNowMs
23400
+ ),
23401
+ nowMs: nudgeNowMs,
23402
+ options
23403
+ });
23404
+ } catch (error) {
23405
+ logException(
23406
+ error,
23407
+ "conversation_work_active_nudge_failed",
23408
+ { conversationId },
23409
+ {},
23410
+ "Conversation work active-lease nudge failed; heartbeat will recover"
23411
+ );
23412
+ }
23320
23413
  logInfo(
23321
23414
  "conversation_work_nudge_deferred_for_active_lease",
23322
23415
  { conversationId },
@@ -23470,35 +23563,97 @@ async function processConversationWork(message, options) {
23470
23563
  return { status: "completed" };
23471
23564
  } catch (error) {
23472
23565
  const errorNowMs = now2(options);
23566
+ let failure2;
23473
23567
  try {
23474
- const continuationMarked = await requestConversationContinuation({
23568
+ failure2 = await recordConversationWorkFailure({
23475
23569
  conversationId,
23476
- destination,
23477
- leaseToken: lease.leaseToken,
23478
23570
  nowMs: errorNowMs,
23479
23571
  state: options.state
23480
23572
  });
23481
- if (continuationMarked) {
23482
- await sendWakeNudge({
23573
+ } catch (recordError) {
23574
+ logException(
23575
+ recordError,
23576
+ "conversation_work_failure_record_failed",
23577
+ { conversationId },
23578
+ {},
23579
+ "Conversation work failure counter update failed"
23580
+ );
23581
+ }
23582
+ if (!isProviderRetryError(error)) {
23583
+ logException(
23584
+ error,
23585
+ "conversation_work_failed",
23586
+ { conversationId },
23587
+ {
23588
+ "app.worker.consecutive_failure_count": failure2?.consecutiveFailureCount ?? null,
23589
+ "app.worker.elapsed_ms": now2(options) - startedAtMs
23590
+ },
23591
+ "Conversation work failed"
23592
+ );
23593
+ }
23594
+ if (failure2?.abandoned) {
23595
+ logWarn(
23596
+ "conversation_work_abandoned",
23597
+ { conversationId },
23598
+ {
23599
+ "app.worker.consecutive_failure_count": failure2.consecutiveFailureCount,
23600
+ "app.worker.max_consecutive_failures": CONVERSATION_WORK_MAX_CONSECUTIVE_FAILURES
23601
+ },
23602
+ "Conversation work abandoned after repeated failures; stopping retries"
23603
+ );
23604
+ if (!failure2.releasedLease) {
23605
+ try {
23606
+ await releaseConversationWork({
23607
+ conversationId,
23608
+ leaseToken: lease.leaseToken,
23609
+ nowMs: errorNowMs,
23610
+ state: options.state
23611
+ });
23612
+ } catch (releaseError) {
23613
+ logException(
23614
+ releaseError,
23615
+ "conversation_work_release_failed",
23616
+ { conversationId },
23617
+ {},
23618
+ "Conversation work release failed after abandoning"
23619
+ );
23620
+ }
23621
+ }
23622
+ return { status: "abandoned" };
23623
+ }
23624
+ let requeueSucceeded = false;
23625
+ if (failure2) {
23626
+ try {
23627
+ const continuationMarked = await requestConversationContinuation({
23483
23628
  conversationId,
23484
23629
  destination,
23485
- idempotencyKey: nudgeIdempotencyKey(
23486
- "error",
23487
- conversationId,
23488
- errorNowMs
23489
- ),
23630
+ leaseToken: lease.leaseToken,
23490
23631
  nowMs: errorNowMs,
23491
- options
23632
+ state: options.state
23492
23633
  });
23634
+ if (continuationMarked) {
23635
+ await sendWakeNudge({
23636
+ conversationId,
23637
+ destination,
23638
+ idempotencyKey: nudgeIdempotencyKey(
23639
+ "error",
23640
+ conversationId,
23641
+ errorNowMs
23642
+ ),
23643
+ nowMs: errorNowMs,
23644
+ options
23645
+ });
23646
+ requeueSucceeded = true;
23647
+ }
23648
+ } catch (requeueError) {
23649
+ logException(
23650
+ requeueError,
23651
+ "conversation_work_requeue_failed",
23652
+ { conversationId },
23653
+ {},
23654
+ "Conversation work requeue failed after runner error"
23655
+ );
23493
23656
  }
23494
- } catch (requeueError) {
23495
- logException(
23496
- requeueError,
23497
- "conversation_work_requeue_failed",
23498
- { conversationId },
23499
- {},
23500
- "Conversation work requeue failed after runner error"
23501
- );
23502
23657
  }
23503
23658
  try {
23504
23659
  await releaseConversationWork({
@@ -23516,16 +23671,8 @@ async function processConversationWork(message, options) {
23516
23671
  "Conversation work release failed after runner error"
23517
23672
  );
23518
23673
  }
23519
- if (!isProviderRetryError(error)) {
23520
- logException(
23521
- error,
23522
- "conversation_work_failed",
23523
- { conversationId },
23524
- {
23525
- "app.worker.elapsed_ms": now2(options) - startedAtMs
23526
- },
23527
- "Conversation work failed"
23528
- );
23674
+ if (requeueSucceeded) {
23675
+ return { status: "pending_requeued" };
23529
23676
  }
23530
23677
  throw error;
23531
23678
  } finally {
@@ -4,6 +4,7 @@ import type { ConversationWorkQueue } from "./queue";
4
4
  export declare const CONVERSATION_WORK_LEASE_TTL_MS = 90000;
5
5
  export declare const CONVERSATION_WORK_CHECK_IN_INTERVAL_MS = 15000;
6
6
  export declare const CONVERSATION_WORK_STALE_ENQUEUE_MS = 60000;
7
+ export declare const CONVERSATION_WORK_MAX_CONSECUTIVE_FAILURES = 5;
7
8
  export type InboundMessageSource = "plugin" | "scheduler" | "slack";
8
9
  export interface AgentInputMessage {
9
10
  attachments?: unknown[];
@@ -28,13 +29,16 @@ export interface ConversationLease {
28
29
  leaseToken: string;
29
30
  }
30
31
  export interface ConversationWorkState {
32
+ consecutiveFailureCount: number;
31
33
  conversationId: string;
32
34
  destination: Destination;
33
35
  lastEnqueuedAtMs?: number;
36
+ lastFailureAtMs?: number;
34
37
  lease?: ConversationLease;
35
38
  messages: InboundMessageRecord[];
36
39
  needsRun: boolean;
37
40
  schemaVersion: 1;
41
+ terminallyFailedAtMs?: number;
38
42
  updatedAtMs: number;
39
43
  }
40
44
  export interface ConversationLeaseAcquired {
@@ -151,6 +155,25 @@ export declare function clearExpiredConversationLease(args: {
151
155
  nowMs?: number;
152
156
  state?: StateAdapter;
153
157
  }): Promise<boolean>;
158
+ export interface RecordConversationWorkFailureResult {
159
+ abandoned: boolean;
160
+ consecutiveFailureCount: number;
161
+ releasedLease: boolean;
162
+ }
163
+ /**
164
+ * Increment the durable failure counter after a caught worker error so
165
+ * deterministic poison work cannot churn the queue forever. When the counter
166
+ * crosses {@link CONVERSATION_WORK_MAX_CONSECUTIVE_FAILURES}, the conversation
167
+ * is marked terminally failed: the lease is cleared, pending mailbox messages
168
+ * are dropped, and the conversation drops out of the recovery index so neither
169
+ * the worker nor heartbeat will requeue it again. A later inbound message
170
+ * resets the counter and gives the conversation a fresh attempt.
171
+ */
172
+ export declare function recordConversationWorkFailure(args: {
173
+ conversationId: string;
174
+ nowMs?: number;
175
+ state?: StateAdapter;
176
+ }): Promise<RecordConversationWorkFailureResult>;
154
177
  /** List bounded conversation ids that may need heartbeat recovery. */
155
178
  export declare function listConversationWorkIds(args?: {
156
179
  limit?: number;
@@ -16,7 +16,7 @@ export interface ConversationWorkerResult {
16
16
  status: "completed" | "lost_lease" | "yielded";
17
17
  }
18
18
  export interface ConversationWorkProcessResult {
19
- status: "active" | "completed" | "lost_lease" | "no_work" | "pending_requeued" | "yielded";
19
+ status: "abandoned" | "active" | "completed" | "lost_lease" | "no_work" | "pending_requeued" | "yielded";
20
20
  }
21
21
  export interface ProcessConversationWorkOptions {
22
22
  checkInIntervalMs?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentry/junior",
3
- "version": "0.70.0",
3
+ "version": "0.71.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -65,7 +65,7 @@
65
65
  "node-html-markdown": "^2.0.0",
66
66
  "yaml": "^2.9.0",
67
67
  "zod": "^4.4.3",
68
- "@sentry/junior-plugin-api": "0.70.0"
68
+ "@sentry/junior-plugin-api": "0.71.0"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@types/node": "^25.9.1",
@@ -78,7 +78,7 @@
78
78
  "typescript": "^6.0.3",
79
79
  "vercel": "^54.4.0",
80
80
  "vitest": "^4.1.7",
81
- "@sentry/junior-scheduler": "0.70.0"
81
+ "@sentry/junior-scheduler": "0.71.0"
82
82
  },
83
83
  "scripts": {
84
84
  "build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly",