@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.
- package/dist/activities/agent-turn/agent-build.d.ts +2 -0
- package/dist/activities/agent-turn/code-search.d.ts +60 -0
- package/dist/activities/agent-turn/codex-capacity.d.ts +13 -0
- package/dist/activities/agent-turn/errors.d.ts +12 -1
- package/dist/activities/agent-turn/failure-settlement.d.ts +1 -1
- package/dist/activities/agent-turn/governance-model.d.ts +2 -0
- package/dist/activities/agent-turn/history.d.ts +2 -0
- package/dist/activities/agent-turn/media-artifacts.d.ts +5 -3
- package/dist/activities/agent-turn/session-title.d.ts +32 -2
- package/dist/activities/agent-turn/tool-environment.d.ts +4 -0
- package/dist/activities/context-compaction.d.ts +10 -2
- package/dist/activities/knowledge-indexing.d.ts +19 -0
- package/dist/activities/retained-screenshots.d.ts +7 -2
- package/dist/activities/sandbox-lease.d.ts +3 -4
- package/dist/{activities-control-QA6OLRQY.js → activities-control-I72CRMAE.js} +283 -35
- package/dist/activities-control-I72CRMAE.js.map +1 -0
- package/dist/{activities-turn-YVVKCGAK.js → activities-turn-CSDKK4N3.js} +531 -77
- package/dist/activities-turn-CSDKK4N3.js.map +1 -0
- package/dist/artifact-outbox-entry.js +3 -0
- package/dist/artifact-outbox-entry.js.map +1 -1
- package/dist/{chunk-M3NVMF2V.js → chunk-7M6G2SCF.js} +172 -17
- package/dist/chunk-7M6G2SCF.js.map +1 -0
- package/dist/{chunk-I7HKKMUJ.js → chunk-DSPP6CZL.js} +48 -3
- package/dist/chunk-DSPP6CZL.js.map +1 -0
- package/dist/index.js +10 -5
- package/dist/index.js.map +1 -1
- package/dist/observability-metrics.d.ts +11 -1
- package/dist/sandbox-resume.d.ts +46 -0
- package/dist/workflow-bundle.js +80 -2
- package/dist/workflows/session.d.ts +30 -0
- package/package.json +20 -19
- package/src/activities/agent-turn/agent-build.ts +4 -0
- package/src/activities/agent-turn/code-search.ts +275 -0
- package/src/activities/agent-turn/codex-capacity.ts +37 -2
- package/src/activities/agent-turn/compaction-prep.ts +52 -16
- package/src/activities/agent-turn/errors.ts +67 -4
- package/src/activities/agent-turn/failure-settlement.ts +40 -8
- package/src/activities/agent-turn/governance-model.ts +17 -1
- package/src/activities/agent-turn/history.ts +14 -1
- package/src/activities/agent-turn/media-artifacts.ts +2 -1
- package/src/activities/agent-turn/run.ts +4 -0
- package/src/activities/agent-turn/sandbox-establish.ts +12 -1
- package/src/activities/agent-turn/sandbox-runtime.ts +25 -9
- package/src/activities/agent-turn/session-title.ts +73 -3
- package/src/activities/agent-turn/stream-attempt.ts +27 -15
- package/src/activities/agent-turn/tool-environment.ts +65 -0
- package/src/activities/context-compaction.ts +49 -14
- package/src/activities/knowledge-indexing.ts +240 -15
- package/src/activities/retained-screenshots.ts +82 -11
- package/src/activities/sandbox-lease.ts +141 -21
- package/src/activities/scheduled-tasks.ts +18 -2
- package/src/activity-services.ts +16 -4
- package/src/editable-artifact-hint-broker.ts +3 -0
- package/src/index.ts +2 -2
- package/src/observability-metrics.ts +70 -1
- package/src/personal-github-git-credentials.ts +2 -0
- package/src/sandbox-resume.ts +211 -5
- package/src/workflows/activities.ts +14 -1
- package/src/workflows/session.ts +102 -6
- package/dist/activities-control-QA6OLRQY.js.map +0 -1
- package/dist/activities-turn-YVVKCGAK.js.map +0 -1
- package/dist/chunk-I7HKKMUJ.js.map +0 -1
- package/dist/chunk-M3NVMF2V.js.map +0 -1
package/src/sandbox-resume.ts
CHANGED
|
@@ -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
|
-
//
|
|
1655
|
-
|
|
1656
|
-
|
|
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 {
|
|
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
|
}
|
package/src/workflows/session.ts
CHANGED
|
@@ -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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
}
|