@opengeni/worker-bundle 0.27.3 → 0.28.3-canary.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 (37) hide show
  1. package/dist/activities/agent-turn/model-usage.d.ts +1 -1
  2. package/dist/activities/mcp-credentials.d.ts +3 -1
  3. package/dist/activities/sandbox-lease.d.ts +4 -4
  4. package/dist/activities/workspace-capture.d.ts +12 -1
  5. package/dist/{activities-control-WZRFEIJH.js → activities-control-JUBMDBDZ.js} +58 -73
  6. package/dist/activities-control-JUBMDBDZ.js.map +1 -0
  7. package/dist/{activities-turn-V7J34ZOF.js → activities-turn-I2ZKGZE6.js} +268 -187
  8. package/dist/activities-turn-I2ZKGZE6.js.map +1 -0
  9. package/dist/{chunk-BARTLVBK.js → chunk-A4XQWVZK.js} +25 -25
  10. package/dist/chunk-A4XQWVZK.js.map +1 -0
  11. package/dist/{chunk-C54VDT6I.js → chunk-ZNIKZQMN.js} +148 -111
  12. package/dist/chunk-ZNIKZQMN.js.map +1 -0
  13. package/dist/index.js +2 -2
  14. package/dist/observability-metrics.d.ts +12 -4
  15. package/dist/sandbox-archive-storage.d.ts +15 -3
  16. package/dist/sandbox-resume.d.ts +1 -1
  17. package/package.json +17 -17
  18. package/src/activities/agent-turn/agent-build.ts +30 -1
  19. package/src/activities/agent-turn/claim.ts +3 -3
  20. package/src/activities/agent-turn/finalization.ts +62 -41
  21. package/src/activities/agent-turn/model-usage.ts +10 -1
  22. package/src/activities/agent-turn/skill-read.ts +2 -21
  23. package/src/activities/agent-turn/stream-attempt.ts +3 -2
  24. package/src/activities/agent-turn/tool-environment.ts +52 -10
  25. package/src/activities/goals.ts +2 -0
  26. package/src/activities/mcp-credentials.ts +42 -2
  27. package/src/activities/sandbox-lease.ts +64 -71
  28. package/src/activities/scheduled-tasks.ts +8 -6
  29. package/src/activities/workspace-capture.ts +148 -126
  30. package/src/observability-metrics.ts +28 -24
  31. package/src/pr-review-credentials.ts +2 -0
  32. package/src/sandbox-archive-storage.ts +82 -18
  33. package/src/sandbox-resume.ts +79 -63
  34. package/dist/activities-control-WZRFEIJH.js.map +0 -1
  35. package/dist/activities-turn-V7J34ZOF.js.map +0 -1
  36. package/dist/chunk-BARTLVBK.js.map +0 -1
  37. package/dist/chunk-C54VDT6I.js.map +0 -1
