Repository navigation
fix(backend): address Greptile review findings on the orchestrator #251
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -73,6 +73,7 @@ def start_run( | |
| run_id=run_id, | ||
| capture_id=capture_id, | ||
| packet_sha256=digest, | ||
| packet_uri=packet_uri, | ||
| parent_run_id=parent_run_id, | ||
| status="pending", | ||
| branch=branch_of(packet), # type: ignore[arg-type] | ||
|
|
@@ -162,16 +163,39 @@ def on_confirmation( | |
| ) | ||
| try: | ||
| from server.reads.policy import confirm | ||
| from server.reads.types import Reads | ||
| except ImportError: | ||
| return | ||
| confirm( | ||
| capture_id, | ||
| key = _latest_reads_key(services, capture_id) | ||
| if key is None: | ||
| return | ||
| try: | ||
| reads = Reads.model_validate(json.loads(services.storage.get_bytes(key))) # type: ignore[attr-defined] | ||
| except (OSError, ValueError): | ||
| return | ||
| updated = confirm( | ||
| reads, | ||
| still_id=confirmation.imageId, | ||
| choice=confirmation.value if confirmation.kind == "confirmed" else None, | ||
| typed=confirmation.value if confirmation.kind == "corrected" else None, | ||
| by=by, | ||
| by=by, # type: ignore[arg-type] | ||
| field_id=confirmation.fieldId, | ||
| ) | ||
| # Persist the corrected record so later results read the answer. | ||
| services.storage.put_bytes(key, updated.model_dump_json().encode(), "application/json") # type: ignore[attr-defined] | ||
|
Comment on lines
+184
to
+185
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Captures in Refresh the affected outputs after applying the answer, and test a correction followed by a result fetch. Prompt To Fix With AIThis is a comment left during a code review.
Path: backend/server/orchestrator/api.py
Line: 184-185
Comment:
**Results ignore later corrections**
Captures in `needs_views` or `manual_review` still accept confirmations, but an answer submitted after the result is built changes only `reads/reads.json`. The result endpoint serves the existing `result/result.json`, whose meter value was copied from reads earlier. This hook neither rebuilds that result nor reruns criteria that already used the old reads, so users still see the old value after a successful correction.
Refresh the affected outputs after applying the answer, and test a correction followed by a result fetch.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
|
|
||
|
|
||
| def _latest_reads_key(services: object, capture_id: str) -> str | None: | ||
| """The reads artifact of the capture's newest run, or None before reads exist.""" | ||
|
|
||
| runs = services.state.runs_for_capture(capture_id) # type: ignore[attr-defined] | ||
| if not runs: | ||
| return None | ||
| run = max(runs, key=lambda item: item.created_at) | ||
| from server.orchestrator.artifacts import overlay_stage_artifacts | ||
|
|
||
| key = overlay_stage_artifacts(services.state, run).artifacts.get("reads/reads.json") # type: ignore[attr-defined] | ||
| return key or None | ||
|
|
||
|
|
||
| def cancel_capture(capture_id: str) -> None: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -188,21 +188,24 @@ def hydrate_packet( | |
| *, | ||
| include_files: bool = True, | ||
| cache_dir: Path | None = None, | ||
| packet_key: str | None = None, | ||
| ) -> None: | ||
| """Copy the packet into ``packet_dir``. | ||
|
|
||
| ``include_files`` is false for stages that only read ``packet.json``. A warm | ||
| volume cache skips GCS for the frames a later stage does need. | ||
| ``packet_key`` is the manifest's storage key: an amendment run passes its | ||
| own ``captures/{capture_id}/amendments/{amendment_id}/packet.json``. | ||
| """ | ||
|
|
||
| packet_dir.mkdir(parents=True, exist_ok=True) | ||
| if _copy_packet_cache(cache_dir, packet_dir, include_files=include_files): | ||
| return | ||
| packet_key = f"captures/{capture_id}/packet.json" | ||
| _download_all(storage, [(packet_dir / "packet.json", packet_key)]) | ||
| manifest_key = packet_key or f"captures/{capture_id}/packet.json" | ||
| _download_all(storage, [(packet_dir / "packet.json", manifest_key)]) | ||
| if not include_files: | ||
| return | ||
| _download_all(storage, _packet_file_jobs(state, capture_id, packet_dir)) | ||
| _download_all(storage, _packet_file_jobs(state, capture_id, packet_dir, manifest_key)) | ||
| if cache_dir is not None and (packet_dir / "packet.json").is_file(): | ||
| _publish_packet_cache(packet_dir, cache_dir) | ||
|
|
||
|
|
@@ -242,7 +245,7 @@ def hydrate_artifacts(storage: object, dest_root: Path, artifacts: dict[str, str | |
| return present | ||
|
|
||
|
|
||
| def _packet_file_jobs(state: object, capture_id: str, packet_dir: Path) -> list[tuple[Path, str]]: | ||
| def _packet_file_jobs(state: object, capture_id: str, packet_dir: Path, packet_key: str) -> list[tuple[Path, str]]: | ||
| packet: dict | None = None | ||
| manifest = packet_dir / "packet.json" | ||
| if manifest.is_file(): | ||
|
|
@@ -252,6 +255,9 @@ def _packet_file_jobs(state: object, capture_id: str, packet_dir: Path) -> list[ | |
| loaded = None | ||
| if isinstance(loaded, dict): | ||
| packet = loaded | ||
| # Manifest paths are relative to the packet. A base packet sits at | ||
| # captures/{capture_id}/packet.json, an amendment under its own prefix. | ||
| base = packet_key.rsplit("packet.json", 1)[0] or f"captures/{capture_id}/" | ||
| jobs: list[tuple[Path, str]] = [] | ||
| seen: set[str] = set() | ||
| files = getattr(state, "files", None) | ||
|
|
@@ -271,7 +277,7 @@ def _packet_file_jobs(state: object, capture_id: str, packet_dir: Path) -> list[ | |
| rel = _safe_rel(rel_text) | ||
| if rel is None: | ||
| continue | ||
| jobs.append((packet_dir / rel, f"captures/{capture_id}/{rel.as_posix()}")) | ||
| jobs.append((packet_dir / rel, f"{base}{rel.as_posix()}")) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an amendment reuses a base filename such as Resolve committed records relative to the selected packet. Add a regression test with matching base and amendment filenames. Prompt To Fix With AIThis is a comment left during a code review.
Path: backend/server/orchestrator/artifacts.py
Line: 280
Comment:
**Amendments still use old photos**
When an amendment reuses a base filename such as `stills/meter.jpg`, `_packet_file_jobs` copies the base image and adds its path to `seen`. The manifest loop then skips that filename before using the new amendment prefix. The replacement image is copied under `amendments/{id}/` instead of the path named in the manifest, so the worker analyzes the original photo.
Resolve committed records relative to the selected packet. Add a regression test with matching base and amendment filenames.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
| seen.add(rel.as_posix()) | ||
| return jobs | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| """Regressions from the Greptile apex review of PR #247. | ||
|
|
||
| Each test names the finding it pins: amendment packet hydration, confirmation | ||
| persistence, per-attempt timeout budgets, and the run's own rules profile. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| from datetime import timedelta | ||
| from pathlib import Path | ||
|
|
||
| from server.contracts.api import ConfirmationRequest | ||
| from server.contracts.jobs import RunRecord, StageRecord | ||
| from server.orchestrator.api import on_confirmation | ||
| from server.orchestrator.clock import Clock | ||
| from server.orchestrator.coordinator import RunCoordinator | ||
| from server.orchestrator.events import emit_failed | ||
| from server.orchestrator.services import bind | ||
| from server.orchestrator.tests.conftest import build_services | ||
| from server.reads.types import ConfirmBlock, MeterRead, Reads | ||
|
|
||
|
|
||
| def _run(services, run_id: str, **overrides: object) -> RunRecord: | ||
| fields: dict[str, object] = { | ||
| "run_id": run_id, | ||
| "capture_id": "cap_test", | ||
| "packet_sha256": "a" * 64, | ||
| "status": "running", | ||
| "branch": "image", | ||
| "flow": "guided", | ||
| "tier": "arkit", | ||
| "profile_default": "C", | ||
| "created_at": services.clock.iso(), | ||
| "deadline_at": (services.clock.now() + timedelta(minutes=20)).isoformat(), | ||
| "artifacts": {}, | ||
| } | ||
| fields.update(overrides) | ||
| return RunRecord(**fields) # type: ignore[arg-type] | ||
|
|
||
|
|
||
| def test_amendment_run_hydrates_its_own_packet(tmp_path: Path) -> None: | ||
| """Finding: artifacts.py:202 — an amendment run read the base packet.""" | ||
| services, _uri, _digest = build_services(tmp_path, "memory") | ||
| amend_key = "captures/cap_test/amendments/amd-1/packet.json" | ||
| amend_packet = { | ||
| "keyframes": [{"id": "door-2"}], | ||
| "stills": [], | ||
| "files": [{"path": "extra/photo.jpg", "sha256": "ab" * 32, "bytes": 4}], | ||
| } | ||
| services.storage.put_bytes(amend_key, json.dumps(amend_packet).encode(), "application/json") | ||
| services.storage.put_bytes("captures/cap_test/amendments/amd-1/extra/photo.jpg", b"jpeg", "image/jpeg") | ||
| run = _run(services, "run_amend", packet_uri=amend_key) | ||
| rec = StageRecord(run_id=run.run_id, stage="poses", status="running", attempt=1, heartbeat=services.clock.iso()) | ||
| with bind(services): | ||
| ctx = services.stage_context(run, rec, "call") | ||
| packet_dir = Path(ctx.inputs["packet_dir"]) | ||
| # The amendment manifest, not captures/cap_test/packet.json. | ||
| assert json.loads((packet_dir / "packet.json").read_text()) == amend_packet | ||
| # Manifest-listed files resolve under the amendment prefix. | ||
| assert (packet_dir / "extra" / "photo.jpg").read_bytes() == b"jpeg" | ||
|
|
||
|
|
||
| def test_confirmation_updates_the_reads_record(tmp_path: Path) -> None: | ||
| """Finding: api.py:173 — confirm() got a capture_id string, not a Reads.""" | ||
| services, _uri, _digest = build_services(tmp_path, "memory") | ||
| key = "captures/cap_test/runs/run_reads/reads/reads.json" | ||
| reads = Reads( | ||
| capture_id="cap_test", | ||
| status="complete", | ||
| meter=MeterRead(status="auto_fill", value="00000", core="00000", confidence=0.5), | ||
| confirm=ConfirmBlock(candidates=["00000"]), | ||
| ) | ||
| services.storage.put_bytes(key, reads.model_dump_json().encode(), "application/json") | ||
| run = _run(services, "run_reads", status="done", artifacts={"reads/reads.json": key}) | ||
| services.state.put_run(run) | ||
| request = ConfirmationRequest( | ||
| fieldId="meter_number", | ||
| kind="corrected", | ||
| value="12345", | ||
| imageId="s1", | ||
| clientAt="2026-10-15T00:00:00.000Z", | ||
| ) | ||
| with bind(services): | ||
| on_confirmation("cap_test", request, "cfm_1", by="device") | ||
| stored = Reads.model_validate(json.loads(services.storage.get_bytes(key))) | ||
| assert stored.meter is not None | ||
| assert stored.meter.value == "12345" | ||
| assert stored.meter.status == "auto_fill" | ||
| assert stored.confirm is not None | ||
| assert stored.confirm.answer == "12345" | ||
|
|
||
|
|
||
| def test_new_attempt_clears_entered_at(tmp_path: Path) -> None: | ||
| """Finding: coordinator.py:266 — a retry inherited the timed-out budget.""" | ||
| services, _uri, _digest = build_services(tmp_path, "memory") | ||
| # No worker runs, so the persisted attempt-start record is observable. | ||
| services.executor.spawn = lambda call_id, ctx: None # type: ignore[method-assign] | ||
| run = _run(services, "run_retry") | ||
| services.state.put_run(run) | ||
| old = services.clock.iso() | ||
| services.state.upsert_stage( | ||
| StageRecord( | ||
| run_id=run.run_id, | ||
| stage="validate", | ||
| status="pending", | ||
| attempt=1, | ||
| failures=1, | ||
| heartbeat=old, | ||
| entered_at=old, | ||
| error_code="timeout", | ||
| error="timeout: validate", | ||
| ) | ||
| ) | ||
| with bind(services): | ||
| assert RunCoordinator(services)._spawn_one(run, "validate") | ||
| rec = services.state.get_stage(run.run_id, "validate") | ||
| assert rec.status == "running" | ||
| # The timeout sweep measures from entered_at; a stale value would cancel | ||
| # the replacement worker before it ever entered the stage. | ||
| assert rec.entered_at is None | ||
| assert rec.error_code is None | ||
| with bind(services): | ||
| RunCoordinator(services)._sweep_timeouts(run) | ||
| assert services.state.get_stage(run.run_id, "validate").status == "running" | ||
|
|
||
|
|
||
| def test_success_reads_the_runs_profile_verdict(tmp_path: Path) -> None: | ||
| """Finding: coordinator.py:571 — success always read verdict.C.json.""" | ||
| services, _uri, _digest = build_services(tmp_path, "memory") | ||
| key = "captures/cap_test/runs/run_prof/verdicts/verdict.A.json" | ||
| verdict = {"site": {"decision": "manual_review"}, "views_needed": ["door-2"]} | ||
| services.storage.put_bytes(key, json.dumps(verdict).encode(), "application/json") | ||
| run = _run(services, "run_prof", profile_default="A", artifacts={"verdicts/verdict.A.json": key}) | ||
| services.state.put_run(run) | ||
| # One final event already exists, so the close does not rewrite the status. | ||
| emit_failed( | ||
| services.state, | ||
| "cap_test", | ||
| run.run_id, | ||
| "seed", | ||
| "seed", | ||
| "manual_review", | ||
| clock=Clock(), | ||
| ) | ||
| with bind(services): | ||
| RunCoordinator(services)._apply_success(run) | ||
| # A-profile verdict asks for more views; verdict.C.json is absent, so the | ||
| # old hard-coded read fell through to manual_review and blocked amendments. | ||
| assert services.state.get_capture("cap_test").status == "needs_views" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The endpoint accepts confirmations during upload and processing, but this hook returns when
reads/reads.jsondoes not exist. Nothing applies the saved answer when the reads stage finishes. Retrying the same request also returns before calling the hook, so a successful correction can leave the later analysis using the original value.Apply saved confirmations when reads are written, and test a confirmation submitted before the run starts.
Prompt To Fix With AI