diffbutler 0.5.0__py3-none-any.whl

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 (105) hide show
  1. diffbutler/__init__.py +1 -0
  2. diffbutler/__main__.py +6 -0
  3. diffbutler/config/__init__.py +1 -0
  4. diffbutler/config/default.toml +91 -0
  5. diffbutler/config/diagnostics.py +90 -0
  6. diffbutler/config/file.py +30 -0
  7. diffbutler/config/instance.py +55 -0
  8. diffbutler/config/settings.py +297 -0
  9. diffbutler/config/setup.py +92 -0
  10. diffbutler/config/transport.py +65 -0
  11. diffbutler/core/__init__.py +1 -0
  12. diffbutler/core/application.py +103 -0
  13. diffbutler/core/context.py +54 -0
  14. diffbutler/core/dashboard.py +393 -0
  15. diffbutler/core/doctor.py +200 -0
  16. diffbutler/core/lifecycle.py +70 -0
  17. diffbutler/core/managed.py +441 -0
  18. diffbutler/core/native.py +301 -0
  19. diffbutler/core/polling.py +118 -0
  20. diffbutler/core/process.py +64 -0
  21. diffbutler/core/repositories.py +260 -0
  22. diffbutler/core/reset.py +34 -0
  23. diffbutler/core/resources.py +50 -0
  24. diffbutler/core/review.py +331 -0
  25. diffbutler/core/setup.py +232 -0
  26. diffbutler/core/snapshot.py +70 -0
  27. diffbutler/core/sync.py +259 -0
  28. diffbutler/core/worker.py +205 -0
  29. diffbutler/core/workspaces.py +139 -0
  30. diffbutler/domain/__init__.py +1 -0
  31. diffbutler/domain/dashboard.py +63 -0
  32. diffbutler/domain/diff.py +285 -0
  33. diffbutler/domain/forge.py +123 -0
  34. diffbutler/domain/lifecycle.py +51 -0
  35. diffbutler/domain/polling.py +92 -0
  36. diffbutler/domain/process.py +29 -0
  37. diffbutler/domain/redaction.py +61 -0
  38. diffbutler/domain/resources.py +50 -0
  39. diffbutler/domain/review.py +167 -0
  40. diffbutler/domain/sync.py +196 -0
  41. diffbutler/forges/__init__.py +1 -0
  42. diffbutler/forges/base.py +39 -0
  43. diffbutler/forges/diagnostics.py +104 -0
  44. diffbutler/forges/github/__init__.py +1 -0
  45. diffbutler/forges/github/adapter.py +352 -0
  46. diffbutler/forges/github/auth.py +49 -0
  47. diffbutler/forges/github/http.py +326 -0
  48. diffbutler/forges/github/mapping.py +157 -0
  49. diffbutler/forges/github/setup.py +86 -0
  50. diffbutler/forges/registry.py +23 -0
  51. diffbutler/py.typed +0 -0
  52. diffbutler/reviewers/__init__.py +1 -0
  53. diffbutler/reviewers/base.py +101 -0
  54. diffbutler/reviewers/extraction.py +198 -0
  55. diffbutler/reviewers/native.py +238 -0
  56. diffbutler/reviewers/parsing.py +247 -0
  57. diffbutler/reviewers/presets.toml +12 -0
  58. diffbutler/reviewers/process.py +78 -0
  59. diffbutler/reviewers/prompt.py +148 -0
  60. diffbutler/reviewers/redaction.py +30 -0
  61. diffbutler/reviewers/resources.py +162 -0
  62. diffbutler/sources/__init__.py +1 -0
  63. diffbutler/sources/diagnostics.py +100 -0
  64. diffbutler/sources/fetch.py +285 -0
  65. diffbutler/sources/git.py +461 -0
  66. diffbutler/sources/remotes.py +82 -0
  67. diffbutler/store/__init__.py +1 -0
  68. diffbutler/store/base.py +88 -0
  69. diffbutler/store/dashboard.py +201 -0
  70. diffbutler/store/database.py +172 -0
  71. diffbutler/store/lifecycle.py +247 -0
  72. diffbutler/store/migrations/__init__.py +1 -0
  73. diffbutler/store/migrations/env.py +14 -0
  74. diffbutler/store/migrations/versions/0001_initial.py +147 -0
  75. diffbutler/store/migrations/versions/0002_monitoring.py +38 -0
  76. diffbutler/store/migrations/versions/0003_reviews.py +72 -0
  77. diffbutler/store/migrations/versions/0004_claims.py +22 -0
  78. diffbutler/store/migrations/versions/0005_results.py +27 -0
  79. diffbutler/store/migrations/versions/0006_anchors.py +17 -0
  80. diffbutler/store/migrations/versions/0007_lifecycle.py +36 -0
  81. diffbutler/store/migrations/versions/0008_processes.py +30 -0
  82. diffbutler/store/migrations/versions/0009_dashboard.py +43 -0
  83. diffbutler/store/migrations/versions/0010_run_reasons.py +17 -0
  84. diffbutler/store/migrations/versions/__init__.py +1 -0
  85. diffbutler/store/models.py +290 -0
  86. diffbutler/store/process.py +75 -0
  87. diffbutler/store/queue.py +289 -0
  88. diffbutler/store/review.py +583 -0
  89. diffbutler/ui/__init__.py +1 -0
  90. diffbutler/ui/cli/__init__.py +532 -0
  91. diffbutler/ui/cli/editor.py +33 -0
  92. diffbutler/ui/tui/__init__.py +1 -0
  93. diffbutler/ui/tui/_fake_agent.py +47 -0
  94. diffbutler/ui/tui/_foreground_exec.py +23 -0
  95. diffbutler/ui/tui/app.py +34 -0
  96. diffbutler/ui/tui/browse.py +119 -0
  97. diffbutler/ui/tui/browser.py +42 -0
  98. diffbutler/ui/tui/dashboard.py +926 -0
  99. diffbutler/ui/tui/foreground.py +103 -0
  100. diffbutler/ui/tui/handoff_probe.py +184 -0
  101. diffbutler-0.5.0.dist-info/METADATA +170 -0
  102. diffbutler-0.5.0.dist-info/RECORD +105 -0
  103. diffbutler-0.5.0.dist-info/WHEEL +4 -0
  104. diffbutler-0.5.0.dist-info/entry_points.txt +3 -0
  105. diffbutler-0.5.0.dist-info/licenses/LICENSE.txt +21 -0
