@sentry/junior 0.69.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
@@ -19,6 +19,7 @@ import {
19
19
  getAgentPlugins,
20
20
  getAgentTurnSessionRecord,
21
21
  getInterruptionMarker,
22
+ initConversationContext,
22
23
  listAgentTurnSessionSummaries,
23
24
  listAgentTurnSessionSummariesForConversation,
24
25
  loadConnectedMcpProviders,
@@ -32,12 +33,13 @@ import {
32
33
  resolveSlackChannelTypeFromMessage,
33
34
  resolveSlackConversationContext,
34
35
  setAgentPlugins,
36
+ setConversationTitle,
35
37
  splitSlackReplyText,
36
38
  truncateStatusText,
37
39
  upsertAgentTurnSessionRecord,
38
40
  validateAgentPlugins,
39
41
  verifySlackDirectCredentialSubject
40
- } from "./chunk-N3MORKTH.js";
42
+ } from "./chunk-HOGQL2H6.js";
41
43
  import {
42
44
  discoverSkills,
43
45
  findSkillByName,
@@ -6545,6 +6547,7 @@ import {
6545
6547
  agentPluginProviderAccountSchema
6546
6548
  } from "@sentry/junior-plugin-api";
6547
6549
  var finiteNumberSchema = z.number().refine(Number.isFinite);
6550
+ var httpStatusSchema = z.number().int().min(100).max(599);
6548
6551
  var providerNameSchema = z.string().regex(/^[a-z][a-z0-9-]*$/);
6549
6552
  var sandboxEgressGrantSchema = agentPluginGrantSchema;
6550
6553
  var sandboxEgressCredentialContextSchema = z.object({
@@ -6584,7 +6587,7 @@ var sandboxEgressPermissionDeniedSignalSchema = z.object({
6584
6587
  provider: providerNameSchema,
6585
6588
  source: z.literal("upstream"),
6586
6589
  sso: z.string().optional(),
6587
- status: z.literal(403),
6590
+ status: httpStatusSchema,
6588
6591
  upstreamHost: z.string().min(1),
6589
6592
  upstreamPath: z.string().min(1),
6590
6593
  createdAtMs: finiteNumberSchema
@@ -8482,6 +8485,10 @@ function stringField(record, key) {
8482
8485
  const value = record[key];
8483
8486
  return typeof value === "string" ? value : "";
8484
8487
  }
8488
+ function numberField(record, key) {
8489
+ const value = record[key];
8490
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
8491
+ }
8485
8492
  function stringListField(record, key) {
8486
8493
  const value = record[key];
8487
8494
  return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
@@ -8502,14 +8509,15 @@ function upstreamPermissionDeniedText(value) {
8502
8509
  return void 0;
8503
8510
  }
8504
8511
  const signal = value.permission_denied;
8505
- if (signal.source !== "upstream" || signal.status !== 403) {
8512
+ if (signal.source !== "upstream") {
8506
8513
  return void 0;
8507
8514
  }
8508
8515
  const provider = stringField(signal, "provider");
8509
8516
  const message = stringField(signal, "message");
8510
8517
  const upstreamHost = stringField(signal, "upstreamHost");
8511
8518
  const upstreamPath2 = stringField(signal, "upstreamPath");
8512
- if (!provider || !message || !upstreamHost || !upstreamPath2) {
8519
+ const status = numberField(signal, "status");
8520
+ if (!provider || !message || !upstreamHost || !upstreamPath2 || !status) {
8513
8521
  return void 0;
8514
8522
  }
8515
8523
  const grant = isRecord3(signal.grant) ? signal.grant : {};
@@ -8535,7 +8543,7 @@ function upstreamPermissionDeniedText(value) {
8535
8543
  ...grantRequirements.map((item) => `- ${item}`)
8536
8544
  ] : [],
8537
8545
  `Upstream: ${upstreamHost}${upstreamPath2}`,
8538
- "Status: 403",
8546
+ `Status: ${status}`,
8539
8547
  ...acceptedPermissions ? [`Accepted provider permissions: ${acceptedPermissions}`] : [],
8540
8548
  ...sso ? [`Provider SSO: ${sso}`] : [],
8541
8549
  ...command ? [`Command: ${command}`] : [],
@@ -8953,12 +8961,41 @@ async function selectPluginGrant(input) {
8953
8961
  plugin: { name: plugin.name },
8954
8962
  log: createAgentPluginLogger(plugin.name),
8955
8963
  request: {
8964
+ ...input.bodyText !== void 0 ? { bodyText: input.bodyText } : {},
8956
8965
  method: input.method,
8957
8966
  url: input.upstreamUrl.toString()
8958
8967
  }
8959
8968
  });
8960
8969
  return result === void 0 ? void 0 : parseGrant(result, plugin.name);
8961
8970
  }
8971
+ async function onPluginEgressResponse(input) {
8972
+ const plugin = agentPluginFor(input.provider);
8973
+ const hook = plugin?.hooks?.onEgressResponse;
8974
+ if (!plugin || !hook) {
8975
+ return {};
8976
+ }
8977
+ let permissionDenied;
8978
+ await hook({
8979
+ plugin: { name: plugin.name },
8980
+ log: createAgentPluginLogger(plugin.name),
8981
+ grant: input.grant,
8982
+ permissionDenied(message) {
8983
+ const trimmed = message.trim();
8984
+ if (!trimmed) {
8985
+ throw new Error(
8986
+ `Plugin "${plugin.name}" onEgressResponse permissionDenied message is empty`
8987
+ );
8988
+ }
8989
+ permissionDenied = { message: trimmed };
8990
+ },
8991
+ request: {
8992
+ method: input.method,
8993
+ url: input.upstreamUrl.toString()
8994
+ },
8995
+ response: input.response
8996
+ });
8997
+ return permissionDenied ? { permissionDenied } : {};
8998
+ }
8962
8999
  function hasEgressCredentialHooks(provider) {
8963
9000
  const hooks = agentPluginFor(provider)?.hooks;
8964
9001
  return Boolean(hooks?.grantForEgress || hooks?.issueCredential);
@@ -13914,6 +13951,7 @@ var CONVERSATION_WORK_MUTATION_RETRY_MS = 25;
13914
13951
  var CONVERSATION_WORK_LEASE_TTL_MS = 9e4;
13915
13952
  var CONVERSATION_WORK_CHECK_IN_INTERVAL_MS = 15e3;
13916
13953
  var CONVERSATION_WORK_STALE_ENQUEUE_MS = 6e4;
13954
+ var CONVERSATION_WORK_MAX_CONSECUTIVE_FAILURES = 5;
13917
13955
  function duplicateInboundNudgeIdempotencyKey(message, nowMs) {
13918
13956
  return `duplicate:${message.conversationId}:${message.inboundMessageId}:${nowMs}`;
13919
13957
  }
@@ -14035,14 +14073,18 @@ function normalizeWorkState(conversationId, value) {
14035
14073
  messages,
14036
14074
  needsRun: value.needsRun === true,
14037
14075
  updatedAtMs,
14076
+ consecutiveFailureCount: toOptionalNumber(value.consecutiveFailureCount) ?? 0,
14038
14077
  lastEnqueuedAtMs: toOptionalNumber(value.lastEnqueuedAtMs),
14039
- lease: normalizeLease(value.lease)
14078
+ lastFailureAtMs: toOptionalNumber(value.lastFailureAtMs),
14079
+ lease: normalizeLease(value.lease),
14080
+ terminallyFailedAtMs: toOptionalNumber(value.terminallyFailedAtMs)
14040
14081
  };
14041
14082
  }
14042
14083
  function emptyWorkState(args) {
14043
14084
  return {
14044
14085
  schemaVersion: CONVERSATION_WORK_SCHEMA_VERSION,
14045
14086
  conversationId: args.conversationId,
14087
+ consecutiveFailureCount: 0,
14046
14088
  destination: args.destination,
14047
14089
  messages: [],
14048
14090
  needsRun: false,
@@ -14056,6 +14098,9 @@ function pendingMessages(state) {
14056
14098
  return state.messages.filter((message) => message.injectedAtMs === void 0).sort(compareMessages);
14057
14099
  }
14058
14100
  function shouldKeepIndexed(state) {
14101
+ if (state.terminallyFailedAtMs !== void 0) {
14102
+ return false;
14103
+ }
14059
14104
  return state.needsRun || Boolean(state.lease) || pendingMessages(state).length > 0;
14060
14105
  }
14061
14106
  async function getConnectedState(stateAdapter) {
@@ -14180,6 +14225,9 @@ async function writeWorkState(state, work) {
14180
14225
  }
14181
14226
  }
14182
14227
  function hasRunnableWork(state) {
14228
+ if (state.terminallyFailedAtMs !== void 0) {
14229
+ return false;
14230
+ }
14183
14231
  return state.needsRun || pendingMessages(state).length > 0;
14184
14232
  }
14185
14233
  function assertSameConversationDestination(args) {
@@ -14229,8 +14277,11 @@ async function appendInboundMessage(args) {
14229
14277
  }
14230
14278
  const next = {
14231
14279
  ...current,
14280
+ consecutiveFailureCount: 0,
14281
+ lastFailureAtMs: void 0,
14232
14282
  messages: [...current.messages, args.message].sort(compareMessages),
14233
14283
  needsRun: true,
14284
+ terminallyFailedAtMs: void 0,
14234
14285
  updatedAtMs: nowMs
14235
14286
  };
14236
14287
  await writeWorkState(state, next);
@@ -14398,6 +14449,8 @@ async function drainConversationMailbox(args) {
14398
14449
  );
14399
14450
  await writeWorkState(state, {
14400
14451
  ...current,
14452
+ consecutiveFailureCount: 0,
14453
+ lastFailureAtMs: void 0,
14401
14454
  messages,
14402
14455
  needsRun: hasPending,
14403
14456
  updatedAtMs: nowMs
@@ -14429,6 +14482,8 @@ async function markConversationMessagesInjected(args) {
14429
14482
  }
14430
14483
  await writeWorkState(state, {
14431
14484
  ...current,
14485
+ consecutiveFailureCount: 0,
14486
+ lastFailureAtMs: void 0,
14432
14487
  messages,
14433
14488
  updatedAtMs: nowMs
14434
14489
  });
@@ -14481,6 +14536,8 @@ async function completeConversationWork(args) {
14481
14536
  const hasRunnableWork2 = current.needsRun || hasPending;
14482
14537
  await writeWorkState(state, {
14483
14538
  ...current,
14539
+ consecutiveFailureCount: 0,
14540
+ lastFailureAtMs: void 0,
14484
14541
  lease: void 0,
14485
14542
  needsRun: hasRunnableWork2,
14486
14543
  updatedAtMs: nowMs
@@ -14504,6 +14561,53 @@ async function clearExpiredConversationLease(args) {
14504
14561
  return true;
14505
14562
  });
14506
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
+ }
14507
14611
  async function listConversationWorkIds(args = {}) {
14508
14612
  const state = await getConnectedState(args.state);
14509
14613
  const ids = uniqueStrings(await state.get(indexKey()) ?? []);
@@ -17426,6 +17530,7 @@ async function selectSandboxEgressGrant(input) {
17426
17530
  return defaultGrantForProvider(input);
17427
17531
  }
17428
17532
  const pluginGrant = await selectPluginGrant({
17533
+ ...input.bodyText !== void 0 ? { bodyText: input.bodyText } : {},
17429
17534
  provider: input.provider,
17430
17535
  method: input.method,
17431
17536
  upstreamUrl: input.upstreamUrl
@@ -17587,6 +17692,7 @@ async function verifyVercelSandboxOidcToken(token) {
17587
17692
  }
17588
17693
 
17589
17694
  // src/chat/sandbox/egress-proxy.ts
17695
+ import { EgressAuthRequired } from "@sentry/junior-plugin-api";
17590
17696
  var OIDC_TOKEN_HEADER = "vercel-sandbox-oidc-token";
17591
17697
  var FORWARDED_HOST_HEADER = "vercel-forwarded-host";
17592
17698
  var FORWARDED_SCHEME_HEADER = "vercel-forwarded-scheme";
@@ -17616,6 +17722,8 @@ var DECODED_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
17616
17722
  ]);
17617
17723
  var UPSTREAM_TOKEN_REJECTION_STATUS = 401;
17618
17724
  var UPSTREAM_PERMISSION_REJECTION_STATUS = 403;
17725
+ var GRANT_SELECTION_BODY_TEXT_LIMIT_BYTES = 64 * 1024;
17726
+ var RESPONSE_BODY_TEXT_LIMIT_BYTES = 64 * 1024;
17619
17727
  function jsonError(message, status) {
17620
17728
  return Response.json({ error: message }, { status });
17621
17729
  }
@@ -17714,6 +17822,9 @@ function githubPermissionHeaders(upstream) {
17714
17822
  function permissionDeniedMessage(provider, grant) {
17715
17823
  return `${provider} returned HTTP 403 after Junior injected the ${grant.name} grant. Junior forwarded the request; this is not a local runtime block.`;
17716
17824
  }
17825
+ function isEgressAuthRequired(error) {
17826
+ return error instanceof EgressAuthRequired || error instanceof Error && error.name === "EgressAuthRequired";
17827
+ }
17717
17828
  function logSandboxEgressUpstreamRequest(input) {
17718
17829
  if (!shouldLogSandboxEgressInfo()) {
17719
17830
  return;
@@ -17822,6 +17933,78 @@ async function requestBodyBytes(request) {
17822
17933
  }
17823
17934
  return await request.arrayBuffer();
17824
17935
  }
17936
+ function isGrantSelectionBodyVisible(input) {
17937
+ return input.provider === "github" && input.upstreamUrl.hostname.toLowerCase() === "api.github.com" && input.upstreamUrl.pathname.toLowerCase().endsWith("/graphql");
17938
+ }
17939
+ function requestBodyText(body) {
17940
+ if (body === void 0 || body.byteLength > GRANT_SELECTION_BODY_TEXT_LIMIT_BYTES) {
17941
+ return void 0;
17942
+ }
17943
+ return new TextDecoder().decode(body);
17944
+ }
17945
+ function responseContentLength(upstream) {
17946
+ const raw = upstream.headers.get("content-length");
17947
+ if (!raw) {
17948
+ return void 0;
17949
+ }
17950
+ const parsed = Number(raw);
17951
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : void 0;
17952
+ }
17953
+ async function responseTextWithinLimit(upstream, maxBytes) {
17954
+ const limit = Math.min(
17955
+ Math.max(0, Math.floor(maxBytes)),
17956
+ RESPONSE_BODY_TEXT_LIMIT_BYTES
17957
+ );
17958
+ if (limit <= 0) {
17959
+ return void 0;
17960
+ }
17961
+ const contentLength = responseContentLength(upstream);
17962
+ if (contentLength !== void 0 && contentLength > limit) {
17963
+ return void 0;
17964
+ }
17965
+ let clone;
17966
+ try {
17967
+ clone = upstream.clone();
17968
+ } catch {
17969
+ return void 0;
17970
+ }
17971
+ const body = clone.body;
17972
+ if (!body) {
17973
+ return "";
17974
+ }
17975
+ const reader = body.getReader();
17976
+ const chunks = [];
17977
+ let bytes = 0;
17978
+ try {
17979
+ while (true) {
17980
+ const { done, value } = await reader.read();
17981
+ if (done) {
17982
+ break;
17983
+ }
17984
+ if (!value) {
17985
+ continue;
17986
+ }
17987
+ bytes += value.byteLength;
17988
+ if (bytes > limit) {
17989
+ await reader.cancel().catch(() => void 0);
17990
+ return void 0;
17991
+ }
17992
+ chunks.push(value);
17993
+ }
17994
+ } catch {
17995
+ await reader.cancel().catch(() => void 0);
17996
+ return void 0;
17997
+ } finally {
17998
+ reader.releaseLock();
17999
+ }
18000
+ const combined = new Uint8Array(bytes);
18001
+ let offset = 0;
18002
+ for (const chunk of chunks) {
18003
+ combined.set(chunk, offset);
18004
+ offset += chunk.byteLength;
18005
+ }
18006
+ return new TextDecoder().decode(combined);
18007
+ }
17825
18008
  function requestHeaders(request, lease, upstreamHost) {
17826
18009
  const headers = new Headers();
17827
18010
  request.headers.forEach((value, key) => {
@@ -17956,7 +18139,14 @@ async function proxySandboxEgressRequest(request, deps = {}) {
17956
18139
  403
17957
18140
  );
17958
18141
  }
18142
+ let body;
18143
+ let bodyRead = false;
18144
+ if (isGrantSelectionBodyVisible({ provider, upstreamUrl })) {
18145
+ body = await requestBodyBytes(request);
18146
+ bodyRead = true;
18147
+ }
17959
18148
  const grantSelection = await selectSandboxEgressGrant({
18149
+ bodyText: requestBodyText(body),
17960
18150
  provider,
17961
18151
  method: request.method,
17962
18152
  upstreamUrl
@@ -18067,7 +18257,9 @@ async function proxySandboxEgressRequest(request, deps = {}) {
18067
18257
  }
18068
18258
  const fetchImpl = deps.fetch ?? fetch;
18069
18259
  const headers = requestHeaders(request, lease, upstreamUrl.hostname);
18070
- const body = await requestBodyBytes(request);
18260
+ if (!bodyRead) {
18261
+ body = await requestBodyBytes(request);
18262
+ }
18071
18263
  const intercepted = await deps.interceptHttp?.({
18072
18264
  provider,
18073
18265
  request: new Request(upstreamUrl, {
@@ -18086,6 +18278,93 @@ async function proxySandboxEgressRequest(request, deps = {}) {
18086
18278
  ...body !== void 0 ? { body } : {},
18087
18279
  redirect: "manual"
18088
18280
  });
18281
+ try {
18282
+ const effects = await onPluginEgressResponse({
18283
+ provider,
18284
+ grant: lease.grant,
18285
+ method: request.method,
18286
+ upstreamUrl,
18287
+ response: {
18288
+ headers: new Headers(upstream.headers),
18289
+ readText: async (maxBytes) => await responseTextWithinLimit(upstream, maxBytes),
18290
+ status: upstream.status
18291
+ }
18292
+ });
18293
+ if (effects.permissionDenied) {
18294
+ await setSandboxEgressPermissionDeniedSignal(credentialContext, {
18295
+ provider,
18296
+ grant: lease.grant,
18297
+ ...lease.account ? { account: lease.account } : {},
18298
+ message: effects.permissionDenied.message,
18299
+ source: "upstream",
18300
+ status: upstream.status,
18301
+ upstreamHost: upstreamUrl.hostname,
18302
+ upstreamPath: displayedUpstreamPath(upstreamUrl),
18303
+ ...provider === "github" ? githubPermissionHeaders(upstream) : {}
18304
+ });
18305
+ logWarn(
18306
+ "sandbox_egress_upstream_permission_classified",
18307
+ {},
18308
+ {
18309
+ ...egressAttributes({
18310
+ egressId: activeEgressId,
18311
+ grantAccess: lease.grant.access,
18312
+ grantName: lease.grant.name,
18313
+ grantReason: lease.grant.reason,
18314
+ host: upstreamUrl.hostname,
18315
+ method: request.method,
18316
+ path: upstreamUrl.pathname,
18317
+ provider,
18318
+ status: upstream.status
18319
+ }),
18320
+ ...routingAttributes(request, upstreamUrl),
18321
+ ...upstreamPermissionAttributes(provider, upstream)
18322
+ },
18323
+ "Sandbox egress plugin classified upstream response as permission denied"
18324
+ );
18325
+ }
18326
+ } catch (error) {
18327
+ if (!isEgressAuthRequired(error)) {
18328
+ throw error;
18329
+ }
18330
+ await clearSandboxEgressCredentialLease(
18331
+ provider,
18332
+ lease.grant.name,
18333
+ credentialContext
18334
+ );
18335
+ await setSandboxEgressAuthRequiredSignal(credentialContext, {
18336
+ provider,
18337
+ grant: lease.grant,
18338
+ ...error.authorization ?? lease.authorization ? { authorization: error.authorization ?? lease.authorization } : {},
18339
+ message: error.message
18340
+ });
18341
+ logWarn(
18342
+ "sandbox_egress_upstream_auth_required_classified",
18343
+ {},
18344
+ {
18345
+ ...egressAttributes({
18346
+ egressId: activeEgressId,
18347
+ grantAccess: lease.grant.access,
18348
+ grantName: lease.grant.name,
18349
+ grantReason: lease.grant.reason,
18350
+ host: upstreamUrl.hostname,
18351
+ method: request.method,
18352
+ path: upstreamUrl.pathname,
18353
+ provider,
18354
+ status: upstream.status
18355
+ }),
18356
+ ...routingAttributes(request, upstreamUrl),
18357
+ ...upstreamPermissionAttributes(provider, upstream)
18358
+ },
18359
+ "Sandbox egress plugin classified upstream response as auth required"
18360
+ );
18361
+ await upstream.body?.cancel().catch(() => void 0);
18362
+ return authRequiredResponse({
18363
+ provider,
18364
+ grant: lease.grant,
18365
+ message: error.message
18366
+ });
18367
+ }
18089
18368
  logSandboxEgressUpstreamRequest({
18090
18369
  egressId: activeEgressId,
18091
18370
  grantAccess: lease.grant.access,
@@ -19034,6 +19313,7 @@ function createSlackTurnRuntime(deps) {
19034
19313
  text: args.text
19035
19314
  });
19036
19315
  }
19316
+ await args.onInputCommitted?.();
19037
19317
  };
19038
19318
  return {
19039
19319
  async handleNewMention(thread, message, hooks) {
@@ -19222,6 +19502,7 @@ function createSlackTurnRuntime(deps) {
19222
19502
  message,
19223
19503
  decision: { shouldReply: false, reason },
19224
19504
  context: threadContext,
19505
+ onInputCommitted: hooks.onInputCommitted,
19225
19506
  text: combinedText
19226
19507
  });
19227
19508
  return;
@@ -19258,6 +19539,7 @@ function createSlackTurnRuntime(deps) {
19258
19539
  message,
19259
19540
  decision,
19260
19541
  context: threadContext,
19542
+ onInputCommitted: hooks.onInputCommitted,
19261
19543
  preparedState,
19262
19544
  text: combinedText
19263
19545
  });
@@ -19269,6 +19551,7 @@ function createSlackTurnRuntime(deps) {
19269
19551
  message,
19270
19552
  decision,
19271
19553
  context: threadContext,
19554
+ onInputCommitted: hooks.onInputCommitted,
19272
19555
  preparedState,
19273
19556
  text: combinedText
19274
19557
  });
@@ -20694,12 +20977,13 @@ function createReplyToThread(deps) {
20694
20977
  updateConversationStats
20695
20978
  });
20696
20979
  if (conversationId) {
20980
+ const turnStartedAtMs = message.metadata.dateSent.getTime();
20697
20981
  void recordAgentTurnSessionSummary({
20698
20982
  channelName,
20699
20983
  conversationId,
20700
20984
  sessionId: turnId,
20701
20985
  sliceId: 1,
20702
- startedAtMs: message.metadata.dateSent.getTime(),
20986
+ startedAtMs: turnStartedAtMs,
20703
20987
  state: "running",
20704
20988
  surface: "slack",
20705
20989
  requester,
@@ -20710,12 +20994,41 @@ function createReplyToThread(deps) {
20710
20994
  error,
20711
20995
  "agent_turn_summary_record_failed",
20712
20996
  turnTraceContext,
20713
- {
20714
- "app.agent.turn.state": "running"
20715
- },
20997
+ { "app.agent.turn.state": "running" },
20716
20998
  "Failed to record running turn summary"
20717
20999
  );
20718
21000
  });
21001
+ void initConversationContext(conversationId, {
21002
+ channelName,
21003
+ originSurface: "slack",
21004
+ originRequester: requester,
21005
+ startedAtMs: turnStartedAtMs
21006
+ }).catch((error) => {
21007
+ logException(
21008
+ error,
21009
+ "conversation_details_context_init_failed",
21010
+ turnTraceContext,
21011
+ { "app.agent.turn.state": "running" },
21012
+ "Failed to init conversation context at turn start"
21013
+ );
21014
+ });
21015
+ const existingAssistantTitle = preparedState.artifacts.assistantTitle?.trim();
21016
+ if (existingAssistantTitle) {
21017
+ void setConversationTitle(conversationId, {
21018
+ displayTitle: existingAssistantTitle,
21019
+ ...preparedState.artifacts.assistantTitleSourceMessageId ? {
21020
+ titleSourceMessageId: preparedState.artifacts.assistantTitleSourceMessageId
21021
+ } : {}
21022
+ }).catch((error) => {
21023
+ logException(
21024
+ error,
21025
+ "conversation_details_title_refresh_failed",
21026
+ turnTraceContext,
21027
+ { "app.agent.turn.state": "running" },
21028
+ "Failed to refresh conversation title from artifacts"
21029
+ );
21030
+ });
21031
+ }
20719
21032
  }
20720
21033
  setTags({
20721
21034
  conversationId
@@ -20791,6 +21104,7 @@ function createReplyToThread(deps) {
20791
21104
  let persistedAtLeastOnce = false;
20792
21105
  let shouldPersistFailureState = true;
20793
21106
  let latestArtifacts = preparedState.artifacts;
21107
+ let assistantTitleArtifacts = {};
20794
21108
  try {
20795
21109
  const loadedPiMessages = await loadPiMessagesForTurn({
20796
21110
  conversationId,
@@ -20833,6 +21147,54 @@ function createReplyToThread(deps) {
20833
21147
  runId,
20834
21148
  threadId
20835
21149
  });
21150
+ void assistantTitleTask.then(async (titleUpdateResult) => {
21151
+ if (!titleUpdateResult) return;
21152
+ assistantTitleArtifacts = {
21153
+ assistantTitleSourceMessageId: titleUpdateResult.sourceMessageId,
21154
+ ...titleUpdateResult.title ? { assistantTitle: titleUpdateResult.title } : {}
21155
+ };
21156
+ latestArtifacts = {
21157
+ ...latestArtifacts,
21158
+ ...assistantTitleArtifacts
21159
+ };
21160
+ if (conversationId && titleUpdateResult.title) {
21161
+ try {
21162
+ await setConversationTitle(conversationId, {
21163
+ displayTitle: titleUpdateResult.title,
21164
+ titleSourceMessageId: titleUpdateResult.sourceMessageId
21165
+ });
21166
+ } catch (error) {
21167
+ logException(
21168
+ error,
21169
+ "conversation_details_title_set_failed",
21170
+ turnTraceContext,
21171
+ {},
21172
+ "Failed to set conversation title in details record"
21173
+ );
21174
+ }
21175
+ }
21176
+ try {
21177
+ await persistThreadState(thread, {
21178
+ artifacts: latestArtifacts
21179
+ });
21180
+ } catch (error) {
21181
+ logException(
21182
+ error,
21183
+ "assistant_title_artifact_persist_failed",
21184
+ turnTraceContext,
21185
+ {},
21186
+ "Failed to persist async assistant title artifact state"
21187
+ );
21188
+ }
21189
+ }).catch((error) => {
21190
+ logException(
21191
+ error,
21192
+ "assistant_title_task_failed",
21193
+ turnTraceContext,
21194
+ {},
21195
+ "Async assistant title task failed"
21196
+ );
21197
+ });
20836
21198
  const toolChannelId = preparedState.artifacts.assistantContextChannelId ?? channelId;
20837
21199
  const resolveSteeringMessages = async (queuedMessages) => {
20838
21200
  return await Promise.all(
@@ -20911,8 +21273,13 @@ function createReplyToThread(deps) {
20911
21273
  });
20912
21274
  },
20913
21275
  onArtifactStateUpdated: async (artifacts) => {
20914
- latestArtifacts = artifacts;
20915
- await persistThreadState(thread, { artifacts });
21276
+ latestArtifacts = {
21277
+ ...artifacts,
21278
+ ...assistantTitleArtifacts
21279
+ };
21280
+ await persistThreadState(thread, {
21281
+ artifacts: latestArtifacts
21282
+ });
20916
21283
  },
20917
21284
  onAuthPending: async (pendingAuth) => {
20918
21285
  await applyPendingAuthUpdate({
@@ -21013,24 +21380,26 @@ function createReplyToThread(deps) {
21013
21380
  await sent.delete();
21014
21381
  }
21015
21382
  }
21016
- const titleUpdateResult = await assistantTitleTask;
21017
- if (titleUpdateResult) {
21018
- artifactStatePatch.assistantTitleSourceMessageId = titleUpdateResult.sourceMessageId;
21019
- if (titleUpdateResult.title) {
21020
- artifactStatePatch.assistantTitle = titleUpdateResult.title;
21021
- }
21022
- }
21023
21383
  const completedState = buildDeliveredTurnStatePatch({
21024
- artifactStatePatch,
21025
- artifacts: preparedState.artifacts,
21384
+ artifactStatePatch: {
21385
+ ...artifactStatePatch,
21386
+ ...assistantTitleArtifacts
21387
+ },
21388
+ artifacts: latestArtifacts,
21026
21389
  conversation: preparedState.conversation,
21027
21390
  reply,
21028
21391
  sessionId: turnId,
21029
21392
  userMessageId: preparedState.userMessageId
21030
21393
  });
21394
+ if (completedState.artifacts) {
21395
+ latestArtifacts = completedState.artifacts;
21396
+ }
21031
21397
  await persistThreadState(thread, {
21032
21398
  ...completedState
21033
21399
  });
21400
+ if (completedState.artifacts && (assistantTitleArtifacts.assistantTitle !== void 0 || assistantTitleArtifacts.assistantTitleSourceMessageId !== void 0) && (completedState.artifacts.assistantTitle !== assistantTitleArtifacts.assistantTitle || completedState.artifacts.assistantTitleSourceMessageId !== assistantTitleArtifacts.assistantTitleSourceMessageId)) {
21401
+ await persistThreadState(thread, { artifacts: latestArtifacts });
21402
+ }
21034
21403
  if (conversationId) {
21035
21404
  await recordAgentTurnSessionSummary({
21036
21405
  channelName,
@@ -21041,7 +21410,6 @@ function createReplyToThread(deps) {
21041
21410
  sliceId: 1,
21042
21411
  startedAtMs: message.metadata.dateSent.getTime(),
21043
21412
  state: "completed",
21044
- conversationTitle: titleUpdateResult?.title,
21045
21413
  requester,
21046
21414
  destination,
21047
21415
  traceId: getActiveTraceId()
@@ -22998,24 +23366,50 @@ async function processConversationWork(message, options) {
22998
23366
  );
22999
23367
  }
23000
23368
  const destination = initial.destination;
23001
- const lease = await startConversationWork({
23002
- conversationId,
23003
- nowMs: now2(options),
23004
- state: options.state
23005
- });
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
+ }
23006
23386
  if (lease.status === "no_work") {
23007
23387
  return { status: "no_work" };
23008
23388
  }
23009
23389
  if (lease.status === "active") {
23010
23390
  const nudgeNowMs = now2(options);
23011
- await sendWakeNudge({
23012
- conversationId,
23013
- destination,
23014
- delayMs: CONVERSATION_WORK_DEFER_DELAY_MS,
23015
- idempotencyKey: nudgeIdempotencyKey("active", conversationId, nudgeNowMs),
23016
- nowMs: nudgeNowMs,
23017
- options
23018
- });
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
+ }
23019
23413
  logInfo(
23020
23414
  "conversation_work_nudge_deferred_for_active_lease",
23021
23415
  { conversationId },
@@ -23169,35 +23563,97 @@ async function processConversationWork(message, options) {
23169
23563
  return { status: "completed" };
23170
23564
  } catch (error) {
23171
23565
  const errorNowMs = now2(options);
23566
+ let failure2;
23172
23567
  try {
23173
- const continuationMarked = await requestConversationContinuation({
23568
+ failure2 = await recordConversationWorkFailure({
23174
23569
  conversationId,
23175
- destination,
23176
- leaseToken: lease.leaseToken,
23177
23570
  nowMs: errorNowMs,
23178
23571
  state: options.state
23179
23572
  });
23180
- if (continuationMarked) {
23181
- 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({
23182
23628
  conversationId,
23183
23629
  destination,
23184
- idempotencyKey: nudgeIdempotencyKey(
23185
- "error",
23186
- conversationId,
23187
- errorNowMs
23188
- ),
23630
+ leaseToken: lease.leaseToken,
23189
23631
  nowMs: errorNowMs,
23190
- options
23632
+ state: options.state
23191
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
+ );
23192
23656
  }
23193
- } catch (requeueError) {
23194
- logException(
23195
- requeueError,
23196
- "conversation_work_requeue_failed",
23197
- { conversationId },
23198
- {},
23199
- "Conversation work requeue failed after runner error"
23200
- );
23201
23657
  }
23202
23658
  try {
23203
23659
  await releaseConversationWork({
@@ -23215,16 +23671,8 @@ async function processConversationWork(message, options) {
23215
23671
  "Conversation work release failed after runner error"
23216
23672
  );
23217
23673
  }
23218
- if (!isProviderRetryError(error)) {
23219
- logException(
23220
- error,
23221
- "conversation_work_failed",
23222
- { conversationId },
23223
- {
23224
- "app.worker.elapsed_ms": now2(options) - startedAtMs
23225
- },
23226
- "Conversation work failed"
23227
- );
23674
+ if (requeueSucceeded) {
23675
+ return { status: "pending_requeued" };
23228
23676
  }
23229
23677
  throw error;
23230
23678
  } finally {