taskproof 0.3.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 (51) hide show
  1. taskproof/__init__.py +23 -0
  2. taskproof/__main__.py +8 -0
  3. taskproof/adapters/__init__.py +101 -0
  4. taskproof/adapters/base.py +228 -0
  5. taskproof/adapters/claude.py +46 -0
  6. taskproof/adapters/codex.py +140 -0
  7. taskproof/adapters/custom.py +61 -0
  8. taskproof/adapters/gemini.py +45 -0
  9. taskproof/adapters/opencode.py +46 -0
  10. taskproof/api/__init__.py +1 -0
  11. taskproof/api/server.py +822 -0
  12. taskproof/cli.py +1400 -0
  13. taskproof/concurrency.py +329 -0
  14. taskproof/dispatch.py +2289 -0
  15. taskproof/errors.py +55 -0
  16. taskproof/ledger.py +185 -0
  17. taskproof/models.py +155 -0
  18. taskproof/registry.py +1585 -0
  19. taskproof/schemas/result.json +58 -0
  20. taskproof/sql/schema.sql +70 -0
  21. taskproof/storage.py +372 -0
  22. taskproof/verify.py +615 -0
  23. taskproof/web/assets/MatrixView-BM8nu9s2.css +446 -0
  24. taskproof/web/assets/MatrixView-BM8nu9s2.css.gz +0 -0
  25. taskproof/web/assets/MatrixView-BMsawnxh.js +475 -0
  26. taskproof/web/assets/MatrixView-BMsawnxh.js.gz +0 -0
  27. taskproof/web/assets/ProjectsView-BcXcx5Gw.js +302 -0
  28. taskproof/web/assets/ProjectsView-BcXcx5Gw.js.gz +0 -0
  29. taskproof/web/assets/ProjectsView-CqNFL-fi.css +23 -0
  30. taskproof/web/assets/SettingsView-D_-waf0s.js +585 -0
  31. taskproof/web/assets/SettingsView-D_-waf0s.js.gz +0 -0
  32. taskproof/web/assets/SettingsView-aqTS3ubB.css +19 -0
  33. taskproof/web/assets/TaskDrawer-6JoGTH0s.js +597 -0
  34. taskproof/web/assets/TaskDrawer-6JoGTH0s.js.gz +0 -0
  35. taskproof/web/assets/TaskDrawer-N299VKzd.css +465 -0
  36. taskproof/web/assets/TaskDrawer-N299VKzd.css.gz +0 -0
  37. taskproof/web/assets/TasksView-BWhSHCHi.js +114 -0
  38. taskproof/web/assets/TasksView-BWhSHCHi.js.gz +0 -0
  39. taskproof/web/assets/TasksView-Bn08731V.css +30 -0
  40. taskproof/web/assets/icon-Dw-ohsdc.png +0 -0
  41. taskproof/web/assets/index-B-PLdDbR.js +75421 -0
  42. taskproof/web/assets/index-B-PLdDbR.js.gz +0 -0
  43. taskproof/web/assets/index-NKj7kbYl.css +1234 -0
  44. taskproof/web/assets/index-NKj7kbYl.css.gz +0 -0
  45. taskproof/web/index.html +23 -0
  46. taskproof-0.3.0.dist-info/METADATA +470 -0
  47. taskproof-0.3.0.dist-info/RECORD +51 -0
  48. taskproof-0.3.0.dist-info/WHEEL +5 -0
  49. taskproof-0.3.0.dist-info/entry_points.txt +2 -0
  50. taskproof-0.3.0.dist-info/licenses/LICENSE +201 -0
  51. taskproof-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,329 @@
