@opengeni/worker-bundle 2.0.1 → 2.0.3-canary.36199476632001

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 (63) hide show
  1. package/dist/activities/agent-turn/agent-build.d.ts +2 -0
  2. package/dist/activities/agent-turn/code-search.d.ts +60 -0
  3. package/dist/activities/agent-turn/codex-capacity.d.ts +13 -0
  4. package/dist/activities/agent-turn/errors.d.ts +12 -1
  5. package/dist/activities/agent-turn/failure-settlement.d.ts +1 -1
  6. package/dist/activities/agent-turn/governance-model.d.ts +2 -0
  7. package/dist/activities/agent-turn/history.d.ts +2 -0
  8. package/dist/activities/agent-turn/media-artifacts.d.ts +5 -3
  9. package/dist/activities/agent-turn/session-title.d.ts +32 -2
  10. package/dist/activities/agent-turn/tool-environment.d.ts +4 -0
  11. package/dist/activities/context-compaction.d.ts +10 -2
  12. package/dist/activities/knowledge-indexing.d.ts +19 -0
  13. package/dist/activities/retained-screenshots.d.ts +7 -2
  14. package/dist/activities/sandbox-lease.d.ts +3 -4
  15. package/dist/{activities-control-QA6OLRQY.js → activities-control-I72CRMAE.js} +283 -35
  16. package/dist/activities-control-I72CRMAE.js.map +1 -0
  17. package/dist/{activities-turn-YVVKCGAK.js → activities-turn-CSDKK4N3.js} +531 -77
  18. package/dist/activities-turn-CSDKK4N3.js.map +1 -0
  19. package/dist/artifact-outbox-entry.js +3 -0
  20. package/dist/artifact-outbox-entry.js.map +1 -1
  21. package/dist/{chunk-M3NVMF2V.js → chunk-7M6G2SCF.js} +172 -17
  22. package/dist/chunk-7M6G2SCF.js.map +1 -0
  23. package/dist/{chunk-I7HKKMUJ.js → chunk-DSPP6CZL.js} +48 -3
  24. package/dist/chunk-DSPP6CZL.js.map +1 -0
  25. package/dist/index.js +10 -5
  26. package/dist/index.js.map +1 -1
  27. package/dist/observability-metrics.d.ts +11 -1
  28. package/dist/sandbox-resume.d.ts +46 -0
  29. package/dist/workflow-bundle.js +80 -2
  30. package/dist/workflows/session.d.ts +30 -0
  31. package/package.json +20 -19
  32. package/src/activities/agent-turn/agent-build.ts +4 -0
  33. package/src/activities/agent-turn/code-search.ts +275 -0
  34. package/src/activities/agent-turn/codex-capacity.ts +37 -2
  35. package/src/activities/agent-turn/compaction-prep.ts +52 -16
  36. package/src/activities/agent-turn/errors.ts +67 -4
  37. package/src/activities/agent-turn/failure-settlement.ts +40 -8
  38. package/src/activities/agent-turn/governance-model.ts +17 -1
  39. package/src/activities/agent-turn/history.ts +14 -1
  40. package/src/activities/agent-turn/media-artifacts.ts +2 -1
  41. package/src/activities/agent-turn/run.ts +4 -0
  42. package/src/activities/agent-turn/sandbox-establish.ts +12 -1
  43. package/src/activities/agent-turn/sandbox-runtime.ts +25 -9
  44. package/src/activities/agent-turn/session-title.ts +73 -3
  45. package/src/activities/agent-turn/stream-attempt.ts +27 -15
  46. package/src/activities/agent-turn/tool-environment.ts +65 -0
  47. package/src/activities/context-compaction.ts +49 -14
  48. package/src/activities/knowledge-indexing.ts +240 -15
  49. package/src/activities/retained-screenshots.ts +82 -11
  50. package/src/activities/sandbox-lease.ts +141 -21
  51. package/src/activities/scheduled-tasks.ts +18 -2
  52. package/src/activity-services.ts +16 -4
  53. package/src/editable-artifact-hint-broker.ts +3 -0
  54. package/src/index.ts +2 -2
  55. package/src/observability-metrics.ts +70 -1
  56. package/src/personal-github-git-credentials.ts +2 -0
  57. package/src/sandbox-resume.ts +211 -5
  58. package/src/workflows/activities.ts +14 -1
  59. package/src/workflows/session.ts +102 -6
  60. package/dist/activities-control-QA6OLRQY.js.map +0 -1
  61. package/dist/activities-turn-YVVKCGAK.js.map +0 -1
  62. package/dist/chunk-I7HKKMUJ.js.map +0 -1
  63. package/dist/chunk-M3NVMF2V.js.map +0 -1
