@opengeni/worker-bundle 2.3.1 → 2.3.2-canary.37090756480001
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/claude-usage-observer.d.ts +30 -6
- package/dist/activities/agent-turn/credential-leases.d.ts +5 -32
- package/dist/activities/agent-turn/errors.d.ts +3 -0
- package/dist/activities/agent-turn/finalization-monitor.d.ts +1 -1
- package/dist/activities/agent-turn/finalization.d.ts +2 -0
- package/dist/activities/agent-turn/quiescence.d.ts +3 -4
- package/dist/activities/agent-turn/subscription-lease.d.ts +38 -0
- package/dist/activities/agent-turn/turn-context.d.ts +1 -1
- package/dist/activities/model-call-fact-reconciler.d.ts +34 -0
- package/dist/activities/session-state.d.ts +2 -2
- package/dist/activities/types.d.ts +7 -0
- package/dist/{activities-control-3HAH4FMG.js → activities-control-3BY4IJKL.js} +166 -35
- package/dist/activities-control-3BY4IJKL.js.map +1 -0
- package/dist/activities-control.d.ts +6 -4
- package/dist/{activities-turn-KU25J3VQ.js → activities-turn-TTNAVGKN.js} +263 -308
- package/dist/activities-turn-TTNAVGKN.js.map +1 -0
- package/dist/activities.d.ts +6 -4
- package/dist/{chunk-3K3ULWEE.js → chunk-PA7Z55P4.js} +242 -17
- package/dist/chunk-PA7Z55P4.js.map +1 -0
- package/dist/{chunk-YG27NEMX.js → chunk-YOI3Z3GF.js} +9 -1
- package/dist/chunk-YOI3Z3GF.js.map +1 -0
- package/dist/http.d.ts +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.js +82 -16
- package/dist/index.js.map +1 -1
- package/dist/observability-metrics.d.ts +6 -1
- package/dist/sandbox-resume.d.ts +3 -0
- package/dist/session-event-fanout.d.ts +6 -0
- package/dist/worker-service-lifecycle.d.ts +12 -0
- package/dist/workflow-bundle.js +3 -2
- package/package.json +21 -21
- package/src/activities/agent-turn/claim.ts +13 -0
- package/src/activities/agent-turn/claude-usage-observer.ts +99 -8
- package/src/activities/agent-turn/credential-leases.ts +86 -213
- package/src/activities/agent-turn/errors.ts +61 -1
- package/src/activities/agent-turn/failure-settlement.ts +59 -5
- package/src/activities/agent-turn/finalization-monitor.ts +3 -4
- package/src/activities/agent-turn/finalization.ts +8 -3
- package/src/activities/agent-turn/quiescence.ts +3 -4
- package/src/activities/agent-turn/run.ts +24 -20
- package/src/activities/agent-turn/sandbox-establish.ts +2 -0
- package/src/activities/agent-turn/sandbox-runtime.ts +5 -1
- package/src/activities/agent-turn/skill-selection.ts +2 -0
- package/src/activities/agent-turn/subscription-lease.ts +115 -0
- package/src/activities/agent-turn/turn-context.ts +1 -4
- package/src/activities/codex-rotation.ts +2 -12
- package/src/activities/model-call-fact-reconciler.ts +112 -0
- package/src/activities/sandbox-lease.ts +59 -8
- package/src/activities/session-state.ts +29 -4
- package/src/activities/types.ts +7 -0
- package/src/activities-control.ts +2 -0
- package/src/activities-turn.ts +5 -0
- package/src/activities.ts +7 -0
- package/src/http.ts +4 -2
- package/src/index.ts +39 -9
- package/src/observability-metrics.ts +27 -0
- package/src/sandbox-resume.ts +9 -0
- package/src/sandbox-routing.ts +77 -0
- package/src/session-event-fanout.ts +23 -0
- package/src/worker-service-lifecycle.ts +53 -0
- package/src/workflows/file-upload-reaper.ts +2 -0
- package/src/workflows/session.ts +6 -1
- package/dist/activities-control-3HAH4FMG.js.map +0 -1
- package/dist/activities-turn-KU25J3VQ.js.map +0 -1
- package/dist/chunk-3K3ULWEE.js.map +0 -1
- package/dist/chunk-YG27NEMX.js.map +0 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { listWorkspaceIdsAfter, reconcileModelCallFacts } from "@opengeni/db";
|
|
3
|
+
import type { ControlActivityServices } from "./types";
|
|
4
|
+
|
|
5
|
+
/** Recent charged calls are rechecked on every run; older gaps use the backfill script. */
|
|
6
|
+
export const MODEL_CALL_FACT_RECONCILE_LOOKBACK_MS = 2 * 60 * 60 * 1_000;
|
|
7
|
+
/** Skip the newest calls so a live fact write is not raced by its own repair. */
|
|
8
|
+
export const MODEL_CALL_FACT_RECONCILE_SETTLE_MS = 10 * 60 * 1_000;
|
|
9
|
+
export const MODEL_CALL_FACT_RECONCILE_WORKSPACE_LIMIT = 500;
|
|
10
|
+
export const MODEL_CALL_FACT_RECONCILE_RUN_BUDGET_MS = 3 * 60 * 1_000;
|
|
11
|
+
const WORKSPACE_PAGE_SIZE = 200;
|
|
12
|
+
|
|
13
|
+
export type ReconcileRecentModelCallFactsResult = {
|
|
14
|
+
workspaces: number;
|
|
15
|
+
missing: number;
|
|
16
|
+
repaired: number;
|
|
17
|
+
unrepaired: number;
|
|
18
|
+
/** Workspaces with more missing facts than one bounded pass examines. */
|
|
19
|
+
truncatedWorkspaces: number;
|
|
20
|
+
failedWorkspaces: number;
|
|
21
|
+
budgetExhausted: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type ModelCallFactReconcilerOptions = {
|
|
25
|
+
now?: () => number;
|
|
26
|
+
runBudgetMs?: number;
|
|
27
|
+
startAfterWorkspaceId?: () => string;
|
|
28
|
+
reconcile?: typeof reconcileModelCallFacts;
|
|
29
|
+
listWorkspaces?: typeof listWorkspaceIdsAfter;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Rebuild missing Insights call facts for recently charged model calls. Each
|
|
34
|
+
* workspace pass is bounded and idempotent, one failure never stops the rest,
|
|
35
|
+
* and each run starts at a random workspace so a run that exhausts its budget
|
|
36
|
+
* does not starve the same tail of workspaces every time.
|
|
37
|
+
*/
|
|
38
|
+
export function createModelCallFactReconcilerActivities(
|
|
39
|
+
services: () => Promise<ControlActivityServices>,
|
|
40
|
+
options: ModelCallFactReconcilerOptions = {},
|
|
41
|
+
) {
|
|
42
|
+
const now = options.now ?? Date.now;
|
|
43
|
+
const runBudgetMs = options.runBudgetMs ?? MODEL_CALL_FACT_RECONCILE_RUN_BUDGET_MS;
|
|
44
|
+
const startAfterWorkspaceId = options.startAfterWorkspaceId ?? randomUUID;
|
|
45
|
+
const reconcile = options.reconcile ?? reconcileModelCallFacts;
|
|
46
|
+
const listWorkspaces = options.listWorkspaces ?? listWorkspaceIdsAfter;
|
|
47
|
+
|
|
48
|
+
async function reconcileRecentModelCallFacts(): Promise<ReconcileRecentModelCallFactsResult> {
|
|
49
|
+
const { db, observability } = await services();
|
|
50
|
+
const startedAt = now();
|
|
51
|
+
const until = new Date(startedAt - MODEL_CALL_FACT_RECONCILE_SETTLE_MS);
|
|
52
|
+
const since = new Date(until.getTime() - MODEL_CALL_FACT_RECONCILE_LOOKBACK_MS);
|
|
53
|
+
const result: ReconcileRecentModelCallFactsResult = {
|
|
54
|
+
workspaces: 0,
|
|
55
|
+
missing: 0,
|
|
56
|
+
repaired: 0,
|
|
57
|
+
unrepaired: 0,
|
|
58
|
+
truncatedWorkspaces: 0,
|
|
59
|
+
failedWorkspaces: 0,
|
|
60
|
+
budgetExhausted: false,
|
|
61
|
+
};
|
|
62
|
+
const start = startAfterWorkspaceId();
|
|
63
|
+
let cursor: string | null = start;
|
|
64
|
+
let wrapped = false;
|
|
65
|
+
while (true) {
|
|
66
|
+
const page = await listWorkspaces(db, { afterId: cursor, limit: WORKSPACE_PAGE_SIZE });
|
|
67
|
+
const ids = wrapped ? page.filter((id) => id <= start) : page;
|
|
68
|
+
for (const workspaceId of ids) {
|
|
69
|
+
if (now() - startedAt >= runBudgetMs) {
|
|
70
|
+
result.budgetExhausted = true;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
result.workspaces += 1;
|
|
74
|
+
try {
|
|
75
|
+
const outcome = await reconcile(db, {
|
|
76
|
+
workspaceId,
|
|
77
|
+
since,
|
|
78
|
+
until,
|
|
79
|
+
limit: MODEL_CALL_FACT_RECONCILE_WORKSPACE_LIMIT,
|
|
80
|
+
});
|
|
81
|
+
result.missing += outcome.missing;
|
|
82
|
+
result.repaired += outcome.repaired;
|
|
83
|
+
result.unrepaired += outcome.unrepaired;
|
|
84
|
+
if (outcome.truncated) result.truncatedWorkspaces += 1;
|
|
85
|
+
} catch (error) {
|
|
86
|
+
result.failedWorkspaces += 1;
|
|
87
|
+
observability.warn("model call fact reconciliation failed for a workspace", {
|
|
88
|
+
workspaceId,
|
|
89
|
+
errorName: error instanceof Error ? error.name : "unknown",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (result.budgetExhausted) break;
|
|
94
|
+
if (wrapped && ids.length < page.length) break;
|
|
95
|
+
if (page.length < WORKSPACE_PAGE_SIZE) {
|
|
96
|
+
if (wrapped) break;
|
|
97
|
+
wrapped = true;
|
|
98
|
+
cursor = null;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
cursor = page[page.length - 1]!;
|
|
102
|
+
}
|
|
103
|
+
if (result.failedWorkspaces > 0 || result.truncatedWorkspaces > 0 || result.budgetExhausted) {
|
|
104
|
+
observability.warn("model call fact reconciliation incomplete", { ...result });
|
|
105
|
+
} else if (result.missing > 0) {
|
|
106
|
+
observability.info("model call fact reconciliation", { ...result });
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { reconcileRecentModelCallFacts };
|
|
112
|
+
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// timer, viewer activity, owner task queue, or provider-specific lifecycle path
|
|
15
15
|
// in the normal drain state machine.
|
|
16
16
|
import { warnDrainSnapshotFailure } from "../sandbox-snapshot-diagnostics";
|
|
17
|
+
import { publishDurableSessionEvents } from "../session-event-fanout";
|
|
17
18
|
import { warnRetainedProcessProofFailure } from "../retained-process-diagnostics";
|
|
18
19
|
import { retainedProcessDeadlineRetryMs } from "../retained-process-retry";
|
|
19
20
|
|
|
@@ -68,6 +69,7 @@ import {
|
|
|
68
69
|
workspaceArchiveCaptureDeadlineElapsed,
|
|
69
70
|
retainedProcessReconciliationProof,
|
|
70
71
|
retainedProcessSettlementIdentity,
|
|
72
|
+
recoverManagedSessionBackgroundCommand,
|
|
71
73
|
settleClaimedConnectedMachineBackgroundCommand,
|
|
72
74
|
settleSandboxCheckpointArtifactGc,
|
|
73
75
|
rlsContextForWorkspace,
|
|
@@ -169,6 +171,7 @@ import {
|
|
|
169
171
|
type SandboxInventoryProjectionDomain,
|
|
170
172
|
recordSandboxLeaseGauges,
|
|
171
173
|
recordSandboxOrphansTerminated,
|
|
174
|
+
recordSandboxCommandContainment,
|
|
172
175
|
recordSandboxProviderMissingBeforeCapture,
|
|
173
176
|
recordSandboxRecoveryObservationGauges,
|
|
174
177
|
recordSandboxRotationBacklogGauges,
|
|
@@ -562,10 +565,15 @@ export function createSandboxLeaseActivities(
|
|
|
562
565
|
try {
|
|
563
566
|
const { db, settings, observability } = await services();
|
|
564
567
|
const timing = sandboxDrainTiming(settings);
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
568
|
+
const commandContainment = {
|
|
569
|
+
idleCommandContainmentMs: settings.sandboxIdleCommandContainmentMs,
|
|
570
|
+
onCommandContainment: (outcome: Parameters<typeof recordSandboxCommandContainment>[1]) =>
|
|
571
|
+
recordSandboxCommandContainment(observability, outcome),
|
|
572
|
+
onCommandContainmentError: (error: unknown) => {
|
|
573
|
+
observability.warn("sandbox reaper: retained command containment inspection failed", {
|
|
574
|
+
error: error instanceof Error ? error.message : String(error),
|
|
575
|
+
});
|
|
576
|
+
},
|
|
569
577
|
};
|
|
570
578
|
if (!settings.sandboxOwnershipEnabled) {
|
|
571
579
|
// Turns skip leases when the flag is off, but Computer/Browser attach
|
|
@@ -573,7 +581,7 @@ export function createSandboxLeaseActivities(
|
|
|
573
581
|
// behind rotation_in_progress forever. Inventory and drain those rows;
|
|
574
582
|
// do not request NEW deadline rotations or meter warm time.
|
|
575
583
|
const drainableInventory = await reapStaleLeaseHoldersGlobal(db, {
|
|
576
|
-
|
|
584
|
+
...commandContainment,
|
|
577
585
|
viewerHolderTtlMs: settings.sandboxViewerHolderTtlMs,
|
|
578
586
|
turnHolderTtlMs: settings.sandboxLeaseTtlMs,
|
|
579
587
|
interactionHolderTtlMs: settings.sandboxInteractionHolderTtlMs,
|
|
@@ -610,7 +618,7 @@ export function createSandboxLeaseActivities(
|
|
|
610
618
|
// Billing, reconciliation, provider-orphan cleanup, artifact GC, and gauges
|
|
611
619
|
// run only after every drainable box has its own durable child.
|
|
612
620
|
const drainableInventory = await reapStaleLeaseHoldersGlobal(db, {
|
|
613
|
-
|
|
621
|
+
...commandContainment,
|
|
614
622
|
viewerHolderTtlMs: settings.sandboxViewerHolderTtlMs,
|
|
615
623
|
// Dead-worker turn holders: a live holder is touched every 10s from the
|
|
616
624
|
// moment it is registered (resumeBoxForTurn's holder-liveness loop covers
|
|
@@ -654,7 +662,7 @@ export function createSandboxLeaseActivities(
|
|
|
654
662
|
instanceId: input.target.instanceId,
|
|
655
663
|
});
|
|
656
664
|
try {
|
|
657
|
-
const { db, settings, observability, objectStorage } = await services();
|
|
665
|
+
const { db, settings, observability, objectStorage, bus } = await services();
|
|
658
666
|
assertSandboxDrainInputTiming(input);
|
|
659
667
|
const drainSettings =
|
|
660
668
|
settings.sandboxSnapshotTimeoutMs === input.snapshotTimeoutMs
|
|
@@ -678,6 +686,7 @@ export function createSandboxLeaseActivities(
|
|
|
678
686
|
probeDrainableProvider,
|
|
679
687
|
captureAttempt,
|
|
680
688
|
objectStorage,
|
|
689
|
+
bus,
|
|
681
690
|
);
|
|
682
691
|
return { status: drainedCold ? "terminated" : "skipped" };
|
|
683
692
|
} catch (error) {
|
|
@@ -1477,6 +1486,32 @@ async function reconcileTerminalRetainedProcesses(
|
|
|
1477
1486
|
sessionId: process.sessionId,
|
|
1478
1487
|
processId: process.id,
|
|
1479
1488
|
};
|
|
1489
|
+
// Recover a yield whose observation failed before normal receipt adoption.
|
|
1490
|
+
// Do this before provider I/O: an unavailable observer must not fence every
|
|
1491
|
+
// subsequent turn. Physical retention and unknown outcome stay unchanged.
|
|
1492
|
+
if (
|
|
1493
|
+
process.ownerActorKind === "turn" &&
|
|
1494
|
+
process.providerBackend === "modal" &&
|
|
1495
|
+
!proof &&
|
|
1496
|
+
!claim.ownerState.startsWith("background_")
|
|
1497
|
+
) {
|
|
1498
|
+
try {
|
|
1499
|
+
const command = await recoverManagedSessionBackgroundCommand(db, {
|
|
1500
|
+
...processScope,
|
|
1501
|
+
expected,
|
|
1502
|
+
reconciliationClaimId: claim.claimId,
|
|
1503
|
+
});
|
|
1504
|
+
if (command) {
|
|
1505
|
+
claim.ownerState = `background_${command.state}`;
|
|
1506
|
+
recordRetainedProcessReconciliation(observability, "background_recovered");
|
|
1507
|
+
}
|
|
1508
|
+
} catch (error) {
|
|
1509
|
+
observability.warn("sandbox reaper: retained-command background recovery failed", {
|
|
1510
|
+
processId: process.id,
|
|
1511
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1480
1515
|
const storedCommandPersistence = retainedProviderCommandPersistence(
|
|
1481
1516
|
db,
|
|
1482
1517
|
processScope,
|
|
@@ -2858,6 +2893,7 @@ async function terminateDrainableBox(
|
|
|
2858
2893
|
probeDrainableProvider: DrainableProviderProbeFn,
|
|
2859
2894
|
attempt: SandboxDrainCaptureAttempt,
|
|
2860
2895
|
objectStorage: ObjectStorage | null,
|
|
2896
|
+
bus: ActivityServices["bus"] | null = null,
|
|
2861
2897
|
): Promise<boolean> {
|
|
2862
2898
|
// Resolve the account for the RLS-scoped confirmDrainCold (the global sweep
|
|
2863
2899
|
// returns no account_id; the workspace->account map is the bootstrap read).
|
|
@@ -3318,13 +3354,22 @@ async function terminateDrainableBox(
|
|
|
3318
3354
|
// with draining->cold. Until this succeeds, arrivals remain fenced by that
|
|
3319
3355
|
// exact claim; a timestamp or a failed provider call can never reopen a box
|
|
3320
3356
|
// while termination may still be in flight.
|
|
3321
|
-
const { wentCold } = await confirmDrainCold(db, {
|
|
3357
|
+
const { wentCold, backgroundCommandEvents } = await confirmDrainCold(db, {
|
|
3322
3358
|
accountId,
|
|
3323
3359
|
workspaceId: row.workspaceId,
|
|
3324
3360
|
sandboxGroupId: row.sandboxGroupId,
|
|
3325
3361
|
expectedEpoch: row.leaseEpoch,
|
|
3326
3362
|
...(captureClaim ? { expectedCaptureId: captureClaim.id } : {}),
|
|
3327
3363
|
providerMissingBeforeCapture: providerMissing,
|
|
3364
|
+
idleCommandContainmentMs: settings.sandboxIdleCommandContainmentMs,
|
|
3365
|
+
});
|
|
3366
|
+
// The command terminal events and agent inputs are already durable in the
|
|
3367
|
+
// cold commit; this is only best-effort live fanout.
|
|
3368
|
+
await publishDurableSessionEvents(bus, row.workspaceId, backgroundCommandEvents, (error) => {
|
|
3369
|
+
observability.warn("sandbox reaper: contained command event fanout failed", {
|
|
3370
|
+
sandboxGroupId: row.sandboxGroupId,
|
|
3371
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3372
|
+
});
|
|
3328
3373
|
});
|
|
3329
3374
|
if (wentCold) {
|
|
3330
3375
|
// Only the exact successful cold commit counts provider loss. A missing
|
|
@@ -3333,6 +3378,12 @@ async function terminateDrainableBox(
|
|
|
3333
3378
|
if (providerMissing) {
|
|
3334
3379
|
recordSandboxProviderMissingBeforeCapture(observability, backend);
|
|
3335
3380
|
}
|
|
3381
|
+
if (lease.unobservableCommandDrainIds?.length) {
|
|
3382
|
+
recordSandboxCommandContainment(
|
|
3383
|
+
observability,
|
|
3384
|
+
providerMissing ? "provider_missing" : "contained",
|
|
3385
|
+
);
|
|
3386
|
+
}
|
|
3336
3387
|
// Durable termination record (sandbox-file-persistence observability): who
|
|
3337
3388
|
// ended this box and whether its /workspace was captured first, appended to
|
|
3338
3389
|
// every session sharing the group's box. Best-effort: attribution must
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
markSessionAttemptQuiesced,
|
|
20
20
|
requireSession,
|
|
21
21
|
settleSessionIdleWithParentOutbox,
|
|
22
|
+
SANDBOX_SETUP_RECOVERY_LIMIT,
|
|
22
23
|
} from "@opengeni/db";
|
|
23
24
|
import { publishDurableSessionEvents } from "@opengeni/events";
|
|
24
25
|
import { CancelledFailure } from "@temporalio/activity";
|
|
@@ -239,6 +240,9 @@ export function createSessionStateActivities(
|
|
|
239
240
|
turn.triggerEventId === postClaimRecovery.triggerEventId &&
|
|
240
241
|
turn.executionGeneration === postClaimRecovery.executionGeneration,
|
|
241
242
|
);
|
|
243
|
+
if (postClaimRecovery?.sandboxSetupRecoveryExhausted && !postClaimIdentityMatches) {
|
|
244
|
+
return { action: "stale" };
|
|
245
|
+
}
|
|
242
246
|
const providerRecoveryCount = postClaimIdentityMatches
|
|
243
247
|
? postClaimRecovery?.providerRecoveryCount
|
|
244
248
|
: undefined;
|
|
@@ -249,7 +253,15 @@ export function createSessionStateActivities(
|
|
|
249
253
|
const hasProviderFailureCode = providerFailureCode !== undefined;
|
|
250
254
|
const setupOutcomeUnknown =
|
|
251
255
|
postClaimIdentityMatches && postClaimRecovery?.sandboxSetupOutcomeUnknown === true;
|
|
252
|
-
|
|
256
|
+
const setupRecoveryExhausted =
|
|
257
|
+
postClaimIdentityMatches && postClaimRecovery?.sandboxSetupRecoveryExhausted === true;
|
|
258
|
+
if (
|
|
259
|
+
(setupOutcomeUnknown && setupRecoveryExhausted) ||
|
|
260
|
+
((setupOutcomeUnknown || setupRecoveryExhausted) &&
|
|
261
|
+
(hasProviderRecoveryCount || hasProviderFailureCode)) ||
|
|
262
|
+
(setupRecoveryExhausted &&
|
|
263
|
+
turn.metadata?.providerRecoveryCount !== SANDBOX_SETUP_RECOVERY_LIMIT)
|
|
264
|
+
) {
|
|
253
265
|
return { action: "stale" };
|
|
254
266
|
}
|
|
255
267
|
if (hasProviderRecoveryCount !== hasProviderFailureCode) {
|
|
@@ -282,15 +294,28 @@ export function createSessionStateActivities(
|
|
|
282
294
|
attemptId: input.attemptId,
|
|
283
295
|
reason: setupOutcomeUnknown
|
|
284
296
|
? "sandbox_command_start_outcome_unknown"
|
|
285
|
-
:
|
|
297
|
+
: setupRecoveryExhausted
|
|
298
|
+
? "sandbox_command_start_recovery_exhausted"
|
|
299
|
+
: (providerFailureCode ?? "claimed_attempt_database_failure"),
|
|
286
300
|
...(setupOutcomeUnknown ? { sandboxSetupOutcomeUnknown: true } : {}),
|
|
301
|
+
...(setupRecoveryExhausted ? { sandboxSetupRecoveryExhausted: true } : {}),
|
|
287
302
|
...(providerRecoveryCount !== undefined ? { providerRecoveryCount } : {}),
|
|
288
303
|
detail: {
|
|
289
304
|
code: setupOutcomeUnknown
|
|
290
305
|
? "sandbox_command_start_outcome_unknown"
|
|
291
|
-
:
|
|
292
|
-
|
|
306
|
+
: setupRecoveryExhausted
|
|
307
|
+
? "sandbox_command_start_recovery_exhausted"
|
|
308
|
+
: (providerFailureCode ?? recoveredClaimCode),
|
|
309
|
+
retryable: !setupOutcomeUnknown && !setupRecoveryExhausted,
|
|
293
310
|
...(setupOutcomeUnknown ? { setupOutcome: "unknown", replay: "blocked" } : {}),
|
|
311
|
+
...(setupRecoveryExhausted
|
|
312
|
+
? {
|
|
313
|
+
setupOutcome: "not_started",
|
|
314
|
+
replay: "blocked",
|
|
315
|
+
recoveryExhausted: true,
|
|
316
|
+
providerRecoveryCount: SANDBOX_SETUP_RECOVERY_LIMIT,
|
|
317
|
+
}
|
|
318
|
+
: {}),
|
|
294
319
|
...(providerRecoveryCount !== undefined
|
|
295
320
|
? {
|
|
296
321
|
databaseFailureCode: recoveredClaimCode,
|
package/src/activities/types.ts
CHANGED
|
@@ -144,6 +144,11 @@ export type ControlActivityServices = SharedActivityServices;
|
|
|
144
144
|
|
|
145
145
|
/** Turn workers own the model loop and never construct document parsers. */
|
|
146
146
|
export type TurnActivityServices = SharedActivityServices & {
|
|
147
|
+
/** Host-owned containment: stop polling and checkpoint peer turns before exit.
|
|
148
|
+
* Bare activity embedders must provide their own worker lifecycle edge. */
|
|
149
|
+
requestWorkerDrain: () => void;
|
|
150
|
+
/** Deterministic test clock for the cleanup-only monitor. */
|
|
151
|
+
turnFinalizationTimeoutMs?: number | undefined;
|
|
147
152
|
runtime: OpenGeniRuntime;
|
|
148
153
|
/** Provider-free test/profiling seam; production injects the real runtime summarizer. */
|
|
149
154
|
summarizeContextForCompaction: typeof import("@opengeni/runtime").summarizeForCompaction;
|
|
@@ -304,6 +309,8 @@ export type PostClaimDatabaseRecoveryDetail = {
|
|
|
304
309
|
providerFailureCode?: string;
|
|
305
310
|
/** Persist the no-replay setup marker, not a new provider/setup attempt. */
|
|
306
311
|
sandboxSetupOutcomeUnknown?: true;
|
|
312
|
+
/** Preserve the exhausted budget for a command proven not dispatched. */
|
|
313
|
+
sandboxSetupRecoveryExhausted?: true;
|
|
307
314
|
};
|
|
308
315
|
|
|
309
316
|
export const POST_CLAIM_DATABASE_RECOVERY_FAILURE_TYPE = "OpenGeniPostClaimDatabaseRecovery";
|
|
@@ -7,6 +7,7 @@ import { createAutomationActivities } from "./activities/automations";
|
|
|
7
7
|
import { createDocumentActivities } from "./activities/documents";
|
|
8
8
|
import { createFileUploadReaperActivities } from "./activities/file-upload-reaper";
|
|
9
9
|
import { createGoalActivities } from "./activities/goals";
|
|
10
|
+
import { createModelCallFactReconcilerActivities } from "./activities/model-call-fact-reconciler";
|
|
10
11
|
import { createKnowledgeSourceSyncActivities } from "./activities/knowledge-source-sync";
|
|
11
12
|
import { createRetainedScreenshotMaintenanceActivities } from "./activities/retained-screenshot-reaper";
|
|
12
13
|
import { createRigVerificationActivities } from "./activities/rig-verification";
|
|
@@ -42,6 +43,7 @@ export function createControlActivitiesFromServices(
|
|
|
42
43
|
...createBrowserStateArtifactMaintenanceActivities(services),
|
|
43
44
|
...createBrowserDeadlineCheckpointActivities(services),
|
|
44
45
|
...createFileUploadReaperActivities(services),
|
|
46
|
+
...createModelCallFactReconcilerActivities(services),
|
|
45
47
|
...createRetainedScreenshotMaintenanceActivities(services),
|
|
46
48
|
...createWorkflowWakeActivities(services),
|
|
47
49
|
...createSandboxLeaseActivities(services),
|
package/src/activities-turn.ts
CHANGED
|
@@ -12,6 +12,9 @@ function createTurnActivityServices(
|
|
|
12
12
|
let servicesPromise: Promise<TurnActivityServices> | null = null;
|
|
13
13
|
return async () => {
|
|
14
14
|
servicesPromise ??= (async () => {
|
|
15
|
+
if (!dependencies.requestWorkerDrain) {
|
|
16
|
+
throw new Error("Turn activities require a host requestWorkerDrain lifecycle edge");
|
|
17
|
+
}
|
|
15
18
|
const services = await shared();
|
|
16
19
|
return {
|
|
17
20
|
...services,
|
|
@@ -20,6 +23,8 @@ function createTurnActivityServices(
|
|
|
20
23
|
createProductionAgentRuntime({
|
|
21
24
|
metrics: runtimeMetricsHooksForObservability(services.observability),
|
|
22
25
|
}),
|
|
26
|
+
requestWorkerDrain: dependencies.requestWorkerDrain,
|
|
27
|
+
turnFinalizationTimeoutMs: dependencies.turnFinalizationTimeoutMs,
|
|
23
28
|
summarizeContextForCompaction:
|
|
24
29
|
dependencies.summarizeContextForCompaction ?? summarizeForCompaction,
|
|
25
30
|
};
|
package/src/activities.ts
CHANGED
|
@@ -69,6 +69,12 @@ export function createActivityTestHarness(dependencies: ActivityDependencies = {
|
|
|
69
69
|
createProductionAgentRuntime({
|
|
70
70
|
metrics: runtimeMetricsHooksForObservability(common.observability),
|
|
71
71
|
}),
|
|
72
|
+
requestWorkerDrain:
|
|
73
|
+
dependencies.requestWorkerDrain ??
|
|
74
|
+
(() => {
|
|
75
|
+
throw new Error("Test host must provide requestWorkerDrain for stalled cleanup");
|
|
76
|
+
}),
|
|
77
|
+
turnFinalizationTimeoutMs: dependencies.turnFinalizationTimeoutMs,
|
|
72
78
|
summarizeContextForCompaction:
|
|
73
79
|
dependencies.summarizeContextForCompaction ?? summarizeForCompaction,
|
|
74
80
|
documentServices: dependencies.documentServices ?? createDocumentServices(common.settings),
|
|
@@ -128,6 +134,7 @@ export const recoverVideoGenerationWorkflows =
|
|
|
128
134
|
defaultControlActivities.recoverVideoGenerationWorkflows;
|
|
129
135
|
export const maintainRetainedScreenshots = defaultControlActivities.maintainRetainedScreenshots;
|
|
130
136
|
export const maintainBrowserStateArtifacts = defaultControlActivities.maintainBrowserStateArtifacts;
|
|
137
|
+
export const reconcileRecentModelCallFacts = defaultControlActivities.reconcileRecentModelCallFacts;
|
|
131
138
|
export const maintainSiteAuthConnections = defaultControlActivities.maintainSiteAuthConnections;
|
|
132
139
|
export const dispatchSessionWorkflowWakes = defaultControlActivities.dispatchSessionWorkflowWakes;
|
|
133
140
|
export const verifyRigChange = defaultControlActivities.verifyRigChange;
|
package/src/http.ts
CHANGED
|
@@ -120,6 +120,7 @@ export async function runReadinessChecks(
|
|
|
120
120
|
export function dbReadyCheck(
|
|
121
121
|
db: Database,
|
|
122
122
|
posture?: RuntimeDatabasePostureOptions,
|
|
123
|
+
afterDatabaseReady?: () => Promise<void>,
|
|
123
124
|
): () => Promise<void> {
|
|
124
125
|
let inFlight: Promise<void> | undefined;
|
|
125
126
|
return () => {
|
|
@@ -130,9 +131,10 @@ export function dbReadyCheck(
|
|
|
130
131
|
inFlight = (async () => {
|
|
131
132
|
if (posture) {
|
|
132
133
|
await assertRuntimeDatabasePosture(db, posture);
|
|
133
|
-
|
|
134
|
+
} else {
|
|
135
|
+
await db.execute(dbSql`select 1`);
|
|
134
136
|
}
|
|
135
|
-
await
|
|
137
|
+
await afterDatabaseReady?.();
|
|
136
138
|
})().finally(() => {
|
|
137
139
|
inFlight = undefined;
|
|
138
140
|
});
|
package/src/index.ts
CHANGED
|
@@ -85,6 +85,7 @@ import {
|
|
|
85
85
|
combineWorkerRunTargets,
|
|
86
86
|
constructWithOwnedConnection,
|
|
87
87
|
createWorkerServiceLifecycle,
|
|
88
|
+
createWorkerCleanupContainment,
|
|
88
89
|
type WorkerServiceLifecycle,
|
|
89
90
|
type WorkerRunTarget,
|
|
90
91
|
} from "./worker-service-lifecycle";
|
|
@@ -271,10 +272,20 @@ export async function createOpenGeniWorker(options: WorkerOptions): Promise<{
|
|
|
271
272
|
},
|
|
272
273
|
),
|
|
273
274
|
async (connection) => {
|
|
275
|
+
let turnWorker: Worker | undefined;
|
|
276
|
+
let cleanupDrainRequested = false;
|
|
274
277
|
const activityDependencies = {
|
|
275
278
|
...options.activityDependencies,
|
|
276
279
|
settings,
|
|
277
280
|
observability,
|
|
281
|
+
requestWorkerDrain:
|
|
282
|
+
options.activityDependencies?.requestWorkerDrain ??
|
|
283
|
+
(() => {
|
|
284
|
+
if (cleanupDrainRequested) return;
|
|
285
|
+
if (!turnWorker) throw new Error("turn worker lifecycle is not initialized");
|
|
286
|
+
turnWorker.shutdown();
|
|
287
|
+
cleanupDrainRequested = true;
|
|
288
|
+
}),
|
|
278
289
|
};
|
|
279
290
|
const activities =
|
|
280
291
|
options.activities ??
|
|
@@ -323,6 +334,7 @@ export async function createOpenGeniWorker(options: WorkerOptions): Promise<{
|
|
|
323
334
|
: turnConcurrency!.options),
|
|
324
335
|
});
|
|
325
336
|
if (options.role !== "control") {
|
|
337
|
+
turnWorker = worker;
|
|
326
338
|
turnConcurrency?.admission?.finalizeStartupBaseline();
|
|
327
339
|
return { worker, connection };
|
|
328
340
|
}
|
|
@@ -822,6 +834,9 @@ export async function registerSessionWorkflowWakeDispatcherSchedule(
|
|
|
822
834
|
|
|
823
835
|
export type OpenGeniWorkerServiceOptions = Omit<WorkerOptions, "activityDependencies"> & {
|
|
824
836
|
activityDependencies: ActivityDependencies & { db: Database; bus: EventBus };
|
|
837
|
+
/** Host-owned final containment after a stalled cleanup has drained peers.
|
|
838
|
+
* The standalone process exits; embedded hosts own their termination policy. */
|
|
839
|
+
terminateWorker?: () => void;
|
|
825
840
|
/**
|
|
826
841
|
* Exact catalog posture required by the standalone runtime. Embedded hosts
|
|
827
842
|
* may omit this when they own an equivalent database isolation contract.
|
|
@@ -878,6 +893,7 @@ export async function createOpenGeniWorkerService(
|
|
|
878
893
|
const onRetry = (event: Parameters<typeof logStartupDependencyRetry>[1]) =>
|
|
879
894
|
logStartupDependencyRetry(observability, event);
|
|
880
895
|
let lifecycle: WorkerServiceLifecycle | undefined;
|
|
896
|
+
let cleanupContainment: ReturnType<typeof createWorkerCleanupContainment> | undefined;
|
|
881
897
|
let signaler: Awaited<ReturnType<typeof createWorkerWorkflowSignaler>> | undefined;
|
|
882
898
|
let workerBundle: Awaited<ReturnType<typeof createOpenGeniWorker>> | undefined;
|
|
883
899
|
let turnCapacityMonitor: ReturnType<typeof startTurnCapacityMonitor> | undefined;
|
|
@@ -957,6 +973,10 @@ export async function createOpenGeniWorkerService(
|
|
|
957
973
|
signalCodexCapacityWorkflow,
|
|
958
974
|
startSandboxReaperWorkflow,
|
|
959
975
|
startVideoGenerationWorkflow,
|
|
976
|
+
requestWorkerDrain: () => {
|
|
977
|
+
if (!cleanupContainment) throw new Error("worker cleanup containment is not initialized");
|
|
978
|
+
cleanupContainment.request();
|
|
979
|
+
},
|
|
960
980
|
},
|
|
961
981
|
});
|
|
962
982
|
|
|
@@ -1018,18 +1038,15 @@ export async function createOpenGeniWorkerService(
|
|
|
1018
1038
|
}
|
|
1019
1039
|
|
|
1020
1040
|
if (options.http !== false) {
|
|
1021
|
-
const
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
);
|
|
1041
|
+
const readinessDb = options.http?.readinessDb ?? options.activityDependencies.db;
|
|
1042
|
+
const databaseReady = dbReadyCheck(readinessDb, options.databasePosture, async () => {
|
|
1043
|
+
await resolveCatalogSettings(readinessDb, settings);
|
|
1044
|
+
});
|
|
1025
1045
|
httpServer = startWorkerHttpServer({
|
|
1026
1046
|
settings,
|
|
1027
1047
|
observability,
|
|
1028
1048
|
checks: {
|
|
1029
|
-
db:
|
|
1030
|
-
await databaseReady();
|
|
1031
|
-
await resolveCatalogSettings(options.activityDependencies.db, settings);
|
|
1032
|
-
},
|
|
1049
|
+
db: databaseReady,
|
|
1033
1050
|
nats: natsReadyCheck(options.activityDependencies.bus),
|
|
1034
1051
|
temporal: temporalReadyCheck(workerBundle.connection),
|
|
1035
1052
|
},
|
|
@@ -1056,9 +1073,21 @@ export async function createOpenGeniWorkerService(
|
|
|
1056
1073
|
throw new Error("OpenGeni worker service initialization did not complete");
|
|
1057
1074
|
}
|
|
1058
1075
|
|
|
1076
|
+
cleanupContainment = createWorkerCleanupContainment({
|
|
1077
|
+
drain: () => lifecycle?.drain("stalled turn finalization") ?? false,
|
|
1078
|
+
...(options.terminateWorker ? { terminate: options.terminateWorker } : {}),
|
|
1079
|
+
observability,
|
|
1080
|
+
});
|
|
1081
|
+
const activeCleanupContainment = cleanupContainment;
|
|
1059
1082
|
lifecycle = createWorkerServiceLifecycle({
|
|
1060
1083
|
role: options.role,
|
|
1061
|
-
worker:
|
|
1084
|
+
worker: {
|
|
1085
|
+
shutdown: () => activeWorkerBundle.worker.shutdown(),
|
|
1086
|
+
run: async () => {
|
|
1087
|
+
await activeWorkerBundle.worker.run();
|
|
1088
|
+
activeCleanupContainment.finished();
|
|
1089
|
+
},
|
|
1090
|
+
},
|
|
1062
1091
|
observability,
|
|
1063
1092
|
closeOwnedResources: async () => {
|
|
1064
1093
|
memoryPressureGuard?.close();
|
|
@@ -1210,6 +1239,7 @@ export async function startWorker() {
|
|
|
1210
1239
|
);
|
|
1211
1240
|
await runOpenGeniWorker({
|
|
1212
1241
|
role,
|
|
1242
|
+
terminateWorker: () => process.exit(1),
|
|
1213
1243
|
settings,
|
|
1214
1244
|
databasePosture,
|
|
1215
1245
|
http: { readinessDb: readinessDbClient.db },
|
|
@@ -1285,6 +1285,32 @@ export function recordSandboxDeadlineRotationsRequested(
|
|
|
1285
1285
|
});
|
|
1286
1286
|
}
|
|
1287
1287
|
|
|
1288
|
+
/** Fixed outcomes of legacy retained-command containment: per-candidate
|
|
1289
|
+
* enrollment inspection, then the enrolled drain's exact cold commit. */
|
|
1290
|
+
export const SANDBOX_COMMAND_CONTAINMENT_OUTCOMES = [
|
|
1291
|
+
"idle_enrolled",
|
|
1292
|
+
"deadline_enrolled",
|
|
1293
|
+
"resumed_enrolled",
|
|
1294
|
+
"not_eligible",
|
|
1295
|
+
"inspection_failed",
|
|
1296
|
+
"contained",
|
|
1297
|
+
"provider_missing",
|
|
1298
|
+
] as const;
|
|
1299
|
+
|
|
1300
|
+
export type SandboxCommandContainmentOutcome =
|
|
1301
|
+
(typeof SANDBOX_COMMAND_CONTAINMENT_OUTCOMES)[number];
|
|
1302
|
+
|
|
1303
|
+
export function recordSandboxCommandContainment(
|
|
1304
|
+
observability: Observability,
|
|
1305
|
+
outcome: SandboxCommandContainmentOutcome,
|
|
1306
|
+
): void {
|
|
1307
|
+
observability.incrementCounter({
|
|
1308
|
+
name: "opengeni_sandbox_command_containment_total",
|
|
1309
|
+
help: "Legacy retained-command containment inspections and enrolled drain commits by fixed outcome.",
|
|
1310
|
+
labels: { outcome },
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1288
1314
|
/** Only call after the exact draining->cold commit reports wentCold. The
|
|
1289
1315
|
* backend is validated against the closed contract so provider IDs and other
|
|
1290
1316
|
* per-sandbox values can never become metric labels. */
|
|
@@ -1471,6 +1497,7 @@ export function recordExpiredDrainingSandboxLeaseGauges(
|
|
|
1471
1497
|
|
|
1472
1498
|
export const RETAINED_PROCESS_RECONCILIATION_OUTCOMES = [
|
|
1473
1499
|
"claim_failed",
|
|
1500
|
+
"background_recovered",
|
|
1474
1501
|
"proof_exited",
|
|
1475
1502
|
"proof_lost",
|
|
1476
1503
|
"proof_checkpoint_failed",
|
package/src/sandbox-resume.ts
CHANGED
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
//
|
|
21
21
|
// Liveness between turns is the lease refcount; there is no keepalive loop.
|
|
22
22
|
|
|
23
|
+
import type { EventBus } from "@opengeni/events";
|
|
24
|
+
import { publishDurableSessionEvents } from "./session-event-fanout";
|
|
23
25
|
import {
|
|
24
26
|
effectiveSandboxLifecycle,
|
|
25
27
|
sandboxArchiveCaptureTimeoutMs,
|
|
@@ -171,6 +173,8 @@ export type SandboxResumeServices = {
|
|
|
171
173
|
* call. Absent, the call gets its own single replacement.
|
|
172
174
|
*/
|
|
173
175
|
freshSandboxReadinessReplacementBudget?: FreshSandboxReadinessReplacementBudget;
|
|
176
|
+
/** Live fanout for background commands a provider loss settled. */
|
|
177
|
+
bus?: EventBus | null;
|
|
174
178
|
/** Called only by the observer that wins the exact warm->cold loss CAS. */
|
|
175
179
|
onSandboxLost?: (input: {
|
|
176
180
|
sandboxGroupId: string;
|
|
@@ -2155,6 +2159,11 @@ async function resumeBoxForTurnOnce(
|
|
|
2155
2159
|
expectedBackend: ids.backend,
|
|
2156
2160
|
});
|
|
2157
2161
|
if (marked.status === "marked") {
|
|
2162
|
+
await publishDurableSessionEvents(
|
|
2163
|
+
services.bus,
|
|
2164
|
+
ids.workspaceId,
|
|
2165
|
+
marked.backgroundCommandEvents,
|
|
2166
|
+
);
|
|
2158
2167
|
await services.onSandboxLost?.({
|
|
2159
2168
|
sandboxGroupId: ids.sandboxGroupId,
|
|
2160
2169
|
instanceId: live.instanceId,
|