@@ -346,6 +346,62 @@ export async function readCaptureRepository(
346
346
 
347
347
  let loggedStorageNullOnce = false;
348
348
 
349
+ /** Review work may use idle time, but must yield to the next turn. Poll durable
350
+ * queue state rather than relying on best-effort event delivery. The capture
351
+ * already fences every continuation on this signal; no replacement writer is
352
+ * admitted until its caller returns. */
353
+ export async function captureWhileIdle(input: {
354
+ hasPendingWork: () => Promise<boolean>;
355
+ capture: (signal: AbortSignal) => Promise<void>;
356
+ signal?: AbortSignal;
357
+ }): Promise<void> {
358
+ if (input.signal?.aborted) return;
359
+ const controller = new AbortController();
360
+ const signal = input.signal
361
+ ? AbortSignal.any([input.signal, controller.signal])
362
+ : controller.signal;
363
+ let checking = false;
364
+ let stopped = false;
365
+ const check = async (): Promise<void> => {
366
+ if (checking || stopped || signal.aborted) return;
367
+ checking = true;
368
+ try {
369
+ let timer: ReturnType<typeof setTimeout> | undefined;
370
+ let onAbort: (() => void) | undefined;
371
+ try {
372
+ const pending = await Promise.race([
373
+ input.hasPendingWork(),
374
+ new Promise<boolean>((resolve) => {
375
+ onAbort = () => resolve(true);
376
+ signal.addEventListener("abort", onAbort, { once: true });
377
+ timer = setTimeout(() => resolve(true), 1_000);
378
+ }),
379
+ ]);
380
+ if (pending) controller.abort();
381
+ } finally {
382
+ clearTimeout(timer);
383
+ if (onAbort) signal.removeEventListener("abort", onAbort);
384
+ }
385
+ } catch {
386
+ // A failed queue read is not permission to delay accepted user work.
387
+ controller.abort();
388
+ } finally {
389
+ checking = false;
390
+ }
391
+ };
392
+ await check();
393
+ if (signal.aborted) return;
394
+ const timer = setInterval(() => void check(), 250);
395
+ timer.unref?.();
396
+ try {
397
+ await input.capture(signal);
398
+ } finally {
399
+ stopped = true;
400
+ clearInterval(timer);
401
+ controller.abort();
402
+ }
403
+ }
404
+
349
405
  /**
350
406
  * Turn-end entrypoint. Gates on the flag + configured object storage, races the
351
407
  * whole capture against a 120s cap, and swallows every failure ("turn outcome
@@ -399,13 +455,25 @@ export async function captureWorkspaceRevision(
399
455
  name: "opengeni_workspace_captures_inflight",
400
456
  help: "Current workspace-capture operations still resident in this worker process.",
401
457
  });
458
+ const ownership = { keys: new Set<string>(), retain: false };
402
459
  const capture = runCapture(
403
460
  input,
404
461
  { ...input, objectStorage: input.objectStorage },
405
462
  startedAt,
406
463
  controller.signal,
407
464
  stage,
408
- ).finally(() => {
465
+ ownership,
466
+ ).finally(async () => {
467
+ // Cleanup follows physical write settlement, even after inference resumes.
468
+ // Unique keys cannot belong to a successor capture.
469
+ if (!ownership.retain && ownership.keys.size > 0) {
470
+ await safeDelete(
471
+ input.objectStorage!,
472
+ [...ownership.keys],
473
+ observability,
474
+ new AbortController().signal,
475
+ );
476
+ }
409
477
  observability.incrementGauge({
410
478
  name: "opengeni_workspace_captures_inflight",
411
479
  help: "Current workspace-capture operations still resident in this worker process.",
@@ -434,13 +502,25 @@ export async function captureWorkspaceRevision(
434
502
  }
435
503
  // The one and only place a capture failure surfaces — as a log line, never
436
504
  // an exception past this boundary (the turn already completed).
437
- observability.warn("workspace capture failed — turn outcome unaffected", {
438
- "opengeni.session_id": input.sessionId,
439
- "opengeni.turn_id": input.turnId ?? "",
440
- "error.message": error instanceof Error ? error.message : String(error),
441
- "workspace_capture.duration_ms": Date.now() - startedAt,
442
- "workspace_capture.stage": stage.value,
443
- });
505
+ // Public telemetry intentionally strips arbitrary error text and unknown
506
+ // attributes. Keep the bounded stage/reason in the message so operators can
507
+ // distinguish a deadline from provider loss without logging file contents,
508
+ // credentials, paths, or upstream exception messages.
509
+ const reason = controller.signal.aborted
510
+ ? "deadline_exceeded"
511
+ : isBoxExitingError(error)
512
+ ? "sandbox_exited"
513
+ : "operation_failed";
514
+ observability.warn(
515
+ `workspace capture failed — stage=${stage.value} reason=${reason} durationMs=${Date.now() - startedAt}; review cache not updated`,
516
+ {
517
+ "opengeni.session_id": input.sessionId,
518
+ "opengeni.turn_id": input.turnId ?? "",
519
+ "error.message": error instanceof Error ? error.message : String(error),
520
+ "workspace_capture.duration_ms": Date.now() - startedAt,
521
+ "workspace_capture.stage": stage.value,
522
+ },
523
+ );
444
524
  observability.incrementCounter({
445
525
  name: "opengeni_workspace_capture_total",
446
526
  labels: { result: "failed" },
@@ -459,7 +539,9 @@ async function runCapture(
459
539
  startedAt: number,
460
540
  signal: AbortSignal,
461
541
  stage: { value: string },
542
+ ownership: { keys: Set<string>; retain: boolean },
462
543
  ): Promise<void> {
544
+ const ownedKeys = ownership.keys;
463
545
  const { observability } = input;
464
546
  const storage = ctx.objectStorage;
465
547
  const keepN = input.keepLatest ?? KEEP_LATEST_REVISIONS;
@@ -581,11 +663,20 @@ async function runCapture(
581
663
  // dirty tree on every read-only turn. Instead we skip when the change surface
582
664
  // is byte-identical to the previous revision — "no new revision when nothing
583
665
  // changed" holds even with a persistently dirty tree, and content-addressed
584
- // blobs already dedupe storage. This preserves the intended empty-turn behavior
666
+ // blobs dedupe against the latest retained capture. This preserves empty-turn revision behavior
585
667
  // while correctly handling a persistently dirty tree.
586
668
  // ── 3. after-images of touched files (size-gated), content-addressed ───────
587
669
  const files: WorkspaceCaptureFile[] = [];
588
670
  const blobKeys = new Set<string>();
671
+ const captureId = crypto.randomUUID();
672
+ // A predecessor GC plan keeps the latest revision's refs. Never reuse refs
673
+ // from older revisions: an already-issued delete may still target those.
674
+ const retainedRefs = retainedCaptureBlobRefs(
675
+ input.workspaceId,
676
+ input.sessionId,
677
+ prev?.blobKeys ?? [],
678
+ );
679
+ const storedKeys = new Set(retainedRefs.values());
589
680
  let totalBytes = 0;
590
681
  let tooLargeCount = 0;
591
682
  let binaryCount = 0;
@@ -626,6 +717,7 @@ async function runCapture(
626
717
  });
627
718
  throwIfCaptureAborted(signal);
628
719
  } catch (error) {
720
+ if (signal.aborted) throw error;
629
721
  const boxExiting = classifyCaptureEntryError(error);
630
722
  if (boxExiting) throw boxExiting;
631
723
  continue; // vanished/unreadable single file — omit it, capture continues
@@ -647,11 +739,12 @@ async function runCapture(
647
739
  }
648
740
  const bytes = Buffer.from(read.content, "base64");
649
741
  const hash = sha256(bytes);
650
- const contentRef = blobKey(input.workspaceId, input.sessionId, hash);
742
+ const contentRef =
743
+ retainedRefs.get(hash) ?? blobKey(input.workspaceId, input.sessionId, `${captureId}/${hash}`);
651
744
  if (read.isBinary) binaryCount += 1;
652
745
  if (!blobKeys.has(contentRef)) {
653
746
  totalBytes += bytes.byteLength;
654
- // Whole-capture guard: bail before writing anything (fall back to live).
747
+ // Never publish a revision exceeding the whole-capture byte limit.
655
748
  if (totalBytes > WHOLE_CAPTURE_GUARD_BYTES) {
656
749
  observability.warn("workspace capture skipped — whole-capture guard tripped", {
657
750
  "opengeni.session_id": input.sessionId,
@@ -665,6 +758,17 @@ async function runCapture(
665
758
  }
666
759
  }
667
760
  blobKeys.add(contentRef);
761
+ if (!storedKeys.has(contentRef)) {
762
+ stage.value = "content_upload";
763
+ ownedKeys.add(contentRef);
764
+ await storage.putObject({
765
+ key: contentRef,
766
+ contentType: "application/octet-stream",
767
+ body: bytes,
768
+ });
769
+ throwIfCaptureAborted(signal);
770
+ storedKeys.add(contentRef);
771
+ }
668
772
  files.push({
669
773
  // baseHash (git HEAD blob sha) is intentionally null in M1: the wake-on-edit
670
774
  // flush guard (M3/C2) compares live content against the after-image `hash`,
@@ -684,10 +788,11 @@ async function runCapture(
684
788
 
685
789
  // ── 4. empty-turn gate (B3) ───────────────────────────────
686
790
  // Skip when the change surface is byte-identical to the previous revision.
687
- // Fires BEFORE the tree BFS + storage PUTs (the expensive parts) so a no-op
688
- // read-only turn on a persistently dirty tree costs only the (small) status/
689
- // diff/after-image probes.
690
- let fingerprint = changeFingerprint(repos, files);
791
+ // New writes use unique keys; reused refs belong to the latest retained revision.
792
+ // An unchanged dirty turn therefore performs no object-storage writes.
793
+ // Each file is read once and its exact bytes are hashed and uploaded together;
794
+ // peak memory remains one guarded file rather than the entire workspace.
795
+ const fingerprint = changeFingerprint(repos, files);
691
796
  if (prev && prev.stats.fingerprint === fingerprint) {
692
797
  observability.incrementCounter({
693
798
  name: "opengeni_workspace_capture_total",
@@ -703,117 +808,11 @@ async function runCapture(
703
808
 
704
809
  // ── 6. PUT blobs + tree + manifest (F9: all writes BEFORE any delete) ──────
705
810
  // Key manifest/tree by the turn (one capture per turn) so the key is known
706
- // before the revision is committed; content blobs are content-addressed.
707
- const turnKey = input.turnId;
811
+ // before commit. Unique capture namespaces make late deletes safe across workers.
812
+ const turnKey = `${input.turnId}/${captureId}`;
708
813
  const treeKey = `workspace-captures/${input.workspaceId}/${input.sessionId}/trees/${turnKey}.json`;
709
814
  const manifestKey = `workspace-captures/${input.workspaceId}/${input.sessionId}/manifests/${turnKey}.json`;
710
815
 
711
- // Re-read and upload ONE after-image at a time. The first pass above computes
712
- // the stable capture fingerprint without retaining every changed file. This
713
- // second pass takes the actual after-image that will be uploaded, refreshing
714
- // metadata when a live process changed a file between passes. Peak worker
715
- // memory is bounded by PER_FILE_CONTENT_GUARD_BYTES instead of
716
- // WHOLE_CAPTURE_GUARD_BYTES per concurrent turn.
717
- const uploadedKeys = new Set<string>();
718
- blobKeys.clear();
719
- totalBytes = 0;
720
- let refreshedFiles = 0;
721
- const omittedFiles = new Set<string>();
722
- for (const file of files) {
723
- stage.value = "content_upload";
724
- throwIfCaptureAborted(signal);
725
- if (file.deleted || file.tooLarge || !file.contentRef || !file.hash) {
726
- continue;
727
- }
728
- let reread: Awaited<ReturnType<typeof svc.fsRead>>;
729
- try {
730
- reread = await svc.fsRead({
731
- path: file.path,
732
- encoding: "base64",
733
- maxBytes: PER_FILE_CONTENT_GUARD_BYTES,
734
- });
735
- throwIfCaptureAborted(signal);
736
- } catch (error) {
737
- if (signal.aborted) throw error;
738
- const boxExiting = classifyCaptureEntryError(error);
739
- if (boxExiting) throw boxExiting;
740
- // Match the first pass: a single vanished or unreadable file is omitted,
741
- // never misreported as deleted and never allowed to fail the full capture.
742
- omittedFiles.add(file.path);
743
- refreshedFiles += 1;
744
- continue;
745
- }
746
- if (reread.truncated) {
747
- file.hash = null;
748
- file.contentRef = null;
749
- file.sizeBytes = reread.sizeBytes;
750
- file.isBinary = reread.isBinary;
751
- file.tooLarge = true;
752
- refreshedFiles += 1;
753
- continue;
754
- }
755
- const bytes = Buffer.from(reread.content, "base64");
756
- const hash = sha256(bytes);
757
- const contentRef = blobKey(input.workspaceId, input.sessionId, hash);
758
- if (
759
- hash !== file.hash ||
760
- bytes.byteLength !== file.sizeBytes ||
761
- reread.isBinary !== file.isBinary
762
- ) {
763
- file.hash = hash;
764
- file.contentRef = contentRef;
765
- file.sizeBytes = bytes.byteLength;
766
- file.isBinary = reread.isBinary;
767
- refreshedFiles += 1;
768
- }
769
- if (!blobKeys.has(contentRef)) {
770
- totalBytes += bytes.byteLength;
771
- if (totalBytes > WHOLE_CAPTURE_GUARD_BYTES) {
772
- observability.warn("workspace capture skipped — whole-capture guard tripped", {
773
- "opengeni.session_id": input.sessionId,
774
- "workspace_capture.total_bytes": totalBytes,
775
- });
776
- observability.incrementCounter({
777
- name: "opengeni_workspace_capture_total",
778
- labels: { result: "guard_tripped" },
779
- });
780
- return;
781
- }
782
- }
783
- blobKeys.add(contentRef);
784
- if (!uploadedKeys.has(contentRef)) {
785
- await storage.putObject({
786
- key: contentRef,
787
- contentType: "application/octet-stream",
788
- body: bytes,
789
- });
790
- throwIfCaptureAborted(signal);
791
- uploadedKeys.add(contentRef);
792
- }
793
- }
794
- if (omittedFiles.size > 0) {
795
- for (let index = files.length - 1; index >= 0; index -= 1) {
796
- const file = files[index];
797
- if (file && omittedFiles.has(file.path)) files.splice(index, 1);
798
- }
799
- }
800
- if (refreshedFiles > 0) {
801
- observability.incrementCounter({
802
- name: "opengeni_workspace_capture_files_refreshed_total",
803
- amount: refreshedFiles,
804
- });
805
- }
806
- tooLargeCount = files.filter((file) => file.tooLarge).length;
807
- binaryCount = files.filter((file) => !file.deleted && file.isBinary).length;
808
- fingerprint = changeFingerprint(repos, files);
809
- if (prev && prev.stats.fingerprint === fingerprint) {
810
- observability.incrementCounter({
811
- name: "opengeni_workspace_capture_total",
812
- labels: { result: "skipped_empty" },
813
- });
814
- return;
815
- }
816
-
817
816
  // ── 7. serialize the exact uploaded after-images ──────────────────────────
818
817
  const capturedAt = new Date();
819
818
  const stats: WorkspaceCaptureStats = {
@@ -850,11 +849,13 @@ async function runCapture(
850
849
  }),
851
850
  );
852
851
  stage.value = "tree_upload";
852
+ ownedKeys.add(treeKey);
853
853
  await storage.putObject({ key: treeKey, contentType: "application/json", body: treeBytes });
854
854
  throwIfCaptureAborted(signal);
855
855
  stats.durationMs = Date.now() - startedAt;
856
856
  const manifestBytes = utf8(JSON.stringify(manifest));
857
857
  stage.value = "manifest_upload";
858
+ ownedKeys.add(manifestKey);
858
859
  await storage.putObject({
859
860
  key: manifestKey,
860
861
  contentType: "application/json",
@@ -865,6 +866,9 @@ async function runCapture(
865
866
 
866
867
  // ── 8. epoch-fenced insert (superseded lease → zero rows) ─────────────────
867
868
  stage.value = "database_commit";
869
+ // A transport failure during commit has an unknown outcome: retain rather
870
+ // than risk deleting a published review. A definite rejected commit is safe.
871
+ ownership.retain = true;
868
872
  const inserted = await insertWorkspaceCapture(input.db, {
869
873
  accountId: input.accountId,
870
874
  workspaceId: input.workspaceId,
@@ -881,6 +885,7 @@ async function runCapture(
881
885
  stats,
882
886
  capturedAt,
883
887
  });
888
+ ownership.retain = inserted !== null;
884
889
  if (process.env.OPENGENI_TEST_SCENARIO === "sandbox") {
885
890
  console.log(
886
891
  `[sandbox-e2e] capture commit inserted=${Boolean(inserted)} revision=${revision} epoch=${input.leaseEpoch} group=${input.sandboxGroupId}`,
@@ -889,8 +894,8 @@ async function runCapture(
889
894
  throwIfCaptureAborted(signal);
890
895
  if (!inserted) {
891
896
  // Lease superseded/released between capture and commit. Best-effort clean up
892
- // the turn-keyed blobs we just PUT (content blobs may be shared with a
893
- // surviving revision — leave them for the next GC); never throw.
897
+ // the turn-keyed blobs we just PUT. The finalizer also cleans newly owned
898
+ // content blobs, leaving reused refs untouched; never throw.
894
899
  observability.incrementCounter({
895
900
  name: "opengeni_workspace_capture_total",
896
901
  labels: { result: "superseded" },
@@ -904,6 +909,7 @@ async function runCapture(
904
909
  }
905
910
 
906
911
  // ── 9. inline keep-latest-N GC (best-effort; F9 — after the commit) ────────
912
+ stage.value = "garbage_collection";
907
913
  let gcDeleted = 0;
908
914
  try {
909
915
  throwIfCaptureAborted(signal);
@@ -1106,11 +1112,27 @@ function utf8(s: string): Uint8Array {
1106
1112
  return new TextEncoder().encode(s);
1107
1113
  }
1108
1114
 
1109
- /** Content-addressed after-image blob key (shared across revisions → GC input). */
1115
+ /** Content-addressed after-image blob key, optionally in a unique capture namespace. */
1110
1116
  export function blobKey(workspaceId: string, sessionId: string, sha256Hex: string): string {
1111
1117
  return `workspace-captures/${workspaceId}/${sessionId}/blobs/${sha256Hex}`;
1112
1118
  }
1113
1119
 
1120
+ /** Only pass the latest retained revision's keys: older refs may be under deletion. */
1121
+ export function retainedCaptureBlobRefs(
1122
+ workspaceId: string,
1123
+ sessionId: string,
1124
+ latestKeys: string[],
1125
+ ): Map<string, string> {
1126
+ const prefix = blobKey(workspaceId, sessionId, "");
1127
+ const refs = new Map<string, string>();
1128
+ for (const key of latestKeys) {
1129
+ if (!key.startsWith(prefix)) continue;
1130
+ const match = /^(?:[0-9a-f-]{36}\/)?([0-9a-f]{64})$/.exec(key.slice(prefix.length));
1131
+ if (match?.[1]) refs.set(match[1], key);
1132
+ }
1133
+ return refs;
1134
+ }
1135
+
1114
1136
  async function safeDelete(
1115
1137
  storage: ObjectStorage,
1116
1138
  keys: string[],
@@ -413,7 +413,7 @@ export function recordWorkerDeathRecoveryMetrics(
413
413
  }
414
414
 
415
415
  export class TurnLifecycleMetrics {
416
- private readonly turns = new Map<string, { startedAt: number; lastProgressAt: number }>();
416
+ private readonly attempts = new Map<string, { startedAt: number; lastProgressAt: number }>();
417
417
  private timer: ReturnType<typeof setInterval> | null = null;
418
418
 
419
419
  constructor(
@@ -424,42 +424,46 @@ export class TurnLifecycleMetrics {
424
424
  } = {},
425
425
  ) {}
426
426
 
427
- start(turnId: string): void {
427
+ start(input: { attemptId: string }): void {
428
428
  const now = this.now();
429
- this.turns.set(turnId, { startedAt: now, lastProgressAt: now });
429
+ this.attempts.set(input.attemptId, { startedAt: now, lastProgressAt: now });
430
430
  this.ensureTimer();
431
431
  this.refreshGauges();
432
432
  }
433
433
 
434
- progress(turnId: string): void {
435
- const turn = this.turns.get(turnId);
436
- if (!turn) return;
437
- turn.lastProgressAt = this.now();
434
+ progress(input: { attemptId: string }): void {
435
+ const attempt = this.attempts.get(input.attemptId);
436
+ if (!attempt) return;
437
+ attempt.lastProgressAt = this.now();
438
438
  }
439
439
 
440
- finish(turnId: string, outcome: TurnOutcome | null, durationSeconds?: number): void {
441
- const startedAt = this.turns.get(turnId)?.startedAt;
440
+ finish(input: {
441
+ attemptId: string;
442
+ outcome: TurnOutcome | null;
443
+ durationSeconds?: number;
444
+ }): void {
445
+ const startedAt = this.attempts.get(input.attemptId)?.startedAt;
442
446
  if (startedAt !== undefined) {
443
- this.turns.delete(turnId);
447
+ this.attempts.delete(input.attemptId);
444
448
  }
445
- if (outcome) {
449
+ if (input.outcome) {
446
450
  const observedDuration =
447
- durationSeconds ??
451
+ input.durationSeconds ??
448
452
  (startedAt === undefined ? 0 : Math.max(0, (this.now() - startedAt) / 1000));
449
453
  this.observability.incrementCounter({
450
454
  name: "opengeni_turns_total",
451
455
  help: "Total agent turns by terminal outcome.",
452
- labels: { outcome },
456
+ labels: { outcome: input.outcome },
453
457
  });
454
458
  this.observability.observeHistogram({
455
459
  name: "opengeni_turn_duration_seconds",
456
460
  help: "Agent turn duration in seconds by terminal outcome.",
457
- labels: { outcome },
461
+ labels: { outcome: input.outcome },
458
462
  value: observedDuration,
459
463
  });
460
464
  }
461
465
  this.refreshGauges();
462
- if (this.turns.size === 0) {
466
+ if (this.attempts.size === 0) {
463
467
  this.stopTimer();
464
468
  }
465
469
  }
@@ -467,42 +471,42 @@ export class TurnLifecycleMetrics {
467
471
  refreshGauges(): void {
468
472
  this.observability.setGauge({
469
473
  name: "opengeni_turns_inflight",
470
- help: "Current number of in-flight agent turns in this worker process.",
471
- value: this.turns.size,
474
+ help: "Current number of in-flight physical agent-turn attempts in this worker process.",
475
+ value: this.attempts.size,
472
476
  });
473
477
  this.observability.setGauge({
474
478
  name: "opengeni_turn_oldest_inflight_age_seconds",
475
- help: "Age in seconds of the oldest in-flight agent turn in this worker process.",
479
+ help: "Age in seconds of the oldest in-flight physical agent-turn attempt in this worker process.",
476
480
  value: this.oldestInflightAgeSeconds(),
477
481
  });
478
482
  this.observability.setGauge({
479
483
  name: "opengeni_turn_oldest_no_progress_age_seconds",
480
- help: "Seconds since durable progress for the least recently progressing in-flight turn.",
484
+ help: "Seconds since durable progress for the least recently progressing in-flight physical agent-turn attempt.",
481
485
  value: this.oldestNoProgressAgeSeconds(),
482
486
  });
483
487
  }
484
488
 
485
489
  stop(): void {
486
- this.turns.clear();
490
+ this.attempts.clear();
487
491
  this.refreshGauges();
488
492
  this.stopTimer();
489
493
  }
490
494
 
491
495
  private oldestInflightAgeSeconds(): number {
492
- if (this.turns.size === 0) {
496
+ if (this.attempts.size === 0) {
493
497
  return 0;
494
498
  }
495
499
  let oldest = Number.POSITIVE_INFINITY;
496
- for (const { startedAt } of this.turns.values()) {
500
+ for (const { startedAt } of this.attempts.values()) {
497
501
  oldest = Math.min(oldest, startedAt);
498
502
  }
499
503
  return Math.max(0, (this.now() - oldest) / 1000);
500
504
  }
501
505
 
502
506
  private oldestNoProgressAgeSeconds(): number {
503
- if (this.turns.size === 0) return 0;
507
+ if (this.attempts.size === 0) return 0;
504
508
  let leastRecentProgress = Number.POSITIVE_INFINITY;
505
- for (const { lastProgressAt } of this.turns.values()) {
509
+ for (const { lastProgressAt } of this.attempts.values()) {
506
510
  leastRecentProgress = Math.min(leastRecentProgress, lastProgressAt);
507
511
  }
508
512
  return Math.max(0, (this.now() - leastRecentProgress) / 1000);
@@ -5,6 +5,7 @@ import type {
5
5
  GitCredentialsRequest,
6
6
  } from "@opengeni/contracts";
7
7
  import { prReviewRegistrationIdFromCredentialBinding } from "@opengeni/core";
8
+ import { createRemoteMcpCredentialsPort } from "@opengeni/core/remote-mcp-credentials";
8
9
  import { decryptVariableSetValue, resolvePrReviewGitCredential, type Database } from "@opengeni/db";
9
10
  import {
10
11
  createGitHubAppInstallationTokenWithExpiry,
@@ -18,6 +19,7 @@ export function createStandaloneConnectionCredentialsPort(
18
19
  db: Database,
19
20
  ): ConnectionCredentialsPort {
20
21
  return {
22
+ ...createRemoteMcpCredentialsPort(settings),
21
23
  gitCredentials: async (request) => await resolveStandaloneGitCredentials(settings, db, request),
22
24
  };
23
25
  }