@@ -0,0 +1,260 @@
1
+ """Persistent, owned Git object storage with cross-process serialization and pins."""
2
+
3
+ import asyncio
4
+ import fcntl
5
+ import json
6
+ import logging
7
+ import os
8
+ import stat
9
+ import time
10
+ from collections.abc import AsyncGenerator
11
+ from contextlib import asynccontextmanager
12
+ from pathlib import Path
13
+ from typing import Literal
14
+ from uuid import NAMESPACE_URL, UUID, uuid5
15
+
16
+ from diffbutler.core.resources import save_json
17
+ from diffbutler.domain.forge import PRRef
18
+ from diffbutler.domain.resources import OwnedDirectory
19
+ from diffbutler.reviewers.resources import OwnedDirectories, settled_io
20
+ from diffbutler.sources.diagnostics import debug_event
21
+ from diffbutler.sources.fetch import fetch_repository, git_fetch_command
22
+ from diffbutler.sources.git import GitSourceReader, SourceError
23
+
24
+
25
+ @asynccontextmanager
26
+ async def file_lock(path: Path) -> AsyncGenerator[None]:
27
+ """Cancellation never leaves a background thread waiting on an advisory lock."""
28
+ await settled_io(lambda: path.parent.mkdir(parents=True, exist_ok=True))
29
+ fd = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
30
+ try:
31
+ info = os.fstat(fd)
32
+ if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid():
33
+ raise ValueError("Invalid managed storage lock")
34
+ while True:
35
+ try:
36
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
37
+ break
38
+ except BlockingIOError:
39
+ await asyncio.sleep(0.05)
40
+ yield
41
+ finally:
42
+ os.close(fd)
43
+
44
+
45
+ async def open_directories(path: Path) -> OwnedDirectories:
46
+ marker = path / ".diffbutler-repositories"
47
+ if path.exists() or path.is_symlink():
48
+ directories = await OwnedDirectories.reopen(path)
49
+ if marker.is_symlink() or await settled_io(marker.read_text) != "1\n":
50
+ raise ValueError("Invalid shared repository root")
51
+ return directories
52
+ await settled_io(lambda: path.parent.mkdir(parents=True, exist_ok=True))
53
+ directories = await OwnedDirectories.initialize(path)
54
+ await settled_io(lambda: marker.write_text("1\n"))
55
+ return directories
56
+
57
+
58
+ class RepositoryCache:
59
+ def __init__(self, path: Path, retention_days: int = 30) -> None:
60
+ self.path = path
61
+ self.retention_seconds = retention_days * 86400
62
+
63
+ @property
64
+ def lock_path(self) -> Path:
65
+ # Outside the root so initialization, deletion, and replacement share a lock.
66
+ return self.path.parent / f".{self.path.name}.lock"
67
+
68
+ @staticmethod
69
+ def resource(directories: OwnedDirectories, ref: PRRef) -> OwnedDirectory:
70
+ identity = json.dumps([ref.forge, ref.namespace, ref.repository])
71
+ key = uuid5(NAMESPACE_URL, identity)
72
+ return OwnedDirectory(directories.root_id, key, key)
73
+
74
+ async def existing(self, ref: PRRef) -> Path | None:
75
+ if not self.path.exists():
76
+ return None
77
+ directories = await open_directories(self.path)
78
+ path = self.path / self.resource(directories, ref).name
79
+ return path if path.is_dir() and not path.is_symlink() else None
80
+
81
+ async def prepare(
82
+ self,
83
+ ref: PRRef,
84
+ session_id: UUID,
85
+ run_id: UUID,
86
+ base_sha: str,
87
+ head_sha: str,
88
+ *,
89
+ local_path: Path | None = None,
90
+ transport: Literal["auto", "https", "ssh"] = "auto",
91
+ ) -> tuple[Path, Literal["https", "ssh"] | None]:
92
+ async with file_lock(self.lock_path):
93
+ directories = await open_directories(self.path)
94
+ resource = self.resource(directories, ref)
95
+ path = self.path / resource.name
96
+ debug_event(
97
+ logging.getLogger(__name__),
98
+ "Preparing source context",
99
+ repository=path,
100
+ local_path=local_path,
101
+ ref=ref,
102
+ base_sha=base_sha,
103
+ head_sha=head_sha,
104
+ transport=transport,
105
+ session_id=session_id,
106
+ run_id=run_id,
107
+ )
108
+ if not path.exists():
109
+ await directories.create(resource)
110
+ if path.is_symlink():
111
+ raise SourceError("Managed repository was replaced by a symlink")
112
+ # Register before any fetch, including failed/interrupted preparations.
113
+ pin = path / "pins" / f"{session_id.hex}-{run_id.hex}.json"
114
+
115
+ def register() -> None:
116
+ pin.parent.mkdir(mode=0o700, exist_ok=True)
117
+ (path / "last-used").touch()
118
+
119
+ await settled_io(register)
120
+ await save_json(pin, {"base": base_sha, "head": head_sha})
121
+ learned: Literal["https", "ssh"] | None = None
122
+ try:
123
+ await GitSourceReader.open(path, base_sha=base_sha, head_sha=head_sha)
124
+ except SourceError as exc:
125
+ debug_event(logging.getLogger(__name__), "Cached source unavailable", reason=exc)
126
+ imported = False
127
+ if local_path is not None:
128
+ try:
129
+ await GitSourceReader.open(local_path, base_sha=base_sha, head_sha=head_sha)
130
+ except SourceError as exc:
131
+ debug_event(
132
+ logging.getLogger(__name__), "Local source unavailable", reason=exc
133
+ )
134
+ # Even without the PR head, a local commit can supply most blobs.
135
+ # Retain a ref so the remote fetch can negotiate from this commit.
136
+ await git_fetch_command(path, "init", "--bare", "--template=")
137
+ logging.getLogger(__name__).info(
138
+ "Seeding PR object cache from local commits"
139
+ )
140
+ for revision in (base_sha, "HEAD"):
141
+ try:
142
+ await git_fetch_command(
143
+ path,
144
+ "fetch",
145
+ *(("--unshallow",) if (path / "shallow").is_file() else ()),
146
+ "--no-tags",
147
+ "--no-recurse-submodules",
148
+ "--",
149
+ str(local_path.absolute()),
150
+ f"+{revision}:refs/diffbutler/local-seed",
151
+ local=True,
152
+ )
153
+ break
154
+ except SourceError as seed_error:
155
+ logging.getLogger(__name__).info(
156
+ "Local cache seed unavailable: %s", seed_error
157
+ )
158
+ else:
159
+ await git_fetch_command(path, "init", "--bare", "--template=")
160
+ await git_fetch_command(
161
+ path,
162
+ "fetch",
163
+ *(("--unshallow",) if (path / "shallow").is_file() else ()),
164
+ "--no-tags",
165
+ "--no-recurse-submodules",
166
+ "--",
167
+ str(local_path.absolute()),
168
+ "+refs/heads/*:refs/remotes/local/*",
169
+ base_sha,
170
+ head_sha,
171
+ local=True,
172
+ )
173
+ imported = True
174
+ if not imported:
175
+ learned = await fetch_repository(
176
+ path, ref, base_sha, head_sha, transport=transport
177
+ )
178
+ reader = await GitSourceReader.open(path, base_sha=base_sha, head_sha=head_sha)
179
+ await save_json(
180
+ pin,
181
+ {
182
+ "base": base_sha,
183
+ "head": head_sha,
184
+ "merge-base": reader.comparison_base_sha,
185
+ },
186
+ )
187
+ for side, sha in (
188
+ ("base", base_sha),
189
+ ("head", head_sha),
190
+ ("merge-base", reader.comparison_base_sha),
191
+ ):
192
+ await git_fetch_command(
193
+ path, "update-ref", f"refs/diffbutler/{session_id.hex}/{run_id.hex}/{side}", sha
194
+ )
195
+ return path, learned
196
+
197
+ async def release(self, session_id: UUID) -> None:
198
+ if not self.path.exists():
199
+ return
200
+ async with file_lock(self.lock_path):
201
+ directories = await open_directories(self.path)
202
+ for resource in await self._resources(directories):
203
+ path = self.path / resource.name
204
+ pins = await settled_io(
205
+ lambda path=path: list((path / "pins").glob(f"{session_id.hex}-*.json"))
206
+ )
207
+ for pin in pins:
208
+ run_id = UUID(pin.stem.split("-")[1])
209
+ if (path / "HEAD").exists():
210
+ for side in ("base", "head", "merge-base"):
211
+ await git_fetch_command(
212
+ path,
213
+ "update-ref",
214
+ "-d",
215
+ f"refs/diffbutler/{session_id.hex}/{run_id.hex}/{side}",
216
+ )
217
+ await settled_io(pin.unlink)
218
+ await settled_io(lambda path=path: (path / "last-used").touch())
219
+
220
+ async def prune(self) -> None:
221
+ """Evict whole inactive repositories; never expire a live session's pins."""
222
+ if not self.path.exists():
223
+ return
224
+ async with file_lock(self.lock_path):
225
+ directories = await open_directories(self.path)
226
+ for resource in await self._resources(directories):
227
+ path = self.path / resource.name
228
+
229
+ def expired(path: Path = path) -> bool:
230
+ if any((path / "pins").glob("*.json")):
231
+ return False
232
+ marker = path / "last-used"
233
+ modified = marker.stat().st_mtime if marker.exists() else path.stat().st_mtime
234
+ return time.time() - modified >= self.retention_seconds
235
+
236
+ if await settled_io(expired):
237
+ await directories.delete(resource)
238
+
239
+ async def _resources(self, directories: OwnedDirectories) -> list[OwnedDirectory]:
240
+ def read() -> list[OwnedDirectory]:
241
+ result: list[OwnedDirectory] = []
242
+ for path in directories.path.iterdir():
243
+ if path.name in {".diffbutler-root", ".diffbutler-repositories"}:
244
+ continue
245
+ try:
246
+ left, right = path.name.split("-")
247
+ resource = OwnedDirectory(directories.root_id, UUID(left), UUID(right))
248
+ except ValueError:
249
+ raise ValueError("Unrecognized managed repository resource") from None
250
+ if (
251
+ resource.name != path.name
252
+ or resource.session_id != resource.resource_id
253
+ or path.is_symlink()
254
+ or not path.is_dir()
255
+ ):
256
+ raise ValueError("Invalid managed repository resource")
257
+ result.append(resource)
258
+ return result
259
+
260
+ return await settled_io(read)
@@ -0,0 +1,34 @@
1
+ """Explicit removal of local application directories without loading configuration."""
2
+
3
+ import asyncio
4
+ import shutil
5
+ from pathlib import Path
6
+
7
+ from diffbutler.config.diagnostics import state_path
8
+ from diffbutler.config.settings import ConfigError, config_path, data_path
9
+
10
+
11
+ def reset_paths() -> tuple[Path, ...]:
12
+ return tuple(
13
+ dict.fromkeys(
14
+ (config_path().parent.absolute(), data_path().absolute(), state_path().absolute())
15
+ )
16
+ )
17
+
18
+
19
+ def remove_directory(path: Path) -> None:
20
+ """Remove the directory tree, unlinking rather than following symbolic links."""
21
+ try:
22
+ if path.is_symlink() or path.is_file():
23
+ path.unlink()
24
+ else:
25
+ shutil.rmtree(path)
26
+ except FileNotFoundError:
27
+ pass
28
+ except OSError:
29
+ raise ConfigError(f"reset: could not remove {path}; check permissions and retry") from None
30
+
31
+
32
+ async def reset_directories(paths: tuple[Path, ...]) -> None:
33
+ for path in dict.fromkeys(paths):
34
+ await asyncio.to_thread(remove_directory, path)
@@ -0,0 +1,50 @@
1
+ """Persist resource ownership before filesystem preparation, under a worker claim."""
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import replace
6
+ from pathlib import Path
7
+ from typing import Literal, Protocol
8
+ from uuid import UUID, uuid4
9
+
10
+ from diffbutler.domain.resources import OwnedDirectory
11
+ from diffbutler.reviewers.resources import settled_io
12
+ from diffbutler.store.base import ReviewStore
13
+
14
+
15
+ async def save_json(path: Path, data: dict[str, str]) -> None:
16
+ """Publish a complete ownership/source record before creating dependent resources."""
17
+
18
+ def write() -> None:
19
+ temporary = path.with_name(f".{path.name}-{uuid4().hex}.tmp")
20
+ with temporary.open("x") as stream:
21
+ json.dump(data, stream)
22
+ stream.flush()
23
+ os.fsync(stream.fileno())
24
+ temporary.replace(path)
25
+ fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
26
+ try:
27
+ os.fsync(fd)
28
+ finally:
29
+ os.close(fd)
30
+
31
+ await settled_io(write)
32
+
33
+
34
+ class DirectoryProvisioner(Protocol):
35
+ def reserve(self, session_id: UUID) -> OwnedDirectory: ...
36
+ async def create(self, resource: OwnedDirectory) -> Path: ...
37
+
38
+
39
+ async def provision_directory(
40
+ store: ReviewStore,
41
+ directories: DirectoryProvisioner,
42
+ *,
43
+ token: UUID,
44
+ session_id: UUID,
45
+ purpose: Literal["artifact", "codex_home", "claude_home"] = "artifact",
46
+ ) -> Path:
47
+ """Failures retain the manifest; ordinary completion can clean absent resources."""
48
+ resource = replace(directories.reserve(session_id), purpose=purpose)
49
+ await store.register_resource(token, resource)
50
+ return await directories.create(resource)
@@ -0,0 +1,331 @@
1
+ """Immutable public review records. Explicit starts and worker claims never launch a process."""
2
+
3
+ import asyncio
4
+ import math
5
+ from collections.abc import Awaitable, Callable
6
+ from datetime import datetime
7
+ from typing import cast
8
+ from uuid import UUID
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
11
+
12
+ from diffbutler.domain.diff import AnchorValidation, DiffAnchor, DiffIndex
13
+ from diffbutler.domain.review import (
14
+ AnalysisIdentity,
15
+ ComparisonChanged,
16
+ ComparisonCheck,
17
+ ReviewPolicy,
18
+ ReviewResultRecord,
19
+ RunClaim,
20
+ RunRecord,
21
+ RunStatus,
22
+ SessionStatus,
23
+ Side,
24
+ )
25
+ from diffbutler.reviewers.base import ReviewOutput
26
+ from diffbutler.reviewers.extraction import OutputFormat
27
+ from diffbutler.reviewers.parsing import (
28
+ DraftReview,
29
+ ResponseFormat,
30
+ ReviewDocument,
31
+ Severity,
32
+ parse_review,
33
+ )
34
+ from diffbutler.store.base import ReviewStore
35
+
36
+
37
+ class ReviewSession(BaseModel):
38
+ model_config = ConfigDict(frozen=True)
39
+
40
+ id: UUID
41
+ task_id: UUID
42
+ reviewer_id: str
43
+ generation: int = Field(ge=1)
44
+ display_name: str
45
+ backend_session_id: str | None
46
+ status: SessionStatus
47
+ created_at: datetime
48
+
49
+
50
+ class AIRun(BaseModel):
51
+ model_config = ConfigDict(frozen=True)
52
+
53
+ id: UUID
54
+ task_id: UUID
55
+ session_id: UUID
56
+ reviewer_id: str
57
+ head_sha: str
58
+ base_sha: str
59
+ prompt_version: str
60
+ config_hash: str
61
+ analysis_key: str
62
+ attempt: int = Field(ge=1)
63
+ status: RunStatus
64
+ requested_at: datetime
65
+ started_at: datetime | None
66
+ finished_at: datetime | None
67
+ status_reason: str | None = None
68
+
69
+
70
+ _ANCHORS = TypeAdapter(tuple[AnchorValidation, ...])
71
+
72
+
73
+ class ReviewResult(BaseModel):
74
+ model_config = ConfigDict(frozen=True, ser_json_bytes="base64", val_json_bytes="base64")
75
+
76
+ run_id: UUID
77
+ exit_code: int
78
+ stdout: bytes | None
79
+ stderr: bytes | None
80
+ document: ReviewDocument | None
81
+ markdown: str | None = None
82
+ parse_tier: str | None
83
+ error: str | None
84
+ notes: tuple[str, ...]
85
+ selected_indices: tuple[int, ...]
86
+ min_confidence: float
87
+ findings_threshold: Severity
88
+ anchors: tuple[AnchorValidation, ...] | None = None
89
+
90
+
91
+ async def _store_call[T](operation: Awaitable[T]) -> T:
92
+ """Settle short store operations before propagating cancellation.
93
+
94
+ Comparison fetches stay cancellable; only database operations are shielded.
95
+ """
96
+ task = asyncio.ensure_future(operation)
97
+ cancelled = False
98
+ try:
99
+ while not task.done():
100
+ try:
101
+ await asyncio.shield(task)
102
+ except asyncio.CancelledError:
103
+ cancelled = True
104
+ return task.result()
105
+ finally:
106
+ if cancelled:
107
+ # Retrieve an exception before cancellation wins, avoiding orphan tasks.
108
+ if task.done() and not task.cancelled():
109
+ task.exception()
110
+ raise asyncio.CancelledError
111
+
112
+
113
+ class ReviewService:
114
+ """Internal scheduling seam; callers map explicit UI actions to requests.
115
+
116
+ The native executor selects the installed agent CLI permission profile. Worker
117
+ claims are durable and never expire automatically: process death must be
118
+ established before releasing ownership or finalizing a captured result.
119
+ """
120
+
121
+ def __init__(
122
+ self,
123
+ store: ReviewStore,
124
+ policy: ReviewPolicy,
125
+ compare_base: Callable[[RunRecord, str], Awaitable[bool]] | None = None,
126
+ ) -> None:
127
+ self.store = store
128
+ self.policy = policy
129
+ self.compare_base = compare_base
130
+
131
+ async def _comparison(self, token: UUID) -> ComparisonCheck:
132
+ run, base = await _store_call(self.store.comparison_snapshot(token))
133
+ matches = base == run.base_sha
134
+ if not matches and run.status == RunStatus.RUNNING and self.compare_base is not None:
135
+ matches = await self.compare_base(run, base)
136
+ return ComparisonCheck(base, matches)
137
+
138
+ async def get_run(self, run_id: UUID) -> AIRun:
139
+ return AIRun.model_validate(await self.store.get_run(run_id), from_attributes=True)
140
+
141
+ async def get_session(self, session_id: UUID) -> ReviewSession:
142
+ """Read durable conversation metadata; does not resume or start a process."""
143
+ return ReviewSession.model_validate(
144
+ await self.store.get_session(session_id), from_attributes=True
145
+ )
146
+
147
+ async def bind_backend_session(self, token: UUID, backend_session_id: str) -> ReviewSession:
148
+ """Checkpoint a backend identity while the caller still owns its worker claim."""
149
+ return ReviewSession.model_validate(
150
+ await self.store.bind_backend_session(token, backend_session_id), from_attributes=True
151
+ )
152
+
153
+ async def suspend_session(self, session_id: UUID) -> ReviewSession:
154
+ """Mark an idle conversation suspended; never stop a process or delete data."""
155
+ return ReviewSession.model_validate(
156
+ await self.store.suspend_session(session_id), from_attributes=True
157
+ )
158
+
159
+ async def request_review(self, identity: AnalysisIdentity) -> AIRun:
160
+ return AIRun.model_validate(
161
+ await self.store.request_review(identity, self.policy), from_attributes=True
162
+ )
163
+
164
+ async def claim_review(
165
+ self, run_id: UUID, *, capacity: int = 2, paused: bool = False
166
+ ) -> RunClaim | None:
167
+ if capacity < 1:
168
+ raise ValueError("capacity must be positive")
169
+ if paused:
170
+ return None
171
+ return await self.store.claim_review(run_id, capacity=capacity, policy=self.policy)
172
+
173
+ async def claim_is_current(self, token: UUID) -> bool:
174
+ while True:
175
+ comparison = await self._comparison(token)
176
+ try:
177
+ return await _store_call(
178
+ self.store.claim_is_current(token, self.policy, comparison)
179
+ )
180
+ except ComparisonChanged:
181
+ continue
182
+
183
+ async def cancel_review(self, run_id: UUID) -> AIRun:
184
+ return AIRun.model_validate(await self.store.cancel_review(run_id), from_attributes=True)
185
+
186
+ async def release_review(
187
+ self, token: UUID, *, reason: str | None = None, output: ReviewOutput | None = None
188
+ ) -> AIRun:
189
+ """Acknowledge worker exit and retain safe diagnostics and available output."""
190
+ failure = (
191
+ None
192
+ if reason is None
193
+ else ReviewResultRecord(
194
+ output.exit_code if output else -1,
195
+ output.stdout if output else None,
196
+ output.stderr if output else None,
197
+ None,
198
+ None,
199
+ reason,
200
+ (),
201
+ (),
202
+ 0.5,
203
+ "minor",
204
+ )
205
+ )
206
+ return AIRun.model_validate(
207
+ await self.store.release_review(token, failure), from_attributes=True
208
+ )
209
+
210
+ async def get_result(self, run_id: UUID) -> ReviewResult | None:
211
+ result = await self.store.get_result(run_id)
212
+ if result is None:
213
+ return None
214
+ return ReviewResult(
215
+ run_id=run_id,
216
+ exit_code=result.exit_code,
217
+ stdout=result.stdout,
218
+ stderr=result.stderr,
219
+ document=(
220
+ ReviewDocument.model_validate_json(result.document_json)
221
+ if result.document_json is not None and result.parse_tier != "markdown"
222
+ else None
223
+ ),
224
+ markdown=(
225
+ DraftReview.model_validate_json(result.document_json).markdown
226
+ if result.document_json is not None and result.parse_tier == "markdown"
227
+ else None
228
+ ),
229
+ parse_tier=result.parse_tier,
230
+ error=result.error,
231
+ notes=result.notes,
232
+ selected_indices=result.selected_indices,
233
+ min_confidence=result.min_confidence,
234
+ findings_threshold=cast(Severity, result.findings_threshold),
235
+ anchors=_ANCHORS.validate_json(result.anchors_json)
236
+ if result.anchors_json is not None
237
+ else None,
238
+ )
239
+
240
+ async def finalize_review(
241
+ self,
242
+ token: UUID,
243
+ output: ReviewOutput,
244
+ *,
245
+ diff_index: DiffIndex | None = None,
246
+ output_format: OutputFormat | None = None,
247
+ response_format: ResponseFormat = "json",
248
+ min_confidence: float = 0.5,
249
+ findings_threshold: Severity = "minor",
250
+ ) -> AIRun:
251
+ """Acknowledge verified worker exit and finalize already-extracted text.
252
+
253
+ A future executor must establish process-tree death before calling this.
254
+ Supply the original pinned context index to retain anchor validation.
255
+ Missing indexes are explicitly unvalidated. No forge is contacted.
256
+ """
257
+ ranks = {"question": 0, "nit": 1, "minor": 2, "important": 3, "blocking": 4}
258
+ if not math.isfinite(min_confidence) or not 0 <= min_confidence <= 1:
259
+ raise ValueError("minimum confidence must be finite and between zero and one")
260
+ if findings_threshold not in ranks:
261
+ raise ValueError("unknown findings threshold")
262
+ parsed = parse_review(
263
+ output,
264
+ output_format=output_format,
265
+ response_format=response_format,
266
+ )
267
+ selected = (
268
+ tuple(
269
+ index
270
+ for index, finding in enumerate(parsed.document.findings)
271
+ if finding.confidence >= min_confidence
272
+ and ranks[finding.severity] >= ranks[findings_threshold]
273
+ )
274
+ if parsed.document is not None
275
+ else ()
276
+ )
277
+ anchors = (
278
+ tuple(
279
+ diff_index.validate(
280
+ DiffAnchor(
281
+ path=finding.path,
282
+ line=finding.line,
283
+ start_line=finding.start_line,
284
+ side=Side.BASE if finding.side == "LEFT" else Side.HEAD,
285
+ base_sha=diff_index.base_sha,
286
+ head_sha=diff_index.head_sha,
287
+ )
288
+ )
289
+ for finding in parsed.document.findings
290
+ )
291
+ if parsed.document is not None and diff_index is not None
292
+ else None
293
+ )
294
+ result = ReviewResultRecord(
295
+ output.exit_code,
296
+ output.stdout,
297
+ output.stderr,
298
+ parsed.draft.model_dump_json()
299
+ if parsed.draft is not None
300
+ else parsed.document.model_dump_json()
301
+ if parsed.document is not None
302
+ else None,
303
+ parsed.tier,
304
+ parsed.error,
305
+ parsed.notes,
306
+ selected,
307
+ min_confidence,
308
+ findings_threshold,
309
+ _ANCHORS.dump_json(anchors).decode() if anchors is not None else None,
310
+ diff_index.base_sha if diff_index is not None else None,
311
+ diff_index.head_sha if diff_index is not None else None,
312
+ )
313
+ while True:
314
+ comparison = await self._comparison(token)
315
+ try:
316
+ return AIRun.model_validate(
317
+ await self.store.finalize_review(
318
+ token,
319
+ result,
320
+ backend_session_id=parsed.output.backend_session_id,
321
+ no_findings=(
322
+ parsed.draft.no_findings
323
+ if parsed.draft is not None
324
+ else parsed.document is not None and not parsed.document.findings
325
+ ),
326
+ comparison=comparison,
327
+ ),
328
+ from_attributes=True,
329
+ )
330
+ except ComparisonChanged:
331
+ continue