@@ -24,6 +24,7 @@ import {
24
24
  effectiveSandboxLifecycle,
25
25
  sandboxArchiveCaptureTimeoutMs,
26
26
  sandboxLifecycleTransitionWaitMs,
27
+ sandboxWarmRateMicrosPerSecond,
27
28
  type Settings,
28
29
  } from "@opengeni/config";
29
30
  import { randomUUID } from "node:crypto";
@@ -80,6 +81,7 @@ import {
80
81
  withoutSandboxProviderIdentity,
81
82
  type EstablishedSandboxSession,
82
83
  type RuntimeMetricsHooks,
84
+ type SandboxReadinessReplacementOutcome,
83
85
  type WorkspaceArchiveDescriptor,
84
86
  } from "@opengeni/runtime";
85
87
  import {
@@ -149,6 +151,24 @@ export type SandboxResumeServices = {
149
151
  /** Test seam for the bounded command-readiness proof performed before an
150
152
  * attached provider box is handed to the agent. */
151
153
  verifyAttachedSandboxReadiness?: (established: EstablishedSandboxSession) => Promise<void>;
154
+ /** Test seam for the bounded command-readiness proof the elected spawner runs
155
+ * on its freshly created box before publishing the lease warm. Production
156
+ * uses {@link waitForSandboxExecReadiness} with the shared 60 s budget. */
157
+ verifySpawnedSandboxReadiness?: (
158
+ established: EstablishedSandboxSession,
159
+ identity: { sandboxGroupId: string },
160
+ ) => Promise<void>;
161
+ /** Test seam for the jittered pause before the single fresh-box readiness
162
+ * replacement. Production uses {@link freshSandboxReadinessReplacementDelayMs}.
163
+ * It may be async so a test can observe the rolled-back lease in between. */
164
+ freshSandboxReadinessReplacementDelayMs?: () => number | Promise<number>;
165
+ /**
166
+ * The turn attempt's fresh-box readiness replacement budget. The lazy
167
+ * provisioner may call resumeBoxForTurn again after a typed lease
168
+ * supersession, so the turn creates the budget once and shares it with every
169
+ * call. Absent, the call gets its own single replacement.
170
+ */
171
+ freshSandboxReadinessReplacementBudget?: FreshSandboxReadinessReplacementBudget;
152
172
  /** Called only by the observer that wins the exact warm->cold loss CAS. */
153
173
  onSandboxLost?: (input: {
154
174
  sandboxGroupId: string;
@@ -317,6 +337,80 @@ export class SandboxSiblingWarmingTimeoutError extends SandboxWarmingTimeoutErro
317
337
  }
318
338
  }
319
339
 
340
+ /**
341
+ * Out-of-band proof attached to one exact {@link SandboxExecReadinessTimeoutError}
342
+ * thrown by the elected spawner: the box that missed its readiness budget was
343
+ * freshly created by this caller (not an attached, resumed, or provider-continuity
344
+ * box), it was never published warm, the provider terminated it, and the holder
345
+ * was released. Only such an error may be replaced by re-entering admission.
346
+ */
347
+ const disposedUnreadyFreshSandboxErrors = new WeakSet<object>();
348
+
349
+ /** True only for a spawner readiness timeout whose unready fresh box is proven gone. */
350
+ export function isReplaceableFreshSandboxReadinessTimeout(error: unknown): boolean {
351
+ return (
352
+ error instanceof SandboxExecReadinessTimeoutError &&
353
+ disposedUnreadyFreshSandboxErrors.has(error)
354
+ );
355
+ }
356
+
357
+ /**
358
+ * A burst of simultaneous turn starts can leave many freshly created provider
359
+ * boxes command-unready at once. The single replacement re-enters admission
360
+ * after a jittered pause so the replacement creates do not re-synchronize into
361
+ * the same burst. Bounded to [2 s, 10 s).
362
+ */
363
+ export const FRESH_SANDBOX_READINESS_REPLACEMENT_BASE_DELAY_MS = 2_000;
364
+ export const FRESH_SANDBOX_READINESS_REPLACEMENT_JITTER_MS = 8_000;
365
+
366
+ export function freshSandboxReadinessReplacementDelayMs(
367
+ random: () => number = Math.random,
368
+ ): number {
369
+ const sample = random();
370
+ const unit = Number.isFinite(sample) ? Math.min(Math.max(sample, 0), 1 - Number.EPSILON) : 0;
371
+ return (
372
+ FRESH_SANDBOX_READINESS_REPLACEMENT_BASE_DELAY_MS +
373
+ Math.floor(unit * FRESH_SANDBOX_READINESS_REPLACEMENT_JITTER_MS)
374
+ );
375
+ }
376
+
377
+ /** Replacements allowed per turn attempt, across every resumeBoxForTurn call. */
378
+ export const FRESH_SANDBOX_READINESS_REPLACEMENTS_PER_TURN_ATTEMPT = 1;
379
+
380
+ export type FreshSandboxReadinessReplacementBudget = { remaining: number };
381
+
382
+ export function createFreshSandboxReadinessReplacementBudget(): FreshSandboxReadinessReplacementBudget {
383
+ return { remaining: FRESH_SANDBOX_READINESS_REPLACEMENTS_PER_TURN_ATTEMPT };
384
+ }
385
+
386
+ function recordFreshSandboxReadinessReplacement(
387
+ metrics: RuntimeMetricsHooks | undefined,
388
+ backend: string,
389
+ outcome: SandboxReadinessReplacementOutcome,
390
+ ): void {
391
+ try {
392
+ metrics?.onSandboxReadinessReplacement?.({ backend, outcome });
393
+ } catch {
394
+ // Metrics emission must never affect sandbox recovery or error propagation.
395
+ }
396
+ }
397
+
398
+ async function sleepUnlessCancelled(ms: number, signal: AbortSignal | undefined): Promise<boolean> {
399
+ if (signal?.aborted) return false;
400
+ if (ms <= 0) return true;
401
+ return await new Promise<boolean>((resolve) => {
402
+ const onAbort = () => {
403
+ clearTimeout(timer);
404
+ resolve(false);
405
+ };
406
+ const timer = setTimeout(() => {
407
+ signal?.removeEventListener("abort", onAbort);
408
+ resolve(true);
409
+ }, ms);
410
+ signal?.addEventListener("abort", onAbort, { once: true });
411
+ });
412
+ }
413
+
320
414
  /** The exact attached caller that won warm->cold after proving the provider
321
415
  * instance gone. It is a lease supersession (the same logical turn recovers),
322
416
  * plus the lost id needed for one durable observability event. */
@@ -1056,12 +1150,103 @@ async function persistWarmWorkspaceSnapshot(
1056
1150
  * holderId is the globally unique durable turn-attempt id. It must not be a
1057
1151
  * Temporal activity id, because activity ids are only workflow-local and
1058
1152
  * collide when sibling sessions share one sandbox group.
1153
+ *
1154
+ * A freshly created box that misses its bounded command-readiness budget is
1155
+ * replaced at most once per turn attempt (the budget is shared by every call
1156
+ * the turn's provisioner makes). The first establish has already terminated that exact
1157
+ * unpublished box, rolled its exact warming epoch back to cold (advancing the
1158
+ * epoch), and released its holder, so the replacement is an ordinary new
1159
+ * admission: it re-runs the cold->warming CAS, persists its own provider
1160
+ * instance before readiness, and may instead attach to a sibling that won the
1161
+ * CAS first. Nothing model- or tool-visible ran on the discarded box, so no
1162
+ * side effect is replayed. A second readiness timeout fails the turn exactly
1163
+ * as before; attached/resumed boxes and unconfirmed terminations never
1164
+ * replace.
1059
1165
  */
1060
1166
  export async function resumeBoxForTurn(
1061
1167
  services: SandboxResumeServices,
1062
1168
  ids: ResumeBoxIds,
1063
1169
  kind: "turn",
1064
1170
  holderId: TurnSandboxLeaseHolderId,
1171
+ ): Promise<ResumedTurnSandbox> {
1172
+ try {
1173
+ return await resumeBoxForTurnOnce(services, ids, kind, holderId);
1174
+ } catch (error) {
1175
+ if (!isReplaceableFreshSandboxReadinessTimeout(error)) throw error;
1176
+ const readiness = error as SandboxExecReadinessTimeoutError;
1177
+ const warn = (message: string, fields: Parameters<Observability["warn"]>[1]) => {
1178
+ if (services.observability) services.observability.warn(message, fields);
1179
+ else console.warn(message, fields);
1180
+ };
1181
+ const identity = {
1182
+ workspaceId: ids.workspaceId,
1183
+ sessionId: ids.sessionId,
1184
+ sandboxGroupId: ids.sandboxGroupId,
1185
+ backend: readiness.backend,
1186
+ instanceId: readiness.instanceId,
1187
+ readinessTimeoutMs: readiness.timeoutMs,
1188
+ };
1189
+ const budget =
1190
+ services.freshSandboxReadinessReplacementBudget ??
1191
+ createFreshSandboxReadinessReplacementBudget();
1192
+ if (budget.remaining <= 0) {
1193
+ recordFreshSandboxReadinessReplacement(
1194
+ services.sandboxMetrics,
1195
+ readiness.backend,
1196
+ "budget_spent",
1197
+ );
1198
+ warn(
1199
+ "sandbox command-readiness timed out on a fresh box; this turn attempt already used its replacement",
1200
+ identity,
1201
+ );
1202
+ throw error;
1203
+ }
1204
+ budget.remaining -= 1;
1205
+ const delayMs = await (
1206
+ services.freshSandboxReadinessReplacementDelayMs ?? freshSandboxReadinessReplacementDelayMs
1207
+ )();
1208
+ warn("sandbox command-readiness timed out on a fresh box; replacing it once after jitter", {
1209
+ ...identity,
1210
+ replacementDelayMs: delayMs,
1211
+ });
1212
+ if (!(await sleepUnlessCancelled(delayMs, services.cancellationSignal))) {
1213
+ // Cancellation owns the turn boundary; surface the original typed
1214
+ // readiness failure rather than starting a replacement.
1215
+ recordFreshSandboxReadinessReplacement(
1216
+ services.sandboxMetrics,
1217
+ readiness.backend,
1218
+ "cancelled",
1219
+ );
1220
+ throw error;
1221
+ }
1222
+ try {
1223
+ const replaced = await resumeBoxForTurnOnce(services, ids, kind, holderId);
1224
+ recordFreshSandboxReadinessReplacement(
1225
+ services.sandboxMetrics,
1226
+ readiness.backend,
1227
+ "replaced",
1228
+ );
1229
+ return replaced;
1230
+ } catch (replacementError) {
1231
+ recordFreshSandboxReadinessReplacement(
1232
+ services.sandboxMetrics,
1233
+ readiness.backend,
1234
+ services.cancellationSignal?.aborted
1235
+ ? "cancelled"
1236
+ : replacementError instanceof SandboxExecReadinessTimeoutError
1237
+ ? "failed_again"
1238
+ : "replacement_failed",
1239
+ );
1240
+ throw replacementError;
1241
+ }
1242
+ }
1243
+ }
1244
+
1245
+ async function resumeBoxForTurnOnce(
1246
+ services: SandboxResumeServices,
1247
+ ids: ResumeBoxIds,
1248
+ kind: "turn",
1249
+ holderId: TurnSandboxLeaseHolderId,
1065
1250
  ): Promise<ResumedTurnSandbox> {
1066
1251
  const { db, settings } = services;
1067
1252
  const os = ids.os ?? "linux";
@@ -1153,6 +1338,10 @@ export async function resumeBoxForTurn(
1153
1338
  holderId,
1154
1339
  subjectId: ids.sessionId,
1155
1340
  backend: ids.backend,
1341
+ warmBilling: {
1342
+ mode: settings.sandboxWarmBillingMode,
1343
+ rateMicrosPerSecond: sandboxWarmRateMicrosPerSecond(settings, ids.backend),
1344
+ },
1156
1345
  os,
1157
1346
  // IMAGE IS SHARED STATE (B3): thread the resolved image so the lease stamps it +
1158
1347
  // conflicts on a live box already running a different image. A
@@ -1283,6 +1472,7 @@ export async function resumeBoxForTurn(
1283
1472
  holderId,
1284
1473
  expectedEpoch: heartbeat.expectedEpoch,
1285
1474
  leaseTtlMs: heartbeat.leaseTtlMs,
1475
+ billingMode: services.settings.sandboxWarmBillingMode,
1286
1476
  })
1287
1477
  .then(async (status) => {
1288
1478
  if (heartbeat !== holderLeaseHeartbeat) return;
@@ -1650,11 +1840,17 @@ export async function resumeBoxForTurn(
1650
1840
  // A sandbox handle is not sufficient evidence that an asynchronous
1651
1841
  // provider's command router is live. Do not publish a warm lease until
1652
1842
  // one bounded no-op exec works.
1653
- // On timeout the catch below terminates the box and rolls warming -> cold,
1654
- // so the next turn cold-creates instead of hanging forever on first use.
1655
- await waitForSandboxExecReadiness(established, MODAL_EXEC_READINESS_TIMEOUT_MS, {
1656
- sandboxGroupId: ids.sandboxGroupId,
1657
- });
1843
+ // On timeout the catch below terminates the box and rolls warming -> cold;
1844
+ // resumeBoxForTurn may then replace a proven-terminated fresh box once.
1845
+ if (services.verifySpawnedSandboxReadiness) {
1846
+ await services.verifySpawnedSandboxReadiness(established, {
1847
+ sandboxGroupId: ids.sandboxGroupId,
1848
+ });
1849
+ } else {
1850
+ await waitForSandboxExecReadiness(established, MODAL_EXEC_READINESS_TIMEOUT_MS, {
1851
+ sandboxGroupId: ids.sandboxGroupId,
1852
+ });
1853
+ }
1658
1854
  await maybeRenewProviderExpiration(true);
1659
1855
  throwIfReleasedOrCancelled();
1660
1856
  // Fold the LIVE box into a re-resumable envelope and persist it as the
@@ -1790,6 +1986,16 @@ export async function resumeBoxForTurn(
1790
1986
  }
1791
1987
  await release();
1792
1988
  recordSandboxWarmingTimeout(services.sandboxMetrics, error);
1989
+ if (
1990
+ terminated &&
1991
+ createdEstablished &&
1992
+ error instanceof SandboxExecReadinessTimeoutError &&
1993
+ error.instanceId === createdEstablished.instanceId &&
1994
+ (createdEstablished.origin === "created" || createdEstablished.origin === "restored") &&
1995
+ !createdEstablished.providerContinuity
1996
+ ) {
1997
+ disposedUnreadyFreshSandboxErrors.add(error);
1998
+ }
1793
1999
  throw sandboxProvisionStageError(rematerialization ? "archive_recovery" : "create", error);
1794
2000
  }
1795
2001
  }
@@ -1,4 +1,9 @@
1
- import { ActivityCancellationType, proxyActivities } from "@temporalio/workflow";
1
+ import {
2
+ ActivityCancellationType,
3
+ ActivityFailure,
4
+ ApplicationFailure,
5
+ proxyActivities,
6
+ } from "@temporalio/workflow";
2
7
  import type * as activities from "../activities";
3
8
  import {
4
9
  KNOWLEDGE_SOURCE_SYNC_ACTIVITY_HEARTBEAT_TIMEOUT_MS,
@@ -160,6 +165,14 @@ export const knowledgeSourceSyncActivity = proxyActivities<
160
165
  });
161
166
 
162
167
  export function workflowFailureMessage(error: unknown): string {
168
+ if (
169
+ error instanceof ActivityFailure &&
170
+ error.activityType === "runAgentTurn" &&
171
+ error.cause instanceof ApplicationFailure &&
172
+ error.cause.type === "TurnExecutionPolicyDefinitionMismatchError"
173
+ ) {
174
+ return error.cause.message;
175
+ }
163
176
  if (error instanceof Error) {
164
177
  return error.message;
165
178
  }
@@ -58,6 +58,50 @@ const HUMAN_INPUT_EXPIRY_STALE_RETRY_MS = 1_000;
58
58
  */
59
59
  const ROTATION_IDLE_FLOOR_MS = 60_000; // 60s
60
60
 
61
+ /**
62
+ * Provider capacity waits are shared: every waiter of one exhausted pool learns
63
+ * the same authoritative reset time, and one capacity mutation (for example the
64
+ * bounded refresh that verifies a quota reset) wakes every waiter at once, both
65
+ * as the typed capacity signal and as the generic durable workflow wake. Without
66
+ * spread, a reset resumes the whole backlog in the same few seconds and its
67
+ * turns stampede sandbox creation and the provider. Each workflow therefore
68
+ * delays its reconciliation by a bounded, replay-deterministic (Temporal-seeded
69
+ * `Math.random`) jitter: up to one minute past a scheduled reset timer and up to
70
+ * 30 seconds after a capacity or queue wake or an already-due waiter. A wake
71
+ * that lands inside a waiter's timer spread does not shorten it. The Postgres
72
+ * waiter stays authoritative; jitter only delays the same reconciliation
73
+ * activity and never creates queue rows, input, or inference. Interruptions are
74
+ * never delayed.
75
+ *
76
+ * The patch marker is not understood by workers built before it: rolling the
77
+ * worker image back past this change while a session has recorded the marker
78
+ * (it waited on capacity in its current run) fails that workflow's tasks as
79
+ * nondeterministic until a patched worker returns.
80
+ */
81
+ export const CAPACITY_WAKE_JITTER_PATCH = "session-capacity-wake-jitter-v1";
82
+ export const CAPACITY_TIMER_WAKE_JITTER_MAX_MS = 60_000;
83
+ export const CAPACITY_WAKE_JITTER_MAX_MS = 30_000;
84
+
85
+ /**
86
+ * Pure + exported so the bound is unit-testable without a workflow environment.
87
+ * `overrideMaxMs` is the test-only SessionWorkflowInput ceiling; it can only
88
+ * narrow the production bound.
89
+ */
90
+ export function capacityWakeJitterMs(
91
+ kind: "timer" | "wake",
92
+ sample: number,
93
+ overrideMaxMs?: number,
94
+ ): number {
95
+ const productionMax =
96
+ kind === "timer" ? CAPACITY_TIMER_WAKE_JITTER_MAX_MS : CAPACITY_WAKE_JITTER_MAX_MS;
97
+ const max =
98
+ overrideMaxMs !== undefined && Number.isFinite(overrideMaxMs)
99
+ ? Math.min(productionMax, Math.max(0, Math.trunc(overrideMaxMs)))
100
+ : productionMax;
101
+ const unit = Number.isFinite(sample) ? Math.min(Math.max(sample, 0), 1 - Number.EPSILON) : 0;
102
+ return Math.floor(unit * max);
103
+ }
104
+
61
105
  /**
62
106
  * How long the continuation loop must hold before re-admitting the next turn. 0 ⇒ no
63
107
  * hold (re-dispatch immediately — a rotation candidate is ready, or no idle delay was
@@ -369,6 +413,9 @@ export type SessionWorkflowInput = {
369
413
  // Test-only override for the durable capacity-wait continue-as-new
370
414
  // backstop. Production uses CODEX_CAPACITY_CHECKS_PER_RUN_BACKSTOP.
371
415
  maxCapacityChecksPerRun?: number;
416
+ // Test-only ceiling for capacity-wake jitter (0 disables it) so real-server
417
+ // workflow tests stay fast. Production omits it and uses the bounds above.
418
+ capacityWakeJitterMaxMs?: number;
372
419
  };
373
420
 
374
421
  export async function sessionWorkflow(input: SessionWorkflowInput): Promise<void> {
@@ -476,7 +523,14 @@ export async function sessionWorkflow(input: SessionWorkflowInput): Promise<void
476
523
  const timerMs = Number.isFinite(parsedDeadline)
477
524
  ? Math.max(0, parsedDeadline - Date.now())
478
525
  : 0;
526
+ const observedCause = (): activities.ReconcileCodexCapacityWaitInput["cause"] =>
527
+ wakeups !== seenWakeups
528
+ ? "queue"
529
+ : capacityWakeups !== seenCapacityWakeups
530
+ ? "signal"
531
+ : "timer";
479
532
  let cause: activities.ReconcileCodexCapacityWaitInput["cause"] = "timer";
533
+ let spread = false;
480
534
  if (wakeups !== seenWakeups) {
481
535
  cause = "queue";
482
536
  } else if (capacityWakeups !== seenCapacityWakeups) {
@@ -492,12 +546,48 @@ export async function sessionWorkflow(input: SessionWorkflowInput): Promise<void
492
546
  if (interruptionWakeups !== seenInterruptionWakeups) {
493
547
  return;
494
548
  }
495
- cause =
496
- wakeups !== seenWakeups
497
- ? "queue"
498
- : capacityWakeups !== seenCapacityWakeups
499
- ? "signal"
500
- : "timer";
549
+ cause = observedCause();
550
+ // Histories recorded before the jitter replay without the marker and
551
+ // reconcile right at the deadline, exactly as they did.
552
+ if (cause === "timer" && patched(CAPACITY_WAKE_JITTER_PATCH)) {
553
+ // The shared reset deadline itself fired. The first waiter to
554
+ // reconcile usually refreshes usage and wakes every other waiter; a
555
+ // waiter already inside its own spread keeps it instead of starting a
556
+ // second, shorter one, so the backlog resumes across the whole window.
557
+ spread = true;
558
+ const timerJitterMs = capacityWakeJitterMs(
559
+ "timer",
560
+ Math.random(),
561
+ input.capacityWakeJitterMaxMs,
562
+ );
563
+ if (timerJitterMs > 0) {
564
+ await condition(() => interruptionWakeups !== seenInterruptionWakeups, timerJitterMs);
565
+ if (interruptionWakeups !== seenInterruptionWakeups) {
566
+ return;
567
+ }
568
+ }
569
+ cause = observedCause();
570
+ }
571
+ }
572
+ if (!spread && patched(CAPACITY_WAKE_JITTER_PATCH)) {
573
+ // One capacity mutation wakes every waiter of the pool at once (the typed
574
+ // capacity signal and the generic durable wake), and a fresh run after
575
+ // continue-as-new, restart, or Resume sees every unobserved wake as an
576
+ // already-due waiter. Spread those reconciliations too. A later wake
577
+ // does not cut the pause short (that would re-synchronize the herd); a
578
+ // queued prompt stays behind the blocked turn either way, so only an
579
+ // interruption (Pause/Steer/Cancel) wins immediately.
580
+ const wakeJitterMs = capacityWakeJitterMs(
581
+ "wake",
582
+ Math.random(),
583
+ input.capacityWakeJitterMaxMs,
584
+ );
585
+ if (wakeJitterMs > 0) {
586
+ await condition(() => interruptionWakeups !== seenInterruptionWakeups, wakeJitterMs);
587
+ if (interruptionWakeups !== seenInterruptionWakeups) {
588
+ return;
589
+ }
590
+ }
501
591
  }
502
592
  const reconcileInput = {
503
593
  accountId: input.accountId,
@@ -536,6 +626,9 @@ export async function sessionWorkflow(input: SessionWorkflowInput): Promise<void
536
626
  ...(input.maxCapacityChecksPerRun !== undefined
537
627
  ? { maxCapacityChecksPerRun: input.maxCapacityChecksPerRun }
538
628
  : {}),
629
+ ...(input.capacityWakeJitterMaxMs !== undefined
630
+ ? { capacityWakeJitterMaxMs: input.capacityWakeJitterMaxMs }
631
+ : {}),
539
632
  });
540
633
  }
541
634
  current = result;
@@ -582,6 +675,9 @@ export async function sessionWorkflow(input: SessionWorkflowInput): Promise<void
582
675
  ...(input.maxCapacityChecksPerRun !== undefined
583
676
  ? { maxCapacityChecksPerRun: input.maxCapacityChecksPerRun }
584
677
  : {}),
678
+ ...(input.capacityWakeJitterMaxMs !== undefined
679
+ ? { capacityWakeJitterMaxMs: input.capacityWakeJitterMaxMs }
680
+ : {}),
585
681
  });
586
682
  }
587
683
  }