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,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)