1
+ """Concurrency guard backed by the SQLite `claims` table.
2
+
3
+ Why not lock files
4
+ ------------------
5
+ The predecessor used `<tmp>/<group>.lock`. When a process died mid-run the lock
6
+ survived and the group stalled until someone deleted it by hand. Here a claim
7
+ carries `expires_at`; an expired claim is reclaimed automatically.
8
+
9
+ Two dimensions, both enforced:
10
+ * same-group serialisation — scope `group:<name>`
11
+ * global cap — scope `global`, plus a live-claim count
12
+
13
+ A claim is only ever held by the process that created it, and only that process
14
+ may release it.
15
+
16
+ Design note
17
+ -----------
18
+ One `acquire()` writes exactly one row, scoped `group:<name>`. That single row
19
+ is *both* the group mutex and the global slot: the same-group check reads it as
20
+ `group:<name>`, while the global cap counts every live non-`global` row. The
21
+ `global` scope is reserved as a sentinel (never counted, never inserted by
22
+ `acquire`) so an operator can pin the global dimension without perturbing the
23
+ per-group accounting.
24
+ """
25
+
26
+ import os
27
+ import socket
28
+ import sqlite3
29
+ from datetime import datetime, timedelta, timezone
30
+ from typing import NamedTuple, Optional
31
+
32
+ from .errors import ConcurrencyError
33
+ from .storage import now_iso
34
+
35
+ GLOBAL_SCOPE = "global"
36
+
37
+ # A claim is live while `expires_at` is strictly in the future. `julianday()`
38
+ # parses the ISO-8601 +offset strings produced by `now_iso`/`_iso_after` and
39
+ # normalises them to UTC, so comparison never depends on the string offset.
40
+ _ACTIVE = "julianday(expires_at) > julianday('now')"
41
+ _EXPIRED = "julianday(expires_at) <= julianday('now')"
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # The effective global cap: a value *and* where it came from (card 42).
46
+ # ---------------------------------------------------------------------------
47
+ #
48
+ # The cap has three possible sources, in precedence order:
49
+ #
50
+ # 1. ``cli`` — a one-off ``run --cap N`` for this dispatch only, never
51
+ # persisted;
52
+ # 2. ``toml`` — ``[defaults] concurrency`` in the workspace registry;
53
+ # 3. ``auto`` — the machine fallback below, used when the registry sets
54
+ # nothing. It is NEVER written back (Q3): a fresh workspace
55
+ # carries no ``concurrency`` key and the number an operator
56
+ # sees comes from the hardware that actually runs the tasks.
57
+ #
58
+ # Whatever the source, every surface (doctor, ``config --show``, a refusal, the
59
+ # read-only API) shows the *value plus source*, never a bare number.
60
+
61
+ SOURCE_AUTO = "auto"
62
+ SOURCE_TOML = "toml"
63
+ SOURCE_CLI = "cli"
64
+
65
+ # The fallback is deliberately conservative. A slot is a whole extra coding
66
+ # agent, so on a box a human is also using we want a handful of lanes, not one
67
+ # per core:
68
+ #
69
+ # cap = clamp(2, cores // 4, 6) # at most one slot per four cores
70
+ # if RAM < 8 GiB: cap = 2 # a small box cannot afford cores / 4
71
+ # if the core count is unknown: cap = 3 # the historic default, not a guess
72
+ #
73
+ # The floor keeps at least two lanes; the ceiling keeps a many-core box from
74
+ # melting under a full house of agents.
75
+ _AUTO_FLOOR = 2
76
+ _AUTO_CEIL = 6
77
+ _CORES_PER_SLOT = 4
78
+ _AUTO_UNKNOWN_CORES = 3
79
+ _MIN_MEMORY_BYTES = 8 * 1024 ** 3
80
+
81
+
82
+ class CapSetting(NamedTuple):
83
+ """An effective global cap together with its provenance.
84
+
85
+ ``source`` is one of :data:`SOURCE_CLI` / :data:`SOURCE_TOML` /
86
+ :data:`SOURCE_AUTO`. ``detail`` is the short human gloss behind the source —
87
+ ``"14 核 ÷ 4"`` for auto, ``"projects.toml"`` for toml, ``"--cap 5"`` for
88
+ cli. ``as_dict`` is the wire shape the read-only API exposes (card 39
89
+ consumes it verbatim).
90
+ """
91
+
92
+ value: int
93
+ source: str
94
+ detail: str
95
+
96
+ def as_dict(self) -> dict:
97
+ return {"value": self.value, "source": self.source, "detail": self.detail}
98
+
99
+
100
+ def _physical_memory_bytes() -> Optional[int]:
101
+ """Physical RAM in bytes, or ``None`` when the platform will not say."""
102
+ try:
103
+ page_size = os.sysconf("SC_PAGE_SIZE")
104
+ pages = os.sysconf("SC_PHYS_PAGES")
105
+ except (ValueError, OSError, AttributeError):
106
+ return None
107
+ if not isinstance(page_size, int) or not isinstance(pages, int):
108
+ return None
109
+ if page_size <= 0 or pages <= 0:
110
+ return None
111
+ return page_size * pages
112
+
113
+
114
+ def _auto_cap(cores, memory_bytes):
115
+ """Pure: ``(logical cores, RAM bytes)`` -> ``(cap, detail)``.
116
+
117
+ Split out of :func:`detect` so the arithmetic — and the "small memory -> 2"
118
+ / "cores unknown -> 3" edges — can be exercised without reading the host.
119
+ """
120
+ if cores is None or cores < 1:
121
+ return _AUTO_UNKNOWN_CORES, "核数不可用"
122
+ if memory_bytes is not None and memory_bytes < _MIN_MEMORY_BYTES:
123
+ return _AUTO_FLOOR, f"内存 {memory_bytes / 1024 ** 3:.0f} GB < 8 GB"
124
+ value = min(max(cores // _CORES_PER_SLOT, _AUTO_FLOOR), _AUTO_CEIL)
125
+ return value, f"{cores} 核 ÷ {_CORES_PER_SLOT}"
126
+
127
+
128
+ def detect() -> int:
129
+ """The machine-derived fallback global cap for **this** box.
130
+
131
+ Touches the hardware and nothing else — no file, no database, no write — so
132
+ it is safe on a read-only path (``config --show``, ``doctor``, an API GET)
133
+ and deterministic for a given machine.
134
+ """
135
+ return _auto_cap(os.cpu_count(), _physical_memory_bytes())[0]
136
+
137
+
138
+ def detect_detail() -> str:
139
+ """The human gloss paired with :func:`detect` (e.g. ``"14 核 ÷ 4"``)."""
140
+ return _auto_cap(os.cpu_count(), _physical_memory_bytes())[1]
141
+
142
+
143
+ def resolve(defaults, cap=None) -> CapSetting:
144
+ """The effective global cap for one dispatch, with its source.
145
+
146
+ ``cap`` (the CLI's ``--cap``) wins over ``[defaults] concurrency`` in
147
+ ``defaults``, which wins over the machine fallback. ``defaults`` is the
148
+ parsed registry ``[defaults]`` table, or ``None``.
149
+ """
150
+ if cap is not None:
151
+ number = int(cap)
152
+ return CapSetting(number, SOURCE_CLI, f"--cap {number}")
153
+ configured = (defaults or {}).get("concurrency")
154
+ if configured is not None:
155
+ return CapSetting(int(configured), SOURCE_TOML, "projects.toml")
156
+ return CapSetting(detect(), SOURCE_AUTO, detect_detail())
157
+
158
+
159
+ def source_label(setting: "CapSetting") -> str:
160
+ """The phrase that follows ``来源:`` in a refusal (``自动探测 14 核 ÷ 4``)."""
161
+ if setting.source == SOURCE_AUTO:
162
+ return f"自动探测 {setting.detail}"
163
+ if setting.source == SOURCE_CLI:
164
+ return f"本次 {setting.detail}"
165
+ return setting.detail
166
+
167
+
168
+ def source_gloss(setting: "CapSetting") -> str:
169
+ """The phrase inside ``config --show``'s parentheses."""
170
+ if setting.source == SOURCE_AUTO:
171
+ return f"自动探测:{setting.detail}"
172
+ return setting.detail
173
+
174
+
175
+ def group_scope(group: str) -> str:
176
+ return f"group:{group}"
177
+
178
+
179
+ def _iso_after(seconds: int) -> str:
180
+ return (datetime.now(timezone.utc).astimezone() + timedelta(seconds=seconds)).isoformat(
181
+ timespec="seconds"
182
+ )
183
+
184
+
185
+ def owner_token() -> str:
186
+ """Identifies this process: `<host>:<pid>`."""
187
+ return f"{socket.gethostname()}:{os.getpid()}"
188
+
189
+
190
+ class Lease(str):
191
+ """A claimed scope, returned by :func:`acquire`.
192
+
193
+ It *is* a ``str`` (so callers may keep treating the result as a list of
194
+ scope names — ``group_scope(g) in scopes``), and it also carries the owner
195
+ ``token``. That token is the proof of ownership :func:`release` checks: a
196
+ bare ``str`` (e.g. ``group_scope("g1")`` built by hand) carries no token and
197
+ is therefore never mistaken for a claim we hold.
198
+ """
199
+
200
+ def __new__(cls, scope: str, token: str) -> "Lease":
201
+ obj = super().__new__(cls, scope)
202
+ obj.token = token
203
+ return obj
204
+
205
+
206
+ def reap_expired(conn: sqlite3.Connection) -> int:
207
+ """Delete claims past `expires_at`. Returns how many were reclaimed.
208
+
209
+ Runs before every acquire so a crashed run never blocks a group.
210
+ """
211
+ cur = conn.execute(f"DELETE FROM claims WHERE {_EXPIRED}")
212
+ return max(0, cur.rowcount)
213
+
214
+
215
+ def count_active(conn: sqlite3.Connection) -> int:
216
+ """Number of live (non-expired) claims, excluding the global sentinel."""
217
+ row = conn.execute(
218
+ f"SELECT COUNT(*) FROM claims WHERE scope != ? AND {_ACTIVE}",
219
+ (GLOBAL_SCOPE,),
220
+ ).fetchone()
221
+ return int(row[0]) if row is not None else 0
222
+
223
+
224
+ def acquire(conn: sqlite3.Connection, task_id: str, group: str, *, cap: int, ttl: int):
225
+ """Claim the group slot and a slot under the global cap.
226
+
227
+ Must be atomic: two processes racing for the last global slot — exactly one
228
+ wins. Use a single transaction (`BEGIN IMMEDIATE`) and re-check the count
229
+ inside it.
230
+
231
+ Raises ConcurrencyError (exit 75) when the group is busy or the cap is full.
232
+ Returns the list of scopes claimed, so the caller can release exactly those.
233
+ """
234
+ scope = group_scope(group)
235
+ token = owner_token()
236
+
237
+ # `BEGIN IMMEDIATE` takes the write lock up front. Two racers cannot both
238
+ # hold it: the second blocks (busy_timeout) until the first commits, then
239
+ # re-reads the *committed* count below — so exactly one of them can pass the
240
+ # cap check for the last slot. Nothing is ever queued; a loser raises.
241
+ started = not conn.in_transaction
242
+ if started:
243
+ conn.execute("BEGIN IMMEDIATE")
244
+ try:
245
+ reap_expired(conn) # a crashed run must not block the group
246
+ held = conn.execute(
247
+ "SELECT task_id FROM claims WHERE scope = ?", (scope,)
248
+ ).fetchone()
249
+ if held is not None:
250
+ raise ConcurrencyError(
251
+ describe_blocker(conn, group),
252
+ hint="another task holds this group; retry once it finishes",
253
+ )
254
+ if count_active(conn) >= cap:
255
+ raise ConcurrencyError(
256
+ describe_blocker(conn, group),
257
+ hint=f"global cap of {cap} reached; retry once a slot frees",
258
+ )
259
+ conn.execute(
260
+ "INSERT INTO claims (scope, task_id, pid, claimed_at, expires_at) "
261
+ "VALUES (?, ?, ?, ?, ?)",
262
+ (scope, task_id, os.getpid(), now_iso(), _iso_after(ttl)),
263
+ )
264
+ except BaseException:
265
+ if started:
266
+ conn.execute("ROLLBACK")
267
+ raise
268
+ if started:
269
+ conn.execute("COMMIT")
270
+ return [Lease(scope, token)]
271
+
272
+
273
+ def _iter_leases(scopes):
274
+ """Yield ``(scope, token)`` pairs from whatever the caller handed us.
275
+
276
+ Accepts a single item or an iterable; each item may be a :class:`Lease`
277
+ (carries a token), a bare ``str`` (token is ``None`` — unverifiable, so it
278
+ will never release anything), a ``(scope, token)`` pair, or a mapping.
279
+ """
280
+ if scopes is None:
281
+ return
282
+ if isinstance(scopes, (Lease, str, dict)):
283
+ items = [scopes]
284
+ else:
285
+ try:
286
+ items = list(scopes)
287
+ except TypeError:
288
+ return
289
+ for item in items:
290
+ if isinstance(item, Lease):
291
+ yield str(item), item.token
292
+ elif isinstance(item, str):
293
+ yield item, None
294
+ elif isinstance(item, dict):
295
+ yield item.get("scope"), item.get("token")
296
+ elif isinstance(item, (tuple, list)) and len(item) == 2:
297
+ yield item[0], item[1]
298
+
299
+
300
+ def release(conn: sqlite3.Connection, scopes) -> None:
301
+ """Release the given scopes. Safe to call twice; never releases another
302
+ process's claim (compare `pid`/token before deleting)."""
303
+ mine = owner_token()
304
+ pid = os.getpid()
305
+ for scope, token in _iter_leases(scopes):
306
+ if not scope or token != mine:
307
+ # No proof of ownership (or a token minted by another process):
308
+ # leave the claim alone. A hand-built bare scope never gets here.
309
+ continue
310
+ row = conn.execute("SELECT pid FROM claims WHERE scope = ?", (scope,)).fetchone()
311
+ if row is None or row[0] != pid:
312
+ continue
313
+ conn.execute("DELETE FROM claims WHERE scope = ?", (scope,))
314
+
315
+
316
+ def describe_blocker(conn: sqlite3.Connection, group: str) -> str:
317
+ """Human-readable reason a dispatch would be refused (for the error hint)."""
318
+ row = conn.execute(
319
+ "SELECT task_id, pid, claimed_at FROM claims "
320
+ f"WHERE scope = ? AND {_ACTIVE}",
321
+ (group_scope(group),),
322
+ ).fetchone()
323
+ if row is not None:
324
+ # index-based: works whether or not the connection has a Row factory.
325
+ return (
326
+ f"group '{group}' is busy: task {row[0]} "
327
+ f"(pid {row[1]}) has held it since {row[2]}"
328
+ )
329
+ return f"global cap reached: {count_active(conn)} task(s) already running"