@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.
- package/dist/activities/agent-turn/model-usage.d.ts +1 -1
- package/dist/activities/mcp-credentials.d.ts +3 -1
- package/dist/activities/sandbox-lease.d.ts +4 -4
- package/dist/activities/workspace-capture.d.ts +12 -1
- package/dist/{activities-control-WZRFEIJH.js → activities-control-JUBMDBDZ.js} +58 -73
- package/dist/activities-control-JUBMDBDZ.js.map +1 -0
- package/dist/{activities-turn-V7J34ZOF.js → activities-turn-I2ZKGZE6.js} +268 -187
- package/dist/activities-turn-I2ZKGZE6.js.map +1 -0
- package/dist/{chunk-BARTLVBK.js → chunk-A4XQWVZK.js} +25 -25
- package/dist/chunk-A4XQWVZK.js.map +1 -0
- package/dist/{chunk-C54VDT6I.js → chunk-ZNIKZQMN.js} +148 -111
- package/dist/chunk-ZNIKZQMN.js.map +1 -0
- package/dist/index.js +2 -2
- package/dist/observability-metrics.d.ts +12 -4
- package/dist/sandbox-archive-storage.d.ts +15 -3
- package/dist/sandbox-resume.d.ts +1 -1
- package/package.json +17 -17
- package/src/activities/agent-turn/agent-build.ts +30 -1
- package/src/activities/agent-turn/claim.ts +3 -3
- package/src/activities/agent-turn/finalization.ts +62 -41
- package/src/activities/agent-turn/model-usage.ts +10 -1
- package/src/activities/agent-turn/skill-read.ts +2 -21
- package/src/activities/agent-turn/stream-attempt.ts +3 -2
- package/src/activities/agent-turn/tool-environment.ts +52 -10
- package/src/activities/goals.ts +2 -0
- package/src/activities/mcp-credentials.ts +42 -2
- package/src/activities/sandbox-lease.ts +64 -71
- package/src/activities/scheduled-tasks.ts +8 -6
- package/src/activities/workspace-capture.ts +148 -126
- package/src/observability-metrics.ts +28 -24
- package/src/pr-review-credentials.ts +2 -0
- package/src/sandbox-archive-storage.ts +82 -18
- package/src/sandbox-resume.ts +79 -63
- package/dist/activities-control-WZRFEIJH.js.map +0 -1
- package/dist/activities-turn-V7J34ZOF.js.map +0 -1
- package/dist/chunk-BARTLVBK.js.map +0 -1
- 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
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
"
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
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
|
-
//
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
|
|
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
|
|
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
|
|
893
|
-
//
|
|
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
|
|
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
|
|
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(
|
|
427
|
+
start(input: { attemptId: string }): void {
|
|
428
428
|
const now = this.now();
|
|
429
|
-
this.
|
|
429
|
+
this.attempts.set(input.attemptId, { startedAt: now, lastProgressAt: now });
|
|
430
430
|
this.ensureTimer();
|
|
431
431
|
this.refreshGauges();
|
|
432
432
|
}
|
|
433
433
|
|
|
434
|
-
progress(
|
|
435
|
-
const
|
|
436
|
-
if (!
|
|
437
|
-
|
|
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(
|
|
441
|
-
|
|
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.
|
|
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.
|
|
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
|
|
471
|
-
value: this.
|
|
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
|
|
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.
|
|
490
|
+
this.attempts.clear();
|
|
487
491
|
this.refreshGauges();
|
|
488
492
|
this.stopTimer();
|
|
489
493
|
}
|
|
490
494
|
|
|
491
495
|
private oldestInflightAgeSeconds(): number {
|
|
492
|
-
if (this.
|
|
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.
|
|
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.
|
|
507
|
+
if (this.attempts.size === 0) return 0;
|
|
504
508
|
let leastRecentProgress = Number.POSITIVE_INFINITY;
|
|
505
|
-
for (const { lastProgressAt } of this.
|
|
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
|
}
|