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,201 @@
1
+ """Atomic local actions and offline history projections."""
2
+
3
+ from datetime import UTC
4
+ from uuid import UUID
5
+
6
+ from sqlalchemy import select, text
7
+
8
+ from diffbutler.domain.dashboard import CycleRecord
9
+ from diffbutler.domain.forge import Account
10
+ from diffbutler.domain.review import ReviewConflict
11
+ from diffbutler.store.database import Database
12
+ from diffbutler.store.models import (
13
+ AIRun,
14
+ CleanupJob,
15
+ ForgeAccount,
16
+ ObservedActivity,
17
+ PRMonitor,
18
+ ReviewSession,
19
+ ReviewTask,
20
+ UIPreference,
21
+ utc_now,
22
+ )
23
+ from diffbutler.store.queue import EVENT_ADAPTER, PR_ADAPTER
24
+ from diffbutler.store.review import record, session_record, transition
25
+
26
+
27
+ class SQLiteDashboardStore:
28
+ def __init__(self, database: Database) -> None:
29
+ self.database = database
30
+
31
+ async def reset_task(self, task_id: UUID) -> None:
32
+ """Forget active monitoring and invalidate work, retaining history and cleanup ownership."""
33
+ async with self.database.sessions() as db:
34
+ await db.execute(text("BEGIN IMMEDIATE"))
35
+ task = await db.get(ReviewTask, task_id)
36
+ if task is None or task.completed_at is not None:
37
+ raise ReviewConflict("Review task is missing or completed")
38
+ monitor = await db.get(PRMonitor, (task.account_id, task.pull_request_id))
39
+ if monitor is None or monitor.task_id != task.id:
40
+ raise ReviewConflict("Review task is no longer current")
41
+ # Older migrated cycles may still rely on the monitor for their snapshot.
42
+ cycles = await db.scalars(
43
+ select(ReviewTask).where(
44
+ ReviewTask.account_id == task.account_id,
45
+ ReviewTask.pull_request_id == task.pull_request_id,
46
+ )
47
+ )
48
+ for cycle in cycles:
49
+ if not cycle.snapshot_json:
50
+ cycle.snapshot_json = monitor.snapshot_json
51
+ await transition(db, task, "DONE")
52
+ task.completed_at = utc_now()
53
+ task.completion_reason = "local_reset"
54
+ task.completion_source = "local"
55
+ task.completed_head_sha = str(monitor.snapshot_json["head_sha"])
56
+ task.following = False
57
+ task.request_sources_json = []
58
+ runs = await db.scalars(
59
+ select(AIRun).where(
60
+ AIRun.review_task_id == task_id, AIRun.status.in_(("queued", "running"))
61
+ )
62
+ )
63
+ for run in runs:
64
+ run.status = "cancelled"
65
+ run.status_reason = "PR reset locally"
66
+ run.finished_at = utc_now()
67
+ # Claims remain held until their supervisors establish process exit.
68
+ db.add(CleanupJob(review_task_id=task_id))
69
+ await db.delete(monitor)
70
+ await db.commit()
71
+
72
+ async def choose_state(self, task_id: UUID, *, waiting: bool) -> None:
73
+ async with self.database.sessions() as db:
74
+ await db.execute(text("BEGIN IMMEDIATE"))
75
+ task = await db.get(ReviewTask, task_id)
76
+ if task is None or task.completed_at is not None:
77
+ raise ReviewConflict("Review task is missing or completed")
78
+ monitor = await db.get(PRMonitor, (task.account_id, task.pull_request_id))
79
+ if (
80
+ monitor is None
81
+ or monitor.task_id != task.id
82
+ or monitor.snapshot_json["state"] != "open"
83
+ ):
84
+ raise ReviewConflict("Review task is no longer current and open")
85
+ now = utc_now()
86
+ await transition(
87
+ db, task, "PENDING_AUTHOR_CHANGES" if waiting else "PENDING_HUMAN_REVIEW"
88
+ )
89
+ # Events predating this explicit choice cannot override it on a retry.
90
+ task.state_event_watermark = max(
91
+ task.state_event_watermark or "",
92
+ monitor.review_key,
93
+ now.isoformat(timespec="microseconds") + ":~",
94
+ )
95
+ if not waiting:
96
+ task.opened_at = now
97
+ monitor.notice = (
98
+ "Waiting for author chosen locally" if waiting else "Manual review chosen locally"
99
+ )
100
+ runs = await db.scalars(
101
+ select(AIRun).where(
102
+ AIRun.review_task_id == task_id, AIRun.status.in_(("queued", "running"))
103
+ )
104
+ )
105
+ for run in runs:
106
+ run.status = "cancelled"
107
+ run.finished_at = now
108
+ await db.commit()
109
+
110
+ async def refresh_pending(self, task_id: UUID) -> None:
111
+ async with self.database.sessions() as db:
112
+ await db.execute(text("BEGIN IMMEDIATE"))
113
+ task = await db.get(ReviewTask, task_id)
114
+ if task is None:
115
+ raise ReviewConflict("Task does not exist")
116
+ monitor = await db.get(PRMonitor, (task.account_id, task.pull_request_id))
117
+ identity = await db.get(ForgeAccount, task.account_id)
118
+ if monitor and identity:
119
+ monitor.refresh_pending = True
120
+ identity.version += 1
121
+ await db.commit()
122
+
123
+ async def preferences(self) -> dict[str, str]:
124
+ async with self.database.sessions() as db:
125
+ return {row.key: row.value for row in await db.scalars(select(UIPreference))}
126
+
127
+ async def set_preference(self, key: str, value: str) -> None:
128
+ async with self.database.sessions() as db:
129
+ await db.execute(text("BEGIN IMMEDIATE"))
130
+ row = await db.get(UIPreference, key)
131
+ if row is None:
132
+ db.add(UIPreference(key=key, value=value))
133
+ else:
134
+ row.value = value
135
+ await db.commit()
136
+
137
+ async def cycles(self) -> tuple[CycleRecord, ...]:
138
+ async with self.database.sessions() as db:
139
+ await db.execute(text("BEGIN"))
140
+ rows = await db.execute(
141
+ select(ReviewTask, ForgeAccount, PRMonitor)
142
+ .join(ForgeAccount, ReviewTask.account_id == ForgeAccount.account_id)
143
+ .outerjoin(
144
+ PRMonitor,
145
+ (PRMonitor.account_id == ReviewTask.account_id)
146
+ & (PRMonitor.pull_request_id == ReviewTask.pull_request_id),
147
+ )
148
+ .order_by(
149
+ ReviewTask.first_seen_at.desc(), ReviewTask.cycle_number.desc(), ReviewTask.id
150
+ )
151
+ )
152
+ result: list[CycleRecord] = []
153
+ for task, identity, monitor in rows:
154
+ runs = tuple(
155
+ record(run)
156
+ for run in await db.scalars(
157
+ select(AIRun)
158
+ .where(AIRun.review_task_id == task.id)
159
+ .order_by(AIRun.requested_at, AIRun.attempt)
160
+ )
161
+ )
162
+ sessions = tuple(
163
+ session_record(row)
164
+ for row in await db.scalars(
165
+ select(ReviewSession)
166
+ .where(ReviewSession.review_task_id == task.id)
167
+ .order_by(ReviewSession.created_at)
168
+ )
169
+ )
170
+ receipts = tuple(
171
+ EVENT_ADAPTER.validate_json(row.payload_json)
172
+ for row in await db.scalars(
173
+ select(ObservedActivity)
174
+ .where(ObservedActivity.review_task_id == task.id)
175
+ .order_by(ObservedActivity.event_id)
176
+ )
177
+ )
178
+ cleanup = await db.scalar(
179
+ select(CleanupJob).where(CleanupJob.review_task_id == task.id)
180
+ )
181
+ result.append(
182
+ CycleRecord(
183
+ task.id,
184
+ Account(identity.forge, identity.login, identity.login),
185
+ PR_ADAPTER.validate_python(
186
+ task.snapshot_json or (monitor.snapshot_json if monitor else {})
187
+ ),
188
+ task.cycle_number,
189
+ task.state,
190
+ task.completed_at.replace(tzinfo=UTC) if task.completed_at else None,
191
+ task.completion_reason,
192
+ task.completed_head_sha,
193
+ cleanup.status if cleanup else "not_requested",
194
+ cleanup.last_error if cleanup else None,
195
+ task.opened_at.replace(tzinfo=UTC) if task.opened_at else None,
196
+ runs,
197
+ sessions,
198
+ receipts,
199
+ )
200
+ )
201
+ return tuple(result)
@@ -0,0 +1,172 @@
1
+ """Async SQLite startup and packaged migrations, with consistent pre-upgrade backups."""
2
+
3
+ import asyncio
4
+ import sqlite3
5
+ from contextlib import closing
6
+ from dataclasses import dataclass
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+ from uuid import uuid4
10
+
11
+ import aiosqlite
12
+ from alembic import command
13
+ from alembic.config import Config
14
+ from alembic.migration import MigrationContext
15
+ from alembic.script import ScriptDirectory
16
+ from sqlalchemy import Connection, event, inspect
17
+ from sqlalchemy.engine import URL, AdaptedConnection, make_url
18
+ from sqlalchemy.exc import ArgumentError
19
+ from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
20
+
21
+
22
+ class DatabaseError(ValueError):
23
+ """A safe diagnostic that does not expose connection credentials."""
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class DatabaseStatus:
28
+ revision: str
29
+ migrated: bool
30
+ backup: Path | None
31
+
32
+
33
+ def migration_config(connection: Connection | None = None) -> Config:
34
+ config = Config()
35
+ config.set_main_option("script_location", str(Path(__file__).parent / "migrations"))
36
+ if connection is not None:
37
+ config.attributes["connection"] = connection
38
+ return config
39
+
40
+
41
+ def sqlite_url(value: str) -> URL:
42
+ try:
43
+ url = make_url(value)
44
+ except ArgumentError, ValueError:
45
+ raise DatabaseError("db.url: invalid SQLAlchemy URL") from None
46
+ if url.drivername != "sqlite+aiosqlite":
47
+ raise DatabaseError(
48
+ "db.url: Only sqlite+aiosqlite is supported; PostgreSQL/MySQL are not implemented"
49
+ )
50
+ if url.username or url.password or url.host or url.port or url.query:
51
+ raise DatabaseError(
52
+ "db.url: SQLite requires a local file path without credentials or query options"
53
+ )
54
+ if not url.database or url.database == ":memory:":
55
+ raise DatabaseError("db.url: use a persistent SQLite file")
56
+ return url.set(database=str(Path(url.database).expanduser().absolute()))
57
+
58
+
59
+ async def configure_sqlite(connection: aiosqlite.Connection) -> None:
60
+ """Enable WAL without blocking the event loop during startup contention."""
61
+ try:
62
+ async with connection.cursor() as cursor:
63
+ await cursor.execute("PRAGMA busy_timeout=5000")
64
+ await cursor.execute("PRAGMA foreign_keys=ON")
65
+ deadline = asyncio.get_running_loop().time() + 5
66
+ while True:
67
+ try:
68
+ await cursor.execute("PRAGMA journal_mode=WAL")
69
+ break
70
+ except sqlite3.OperationalError as exc:
71
+ # SQLite may return BUSY immediately on a journal-mode lock
72
+ # upgrade, even with busy_timeout set. Retry after yielding.
73
+ if (
74
+ getattr(exc, "sqlite_errorcode", None) != sqlite3.SQLITE_BUSY
75
+ or asyncio.get_running_loop().time() >= deadline
76
+ ):
77
+ raise
78
+ await asyncio.sleep(0.05)
79
+ except BaseException:
80
+ # Connect-event failures can occur before SQLAlchemy owns a pool record.
81
+ await connection.close()
82
+ raise
83
+
84
+
85
+ def _configure_sqlite(connection: AdaptedConnection, _record: object) -> None:
86
+ connection.run_async(configure_sqlite)
87
+
88
+
89
+ def backup_sqlite(path: Path) -> Path:
90
+ """Save a consistent snapshot beside the database before changing its schema."""
91
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
92
+ destination = path.with_name(f"{path.name}.{stamp}.{uuid4().hex}.bak")
93
+ # SQLite's backup API includes committed WAL contents, unlike copying the file.
94
+ try:
95
+ with (
96
+ closing(sqlite3.connect(path)) as source,
97
+ closing(sqlite3.connect(destination)) as target,
98
+ ):
99
+ source.backup(target)
100
+ except sqlite3.Error:
101
+ destination.unlink(missing_ok=True)
102
+ raise DatabaseError(
103
+ "database backup failed; check file permissions and SQLite integrity"
104
+ ) from None
105
+ except BaseException:
106
+ destination.unlink(missing_ok=True)
107
+ raise
108
+ return destination
109
+
110
+
111
+ class Database:
112
+ """Owns the engine/session factory; callers must close it after use."""
113
+
114
+ def __init__(self, url: str) -> None:
115
+ self.url = sqlite_url(url)
116
+ assert self.url.database is not None
117
+ self.path = Path(self.url.database)
118
+ self.engine: AsyncEngine = create_async_engine(self.url)
119
+ event.listen(self.engine.sync_engine, "connect", _configure_sqlite)
120
+ self.sessions = async_sessionmaker(self.engine, expire_on_commit=False)
121
+
122
+ async def initialize(self, *, auto_migrate: bool = True) -> DatabaseStatus:
123
+ existed = self.path.exists()
124
+ if not auto_migrate and not existed:
125
+ raise DatabaseError("database is missing; enable db.auto_migrate to initialize it")
126
+ if auto_migrate:
127
+ await asyncio.to_thread(self.path.parent.mkdir, parents=True, exist_ok=True)
128
+ async with self.engine.connect() as connection:
129
+ # Serialize startup migrations across processes. No tables are changed
130
+ # until a consistent backup has completed successfully.
131
+ await connection.exec_driver_sql("BEGIN IMMEDIATE")
132
+ current = await connection.run_sync(
133
+ lambda conn: MigrationContext.configure(conn).get_current_heads()
134
+ )
135
+ scripts = ScriptDirectory.from_config(migration_config())
136
+ head = scripts.get_current_head()
137
+ assert head is not None
138
+ if current == (head,):
139
+ await connection.commit()
140
+ return DatabaseStatus(head, False, None)
141
+ known = {revision.revision for revision in scripts.walk_revisions()}
142
+ if current and (len(current) != 1 or current[0] not in known):
143
+ raise DatabaseError(
144
+ "database revision is unknown to this version; "
145
+ "use the matching DiffButler release"
146
+ )
147
+ tables = await connection.run_sync(lambda conn: inspect(conn).get_table_names())
148
+ if not current and set(tables) - {"alembic_version"}:
149
+ raise DatabaseError(
150
+ "database has unversioned tables; refusing to adopt or overwrite it"
151
+ )
152
+ if not auto_migrate:
153
+ raise DatabaseError("database migrations are not current; enable db.auto_migrate")
154
+ backup = await asyncio.to_thread(backup_sqlite, self.path) if existed else None
155
+ await connection.run_sync(lambda conn: command.upgrade(migration_config(conn), "head"))
156
+ await connection.commit()
157
+ return DatabaseStatus(head, True, backup)
158
+
159
+ async def close(self) -> None:
160
+ # Cancellation during disposal can interrupt aiosqlite.close after the
161
+ # pool has discarded the connection. Finish closing it before propagating
162
+ # cancellation, including repeated cancellation during app shutdown.
163
+ closing = asyncio.create_task(self.engine.dispose())
164
+ cancelled = False
165
+ while not closing.done():
166
+ try:
167
+ await asyncio.shield(closing)
168
+ except asyncio.CancelledError:
169
+ cancelled = True
170
+ closing.result()
171
+ if cancelled:
172
+ raise asyncio.CancelledError
@@ -0,0 +1,247 @@
1
+ """Short transactions for foreground leases and completion-triggered cleanup."""
2
+
3
+ from datetime import timedelta
4
+ from uuid import UUID
5
+
6
+ from sqlalchemy import func, select
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ from diffbutler.domain.lifecycle import (
10
+ CleanupClaim,
11
+ CleanupSession,
12
+ CleanupStatus,
13
+ ForegroundLease,
14
+ HeldOperation,
15
+ )
16
+ from diffbutler.domain.review import ReviewConflict
17
+ from diffbutler.store.database import Database
18
+ from diffbutler.store.models import (
19
+ AIRun,
20
+ CleanupJob,
21
+ PRMonitor,
22
+ ReviewSession,
23
+ ReviewTask,
24
+ RunClaim,
25
+ utc_now,
26
+ )
27
+ from diffbutler.store.models import CleanupClaim as ClaimModel
28
+ from diffbutler.store.models import ForegroundLease as LeaseModel
29
+
30
+
31
+ async def _background_owned(db: AsyncSession, task_id: UUID) -> bool:
32
+ active = await db.scalar(
33
+ select(AIRun.id).where(
34
+ AIRun.review_task_id == task_id, AIRun.status.in_(("queued", "running"))
35
+ )
36
+ )
37
+ held = await db.scalar(
38
+ select(RunClaim.token).join(AIRun).where(AIRun.review_task_id == task_id)
39
+ )
40
+ return active is not None or held is not None
41
+
42
+
43
+ class SQLiteLifecycleStore:
44
+ def __init__(self, database: Database) -> None:
45
+ self.database = database
46
+
47
+ async def _begin(self, db: AsyncSession) -> None:
48
+ connection = await db.connection()
49
+ await connection.exec_driver_sql("BEGIN IMMEDIATE")
50
+
51
+ async def cleanup_status(self) -> CleanupStatus:
52
+ async with self.database.sessions() as db:
53
+ rows = await db.execute(
54
+ select(CleanupJob.status, func.count()).group_by(CleanupJob.status)
55
+ )
56
+ counts = dict(rows.all())
57
+ return CleanupStatus(
58
+ succeeded=counts.get("succeeded", 0),
59
+ pending=counts.get("pending", 0),
60
+ running=counts.get("running", 0),
61
+ failed=counts.get("failed", 0),
62
+ )
63
+
64
+ async def held_operations(self) -> tuple[HeldOperation, ...]:
65
+ """Expose durable ownership for recovery; ages are not evidence of exit."""
66
+ async with self.database.sessions() as db:
67
+ foreground = await db.scalars(select(LeaseModel).order_by(LeaseModel.acquired_at))
68
+ result = [
69
+ HeldOperation(row.token, "foreground", row.session_id, row.acquired_at)
70
+ for row in foreground
71
+ ]
72
+ cleanup = await db.scalars(select(ClaimModel).order_by(ClaimModel.acquired_at))
73
+ result.extend(
74
+ HeldOperation(row.token, "cleanup", row.job_id, row.acquired_at) for row in cleanup
75
+ )
76
+ background = await db.execute(select(RunClaim, AIRun).join(AIRun))
77
+ result.extend(
78
+ HeldOperation(claim.token, "background", run.id, run.started_at or run.requested_at)
79
+ for claim, run in background
80
+ )
81
+ return tuple(result)
82
+
83
+ async def acquire_foreground(self, session_id: UUID) -> ForegroundLease:
84
+ async with self.database.sessions() as db:
85
+ await self._begin(db)
86
+ session = await db.get(ReviewSession, session_id)
87
+ if (
88
+ session is None
89
+ or session.status == "cleaned"
90
+ or session.cleanup_status != "not_requested"
91
+ ):
92
+ raise ReviewConflict("Session is missing, cleaned, or awaiting cleanup")
93
+ task = await db.get(ReviewTask, session.review_task_id)
94
+ if task is None or task.completed_at is not None:
95
+ raise ReviewConflict("Session task is completed")
96
+ monitor = await db.get(PRMonitor, (task.account_id, task.pull_request_id))
97
+ if (
98
+ monitor is None
99
+ or monitor.task_id != task.id
100
+ or monitor.snapshot_json["state"] != "open"
101
+ ):
102
+ raise ReviewConflict("Session task is no longer current and open")
103
+ if not session.backend_session_id:
104
+ raise ReviewConflict(
105
+ "No agent conversation was created. Request another review before resuming."
106
+ )
107
+ if await _background_owned(db, task.id):
108
+ raise ReviewConflict("Background work still owns this task")
109
+ if await db.scalar(select(LeaseModel.token).where(LeaseModel.session_id == session.id)):
110
+ raise ReviewConflict("Foreground process still owns this session")
111
+ lease = LeaseModel(
112
+ session_id=session.id,
113
+ backend_session_id=session.backend_session_id,
114
+ head_sha=str(monitor.snapshot_json["head_sha"]),
115
+ task_version=task.action_version,
116
+ )
117
+ db.add(lease)
118
+ session.status = "ready"
119
+ session.resumed_at = utc_now()
120
+ await db.flush()
121
+ result = ForegroundLease(
122
+ lease.token,
123
+ session.id,
124
+ lease.backend_session_id,
125
+ lease.head_sha,
126
+ lease.task_version,
127
+ lease.acquired_at,
128
+ )
129
+ await db.commit()
130
+ return result
131
+
132
+ async def release_foreground(self, token: UUID) -> None:
133
+ """Caller has established exit (or that launch never occurred), including descendants."""
134
+ async with self.database.sessions() as db:
135
+ await self._begin(db)
136
+ lease = await db.get(LeaseModel, token)
137
+ if lease is None:
138
+ raise ReviewConflict("Foreground lease does not exist")
139
+ session = await db.get(ReviewSession, lease.session_id)
140
+ assert session is not None
141
+ session.status = "suspended"
142
+ await db.delete(lease)
143
+ await db.commit()
144
+
145
+ async def claim_cleanup(self) -> CleanupClaim | None:
146
+ """Skip busy jobs; no timeout can steal ownership from a living process."""
147
+ async with self.database.sessions() as db:
148
+ await self._begin(db)
149
+ jobs = await db.scalars(
150
+ select(CleanupJob)
151
+ .where(
152
+ CleanupJob.status.in_(("pending", "failed")),
153
+ (CleanupJob.next_retry_at.is_(None)) | (CleanupJob.next_retry_at <= utc_now()),
154
+ )
155
+ .order_by(CleanupJob.created_at)
156
+ )
157
+ for job in jobs:
158
+ task = await db.get(ReviewTask, job.review_task_id)
159
+ if task is None or task.completed_at is None:
160
+ continue
161
+ if await _background_owned(db, task.id):
162
+ continue
163
+ foreground = await db.scalar(
164
+ select(LeaseModel.token)
165
+ .join(ReviewSession)
166
+ .where(ReviewSession.review_task_id == task.id)
167
+ )
168
+ if foreground is not None:
169
+ continue
170
+ sessions = tuple(
171
+ await db.scalars(
172
+ select(ReviewSession).where(
173
+ ReviewSession.review_task_id == task.id,
174
+ ReviewSession.status != "cleaned",
175
+ )
176
+ )
177
+ )
178
+ claim = ClaimModel(job_id=job.id)
179
+ db.add(claim)
180
+ job.status = "running"
181
+ job.attempts += 1
182
+ job.last_error = None
183
+ for session in sessions:
184
+ session.cleanup_status = "running"
185
+ await db.flush()
186
+ result = CleanupClaim(
187
+ claim.token,
188
+ job.id,
189
+ tuple(
190
+ CleanupSession(
191
+ session.id,
192
+ session.reviewer_id,
193
+ session.backend_session_id,
194
+ session.transcript_ref,
195
+ tuple(
196
+ tuple(sorted(item.items()))
197
+ for item in session.resource_manifest_json
198
+ ),
199
+ )
200
+ for session in sessions
201
+ ),
202
+ )
203
+ await db.commit()
204
+ return result
205
+ return None
206
+
207
+ async def finish_cleanup(self, token: UUID, *, succeeded: bool) -> None:
208
+ """Commit deletion evidence, or retain resources for an idempotent retry."""
209
+ async with self.database.sessions() as db:
210
+ await self._begin(db)
211
+ claim = await db.get(ClaimModel, token)
212
+ if claim is None:
213
+ raise ReviewConflict("Cleanup claim does not exist")
214
+ job = await db.get(CleanupJob, claim.job_id)
215
+ assert job is not None
216
+ sessions = await db.scalars(
217
+ select(ReviewSession).where(
218
+ ReviewSession.review_task_id == job.review_task_id,
219
+ ReviewSession.status != "cleaned",
220
+ )
221
+ )
222
+ for session in sessions:
223
+ session.cleanup_status = "succeeded" if succeeded else "failed"
224
+ if succeeded:
225
+ session.status = "cleaned"
226
+ session.cleaned_at = utc_now()
227
+ session.backend_session_id = None
228
+ session.transcript_ref = None
229
+ session.resource_manifest_json = []
230
+ if succeeded:
231
+ runs = await db.scalars(
232
+ select(AIRun).where(AIRun.review_task_id == job.review_task_id)
233
+ )
234
+ for run in runs:
235
+ run.stdout_blob = run.stderr_blob = None
236
+ job.status = "succeeded"
237
+ job.completed_at = utc_now()
238
+ job.next_retry_at = None
239
+ job.last_error = None
240
+ else:
241
+ job.status = "failed"
242
+ job.last_error = "Session cleanup failed; resources retained for retry"
243
+ job.next_retry_at = utc_now() + timedelta(
244
+ seconds=min(3600, 30 * 2 ** min(job.attempts, 7))
245
+ )
246
+ await db.delete(claim)
247
+ await db.commit()
@@ -0,0 +1 @@
1
+ """Packaged Alembic migrations."""
@@ -0,0 +1,14 @@
1
+ """Run migrations on the caller's connection, including async run_sync bridges."""
2
+
3
+ from alembic import context
4
+
5
+ from diffbutler.store.models import Base
6
+
7
+ context.configure(
8
+ connection=context.config.attributes["connection"],
9
+ target_metadata=Base.metadata,
10
+ render_as_batch=True,
11
+ compare_type=True,
12
+ )
13
+ with context.begin_transaction():
14
+ context.run_migrations()