@r1z4x/tezgah 1.6.0 → 1.7.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 +80 -1
- package/MANIFEST +5 -0
- package/README.md +6 -3
- package/bin/tezgah-docs +18 -16
- package/bin/tezgah-doctor +152 -0
- package/bin/tezgah-research +234 -10
- package/bin/tezgah-route +48 -1
- package/bin/tezgah-setup +26 -13
- package/bin/tezgah-task +589 -17
- package/bin/tezgah-taste +48 -10
- package/docs/architecture.md +7 -4
- package/docs/clarity-baseline.json +2 -2
- package/docs/contract.md +108 -24
- package/docs/evidence.md +201 -28
- package/docs/feature-audit.md +1 -1
- package/docs/gate.md +60 -22
- package/docs/glossary.md +4 -4
- package/docs/hosts.md +8 -8
- package/docs/index.json +11 -5
- package/docs/judge.md +9 -3
- package/docs/models.md +35 -2
- package/docs/operations.md +103 -3
- package/docs/research.md +346 -13
- package/docs/skills.md +1 -1
- package/docs/status-line.md +5 -5
- package/docs/testing.md +2 -2
- package/docs/vision.md +12 -4
- package/hooks/hooks.json +2 -2
- package/hooks/lang/en.json +323 -0
- package/hooks/lang/tr.json +344 -0
- package/hooks/projects-posttooluse.py +24 -5
- package/hooks/projects-pretooluse.py +9 -4
- package/hooks/projects-stop.py +28 -16
- package/hooks/tezgah_agents.py +29 -10
- package/hooks/tezgah_clarity.py +7 -3
- package/hooks/tezgah_context.py +425 -406
- package/hooks/tezgah_gate.py +583 -33
- package/hooks/tezgah_integrity.py +1480 -197
- package/hooks/tezgah_models.py +337 -15
- package/hooks/tezgah_paths.py +103 -10
- package/hooks/tezgah_policy.py +258 -126
- package/hooks/tezgah_rank.py +29 -27
- package/hooks/tezgah_replay.py +200 -30
- package/hooks/tezgah_research.py +1294 -9
- package/hooks/tezgah_stopspec.py +7 -0
- package/hooks/tezgah_store.py +74 -27
- package/hooks/tezgah_task.py +915 -6
- package/hosts/codex/hook.py +9 -10
- package/hosts/cursor/hook.py +21 -14
- package/hosts/dsh/hooks.json +2 -2
- package/hosts/omp/hook.py +44 -23
- package/hosts/omp/tezgah-hook.ts.in +93 -7
- package/hosts/opencode/plugins/tezgah.js +102 -2
- package/output-styles/tezgah.md +76 -42
- package/package.json +1 -1
- package/skills/i-have-adhd/EVALS.md +22 -2
- package/skills/i-have-adhd/SKILL.md +12 -5
- package/skills/plan-add/SKILL.md +5 -3
- package/skills/plan-sync/SKILL.md +5 -3
- package/skills/ponytail/EVALS.md +21 -2
- package/skills/ponytail/SKILL.md +4 -4
- package/skills/research/SKILL.md +89 -1
- package/skills/tezgah-contract/EVALS.md +52 -7
- package/skills/tezgah-contract/SKILL.md +151 -56
package/hooks/tezgah_task.py
CHANGED
|
@@ -42,7 +42,14 @@ when the tree was clean, or `pending <sha>` when it was not, `<sha>` being the
|
|
|
42
42
|
reader of that form). The gate refuses a write in that phase only while the
|
|
43
43
|
recorded sha is still HEAD and the tree is still dirty, so the commit its refusal
|
|
44
44
|
names clears it by itself.
|
|
45
|
+
|
|
46
|
+
The slice journal lives here too (`open_slice` ... `slice_lines`): one row per
|
|
47
|
+
delegated slice in `<main checkout>/.tezgah/slices/slices.db`, written by
|
|
48
|
+
the post-tool path on a spawn call and by `tezgah-task slice`, so a parent
|
|
49
|
+
crash, an omp `/clear`, a quota stop or a host switch loses no slice. They shell
|
|
50
|
+
out to git and read omp transcripts; the Stop rule reads `pending` (fail open).
|
|
45
51
|
"""
|
|
52
|
+
import contextlib
|
|
46
53
|
import os
|
|
47
54
|
import re
|
|
48
55
|
|
|
@@ -513,20 +520,60 @@ def match(rel, pattern):
|
|
|
513
520
|
return re.fullmatch("".join(out), rel) is not None
|
|
514
521
|
|
|
515
522
|
|
|
516
|
-
def relative(path, cwd, base):
|
|
523
|
+
def relative(path, cwd, base, worktrees=False):
|
|
517
524
|
"""The realpath of `path` (absolute or relative to cwd) as a posix path
|
|
518
525
|
relative to repo_root(cwd, base), or None when it resolves outside that root
|
|
519
|
-
(so an outside write can never be inside the allowlist).
|
|
526
|
+
(so an outside write can never be inside the allowlist).
|
|
527
|
+
|
|
528
|
+
With `worktrees`, a linked worktree of the same repository is that
|
|
529
|
+
repository, not outside it: the contract gives every writing slice its own
|
|
530
|
+
worktree, and an allowlist that could not name `hooks/a.py` there refused the
|
|
531
|
+
workflow it prescribes (three slices of plan 077 hit it). A path under such a
|
|
532
|
+
worktree answers relative to the worktree's top. Only the allowlist asks for
|
|
533
|
+
this (`hooks/tezgah_gate.py::task_reason`); the record and workspace rules
|
|
534
|
+
keep the main-root reading, so their decisions do not move. The test is
|
|
535
|
+
fork-free and only a path already outside the root pays for it; a worktree of
|
|
536
|
+
another repository is still outside."""
|
|
520
537
|
root = repo_root(cwd, base)
|
|
538
|
+
path = os.path.expanduser(path) # a host may spell it `~/...`
|
|
521
539
|
real = os.path.realpath(path if os.path.isabs(path) else os.path.join(cwd, path))
|
|
522
540
|
if real == root or not _under(real, root):
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
541
|
+
top = _sibling_worktree(real, root) if worktrees else None
|
|
542
|
+
if top is None or real == top:
|
|
543
|
+
# the root itself is not a path inside the root: an allowlist is
|
|
544
|
+
# about files under it, so this is a None like any other, and every
|
|
545
|
+
# caller fails in the refusing direction
|
|
546
|
+
return None
|
|
547
|
+
root = top
|
|
527
548
|
return real[len(root) + 1:].replace(os.sep, "/")
|
|
528
549
|
|
|
529
550
|
|
|
551
|
+
def _sibling_worktree(real, root):
|
|
552
|
+
"""The top of the linked worktree holding `real` when it is a checkout of the
|
|
553
|
+
repository `root` is a checkout of, else None.
|
|
554
|
+
|
|
555
|
+
The worktree's admin directory has to point back at it (`<admin>/gitdir` is
|
|
556
|
+
`<top>/.git`, which git always writes): a `.git` file planted anywhere that
|
|
557
|
+
names a real admin directory would otherwise make any directory a sibling."""
|
|
558
|
+
from tezgah_paths import linked_main, worktree_top
|
|
559
|
+
top = worktree_top(real)
|
|
560
|
+
if top is None:
|
|
561
|
+
return None
|
|
562
|
+
main = linked_main(root) or root
|
|
563
|
+
if linked_main(top) != os.path.realpath(main):
|
|
564
|
+
return None
|
|
565
|
+
try:
|
|
566
|
+
with open(os.path.join(top, ".git"), encoding="utf-8") as fh:
|
|
567
|
+
admin = fh.readline().strip()[len("gitdir:"):].strip()
|
|
568
|
+
admin = os.path.normpath(os.path.join(top, admin))
|
|
569
|
+
with open(os.path.join(admin, "gitdir"), encoding="utf-8") as fh:
|
|
570
|
+
back = fh.readline().strip()
|
|
571
|
+
except (OSError, UnicodeDecodeError):
|
|
572
|
+
return None
|
|
573
|
+
back = os.path.normpath(os.path.join(admin, back))
|
|
574
|
+
return top if os.path.realpath(back) == os.path.join(top, ".git") else None
|
|
575
|
+
|
|
576
|
+
|
|
530
577
|
def set_fields(path, **fields):
|
|
531
578
|
"""Rewrite these frontmatter keys in the plan file in place: replace the
|
|
532
579
|
`key: value` line when present, insert it before the closing `---` when not.
|
|
@@ -570,3 +617,865 @@ def _field_lines(key, value):
|
|
|
570
617
|
if value is None or value == "":
|
|
571
618
|
return [key + ":"]
|
|
572
619
|
return ["%s: %s" % (key, value)]
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _lines(path):
|
|
623
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
624
|
+
return fh.read().split("\n")
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _write_lines(path, lines):
|
|
628
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
629
|
+
fh.write("\n".join(lines))
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def set_box(path, line, checked):
|
|
633
|
+
"""Set the `- [ ]`/`- [x]` box on 1-based `line` (an acceptance item's
|
|
634
|
+
`line`). Raises OSError."""
|
|
635
|
+
lines = _lines(path)
|
|
636
|
+
lines[line - 1] = re.sub(r"- \[[ xX]\]", "- [x]" if checked else "- [ ]",
|
|
637
|
+
lines[line - 1], count=1)
|
|
638
|
+
_write_lines(path, lines)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def set_section(path, heading, body):
|
|
642
|
+
"""Replace the body of the last `## <heading>` section - the newest, since
|
|
643
|
+
a plan appends a dated `## State (...)` - with `body` and one blank line.
|
|
644
|
+
Returns False, writing nothing, when the plan has no such section. Raises
|
|
645
|
+
OSError."""
|
|
646
|
+
lines = _lines(path)
|
|
647
|
+
heads = [i for i, line in enumerate(lines)
|
|
648
|
+
if re.match(r"## %s(\s|$)" % re.escape(heading), line.strip())]
|
|
649
|
+
if not heads:
|
|
650
|
+
return False
|
|
651
|
+
start = heads[-1] + 1
|
|
652
|
+
end = next((i for i in range(start, len(lines))
|
|
653
|
+
if lines[i].lstrip().startswith("## ")), len(lines))
|
|
654
|
+
lines[start:end] = body.strip("\n").split("\n") + [""]
|
|
655
|
+
_write_lines(path, lines)
|
|
656
|
+
return True
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
# ---------------------------------------------------------------- slice journal
|
|
660
|
+
#
|
|
661
|
+
# One row per delegated slice in `<main checkout>/.tezgah/slices/slices.db`,
|
|
662
|
+
# so a parent crash, an omp `/clear`, a quota stop or a model or host switch
|
|
663
|
+
# loses no slice: the record names the brief, the worktree and branch it works
|
|
664
|
+
# in, and how it ended. Written by the post-tool path on a spawn call
|
|
665
|
+
# (`note_spawn`) and by `tezgah-task slice`; read by the context line
|
|
666
|
+
# (`slice_lines`), the Stop rule and `tezgah-task slice reconcile|resume|stats`.
|
|
667
|
+
# Every reader fails open: an unreadable record is skipped, never a refusal.
|
|
668
|
+
# These functions shell out to git; none of them is on the gate's write path.
|
|
669
|
+
|
|
670
|
+
SLICE_KINDS = ("agent", "job")
|
|
671
|
+
SLICE_HOSTS = ("omp", "claude", "codex", "cursor", "opencode", "dsh")
|
|
672
|
+
# `stale`: no terminal status was observed within SLICE_IDLE_SECONDS - a host
|
|
673
|
+
# that reports none (Codex `spawn_agent` returns at once and tezgah sees no child
|
|
674
|
+
# event) or a child that died without writing its end. It leaves the pending
|
|
675
|
+
# list without being read as finished or as failed.
|
|
676
|
+
SLICE_STATUSES = ("open", "done", "stopped", "resumed", "stale")
|
|
677
|
+
SLICE_CLOSED = ("done", "stopped", "resumed", "stale")
|
|
678
|
+
SLICE_IDLE_SECONDS = 6 * 3600
|
|
679
|
+
FAILURES = ("quota", "rate_limit", "aborted", "timeout", "cut_off", "error")
|
|
680
|
+
SLICE_BRIEF = 4000
|
|
681
|
+
SLICE_LINE = 160
|
|
682
|
+
SLICE_TAIL = 64 * 1024
|
|
683
|
+
_SLICE_ID = re.compile(r"[^A-Za-z0-9._-]+")
|
|
684
|
+
# Failure classes, first match wins, in this order. The table moved here from
|
|
685
|
+
# the research line's measure (data/E2-subagent-stops/measure.py::CLASSES) and
|
|
686
|
+
# was narrowed to the classes a lever exists for (`tezgah-task slice stats`).
|
|
687
|
+
_CLASSES = (
|
|
688
|
+
("rate_limit", re.compile(r"\b429\b|rate[ _-]?limit", re.I)),
|
|
689
|
+
("quota", re.compile(r"quota|key limit exceeded|usage limit|"
|
|
690
|
+
r"\b40[23]\b.*\blimit|\blimit\b.*\b40[23]\b", re.I)),
|
|
691
|
+
("aborted", re.compile(r"abort|cancell?ed", re.I)),
|
|
692
|
+
("timeout", re.compile(r"timed out|timeout", re.I)),
|
|
693
|
+
("cut_off", re.compile(r"max[ _]turns|max_tokens|cut off|context[ _]length",
|
|
694
|
+
re.I)),
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _now():
|
|
699
|
+
import time
|
|
700
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _git(cwd, *args):
|
|
704
|
+
"""One git command's stdout in `cwd`, or None when git cannot answer."""
|
|
705
|
+
import subprocess
|
|
706
|
+
try:
|
|
707
|
+
out = subprocess.run(["git", "-C", cwd] + list(args), capture_output=True,
|
|
708
|
+
text=True, timeout=10)
|
|
709
|
+
except (OSError, subprocess.SubprocessError):
|
|
710
|
+
return None
|
|
711
|
+
return out.stdout.strip() if out.returncode == 0 else None
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _journal_root(root):
|
|
715
|
+
"""The main checkout of `root`'s repository: every worktree of one repository
|
|
716
|
+
writes one journal, so a slice opened from a worktree is still found from the
|
|
717
|
+
main checkout the parent session runs in."""
|
|
718
|
+
from tezgah_paths import linked_main
|
|
719
|
+
real = os.path.realpath(root)
|
|
720
|
+
return linked_main(real) or real
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def slice_dir(root):
|
|
724
|
+
"""`<main checkout>/.tezgah/slices`, the one directory the records live in."""
|
|
725
|
+
return os.path.join(_journal_root(root), ".tezgah", "slices")
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _digest(text):
|
|
729
|
+
import hashlib
|
|
730
|
+
return hashlib.sha1(text.encode("utf-8", "replace")).hexdigest()[:12]
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def slice_id(name, brief=""):
|
|
734
|
+
"""A record id: `name` cut to [A-Za-z0-9._-] and 80 characters, else the
|
|
735
|
+
first 12 hex digits of the brief's sha1 - so a re-dispatch of the same brief
|
|
736
|
+
re-opens the same record and counts an attempt."""
|
|
737
|
+
sid = _SLICE_ID.sub("-", str(name or "")).strip("-.")[:80]
|
|
738
|
+
return sid or _digest(brief)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
# The journal is one SQLite file opened like the taste store
|
|
742
|
+
# (`tezgah_store.connect`: WAL, a busy timeout, 0600). One JSON file per record
|
|
743
|
+
# piled up a file per subagent in every repository (owner, 2026-10-11). `row`
|
|
744
|
+
# is the record as JSON; `parent`, `status` and `updated` are the columns a
|
|
745
|
+
# reader filters and sorts on, and `seq` orders two writes in one second.
|
|
746
|
+
SLICE_DB = "slices.db"
|
|
747
|
+
SLICE_SCHEMA = """
|
|
748
|
+
CREATE TABLE IF NOT EXISTS slices (id TEXT PRIMARY KEY, parent TEXT NOT NULL,
|
|
749
|
+
status TEXT NOT NULL, updated TEXT NOT NULL,
|
|
750
|
+
seq INTEGER NOT NULL, row TEXT NOT NULL);
|
|
751
|
+
"""
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
@contextlib.contextmanager
|
|
755
|
+
def _journal(root, create=True):
|
|
756
|
+
"""The journal database of `root`'s repository, the `*.json` records an
|
|
757
|
+
older release wrote imported first (`_import_json`). With `create` False it
|
|
758
|
+
yields None where neither the database nor an old record exists, so a read
|
|
759
|
+
leaves no file behind. The directory carries a `.gitignore` of `*`: the
|
|
760
|
+
records are runtime state, never part of the private `.tezgah` history."""
|
|
761
|
+
import glob
|
|
762
|
+
import tezgah_store
|
|
763
|
+
folder = slice_dir(root)
|
|
764
|
+
path = os.path.join(folder, SLICE_DB)
|
|
765
|
+
if not create and not os.path.exists(path) and not glob.glob(
|
|
766
|
+
os.path.join(folder, "*.json")):
|
|
767
|
+
yield None
|
|
768
|
+
return
|
|
769
|
+
os.makedirs(folder, mode=0o700, exist_ok=True)
|
|
770
|
+
ignore = os.path.join(folder, ".gitignore")
|
|
771
|
+
if not os.path.exists(ignore):
|
|
772
|
+
with open(ignore, "w", encoding="utf-8") as fh:
|
|
773
|
+
fh.write("*\n")
|
|
774
|
+
conn = tezgah_store.connect(path, SLICE_SCHEMA, version=1)
|
|
775
|
+
try:
|
|
776
|
+
_import_json(conn, folder)
|
|
777
|
+
yield conn
|
|
778
|
+
finally:
|
|
779
|
+
conn.close()
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _import_json(conn, folder):
|
|
783
|
+
"""Bring the one-file-per-record journal into the table, then delete each
|
|
784
|
+
file it took. A file whose `updated` is newer than the row replaces it - an
|
|
785
|
+
older release still running writes files beside the database - and an
|
|
786
|
+
older one leaves the row alone. A file that does not parse, or whose id is
|
|
787
|
+
not a string, stays where it is. With no old file, one glob."""
|
|
788
|
+
import glob
|
|
789
|
+
import json
|
|
790
|
+
for path in glob.glob(os.path.join(folder, "*.json")):
|
|
791
|
+
try:
|
|
792
|
+
with open(path, encoding="utf-8") as fh:
|
|
793
|
+
rec = json.load(fh)
|
|
794
|
+
except (OSError, ValueError):
|
|
795
|
+
continue
|
|
796
|
+
if not isinstance(rec, dict) or not isinstance(rec.get("id"), str) \
|
|
797
|
+
or not rec["id"]:
|
|
798
|
+
continue
|
|
799
|
+
held = conn.execute("SELECT updated FROM slices WHERE id = ?",
|
|
800
|
+
(rec["id"],)).fetchone()
|
|
801
|
+
if held is None or str(rec.get("updated") or "") > held[0]:
|
|
802
|
+
_put(conn, rec)
|
|
803
|
+
try:
|
|
804
|
+
os.remove(path)
|
|
805
|
+
except OSError:
|
|
806
|
+
pass
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _put(conn, rec):
|
|
810
|
+
"""Upsert one record."""
|
|
811
|
+
import json
|
|
812
|
+
conn.execute(
|
|
813
|
+
"INSERT OR REPLACE INTO slices (id, parent, status, updated, seq, row) "
|
|
814
|
+
"VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(seq), 0) + 1 FROM slices), ?)",
|
|
815
|
+
(rec["id"], str(rec.get("parent") or ""), str(rec.get("status") or ""),
|
|
816
|
+
str(rec.get("updated") or ""), json.dumps(rec, sort_keys=True)))
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _row(text):
|
|
820
|
+
import json
|
|
821
|
+
try:
|
|
822
|
+
rec = json.loads(text)
|
|
823
|
+
except ValueError:
|
|
824
|
+
return None
|
|
825
|
+
return rec if isinstance(rec, dict) and rec.get("id") else None
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _read_record(root, sid):
|
|
829
|
+
"""The record `sid`, or None; a journal that cannot be opened reads as none."""
|
|
830
|
+
import tezgah_store
|
|
831
|
+
try:
|
|
832
|
+
with _journal(root, create=False) as db:
|
|
833
|
+
hit = db and db.execute("SELECT row FROM slices WHERE id = ?",
|
|
834
|
+
(slice_id(sid),)).fetchone()
|
|
835
|
+
except tezgah_store.ERRORS:
|
|
836
|
+
return None
|
|
837
|
+
return _row(hit[0]) if hit else None
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _write_record(root, rec):
|
|
841
|
+
"""Write one record (one upsert statement) and return it. A database that
|
|
842
|
+
cannot be opened or written raises OSError, the error every caller already
|
|
843
|
+
handles (`tezgah-task slice` prints it, the hooks swallow it)."""
|
|
844
|
+
import tezgah_store
|
|
845
|
+
try:
|
|
846
|
+
with _journal(root) as db:
|
|
847
|
+
_put(db, rec)
|
|
848
|
+
except tezgah_store.ERRORS as exc:
|
|
849
|
+
raise OSError("slice journal %s: %s" % (slice_dir(root), exc)) from exc
|
|
850
|
+
return rec
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _own_id(root, base, brief, parent):
|
|
854
|
+
"""The record id a spawn of `base` opens: `base` itself, unless another
|
|
855
|
+
session holds that record under another brief. Then it is this session's
|
|
856
|
+
own record of that name, else `base-<digest6>` of the brief, so another
|
|
857
|
+
session's stopped slice stays its owner's (research orchestrator-flow-audit
|
|
858
|
+
S7). A same-brief re-dispatch from another session still re-opens `base`."""
|
|
859
|
+
held = _read_record(root, base)
|
|
860
|
+
if (held is None or not parent or not brief
|
|
861
|
+
or held.get("parent") in ("", None, parent)
|
|
862
|
+
or held.get("brief") == brief):
|
|
863
|
+
return base
|
|
864
|
+
own = next((r["id"] for r in slices(root) if r.get("parent") == parent
|
|
865
|
+
and (r.get("name") or r["id"]) == base), None)
|
|
866
|
+
return own or "%s-%s" % (base, _digest(brief)[:6])
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def find_slice(root, ref, session=None):
|
|
870
|
+
"""The record `ref` names for `tezgah-task slice done|stop|resume`. An
|
|
871
|
+
exact id answers whatever session owns it: an id is unambiguous, and it is
|
|
872
|
+
what `slice reconcile` and the review guard print, so a crashed session's
|
|
873
|
+
slice stays reachable. A bare spawned `name` is a lookup: with `session`
|
|
874
|
+
(TEZGAH_SESSION), the newest record of that name the session spawned or no
|
|
875
|
+
session owns; without one, the one record of that name. None when nothing
|
|
876
|
+
answers."""
|
|
877
|
+
ref = slice_id(ref)
|
|
878
|
+
recs = slices(root)
|
|
879
|
+
exact = next((r for r in recs if r["id"] == ref), None)
|
|
880
|
+
if exact or session:
|
|
881
|
+
return exact or next((r for r in recs if r.get("name") == ref
|
|
882
|
+
and r.get("parent") in (session, "", None)), None)
|
|
883
|
+
named = [r for r in recs if r.get("name") == ref]
|
|
884
|
+
return named[0] if len(named) == 1 else None
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _worktree_in(root, *texts):
|
|
888
|
+
"""The linked worktree of this repository the first of `texts` that names
|
|
889
|
+
one names, else "": a slice's own task before the batch's shared context
|
|
890
|
+
(which may list every slice's worktree), the one named last in a text, and
|
|
891
|
+
on a tie the longer path, since `.../a` is found inside `.../a-b` at the
|
|
892
|
+
same place. What reconcile, resume, the board and merge-plan read."""
|
|
893
|
+
from tezgah_paths import worktrees
|
|
894
|
+
trees = worktrees(root)[1:]
|
|
895
|
+
for text in texts:
|
|
896
|
+
found = [p for p in trees if text and p in text]
|
|
897
|
+
if found:
|
|
898
|
+
return max(found, key=lambda p: (text.rfind(p), len(p)))
|
|
899
|
+
return ""
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _named_worktree(root, *texts):
|
|
903
|
+
"""(worktree, branch) for the linked worktree `_worktree_in` reads off
|
|
904
|
+
`texts`, else ("", "")."""
|
|
905
|
+
top = _worktree_in(root, *texts)
|
|
906
|
+
return (top, _git(top, "symbolic-ref", "-q", "--short", "HEAD") or "") if top else ("", "")
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
# Where a brief says its slice works: a `Worktree:`, `Checkout` or `Cwd:` line
|
|
910
|
+
# opening with an absolute path. A path merely mentioned - a worktree to remove,
|
|
911
|
+
# a sibling's tree, a file to read - is no declaration (review of S6, F2). Only
|
|
912
|
+
# the main-checkout refusal reads it (`tezgah_gate.lease_reason`).
|
|
913
|
+
_DECLARED = re.compile(r"(?im)^[ \t>#*_-]*(?:worktree|checkout|cwd)\b[*_]*[ \t]*:?"
|
|
914
|
+
r"[*_]*[ \t]*`?(/[^\s`'\"()<>,;]+)")
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _declared_in(root, *texts):
|
|
918
|
+
"""The linked worktree of this repository the first of `texts` that
|
|
919
|
+
declares a checkout of it declares, else "": a slice's own task before the
|
|
920
|
+
batch's shared context, the last declaration in a text. A declared main
|
|
921
|
+
checkout is none - an integrator works there."""
|
|
922
|
+
from tezgah_paths import worktrees
|
|
923
|
+
trees = worktrees(root)
|
|
924
|
+
for text in texts:
|
|
925
|
+
found = [os.path.realpath(p.rstrip("/.:")) for p in _DECLARED.findall(text or "")]
|
|
926
|
+
found = [p for p in found if p in trees]
|
|
927
|
+
if found:
|
|
928
|
+
return found[-1] if found[-1] != trees[0] else ""
|
|
929
|
+
return ""
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
# `Owns:` in a slice's own task: the repository-relative globs only that slice
|
|
933
|
+
# writes while its record is open (research orchestrator-flow-audit S6, pick
|
|
934
|
+
# V2). The gate refuses a sibling's write into them (`tezgah_gate.lease_reason`).
|
|
935
|
+
# The globs follow on the line, split on commas and spaces, or - with nothing
|
|
936
|
+
# after the colon - as the `- glob` list items under it. Markdown emphasis and
|
|
937
|
+
# backticks are dropped; an absolute glob under the slice's checkout is cut to it.
|
|
938
|
+
_OWNS = re.compile(r"(?i)^[ \t>#*_-]*owns[*_]*[ \t]*:[*_]*[ \t]*(.*)$")
|
|
939
|
+
_LIST_ITEM = re.compile(r"^[ \t>]*(?:[-*+]|\d+[.)])[ \t]+(.+)$")
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def owned_globs(text, tops=()):
|
|
943
|
+
"""The `Owns:` globs `text` names, in order, each once."""
|
|
944
|
+
found, lines = [], (text or "").splitlines()
|
|
945
|
+
for i, line in enumerate(lines):
|
|
946
|
+
hit = _OWNS.match(line)
|
|
947
|
+
if not hit:
|
|
948
|
+
continue
|
|
949
|
+
if hit.group(1).strip():
|
|
950
|
+
found.append(hit.group(1))
|
|
951
|
+
continue
|
|
952
|
+
for item in lines[i + 1:]:
|
|
953
|
+
listed = _LIST_ITEM.match(item)
|
|
954
|
+
if not listed:
|
|
955
|
+
break
|
|
956
|
+
found.append(listed.group(1))
|
|
957
|
+
out = []
|
|
958
|
+
for line in found:
|
|
959
|
+
for glob in re.split(r"[\s,]+", line.replace("`", "")):
|
|
960
|
+
glob = glob.rstrip(".;")
|
|
961
|
+
top = next((t for t in tops if t and glob.startswith(t + "/")), "")
|
|
962
|
+
glob = glob[len(top) + 1:] if top else glob
|
|
963
|
+
if glob and glob not in out:
|
|
964
|
+
out.append(glob)
|
|
965
|
+
return out
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def slice_of(root, parent, name):
|
|
969
|
+
"""The newest record `parent` spawned under `name` - a subagent's own
|
|
970
|
+
record, read from its `spawned` row (`tezgah_integrity.spawn_of`), or from
|
|
971
|
+
Claude's `agent_id`, which a background spawn stores as `child` - else
|
|
972
|
+
None. An exact name or child first; omp names a re-dispatch `<name>-2`, so
|
|
973
|
+
the bare name answers only after that."""
|
|
974
|
+
if not (parent and name):
|
|
975
|
+
return None
|
|
976
|
+
mine = [r for r in slices(root) if r.get("parent") == parent]
|
|
977
|
+
base = re.sub(r"-\d+$", "", name)
|
|
978
|
+
return (next((r for r in mine if (r.get("name") or r["id"]) == name
|
|
979
|
+
or r.get("child") == name), None)
|
|
980
|
+
or next((r for r in mine if (r.get("name") or r["id"]) == base), None))
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def owner_of(root, path, parent, me):
|
|
984
|
+
"""The open record of another writing slice `parent` spawned whose `owns`
|
|
985
|
+
matches the repository-relative `path`, else None; `me` is the writing
|
|
986
|
+
slice's id. A reader - an agent named for a read-only role - holds no
|
|
987
|
+
lease, whatever `Owns:` line its task quotes. A hit re-reads after
|
|
988
|
+
`pending`, which closes an omp sibling whose child transcript ended, so a
|
|
989
|
+
finished owner frees its files."""
|
|
990
|
+
def held():
|
|
991
|
+
return next((r for r in slices(root) if parent and r.get("parent") == parent
|
|
992
|
+
and r.get("status") == "open" and r["id"] != me
|
|
993
|
+
and not _READ_ONLY_AGENT.search(r.get("agent") or "")
|
|
994
|
+
and any(match(path, g) for g in r.get("owns") or ())), None)
|
|
995
|
+
if held() is None:
|
|
996
|
+
return None
|
|
997
|
+
pending(root)
|
|
998
|
+
return held()
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def close_child(root, parent, child):
|
|
1002
|
+
"""Close as done the open record `parent` spawned whose `child` is the
|
|
1003
|
+
host's id of a background child that just ended (Claude's SubagentStop),
|
|
1004
|
+
and return it; None when no record holds it."""
|
|
1005
|
+
rec = next((r for r in slices(root) if parent and child and r.get("parent") == parent
|
|
1006
|
+
and r.get("child") == child and r.get("status") == "open"), None)
|
|
1007
|
+
return close_slice(root, rec["id"], "done") if rec else None
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
BOARD_LINES = 12
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def board(root, parent=None, limit=BOARD_LINES):
|
|
1014
|
+
"""One bounded line (SLICE_LINE) per open record `parent` spawned - every
|
|
1015
|
+
open record without one - newest first, at most `limit` and a count of the
|
|
1016
|
+
rest: what each slice owns, where it works and its last commit. A pull
|
|
1017
|
+
read: siblings see each other's leases here instead of by message."""
|
|
1018
|
+
recs = [r for r in pending(root) if r.get("status") == "open"
|
|
1019
|
+
and r.get("kind") != "job" and (parent is None or r.get("parent") == parent)]
|
|
1020
|
+
out = []
|
|
1021
|
+
for rec in recs[:limit]:
|
|
1022
|
+
tree = rec.get("worktree") or ""
|
|
1023
|
+
head = _git(tree, "log", "-1", "--format=%h") if tree and os.path.isdir(tree) else None
|
|
1024
|
+
out.append(_cut("- %s (%s/%s) owns %s; worktree %s on %s @ %s" % (
|
|
1025
|
+
rec.get("name") or rec["id"], rec.get("host") or "?", rec.get("agent") or "?",
|
|
1026
|
+
", ".join(rec.get("owns") or ()) or "nothing declared", tree or "none",
|
|
1027
|
+
rec.get("branch") or "?", head or "?")))
|
|
1028
|
+
if len(recs) > limit:
|
|
1029
|
+
out.append("- %d more open slice(s)" % (len(recs) - limit))
|
|
1030
|
+
return out
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
# An agent whose name reads as a read-only role shares a worktree freely: a
|
|
1034
|
+
# reviewer panel on one slice's checkout is the allowed case (research
|
|
1035
|
+
# orchestrator-flow-audit S6, finding 5). A brief with `Owns:` writes whatever
|
|
1036
|
+
# its agent is called.
|
|
1037
|
+
_READ_ONLY_AGENT = re.compile(r"review|explor|research|scout|audit|critic|judge", re.I)
|
|
1038
|
+
SHARED_WORKTREE = (
|
|
1039
|
+
"tezgah: writing slices %s all name worktree %s. Every writing slice gets its own "
|
|
1040
|
+
"branch and checkout (`orca worktree create` inside Orca, else `git worktree "
|
|
1041
|
+
"add`): two slices in one checkout overwrite each other's edits.")
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _writing(agent, owns):
|
|
1045
|
+
return bool(owns) or not _READ_ONLY_AGENT.search(agent or "")
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def _writes(rec):
|
|
1049
|
+
"""A writing record: its own task's `Owns:`, else an agent not named for a
|
|
1050
|
+
read-only role. A reviewer whose lease came from a shared brief is not one."""
|
|
1051
|
+
return _writing(rec.get("agent"), owned_globs(rec.get("task") or ""))
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def worktree_clash(root, tool, inp, parent=""):
|
|
1055
|
+
"""SHARED_WORKTREE when two writing slices - of this spawn call, or this
|
|
1056
|
+
call and an open record `parent` spawned - name one worktree, else None.
|
|
1057
|
+
Each task's own text decides its worktree; one that names none takes the
|
|
1058
|
+
shared context's, which is how ten slices landed in one checkout."""
|
|
1059
|
+
main = _journal_root(root)
|
|
1060
|
+
seen = {}
|
|
1061
|
+
for rec in slices(root):
|
|
1062
|
+
if parent and rec.get("parent") == parent and rec.get("status") == "open" \
|
|
1063
|
+
and rec.get("worktree") and _writes(rec):
|
|
1064
|
+
seen.setdefault(rec["worktree"], set()).add(rec.get("name") or rec["id"])
|
|
1065
|
+
context = str(inp.get("context") or "").strip() if isinstance(inp, dict) else ""
|
|
1066
|
+
for sid, agent, brief in spawned(tool, inp):
|
|
1067
|
+
task = brief[len(context):] if context else brief
|
|
1068
|
+
tree = _worktree_in(main, task, brief)
|
|
1069
|
+
if tree and _writing(agent, owned_globs(task)):
|
|
1070
|
+
seen.setdefault(tree, set()).add(sid)
|
|
1071
|
+
clash = sorted((t, sorted(n)) for t, n in seen.items() if len(n) > 1)
|
|
1072
|
+
return SHARED_WORKTREE % (", ".join(clash[0][1]), clash[0][0]) if clash else None
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def open_slice(root, sid, *, kind="agent", host="", agent="", brief="", parent="",
|
|
1076
|
+
cwd="", repo="", task="", child=""):
|
|
1077
|
+
"""Open (or re-open) the record `sid` and return it. The worktree and branch
|
|
1078
|
+
are the ones the slice's own task names (else the brief, `_worktree_in`);
|
|
1079
|
+
`declared` is the worktree a `Worktree:`/`Checkout`/`Cwd:` line declares
|
|
1080
|
+
(`_declared_in`); the base is the main checkout's HEAD, and `owns` holds the
|
|
1081
|
+
`Owns:` globs of the task (else the brief, `owned_globs`). A re-open
|
|
1082
|
+
of an existing id counts one more attempt, sets the status back to open and
|
|
1083
|
+
clears the failure; a field the new call leaves empty keeps its old value.
|
|
1084
|
+
A record another session holds under another brief is not re-opened: the
|
|
1085
|
+
spawn gets its own id (`_own_id`), and `name` keeps the spawned name.
|
|
1086
|
+
`repo` names the repository the slice works in when that is not this
|
|
1087
|
+
journal's (`_brief_repo`); `child` is the host's id for the running child
|
|
1088
|
+
(Claude's background `agentId`), the key its own writes carry."""
|
|
1089
|
+
base = slice_id(sid, brief)
|
|
1090
|
+
main = _journal_root(root)
|
|
1091
|
+
own = task or brief or ""
|
|
1092
|
+
brief = (brief or "")[:SLICE_BRIEF]
|
|
1093
|
+
now = _now()
|
|
1094
|
+
sid = _own_id(root, base, brief, parent)
|
|
1095
|
+
rec = _read_record(root, sid)
|
|
1096
|
+
if rec is None:
|
|
1097
|
+
rec = {"id": sid, "name": base, "kind": kind if kind in SLICE_KINDS else "agent",
|
|
1098
|
+
"host": host, "agent": agent, "brief": brief, "parent": parent,
|
|
1099
|
+
"cwd": cwd, "repo": repo, "worktree": "", "branch": "", "attempts": 1,
|
|
1100
|
+
"opened": now, "child": child}
|
|
1101
|
+
else:
|
|
1102
|
+
rec["attempts"] = int(rec.get("attempts") or 0) + 1
|
|
1103
|
+
if parent and parent != rec.get("parent"):
|
|
1104
|
+
# a re-dispatch from another session (after a /clear, a crash, a
|
|
1105
|
+
# resume) writes its child under that session's directory: the old
|
|
1106
|
+
# one holds the first run, whose end must not close this record
|
|
1107
|
+
rec["transcript_dir"] = ""
|
|
1108
|
+
for key, value in (("host", host), ("agent", agent), ("brief", brief),
|
|
1109
|
+
("parent", parent), ("cwd", cwd), ("repo", repo),
|
|
1110
|
+
("child", child)):
|
|
1111
|
+
if value:
|
|
1112
|
+
rec[key] = value
|
|
1113
|
+
if task: # the slice's own task, without a batch's shared context
|
|
1114
|
+
rec["task"] = task[:SLICE_BRIEF]
|
|
1115
|
+
if brief or not rec.get("worktree"):
|
|
1116
|
+
rec["worktree"], rec["branch"] = _named_worktree(main, task, rec.get("brief"))
|
|
1117
|
+
rec["declared"] = _declared_in(main, task, rec.get("brief"))
|
|
1118
|
+
if own:
|
|
1119
|
+
rec["owns"] = owned_globs(own, (rec["worktree"], main))
|
|
1120
|
+
if rec.get("host") == "omp" and rec.get("parent") and not rec.get("transcript_dir"):
|
|
1121
|
+
# one glob over every omp project at open, so `pending` - run on every
|
|
1122
|
+
# prompt and Stop - never globs (40 ms on 1393 project dirs, measured)
|
|
1123
|
+
rec["transcript_dir"] = _omp_session_dir(rec["parent"]) or ""
|
|
1124
|
+
# the plan whose task was active when the slice opened: the review guard
|
|
1125
|
+
# reads its own plan's records only
|
|
1126
|
+
rec["plan"] = (active(main, main) or {}).get("id") or rec.get("plan") or ""
|
|
1127
|
+
rec.update(base=_git(main, "rev-parse", "HEAD") or "", status="open",
|
|
1128
|
+
failure="", updated=now)
|
|
1129
|
+
return _write_record(root, rec)
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def close_slice(root, sid, status, failure="", stop=None):
|
|
1133
|
+
"""Close the record `sid` as done, stopped or resumed; None when it has none.
|
|
1134
|
+
A stopped record takes `failure` (a FAILURES class or ""); done and resumed
|
|
1135
|
+
keep the class the slice stopped with, so the stats still count the event.
|
|
1136
|
+
A quota or rate-limit stop whose message `stop` ({errorMessage, provider,
|
|
1137
|
+
model, default}) names its family holds that family down
|
|
1138
|
+
(`tezgah_models.note_limit`; a rate limit briefly);
|
|
1139
|
+
that push fails open."""
|
|
1140
|
+
if status not in SLICE_CLOSED:
|
|
1141
|
+
raise ValueError("status %r is not one of %s" % (status, ", ".join(SLICE_CLOSED)))
|
|
1142
|
+
rec = _read_record(root, sid)
|
|
1143
|
+
if rec is None:
|
|
1144
|
+
return None
|
|
1145
|
+
if status == "stopped" or failure:
|
|
1146
|
+
rec["failure"] = failure if failure in FAILURES else ""
|
|
1147
|
+
rec.update(status=status, updated=_now())
|
|
1148
|
+
if failure in ("quota", "rate_limit") and isinstance(stop, dict):
|
|
1149
|
+
try:
|
|
1150
|
+
import tezgah_models
|
|
1151
|
+
tezgah_models.note_limit(
|
|
1152
|
+
stop.get("errorMessage") or "", stop.get("provider") or "",
|
|
1153
|
+
stop.get("model") or "", transient=failure == "rate_limit",
|
|
1154
|
+
default=stop.get("default") or "")
|
|
1155
|
+
except Exception: # noqa: BLE001 - health is advice: the close still lands
|
|
1156
|
+
pass
|
|
1157
|
+
return _write_record(root, rec)
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def slices(root):
|
|
1161
|
+
"""Every readable record, newest `updated` first (the later write first on a
|
|
1162
|
+
tie); a row that does not parse, or a journal that cannot be opened, is
|
|
1163
|
+
skipped - readers fail open."""
|
|
1164
|
+
import tezgah_store
|
|
1165
|
+
try:
|
|
1166
|
+
with _journal(root, create=False) as db:
|
|
1167
|
+
rows = db.execute("SELECT row FROM slices ORDER BY updated DESC, "
|
|
1168
|
+
"seq DESC").fetchall() if db else []
|
|
1169
|
+
except tezgah_store.ERRORS:
|
|
1170
|
+
return []
|
|
1171
|
+
return [rec for rec in (_row(r[0]) for r in rows) if rec]
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def classify(text):
|
|
1175
|
+
"""The failure class of an error or result text: rate_limit, quota, aborted,
|
|
1176
|
+
timeout, cut_off, else error for any other non-empty text, "" for none."""
|
|
1177
|
+
text = str(text or "").strip()
|
|
1178
|
+
if not text:
|
|
1179
|
+
return ""
|
|
1180
|
+
for name, pattern in _CLASSES:
|
|
1181
|
+
if pattern.search(text):
|
|
1182
|
+
return name
|
|
1183
|
+
return "error"
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def _brief(*parts):
|
|
1187
|
+
return "\n\n".join(str(p).strip() for p in parts if str(p or "").strip())
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
def spawned(tool, inp):
|
|
1191
|
+
"""[(id, agent, brief)] for one spawn call's input, [] for any other tool.
|
|
1192
|
+
omp `task` carries a batch ({tasks: [{name, agent, task}], context}, brief =
|
|
1193
|
+
context + task); Claude, dsh and Cursor `Agent`/`Task` and opencode `task`
|
|
1194
|
+
carry one ({description, prompt, subagent_type}); Codex `spawn_agent` carries
|
|
1195
|
+
{task_name, message|prompt, agent_type|agent} - its `task_name` is the id the
|
|
1196
|
+
user addresses the child by."""
|
|
1197
|
+
if not isinstance(inp, dict):
|
|
1198
|
+
return []
|
|
1199
|
+
name = str(tool or "").lower()
|
|
1200
|
+
out = []
|
|
1201
|
+
if name == "task" and isinstance(inp.get("tasks"), list):
|
|
1202
|
+
context = inp.get("context") or ""
|
|
1203
|
+
for item in inp["tasks"]:
|
|
1204
|
+
if not isinstance(item, dict):
|
|
1205
|
+
continue
|
|
1206
|
+
brief = _brief(context, item.get("task") or "")
|
|
1207
|
+
out.append((slice_id(item.get("name"), brief), str(item.get("agent") or ""),
|
|
1208
|
+
brief))
|
|
1209
|
+
elif name in ("task", "agent"):
|
|
1210
|
+
brief = _brief(inp.get("description") or "", inp.get("prompt") or "")
|
|
1211
|
+
out.append((slice_id(inp.get("name"), brief),
|
|
1212
|
+
str(inp.get("subagent_type") or ""), brief))
|
|
1213
|
+
elif name == "spawn_agent":
|
|
1214
|
+
brief = str(inp.get("message") or inp.get("prompt") or "")
|
|
1215
|
+
out.append((slice_id(inp.get("task_name") or inp.get("name"), brief),
|
|
1216
|
+
str(inp.get("agent_type") or inp.get("agent") or ""), brief))
|
|
1217
|
+
return [row for row in out if row[2]]
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def note_spawn(cwd, session_id, host, tool, inp, failed=None, result_text="",
|
|
1221
|
+
child=""):
|
|
1222
|
+
"""Open one record per slice a spawn call started, and return them. A host
|
|
1223
|
+
that runs the subagent synchronously (Claude's PostToolUse on Agent/Task)
|
|
1224
|
+
passes `failed` as a bool and the record closes at once: stopped with the
|
|
1225
|
+
class of `result_text` (error when it names none), else done. No-op ([])
|
|
1226
|
+
under `slices-off` or outside a git checkout. The record stays in cwd's
|
|
1227
|
+
journal, the one the owner's Stop rule, resume block and review guard
|
|
1228
|
+
read, and names the repository its brief works in as `repo`
|
|
1229
|
+
(`_brief_repo`) when that is another one. `child` is the id the host
|
|
1230
|
+
returned for a background child (Claude's `agentId`)."""
|
|
1231
|
+
from tezgah_paths import _toplevel, off
|
|
1232
|
+
if off("slices-off") or not cwd:
|
|
1233
|
+
return []
|
|
1234
|
+
root = _toplevel(os.path.realpath(cwd))
|
|
1235
|
+
if not root:
|
|
1236
|
+
return []
|
|
1237
|
+
here = _journal_root(root)
|
|
1238
|
+
out = []
|
|
1239
|
+
context = str(inp.get("context") or "").strip() if isinstance(inp, dict) else ""
|
|
1240
|
+
for sid, agent, brief in spawned(tool, inp):
|
|
1241
|
+
repo = _brief_repo(brief, here)
|
|
1242
|
+
rec = open_slice(root, sid, host=host, agent=agent, brief=brief,
|
|
1243
|
+
parent=session_id or "", cwd=cwd,
|
|
1244
|
+
repo="" if repo == here else repo,
|
|
1245
|
+
task=brief[len(context):].strip() if context else brief,
|
|
1246
|
+
child=child)
|
|
1247
|
+
if failed is True:
|
|
1248
|
+
# a Claude subagent runs on Anthropic unless its model names another
|
|
1249
|
+
# vendor; another host's input may name a model
|
|
1250
|
+
stop = {"errorMessage": result_text, "model": str(inp.get("model") or ""),
|
|
1251
|
+
"default": "anthropic" if host == "claude" else ""}
|
|
1252
|
+
rec = close_slice(root, rec["id"], "stopped",
|
|
1253
|
+
classify(result_text) or "error", stop)
|
|
1254
|
+
elif failed is False:
|
|
1255
|
+
rec = close_slice(root, rec["id"], "done")
|
|
1256
|
+
out.append(rec)
|
|
1257
|
+
return out
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
# An absolute path in a brief: a slash after a space, a quote or a bracket, up
|
|
1261
|
+
# to the next space, quote, bracket or separator.
|
|
1262
|
+
_ABS_PATH = re.compile(r"(?<![\w.~/-])/[^\s'\"`<>()\[\]{},;|*?]+")
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def main_repo(path):
|
|
1266
|
+
"""The main checkout of the repository holding `path`, else None. A path
|
|
1267
|
+
under a repository's own `.tezgah` - a private git repository of its own -
|
|
1268
|
+
belongs to the repository around it, and a worktree to its main checkout."""
|
|
1269
|
+
from tezgah_paths import _toplevel
|
|
1270
|
+
top = _toplevel(os.path.realpath(path))
|
|
1271
|
+
while top and os.path.basename(top) == ".tezgah":
|
|
1272
|
+
top = _toplevel(os.path.dirname(top))
|
|
1273
|
+
return _journal_root(top) if top else None
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def _brief_repo(brief, root):
|
|
1277
|
+
"""The main checkout of the one armed repository every absolute path the
|
|
1278
|
+
brief names lies in, else `root`. A brief naming two repositories, or none,
|
|
1279
|
+
keeps `root` (research orchestrator-flow-audit S7, finding 2)."""
|
|
1280
|
+
from tezgah_paths import root_for
|
|
1281
|
+
found = set()
|
|
1282
|
+
for path in _ABS_PATH.findall(brief or ""):
|
|
1283
|
+
top = main_repo(path.rstrip(".:"))
|
|
1284
|
+
if top and root_for(top):
|
|
1285
|
+
found.add(top)
|
|
1286
|
+
return found.pop() if len(found) == 1 else root
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def _omp_session_dir(parent):
|
|
1290
|
+
"""The omp session directory of parent session `parent`
|
|
1291
|
+
(`~/.omp/agent/sessions/<project>/<stamp>_<parent>/`, one `<agent id>.jsonl`
|
|
1292
|
+
per subagent), or None. One glob over every project, so it runs once per
|
|
1293
|
+
record (`open_slice`), never per prompt."""
|
|
1294
|
+
import glob
|
|
1295
|
+
if not parent or "/" in parent or os.sep in parent:
|
|
1296
|
+
return None
|
|
1297
|
+
home = os.path.expanduser("~")
|
|
1298
|
+
hits = glob.glob(os.path.join(home, ".omp", "agent", "sessions", "*",
|
|
1299
|
+
"*_" + glob.escape(parent)))
|
|
1300
|
+
return hits[0] if hits else None
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
def _omp_transcript(rec):
|
|
1304
|
+
"""The newest omp child transcript of the record: omp names a re-dispatch of
|
|
1305
|
+
the same agent name in one session `<name>-2.jsonl`, `<name>-3.jsonl`, so the
|
|
1306
|
+
highest suffix is the attempt the record now tracks - reading `<name>.jsonl`
|
|
1307
|
+
would close a re-opened record with the first run's end. None when the
|
|
1308
|
+
record names no session directory or the directory holds no such file. The
|
|
1309
|
+
file is named by the spawned `name`, which a record's own id may extend."""
|
|
1310
|
+
folder, sid = rec.get("transcript_dir"), rec.get("name") or rec.get("id")
|
|
1311
|
+
if not folder or not sid:
|
|
1312
|
+
return None
|
|
1313
|
+
try:
|
|
1314
|
+
names = os.listdir(folder)
|
|
1315
|
+
except OSError:
|
|
1316
|
+
return None
|
|
1317
|
+
best, rank = None, -1
|
|
1318
|
+
attempt = re.compile(re.escape(sid) + r"(?:-(\d+))?\.jsonl")
|
|
1319
|
+
for name in names:
|
|
1320
|
+
hit = attempt.fullmatch(name)
|
|
1321
|
+
n = int(hit.group(1) or 1) if hit else -1
|
|
1322
|
+
if n > rank:
|
|
1323
|
+
best, rank = os.path.join(folder, name), n
|
|
1324
|
+
return best
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def _transcript_end(path):
|
|
1328
|
+
"""How an omp child transcript ended, read from its last SLICE_TAIL bytes:
|
|
1329
|
+
("stopped", class) for an error, abort or length stop on the last assistant
|
|
1330
|
+
message, an error with that message third (`close_slice`'s `stop`);
|
|
1331
|
+
("done", "") once it yielded, stopped normally or exited; None while
|
|
1332
|
+
it may still be running or cannot be read."""
|
|
1333
|
+
import json
|
|
1334
|
+
try:
|
|
1335
|
+
with open(path, "rb") as fh:
|
|
1336
|
+
fh.seek(0, os.SEEK_END)
|
|
1337
|
+
size = fh.tell()
|
|
1338
|
+
fh.seek(max(0, size - SLICE_TAIL))
|
|
1339
|
+
data = fh.read().decode("utf-8", "replace")
|
|
1340
|
+
except OSError:
|
|
1341
|
+
return None
|
|
1342
|
+
rows = []
|
|
1343
|
+
for line in data.splitlines():
|
|
1344
|
+
try:
|
|
1345
|
+
row = json.loads(line)
|
|
1346
|
+
except ValueError:
|
|
1347
|
+
continue # the first line of a cut tail, or a torn last one
|
|
1348
|
+
if isinstance(row, dict):
|
|
1349
|
+
rows.append(row)
|
|
1350
|
+
exited = yielded = False
|
|
1351
|
+
for row in reversed(rows):
|
|
1352
|
+
if row.get("type") == "custom" and row.get("customType") == "session_exit":
|
|
1353
|
+
exited = True
|
|
1354
|
+
msg = row.get("message") if isinstance(row.get("message"), dict) else None
|
|
1355
|
+
if not msg:
|
|
1356
|
+
continue
|
|
1357
|
+
if msg.get("role") == "toolResult" and msg.get("toolName") in ("yield",
|
|
1358
|
+
"submit_result"):
|
|
1359
|
+
yielded = True
|
|
1360
|
+
if msg.get("role") != "assistant":
|
|
1361
|
+
continue
|
|
1362
|
+
stop = msg.get("stopReason")
|
|
1363
|
+
if stop == "error":
|
|
1364
|
+
return "stopped", classify(msg.get("errorMessage")) or "error", msg
|
|
1365
|
+
if stop == "aborted":
|
|
1366
|
+
return "stopped", "aborted"
|
|
1367
|
+
if stop == "length":
|
|
1368
|
+
return "stopped", "cut_off"
|
|
1369
|
+
if yielded or exited or stop == "stop":
|
|
1370
|
+
return "done", ""
|
|
1371
|
+
return None
|
|
1372
|
+
return ("done", "") if yielded or exited else None
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
# How long a `job` record stays pending: omp clamps a background call's
|
|
1376
|
+
# deadline to 3600 s, so a job opened longer ago has ended either way and its
|
|
1377
|
+
# line would only repeat itself on every prompt. Its result stays in the parent
|
|
1378
|
+
# transcript the record names, so it closes `stale`, not done.
|
|
1379
|
+
SLICE_JOB_SECONDS = 3600
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def _age(rec):
|
|
1383
|
+
"""Seconds since the record last changed (`updated`, else `opened`), or None
|
|
1384
|
+
when neither reads as a record time."""
|
|
1385
|
+
import calendar
|
|
1386
|
+
import time
|
|
1387
|
+
stamp = str(rec.get("updated") or rec.get("opened") or "")
|
|
1388
|
+
try:
|
|
1389
|
+
then = calendar.timegm(time.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ"))
|
|
1390
|
+
except ValueError:
|
|
1391
|
+
return None
|
|
1392
|
+
return time.time() - then
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
def pending(root):
|
|
1396
|
+
"""The records still owed work - status open or stopped - newest first. An
|
|
1397
|
+
omp record still open whose child transcript has ended is closed first:
|
|
1398
|
+
stopped with its failure class, or done when it ended normally. An open
|
|
1399
|
+
record nothing has moved for SLICE_IDLE_SECONDS (an omp child's transcript
|
|
1400
|
+
included) - or a `job` past SLICE_JOB_SECONDS - closes `stale`. One stat and
|
|
1401
|
+
at most one SLICE_TAIL read per open omp record."""
|
|
1402
|
+
out = []
|
|
1403
|
+
for rec in slices(root):
|
|
1404
|
+
if rec.get("status") != "open":
|
|
1405
|
+
if rec.get("status") == "stopped":
|
|
1406
|
+
out.append(rec)
|
|
1407
|
+
continue
|
|
1408
|
+
idle, limit = _age(rec), SLICE_IDLE_SECONDS
|
|
1409
|
+
if rec.get("kind") == "job":
|
|
1410
|
+
limit = SLICE_JOB_SECONDS
|
|
1411
|
+
elif rec.get("host") == "omp":
|
|
1412
|
+
if not rec.get("transcript_dir") and idle is not None and idle < 600:
|
|
1413
|
+
# the parent's directory did not exist yet when the record
|
|
1414
|
+
# opened: one more glob while the record is young, then kept
|
|
1415
|
+
found = _omp_session_dir(rec.get("parent"))
|
|
1416
|
+
if found:
|
|
1417
|
+
rec["transcript_dir"] = found
|
|
1418
|
+
_write_record(root, rec)
|
|
1419
|
+
path = _omp_transcript(rec)
|
|
1420
|
+
end = _transcript_end(path) if path else None
|
|
1421
|
+
if end:
|
|
1422
|
+
rec = close_slice(root, rec["id"], *end) or rec
|
|
1423
|
+
elif path:
|
|
1424
|
+
try:
|
|
1425
|
+
import time
|
|
1426
|
+
idle = min(idle if idle is not None else 1e18,
|
|
1427
|
+
time.time() - os.stat(path).st_mtime)
|
|
1428
|
+
except OSError:
|
|
1429
|
+
pass
|
|
1430
|
+
if rec.get("status") == "open" and idle is not None and idle > limit:
|
|
1431
|
+
rec = close_slice(root, rec["id"], "stale") or rec
|
|
1432
|
+
if rec.get("status") in ("open", "stopped"):
|
|
1433
|
+
out.append(rec)
|
|
1434
|
+
return out
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
def _cut(line, keep=""):
|
|
1438
|
+
"""`line` + `keep` cut to SLICE_LINE characters, the cut taken from `line` so
|
|
1439
|
+
`keep` (the command that acts on the record) survives whole."""
|
|
1440
|
+
room = SLICE_LINE - len(keep)
|
|
1441
|
+
if len(line) > room:
|
|
1442
|
+
line = line[:max(0, room - 3)] + "..."
|
|
1443
|
+
return (line + keep)[:SLICE_LINE]
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
def slice_line(rec):
|
|
1447
|
+
"""One bounded line (SLICE_LINE characters) for one record."""
|
|
1448
|
+
sid = rec.get("id") or ""
|
|
1449
|
+
status = rec.get("status") or ""
|
|
1450
|
+
if rec.get("kind") == "job":
|
|
1451
|
+
return _cut("- job %s %s: %s (parent session %s) - read its result there "
|
|
1452
|
+
"before re-running" % (sid, status,
|
|
1453
|
+
" ".join(str(rec.get("brief") or "").split())[:60],
|
|
1454
|
+
rec.get("parent") or "?"))
|
|
1455
|
+
head = "- slice %s (%s/%s) %s%s attempts %s" % (
|
|
1456
|
+
sid, rec.get("host") or "?", rec.get("agent") or "?", status,
|
|
1457
|
+
(" " + rec["failure"]) if rec.get("failure") else "", rec.get("attempts") or 1)
|
|
1458
|
+
tree = rec.get("worktree") or ""
|
|
1459
|
+
if rec.get("repo"):
|
|
1460
|
+
head += " (in %s)" % os.path.basename(rec["repo"])
|
|
1461
|
+
if tree and os.path.isdir(tree):
|
|
1462
|
+
head += " worktree %s @ %s" % (tree, _git(tree, "rev-parse", "--short", "HEAD") or "?")
|
|
1463
|
+
return _cut(head, ": tezgah-task slice resume %s" % sid)
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def slice_lines(root, limit=3, session=None, others=False):
|
|
1467
|
+
"""One line per pending record, newest first, at most `limit` of them; with
|
|
1468
|
+
`session`, only the records that session spawned (`parent`) or that no
|
|
1469
|
+
session owns (one opened by hand with no `--parent`) - another session's
|
|
1470
|
+
slices are its own to resume. With `others`, one closing line counts those
|
|
1471
|
+
others, so a new session after a crash or a host switch still learns they
|
|
1472
|
+
exist (the resume block asks for it, the per-prompt part does not)."""
|
|
1473
|
+
recs = pending(root)
|
|
1474
|
+
if session is None:
|
|
1475
|
+
return [slice_line(rec) for rec in recs[:limit]]
|
|
1476
|
+
mine = [r for r in recs if r.get("parent") in (session, "", None)]
|
|
1477
|
+
out = [slice_line(rec) for rec in mine[:limit]]
|
|
1478
|
+
if others and len(recs) > len(mine):
|
|
1479
|
+
out.append("- %d pending slice(s) of other sessions: tezgah-task slice reconcile"
|
|
1480
|
+
% (len(recs) - len(mine)))
|
|
1481
|
+
return out
|