feat(runs): checkpoint-resume for interrupted evaluation runs + concurrent-safe run updates - #414
Conversation
…tion; stop pollers clobbering judged verdicts The server-side trace poller correlated by runId only (Strategy B), but Claude Code spans carry only session.id and pi/REST agents tag neither - so every useTraces:true run timed out after 10 minutes headless and errored all 84 reports (browser-side recovery masked this when a run page happened to be open). - tracePoller: union Strategy B with sessionId/service-window hints (buildJudgeAgentsHints) derived from the report; runId now optional; MAX_POLL_CEILING 60 -> 240 (stopped silently clamping per-agent tracePolling.maxAttempts overrides). - Clobber guards: stop polling when the report reached a terminal metricsStatus through another path; never overwrite an already-judged report with trace_timeout/trace_incomplete/trace_fetch_failed patches; RunInspectorPage recovery fan-out waits a 3-min grace period so eager transiently-pending placeholders aren't raced (this clobbered a live benchmark's early verdicts on 2026-08-25). - Poll call sites (benchmarkRunner x2, evaluationRunner, browserRecovery): drop the runId requirement - REST-connector reports never get one. - traceRecoveryOnBoot: skip reports younger than TRACE_RECOVERY_MIN_AGE_MS (default 15 min; likely in-flight, possibly on a sibling server sharing the storage cluster - tombstoning them killed healthy mid-benchmark placeholders) and resume no-runId reports via hint-based polling instead of erroring them. Signed-off-by: Megha Goyal <goyamegh@amazon.com>
…ge claim state, no-hang stop path Addresses adversarial-review findings on the correlation fix: - benchmarkRunner upstream gates no longer require report.runId to start polling (the helpers accepted undefined but were never reached). - New PollCallbacks.onStopped: the top-of-poll terminal-state stop now notifies plain startPolling wrappers (waitForTracesAndJudge resolved only from onTracesFound/onError and would hang forever). - Poller claims the report (pending -> calculating) before judging, making browser recovery's 'someone else is judging' guard effective; narrows the sibling-poller double-judge window (no CAS in the storage layer). - Exact-match post-filter: window-fallback (Strategy C) results are filtered to the report's session.id, else its eval-span traceId, before judging - concurrent same-service runs no longer contaminate each other's trajectory. The runners stamp the eval test_case span's traceId on the report at case start (REST agents adopt the traceparent header, pi adopts TRACEPARENT env - both verified live). - patchErrorIfStillPending also skips reports already in 'error' (don't stomp a more specific cause with a generic timeout). - Skip the trace fetch entirely when a poll attempt has no correlation key (unfiltered queries are rejected server-side; treat as a normal attempt instead of misreporting a fetch failure). Signed-off-by: Megha Goyal <goyamegh@amazon.com>
…user configs; fail-closed span filtering; preserve answer content in span-built trajectories
Live smoke findings on the previous commit:
- lib/config/loader.ts toAgentConfig() silently DROPPED traceServiceName
and tracePolling from user agent configs. Window correlation then fell
back to the connector-protocol default service name ('pi-agent'), which
collides with other emitters on a shared cluster - a pi eval run judged
a pi-web session's spans. Poll budgets also silently reverted to 60x10s.
- The exact-match span filter was fail-OPEN: when no fetched span matched
the report's sessionId/eval-traceId it judged the unmatched window
spans anyway. Now fail-closed: no match = traces not yet available,
keep polling.
- The default span->trajectory conversion replaced the rich connector
trajectory with content-less span stubs for Claude Code (message
content lives in OTel logs, not span attributes), so the judge failed
every case. Hook output still wins wholesale (opensearch-project#320); the default
conversion now appends the connector trajectory's response steps when
the span-built steps carry no response content.
Signed-off-by: Megha Goyal <goyamegh@amazon.com>
…gents; honor agentConfig.tracePolling in the poller - BaseConnector.buildTraceparentEnv() existed (and traceContext.propagateEnv claimed it) but NO connector ever called it - subprocess agents (pi, Claude Code, Kiro) spawned without W3C trace context. pi's OTel SDK honors TRACEPARENT (verified live), so its spans now land under the eval test_case span's traceId, giving the poller an exact Strategy-A correlator; without it the fail-closed filter had nothing to match and pi trace-judging timed out. - tracePollingManager.startPolling now falls back to options.agentConfig.tracePolling when explicit maxAttempts/intervalMs aren't passed - evaluationRunner.waitForTracesAndJudge passes agentConfig only, so per-agent poll budgets were silently ignored on that path (observed: 60 attempts despite maxAttempts: 90 in config). Signed-off-by: Megha Goyal <goyamegh@amazon.com>
PR Reviewer Guide 🔍(Review updated until commit ca3ff17)Here are some key observations to aid the review process:
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #414 +/- ##
==========================================
- Coverage 60.14% 60.03% -0.12%
==========================================
Files 373 373
Lines 30441 30602 +161
Branches 9100 9136 +36
==========================================
+ Hits 18310 18373 +63
- Misses 10375 10476 +101
+ Partials 1756 1753 -3
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
535d750 to
e800a93
Compare
PR Code Suggestions ✨Latest suggestions up to ca3ff17 Explore these optional code suggestions:
Previous suggestionsSuggestions up to commit ba72497
Suggestions up to commit 24facd2
Suggestions up to commit 68b6299
Suggestions up to commit ad41511
Suggestions up to commit c4c77e8
|
|
Persistent review updated to latest commit e800a93 |
|
Persistent review updated to latest commit c4c77e8 |
Adversarial review (codex / gpt-5.4) — findings & resolutionsRan a second-opinion review from a different model family on this diff before requesting approval. Findings and what was done (all fixes in
Verification proof
|
The polling gate introduced in this PR requires agentConfig.useTraces — only trace-mode agents legitimately produce metricsStatus 'pending'. The 'triggers trace polling' test relied on the pre-gate behavior (poll on pending regardless of agent mode); give its mocked agent useTraces: true to match the new semantics. Signed-off-by: Megha Goyal <goyamegh@amazon.com>
…oint-resume parity) - POST /api/storage/evaluation-runs/:id/resume re-executes only test cases without a persisted report; completed reports are preserved (the persisted per-test-case reports ARE the checkpoint, RedKite-style) - Boot recovery for orphaned EvaluationRun docs (stale 'running' -> failed with note, resumable) — sister of benchmarkRunRecoveryOnBoot - Liveness heartbeat (heartbeatAt, 60s) so servers sharing one storage cluster never orphan-kill each other's active runs; resume + recovery gate on EVALUATION_RUN_STALE_AFTER_MS (default 1h) - Resume (N left) button on the run detail page; resumeEvaluationRun client - Tests: unit (resumable-ids, liveness), integration (resume path e2e via demo agent), Playwright (button contract, hidden when nothing to resume) Signed-off-by: Megha Goyal <goyamegh@amazon.com>
…me tests Signed-off-by: Megha Goyal <goyamegh@amazon.com>
- updateResult: retry_on_conflict on the per-test-case script update — concurrency>1 runs aborted with version_conflict_engine_exception - update(): partial doc-merge via _update API instead of read-modify-write full reindex, which could clobber concurrent per-test-case results (and raced the new 60s run heartbeat) Found by P1 resume verification on the shared cluster (84-case EnterpriseRAG run at concurrency 4 failed within seconds). Signed-off-by: Megha Goyal <goyamegh@amazon.com>
Signed-off-by: Megha Goyal <goyamegh@amazon.com>
- runLivenessAgeMs uses max(heartbeat, resumed, created) — a fresh resume claim counts as liveness immediately; previously the dead server's stale heartbeatAt left a just-resumed run looking orphaned (double-resume window) - resume claim: stamp heartbeatAt + a resumeToken nonce, re-read and abort with an SSE error if another server won the claim race (no cross-server CAS primitive exists; write-then-verify shrinks the window to ~one refresh) - partial source re-resolution: only reset test cases that will actually execute; ids the sources no longer resolve keep their failed-with-note entry and are surfaced as missingCount/missingTestCaseIds on started - boot recovery: two-phase scan-then-mutate — offset pagination over the shrinking status:running result set skipped stale runs beyond page 1 - PUT upsert route: full-document replace via create() (update() is now a doc-merge; omitted nested results keys would have survived an import) - file adapter: per-run write serialization for update/updateResult - tests: liveness max-semantics regression, double-resume 409 integration Signed-off-by: Megha Goyal <goyamegh@amazon.com>
c4c77e8 to
ad41511
Compare
|
Persistent review updated to latest commit ad41511 |
Production bug, hit twice: the create route's success path links a completed run into the benchmark's embedded `runs` array via `storage.benchmarks.addRun` (that projection is what the benchmark detail page and the scoped comparison pool read). If a run's original create-route execution crashed before ever reaching that success branch, the run was never linked -- and until now, a later `POST .../resume` that finished it successfully didn't link it either, since the resume completion path only updated the evaluation-run document itself. `GET .../evaluation-runs/:id` and the Evaluation Runs page looked completely fine (they read the evaluation-run document directly), so the run silently stayed missing from every benchmark-scoped view. The resume completion path now builds the same BenchmarkRun projection (buildBenchmarkRunProjection, exported so the create route's success path can share it once opensearch-project#399 lands) and links it via a new idempotent linkCompletedRunToBenchmark helper: upserts by run id (updateRun if already linked, addRun otherwise) so a run resumed more than once, or already linked by the create path, is never duplicated in benchmark.runs. BenchmarkRun gains an optional completedAt field to carry the terminal timestamp. Tests (red -> green at every level, verified locally by reverting the route change and re-running): - unit: buildBenchmarkRunProjection field coverage; linkCompletedRunToBenchmark add-vs-upsert-by-id and a repeated-call regression guard against real array semantics. - integration: seeds a benchmark-linked run with an empty benchmark.runs (simulating the pre-addRun crash), resumes it to completion, asserts it appears in benchmark.runs exactly once, then resumes again and asserts it is still not duplicated. - e2e: drives a real resume against the mock demo agent and asserts the run renders on /benchmarks/:id/runs (BenchmarkRunsPage reads benchmark.runs directly). Signed-off-by: Megha Goyal <goyamegh@amazon.com>
PR Code Analyzer ❗AI-powered 'Code-Diff-Analyzer' found issues on commit 24facd2.
The table above displays the top 10 most important findings. Pull Requests Author(s): Please update your Pull Request according to the report above. Repository Maintainer(s): You can Thanks. |
|
Persistent review updated to latest commit 68b6299 |
Per codex_review on the previous commit: - Blocking: a benchmark-linking failure (e.g. the benchmark was deleted mid-resume) rewrote the resumed run's own status to 'failed', even though its test cases had genuinely completed and their reports were already persisted. The linking call is now in its own try/catch, logged loudly on failure, and never allowed to corrupt the canonical run record. - Medium: buildBenchmarkRunProjection used truthy checks for optional fields, silently dropping a real concurrency: 0 or an intentionally empty description/evaluatorId. Switched to explicit != null checks. - Documented (not silently claimed solved): linkCompletedRunToBenchmark is read-then-branch-then-write, not an atomic storage-level upsert. Two truly concurrent links of the same run id could both addRun, producing a duplicate a later updateRun can't repair. Added an explicit code comment on the known, bounded race window plus a unit test that demonstrates it under controlled concurrency, and a new integration test proving the failure-decoupling fix (benchmark pointed at a nonexistent id still resolves the run to 'completed'). Closing the race fully needs an atomic upsert-by-id primitive in the storage adapters (OpenSearch + file) touching both the create and resume paths uniformly -- a bigger, separate change, tracked as a follow-up rather than bundled into this bug fix. Verified: full unit suite (4661 tests) and the resume unit+integration suites green against an isolated disposable OpenSearch container; e2e resume specs green. Signed-off-by: Megha Goyal <goyamegh@amazon.com>
Addendum: resumed runs no longer vanish from
|
|
Persistent review updated to latest commit 24facd2 |
…nsearch-project#416 opensearch-project#429 opensearch-project#421 opensearch-project#417) into run-resume-checkpoint Real conflicts in services/evaluationRunner.ts + its integration test: this branch predates opensearch-project#417's trace-judged-stats-inflation fix (judgeOutcome capture from waitForTracesAndJudge). Took origin/main's side throughout (judgeOutcome declaration/capture, onStopped resolving with an explicit null to match Promise<PassFailStatus | null>) — the checkpoint-resume changes in this PR don't touch that code path, so there's no actual behavioral conflict, just adjacent-line churn. Signed-off-by: Megha Goyal <goyamegh@amazon.com>
|
Persistent review updated to latest commit ba72497 |
…nspector Convergence audit against PR opensearch-project#414 (checkpoint-resume): EvalRunDetailPage has always polled every 3s while status === 'running' so pass/fail counts and per-test-case statuses update live during/after a resume without a manual reload. RunInspectorPage had no equivalent — clicking Resume there updated nothing until resumeEvaluationRun()'s whole SSE stream settled, since the client call passes a no-op progress callback (true on both pages; opensearch-project#414 never wired up onStarted/onProgress). Adds the same running-poll to RunInspectorPage (evalRun mode only), but improves on the source pattern instead of copying its flaw: loadData() now takes a { silent } option that skips the full-page loading-skeleton flip, so a background refresh doesn't blow away the user's open test-case selection every 3s (EvalRunDetailPage's loadRun() does flip loading on every poll tick — harmless there since it has no left/right selection state to lose, but would have been disruptive on the two-pane inspector). The post-resume-click refreshes are silent too. Signed-off-by: Megha Goyal <goyamegh@amazon.com>
PR opensearch-project#414 convergence audit follow-up. The existing resume-run.spec.ts (ported from opensearch-project#414, now targeting the canonical page via inspector-resume-btn) only pins the UI contract with a mocked SSE response. This adds a real, non-mocked resume: seeds a run with one genuinely-executed completed report and one pending test case (status 'cancelled'), clicks Resume on /evaluations/runs/:runId, lets the real demo agent execute the pending test case, and asserts the canonical page reaches status 'completed' with correct 2/2 pass stats and the Resume button gone — without ever leaving the canonical URL, and without a manual reload (regression guard for the running-poll fix in c131053). Uses the testData fixture (testCase/evaluationRun/run) for tracked cleanup, per repo policy. Signed-off-by: Megha Goyal <goyamegh@amazon.com>
…sume convergence audit Signed-off-by: Megha Goyal <goyamegh@amazon.com>
ba72497 to
ca3ff17
Compare
|
Persistent review updated to latest commit ca3ff17 |
Summary
Interrupted evaluation runs (server crash / deploy / cancellation) can now be resumed in place:
POST /api/storage/evaluation-runs/:id/resumere-executes ONLY the test cases without a persisted report, preserving completed reports byte-for-byte. The per-test-case reports already persisted in storage act as the checkpoint — nothing new to store, nothing to lose.What's included
POST /api/storage/evaluation-runs/:id/resume(SSE, mirrors the create route); the run detail page shows aResume (N left)button for interrupted runs with unfinished test cases. Same run id — history and stats stay coherent,resumedAtis stamped.evaluationRunRecoveryOnBoot.ts, sister ofbenchmarkRunRecoveryOnBoot.ts) — stalerunningrun docs are marked failed-with-note and become resumable. Previously they were stuckrunningforever with no way forward.heartbeatAtevery 60s. Recovery and resume gate on heartbeat staleness (EVALUATION_RUN_STALE_AFTER_MS, default 1h), so multiple agent-health servers sharing one storage cluster can never orphan-kill or double-execute each other's active runs.concurrency > 1could abort within seconds onversion_conflict_engine_exception.updateResultnow setsretry_on_conflict, andupdate()is a partial doc-merge via the_updateAPI instead of a read-modify-write full reindex (which could clobber concurrent per-test-case results).Verification (real 84-case benchmark, shared OpenSearch cluster)
kill -9the server at 60/84 → run frozenrunning, heartbeat stoppedtotalcovers the full run400 Nothing to resumeTests
tests/unit/server/routes/storage/evaluationRunResume.test.ts(resumable-id semantics, liveness/staleness) — 9 casestests/integration/server/routes/evaluationRunResume.integration.test.ts(seed interrupted run → resume via API → preserved vs re-executed reports, full-size stats, 400/404 paths) — runs against a live backend with the built-in demo agent, no AWS neededtests/e2e/resume-run.spec.ts(button contract: visible with correct remaining count on interrupted runs, hidden when nothing to resume, POSTs to/resume)CHANGELOG updated under
## [Unreleased].