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.
- taskproof/__init__.py +23 -0
- taskproof/__main__.py +8 -0
- taskproof/adapters/__init__.py +101 -0
- taskproof/adapters/base.py +228 -0
- taskproof/adapters/claude.py +46 -0
- taskproof/adapters/codex.py +140 -0
- taskproof/adapters/custom.py +61 -0
- taskproof/adapters/gemini.py +45 -0
- taskproof/adapters/opencode.py +46 -0
- taskproof/api/__init__.py +1 -0
- taskproof/api/server.py +822 -0
- taskproof/cli.py +1400 -0
- taskproof/concurrency.py +329 -0
- taskproof/dispatch.py +2289 -0
- taskproof/errors.py +55 -0
- taskproof/ledger.py +185 -0
- taskproof/models.py +155 -0
- taskproof/registry.py +1585 -0
- taskproof/schemas/result.json +58 -0
- taskproof/sql/schema.sql +70 -0
- taskproof/storage.py +372 -0
- taskproof/verify.py +615 -0
- taskproof/web/assets/MatrixView-BM8nu9s2.css +446 -0
- taskproof/web/assets/MatrixView-BM8nu9s2.css.gz +0 -0
- taskproof/web/assets/MatrixView-BMsawnxh.js +475 -0
- taskproof/web/assets/MatrixView-BMsawnxh.js.gz +0 -0
- taskproof/web/assets/ProjectsView-BcXcx5Gw.js +302 -0
- taskproof/web/assets/ProjectsView-BcXcx5Gw.js.gz +0 -0
- taskproof/web/assets/ProjectsView-CqNFL-fi.css +23 -0
- taskproof/web/assets/SettingsView-D_-waf0s.js +585 -0
- taskproof/web/assets/SettingsView-D_-waf0s.js.gz +0 -0
- taskproof/web/assets/SettingsView-aqTS3ubB.css +19 -0
- taskproof/web/assets/TaskDrawer-6JoGTH0s.js +597 -0
- taskproof/web/assets/TaskDrawer-6JoGTH0s.js.gz +0 -0
- taskproof/web/assets/TaskDrawer-N299VKzd.css +465 -0
- taskproof/web/assets/TaskDrawer-N299VKzd.css.gz +0 -0
- taskproof/web/assets/TasksView-BWhSHCHi.js +114 -0
- taskproof/web/assets/TasksView-BWhSHCHi.js.gz +0 -0
- taskproof/web/assets/TasksView-Bn08731V.css +30 -0
- taskproof/web/assets/icon-Dw-ohsdc.png +0 -0
- taskproof/web/assets/index-B-PLdDbR.js +75421 -0
- taskproof/web/assets/index-B-PLdDbR.js.gz +0 -0
- taskproof/web/assets/index-NKj7kbYl.css +1234 -0
- taskproof/web/assets/index-NKj7kbYl.css.gz +0 -0
- taskproof/web/index.html +23 -0
- taskproof-0.3.0.dist-info/METADATA +470 -0
- taskproof-0.3.0.dist-info/RECORD +51 -0
- taskproof-0.3.0.dist-info/WHEEL +5 -0
- taskproof-0.3.0.dist-info/entry_points.txt +2 -0
- taskproof-0.3.0.dist-info/licenses/LICENSE +201 -0
- taskproof-0.3.0.dist-info/top_level.txt +1 -0
taskproof/concurrency.py
ADDED
|
@@ -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"
|