@opengeni/worker-bundle 1.0.1 → 2.0.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.
Files changed (122) hide show
  1. package/dist/activities/agent-turn/codex.d.ts +1 -1
  2. package/dist/activities/agent-turn/compaction-prep.d.ts +1 -5
  3. package/dist/activities/agent-turn/errors.d.ts +31 -5
  4. package/dist/activities/agent-turn/finalization-monitor.d.ts +17 -0
  5. package/dist/activities/agent-turn/finalization.d.ts +1 -1
  6. package/dist/activities/agent-turn/governance-model.d.ts +0 -2
  7. package/dist/activities/agent-turn/quiescence.d.ts +1 -0
  8. package/dist/activities/agent-turn/recovery-warning.d.ts +8 -0
  9. package/dist/activities/agent-turn/run-credentials.d.ts +0 -1
  10. package/dist/activities/agent-turn/sandbox-establish.d.ts +1 -1
  11. package/dist/activities/agent-turn/sandbox-route.d.ts +0 -20
  12. package/dist/activities/agent-turn/sandbox-runtime.d.ts +1 -1
  13. package/dist/activities/agent-turn/skill-remove.d.ts +14 -0
  14. package/dist/activities/agent-turn/skill-search.d.ts +1 -1
  15. package/dist/activities/agent-turn/stream-attempt.d.ts +1 -0
  16. package/dist/activities/agent-turn/tool-environment.d.ts +0 -4
  17. package/dist/activities/agent-turn/turn-context.d.ts +2 -0
  18. package/dist/activities/agent-turn/turn-sandbox-access.d.ts +3 -0
  19. package/dist/activities/codex-capacity.d.ts +6 -1
  20. package/dist/activities/codex-rotation.d.ts +1 -1
  21. package/dist/activities/context-compaction.d.ts +7 -1
  22. package/dist/activities/mcp-account-routes.d.ts +22 -0
  23. package/dist/activities/mcp-credentials.d.ts +12 -13
  24. package/dist/activities/mcp-operation-authority.d.ts +0 -5
  25. package/dist/activities/rig-verification.d.ts +2 -3
  26. package/dist/activities/sandbox-images.d.ts +1 -1
  27. package/dist/activities/sandbox-lease.d.ts +4 -3
  28. package/dist/activities/session-state.d.ts +57 -2
  29. package/dist/activities/types.d.ts +12 -3
  30. package/dist/{activities-control-HMFGSKC5.js → activities-control-BOLIY3H4.js} +366 -187
  31. package/dist/activities-control-BOLIY3H4.js.map +1 -0
  32. package/dist/activities-control.d.ts +110 -2
  33. package/dist/{activities-turn-AP67JADS.js → activities-turn-2B3WMHBB.js} +767 -471
  34. package/dist/activities-turn-2B3WMHBB.js.map +1 -0
  35. package/dist/activities.d.ts +110 -2
  36. package/dist/artifact-materializer-entry.js +5 -2
  37. package/dist/artifact-materializer-entry.js.map +1 -1
  38. package/dist/artifact-outbox-entry.js +2 -2
  39. package/dist/artifact-outbox-entry.js.map +1 -1
  40. package/dist/{chunk-A4XQWVZK.js → chunk-I7HKKMUJ.js} +77 -1
  41. package/dist/chunk-I7HKKMUJ.js.map +1 -0
  42. package/dist/{chunk-CYITU5UH.js → chunk-TVEE2UUC.js} +275 -146
  43. package/dist/chunk-TVEE2UUC.js.map +1 -0
  44. package/dist/editable-artifact-materializer-service.d.ts +2 -2
  45. package/dist/editable-artifact-outbox-service.d.ts +2 -2
  46. package/dist/index.d.ts +55 -1
  47. package/dist/index.js +4 -3
  48. package/dist/index.js.map +1 -1
  49. package/dist/observability-metrics.d.ts +11 -3
  50. package/dist/retained-process-diagnostics.d.ts +4 -0
  51. package/dist/sandbox-resume.d.ts +2 -12
  52. package/dist/sandbox-routing.d.ts +5 -3
  53. package/dist/sandbox-snapshot-diagnostics.d.ts +20 -0
  54. package/dist/warm-capture-holder.d.ts +3 -0
  55. package/dist/workflow-bundle.js +51 -11
  56. package/package.json +15 -15
  57. package/src/activities/agent-turn/agent-build.ts +42 -5
  58. package/src/activities/agent-turn/claim.ts +12 -0
  59. package/src/activities/agent-turn/codex-capacity.ts +26 -4
  60. package/src/activities/agent-turn/codex.ts +6 -2
  61. package/src/activities/agent-turn/compaction-prep.ts +53 -72
  62. package/src/activities/agent-turn/errors.ts +98 -10
  63. package/src/activities/agent-turn/failure-settlement.ts +49 -28
  64. package/src/activities/agent-turn/finalization-monitor.ts +121 -0
  65. package/src/activities/agent-turn/finalization.ts +79 -21
  66. package/src/activities/agent-turn/governance-model.ts +15 -19
  67. package/src/activities/agent-turn/history.ts +24 -10
  68. package/src/activities/agent-turn/quiescence.ts +3 -0
  69. package/src/activities/agent-turn/recovery-warning.ts +18 -0
  70. package/src/activities/agent-turn/run-credentials.ts +3 -7
  71. package/src/activities/agent-turn/run.ts +68 -52
  72. package/src/activities/agent-turn/sandbox-establish.ts +10 -2
  73. package/src/activities/agent-turn/sandbox-route.ts +0 -20
  74. package/src/activities/agent-turn/sandbox-runtime.ts +40 -7
  75. package/src/activities/agent-turn/skill-remove.ts +46 -0
  76. package/src/activities/agent-turn/skill-save.ts +1 -1
  77. package/src/activities/agent-turn/skill-search.ts +1 -1
  78. package/src/activities/agent-turn/skill-selection.ts +2 -0
  79. package/src/activities/agent-turn/skill-tools.ts +10 -5
  80. package/src/activities/agent-turn/stream-attempt.ts +38 -15
  81. package/src/activities/agent-turn/tool-environment.ts +44 -29
  82. package/src/activities/agent-turn/turn-context.ts +3 -0
  83. package/src/activities/agent-turn/turn-sandbox-access.ts +7 -1
  84. package/src/activities/capabilities.ts +17 -2
  85. package/src/activities/codex-capacity.ts +38 -9
  86. package/src/activities/codex-rotation.ts +18 -5
  87. package/src/activities/context-compaction.ts +26 -4
  88. package/src/activities/goals.ts +1 -0
  89. package/src/activities/google-drive-publication.ts +1 -3
  90. package/src/activities/mcp-account-routes.ts +139 -0
  91. package/src/activities/mcp-credentials.ts +83 -194
  92. package/src/activities/mcp-operation-authority.ts +16 -41
  93. package/src/activities/parent-wake.ts +8 -0
  94. package/src/activities/rig-verification.ts +38 -37
  95. package/src/activities/run-input.ts +25 -16
  96. package/src/activities/sandbox-images.ts +0 -1
  97. package/src/activities/sandbox-lease.ts +177 -44
  98. package/src/activities/scheduled-tasks.ts +118 -93
  99. package/src/activities/session-state.ts +83 -3
  100. package/src/activities/types.ts +18 -2
  101. package/src/activities/workflow-wake.ts +2 -17
  102. package/src/editable-artifact-materializer-service.ts +7 -3
  103. package/src/editable-artifact-outbox-service.ts +4 -4
  104. package/src/github-rest-mcp.ts +0 -1
  105. package/src/index.ts +2 -1
  106. package/src/observability-metrics.ts +105 -2
  107. package/src/personal-github-git-credentials.ts +0 -1
  108. package/src/pr-review-credentials.ts +0 -2
  109. package/src/retained-process-diagnostics.ts +50 -0
  110. package/src/sandbox-resume.ts +58 -73
  111. package/src/sandbox-routing.ts +44 -19
  112. package/src/sandbox-snapshot-diagnostics.ts +112 -0
  113. package/src/warm-capture-holder.ts +15 -0
  114. package/src/workflows/session.ts +64 -2
  115. package/dist/activities/agent-turn/knowledge-sources.d.ts +0 -20
  116. package/dist/activities/packs.d.ts +0 -55
  117. package/dist/activities-control-HMFGSKC5.js.map +0 -1
  118. package/dist/activities-turn-AP67JADS.js.map +0 -1
  119. package/dist/chunk-A4XQWVZK.js.map +0 -1
  120. package/dist/chunk-CYITU5UH.js.map +0 -1
  121. package/src/activities/agent-turn/knowledge-sources.ts +0 -86
  122. package/src/activities/packs.ts +0 -166
