@r1z4x/tezgah 1.3.0 → 1.5.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 +103 -1
- package/MANIFEST +3 -0
- package/agents/tezgah-reviewer.md +19 -10
- package/bin/tezgah-docs +3 -3
- package/bin/tezgah-doctor +75 -18
- package/bin/tezgah-route +5 -2
- package/bin/tezgah-setup +30 -18
- package/bin/tezgah-status +3 -1
- package/bin/tezgah-task +130 -37
- package/bin/tezgah-taste +74 -55
- package/bin/tezgah-triage +12 -7
- package/docs/architecture.md +4 -4
- package/docs/clarity-baseline.json +9 -9
- package/docs/contract.md +22 -12
- package/docs/environment.md +4 -1
- package/docs/evidence.md +71 -34
- package/docs/gate.md +21 -21
- package/docs/glossary.md +5 -5
- package/docs/hosts.md +8 -8
- package/docs/judge.md +93 -27
- package/docs/operations.md +69 -30
- package/docs/skills.md +2 -2
- package/docs/status-line.md +7 -7
- package/docs/vision.md +10 -5
- package/hooks/tezgah_agents.py +22 -11
- package/hooks/tezgah_attest.py +15 -22
- package/hooks/tezgah_context.py +84 -124
- 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 +270 -122
- package/hooks/tezgah_lessons.py +18 -63
- package/hooks/tezgah_paths.py +183 -55
- package/hooks/tezgah_policy.py +35 -8
- package/hooks/tezgah_replay.py +57 -33
- 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_task.py +146 -40
- package/hooks/tezgah_taste.py +21 -18
- package/hooks/tezgah_taste_ledger.py +63 -88
- package/hooks/tezgah_update.py +6 -26
- package/hosts/codex/hook.py +5 -0
- package/hosts/cursor/hook.py +19 -31
- package/hosts/opencode/plugins/tezgah.js +309 -108
- package/package.json +1 -1
- package/skills/harness/SKILL.md +4 -0
- package/skills/plan-add/SKILL.md +23 -7
- package/skills/plan-add/render_table.py +3 -2
- package/skills/plan-status/SKILL.md +12 -4
- package/skills/plan-sync/SKILL.md +19 -9
- package/skills/tezgah-contract/SKILL.md +25 -12
|
@@ -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)
|