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.
- diffbutler/__init__.py +1 -0
- diffbutler/__main__.py +6 -0
- diffbutler/config/__init__.py +1 -0
- diffbutler/config/default.toml +91 -0
- diffbutler/config/diagnostics.py +90 -0
- diffbutler/config/file.py +30 -0
- diffbutler/config/instance.py +55 -0
- diffbutler/config/settings.py +297 -0
- diffbutler/config/setup.py +92 -0
- diffbutler/config/transport.py +65 -0
- diffbutler/core/__init__.py +1 -0
- diffbutler/core/application.py +103 -0
- diffbutler/core/context.py +54 -0
- diffbutler/core/dashboard.py +393 -0
- diffbutler/core/doctor.py +200 -0
- diffbutler/core/lifecycle.py +70 -0
- diffbutler/core/managed.py +441 -0
- diffbutler/core/native.py +301 -0
- diffbutler/core/polling.py +118 -0
- diffbutler/core/process.py +64 -0
- diffbutler/core/repositories.py +260 -0
- diffbutler/core/reset.py +34 -0
- diffbutler/core/resources.py +50 -0
- diffbutler/core/review.py +331 -0
- diffbutler/core/setup.py +232 -0
- diffbutler/core/snapshot.py +70 -0
- diffbutler/core/sync.py +259 -0
- diffbutler/core/worker.py +205 -0
- diffbutler/core/workspaces.py +139 -0
- diffbutler/domain/__init__.py +1 -0
- diffbutler/domain/dashboard.py +63 -0
- diffbutler/domain/diff.py +285 -0
- diffbutler/domain/forge.py +123 -0
- diffbutler/domain/lifecycle.py +51 -0
- diffbutler/domain/polling.py +92 -0
- diffbutler/domain/process.py +29 -0
- diffbutler/domain/redaction.py +61 -0
- diffbutler/domain/resources.py +50 -0
- diffbutler/domain/review.py +167 -0
- diffbutler/domain/sync.py +196 -0
- diffbutler/forges/__init__.py +1 -0
- diffbutler/forges/base.py +39 -0
- diffbutler/forges/diagnostics.py +104 -0
- diffbutler/forges/github/__init__.py +1 -0
- diffbutler/forges/github/adapter.py +352 -0
- diffbutler/forges/github/auth.py +49 -0
- diffbutler/forges/github/http.py +326 -0
- diffbutler/forges/github/mapping.py +157 -0
- diffbutler/forges/github/setup.py +86 -0
- diffbutler/forges/registry.py +23 -0
- diffbutler/py.typed +0 -0
- diffbutler/reviewers/__init__.py +1 -0
- diffbutler/reviewers/base.py +101 -0
- diffbutler/reviewers/extraction.py +198 -0
- diffbutler/reviewers/native.py +238 -0
- diffbutler/reviewers/parsing.py +247 -0
- diffbutler/reviewers/presets.toml +12 -0
- diffbutler/reviewers/process.py +78 -0
- diffbutler/reviewers/prompt.py +148 -0
- diffbutler/reviewers/redaction.py +30 -0
- diffbutler/reviewers/resources.py +162 -0
- diffbutler/sources/__init__.py +1 -0
- diffbutler/sources/diagnostics.py +100 -0
- diffbutler/sources/fetch.py +285 -0
- diffbutler/sources/git.py +461 -0
- diffbutler/sources/remotes.py +82 -0
- diffbutler/store/__init__.py +1 -0
- diffbutler/store/base.py +88 -0
- diffbutler/store/dashboard.py +201 -0
- diffbutler/store/database.py +172 -0
- diffbutler/store/lifecycle.py +247 -0
- diffbutler/store/migrations/__init__.py +1 -0
- diffbutler/store/migrations/env.py +14 -0
- diffbutler/store/migrations/versions/0001_initial.py +147 -0
- diffbutler/store/migrations/versions/0002_monitoring.py +38 -0
- diffbutler/store/migrations/versions/0003_reviews.py +72 -0
- diffbutler/store/migrations/versions/0004_claims.py +22 -0
- diffbutler/store/migrations/versions/0005_results.py +27 -0
- diffbutler/store/migrations/versions/0006_anchors.py +17 -0
- diffbutler/store/migrations/versions/0007_lifecycle.py +36 -0
- diffbutler/store/migrations/versions/0008_processes.py +30 -0
- diffbutler/store/migrations/versions/0009_dashboard.py +43 -0
- diffbutler/store/migrations/versions/0010_run_reasons.py +17 -0
- diffbutler/store/migrations/versions/__init__.py +1 -0
- diffbutler/store/models.py +290 -0
- diffbutler/store/process.py +75 -0
- diffbutler/store/queue.py +289 -0
- diffbutler/store/review.py +583 -0
- diffbutler/ui/__init__.py +1 -0
- diffbutler/ui/cli/__init__.py +532 -0
- diffbutler/ui/cli/editor.py +33 -0
- diffbutler/ui/tui/__init__.py +1 -0
- diffbutler/ui/tui/_fake_agent.py +47 -0
- diffbutler/ui/tui/_foreground_exec.py +23 -0
- diffbutler/ui/tui/app.py +34 -0
- diffbutler/ui/tui/browse.py +119 -0
- diffbutler/ui/tui/browser.py +42 -0
- diffbutler/ui/tui/dashboard.py +926 -0
- diffbutler/ui/tui/foreground.py +103 -0
- diffbutler/ui/tui/handoff_probe.py +184 -0
- diffbutler-0.5.0.dist-info/METADATA +170 -0
- diffbutler-0.5.0.dist-info/RECORD +105 -0
- diffbutler-0.5.0.dist-info/WHEEL +4 -0
- diffbutler-0.5.0.dist-info/entry_points.txt +3 -0
- diffbutler-0.5.0.dist-info/licenses/LICENSE.txt +21 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Managed native execution and exact conversation handoff preparation."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Literal
|
|
11
|
+
from uuid import UUID
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict
|
|
14
|
+
|
|
15
|
+
from diffbutler.core.process import ProcessRecovery
|
|
16
|
+
from diffbutler.core.repositories import RepositoryCache
|
|
17
|
+
from diffbutler.core.resources import provision_directory, save_json
|
|
18
|
+
from diffbutler.core.workspaces import SnapshotWorkspaces, materialize, materialize_checkout
|
|
19
|
+
from diffbutler.domain.lifecycle import CleanupSession
|
|
20
|
+
from diffbutler.domain.resources import OwnedDirectory
|
|
21
|
+
from diffbutler.domain.review import RunClaim
|
|
22
|
+
from diffbutler.reviewers.base import ReviewContext, ReviewOutput
|
|
23
|
+
from diffbutler.reviewers.native import CapturedProcess, NativeError, native_preset
|
|
24
|
+
from diffbutler.reviewers.prompt import render_git_prompt
|
|
25
|
+
from diffbutler.reviewers.resources import OwnedDirectories, OwnedSessionCleaner, settled_io
|
|
26
|
+
from diffbutler.sources.git import GitSourceReader
|
|
27
|
+
from diffbutler.store.base import LifecycleStore, ProcessStore, ReviewStore
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def background_environment(agent_home: Path, preset_name: str = "codex") -> dict[str, str]:
|
|
31
|
+
# Provider selection, authentication, proxies, and user customizations belong
|
|
32
|
+
# to the native CLI. Keep its normal environment, pinning only the session home.
|
|
33
|
+
home_key = "CLAUDE_CONFIG_DIR" if preset_name == "claude" else "CODEX_HOME"
|
|
34
|
+
return {**os.environ, home_key: str(agent_home)}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def native_agent_home(root: Path, preset_name: str) -> Path:
|
|
38
|
+
"""Remember the native home so retries and handoffs find the same conversation."""
|
|
39
|
+
record = root / "native-home.json"
|
|
40
|
+
if record.is_symlink():
|
|
41
|
+
raise NativeError("Native home record was replaced by a symlink")
|
|
42
|
+
if record.exists():
|
|
43
|
+
data = json.loads(await settled_io(record.read_text))
|
|
44
|
+
home = Path(data["path"])
|
|
45
|
+
if not home.is_absolute():
|
|
46
|
+
raise NativeError("Native home must be an absolute path")
|
|
47
|
+
return home
|
|
48
|
+
legacy = root / "agent-home"
|
|
49
|
+
if legacy.is_symlink():
|
|
50
|
+
raise NativeError("Managed native directory was replaced by a symlink")
|
|
51
|
+
if legacy.is_dir():
|
|
52
|
+
# Existing conversations remain in their original private home.
|
|
53
|
+
return legacy
|
|
54
|
+
key, default = (
|
|
55
|
+
("CLAUDE_CONFIG_DIR", ".claude") if preset_name == "claude" else ("CODEX_HOME", ".codex")
|
|
56
|
+
)
|
|
57
|
+
home = Path(os.environ.get(key, str(Path.home() / default))).expanduser().absolute()
|
|
58
|
+
await save_json(record, {"path": str(home)})
|
|
59
|
+
return home
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def session_directory(
|
|
63
|
+
store: ReviewStore, directories: OwnedDirectories, session_id: UUID, preset_name: str = "codex"
|
|
64
|
+
) -> Path:
|
|
65
|
+
resources = await store.session_resources(session_id)
|
|
66
|
+
if (
|
|
67
|
+
len(resources) != 1
|
|
68
|
+
or resources[0].root_id != directories.root_id
|
|
69
|
+
or resources[0].purpose != f"{preset_name}_home"
|
|
70
|
+
):
|
|
71
|
+
raise NativeError("Managed native session workspace is unavailable")
|
|
72
|
+
path = directories.path / resources[0].name
|
|
73
|
+
if path.is_symlink() or not path.is_dir():
|
|
74
|
+
raise NativeError("Managed native session workspace is unavailable")
|
|
75
|
+
return path
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def ensure_session_directory(
|
|
79
|
+
store: ReviewStore,
|
|
80
|
+
directories: OwnedDirectories,
|
|
81
|
+
claim: RunClaim,
|
|
82
|
+
preset_name: Literal["codex", "claude"],
|
|
83
|
+
) -> Path:
|
|
84
|
+
resources = await store.session_resources(claim.run.session_id)
|
|
85
|
+
if not resources:
|
|
86
|
+
return await provision_directory(
|
|
87
|
+
store,
|
|
88
|
+
directories,
|
|
89
|
+
token=claim.token,
|
|
90
|
+
session_id=claim.run.session_id,
|
|
91
|
+
purpose="claude_home" if preset_name == "claude" else "codex_home",
|
|
92
|
+
)
|
|
93
|
+
if len(resources) == 1 and not (directories.path / resources[0].name).exists():
|
|
94
|
+
await directories.create(resources[0])
|
|
95
|
+
return await session_directory(store, directories, claim.run.session_id, preset_name)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class NativeExecution:
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
claim: RunClaim,
|
|
102
|
+
store: ReviewStore,
|
|
103
|
+
directories: OwnedDirectories,
|
|
104
|
+
sources: GitSourceReader,
|
|
105
|
+
command: tuple[str, ...],
|
|
106
|
+
recovery: ProcessRecovery,
|
|
107
|
+
processes: ProcessStore,
|
|
108
|
+
is_current: Callable[[], Awaitable[bool]],
|
|
109
|
+
preset_name: Literal["codex", "claude"] = "codex",
|
|
110
|
+
workspaces: SnapshotWorkspaces | None = None,
|
|
111
|
+
progress: Callable[[str], None] | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
self.progress = progress
|
|
114
|
+
self.preset_name: Literal["codex", "claude"] = preset_name
|
|
115
|
+
self.workspaces = workspaces or SnapshotWorkspaces(directories)
|
|
116
|
+
self.claim = claim
|
|
117
|
+
self.store = store
|
|
118
|
+
self.directories = directories
|
|
119
|
+
self.sources = sources
|
|
120
|
+
self.command = command
|
|
121
|
+
self.recovery = recovery
|
|
122
|
+
self.processes = processes
|
|
123
|
+
self.is_current = is_current
|
|
124
|
+
self.execution = CapturedProcess()
|
|
125
|
+
|
|
126
|
+
async def run(
|
|
127
|
+
self,
|
|
128
|
+
context: ReviewContext,
|
|
129
|
+
backend_session_id: str | None,
|
|
130
|
+
checkpoint: Callable[[str], Awaitable[None]],
|
|
131
|
+
) -> ReviewOutput:
|
|
132
|
+
root = await ensure_session_directory(
|
|
133
|
+
self.store, self.directories, self.claim, self.preset_name
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
agent_home = await native_agent_home(root, self.preset_name)
|
|
137
|
+
resource = (await self.store.session_resources(self.claim.run.session_id))[0]
|
|
138
|
+
snapshot_root = await self.workspaces.session_path(resource)
|
|
139
|
+
workdir = snapshot_root / "runs" / str(context.run_id)
|
|
140
|
+
await materialize_checkout(self.sources, workdir, progress=self.progress)
|
|
141
|
+
prompt = render_git_prompt(context)
|
|
142
|
+
|
|
143
|
+
await save_json(root / "current.json", {"run_id": str(context.run_id)})
|
|
144
|
+
env = background_environment(agent_home, self.preset_name)
|
|
145
|
+
preset = native_preset(self.preset_name)
|
|
146
|
+
if not await self.is_current():
|
|
147
|
+
raise NativeError("Review became stale during source preparation")
|
|
148
|
+
await self.recovery.prepare_launch(self.claim.token, "background")
|
|
149
|
+
if self.progress is not None:
|
|
150
|
+
self.progress("Running AI review…")
|
|
151
|
+
|
|
152
|
+
async def native_checkpoint(identity: str) -> None:
|
|
153
|
+
try:
|
|
154
|
+
UUID(identity)
|
|
155
|
+
except ValueError:
|
|
156
|
+
raise NativeError("Invalid native conversation identity") from None
|
|
157
|
+
await checkpoint(identity)
|
|
158
|
+
|
|
159
|
+
logging.getLogger(__name__).info("Background agent starting pr=%s", context.pr.ref.number)
|
|
160
|
+
capture = asyncio.create_task(
|
|
161
|
+
self.execution.capture(
|
|
162
|
+
preset.argv(
|
|
163
|
+
preset.command_at(self.command, workdir),
|
|
164
|
+
backend_session_id,
|
|
165
|
+
session_name=f"diffbutler-pr-{context.pr.ref.number}",
|
|
166
|
+
),
|
|
167
|
+
cwd=workdir,
|
|
168
|
+
env=env,
|
|
169
|
+
input=prompt.content,
|
|
170
|
+
checkpoint=native_checkpoint,
|
|
171
|
+
output_format=preset.output_format,
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
try:
|
|
175
|
+
# capture starts the spawn task synchronously on its first scheduling turn.
|
|
176
|
+
await asyncio.sleep(0)
|
|
177
|
+
if self.execution.spawn is not None:
|
|
178
|
+
process = await asyncio.shield(self.execution.spawn)
|
|
179
|
+
await self.processes.bind_launcher(self.claim.token, process.pid)
|
|
180
|
+
return await capture
|
|
181
|
+
finally:
|
|
182
|
+
if not capture.done():
|
|
183
|
+
capture.cancel()
|
|
184
|
+
await asyncio.gather(capture, return_exceptions=True)
|
|
185
|
+
|
|
186
|
+
async def stop_and_wait(self) -> None:
|
|
187
|
+
await self.execution.stop_and_wait()
|
|
188
|
+
|
|
189
|
+
def partial_output(self) -> ReviewOutput:
|
|
190
|
+
process = self.execution.process
|
|
191
|
+
code = process.returncode if process is not None else None
|
|
192
|
+
return ReviewOutput(
|
|
193
|
+
bytes(self.execution.stdout),
|
|
194
|
+
bytes(self.execution.stderr),
|
|
195
|
+
code if code is not None else -1,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class NativeSessionCleaner:
|
|
200
|
+
"""Remove owned workspaces and legacy homes, then release shared object pins."""
|
|
201
|
+
|
|
202
|
+
def __init__(
|
|
203
|
+
self,
|
|
204
|
+
directories: OwnedDirectories,
|
|
205
|
+
repositories: RepositoryCache | None = None,
|
|
206
|
+
workspaces: SnapshotWorkspaces | None = None,
|
|
207
|
+
) -> None:
|
|
208
|
+
self.directories = directories
|
|
209
|
+
self.repositories = repositories
|
|
210
|
+
self.workspaces = workspaces or SnapshotWorkspaces(directories)
|
|
211
|
+
|
|
212
|
+
async def cleanup(self, session: CleanupSession) -> None:
|
|
213
|
+
resources = tuple(OwnedDirectory.from_manifest(dict(item)) for item in session.resources)
|
|
214
|
+
for resource in resources:
|
|
215
|
+
if resource.session_id != session.id or resource.root_id != self.directories.root_id:
|
|
216
|
+
raise ValueError("Cleanup manifest ownership mismatch")
|
|
217
|
+
if session.transcript_ref is not None:
|
|
218
|
+
raise ValueError("External transcript deletion is unsupported")
|
|
219
|
+
if session.backend_session_id is not None and (
|
|
220
|
+
len(resources) != 1 or resources[0].purpose not in {"codex_home", "claude_home"}
|
|
221
|
+
):
|
|
222
|
+
raise NativeError("Native cleanup requires an owned native home manifest")
|
|
223
|
+
# Only registered owned directories are deleted. The recorded user home
|
|
224
|
+
# and its native conversation history are never cleanup targets.
|
|
225
|
+
for resource in resources:
|
|
226
|
+
await self.workspaces.cleanup(resource)
|
|
227
|
+
await OwnedSessionCleaner(self.directories, {}).cleanup(
|
|
228
|
+
replace(session, backend_session_id=None)
|
|
229
|
+
)
|
|
230
|
+
if self.repositories is not None:
|
|
231
|
+
await self.repositories.release(session.id)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class SessionLaunch(BaseModel):
|
|
235
|
+
model_config = ConfigDict(frozen=True)
|
|
236
|
+
token: UUID
|
|
237
|
+
session_id: UUID
|
|
238
|
+
argv: tuple[str, ...]
|
|
239
|
+
cwd: Path
|
|
240
|
+
agent_home: Path
|
|
241
|
+
preset_name: Literal["codex", "claude"] = "codex"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
async def prepare_native_handoff(
|
|
245
|
+
store: ReviewStore,
|
|
246
|
+
lifecycle: LifecycleStore,
|
|
247
|
+
directories: OwnedDirectories,
|
|
248
|
+
session_id: UUID,
|
|
249
|
+
command: tuple[str, ...],
|
|
250
|
+
preset_name: Literal["codex", "claude"] = "codex",
|
|
251
|
+
*,
|
|
252
|
+
pr_number: int,
|
|
253
|
+
workspaces: SnapshotWorkspaces | None = None,
|
|
254
|
+
) -> SessionLaunch:
|
|
255
|
+
lease = await lifecycle.acquire_foreground(session_id)
|
|
256
|
+
try:
|
|
257
|
+
root = await session_directory(store, directories, session_id, preset_name)
|
|
258
|
+
run_id = UUID(json.loads((root / "current.json").read_text())["run_id"])
|
|
259
|
+
run = await store.get_run(run_id)
|
|
260
|
+
if run.session_id != session_id:
|
|
261
|
+
raise NativeError("Pinned review workspace identity mismatch")
|
|
262
|
+
resource = (await store.session_resources(session_id))[0]
|
|
263
|
+
snapshots = workspaces or SnapshotWorkspaces(directories)
|
|
264
|
+
source_record = root / f"source-{run_id}.json"
|
|
265
|
+
# Sessions created before shared storage keep their original workspace.
|
|
266
|
+
snapshot_root = await snapshots.session_path(resource) if source_record.is_file() else root
|
|
267
|
+
cwd = snapshot_root / "runs" / str(run_id)
|
|
268
|
+
if cwd.is_symlink():
|
|
269
|
+
raise NativeError("Pinned review workspace is unavailable")
|
|
270
|
+
if not cwd.exists():
|
|
271
|
+
if not source_record.is_file():
|
|
272
|
+
raise NativeError("Pinned review workspace is unavailable")
|
|
273
|
+
data = json.loads(await settled_io(source_record.read_text))
|
|
274
|
+
reader = await GitSourceReader.open(
|
|
275
|
+
Path(data["repository"]), base_sha=run.base_sha, head_sha=run.head_sha
|
|
276
|
+
)
|
|
277
|
+
prepare_sources = materialize_checkout if data.get("format") == "git" else materialize
|
|
278
|
+
await prepare_sources(reader, cwd)
|
|
279
|
+
if not cwd.is_dir():
|
|
280
|
+
raise NativeError("Pinned review workspace is unavailable")
|
|
281
|
+
preset = native_preset(preset_name)
|
|
282
|
+
return SessionLaunch(
|
|
283
|
+
token=lease.token,
|
|
284
|
+
session_id=session_id,
|
|
285
|
+
argv=preset.argv(
|
|
286
|
+
preset.command_at(command, cwd),
|
|
287
|
+
lease.backend_session_id,
|
|
288
|
+
foreground=True,
|
|
289
|
+
session_name=f"diffbutler-pr-{pr_number}",
|
|
290
|
+
),
|
|
291
|
+
cwd=cwd,
|
|
292
|
+
agent_home=await native_agent_home(root, preset_name),
|
|
293
|
+
preset_name=preset_name,
|
|
294
|
+
)
|
|
295
|
+
except BaseException:
|
|
296
|
+
await lifecycle.release_foreground(lease.token)
|
|
297
|
+
raise
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def foreground_environment(launch: SessionLaunch) -> Mapping[str, str]:
|
|
301
|
+
return background_environment(launch.agent_home, launch.preset_name)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Serial polling with account-scoped quota budgets and server cooldowns."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
|
|
8
|
+
from diffbutler.domain.polling import QuotaBudget
|
|
9
|
+
from diffbutler.forges.base import ForgeError
|
|
10
|
+
from diffbutler.forges.diagnostics import RateLimitObservations, collect_rate_limits
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Poller:
|
|
14
|
+
"""No reviewer capability; manual refreshes also update the next automatic deadline."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
refresh: Callable[[], Awaitable[None]],
|
|
19
|
+
interval: float,
|
|
20
|
+
*,
|
|
21
|
+
now: Callable[[], float] = time.time,
|
|
22
|
+
) -> None:
|
|
23
|
+
self.refresh = refresh
|
|
24
|
+
self.minimum = interval
|
|
25
|
+
self.now = now
|
|
26
|
+
self.paused = False
|
|
27
|
+
self.budgets: dict[tuple[str, str, str], QuotaBudget] = {}
|
|
28
|
+
self.cooldown_until = 0.0
|
|
29
|
+
self.started = now()
|
|
30
|
+
self.failures = 0
|
|
31
|
+
self.generation = 0
|
|
32
|
+
self.changed = asyncio.Event()
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def interval(self) -> float:
|
|
36
|
+
return max([self.minimum, *(budget.interval for budget in self.budgets.values())])
|
|
37
|
+
|
|
38
|
+
def delay(self) -> float:
|
|
39
|
+
now = self.now()
|
|
40
|
+
next_start = max(
|
|
41
|
+
[
|
|
42
|
+
self.started + self.minimum,
|
|
43
|
+
*(budget.next_start(self.started, now) for budget in self.budgets.values()),
|
|
44
|
+
]
|
|
45
|
+
)
|
|
46
|
+
if self.failures:
|
|
47
|
+
next_start = max(next_start, self.started + self.minimum * 2**self.failures)
|
|
48
|
+
return max(0, next_start - now, self.cooldown_until - now)
|
|
49
|
+
|
|
50
|
+
async def refresh_observed(
|
|
51
|
+
self, operation: Callable[[], Awaitable[None]]
|
|
52
|
+
) -> RateLimitObservations:
|
|
53
|
+
"""Observe even failed/partial syncs, including errors the sync service catches."""
|
|
54
|
+
if self.cooldown_until > self.now():
|
|
55
|
+
raise ForgeError(
|
|
56
|
+
"GitHub rate limit cooldown is active",
|
|
57
|
+
retry_after=self.cooldown_until - self.now(),
|
|
58
|
+
)
|
|
59
|
+
self.started = self.now()
|
|
60
|
+
self.generation += 1
|
|
61
|
+
successful = False
|
|
62
|
+
with collect_rate_limits() as observations:
|
|
63
|
+
try:
|
|
64
|
+
await operation()
|
|
65
|
+
successful = True
|
|
66
|
+
self.failures = 0
|
|
67
|
+
except Exception:
|
|
68
|
+
self.failures = min(self.failures + 1, 5)
|
|
69
|
+
raise
|
|
70
|
+
finally:
|
|
71
|
+
self.cooldown_until = max(self.cooldown_until, observations.cooldown_until)
|
|
72
|
+
for sample in observations.quotas():
|
|
73
|
+
if sample.quota.reset_at.timestamp() <= self.now():
|
|
74
|
+
continue
|
|
75
|
+
budget = self.budgets.setdefault(sample.key, QuotaBudget(self.minimum))
|
|
76
|
+
previous = (budget.interval, budget.wait_for_reset)
|
|
77
|
+
budget.update(sample, successful=successful, now=self.now())
|
|
78
|
+
if previous != (budget.interval, budget.wait_for_reset):
|
|
79
|
+
logging.getLogger(__name__).info(
|
|
80
|
+
"Polling adjusted host=%s login=%s resource=%s interval_s=%.1f "
|
|
81
|
+
"remaining=%d limit=%d reset_in_s=%.1f cost=%.1f "
|
|
82
|
+
"external_calls_per_s=%.3f wait_for_reset=%s",
|
|
83
|
+
sample.host,
|
|
84
|
+
sample.login,
|
|
85
|
+
sample.quota.resource,
|
|
86
|
+
budget.interval,
|
|
87
|
+
sample.quota.remaining,
|
|
88
|
+
sample.quota.limit,
|
|
89
|
+
sample.quota.reset_at.timestamp() - self.now(),
|
|
90
|
+
budget.cost,
|
|
91
|
+
budget.external_rate,
|
|
92
|
+
budget.wait_for_reset,
|
|
93
|
+
)
|
|
94
|
+
self.changed.set()
|
|
95
|
+
return observations
|
|
96
|
+
|
|
97
|
+
async def run(self) -> None:
|
|
98
|
+
while True:
|
|
99
|
+
if not self.paused:
|
|
100
|
+
generation = self.generation
|
|
101
|
+
started = self.now()
|
|
102
|
+
try:
|
|
103
|
+
await self.refresh()
|
|
104
|
+
self.failures = 0
|
|
105
|
+
except Exception:
|
|
106
|
+
if generation == self.generation:
|
|
107
|
+
self.failures = min(self.failures + 1, 5)
|
|
108
|
+
finally:
|
|
109
|
+
# Also support refresh callbacks that don't collect quota headers.
|
|
110
|
+
if generation == self.generation:
|
|
111
|
+
self.started = started
|
|
112
|
+
while True:
|
|
113
|
+
self.changed.clear()
|
|
114
|
+
delay = self.minimum if self.paused else self.delay()
|
|
115
|
+
try:
|
|
116
|
+
await asyncio.wait_for(self.changed.wait(), timeout=delay)
|
|
117
|
+
except TimeoutError:
|
|
118
|
+
break
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Recover ownership only when local host reboot proves prior processes ended."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from diffbutler.domain.process import HostBoot, OperationKind, ProcessLaunch
|
|
7
|
+
from diffbutler.domain.review import ReviewConflict
|
|
8
|
+
from diffbutler.store.base import LifecycleStore, ProcessStore, ReviewStore
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HostBootReader(Protocol):
|
|
12
|
+
async def current(self) -> HostBoot: ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ProcessRecovery:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
processes: ProcessStore,
|
|
19
|
+
reviews: ReviewStore,
|
|
20
|
+
lifecycle: LifecycleStore,
|
|
21
|
+
host: HostBootReader,
|
|
22
|
+
) -> None:
|
|
23
|
+
self.processes = processes
|
|
24
|
+
self.reviews = reviews
|
|
25
|
+
self.lifecycle = lifecycle
|
|
26
|
+
self.host = host
|
|
27
|
+
|
|
28
|
+
async def prepare_launch(self, token: UUID, kind: OperationKind) -> ProcessLaunch:
|
|
29
|
+
"""Commit before spawn; a prior intent must never cause a second launch."""
|
|
30
|
+
launch = ProcessLaunch(token, kind, await self.host.current())
|
|
31
|
+
await self.processes.reserve_launch(launch)
|
|
32
|
+
return launch
|
|
33
|
+
|
|
34
|
+
async def recover_rebooted(self) -> tuple[UUID, ...]:
|
|
35
|
+
"""Release old-boot local owners only; never start reviews or kill PIDs.
|
|
36
|
+
|
|
37
|
+
Same-boot crashes, unknown/migrated owners, and database moves to another
|
|
38
|
+
host remain held. Call only for local operations, not remote supervisors.
|
|
39
|
+
"""
|
|
40
|
+
operations = await self.lifecycle.held_operations()
|
|
41
|
+
if not operations:
|
|
42
|
+
return ()
|
|
43
|
+
current = await self.host.current()
|
|
44
|
+
recovered: list[UUID] = []
|
|
45
|
+
for operation in operations:
|
|
46
|
+
launch = await self.processes.get_launch(operation.token)
|
|
47
|
+
if (
|
|
48
|
+
launch is None
|
|
49
|
+
or launch.kind != operation.kind
|
|
50
|
+
or not launch.ended_by_reboot(current)
|
|
51
|
+
):
|
|
52
|
+
continue
|
|
53
|
+
try:
|
|
54
|
+
if launch.kind == "background":
|
|
55
|
+
await self.reviews.release_review(launch.token)
|
|
56
|
+
elif launch.kind == "foreground":
|
|
57
|
+
await self.lifecycle.release_foreground(launch.token)
|
|
58
|
+
else:
|
|
59
|
+
await self.lifecycle.finish_cleanup(launch.token, succeeded=False)
|
|
60
|
+
except ReviewConflict:
|
|
61
|
+
# Another recovery worker consumed this immutable token.
|
|
62
|
+
continue
|
|
63
|
+
recovered.append(launch.token)
|
|
64
|
+
return tuple(recovered)
|