@@ -11,10 +11,16 @@ import {
11
11
  ActiveBackendUnresolvableError,
12
12
  CompactionProviderResponseError,
13
13
  EmptyCompactionSummaryError,
14
+ compactionProviderRejection,
15
+ describeCompactionProviderRejection,
16
+ type CompactionProviderRejection,
14
17
  isMcpRequestTimeoutError,
15
18
  isMcpTransportConnectivityError,
16
19
  isModalTaskExecStartDnsResolutionError,
17
20
  RoutingWorkspaceRootChangedError,
21
+ SandboxMaterializationVerificationError,
22
+ materializationVerificationDiagnostic,
23
+ type MaterializationVerificationDiagnostic,
18
24
  SelfhostedWorkspaceRootChangedError,
19
25
  UNKNOWN_MODEL_FINISH_REASON_CODE,
20
26
  } from "@opengeni/runtime";
@@ -28,6 +34,7 @@ import { RetainedAttachmentTransportLimitError } from "../run-input";
28
34
  import type { CodexAccountStatus } from "@opengeni/db";
29
35
  import {
30
36
  CodexReloginRequired,
37
+ classifyCodexEncryptedArtifactRejection,
31
38
  classifyCodexResponseTimeoutError,
32
39
  classifyCodexUsageLimitError,
33
40
  isCodexTransportError,
@@ -269,22 +276,38 @@ export function escapedMcpTimeoutRecoveryFailure(input: {
269
276
  * Convert the atomic claim transaction's failure into a small, stable
270
277
  * Temporal wire contract. The original error remains in activity diagnostics,
271
278
  * but SQL text, parameters, and arbitrary invariant messages never enter
272
- * workflow history. Every database failure is safe to re-read after backoff
273
- * because the claim transaction contains no model/tool effects. Database
274
- * constraints and authorization guards can also be repaired by a deployment or
275
- * authority refresh, so they must preserve pending work. Only a non-database
276
- * claim invariant is terminal.
279
+ * workflow history. Operational failures retry; other persistence rejections
280
+ * retain accepted work behind a durable explicit-recheck fence. Repeating the
281
+ * same rejected transaction without changed conditions is not recovery.
277
282
  */
278
283
  export function preClaimAdmissionFailure(error: unknown): ApplicationFailure {
279
284
  const persistenceFailure = isSessionEventPersistenceError(error) ? error : null;
280
285
  const retryableCode = retryableDatabaseFailureCode(error);
281
- // The workflow retries after a durable re-read and bounded delay. A
282
- // SessionEventPersistenceError proves the failure happened inside the atomic
283
- // claim boundary, before provider or tool work; retaining it is safer than
284
- // destroying accepted machine input on an application SQLSTATE.
286
+ const rejectedClaim =
287
+ persistenceFailure?.details.stage === "session_attempts.claim" &&
288
+ persistenceFailure.details.retryOutcome === "not_retryable" &&
289
+ !retryableCode;
285
290
  const detail: PreClaimFailureDetail = {
286
- disposition: persistenceFailure || retryableCode ? "retryable" : "permanent",
291
+ disposition: rejectedClaim
292
+ ? "blocked"
293
+ : persistenceFailure || retryableCode
294
+ ? "retryable"
295
+ : "permanent",
287
296
  code: retryableCode ?? persistenceFailure?.details.code ?? "claim_invariant",
297
+ ...(persistenceFailure && rejectedClaim
298
+ ? {
299
+ sqlState: persistenceFailure.details.sqlState,
300
+ reason:
301
+ persistenceFailure.details.stage === "session_attempts.claim" &&
302
+ persistenceFailure.details.sqlState === "OG001"
303
+ ? ("initiator_membership_required" as const)
304
+ : persistenceFailure.details.stage === "session_attempts.claim" &&
305
+ persistenceFailure.details.sqlState === "OG002"
306
+ ? ("personal_resource_grant_required" as const)
307
+ : ("database_claim_rejected" as const),
308
+ retryPolicy: "explicit_recheck" as const,
309
+ }
310
+ : {}),
288
311
  };
289
312
  return ApplicationFailure.create({
290
313
  message: PRE_CLAIM_FAILURE_MESSAGE,
@@ -574,7 +597,21 @@ export function safeErrorForTelemetry(error: unknown): Error {
574
597
  return safe;
575
598
  }
576
599
 
600
+ /**
601
+ * Guidance appended to a definitive provider rejection. Repeating the exact
602
+ * request cannot succeed, so the generic "send another message to retry"
603
+ * advice is wrong here: a new prompt re-sends the same rejected history.
604
+ */
605
+ export const COMPACTION_PROVIDER_REJECTION_GUIDANCE =
606
+ "The provider refused this exact request, so repeating it fails the same way until the conversation changes. If a new message fails again, start a new session.";
607
+
577
608
  export function compactionFailureReasonFromError(error: unknown): string {
609
+ const rejection = compactionProviderRejection(error);
610
+ if (rejection) {
611
+ return compactionFailureReason(
612
+ `the model provider rejected the compaction request (${describeCompactionProviderRejection(rejection)}). Active history was preserved. ${COMPACTION_PROVIDER_REJECTION_GUIDANCE}`,
613
+ );
614
+ }
578
615
  if (
579
616
  error instanceof CompactionProviderResponseError ||
580
617
  error instanceof EmptyCompactionSummaryError
@@ -585,6 +622,34 @@ export function compactionFailureReasonFromError(error: unknown): string {
585
622
  return compactionFailureReason(`unexpected ${errorName}`);
586
623
  }
587
624
 
625
+ /**
626
+ * Exact `turn.failed` payload for a terminal compaction failure. A definitive
627
+ * provider rejection additionally carries its closed identifier record so the
628
+ * timeline, API consumers, and operators can name the rejected field without
629
+ * parsing the message.
630
+ */
631
+ export function compactionFailureTurnEventPayload(
632
+ error: unknown,
633
+ overrides: { error?: string } = {},
634
+ ): {
635
+ error: string;
636
+ code: "context_compaction_failed";
637
+ retryable: false;
638
+ recovery: "user_message";
639
+ compacted: false;
640
+ providerRejection?: CompactionProviderRejection;
641
+ } {
642
+ const rejection = compactionProviderRejection(error);
643
+ return {
644
+ error: overrides.error ?? compactionFailureReasonFromError(error),
645
+ code: "context_compaction_failed",
646
+ retryable: false,
647
+ recovery: "user_message",
648
+ compacted: false,
649
+ ...(rejection ? { providerRejection: rejection } : {}),
650
+ };
651
+ }
652
+
588
653
  export function isCompactionSummaryFailure(error: unknown): boolean {
589
654
  return (
590
655
  error instanceof CompactionProviderResponseError || error instanceof EmptyCompactionSummaryError
@@ -594,6 +659,11 @@ export function isCompactionSummaryFailure(error: unknown): boolean {
594
659
  export function shouldRecoverCompactionProviderFailure(error: unknown): boolean {
595
660
  if (!(error instanceof CompactionProviderResponseError)) return false;
596
661
  if (isCodexTransportError(error) && classifyCodexUsageLimitError(error)) return true;
662
+ // Codex may reject an opaque artifact it minted itself on the compaction
663
+ // request exactly as it can on an ordinary request. Failure settlement
664
+ // invalidates only the exact participating artifacts and recovers the same
665
+ // logical turn; when nothing can be invalidated it fails closed there.
666
+ if (classifyCodexEncryptedArtifactRejection(error)) return true;
597
667
  return agentRunFailurePayload(error).retryable === true;
598
668
  }
599
669
 
@@ -851,6 +921,15 @@ function findPostgresDriverError(error: unknown): Record<string, unknown> | null
851
921
  export function agentRunFailurePayload(
852
922
  error: unknown,
853
923
  options: { isCodexTurn?: boolean } = {},
924
+ ): ReturnType<typeof baseAgentRunFailurePayload> {
925
+ const failure = baseAgentRunFailurePayload(error, options);
926
+ const diagnostic = materializationVerificationDiagnostic(error);
927
+ return diagnostic ? { ...failure, materializationDiagnostic: diagnostic } : failure;
928
+ }
929
+
930
+ function baseAgentRunFailurePayload(
931
+ error: unknown,
932
+ options: { isCodexTurn?: boolean } = {},
854
933
  ): {
855
934
  error: string;
856
935
  code?: string;
@@ -870,7 +949,16 @@ export function agentRunFailurePayload(
870
949
  database?: Record<string, string>;
871
950
  historyPersistenceStage?: MandatoryHistoryPersistenceStage;
872
951
  mcpTransportDiagnostic?: McpTransportRequestFailureDiagnostic;
952
+ materializationDiagnostic?: MaterializationVerificationDiagnostic;
873
953
  } {
954
+ if (error instanceof SandboxMaterializationVerificationError) {
955
+ return {
956
+ error: error.message,
957
+ code: error.code,
958
+ retryable: false,
959
+ materializationDiagnostic: error.diagnostic,
960
+ };
961
+ }
874
962
  if (error instanceof RetainedAttachmentTransportLimitError) {
875
963
  return { error: error.message, code: "retained_attachment_transport_limit", retryable: false };
876
964
  }
@@ -225,6 +225,16 @@ function acceptedCodexPolicySnapshot(
225
225
  }
226
226
 
227
227
  export async function settleTurnFailure(deps: TurnFailureDeps): Promise<RunAgentTurnResult> {
228
+ if (deps.settings.environment === "local" && deps.error instanceof Error) {
229
+ // Keep local startup failures diagnosable without logging error messages,
230
+ // absolute host paths, prompts, credentials, or provider response bodies.
231
+ const locations = (deps.error.stack ?? "")
232
+ .split("\n")
233
+ .slice(1)
234
+ .flatMap((line) => line.match(/(?:apps|packages)\/[A-Za-z0-9_./-]+:\d+:\d+/g) ?? [])
235
+ .slice(0, 8);
236
+ console.error(JSON.stringify({ message: "Local turn failure source locations", locations }));
237
+ }
228
238
  const {
229
239
  error,
230
240
  input,
@@ -249,6 +259,36 @@ export async function settleTurnFailure(deps: TurnFailureDeps): Promise<RunAgent
249
259
  acknowledgeLostAttemptOwnership,
250
260
  acknowledgeRecoveryQuiescence,
251
261
  } = deps;
262
+ // Capture before any recovery/checkpoint DB operation can fail again.
263
+ if (isSessionEventPersistenceError(error)) {
264
+ try {
265
+ const diagnosticId = observability.recordFailureDiagnostic({
266
+ code: error.details.code,
267
+ stage:
268
+ error.details.stage === "session_events.append_generic"
269
+ ? "session_events.append_generic"
270
+ : error.details.stage === "session_events.append_for_turn_attempt"
271
+ ? "session_events.append_for_turn_attempt"
272
+ : error.details.stage === "session_attempts.claim"
273
+ ? "session_attempts.claim"
274
+ : "failure_settlement",
275
+ retryDecision: error.details.retryOutcome,
276
+ error,
277
+ sessionId: input.sessionId,
278
+ ...(attempt.turnId ? { turnId: attempt.turnId } : {}),
279
+ attemptId: input.attemptId,
280
+ attempts: error.details.attempts,
281
+ eventTypes: error.details.eventTypes,
282
+ sqlState: error.details.sqlState,
283
+ ...(error.details.database.constraint
284
+ ? { constraint: error.details.database.constraint }
285
+ : {}),
286
+ });
287
+ observability.error("session event persistence failed", { correlationId: diagnosticId });
288
+ } catch {
289
+ // Failure settlement must not depend on telemetry availability.
290
+ }
291
+ }
252
292
  // Graceful worker shutdown (deploy / rollout restart): checkpoint the
253
293
  // same current inference for a new fenced attempt instead of failing the
254
294
  // session. Conversation truth is already persisted per model response;
@@ -724,7 +764,9 @@ export async function settleTurnFailure(deps: TurnFailureDeps): Promise<RunAgent
724
764
  labels: { workspace_key: codexWorkspaceKey, outcome: "completed" },
725
765
  });
726
766
  const now = new Date();
727
- const before = await listCodexAccountStatuses(db, input.workspaceId).catch(() => []);
767
+ const before = await listCodexAccountStatuses(db, input.workspaceId, attempt.turnId).catch(
768
+ () => [],
769
+ );
728
770
  const servingCached = before.find(
729
771
  (account) => account.id === providerTurn.effectiveCodexCredentialId,
730
772
  );
@@ -892,7 +934,7 @@ export async function settleTurnFailure(deps: TurnFailureDeps): Promise<RunAgent
892
934
  }
893
935
  let accounts: Awaited<ReturnType<typeof listCodexAccountStatuses>>;
894
936
  try {
895
- accounts = await listCodexAccountStatuses(db, input.workspaceId);
937
+ accounts = await listCodexAccountStatuses(db, input.workspaceId, attempt.turnId);
896
938
  } catch (metadataError) {
897
939
  // Current account health/cooldown metadata is still required after
898
940
  // quarantine. Operational database failures re-enter the existing
@@ -1126,6 +1168,11 @@ export async function settleTurnFailure(deps: TurnFailureDeps): Promise<RunAgent
1126
1168
  { onArmed: () => (leases.codex.held = false) },
1127
1169
  );
1128
1170
  control.activityError = error;
1171
+ if (evaluated.action === "stopped") {
1172
+ control.activityStatus = evaluated.sessionStatus === "queued" ? "idle" : "failed";
1173
+ control.turnMetricOutcome = "failed";
1174
+ return claimedResult({ status: control.activityStatus });
1175
+ }
1129
1176
  if (evaluated.action === "resumed") {
1130
1177
  control.activityStatus = "recovering";
1131
1178
  control.turnMetricOutcome = "recovering";
@@ -1455,32 +1502,6 @@ export async function settleTurnFailure(deps: TurnFailureDeps): Promise<RunAgent
1455
1502
  let failure = agentRunFailurePayload(error, {
1456
1503
  isCodexTurn: billingState.isCodexTurn,
1457
1504
  }) as ReturnType<typeof agentRunFailurePayload>;
1458
- if (isSessionEventPersistenceError(error)) {
1459
- // Preserve the exact source message in the internal runtime diagnostic;
1460
- // SQLSTATE/catalog facts remain separate classification attributes.
1461
- observability.error("session event persistence failed", {
1462
- accountId: input.accountId,
1463
- workspaceId: input.workspaceId,
1464
- sessionId: input.sessionId,
1465
- turnId: attempt.turnId,
1466
- attemptId: input.attemptId,
1467
- code: error.details.code,
1468
- sqlState: error.details.sqlState ?? "unknown",
1469
- stage: error.details.stage,
1470
- eventTypes: error.details.eventTypes.join(","),
1471
- correlationId: error.details.correlationId,
1472
- attempts: error.details.attempts,
1473
- retryOutcome: error.details.retryOutcome,
1474
- dbSeverity: error.details.database.severity,
1475
- dbSchema: error.details.database.schema,
1476
- dbTable: error.details.database.table,
1477
- dbColumn: error.details.database.column,
1478
- dbDataType: error.details.database.dataType,
1479
- dbConstraint: error.details.database.constraint,
1480
- dbRoutine: error.details.database.routine,
1481
- error: error.message,
1482
- });
1483
- }
1484
1505
  if (failure.retryable && eventing.publish && attempt.turnId && eventing.turnStartedPublished) {
1485
1506
  const nextProviderRecoveryCount = attempt.providerRecoveryCount + 1;
1486
1507
  const recoveryResult = providerRecoveryResult({
@@ -0,0 +1,121 @@
1
+ import type { Observability } from "@opengeni/observability";
2
+ import type { TurnHeartbeatDetails } from "../../op-journal";
3
+ import { armTurnQuiescenceWatchdog } from "./quiescence";
4
+
5
+ export const TURN_FINALIZATION_STAGES = [
6
+ "tool_writers",
7
+ "credential_renewals",
8
+ "credential_cleanup",
9
+ "quiescence_receipt",
10
+ "event_flush",
11
+ "provider_leases",
12
+ "workspace_capture",
13
+ "tool_close",
14
+ "sandbox_provisioning",
15
+ "sandbox_rotation",
16
+ "workspace_snapshot",
17
+ "sandbox_release",
18
+ ] as const;
19
+ export type TurnFinalizationStage = (typeof TURN_FINALIZATION_STAGES)[number];
20
+
21
+ const INFLIGHT = {
22
+ name: "opengeni_turn_finalization_inflight",
23
+ help: "Physical turn finalizers currently waiting in each cleanup stage.",
24
+ };
25
+ const SLOW_STAGES = {
26
+ name: "opengeni_turn_finalization_slow_total",
27
+ help: "Turn cleanup stages observed to exceed thirty seconds.",
28
+ };
29
+ const initialized = new WeakSet<Observability>();
30
+ function diagnose(operation: () => void): void {
31
+ try {
32
+ operation();
33
+ } catch {
34
+ // Diagnostics cannot own physical cleanup or containment.
35
+ }
36
+ }
37
+
38
+ /** Starts only after agent execution ends. A cleanup-stage deadline never caps
39
+ * agent work and never detaches a physical writer to manufacture quiescence. */
40
+ export function startTurnFinalizationMonitor(input: {
41
+ observability: Observability;
42
+ details: TurnHeartbeatDetails;
43
+ heartbeat: (details: TurnHeartbeatDetails) => void;
44
+ terminateWorker: () => void;
45
+ timeoutMs?: number;
46
+ slowAfterMs?: number;
47
+ }) {
48
+ const { observability, details } = input;
49
+ if (!initialized.has(observability)) {
50
+ for (const stage of TURN_FINALIZATION_STAGES) {
51
+ diagnose(() => observability.incrementGauge({ ...INFLIGHT, labels: { stage }, amount: 0 }));
52
+ diagnose(() =>
53
+ observability.incrementCounter({ ...SLOW_STAGES, labels: { stage }, amount: 0 }),
54
+ );
55
+ }
56
+ initialized.add(observability);
57
+ }
58
+ let stage: TurnFinalizationStage | null = null;
59
+ let disarm = () => {};
60
+ let stopped = false;
61
+ let slowTimer: ReturnType<typeof setTimeout> | undefined;
62
+ const leave = () => {
63
+ disarm();
64
+ clearTimeout(slowTimer);
65
+ if (stage) {
66
+ const previous = stage;
67
+ diagnose(() =>
68
+ observability.incrementGauge({ ...INFLIGHT, labels: { stage: previous }, amount: -1 }),
69
+ );
70
+ }
71
+ stage = null;
72
+ };
73
+ return {
74
+ enter(next: TurnFinalizationStage) {
75
+ if (stopped || stage === next) return;
76
+ leave();
77
+ stage = next;
78
+ details.phase = "finalizing";
79
+ details.finalizationStage = next;
80
+ details.finalizationStageStartedAt = new Date().toISOString();
81
+ diagnose(() => observability.incrementGauge({ ...INFLIGHT, labels: { stage: next } }));
82
+ disarm = armTurnQuiescenceWatchdog({
83
+ enabled: true,
84
+ ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
85
+ onTimeout: () => {
86
+ try {
87
+ observability.error("turn finalization stalled; containing worker", {
88
+ surface: "turn_finalization",
89
+ outcome: "containment",
90
+ reason: next,
91
+ });
92
+ } catch {
93
+ // Diagnostic failure must never disable physical containment.
94
+ }
95
+ },
96
+ terminateWorker: input.terminateWorker,
97
+ });
98
+ // Record the precursor while the process remains scrapeable. A counter
99
+ // increment immediately before process.exit can never reach Prometheus.
100
+ slowTimer = setTimeout(
101
+ () =>
102
+ diagnose(() => {
103
+ observability.incrementCounter({ ...SLOW_STAGES, labels: { stage: next } });
104
+ observability.warn("turn finalization is taking longer than expected", {
105
+ surface: "turn_finalization",
106
+ outcome: "slow",
107
+ reason: next,
108
+ });
109
+ }),
110
+ input.slowAfterMs ?? 30_000,
111
+ );
112
+ slowTimer.unref?.();
113
+ diagnose(() => input.heartbeat(details));
114
+ },
115
+ stop() {
116
+ if (stopped) return;
117
+ stopped = true;
118
+ leave();
119
+ },
120
+ };
121
+ }
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  commitSessionAttemptQuiescence,
3
3
  listPendingSessionTurns,
4
- recordCodexAccountUsageWithWakeTargets,
4
+ recordCodexAccountUsageForFinalization,
5
5
  releaseCodexCredentialLease,
6
6
  releaseXaiCredentialLease,
7
7
  updateXaiQuotaMetadata,
@@ -13,7 +13,8 @@ import { clearRunCredentialsForAttempt } from "@opengeni/runtime";
13
13
  import { fetchXaiSubscriptionQuota } from "@opengeni/xai-subscription";
14
14
  import type { Settings } from "@opengeni/config";
15
15
  import { signalCodexCapacityWakeTargets } from "../codex-capacity";
16
- import type { currentActivityContext } from "../streaming";
16
+ import { startActivityHeartbeat, type currentActivityContext } from "../streaming";
17
+ import { startTurnFinalizationMonitor } from "./finalization-monitor";
17
18
  import type { CodemodeTokenRenewalController } from "../codemode-token-renewal";
18
19
  import type { RunCredentialRenewalController } from "../run-credential-renewal";
19
20
  import type {
@@ -32,6 +33,7 @@ import {
32
33
  makeMachineOpObserver,
33
34
  recordSessionEventAppendPhase,
34
35
  turnLifecycleMetricsFor,
36
+ runtimeMetricsHooksForObservability,
35
37
  } from "../../observability-metrics";
36
38
  import {
37
39
  maybePersistWarmWorkspaceSnapshot,
@@ -43,7 +45,6 @@ import { safeErrorDiagnostic, safeErrorForTelemetry } from "./errors";
43
45
  import {
44
46
  assertPhysicalToolQuiescenceForCancellation,
45
47
  assertSessionAttemptQuiescenceRecoveryDurable,
46
- armTurnQuiescenceWatchdog,
47
48
  clearAttemptCredentialsWithSettledFence,
48
49
  drainAttemptOwnedSandboxWriters,
49
50
  persistOrSignalSessionAttemptQuiescence,
@@ -99,6 +100,42 @@ export type TurnFinalizationDeps = {
99
100
  };
100
101
 
101
102
  export async function finalizeTurnAttempt(deps: TurnFinalizationDeps): Promise<void> {
103
+ const details = deps.eventing.heartbeatDetails ?? {
104
+ sessionId: deps.input.sessionId,
105
+ turnId: deps.attempt.turnId,
106
+ opAcks: {},
107
+ };
108
+ deps.eventing.heartbeatDetails = details;
109
+ // Keep one heartbeat owner through all physical cleanup, with the same op
110
+ // acknowledgements used during execution. It must no longer say "running".
111
+ if (deps.eventing.heartbeatTimer) clearInterval(deps.eventing.heartbeatTimer);
112
+ deps.eventing.heartbeatTimer = startActivityHeartbeat(deps.activityContext, details);
113
+ const monitor = startTurnFinalizationMonitor({
114
+ observability: deps.observability,
115
+ details,
116
+ heartbeat: (value) => {
117
+ try {
118
+ deps.activityContext?.heartbeat({ ...value, at: new Date().toISOString() });
119
+ } catch {
120
+ // A closed Temporal transport is not proof of physical quiescence.
121
+ }
122
+ },
123
+ terminateWorker: () => process.exit(1),
124
+ });
125
+ try {
126
+ monitor.enter("tool_writers");
127
+ await finalizeTurnAttemptSteps(deps, monitor);
128
+ } finally {
129
+ monitor.stop();
130
+ if (deps.eventing.heartbeatTimer) clearInterval(deps.eventing.heartbeatTimer);
131
+ deps.eventing.heartbeatTimer = undefined;
132
+ }
133
+ }
134
+
135
+ async function finalizeTurnAttemptSteps(
136
+ deps: TurnFinalizationDeps,
137
+ monitor: ReturnType<typeof startTurnFinalizationMonitor>,
138
+ ): Promise<void> {
102
139
  const {
103
140
  input,
104
141
  settings,
@@ -142,17 +179,6 @@ export async function finalizeTurnAttempt(deps: TurnFinalizationDeps): Promise<v
142
179
  let finalizationError: unknown;
143
180
  let physicalToolQuiescenceConfirmed = !control.acknowledgeQuiescence;
144
181
  let quiescenceReceiptOrProofDurable = !control.acknowledgeQuiescence;
145
- const disarmQuiescenceWatchdog = armTurnQuiescenceWatchdog({
146
- enabled: control.acknowledgeQuiescence,
147
- onTimeout: () => {
148
- observability.error("turn quiescence drain exceeded hard containment deadline", {
149
- "opengeni.session_id": input.sessionId,
150
- "opengeni.turn_id": attempt.turnId ?? "",
151
- "opengeni.attempt_id": input.attemptId,
152
- });
153
- },
154
- terminateWorker: () => process.exit(1),
155
- });
156
182
  const finalizerSignal = turnFinalizerCancellationSignal(
157
183
  cancellationSignal,
158
184
  control.activityStatus,
@@ -200,6 +226,7 @@ export async function finalizeTurnAttempt(deps: TurnFinalizationDeps): Promise<v
200
226
  // and durably settled before workspace capture. Only receipt
201
227
  // publication remains conditional on acknowledgeQuiescence.
202
228
  toolCancellationFence,
229
+ onStage: monitor.enter,
203
230
  cancellationReason: cancellationSignal?.reason ?? new Error("TURN_ATTEMPT_FINALIZED"),
204
231
  gitCredentialRenewals: gitRenewalsToStop,
205
232
  codemodeTokenRenewal: codemodeRenewalToStop,
@@ -211,6 +238,7 @@ export async function finalizeTurnAttempt(deps: TurnFinalizationDeps): Promise<v
211
238
  // attempt's generation before publishing physical quiescence. A failure
212
239
  // deliberately keeps the receipt closed.
213
240
  if (credentialSessionToClear) {
241
+ monitor.enter("credential_cleanup");
214
242
  const clearAttemptCredentials = async (): Promise<void> =>
215
243
  await clearRunCredentialsForAttempt(credentialSessionToClear, {
216
244
  sessionId: input.sessionId,
@@ -258,6 +286,7 @@ export async function finalizeTurnAttempt(deps: TurnFinalizationDeps): Promise<v
258
286
  // and before lease, cache, recording, or provider cleanup. Its
259
287
  // transaction also enqueues the exact workflow wake that will admit
260
288
  // the replacement; Temporal activity terminalization does neither.
289
+ monitor.enter("quiescence_receipt");
261
290
  const proof: SessionAttemptQuiescenceProof = {
262
291
  accountId: input.accountId,
263
292
  workspaceId: input.workspaceId,
@@ -304,6 +333,7 @@ export async function finalizeTurnAttempt(deps: TurnFinalizationDeps): Promise<v
304
333
  signalProof: signalSessionAttemptQuiesced,
305
334
  heartbeat: (deliveryAttempt, retryMs) => {
306
335
  activityContext?.heartbeat({
336
+ ...eventing.heartbeatDetails,
307
337
  phase: "quiescence-proof-delivery",
308
338
  sessionId: input.sessionId,
309
339
  attemptId: input.attemptId,
@@ -336,7 +366,6 @@ export async function finalizeTurnAttempt(deps: TurnFinalizationDeps): Promise<v
336
366
  },
337
367
  });
338
368
  quiescenceReceiptOrProofDurable = true;
339
- disarmQuiescenceWatchdog();
340
369
  if (recoveryMode === "signal") {
341
370
  observability.info("agent turn quiescence proof handed to workflow recovery", {
342
371
  "opengeni.session_id": input.sessionId,
@@ -351,6 +380,7 @@ export async function finalizeTurnAttempt(deps: TurnFinalizationDeps): Promise<v
351
380
  // the turn. Sync observer → buffer → single awaited append here (no unawaited
352
381
  // DB write inside the activity). Scoped to this turn; skipped if no turnId
353
382
  // (the op ran under a turn, so on the normal path turnId is set).
383
+ monitor.enter("event_flush");
354
384
  const machineOpEvents = machineOpObserver.drainEvents();
355
385
  if (machineOpEvents.length > 0 && attempt.turnId && attempt.executionGeneration > 0) {
356
386
  await waitForTurnFinalizerStep(
@@ -377,17 +407,34 @@ export async function finalizeTurnAttempt(deps: TurnFinalizationDeps): Promise<v
377
407
  // Multi-account P4: flush the serving account's free per-turn caches ONCE,
378
408
  // best-effort (same discipline as today's usage write). Both writers skip
379
409
  // version/updatedAt, so neither can race the token-refresh CAS.
410
+ monitor.enter("provider_leases");
380
411
  if (providerTurn.effectiveCodexCredentialId) {
381
412
  // Part A: the latest scraped usage-header snapshot → the P2 usage cache. A
382
413
  // full both-windows snapshot (parseCodexUsageHeaders gates on both), so this
383
414
  // is byte-identical to the /wham/usage write — no partial-window clobber.
384
- if (providerTurn.latestCodexUsage) {
415
+ if (
416
+ providerTurn.latestCodexUsage &&
417
+ attempt.turnId &&
418
+ leases.codex.held &&
419
+ leases.codex.holderId &&
420
+ leases.codex.generation !== null &&
421
+ providerTurn.effectiveCodexCredentialVersion !== null
422
+ ) {
385
423
  const usageMutation = await waitForTurnFinalizerStep(
386
- recordCodexAccountUsageWithWakeTargets(
424
+ recordCodexAccountUsageForFinalization(
387
425
  db,
388
426
  input.workspaceId,
389
427
  providerTurn.effectiveCodexCredentialId,
390
428
  providerTurn.latestCodexUsage,
429
+ {
430
+ turnId: attempt.turnId,
431
+ sessionId: input.sessionId,
432
+ attemptId: input.attemptId,
433
+ executionGeneration: attempt.executionGeneration,
434
+ holderId: leases.codex.holderId,
435
+ generation: leases.codex.generation,
436
+ credentialVersion: providerTurn.effectiveCodexCredentialVersion,
437
+ },
391
438
  ).catch(() => null),
392
439
  finalizerSignal,
393
440
  );
@@ -502,6 +549,7 @@ export async function finalizeTurnAttempt(deps: TurnFinalizationDeps): Promise<v
502
549
  ) {
503
550
  // Review reads yield to queued work. Recovery snapshots have their own
504
551
  // physical-settlement requirement and are drained later in finalization.
552
+ monitor.enter("workspace_capture");
505
553
  sandboxState.turnEndCaptureInProgress = true;
506
554
  const captureSandbox = sandboxState.resolvedSandbox;
507
555
  const captureSession = sandboxState.setupBoxSession as ChannelASession;
@@ -549,6 +597,7 @@ export async function finalizeTurnAttempt(deps: TurnFinalizationDeps): Promise<v
549
597
  },
550
598
  });
551
599
  }
600
+ monitor.enter("tool_close");
552
601
  eventing.toolPreparationClosing = true;
553
602
  if (eventing.toolPreparationReady) {
554
603
  await waitForTurnFinalizerStep(
@@ -569,9 +618,7 @@ export async function finalizeTurnAttempt(deps: TurnFinalizationDeps): Promise<v
569
618
  finalizerSignal,
570
619
  );
571
620
  }
572
- if (eventing.heartbeatTimer) {
573
- clearInterval(eventing.heartbeatTimer);
574
- }
621
+ monitor.enter("sandbox_provisioning");
575
622
  if (sandboxState.turnSandboxProvisioner?.hasStarted()) {
576
623
  await waitForTurnFinalizerStep(
577
624
  sandboxState.turnSandboxProvisioner.waitForSettled(30_000),
@@ -599,12 +646,14 @@ export async function finalizeTurnAttempt(deps: TurnFinalizationDeps): Promise<v
599
646
  if (sandboxState.leaseHeartbeatTimer) {
600
647
  stopLeaseHeartbeat();
601
648
  }
649
+ monitor.enter("sandbox_rotation");
602
650
  if (sandboxState.rotationPreemptionInFlight) {
603
651
  await sandboxState.rotationPreemptionInFlight.catch(() => undefined);
604
652
  }
605
653
  // No heartbeat can register another capture after this point. Join the
606
654
  // actual provider continuation before either starting a turn-end capture
607
655
  // or entering the proof-bearing holder release.
656
+ monitor.enter("workspace_snapshot");
608
657
  await drainInFlightWarmSnapshot();
609
658
  if (sandboxState.resolvedSandbox) {
610
659
  // TURN-END mid-session snapshot (sandbox-file-persistence): fold the
@@ -622,7 +671,13 @@ export async function finalizeTurnAttempt(deps: TurnFinalizationDeps): Promise<v
622
671
  settledTurnId
623
672
  ) {
624
673
  const snapshot = maybePersistWarmWorkspaceSnapshot(
625
- { db, settings, objectStorage },
674
+ {
675
+ db,
676
+ settings,
677
+ objectStorage,
678
+ sandboxMetrics: runtimeMetricsHooksForObservability(observability),
679
+ observability,
680
+ },
626
681
  {
627
682
  accountId: input.accountId,
628
683
  workspaceId: input.workspaceId,
@@ -667,10 +722,13 @@ export async function finalizeTurnAttempt(deps: TurnFinalizationDeps): Promise<v
667
722
  // Temporal cancellation: the eager listener may already have dropped
668
723
  // the holder, and this idempotent proof-bearing pass still must run.
669
724
  stopLeaseHeartbeat();
725
+ monitor.enter("sandbox_rotation");
670
726
  if (sandboxState.rotationPreemptionInFlight) {
671
727
  await sandboxState.rotationPreemptionInFlight.catch(() => undefined);
672
728
  }
729
+ monitor.enter("workspace_snapshot");
673
730
  await drainInFlightWarmSnapshot();
731
+ monitor.enter("sandbox_release");
674
732
  const sandboxReleaseTargets = new Set(sandboxState.lateSandboxesAwaitingWriterDrain);
675
733
  sandboxState.lateSandboxesAwaitingWriterDrain.clear();
676
734
  if (sandboxState.resolvedSandbox) {