tdd-cli 0.1.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.
- tdd_cli-0.1.0.dist-info/METADATA +391 -0
- tdd_cli-0.1.0.dist-info/RECORD +24 -0
- tdd_cli-0.1.0.dist-info/WHEEL +4 -0
- tdd_cli-0.1.0.dist-info/entry_points.txt +2 -0
- tdd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- tddcli/__init__.py +6 -0
- tddcli/adapters/__init__.py +48 -0
- tddcli/adapters/base.py +119 -0
- tddcli/adapters/pytest_adapter.py +179 -0
- tddcli/adapters/vitest_adapter.py +170 -0
- tddcli/advance.py +423 -0
- tddcli/cli.py +1043 -0
- tddcli/config.py +255 -0
- tddcli/contract.py +237 -0
- tddcli/envelope.py +96 -0
- tddcli/fleet.py +128 -0
- tddcli/gitutil.py +138 -0
- tddcli/identity.py +82 -0
- tddcli/leases.py +118 -0
- tddcli/ledger.py +433 -0
- tddcli/machine.py +390 -0
- tddcli/render.py +275 -0
- tddcli/snapshot.py +90 -0
- tddcli/staging.py +130 -0
tddcli/ledger.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""SQLite ledger. One per repository (R13.3), outside every worktree, never in the repo.
|
|
2
|
+
|
|
3
|
+
Invocations, transitions and events are append-only. Nothing here accepts a phase
|
|
4
|
+
from a caller — phases are written only by the state machine.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import socket
|
|
12
|
+
import sqlite3
|
|
13
|
+
from datetime import datetime, timedelta, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
SCHEMA_VERSION = 2
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LedgerVersionError(RuntimeError):
|
|
20
|
+
"""The ledger on disk was written by a newer tdd-cli than this one."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
#: Forward migrations, keyed by the version they upgrade *from*. Applied in order
|
|
24
|
+
#: after the idempotent SCHEMA script, which already creates missing tables and
|
|
25
|
+
#: indexes; a migration entry therefore only needs statements SCHEMA cannot express,
|
|
26
|
+
#: such as ALTER TABLE on an existing table. Every released schema version must have
|
|
27
|
+
#: an entry here (empty string when SCHEMA alone suffices), so an old ledger is
|
|
28
|
+
#: upgraded rather than silently run against a shape the code no longer expects.
|
|
29
|
+
MIGRATIONS: dict[int, str] = {
|
|
30
|
+
# v1 -> v2 added the baseline_claim table; CREATE TABLE IF NOT EXISTS covers it.
|
|
31
|
+
1: "",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
SCHEMA = """
|
|
35
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
36
|
+
|
|
37
|
+
CREATE TABLE IF NOT EXISTS baseline_claim (
|
|
38
|
+
id INTEGER PRIMARY KEY,
|
|
39
|
+
worktree_path TEXT NOT NULL UNIQUE,
|
|
40
|
+
hostname TEXT NOT NULL,
|
|
41
|
+
pid INTEGER NOT NULL,
|
|
42
|
+
projects_total INTEGER NOT NULL DEFAULT 0,
|
|
43
|
+
projects_done INTEGER NOT NULL DEFAULT 0,
|
|
44
|
+
current_project TEXT,
|
|
45
|
+
started_at TEXT NOT NULL
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
CREATE TABLE IF NOT EXISTS plan_contract (
|
|
49
|
+
id INTEGER PRIMARY KEY,
|
|
50
|
+
plan_path TEXT NOT NULL,
|
|
51
|
+
git_blob_sha TEXT,
|
|
52
|
+
git_commit TEXT,
|
|
53
|
+
status TEXT NOT NULL, -- declared | undeclared
|
|
54
|
+
declared_cycles TEXT NOT NULL, -- json
|
|
55
|
+
annotation_keys TEXT NOT NULL, -- json
|
|
56
|
+
registered_at TEXT NOT NULL
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
CREATE TABLE IF NOT EXISTS run (
|
|
60
|
+
id INTEGER PRIMARY KEY,
|
|
61
|
+
plan_contract_id INTEGER NOT NULL REFERENCES plan_contract(id),
|
|
62
|
+
executor_model TEXT NOT NULL,
|
|
63
|
+
executor_session TEXT,
|
|
64
|
+
executor_source TEXT NOT NULL, -- transcript | human | unknown
|
|
65
|
+
worktree_path TEXT NOT NULL,
|
|
66
|
+
started_at TEXT NOT NULL,
|
|
67
|
+
ended_at TEXT,
|
|
68
|
+
outcome TEXT, -- complete | blocked | abandoned
|
|
69
|
+
allow_dirty INTEGER NOT NULL DEFAULT 0,
|
|
70
|
+
preexisting_dirty TEXT NOT NULL, -- json: excluded from authorship forever (R9.21)
|
|
71
|
+
config_sha TEXT -- tdd.toml as of run start; drift is an event
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
CREATE TABLE IF NOT EXISTS baseline (
|
|
75
|
+
id INTEGER PRIMARY KEY,
|
|
76
|
+
run_id INTEGER NOT NULL REFERENCES run(id),
|
|
77
|
+
project TEXT NOT NULL,
|
|
78
|
+
failing TEXT NOT NULL, -- json
|
|
79
|
+
captured_at TEXT NOT NULL
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
CREATE TABLE IF NOT EXISTS collection_snapshot (
|
|
83
|
+
id INTEGER PRIMARY KEY,
|
|
84
|
+
run_id INTEGER NOT NULL REFERENCES run(id),
|
|
85
|
+
project TEXT NOT NULL,
|
|
86
|
+
tests TEXT NOT NULL, -- json list
|
|
87
|
+
failed_files TEXT NOT NULL, -- json map path -> error
|
|
88
|
+
captured_at TEXT NOT NULL
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
CREATE TABLE IF NOT EXISTS cycle (
|
|
92
|
+
id INTEGER PRIMARY KEY,
|
|
93
|
+
run_id INTEGER NOT NULL REFERENCES run(id),
|
|
94
|
+
ordinal INTEGER NOT NULL,
|
|
95
|
+
kind TEXT NOT NULL, -- standard | pin | contract
|
|
96
|
+
projects TEXT NOT NULL, -- json list
|
|
97
|
+
declared_tests TEXT NOT NULL, -- json list
|
|
98
|
+
target_tests TEXT NOT NULL, -- json list (adopted; may differ, R8.9)
|
|
99
|
+
phase TEXT NOT NULL,
|
|
100
|
+
head_at_open TEXT NOT NULL,
|
|
101
|
+
title TEXT,
|
|
102
|
+
opened_at TEXT NOT NULL,
|
|
103
|
+
closed_at TEXT,
|
|
104
|
+
skip_reason TEXT
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
CREATE TABLE IF NOT EXISTS invocation (
|
|
108
|
+
id INTEGER PRIMARY KEY,
|
|
109
|
+
run_id INTEGER NOT NULL REFERENCES run(id),
|
|
110
|
+
cycle_id INTEGER REFERENCES cycle(id),
|
|
111
|
+
phase_at TEXT NOT NULL,
|
|
112
|
+
project TEXT NOT NULL,
|
|
113
|
+
adapter TEXT NOT NULL,
|
|
114
|
+
target_test TEXT,
|
|
115
|
+
target_outcome TEXT,
|
|
116
|
+
target_failure TEXT,
|
|
117
|
+
total_passed INTEGER NOT NULL DEFAULT 0,
|
|
118
|
+
total_failed INTEGER NOT NULL DEFAULT 0,
|
|
119
|
+
other_failures TEXT NOT NULL, -- json, baseline-subtracted
|
|
120
|
+
duration_ms INTEGER NOT NULL DEFAULT 0,
|
|
121
|
+
retried INTEGER NOT NULL DEFAULT 0,
|
|
122
|
+
tree_hash TEXT,
|
|
123
|
+
started_at TEXT NOT NULL
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
CREATE TABLE IF NOT EXISTS gate_result (
|
|
127
|
+
id INTEGER PRIMARY KEY,
|
|
128
|
+
run_id INTEGER NOT NULL REFERENCES run(id),
|
|
129
|
+
cycle_id INTEGER REFERENCES cycle(id),
|
|
130
|
+
project TEXT NOT NULL,
|
|
131
|
+
kind TEXT NOT NULL, -- lint | typecheck
|
|
132
|
+
ok INTEGER NOT NULL,
|
|
133
|
+
output TEXT,
|
|
134
|
+
at TEXT NOT NULL
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
CREATE TABLE IF NOT EXISTS transition (
|
|
138
|
+
id INTEGER PRIMARY KEY,
|
|
139
|
+
cycle_id INTEGER NOT NULL REFERENCES cycle(id),
|
|
140
|
+
from_phase TEXT NOT NULL,
|
|
141
|
+
to_phase TEXT NOT NULL,
|
|
142
|
+
invocation_id INTEGER REFERENCES invocation(id),
|
|
143
|
+
at TEXT NOT NULL
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
CREATE TABLE IF NOT EXISTS annotation (
|
|
147
|
+
id INTEGER PRIMARY KEY,
|
|
148
|
+
run_id INTEGER NOT NULL REFERENCES run(id),
|
|
149
|
+
cycle_id INTEGER REFERENCES cycle(id),
|
|
150
|
+
key TEXT NOT NULL,
|
|
151
|
+
value TEXT NOT NULL,
|
|
152
|
+
at TEXT NOT NULL
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
CREATE TABLE IF NOT EXISTS integrity_event (
|
|
156
|
+
id INTEGER PRIMARY KEY,
|
|
157
|
+
run_id INTEGER NOT NULL REFERENCES run(id),
|
|
158
|
+
cycle_id INTEGER REFERENCES cycle(id),
|
|
159
|
+
kind TEXT NOT NULL,
|
|
160
|
+
detail TEXT,
|
|
161
|
+
at TEXT NOT NULL
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
CREATE TABLE IF NOT EXISTS blocker (
|
|
165
|
+
id INTEGER PRIMARY KEY,
|
|
166
|
+
run_id INTEGER NOT NULL REFERENCES run(id),
|
|
167
|
+
cycle_id INTEGER REFERENCES cycle(id),
|
|
168
|
+
kind TEXT NOT NULL,
|
|
169
|
+
detail TEXT,
|
|
170
|
+
at TEXT NOT NULL
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
CREATE TABLE IF NOT EXISTS sensitivity_check (
|
|
174
|
+
id INTEGER PRIMARY KEY,
|
|
175
|
+
cycle_id INTEGER NOT NULL REFERENCES cycle(id),
|
|
176
|
+
reference_diff TEXT NOT NULL,
|
|
177
|
+
reference_untracked TEXT NOT NULL,
|
|
178
|
+
mutation_diff TEXT,
|
|
179
|
+
observed_failure TEXT,
|
|
180
|
+
restored_ok INTEGER,
|
|
181
|
+
opened_at TEXT NOT NULL,
|
|
182
|
+
closed_at TEXT
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
CREATE TABLE IF NOT EXISTS commit_record (
|
|
186
|
+
id INTEGER PRIMARY KEY,
|
|
187
|
+
run_id INTEGER NOT NULL REFERENCES run(id),
|
|
188
|
+
cycle_id INTEGER REFERENCES cycle(id),
|
|
189
|
+
phase TEXT NOT NULL,
|
|
190
|
+
sha TEXT NOT NULL,
|
|
191
|
+
message TEXT NOT NULL,
|
|
192
|
+
files TEXT NOT NULL,
|
|
193
|
+
at TEXT NOT NULL
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
CREATE TABLE IF NOT EXISTS artifact_check (
|
|
197
|
+
id INTEGER PRIMARY KEY,
|
|
198
|
+
run_id INTEGER NOT NULL REFERENCES run(id),
|
|
199
|
+
cycle_id INTEGER REFERENCES cycle(id),
|
|
200
|
+
artifact TEXT NOT NULL,
|
|
201
|
+
stale INTEGER NOT NULL,
|
|
202
|
+
regenerated INTEGER NOT NULL DEFAULT 0,
|
|
203
|
+
at TEXT NOT NULL
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
CREATE TABLE IF NOT EXISTS human_intervention (
|
|
207
|
+
id INTEGER PRIMARY KEY,
|
|
208
|
+
run_id INTEGER NOT NULL REFERENCES run(id),
|
|
209
|
+
note TEXT NOT NULL,
|
|
210
|
+
at TEXT NOT NULL
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
CREATE INDEX IF NOT EXISTS idx_cycle_run ON cycle(run_id);
|
|
214
|
+
CREATE INDEX IF NOT EXISTS idx_inv_cycle ON invocation(cycle_id);
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def now() -> str:
|
|
219
|
+
return datetime.now(timezone.utc).isoformat()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def ledger_path(repo_path: Path) -> Path:
|
|
223
|
+
base = os.environ.get("TDD_LEDGER_HOME")
|
|
224
|
+
root = Path(base) if base else Path.home() / ".local" / "share" / "tdd-cli"
|
|
225
|
+
slug = str(repo_path).replace(os.sep, "-").strip("-")
|
|
226
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
return root / f"{slug}.sqlite3"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class Ledger:
|
|
231
|
+
def __init__(self, repo_path: Path):
|
|
232
|
+
self.repo_path = repo_path
|
|
233
|
+
self.path = ledger_path(repo_path)
|
|
234
|
+
# A generous busy timeout: two `run start` calls against one worktree open
|
|
235
|
+
# separate connections and both write (claim, then run/baseline rows).
|
|
236
|
+
# SQLite's default 5s timeout can be exceeded while one holds the write lock
|
|
237
|
+
# through a real baseline probe (subprocess pytest/vitest calls), surfacing
|
|
238
|
+
# as `sqlite3.OperationalError: database is locked` instead of the intended
|
|
239
|
+
# `IntegrityError` rejection path.
|
|
240
|
+
self.db = sqlite3.connect(self.path, timeout=30.0)
|
|
241
|
+
self.db.row_factory = sqlite3.Row
|
|
242
|
+
self.db.execute("PRAGMA journal_mode=WAL")
|
|
243
|
+
self.db.execute("PRAGMA foreign_keys=ON")
|
|
244
|
+
stored = self._stored_version()
|
|
245
|
+
if stored is not None and stored > SCHEMA_VERSION:
|
|
246
|
+
self.db.close()
|
|
247
|
+
raise LedgerVersionError(
|
|
248
|
+
f"ledger {self.path} has schema version {stored}, but this tdd-cli"
|
|
249
|
+
f" understands up to {SCHEMA_VERSION} — it was written by a newer"
|
|
250
|
+
" tdd-cli. Upgrade tdd-cli; do not downgrade the ledger."
|
|
251
|
+
)
|
|
252
|
+
self.db.executescript(SCHEMA)
|
|
253
|
+
while stored is not None and stored < SCHEMA_VERSION:
|
|
254
|
+
self.db.executescript(MIGRATIONS[stored])
|
|
255
|
+
stored += 1
|
|
256
|
+
self.db.execute(
|
|
257
|
+
"INSERT INTO meta(key, value) VALUES ('schema_version', ?)"
|
|
258
|
+
" ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
259
|
+
(str(SCHEMA_VERSION),),
|
|
260
|
+
)
|
|
261
|
+
self.db.commit()
|
|
262
|
+
|
|
263
|
+
def _stored_version(self) -> int | None:
|
|
264
|
+
"""The schema version already on disk, or None for a fresh database."""
|
|
265
|
+
try:
|
|
266
|
+
row = self.db.execute(
|
|
267
|
+
"SELECT value FROM meta WHERE key = 'schema_version'"
|
|
268
|
+
).fetchone()
|
|
269
|
+
except sqlite3.OperationalError: # no meta table: fresh database
|
|
270
|
+
return None
|
|
271
|
+
return int(row[0]) if row else None
|
|
272
|
+
|
|
273
|
+
# -- generic helpers -------------------------------------------------
|
|
274
|
+
|
|
275
|
+
def _write(self, sql: str, params: tuple) -> sqlite3.Cursor:
|
|
276
|
+
"""Every write goes through here, so none can strand the write lock.
|
|
277
|
+
|
|
278
|
+
Python's sqlite3 module does not roll back a failed statement, so a
|
|
279
|
+
constraint violation — the `baseline_claim.worktree_path` UNIQUE violation
|
|
280
|
+
that *is* the claim's lock, among others — leaves this connection's implicit
|
|
281
|
+
transaction open. An unrolled-back writer holds SQLite's write lock until the
|
|
282
|
+
connection is garbage collected, starving concurrent writers well past any
|
|
283
|
+
reasonable busy timeout. The claim mechanism is designed around failed writes
|
|
284
|
+
being cheap and side-effect-free, so this must hold on every path, not just
|
|
285
|
+
the one that happened to be exercised under load.
|
|
286
|
+
"""
|
|
287
|
+
try:
|
|
288
|
+
cur = self.db.execute(sql, params)
|
|
289
|
+
self.db.commit()
|
|
290
|
+
return cur
|
|
291
|
+
except Exception:
|
|
292
|
+
self.db.rollback()
|
|
293
|
+
raise
|
|
294
|
+
|
|
295
|
+
def insert(self, table: str, **cols) -> int:
|
|
296
|
+
keys = ", ".join(cols)
|
|
297
|
+
marks = ", ".join("?" for _ in cols)
|
|
298
|
+
return self._write(
|
|
299
|
+
f"INSERT INTO {table} ({keys}) VALUES ({marks})", tuple(cols.values())
|
|
300
|
+
).lastrowid
|
|
301
|
+
|
|
302
|
+
def one(self, sql: str, params: tuple = ()) -> sqlite3.Row | None:
|
|
303
|
+
return self.db.execute(sql, params).fetchone()
|
|
304
|
+
|
|
305
|
+
def all(self, sql: str, params: tuple = ()) -> list[sqlite3.Row]:
|
|
306
|
+
return self.db.execute(sql, params).fetchall()
|
|
307
|
+
|
|
308
|
+
def update(self, table: str, row_id: int, **cols) -> None:
|
|
309
|
+
sets = ", ".join(f"{k} = ?" for k in cols)
|
|
310
|
+
self._write(
|
|
311
|
+
f"UPDATE {table} SET {sets} WHERE id = ?", (*cols.values(), row_id)
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# -- domain queries --------------------------------------------------
|
|
315
|
+
|
|
316
|
+
def active_run(self, worktree: str) -> sqlite3.Row | None:
|
|
317
|
+
return self.one(
|
|
318
|
+
"SELECT * FROM run WHERE worktree_path = ? AND ended_at IS NULL"
|
|
319
|
+
" ORDER BY id DESC LIMIT 1",
|
|
320
|
+
(worktree,),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
def open_cycle(self, run_id: int) -> sqlite3.Row | None:
|
|
324
|
+
return self.one(
|
|
325
|
+
"SELECT * FROM cycle WHERE run_id = ? AND closed_at IS NULL"
|
|
326
|
+
" ORDER BY ordinal LIMIT 1",
|
|
327
|
+
(run_id,),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def cycles(self, run_id: int) -> list[sqlite3.Row]:
|
|
331
|
+
return self.all("SELECT * FROM cycle WHERE run_id = ? ORDER BY ordinal", (run_id,))
|
|
332
|
+
|
|
333
|
+
def baselines(self, run_id: int) -> dict[str, set[str]]:
|
|
334
|
+
rows = self.all("SELECT project, failing FROM baseline WHERE run_id = ?", (run_id,))
|
|
335
|
+
return {r["project"]: set(json.loads(r["failing"])) for r in rows}
|
|
336
|
+
|
|
337
|
+
def collection(self, run_id: int) -> dict[str, set[str]]:
|
|
338
|
+
rows = self.all(
|
|
339
|
+
"SELECT project, tests FROM collection_snapshot WHERE run_id = ?", (run_id,)
|
|
340
|
+
)
|
|
341
|
+
return {r["project"]: set(json.loads(r["tests"])) for r in rows}
|
|
342
|
+
|
|
343
|
+
def invocations(self, cycle_id: int, phase: str | None = None) -> list[sqlite3.Row]:
|
|
344
|
+
if phase:
|
|
345
|
+
return self.all(
|
|
346
|
+
"SELECT * FROM invocation WHERE cycle_id = ? AND phase_at = ? ORDER BY id",
|
|
347
|
+
(cycle_id, phase),
|
|
348
|
+
)
|
|
349
|
+
return self.all("SELECT * FROM invocation WHERE cycle_id = ? ORDER BY id", (cycle_id,))
|
|
350
|
+
|
|
351
|
+
def open_sensitivity(self, cycle_id: int) -> sqlite3.Row | None:
|
|
352
|
+
return self.one(
|
|
353
|
+
"SELECT * FROM sensitivity_check WHERE cycle_id = ? AND closed_at IS NULL"
|
|
354
|
+
" ORDER BY id DESC LIMIT 1",
|
|
355
|
+
(cycle_id,),
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def completed_sensitivity(self, cycle_id: int) -> sqlite3.Row | None:
|
|
359
|
+
return self.one(
|
|
360
|
+
"SELECT * FROM sensitivity_check WHERE cycle_id = ? AND restored_ok = 1"
|
|
361
|
+
" ORDER BY id DESC LIMIT 1",
|
|
362
|
+
(cycle_id,),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def event(self, run_id: int, cycle_id: int | None, kind: str, detail: str = "") -> None:
|
|
366
|
+
self.insert(
|
|
367
|
+
"integrity_event",
|
|
368
|
+
run_id=run_id,
|
|
369
|
+
cycle_id=cycle_id,
|
|
370
|
+
kind=kind,
|
|
371
|
+
detail=detail,
|
|
372
|
+
at=now(),
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# -- baseline claim ----------------------------------------------------
|
|
376
|
+
|
|
377
|
+
def claim(self, worktree: str, hostname: str, pid: int, projects_total: int) -> int:
|
|
378
|
+
"""Insert the claim row. The insert is the lock: `worktree_path`
|
|
379
|
+
carries `UNIQUE`, so a second claim on the same worktree raises
|
|
380
|
+
`sqlite3.IntegrityError` rather than racing a read-then-write check."""
|
|
381
|
+
return self.insert(
|
|
382
|
+
"baseline_claim",
|
|
383
|
+
worktree_path=worktree,
|
|
384
|
+
hostname=hostname,
|
|
385
|
+
pid=pid,
|
|
386
|
+
projects_total=projects_total,
|
|
387
|
+
projects_done=0,
|
|
388
|
+
current_project=None,
|
|
389
|
+
started_at=now(),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
def release_claim(self, worktree: str) -> None:
|
|
393
|
+
self.db.execute("DELETE FROM baseline_claim WHERE worktree_path = ?", (worktree,))
|
|
394
|
+
self.db.commit()
|
|
395
|
+
|
|
396
|
+
def update_claim(self, worktree: str, projects_done: int, current_project: str) -> None:
|
|
397
|
+
"""Counters and progress only — no per-project timing history, that lives in
|
|
398
|
+
the stderr heartbeat lines."""
|
|
399
|
+
self.db.execute(
|
|
400
|
+
"UPDATE baseline_claim SET projects_done = ?, current_project = ?"
|
|
401
|
+
" WHERE worktree_path = ?",
|
|
402
|
+
(projects_done, current_project, worktree),
|
|
403
|
+
)
|
|
404
|
+
self.db.commit()
|
|
405
|
+
|
|
406
|
+
def active_claim(self, worktree: str) -> dict | None:
|
|
407
|
+
"""Read-only, per the store's append-only contract — `cmd_progress` and
|
|
408
|
+
`cmd_status` call this as pure observers. Only `cmd_run_start` acts on the
|
|
409
|
+
computed `stale` flag (release + reclaim); nothing here deletes a row."""
|
|
410
|
+
row = self.one("SELECT * FROM baseline_claim WHERE worktree_path = ?", (worktree,))
|
|
411
|
+
if row is None:
|
|
412
|
+
return None
|
|
413
|
+
claim = dict(row)
|
|
414
|
+
if claim["hostname"] == socket.gethostname():
|
|
415
|
+
try:
|
|
416
|
+
os.kill(claim["pid"], 0)
|
|
417
|
+
stale = False
|
|
418
|
+
except ProcessLookupError:
|
|
419
|
+
# Same host, pid no longer running (e.g. a `SIGKILL`ed `run start`).
|
|
420
|
+
stale = True
|
|
421
|
+
except PermissionError:
|
|
422
|
+
# Pid exists but is owned by someone else — alive.
|
|
423
|
+
stale = False
|
|
424
|
+
else:
|
|
425
|
+
# A pid is meaningless from another host, and reused pids would make a
|
|
426
|
+
# host-crossing liveness check actively wrong. Fall back to age: a false
|
|
427
|
+
# "alive" bricks the worktree, a false "dead" reopens the bug (Decisions).
|
|
428
|
+
started = datetime.fromisoformat(claim["started_at"])
|
|
429
|
+
if started.tzinfo is None:
|
|
430
|
+
started = started.replace(tzinfo=timezone.utc)
|
|
431
|
+
stale = datetime.now(timezone.utc) - started > timedelta(minutes=60)
|
|
432
|
+
claim["stale"] = stale
|
|
433
|
+
return claim
|