@r1z4x/tezgah 1.3.0 → 1.4.0
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.
- package/CHANGELOG.md +33 -1
- package/MANIFEST +3 -0
- package/bin/tezgah-doctor +75 -18
- package/bin/tezgah-setup +6 -17
- package/bin/tezgah-taste +30 -29
- package/docs/architecture.md +4 -4
- package/docs/clarity-baseline.json +3 -3
- package/docs/contract.md +1 -1
- package/docs/environment.md +1 -0
- package/docs/evidence.md +67 -31
- package/docs/gate.md +21 -21
- package/docs/glossary.md +4 -4
- package/docs/hosts.md +8 -8
- package/docs/operations.md +48 -20
- package/docs/skills.md +1 -1
- package/docs/status-line.md +7 -7
- package/hooks/tezgah_attest.py +15 -22
- package/hooks/tezgah_context.py +44 -118
- package/hooks/tezgah_gate.py +26 -23
- package/hooks/tezgah_guard.py +9 -11
- package/hooks/tezgah_integrity.py +77 -178
- package/hooks/tezgah_judge.py +25 -43
- package/hooks/tezgah_lessons.py +18 -63
- package/hooks/tezgah_paths.py +18 -28
- package/hooks/tezgah_replay.py +31 -18
- package/hooks/tezgah_shapes.py +4 -4
- package/hooks/tezgah_skill_pick.py +6 -35
- package/hooks/tezgah_snapshot.py +25 -46
- package/hooks/tezgah_store.py +1268 -0
- package/hooks/tezgah_taste.py +20 -17
- package/hooks/tezgah_taste_ledger.py +52 -86
- package/hooks/tezgah_update.py +6 -26
- package/hosts/cursor/hook.py +19 -31
- package/hosts/opencode/plugins/tezgah.js +309 -108
- package/package.json +1 -1
package/hooks/tezgah_guard.py
CHANGED
|
@@ -135,13 +135,11 @@ def attest_session(host, session_id, cwd):
|
|
|
135
135
|
return tezgah_attest.run(host, session_id, cwd)
|
|
136
136
|
|
|
137
137
|
|
|
138
|
-
def
|
|
139
|
-
"""The mark an import failure leaves for the status line, one
|
|
140
|
-
|
|
141
|
-
`tezgah_paths.CACHE`), because the module that failed may be tezgah_paths."""
|
|
138
|
+
def import_crash_key(session_id):
|
|
139
|
+
"""The key of the mark an import failure leaves for the status line, one
|
|
140
|
+
per session: the `import_crash` store of the cache database."""
|
|
142
141
|
import hashlib
|
|
143
|
-
return
|
|
144
|
-
hashlib.sha256(str(session_id).encode()).hexdigest()[:16])
|
|
142
|
+
return hashlib.sha256(str(session_id).encode()).hexdigest()[:16]
|
|
145
143
|
|
|
146
144
|
|
|
147
145
|
def import_failed(exc, code=0):
|
|
@@ -154,7 +152,7 @@ def import_failed(exc, code=0):
|
|
|
154
152
|
the rename that crashed every omp hook). A hook fails open (`code` 0) like
|
|
155
153
|
every other caught fault, so the dead core must be visible elsewhere: one
|
|
156
154
|
stderr line always, a `crash` row when the ledger's own modules still
|
|
157
|
-
import, and a mark the status line draws (`
|
|
155
|
+
import, and a mark the status line draws (`import_crash_key`). A CLI a
|
|
158
156
|
person or a tool asks for a verdict (`tezgah-gate check`) passes a non-zero
|
|
159
157
|
`code`: an empty answer from it would read as a pass."""
|
|
160
158
|
detail = "import: %s: %s" % (type(exc).__name__, str(exc)[:120])
|
|
@@ -163,10 +161,10 @@ def import_failed(exc, code=0):
|
|
|
163
161
|
% (os.path.basename(sys.argv[0] if sys.argv else "hook"), detail))
|
|
164
162
|
session = _payload_session()
|
|
165
163
|
try:
|
|
166
|
-
mark
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
164
|
+
# the store's own import may be what failed: then no mark, as no
|
|
165
|
+
# status line that imports it can draw one either
|
|
166
|
+
import tezgah_store
|
|
167
|
+
tezgah_store.put_doc("import_crash", import_crash_key(session), detail)
|
|
170
168
|
except Exception:
|
|
171
169
|
pass
|
|
172
170
|
try:
|
|
@@ -34,11 +34,12 @@ import time
|
|
|
34
34
|
|
|
35
35
|
try:
|
|
36
36
|
import fcntl
|
|
37
|
-
except ImportError: # not POSIX:
|
|
37
|
+
except ImportError: # not POSIX: a legacy JSONL ledger was appended unlocked
|
|
38
38
|
fcntl = None
|
|
39
39
|
|
|
40
40
|
from tezgah_paths import (SWITCHES, _toplevel, cache_dir, latch, off, reply_lang,
|
|
41
41
|
root_for)
|
|
42
|
+
import tezgah_store as ts
|
|
42
43
|
|
|
43
44
|
# A command that actually checks the change, as opposed to one that merely runs.
|
|
44
45
|
# Command position, like UI_CHECK: `echo pytest` and `cat pytest.ini` were
|
|
@@ -748,15 +749,6 @@ def _stored_text(value):
|
|
|
748
749
|
return redact(str(value or ""))[:DETAIL_MAX]
|
|
749
750
|
|
|
750
751
|
|
|
751
|
-
# How long an append waits for the lock before falling back to the unlocked
|
|
752
|
-
# write it replaces. The holders are other hook processes appending one line, so
|
|
753
|
-
# the wait is normally microseconds; the bound is what keeps a stuck holder from
|
|
754
|
-
# wedging a hook, and the fallback is what keeps the row from being lost to the
|
|
755
|
-
# lock that was meant to protect it.
|
|
756
|
-
LOCK_WAIT = 1.0
|
|
757
|
-
LOCK_POLL = 0.01
|
|
758
|
-
|
|
759
|
-
|
|
760
752
|
def committed_size(handle):
|
|
761
753
|
"""The byte offset just past the last `\\n` in `handle`'s file, or 0 when the
|
|
762
754
|
file holds no newline at all.
|
|
@@ -765,8 +757,8 @@ def committed_size(handle):
|
|
|
765
757
|
record. A process killed inside a write leaves a trailing fragment that no
|
|
766
758
|
newline ever terminated, and that fragment is not a record - it is the gap
|
|
767
759
|
between what the writer meant to say and what reached the disk. The read
|
|
768
|
-
walks backwards in TAIL_CHUNK steps
|
|
769
|
-
|
|
760
|
+
walks backwards in TAIL_CHUNK steps, so its cost does not grow with a file
|
|
761
|
+
that has run for a long session.
|
|
770
762
|
|
|
771
763
|
`handle` is an open binary file object and is left positioned wherever the
|
|
772
764
|
walk stopped: the callers truncate or append next, and neither reads the
|
|
@@ -795,54 +787,35 @@ def truncate_to_committed(handle):
|
|
|
795
787
|
|
|
796
788
|
|
|
797
789
|
def _append(path, line):
|
|
798
|
-
"""Append one line
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
`_unlocked`)."""
|
|
819
|
-
handle = None
|
|
790
|
+
"""Append one row (`line`: its JSON and a newline) to the ledger at `path`.
|
|
791
|
+
|
|
792
|
+
The row goes into the cache's evidence database (`tezgah_store`) as one
|
|
793
|
+
INSERT in autocommit, so it is whole or absent: no torn tail, and two
|
|
794
|
+
writers queue on the database's lock instead of a file's. A row the
|
|
795
|
+
database cannot take (busy past its timeout during a sweep or a large
|
|
796
|
+
import, a damaged file) goes to the session's legacy JSONL file instead,
|
|
797
|
+
marked `unlocked` (`_jsonl_append`): the next read or write imports it, and
|
|
798
|
+
a lost `began` row would otherwise turn an honest pass into an orphan. Best
|
|
799
|
+
effort: a write that fails both ways is not fatal, as `note` promises."""
|
|
800
|
+
try:
|
|
801
|
+
ts.append_evidence(path, line.rstrip("\n"))
|
|
802
|
+
except ts.ERRORS:
|
|
803
|
+
_jsonl_append(path, _unlocked(line))
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _jsonl_append(path, line):
|
|
807
|
+
"""Append one line to the legacy JSONL file at `path`, without a lock (the
|
|
808
|
+
row says so: `_unlocked`), after cutting a torn tail back to the committed
|
|
809
|
+
boundary so the line is not merged into a fragment."""
|
|
820
810
|
try:
|
|
821
|
-
# owner-only: a row carries commands and paths
|
|
822
|
-
# left the dir 0755 and the file 0644 (audit SEC-05 / L-6). The modes
|
|
823
|
-
# apply when the dir and the file are created.
|
|
811
|
+
# owner-only: a row carries commands and paths (audit SEC-05 / L-6)
|
|
824
812
|
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
deadline = time.time() + LOCK_WAIT
|
|
830
|
-
while True:
|
|
831
|
-
try:
|
|
832
|
-
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
833
|
-
break
|
|
834
|
-
except OSError:
|
|
835
|
-
if time.time() >= deadline:
|
|
836
|
-
line = _unlocked(line)
|
|
837
|
-
break
|
|
838
|
-
time.sleep(LOCK_POLL)
|
|
839
|
-
truncate_to_committed(handle)
|
|
840
|
-
handle.write(line.encode("utf-8"))
|
|
813
|
+
with os.fdopen(os.open(path, os.O_RDWR | os.O_APPEND | os.O_CREAT, 0o600),
|
|
814
|
+
"a+b") as handle:
|
|
815
|
+
truncate_to_committed(handle)
|
|
816
|
+
handle.write(line.encode("utf-8"))
|
|
841
817
|
except OSError:
|
|
842
818
|
pass
|
|
843
|
-
finally:
|
|
844
|
-
if handle is not None:
|
|
845
|
-
handle.close() # flushes the line and releases the flock
|
|
846
819
|
|
|
847
820
|
|
|
848
821
|
def note_path(path, kind, detail="", **fields):
|
|
@@ -884,22 +857,17 @@ def note(session_id, kind, detail="", **fields):
|
|
|
884
857
|
|
|
885
858
|
|
|
886
859
|
def ledgers():
|
|
887
|
-
"""Every evidence ledger, newest activity first
|
|
860
|
+
"""Every evidence ledger, newest activity first, as the paths
|
|
861
|
+
`events_path` reads: the sessions of this cache's evidence database, after
|
|
862
|
+
the bulk import of the legacy files (`tezgah_store.sessions`).
|
|
888
863
|
|
|
889
864
|
The newest first is what a reader without a session id needs: the ledger a
|
|
890
865
|
refusal was just written to is the one whose activity is newest."""
|
|
891
|
-
|
|
892
|
-
def mtime(path):
|
|
893
|
-
try:
|
|
894
|
-
return os.path.getmtime(path)
|
|
895
|
-
except OSError:
|
|
896
|
-
return 0.0
|
|
897
|
-
|
|
898
866
|
d = os.path.join(cache_dir(), "evidence")
|
|
899
867
|
try:
|
|
900
|
-
return
|
|
901
|
-
|
|
902
|
-
except
|
|
868
|
+
return [os.path.join(d, session + ".jsonl")
|
|
869
|
+
for session, _ts in ts.sessions(cache_dir())]
|
|
870
|
+
except ts.ERRORS:
|
|
903
871
|
return []
|
|
904
872
|
|
|
905
873
|
|
|
@@ -908,56 +876,21 @@ def kinds(session_id):
|
|
|
908
876
|
return {str(e.get("kind")) for e in events(session_id) if e.get("kind")}
|
|
909
877
|
|
|
910
878
|
|
|
911
|
-
# How much of
|
|
912
|
-
# of lines, so a chunk size only decides how many read() calls it takes.
|
|
879
|
+
# How much of a file one backwards read pulls in (`committed_size`).
|
|
913
880
|
TAIL_CHUNK = 8192
|
|
914
881
|
|
|
915
882
|
|
|
916
|
-
def
|
|
917
|
-
"""The
|
|
883
|
+
def _ledger_lines(path, tail=None):
|
|
884
|
+
"""The ledger's rows at `path` as text lines, each ending in a newline (the
|
|
885
|
+
shape `_parse` reads), oldest first; the last `tail` with one.
|
|
918
886
|
|
|
919
|
-
|
|
920
|
-
uses, and a gate that cannot see the ledger must let the
|
|
887
|
+
A ledger that cannot be read yields nothing: this is the reader the
|
|
888
|
+
PreToolUse path uses, and a gate that cannot see the ledger must let the
|
|
889
|
+
call through."""
|
|
921
890
|
try:
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
pos = fh.tell()
|
|
925
|
-
data = b""
|
|
926
|
-
while pos > 0 and data.count(b"\n") <= n:
|
|
927
|
-
step = min(TAIL_CHUNK, pos)
|
|
928
|
-
pos -= step
|
|
929
|
-
fh.seek(pos)
|
|
930
|
-
data = fh.read(step) + data
|
|
931
|
-
except OSError:
|
|
891
|
+
return [text + "\n" for text in ts.evidence_rows(path, tail)]
|
|
892
|
+
except ts.ERRORS:
|
|
932
893
|
return []
|
|
933
|
-
# `keepends`: whether the last line was terminated is what `_parse` reads to
|
|
934
|
-
# tell a torn tail from a committed record, and splitting it away here would
|
|
935
|
-
# leave every line looking unterminated.
|
|
936
|
-
return _lines(data)[-n:]
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
def _lines(data):
|
|
940
|
-
"""A ledger's bytes as text lines, ends kept, split on b"\\n" alone.
|
|
941
|
-
|
|
942
|
-
`str.splitlines` also splits on U+2028, U+2029 and U+0085, which a JSON
|
|
943
|
-
writer may leave raw inside a string (opencode's JSON.stringify does), so an
|
|
944
|
-
honest row read as two damaged ones. A line that is not UTF-8 becomes a
|
|
945
|
-
marker no JSON reader parses, so `_parse` names it as damage; decoding the
|
|
946
|
-
whole file strictly raised instead, and the Stop rule failed open."""
|
|
947
|
-
parts = data.split(b"\n")
|
|
948
|
-
out = []
|
|
949
|
-
for i, part in enumerate(parts):
|
|
950
|
-
if i < len(parts) - 1:
|
|
951
|
-
part += b"\n"
|
|
952
|
-
elif not part:
|
|
953
|
-
break
|
|
954
|
-
try:
|
|
955
|
-
out.append(part.decode("utf-8"))
|
|
956
|
-
except UnicodeDecodeError:
|
|
957
|
-
out.append("\x00not utf-8 %s%s" % (
|
|
958
|
-
hashlib.sha1(part).hexdigest()[:12],
|
|
959
|
-
"\n" if part.endswith(b"\n") else ""))
|
|
960
|
-
return out
|
|
961
894
|
|
|
962
895
|
|
|
963
896
|
# The kind a damaged ledger line is recorded under: one row per damaged line,
|
|
@@ -976,15 +909,11 @@ _DAMAGE_SEEN = set()
|
|
|
976
909
|
|
|
977
910
|
def _note_damage(path, key, line):
|
|
978
911
|
"""Write the one `ledger_damage` row for a damaged line, unless the ledger
|
|
979
|
-
already holds it. Damage is rare, so the dedup reads the whole
|
|
912
|
+
already holds it. Damage is rare, so the dedup reads the whole ledger."""
|
|
980
913
|
if (path, key) in _DAMAGE_SEEN:
|
|
981
914
|
return
|
|
982
915
|
_DAMAGE_SEEN.add((path, key))
|
|
983
|
-
|
|
984
|
-
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
985
|
-
if any(DAMAGE_KIND in held and key in held for held in fh):
|
|
986
|
-
return
|
|
987
|
-
except OSError:
|
|
916
|
+
if any(DAMAGE_KIND in held and key in held for held in _ledger_lines(path)):
|
|
988
917
|
return
|
|
989
918
|
note_path(path, DAMAGE_KIND, cut(line.strip(), 80), key=key)
|
|
990
919
|
|
|
@@ -1045,21 +974,14 @@ def events_path(path, tail=None):
|
|
|
1045
974
|
"""Every parseable ledger entry at an explicit ledger path, oldest first.
|
|
1046
975
|
`events` is this function with the path derived from a session id. Every
|
|
1047
976
|
`verify_ok` is paired with the gate's `began` row on the way out (`_pair`)."""
|
|
1048
|
-
|
|
1049
|
-
lines = _tail_lines(path, tail)
|
|
1050
|
-
else:
|
|
1051
|
-
try:
|
|
1052
|
-
with open(path, "rb") as fh:
|
|
1053
|
-
lines = _lines(fh.read())
|
|
1054
|
-
except OSError:
|
|
1055
|
-
return []
|
|
977
|
+
lines = _ledger_lines(path, tail)
|
|
1056
978
|
return _pair(_parse(lines, path), path, lines)
|
|
1057
979
|
|
|
1058
980
|
|
|
1059
981
|
def events(session_id, tail=None):
|
|
1060
982
|
"""Every parseable ledger entry for this session, oldest first.
|
|
1061
983
|
|
|
1062
|
-
With `tail`, only the last `tail`
|
|
984
|
+
With `tail`, only the last `tail` rows are read. The ledger grows with the
|
|
1063
985
|
session and the gate reads it on every gated call, so the tail path must
|
|
1064
986
|
never parse the whole of it."""
|
|
1065
987
|
return events_path(_path(session_id), tail)
|
|
@@ -1100,11 +1022,7 @@ def turn_rows(session_id, turns=False, agent=None):
|
|
|
1100
1022
|
taint another's effects (security-09). The parent (`agent` None) keeps the
|
|
1101
1023
|
whole turn, its subagents' work included - that work is the parent's turn."""
|
|
1102
1024
|
path = _path(session_id)
|
|
1103
|
-
|
|
1104
|
-
with open(path, "rb") as fh:
|
|
1105
|
-
lines = _lines(fh.read())
|
|
1106
|
-
except OSError:
|
|
1107
|
-
return ([], 0) if turns else []
|
|
1025
|
+
lines = _ledger_lines(path)
|
|
1108
1026
|
start = _turn_line(lines)
|
|
1109
1027
|
rows = _pair(_parse(lines[start:], path), path, lines, lines[:start])
|
|
1110
1028
|
if agent:
|
|
@@ -1194,13 +1112,6 @@ def prior_calls(session_id, digest, tail=200, agent=None):
|
|
|
1194
1112
|
turn[-1].get("fail_class"))
|
|
1195
1113
|
|
|
1196
1114
|
|
|
1197
|
-
# How much of a sibling session's ledger a cross-session read parses. A write
|
|
1198
|
-
# inside the window is that ledger's newest activity by definition of "inside",
|
|
1199
|
-
# so the tail is where it is; a session whose window-write sits further back
|
|
1200
|
-
# than this many rows is missed.
|
|
1201
|
-
WRITE_TAIL = 200
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
1115
|
def _abs_target(path, cwd):
|
|
1205
1116
|
"""`path` as an absolute real path, resolved against `cwd` when relative.
|
|
1206
1117
|
|
|
@@ -1276,11 +1187,9 @@ def writers_elsewhere(path, session_id, minutes=10, cwd=None):
|
|
|
1276
1187
|
back to a reader that takes a session id (`_path` would slug it a second
|
|
1277
1188
|
time and open another file).
|
|
1278
1189
|
|
|
1279
|
-
Neutral value: [] when the
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
the other ledgers has learned nothing, and must stay silent rather than act
|
|
1283
|
-
on a guess.
|
|
1190
|
+
Neutral value: [] when the ledger cannot be read; a row that does not
|
|
1191
|
+
parse is skipped. A cross-session rule that cannot see the other ledgers has
|
|
1192
|
+
learned nothing, and must stay silent rather than act on a guess.
|
|
1284
1193
|
|
|
1285
1194
|
ponytail: a shell write counts only through the target its `run` row
|
|
1286
1195
|
carries (a redirect or `tee`, `tezgah_gate.write_paths`): a sibling that
|
|
@@ -1289,38 +1198,23 @@ def writers_elsewhere(path, session_id, minutes=10, cwd=None):
|
|
|
1289
1198
|
want = _abs_target(path, cwd)
|
|
1290
1199
|
if not want:
|
|
1291
1200
|
return []
|
|
1292
|
-
d = os.path.join(cache_dir(), "evidence")
|
|
1293
|
-
mine = _slug(session_id) + ".jsonl"
|
|
1294
1201
|
now = time.time()
|
|
1295
|
-
|
|
1296
|
-
found = []
|
|
1202
|
+
found = {}
|
|
1297
1203
|
try:
|
|
1298
|
-
|
|
1299
|
-
|
|
1204
|
+
recent = ts.recent_rows(cache_dir(), now - minutes * 60, ("edit", "run"),
|
|
1205
|
+
_slug(session_id))
|
|
1206
|
+
except ts.ERRORS:
|
|
1300
1207
|
return []
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1208
|
+
# the `ts` column, not the row's own field: a row inserted outside the
|
|
1209
|
+
# hooks need not carry one, and nothing here may raise out of the gate
|
|
1210
|
+
for stem, stamp, text in recent:
|
|
1304
1211
|
try:
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
if now - entry.stat().st_mtime > window:
|
|
1308
|
-
continue
|
|
1309
|
-
except OSError:
|
|
1212
|
+
row = json.loads(text)
|
|
1213
|
+
except ValueError:
|
|
1310
1214
|
continue
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
continue
|
|
1315
|
-
ts = row.get("ts")
|
|
1316
|
-
if not isinstance(ts, (int, float)) or now - ts > window:
|
|
1317
|
-
continue
|
|
1318
|
-
if row.get("target") != want:
|
|
1319
|
-
continue
|
|
1320
|
-
newest = ts if newest is None else max(newest, ts)
|
|
1321
|
-
if newest is not None:
|
|
1322
|
-
found.append((newest, entry.name[:-len(".jsonl")]))
|
|
1323
|
-
return [stem for _, stem in sorted(found, key=lambda pair: -pair[0])]
|
|
1215
|
+
if isinstance(row, dict) and row.get("target") == want:
|
|
1216
|
+
found[stem] = max(found.get(stem, stamp), stamp)
|
|
1217
|
+
return sorted(found, key=lambda stem: -found[stem])
|
|
1324
1218
|
|
|
1325
1219
|
|
|
1326
1220
|
def note_turn(session_id, prompt, workspace=None):
|
|
@@ -5055,10 +4949,12 @@ def changed_files_notice(session_id, base=None):
|
|
|
5055
4949
|
|
|
5056
4950
|
|
|
5057
4951
|
# --- pairing: a pass the gate never saw begin (plan 051 part 8) --------------
|
|
5058
|
-
# The field
|
|
5059
|
-
#
|
|
5060
|
-
#
|
|
5061
|
-
#
|
|
4952
|
+
# The field a JSONL append adds to a row it wrote without the lock (`_append`'s
|
|
4953
|
+
# fallback when the database refuses a row, or the old file writer's LOCK_WAIT
|
|
4954
|
+
# fallback, whose imported rows may still carry it), and the in-memory mark
|
|
4955
|
+
# `_pair` puts on a `verify_ok` no gate-written `began` row answers. The mark is
|
|
4956
|
+
# never written: `passing_check` refuses a marked row, and the Stop rule reads
|
|
4957
|
+
# one in the turn as "evidence tampered".
|
|
5062
4958
|
UNLOCKED = "unlocked"
|
|
5063
4959
|
ORPHAN = "orphan"
|
|
5064
4960
|
ORPHANED = (
|
|
@@ -5075,7 +4971,7 @@ PAIR_SEED = 200
|
|
|
5075
4971
|
|
|
5076
4972
|
|
|
5077
4973
|
def _unlocked(line):
|
|
5078
|
-
"""`line` with `unlocked: 1` added to its row (`_append`'s
|
|
4974
|
+
"""`line` with `unlocked: 1` added to its row (`_append`'s JSONL
|
|
5079
4975
|
fallback), or as it was when it is not one row."""
|
|
5080
4976
|
try:
|
|
5081
4977
|
row = json.loads(line)
|
|
@@ -5106,8 +5002,11 @@ def _pair(rows, path, lines, before=()):
|
|
|
5106
5002
|
- in a turn holding a `crash` row: the gate that writes `began` may be the
|
|
5107
5003
|
code that crashed;
|
|
5108
5004
|
- with `pretooluse-off` armed: no gate ran;
|
|
5109
|
-
- when
|
|
5110
|
-
|
|
5005
|
+
- when a row says a JSONL append wrote it without the lock (UNLOCKED:
|
|
5006
|
+
`_append`'s fallback when the database refused a row, or the old file
|
|
5007
|
+
writer's), or this platform has no flock, so a legacy file's appends
|
|
5008
|
+
were all unlocked: that append's truncate can have cut a concurrent
|
|
5009
|
+
writer's `began` row, and a row the database refused may be one;
|
|
5111
5010
|
- for a ledger in the sandbox fallback cache, or one whose session also has
|
|
5112
5011
|
a ledger in the other cache dir: a sandboxed host can split one call's two
|
|
5113
5012
|
rows over two files (`tezgah_context`'s fallback-cache note).
|
|
@@ -5185,4 +5084,4 @@ def _unpaired_exempt(path, lines):
|
|
|
5185
5084
|
fallback = os.path.realpath(os.path.join(fallback_cache(), "evidence"))
|
|
5186
5085
|
dirs = {os.path.realpath(os.path.join(CACHE, "evidence")), fallback} - {here}
|
|
5187
5086
|
return here == fallback or any(
|
|
5188
|
-
|
|
5087
|
+
ts.has_evidence(os.path.join(d, os.path.basename(path))) for d in dirs)
|
package/hooks/tezgah_judge.py
CHANGED
|
@@ -55,6 +55,7 @@ import urllib.parse
|
|
|
55
55
|
import urllib.request
|
|
56
56
|
|
|
57
57
|
import tezgah_paths as tp
|
|
58
|
+
import tezgah_store
|
|
58
59
|
|
|
59
60
|
URL = "https://api.typesafe.ai/v1/systemone"
|
|
60
61
|
KEY_FILE = "~/.config/typesafe/key"
|
|
@@ -308,8 +309,8 @@ def _ask_one(provider, secret, state, questions, model, timeout, attempts, stop)
|
|
|
308
309
|
def send():
|
|
309
310
|
left = timeout if stop is None else max(0.1, min(timeout, stop - time.monotonic()))
|
|
310
311
|
return _session_request(provider, secret, prompt, left, questions)
|
|
311
|
-
|
|
312
|
-
if _down(
|
|
312
|
+
down = _down_key(provider, url, secret)
|
|
313
|
+
if _down(down):
|
|
313
314
|
return None, "marked down for %ds after a refusal" % DOWN_FOR
|
|
314
315
|
except Exception as exc:
|
|
315
316
|
return None, _why(exc)
|
|
@@ -324,7 +325,7 @@ def _ask_one(provider, secret, state, questions, model, timeout, attempts, stop)
|
|
|
324
325
|
code = getattr(exc, "code", None)
|
|
325
326
|
if code in (401, 402) or (isinstance(code, int) and code >= 500) \
|
|
326
327
|
or isinstance(exc, SessionFailed):
|
|
327
|
-
_mark_down(
|
|
328
|
+
_mark_down(down)
|
|
328
329
|
return None, _why(exc)
|
|
329
330
|
continue
|
|
330
331
|
named = result.get("model")
|
|
@@ -535,35 +536,29 @@ def cheap_default():
|
|
|
535
536
|
# How long a provider stays marked down after a 401, 402 or 5xx: five minutes,
|
|
536
537
|
# so a dead key or an empty account costs one refusal per five minutes rather
|
|
537
538
|
# than one per prompt, and a provider that comes back is asked again within the
|
|
538
|
-
# same working session. The
|
|
539
|
-
# digest of the credential, so a rotated key is asked at once
|
|
539
|
+
# same working session. The mark is keyed on the provider, the endpoint and a
|
|
540
|
+
# digest of the credential, so a rotated key is asked at once; the store keeps
|
|
541
|
+
# the time it expires.
|
|
540
542
|
DOWN_FOR = 300
|
|
541
543
|
|
|
542
544
|
|
|
543
|
-
def
|
|
545
|
+
def _down_key(provider, url, secret):
|
|
544
546
|
import hashlib
|
|
545
|
-
|
|
546
|
-
return os.path.join(tp.cache_dir(), "judge-down", digest[:16])
|
|
547
|
+
return hashlib.sha256("\0".join((provider, url, secret)).encode()).hexdigest()[:16]
|
|
547
548
|
|
|
548
549
|
|
|
549
|
-
def _down(
|
|
550
|
-
"""True while the
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
return False
|
|
550
|
+
def _down(key):
|
|
551
|
+
"""True while the mark of `key` has not expired; one expiring more than
|
|
552
|
+
`DOWN_FOR` from now (a clock step back, a copied cache) and any read error
|
|
553
|
+
are up."""
|
|
554
|
+
until = tezgah_store.down_until(key)
|
|
555
|
+
return until is not None and 0 < until - time.time() <= DOWN_FOR
|
|
556
556
|
|
|
557
557
|
|
|
558
|
-
def _mark_down(
|
|
559
|
-
"""Best effort, like every other write a hook makes: a
|
|
558
|
+
def _mark_down(key):
|
|
559
|
+
"""Best effort, like every other write a hook makes: a store that cannot be
|
|
560
560
|
written costs the next call a request, never an exception."""
|
|
561
|
-
|
|
562
|
-
os.makedirs(os.path.dirname(marker), exist_ok=True)
|
|
563
|
-
with open(marker, "w", encoding="utf-8"):
|
|
564
|
-
pass
|
|
565
|
-
except OSError:
|
|
566
|
-
pass
|
|
561
|
+
tezgah_store.set_down(key, time.time() + DOWN_FOR)
|
|
567
562
|
|
|
568
563
|
|
|
569
564
|
def _bounded(send, stop):
|
|
@@ -666,11 +661,6 @@ def _why(exc):
|
|
|
666
661
|
return ("%s: %s" % (type(exc).__name__, exc))[:200]
|
|
667
662
|
|
|
668
663
|
|
|
669
|
-
# The last judgement's record, one file: what answered, and why it was not the
|
|
670
|
-
# session's own CLI when it was not. `tezgah-status --judge` prints it.
|
|
671
|
-
LAST = "judge-last.json"
|
|
672
|
-
|
|
673
|
-
|
|
674
664
|
def _say(line):
|
|
675
665
|
try:
|
|
676
666
|
import sys
|
|
@@ -680,9 +670,11 @@ def _say(line):
|
|
|
680
670
|
|
|
681
671
|
|
|
682
672
|
def _record(provider, model, fallback=None, failed=None, refused=None):
|
|
683
|
-
"""Write the last-use record
|
|
684
|
-
|
|
685
|
-
|
|
673
|
+
"""Write the last-use record (the `judge_last` store's one document: what
|
|
674
|
+
answered, and why it was not the session's own CLI when it was not;
|
|
675
|
+
`tezgah-status --judge` prints it) and say any fallback or refusal on
|
|
676
|
+
stderr. Best effort like every hook write: a store that cannot be written
|
|
677
|
+
loses the record, never the answer."""
|
|
686
678
|
if fallback and provider:
|
|
687
679
|
_say("answered by %s/%s - %s" % (provider, model, fallback))
|
|
688
680
|
elif refused:
|
|
@@ -690,20 +682,10 @@ def _record(provider, model, fallback=None, failed=None, refused=None):
|
|
|
690
682
|
row = {"at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "provider": provider,
|
|
691
683
|
"model": model, "fallback": fallback, "failed": failed or None,
|
|
692
684
|
"refused": refused, "policy": tp.fallback_policy()}
|
|
693
|
-
|
|
694
|
-
path = os.path.join(tp.cache_dir(), LAST)
|
|
695
|
-
with open(path + ".tmp", "w", encoding="utf-8") as fh:
|
|
696
|
-
json.dump(row, fh)
|
|
697
|
-
os.replace(path + ".tmp", path)
|
|
698
|
-
except (OSError, TypeError, ValueError):
|
|
699
|
-
pass
|
|
685
|
+
tezgah_store.put_doc("judge_last", "", row)
|
|
700
686
|
|
|
701
687
|
|
|
702
688
|
def last_use():
|
|
703
689
|
"""The last judgement's record (see `_record`), or None when none was kept."""
|
|
704
|
-
|
|
705
|
-
with open(os.path.join(tp.cache_dir(), LAST), encoding="utf-8") as fh:
|
|
706
|
-
row = json.load(fh)
|
|
707
|
-
except (OSError, ValueError):
|
|
708
|
-
return None
|
|
690
|
+
row = tezgah_store.doc("judge_last", "")
|
|
709
691
|
return row if isinstance(row, dict) else None
|