@r1z4x/tezgah 1.6.0 → 1.8.2
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 +140 -1
- package/MANIFEST +7 -0
- package/README.md +6 -3
- package/bin/tezgah-decisions +6 -14
- package/bin/tezgah-design +21 -5
- package/bin/tezgah-docs +18 -16
- package/bin/tezgah-doctor +152 -0
- package/bin/tezgah-lessons +4 -1
- package/bin/tezgah-research +249 -17
- package/bin/tezgah-route +70 -8
- package/bin/tezgah-setup +26 -13
- package/bin/tezgah-task +877 -60
- package/bin/tezgah-taste +56 -15
- package/docs/architecture.md +28 -11
- package/docs/clarity-baseline.json +2 -2
- package/docs/contract.md +108 -24
- package/docs/environment.md +3 -1
- package/docs/evidence.md +209 -28
- package/docs/feature-audit.md +1 -1
- package/docs/gate.md +78 -23
- 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 +75 -14
- package/docs/operations.md +133 -3
- package/docs/research.md +349 -15
- package/docs/skills.md +1 -1
- package/docs/status-line.md +5 -5
- package/docs/testing.md +20 -13
- 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-auto-init.py +29 -1
- 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 +53 -17
- package/hooks/tezgah_clarity.py +7 -3
- package/hooks/tezgah_context.py +489 -438
- package/hooks/tezgah_gate.py +996 -80
- package/hooks/tezgah_integrity.py +1547 -197
- package/hooks/tezgah_models.py +427 -45
- package/hooks/tezgah_paths.py +147 -16
- package/hooks/tezgah_policy.py +261 -129
- package/hooks/tezgah_rank.py +29 -27
- package/hooks/tezgah_replay.py +200 -30
- package/hooks/tezgah_research.py +1303 -14
- package/hooks/tezgah_stopspec.py +7 -0
- package/hooks/tezgah_store.py +74 -27
- package/hooks/tezgah_task.py +1201 -40
- package/hooks/tezgah_taste.py +8 -4
- package/hosts/codex/hook.py +32 -13
- package/hosts/cursor/hook.py +32 -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 +273 -6
- 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 +27 -11
- package/skills/plan-add/render_table.py +8 -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 +154 -59
package/hooks/tezgah_task.py
CHANGED
|
@@ -42,9 +42,17 @@ 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
|
|
55
|
+
import shlex
|
|
48
56
|
|
|
49
57
|
TASK_PHASES = ("discovery", "implementation", "verification")
|
|
50
58
|
WRITE_PHASES = ("implementation", "verification")
|
|
@@ -54,12 +62,15 @@ def repo_root(cwd, base):
|
|
|
54
62
|
"""The git top-level at or above cwd and under base, else the first path
|
|
55
63
|
component below base. Walks up for a `.git` entry (dir or file: a worktree's
|
|
56
64
|
is a file); no subprocess. Same answer as tezgah_context.repo_root without
|
|
57
|
-
importing the context module into the gate's hot path
|
|
65
|
+
importing the context module into the gate's hot path; inside `.tezgah` the
|
|
66
|
+
project around it (`tezgah_paths.project_top`)."""
|
|
67
|
+
from tezgah_paths import project_top
|
|
58
68
|
cwd = os.path.realpath(cwd)
|
|
59
69
|
inside = os.path.realpath(base) if base else None
|
|
60
70
|
cur = cwd
|
|
61
71
|
while True:
|
|
62
72
|
if os.path.exists(os.path.join(cur, ".git")):
|
|
73
|
+
cur = project_top(cur)
|
|
63
74
|
if inside and _under(cur, inside):
|
|
64
75
|
return cur
|
|
65
76
|
break
|
|
@@ -157,6 +168,10 @@ _MARKER = re.compile(r"unverifiable(?![-\w])", re.I)
|
|
|
157
168
|
# names the word ("either the command or the word `unverifiable` followed by
|
|
158
169
|
# why") has no separator there and is not a declaration.
|
|
159
170
|
_SEPARATORS = ":,;(-"
|
|
171
|
+
# An unticked item the owner accepted as not met: `UNMET:` and the reason, upper
|
|
172
|
+
# case so prose that names the word is not a marker. `close NNN done` lets
|
|
173
|
+
# such an item through and refuses every other open box (open_items).
|
|
174
|
+
_UNMET = re.compile(r"(?<!\w)UNMET:\s*(\S.*)")
|
|
160
175
|
_SPAN = re.compile(r"`([^`\n]+)`")
|
|
161
176
|
# A runner whose test entry point is a bare subcommand: `pnpm test`, `cargo
|
|
162
177
|
# test`, `uv run pytest` carry no option and no path, yet each is the command.
|
|
@@ -269,6 +284,14 @@ def acceptance_gap(text):
|
|
|
269
284
|
return unproven(acceptance_items(text))
|
|
270
285
|
|
|
271
286
|
|
|
287
|
+
def open_items(text):
|
|
288
|
+
"""The plan's unticked Acceptance items that carry no `UNMET: <reason>`, as
|
|
289
|
+
(number counted from 1, item) pairs: what `close NNN done` refuses on. Plan
|
|
290
|
+
095 closed done with an open box because close read only the review."""
|
|
291
|
+
return [(n, item) for n, item in enumerate(acceptance_items(text), 1)
|
|
292
|
+
if not item["checked"] and not _UNMET.search(item["text"])]
|
|
293
|
+
|
|
294
|
+
|
|
272
295
|
# The `checkpoint:` values a plan can carry: a commit sha (the tree was clean at
|
|
273
296
|
# the phase move, so the boundary is already real), or `pending <sha>` while the
|
|
274
297
|
# tree still holds work no commit names - `<sha>` being the `HEAD` the commit the
|
|
@@ -307,13 +330,15 @@ def slug(path):
|
|
|
307
330
|
return name[:-3] if name.endswith(".md") else name
|
|
308
331
|
|
|
309
332
|
|
|
310
|
-
def checkpoint_command(path):
|
|
333
|
+
def checkpoint_command(path, worktree=None):
|
|
311
334
|
"""The one command that makes a plan's boundary real: a commit whose message
|
|
312
335
|
marks where the risky work starts. Named by the CLI's note and by the gate's
|
|
313
336
|
refusal - the same string, from the same caller-visible place - and running it
|
|
314
337
|
is also what clears the refusal: it moves HEAD off the sha the record wrote
|
|
315
|
-
(`checkpoint_sha`).
|
|
316
|
-
|
|
338
|
+
(`checkpoint_sha`). A plan held by its own worktree (`branch_task`) commits
|
|
339
|
+
there, so the command names that checkout."""
|
|
340
|
+
git = "git -C %s" % shlex.quote(worktree) if worktree else "git"
|
|
341
|
+
return '%s add -A && %s commit -m "checkpoint: before %s"' % (git, git, slug(path))
|
|
317
342
|
|
|
318
343
|
|
|
319
344
|
def spike_unanswered(text):
|
|
@@ -445,20 +470,53 @@ def parallel(rows):
|
|
|
445
470
|
return picked if len(picked) > 1 else []
|
|
446
471
|
|
|
447
472
|
|
|
448
|
-
def active(cwd, base):
|
|
449
|
-
"""The active task as a dict, or None.
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
473
|
+
def active(cwd, base, held=False):
|
|
474
|
+
"""The active task as a dict, or None. A linked worktree on a `plan/NNN-...`
|
|
475
|
+
branch answers plan NNN when that plan carries a phase (`branch_task`), so
|
|
476
|
+
two plans can run at once, one per worktree. Anywhere else - the main
|
|
477
|
+
checkout, or a worktree on another branch - it reads the main checkout's
|
|
478
|
+
`.tezgah/plans/open/*.md` sorted by name: the first plan carrying a valid
|
|
479
|
+
`phase` that no linked worktree holds (`plan_holders`). When every phased
|
|
480
|
+
plan is held there is none: a held plan's scope is its worktree's, and a
|
|
481
|
+
checkout outside it reading that scope held its writes to the wrong plan
|
|
482
|
+
(plan 097, D6). `held` answers the first phased plan then instead, marked
|
|
483
|
+
`"held": True`: the plan bin/tezgah-task's `phase`, `allow`, `stop` and
|
|
484
|
+
`status` act on from the main checkout, and the record the gate still
|
|
485
|
+
refuses a direct write to - while it holds no write to that plan's scope
|
|
486
|
+
outside its worktree. The context line never passes it.
|
|
487
|
+
Dict: {"id","title","phase","allowed_paths","checkpoint","branch","path"}:
|
|
488
|
+
id/title are the frontmatter values (id falls back to the filename's NNN),
|
|
489
|
+
checkpoint is the frontmatter value or None when the plan carries none,
|
|
490
|
+
path is the file.
|
|
454
491
|
Never raises: an unreadable directory or file means None (readers fail
|
|
455
492
|
open)."""
|
|
456
493
|
root = repo_root(cwd, base)
|
|
494
|
+
own = branch_task(root)
|
|
495
|
+
if own:
|
|
496
|
+
return own
|
|
497
|
+
from tezgah_paths import linked_main
|
|
498
|
+
main = linked_main(root) or root
|
|
499
|
+
records = _phased(main)
|
|
500
|
+
branches = _worktree_branches(main) if records else {}
|
|
501
|
+
for record in records:
|
|
502
|
+
if not any(_binds(record["id"], b) for b in branches):
|
|
503
|
+
return record
|
|
504
|
+
if not held or not records:
|
|
505
|
+
return None
|
|
506
|
+
records[0]["held"] = True
|
|
507
|
+
return records[0]
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _phased(root, ident=None):
|
|
511
|
+
"""Every open plan under `root` carrying a valid phase - plan `ident` only,
|
|
512
|
+
when one is named - as active()'s dicts, in name order; [] when there is
|
|
513
|
+
none or a plan file cannot be read."""
|
|
457
514
|
directory = os.path.join(root, ".tezgah", "plans", "open")
|
|
458
515
|
try:
|
|
459
516
|
names = sorted(os.listdir(directory))
|
|
460
517
|
except OSError:
|
|
461
|
-
return
|
|
518
|
+
return []
|
|
519
|
+
out = []
|
|
462
520
|
for name in names:
|
|
463
521
|
if not name.endswith(".md"):
|
|
464
522
|
continue
|
|
@@ -467,29 +525,158 @@ def active(cwd, base):
|
|
|
467
525
|
with open(path, "r", encoding="utf-8") as fh:
|
|
468
526
|
text = fh.read()
|
|
469
527
|
except OSError:
|
|
470
|
-
return
|
|
528
|
+
return []
|
|
471
529
|
fields = frontmatter(text)
|
|
472
530
|
if fields.get("phase") not in TASK_PHASES:
|
|
473
531
|
continue
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
532
|
+
ident_here = fields.get("id") or name.split("-")[0]
|
|
533
|
+
if ident is not None and ident_here != ident:
|
|
534
|
+
continue
|
|
535
|
+
out.append({"id": ident_here,
|
|
536
|
+
"title": fields.get("title", ""),
|
|
537
|
+
"phase": fields["phase"],
|
|
538
|
+
"allowed_paths": allowed_paths(text),
|
|
539
|
+
# read from the frontmatter already in hand, so the gate's
|
|
540
|
+
# checkpoint rule costs this module no second read of the plan
|
|
541
|
+
"checkpoint": fields.get("checkpoint"),
|
|
542
|
+
"branch": fields.get("branch", ""),
|
|
543
|
+
"path": path})
|
|
544
|
+
# A plan the repository shipped is data, not the user's task: its phase
|
|
545
|
+
# and allowlist would lock writes in a cloned repo (audit L-16). Only
|
|
546
|
+
# positive evidence turns the rule off - the index tracks `.tezgah` -
|
|
547
|
+
# never a can't-tell, which would switch the user's own guard off in
|
|
548
|
+
# silence (review R2); the answer is cached on the index's stat.
|
|
549
|
+
from tezgah_paths import workspace_tracked
|
|
550
|
+
if out and workspace_tracked(root):
|
|
551
|
+
return []
|
|
552
|
+
return out
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
_PLAN_BRANCH = re.compile(r"plan/(\d+)(?:-|$)")
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def worktree_branch(top):
|
|
559
|
+
"""The branch the linked worktree `top` checks out, read from its admin
|
|
560
|
+
`HEAD` without a fork (the gate asks on every write); None for a detached
|
|
561
|
+
HEAD, a plain checkout or anything unreadable."""
|
|
562
|
+
try:
|
|
563
|
+
with open(os.path.join(top, ".git"), encoding="utf-8") as fh:
|
|
564
|
+
admin = fh.readline().strip()[len("gitdir:"):].strip()
|
|
565
|
+
with open(os.path.join(top, admin, "HEAD"), encoding="utf-8") as fh:
|
|
566
|
+
head = fh.readline().strip()
|
|
567
|
+
except (OSError, UnicodeDecodeError):
|
|
568
|
+
return None
|
|
569
|
+
prefix = "ref: refs/heads/"
|
|
570
|
+
return head[len(prefix):] if head.startswith(prefix) else None
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def plan_of_branch(branch):
|
|
574
|
+
"""The plan id a `plan/NNN-slug` branch names, else None."""
|
|
575
|
+
found = _PLAN_BRANCH.match(branch or "")
|
|
576
|
+
return found.group(1) if found else None
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _binds(ident, branch):
|
|
580
|
+
"""Does `branch` hold plan `ident`? It has to name the plan: `plan/NNN-...`.
|
|
581
|
+
A plan's declared `branch:` is one of those, and the slices it fans out to
|
|
582
|
+
run on others (`plan/097-records` beside `plan/097-workspace-integrity`):
|
|
583
|
+
each slice is held to its plan's scope, never to another plan's, which is
|
|
584
|
+
what a first-unheld-plan reading gave it (plan 097, D6)."""
|
|
585
|
+
return bool(branch) and plan_of_branch(branch) == ident
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _worktree_branches(main):
|
|
589
|
+
"""{branch: top} for every linked worktree of the main checkout `main`."""
|
|
590
|
+
from tezgah_paths import worktrees
|
|
591
|
+
out = {}
|
|
592
|
+
for top in worktrees(main)[1:]:
|
|
593
|
+
branch = worktree_branch(top)
|
|
594
|
+
if branch:
|
|
595
|
+
out[branch] = top
|
|
596
|
+
return out
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def plan_holders(main):
|
|
600
|
+
"""{plan id: worktree top} for every open plan under the main checkout
|
|
601
|
+
`main` that a linked worktree on one of its branches (`_binds`) holds, phase
|
|
602
|
+
or not. Of several, the worktree on the plan's declared `branch:` holds it -
|
|
603
|
+
the checkout its checkpoint, review and diff are read in - else the first
|
|
604
|
+
by branch name."""
|
|
605
|
+
branches = _worktree_branches(main)
|
|
606
|
+
out = {}
|
|
607
|
+
if not branches:
|
|
608
|
+
return out
|
|
609
|
+
for name, _, text in _plan_files(main, "open"):
|
|
610
|
+
fields = frontmatter(text)
|
|
611
|
+
ident = _plan_id(fields, name)
|
|
612
|
+
held = sorted(b for b in branches if _binds(ident, b))
|
|
613
|
+
if held:
|
|
614
|
+
declared = fields.get("branch", "")
|
|
615
|
+
out[ident] = branches[declared if declared in held else held[0]]
|
|
616
|
+
return out
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def branch_task(top):
|
|
620
|
+
"""Plan NNN's record (active()'s dict, plus "worktree": `top`, the checkout
|
|
621
|
+
whose HEAD and tree its checkpoint is read against) when `top` is a linked
|
|
622
|
+
worktree on a `plan/NNN-...` branch (`_binds`) and plan NNN, open under the
|
|
623
|
+
main checkout, carries a phase. None otherwise - another branch, a plan with
|
|
624
|
+
no phase, an unknown mapping - and the caller reads the main checkout's task
|
|
625
|
+
(plan 093)."""
|
|
626
|
+
ident = plan_of_branch(worktree_branch(top))
|
|
627
|
+
if ident is None:
|
|
628
|
+
return None
|
|
629
|
+
from tezgah_paths import linked_main
|
|
630
|
+
main = linked_main(top)
|
|
631
|
+
for record in _phased(main, ident) if main else ():
|
|
632
|
+
record["worktree"] = top
|
|
633
|
+
return record
|
|
490
634
|
return None
|
|
491
635
|
|
|
492
636
|
|
|
637
|
+
def worktree_task(path, cwd, base):
|
|
638
|
+
"""The record a write to `path` is held to when it lands in a sibling linked
|
|
639
|
+
worktree on a plan branch (`branch_task`), else None. A path under cwd's own
|
|
640
|
+
root is active()'s to answer, so it pays one realpath and nothing more."""
|
|
641
|
+
root = repo_root(cwd, base)
|
|
642
|
+
path = os.path.expanduser(path)
|
|
643
|
+
real = os.path.realpath(path if os.path.isabs(path) else os.path.join(cwd, path))
|
|
644
|
+
if _under(real, root):
|
|
645
|
+
return None
|
|
646
|
+
top = _sibling_worktree(real, root)
|
|
647
|
+
return branch_task(top) if top else None
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def main_relative(path, cwd, base):
|
|
651
|
+
"""`path` (absolute or relative to cwd) as a posix path relative to the main
|
|
652
|
+
checkout of cwd's repository - the checkout that keeps `.tezgah/plans` -
|
|
653
|
+
or None outside it. A session in a linked worktree reads the same records
|
|
654
|
+
as one in the main checkout."""
|
|
655
|
+
from tezgah_paths import linked_main
|
|
656
|
+
root = repo_root(cwd, base)
|
|
657
|
+
main = linked_main(root) or root
|
|
658
|
+
path = os.path.expanduser(path)
|
|
659
|
+
real = os.path.realpath(path if os.path.isabs(path) else os.path.join(cwd, path))
|
|
660
|
+
if real == main or not _under(real, main):
|
|
661
|
+
return None
|
|
662
|
+
return real[len(main) + 1:].replace(os.sep, "/")
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def phased_record(root, rel):
|
|
666
|
+
"""True when `rel` (relative to the main checkout `root`) is an open plan
|
|
667
|
+
carrying a valid phase - a record only bin/tezgah-task writes, whichever
|
|
668
|
+
plan the main checkout reads as active. Matched without case, since a
|
|
669
|
+
case-insensitive filesystem opens `.TEZGAH/PLANS/open/x.md` as the record."""
|
|
670
|
+
parts = (rel or "").split("/")
|
|
671
|
+
if (len(parts) != 4 or "/".join(parts[:3]).casefold() != ".tezgah/plans/open"
|
|
672
|
+
or not parts[3].casefold().endswith(".md")):
|
|
673
|
+
return False
|
|
674
|
+
for name, _, text in _plan_files(root, "open"):
|
|
675
|
+
if name.casefold() == parts[3].casefold():
|
|
676
|
+
return frontmatter(text).get("phase") in TASK_PHASES
|
|
677
|
+
return False
|
|
678
|
+
|
|
679
|
+
|
|
493
680
|
def match(rel, pattern):
|
|
494
681
|
"""Glob match for one allowlist pattern against a repo-relative posix path.
|
|
495
682
|
`**` crosses separators, `*` does not, everything else is literal."""
|
|
@@ -513,29 +700,121 @@ def match(rel, pattern):
|
|
|
513
700
|
return re.fullmatch("".join(out), rel) is not None
|
|
514
701
|
|
|
515
702
|
|
|
516
|
-
def relative(path, cwd, base):
|
|
703
|
+
def relative(path, cwd, base, worktrees=False):
|
|
517
704
|
"""The realpath of `path` (absolute or relative to cwd) as a posix path
|
|
518
705
|
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).
|
|
706
|
+
(so an outside write can never be inside the allowlist).
|
|
707
|
+
|
|
708
|
+
With `worktrees`, a linked worktree of the same repository is that
|
|
709
|
+
repository, not outside it: the contract gives every writing slice its own
|
|
710
|
+
worktree, and an allowlist that could not name `hooks/a.py` there refused the
|
|
711
|
+
workflow it prescribes (three slices of plan 077 hit it). A path under such a
|
|
712
|
+
worktree answers relative to the worktree's top. Only the allowlist asks for
|
|
713
|
+
this (`hooks/tezgah_gate.py::task_reason`); the record and workspace rules
|
|
714
|
+
keep the main-root reading, so their decisions do not move. The test is
|
|
715
|
+
fork-free and only a path already outside the root pays for it; a worktree of
|
|
716
|
+
another repository is still outside."""
|
|
520
717
|
root = repo_root(cwd, base)
|
|
718
|
+
path = os.path.expanduser(path) # a host may spell it `~/...`
|
|
521
719
|
real = os.path.realpath(path if os.path.isabs(path) else os.path.join(cwd, path))
|
|
522
720
|
if real == root or not _under(real, root):
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
721
|
+
top = _sibling_worktree(real, root) if worktrees else None
|
|
722
|
+
if top is None or real == top:
|
|
723
|
+
# the root itself is not a path inside the root: an allowlist is
|
|
724
|
+
# about files under it, so this is a None like any other, and every
|
|
725
|
+
# caller fails in the refusing direction
|
|
726
|
+
return None
|
|
727
|
+
root = top
|
|
527
728
|
return real[len(root) + 1:].replace(os.sep, "/")
|
|
528
729
|
|
|
529
730
|
|
|
731
|
+
def _sibling_worktree(real, root):
|
|
732
|
+
"""The top of the linked worktree holding `real` when it is a checkout of the
|
|
733
|
+
repository `root` is a checkout of, else None.
|
|
734
|
+
|
|
735
|
+
The worktree's admin directory has to point back at it (`<admin>/gitdir` is
|
|
736
|
+
`<top>/.git`, which git always writes): a `.git` file planted anywhere that
|
|
737
|
+
names a real admin directory would otherwise make any directory a sibling."""
|
|
738
|
+
from tezgah_paths import linked_main, worktree_top
|
|
739
|
+
top = worktree_top(real)
|
|
740
|
+
if top is None:
|
|
741
|
+
return None
|
|
742
|
+
main = linked_main(root) or root
|
|
743
|
+
if linked_main(top) != os.path.realpath(main):
|
|
744
|
+
return None
|
|
745
|
+
try:
|
|
746
|
+
with open(os.path.join(top, ".git"), encoding="utf-8") as fh:
|
|
747
|
+
admin = fh.readline().strip()[len("gitdir:"):].strip()
|
|
748
|
+
admin = os.path.normpath(os.path.join(top, admin))
|
|
749
|
+
with open(os.path.join(admin, "gitdir"), encoding="utf-8") as fh:
|
|
750
|
+
back = fh.readline().strip()
|
|
751
|
+
except (OSError, UnicodeDecodeError):
|
|
752
|
+
return None
|
|
753
|
+
back = os.path.normpath(os.path.join(admin, back))
|
|
754
|
+
return top if os.path.realpath(back) == os.path.join(top, ".git") else None
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
@contextlib.contextmanager
|
|
758
|
+
def plans_lock(path):
|
|
759
|
+
"""Hold the exclusive lock every plan record writer takes, on the `plans/`
|
|
760
|
+
directory above the plan file `path`, across its whole read-modify-write:
|
|
761
|
+
two writers in two checkouts lost one write in 40 rounds without it (plan
|
|
762
|
+
097, E7). The directory itself is locked, so no lock file is left for the
|
|
763
|
+
private repository's `add -A` to commit (review of plan 097, F6). Raises
|
|
764
|
+
OSError when the directory cannot be opened."""
|
|
765
|
+
try:
|
|
766
|
+
import fcntl
|
|
767
|
+
except ImportError:
|
|
768
|
+
# ponytail: Windows has no fcntl, so writers run unlocked there; the
|
|
769
|
+
# rename in _write still keeps every read whole, but two concurrent
|
|
770
|
+
# writers can lose one write. msvcrt.locking is the fuller version.
|
|
771
|
+
fcntl = None
|
|
772
|
+
if fcntl is None:
|
|
773
|
+
yield
|
|
774
|
+
return
|
|
775
|
+
fd = os.open(os.path.dirname(os.path.dirname(os.path.realpath(path))), os.O_RDONLY)
|
|
776
|
+
try:
|
|
777
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
778
|
+
yield
|
|
779
|
+
finally:
|
|
780
|
+
os.close(fd) # closing the descriptor releases the lock
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _write(path, text):
|
|
784
|
+
"""Replace the file at `path` with `text` whole: written to a temp file
|
|
785
|
+
beside it, then renamed over it, so a reader sees the old record or the new
|
|
786
|
+
one and never a truncated one - which read as no frontmatter, so no active
|
|
787
|
+
task, and opened the gate (29 torn reads in 40, plan 097 E7). Keeps the
|
|
788
|
+
file's mode. Raises OSError."""
|
|
789
|
+
import tempfile
|
|
790
|
+
path = os.path.realpath(path)
|
|
791
|
+
folder, name = os.path.split(path)
|
|
792
|
+
fd, tmp = tempfile.mkstemp(prefix="." + name + ".", suffix=".tmp", dir=folder)
|
|
793
|
+
try:
|
|
794
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
795
|
+
fh.write(text)
|
|
796
|
+
os.chmod(tmp, os.stat(path).st_mode & 0o7777)
|
|
797
|
+
os.replace(tmp, path)
|
|
798
|
+
except BaseException:
|
|
799
|
+
with contextlib.suppress(OSError):
|
|
800
|
+
os.unlink(tmp)
|
|
801
|
+
raise
|
|
802
|
+
|
|
803
|
+
|
|
530
804
|
def set_fields(path, **fields):
|
|
531
|
-
"""Rewrite these frontmatter keys in the plan file
|
|
805
|
+
"""Rewrite these frontmatter keys in the plan file: replace the
|
|
532
806
|
`key: value` line when present, insert it before the closing `---` when not.
|
|
533
807
|
`allowed_paths` takes a list and is written as a `- item` block, replacing an
|
|
534
808
|
existing block. `updated` is not special-cased here: the CLI passes today's
|
|
535
809
|
date under it, as it does for a research line's `created:`, so this module
|
|
536
|
-
needs no clock of its own.
|
|
537
|
-
|
|
538
|
-
|
|
810
|
+
needs no clock of its own. Under the plans lock, written whole (`_write`).
|
|
811
|
+
Raises OSError."""
|
|
812
|
+
with plans_lock(path):
|
|
813
|
+
_set_fields(path, fields)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _set_fields(path, fields):
|
|
817
|
+
lines = _lines(path)
|
|
539
818
|
if not lines or lines[0].strip() != "---":
|
|
540
819
|
lines = ["---", "---"] + lines
|
|
541
820
|
close = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None)
|
|
@@ -556,8 +835,17 @@ def set_fields(path, **fields):
|
|
|
556
835
|
end += 1
|
|
557
836
|
block[head:end] = fresh
|
|
558
837
|
lines[1:close] = block
|
|
559
|
-
|
|
560
|
-
|
|
838
|
+
_write(path, "\n".join(lines))
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def close_record(path, target, **fields):
|
|
842
|
+
"""Write `fields` into the plan at `path` and move it to `target`, under
|
|
843
|
+
the plans lock: a tick racing the close then fails on the moved file instead
|
|
844
|
+
of writing it back into `open/`. Raises OSError."""
|
|
845
|
+
with plans_lock(path):
|
|
846
|
+
_set_fields(path, fields)
|
|
847
|
+
os.makedirs(os.path.dirname(target), exist_ok=True)
|
|
848
|
+
os.replace(path, target)
|
|
561
849
|
|
|
562
850
|
|
|
563
851
|
def _field_lines(key, value):
|
|
@@ -570,3 +858,876 @@ def _field_lines(key, value):
|
|
|
570
858
|
if value is None or value == "":
|
|
571
859
|
return [key + ":"]
|
|
572
860
|
return ["%s: %s" % (key, value)]
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _lines(path):
|
|
864
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
865
|
+
return fh.read().split("\n")
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def set_box(path, n, checked):
|
|
869
|
+
"""Set the `- [ ]`/`- [x]` box of Acceptance item `n` (1-based, as `tick`
|
|
870
|
+
counts) and return the item, or None when the plan has no item `n`. The
|
|
871
|
+
item's line is found under the plans lock: read before it, a frontmatter
|
|
872
|
+
line another writer inserted moved the box ticked (review of plan 097,
|
|
873
|
+
F5). Raises OSError."""
|
|
874
|
+
with plans_lock(path):
|
|
875
|
+
lines = _lines(path)
|
|
876
|
+
items = acceptance_items("\n".join(lines))
|
|
877
|
+
if not 1 <= n <= len(items):
|
|
878
|
+
return None
|
|
879
|
+
line = items[n - 1]["line"] - 1
|
|
880
|
+
lines[line] = re.sub(r"- \[[ xX]\]", "- [x]" if checked else "- [ ]",
|
|
881
|
+
lines[line], count=1)
|
|
882
|
+
_write(path, "\n".join(lines))
|
|
883
|
+
return items[n - 1]
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def set_section(path, heading, body):
|
|
887
|
+
"""Replace the body of the last `## <heading>` section - the newest, since
|
|
888
|
+
a plan appends a dated `## State (...)` - with `body` and one blank line,
|
|
889
|
+
under the plans lock. Returns False, writing nothing, when the plan has no
|
|
890
|
+
such section. Raises OSError."""
|
|
891
|
+
with plans_lock(path):
|
|
892
|
+
lines = _lines(path)
|
|
893
|
+
heads = [i for i, line in enumerate(lines)
|
|
894
|
+
if re.match(r"## %s(\s|$)" % re.escape(heading), line.strip())]
|
|
895
|
+
if not heads:
|
|
896
|
+
return False
|
|
897
|
+
start = heads[-1] + 1
|
|
898
|
+
end = next((i for i in range(start, len(lines))
|
|
899
|
+
if lines[i].lstrip().startswith("## ")), len(lines))
|
|
900
|
+
lines[start:end] = body.strip("\n").split("\n") + [""]
|
|
901
|
+
_write(path, "\n".join(lines))
|
|
902
|
+
return True
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
# ---------------------------------------------------------------- slice journal
|
|
906
|
+
#
|
|
907
|
+
# One row per delegated slice in `<main checkout>/.tezgah/slices/slices.db`,
|
|
908
|
+
# so a parent crash, an omp `/clear`, a quota stop or a model or host switch
|
|
909
|
+
# loses no slice: the record names the brief, the worktree and branch it works
|
|
910
|
+
# in, and how it ended. Written by the post-tool path on a spawn call
|
|
911
|
+
# (`note_spawn`) and by `tezgah-task slice`; read by the context line
|
|
912
|
+
# (`slice_lines`), the Stop rule and `tezgah-task slice reconcile|resume|stats`.
|
|
913
|
+
# Every reader fails open: an unreadable record is skipped, never a refusal.
|
|
914
|
+
# These functions shell out to git; none of them is on the gate's write path.
|
|
915
|
+
|
|
916
|
+
SLICE_KINDS = ("agent", "job")
|
|
917
|
+
SLICE_HOSTS = ("omp", "claude", "codex", "cursor", "opencode", "dsh")
|
|
918
|
+
# `stale`: no terminal status was observed within SLICE_IDLE_SECONDS - a host
|
|
919
|
+
# that reports none (Codex `spawn_agent` returns at once and tezgah sees no child
|
|
920
|
+
# event) or a child that died without writing its end. It leaves the pending
|
|
921
|
+
# list without being read as finished or as failed.
|
|
922
|
+
SLICE_STATUSES = ("open", "done", "stopped", "resumed", "stale")
|
|
923
|
+
SLICE_CLOSED = ("done", "stopped", "resumed", "stale")
|
|
924
|
+
SLICE_IDLE_SECONDS = 6 * 3600
|
|
925
|
+
FAILURES = ("quota", "rate_limit", "aborted", "timeout", "cut_off", "error")
|
|
926
|
+
SLICE_BRIEF = 4000
|
|
927
|
+
SLICE_LINE = 160
|
|
928
|
+
SLICE_TAIL = 64 * 1024
|
|
929
|
+
_SLICE_ID = re.compile(r"[^A-Za-z0-9._-]+")
|
|
930
|
+
# Failure classes, first match wins, in this order. The table moved here from
|
|
931
|
+
# the research line's measure (data/E2-subagent-stops/measure.py::CLASSES) and
|
|
932
|
+
# was narrowed to the classes a lever exists for (`tezgah-task slice stats`).
|
|
933
|
+
_CLASSES = (
|
|
934
|
+
("rate_limit", re.compile(r"\b429\b|rate[ _-]?limit", re.I)),
|
|
935
|
+
("quota", re.compile(r"quota|key limit exceeded|usage limit|"
|
|
936
|
+
r"\b40[23]\b.*\blimit|\blimit\b.*\b40[23]\b", re.I)),
|
|
937
|
+
("aborted", re.compile(r"abort|cancell?ed", re.I)),
|
|
938
|
+
("timeout", re.compile(r"timed out|timeout", re.I)),
|
|
939
|
+
("cut_off", re.compile(r"max[ _]turns|max_tokens|cut off|context[ _]length",
|
|
940
|
+
re.I)),
|
|
941
|
+
)
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _now():
|
|
945
|
+
import time
|
|
946
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _git(cwd, *args):
|
|
950
|
+
"""One git command's stdout in `cwd`, or None when git cannot answer."""
|
|
951
|
+
import subprocess
|
|
952
|
+
try:
|
|
953
|
+
out = subprocess.run(["git", "-C", cwd] + list(args), capture_output=True,
|
|
954
|
+
text=True, timeout=10)
|
|
955
|
+
except (OSError, subprocess.SubprocessError):
|
|
956
|
+
return None
|
|
957
|
+
return out.stdout.strip() if out.returncode == 0 else None
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _journal_root(root):
|
|
961
|
+
"""The main checkout of `root`'s repository: every worktree of one repository
|
|
962
|
+
writes one journal, so a slice opened from a worktree is still found from the
|
|
963
|
+
main checkout the parent session runs in."""
|
|
964
|
+
from tezgah_paths import linked_main
|
|
965
|
+
real = os.path.realpath(root)
|
|
966
|
+
return linked_main(real) or real
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def slice_dir(root):
|
|
970
|
+
"""`<main checkout>/.tezgah/slices`, the one directory the records live in."""
|
|
971
|
+
return os.path.join(_journal_root(root), ".tezgah", "slices")
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _digest(text):
|
|
975
|
+
import hashlib
|
|
976
|
+
return hashlib.sha1(text.encode("utf-8", "replace")).hexdigest()[:12]
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def slice_id(name, brief=""):
|
|
980
|
+
"""A record id: `name` cut to [A-Za-z0-9._-] and 80 characters, else the
|
|
981
|
+
first 12 hex digits of the brief's sha1 - so a re-dispatch of the same brief
|
|
982
|
+
re-opens the same record and counts an attempt."""
|
|
983
|
+
sid = _SLICE_ID.sub("-", str(name or "")).strip("-.")[:80]
|
|
984
|
+
return sid or _digest(brief)
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
# The journal is one SQLite file opened like the taste store
|
|
988
|
+
# (`tezgah_store.connect`: WAL, a busy timeout, 0600). One JSON file per record
|
|
989
|
+
# piled up a file per subagent in every repository (owner, 2026-10-11). `row`
|
|
990
|
+
# is the record as JSON; `parent`, `status` and `updated` are the columns a
|
|
991
|
+
# reader filters and sorts on, and `seq` orders two writes in one second.
|
|
992
|
+
SLICE_DB = "slices.db"
|
|
993
|
+
SLICE_SCHEMA = """
|
|
994
|
+
CREATE TABLE IF NOT EXISTS slices (id TEXT PRIMARY KEY, parent TEXT NOT NULL,
|
|
995
|
+
status TEXT NOT NULL, updated TEXT NOT NULL,
|
|
996
|
+
seq INTEGER NOT NULL, row TEXT NOT NULL);
|
|
997
|
+
"""
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
@contextlib.contextmanager
|
|
1001
|
+
def _journal(root, create=True):
|
|
1002
|
+
"""The journal database of `root`'s repository, the `*.json` records an
|
|
1003
|
+
older release wrote imported first (`_import_json`). With `create` False it
|
|
1004
|
+
yields None where neither the database nor an old record exists, so a read
|
|
1005
|
+
leaves no file behind. The directory carries a `.gitignore` of `*`: the
|
|
1006
|
+
records are runtime state, never part of the private `.tezgah` history."""
|
|
1007
|
+
import glob
|
|
1008
|
+
import tezgah_store
|
|
1009
|
+
folder = slice_dir(root)
|
|
1010
|
+
path = os.path.join(folder, SLICE_DB)
|
|
1011
|
+
if not create and not os.path.exists(path) and not glob.glob(
|
|
1012
|
+
os.path.join(folder, "*.json")):
|
|
1013
|
+
yield None
|
|
1014
|
+
return
|
|
1015
|
+
os.makedirs(folder, mode=0o700, exist_ok=True)
|
|
1016
|
+
ignore = os.path.join(folder, ".gitignore")
|
|
1017
|
+
if not os.path.exists(ignore):
|
|
1018
|
+
with open(ignore, "w", encoding="utf-8") as fh:
|
|
1019
|
+
fh.write("*\n")
|
|
1020
|
+
conn = tezgah_store.connect(path, SLICE_SCHEMA, version=1)
|
|
1021
|
+
try:
|
|
1022
|
+
_import_json(conn, folder)
|
|
1023
|
+
yield conn
|
|
1024
|
+
finally:
|
|
1025
|
+
conn.close()
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def _import_json(conn, folder):
|
|
1029
|
+
"""Bring the one-file-per-record journal into the table, then delete each
|
|
1030
|
+
file it took. A file whose `updated` is newer than the row replaces it - an
|
|
1031
|
+
older release still running writes files beside the database - and an
|
|
1032
|
+
older one leaves the row alone. A file that does not parse, or whose id is
|
|
1033
|
+
not a string, stays where it is. With no old file, one glob."""
|
|
1034
|
+
import glob
|
|
1035
|
+
import json
|
|
1036
|
+
for path in glob.glob(os.path.join(folder, "*.json")):
|
|
1037
|
+
try:
|
|
1038
|
+
with open(path, encoding="utf-8") as fh:
|
|
1039
|
+
rec = json.load(fh)
|
|
1040
|
+
except (OSError, ValueError):
|
|
1041
|
+
continue
|
|
1042
|
+
if not isinstance(rec, dict) or not isinstance(rec.get("id"), str) \
|
|
1043
|
+
or not rec["id"]:
|
|
1044
|
+
continue
|
|
1045
|
+
held = conn.execute("SELECT updated FROM slices WHERE id = ?",
|
|
1046
|
+
(rec["id"],)).fetchone()
|
|
1047
|
+
if held is None or str(rec.get("updated") or "") > held[0]:
|
|
1048
|
+
_put(conn, rec)
|
|
1049
|
+
try:
|
|
1050
|
+
os.remove(path)
|
|
1051
|
+
except OSError:
|
|
1052
|
+
pass
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _put(conn, rec):
|
|
1056
|
+
"""Upsert one record."""
|
|
1057
|
+
import json
|
|
1058
|
+
conn.execute(
|
|
1059
|
+
"INSERT OR REPLACE INTO slices (id, parent, status, updated, seq, row) "
|
|
1060
|
+
"VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(seq), 0) + 1 FROM slices), ?)",
|
|
1061
|
+
(rec["id"], str(rec.get("parent") or ""), str(rec.get("status") or ""),
|
|
1062
|
+
str(rec.get("updated") or ""), json.dumps(rec, sort_keys=True)))
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def _row(text):
|
|
1066
|
+
import json
|
|
1067
|
+
try:
|
|
1068
|
+
rec = json.loads(text)
|
|
1069
|
+
except ValueError:
|
|
1070
|
+
return None
|
|
1071
|
+
return rec if isinstance(rec, dict) and rec.get("id") else None
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def _read_record(root, sid):
|
|
1075
|
+
"""The record `sid`, or None; a journal that cannot be opened reads as none."""
|
|
1076
|
+
import tezgah_store
|
|
1077
|
+
try:
|
|
1078
|
+
with _journal(root, create=False) as db:
|
|
1079
|
+
hit = db and db.execute("SELECT row FROM slices WHERE id = ?",
|
|
1080
|
+
(slice_id(sid),)).fetchone()
|
|
1081
|
+
except tezgah_store.ERRORS:
|
|
1082
|
+
return None
|
|
1083
|
+
return _row(hit[0]) if hit else None
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def _write_record(root, rec):
|
|
1087
|
+
"""Write one record (one upsert statement) and return it. A database that
|
|
1088
|
+
cannot be opened or written raises OSError, the error every caller already
|
|
1089
|
+
handles (`tezgah-task slice` prints it, the hooks swallow it)."""
|
|
1090
|
+
import tezgah_store
|
|
1091
|
+
try:
|
|
1092
|
+
with _journal(root) as db:
|
|
1093
|
+
_put(db, rec)
|
|
1094
|
+
except tezgah_store.ERRORS as exc:
|
|
1095
|
+
raise OSError("slice journal %s: %s" % (slice_dir(root), exc)) from exc
|
|
1096
|
+
return rec
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def _own_id(root, base, brief, parent):
|
|
1100
|
+
"""The record id a spawn of `base` opens: `base` itself, unless another
|
|
1101
|
+
session holds that record under another brief. Then it is this session's
|
|
1102
|
+
own record of that name, else `base-<digest6>` of the brief, so another
|
|
1103
|
+
session's stopped slice stays its owner's (research orchestrator-flow-audit
|
|
1104
|
+
S7). A same-brief re-dispatch from another session still re-opens `base`."""
|
|
1105
|
+
held = _read_record(root, base)
|
|
1106
|
+
if (held is None or not parent or not brief
|
|
1107
|
+
or held.get("parent") in ("", None, parent)
|
|
1108
|
+
or held.get("brief") == brief):
|
|
1109
|
+
return base
|
|
1110
|
+
own = next((r["id"] for r in slices(root) if r.get("parent") == parent
|
|
1111
|
+
and (r.get("name") or r["id"]) == base), None)
|
|
1112
|
+
return own or "%s-%s" % (base, _digest(brief)[:6])
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def find_slice(root, ref, session=None):
|
|
1116
|
+
"""The record `ref` names for `tezgah-task slice done|stop|resume`. An
|
|
1117
|
+
exact id answers whatever session owns it: an id is unambiguous, and it is
|
|
1118
|
+
what `slice reconcile` and the review guard print, so a crashed session's
|
|
1119
|
+
slice stays reachable. A bare spawned `name` is a lookup: with `session`
|
|
1120
|
+
(TEZGAH_SESSION), the newest record of that name the session spawned or no
|
|
1121
|
+
session owns; without one, the one record of that name. None when nothing
|
|
1122
|
+
answers."""
|
|
1123
|
+
ref = slice_id(ref)
|
|
1124
|
+
recs = slices(root)
|
|
1125
|
+
exact = next((r for r in recs if r["id"] == ref), None)
|
|
1126
|
+
if exact or session:
|
|
1127
|
+
return exact or next((r for r in recs if r.get("name") == ref
|
|
1128
|
+
and r.get("parent") in (session, "", None)), None)
|
|
1129
|
+
named = [r for r in recs if r.get("name") == ref]
|
|
1130
|
+
return named[0] if len(named) == 1 else None
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def _worktree_in(root, *texts):
|
|
1134
|
+
"""The linked worktree of this repository the first of `texts` that names
|
|
1135
|
+
one names, else "": a slice's own task before the batch's shared context
|
|
1136
|
+
(which may list every slice's worktree), the one named last in a text, and
|
|
1137
|
+
on a tie the longer path, since `.../a` is found inside `.../a-b` at the
|
|
1138
|
+
same place. What reconcile, resume, the board and merge-plan read."""
|
|
1139
|
+
from tezgah_paths import worktrees
|
|
1140
|
+
trees = worktrees(root)[1:]
|
|
1141
|
+
for text in texts:
|
|
1142
|
+
found = [p for p in trees if text and p in text]
|
|
1143
|
+
if found:
|
|
1144
|
+
return max(found, key=lambda p: (text.rfind(p), len(p)))
|
|
1145
|
+
return ""
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _named_worktree(root, *texts):
|
|
1149
|
+
"""(worktree, branch) for the linked worktree `_worktree_in` reads off
|
|
1150
|
+
`texts`, else ("", "")."""
|
|
1151
|
+
top = _worktree_in(root, *texts)
|
|
1152
|
+
return (top, _git(top, "symbolic-ref", "-q", "--short", "HEAD") or "") if top else ("", "")
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
# Where a brief says its slice works: a `Worktree:`, `Checkout` or `Cwd:` line
|
|
1156
|
+
# opening with an absolute path. A path merely mentioned - a worktree to remove,
|
|
1157
|
+
# a sibling's tree, a file to read - is no declaration (review of S6, F2). Only
|
|
1158
|
+
# the main-checkout refusal reads it (`tezgah_gate.lease_reason`).
|
|
1159
|
+
_DECLARED = re.compile(r"(?im)^[ \t>#*_-]*(?:worktree|checkout|cwd)\b[*_]*[ \t]*:?"
|
|
1160
|
+
r"[*_]*[ \t]*`?(/[^\s`'\"()<>,;]+)")
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def _declared_in(root, *texts):
|
|
1164
|
+
"""The linked worktree of this repository the first of `texts` that
|
|
1165
|
+
declares a checkout of it declares, else "": a slice's own task before the
|
|
1166
|
+
batch's shared context, the last declaration in a text. A declared main
|
|
1167
|
+
checkout is none - an integrator works there."""
|
|
1168
|
+
from tezgah_paths import worktrees
|
|
1169
|
+
trees = worktrees(root)
|
|
1170
|
+
for text in texts:
|
|
1171
|
+
found = [os.path.realpath(p.rstrip("/.:")) for p in _DECLARED.findall(text or "")]
|
|
1172
|
+
found = [p for p in found if p in trees]
|
|
1173
|
+
if found:
|
|
1174
|
+
return found[-1] if found[-1] != trees[0] else ""
|
|
1175
|
+
return ""
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
# `Owns:` in a slice's own task: the repository-relative globs only that slice
|
|
1179
|
+
# writes while its record is open (research orchestrator-flow-audit S6, pick
|
|
1180
|
+
# V2). The gate refuses a sibling's write into them (`tezgah_gate.lease_reason`).
|
|
1181
|
+
# The globs follow on the line, split on commas and spaces, or - with nothing
|
|
1182
|
+
# after the colon - as the `- glob` list items under it. Markdown emphasis and
|
|
1183
|
+
# backticks are dropped; an absolute glob under the slice's checkout is cut to it.
|
|
1184
|
+
_OWNS = re.compile(r"(?i)^[ \t>#*_-]*owns[*_]*[ \t]*:[*_]*[ \t]*(.*)$")
|
|
1185
|
+
_LIST_ITEM = re.compile(r"^[ \t>]*(?:[-*+]|\d+[.)])[ \t]+(.+)$")
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def owned_globs(text, tops=()):
|
|
1189
|
+
"""The `Owns:` globs `text` names, in order, each once."""
|
|
1190
|
+
found, lines = [], (text or "").splitlines()
|
|
1191
|
+
for i, line in enumerate(lines):
|
|
1192
|
+
hit = _OWNS.match(line)
|
|
1193
|
+
if not hit:
|
|
1194
|
+
continue
|
|
1195
|
+
if hit.group(1).strip():
|
|
1196
|
+
found.append(hit.group(1))
|
|
1197
|
+
continue
|
|
1198
|
+
for item in lines[i + 1:]:
|
|
1199
|
+
listed = _LIST_ITEM.match(item)
|
|
1200
|
+
if not listed:
|
|
1201
|
+
break
|
|
1202
|
+
found.append(listed.group(1))
|
|
1203
|
+
out = []
|
|
1204
|
+
for line in found:
|
|
1205
|
+
for glob in re.split(r"[\s,]+", line.replace("`", "")):
|
|
1206
|
+
glob = glob.rstrip(".;")
|
|
1207
|
+
top = next((t for t in tops if t and glob.startswith(t + "/")), "")
|
|
1208
|
+
glob = glob[len(top) + 1:] if top else glob
|
|
1209
|
+
if glob and glob not in out:
|
|
1210
|
+
out.append(glob)
|
|
1211
|
+
return out
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def slice_of(root, parent, name):
|
|
1215
|
+
"""The newest record `parent` spawned under `name` - a subagent's own
|
|
1216
|
+
record, read from its `spawned` row (`tezgah_integrity.spawn_of`), or from
|
|
1217
|
+
Claude's `agent_id`, which a background spawn stores as `child` - else
|
|
1218
|
+
None. An exact name or child first; omp names a re-dispatch `<name>-2`, so
|
|
1219
|
+
the bare name answers only after that."""
|
|
1220
|
+
if not (parent and name):
|
|
1221
|
+
return None
|
|
1222
|
+
mine = [r for r in slices(root) if r.get("parent") == parent]
|
|
1223
|
+
base = re.sub(r"-\d+$", "", name)
|
|
1224
|
+
return (next((r for r in mine if (r.get("name") or r["id"]) == name
|
|
1225
|
+
or r.get("child") == name), None)
|
|
1226
|
+
or next((r for r in mine if (r.get("name") or r["id"]) == base), None))
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
def owner_of(root, path, parent, me):
|
|
1230
|
+
"""The open record of another writing slice `parent` spawned whose `owns`
|
|
1231
|
+
matches the repository-relative `path`, else None; `me` is the writing
|
|
1232
|
+
slice's id. A reader - an agent named for a read-only role - holds no
|
|
1233
|
+
lease, whatever `Owns:` line its task quotes. A hit re-reads after
|
|
1234
|
+
`pending`, which closes an omp sibling whose child transcript ended, so a
|
|
1235
|
+
finished owner frees its files."""
|
|
1236
|
+
def held():
|
|
1237
|
+
return next((r for r in slices(root) if parent and r.get("parent") == parent
|
|
1238
|
+
and r.get("status") == "open" and r["id"] != me
|
|
1239
|
+
and not _READ_ONLY_AGENT.search(r.get("agent") or "")
|
|
1240
|
+
and any(match(path, g) for g in r.get("owns") or ())), None)
|
|
1241
|
+
if held() is None:
|
|
1242
|
+
return None
|
|
1243
|
+
pending(root)
|
|
1244
|
+
return held()
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
def close_child(root, parent, child):
|
|
1248
|
+
"""Close as done the open record `parent` spawned whose `child` is the
|
|
1249
|
+
host's id of a background child that just ended (Claude's SubagentStop),
|
|
1250
|
+
and return it; None when no record holds it."""
|
|
1251
|
+
rec = next((r for r in slices(root) if parent and child and r.get("parent") == parent
|
|
1252
|
+
and r.get("child") == child and r.get("status") == "open"), None)
|
|
1253
|
+
return close_slice(root, rec["id"], "done") if rec else None
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
BOARD_LINES = 12
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
def board(root, parent=None, limit=BOARD_LINES):
|
|
1260
|
+
"""One bounded line (SLICE_LINE) per open record `parent` spawned - every
|
|
1261
|
+
open record without one - newest first, at most `limit` and a count of the
|
|
1262
|
+
rest: what each slice owns, where it works and its last commit. A pull
|
|
1263
|
+
read: siblings see each other's leases here instead of by message."""
|
|
1264
|
+
recs = [r for r in pending(root) if r.get("status") == "open"
|
|
1265
|
+
and r.get("kind") != "job" and (parent is None or r.get("parent") == parent)]
|
|
1266
|
+
out = []
|
|
1267
|
+
for rec in recs[:limit]:
|
|
1268
|
+
tree = rec.get("worktree") or ""
|
|
1269
|
+
head = _git(tree, "log", "-1", "--format=%h") if tree and os.path.isdir(tree) else None
|
|
1270
|
+
out.append(_cut("- %s (%s/%s) owns %s; worktree %s on %s @ %s" % (
|
|
1271
|
+
rec.get("name") or rec["id"], rec.get("host") or "?", rec.get("agent") or "?",
|
|
1272
|
+
", ".join(rec.get("owns") or ()) or "nothing declared", tree or "none",
|
|
1273
|
+
rec.get("branch") or "?", head or "?")))
|
|
1274
|
+
if len(recs) > limit:
|
|
1275
|
+
out.append("- %d more open slice(s)" % (len(recs) - limit))
|
|
1276
|
+
return out
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
# An agent whose name reads as a read-only role shares a worktree freely: a
|
|
1280
|
+
# reviewer panel on one slice's checkout is the allowed case (research
|
|
1281
|
+
# orchestrator-flow-audit S6, finding 5). A brief with `Owns:` writes whatever
|
|
1282
|
+
# its agent is called.
|
|
1283
|
+
_READ_ONLY_AGENT = re.compile(r"review|explor|research|scout|audit|critic|judge", re.I)
|
|
1284
|
+
SHARED_WORKTREE = (
|
|
1285
|
+
"tezgah: writing slices %s all name worktree %s. Every writing slice gets its own "
|
|
1286
|
+
"branch and checkout (`orca worktree create` inside Orca, else `git worktree "
|
|
1287
|
+
"add`): two slices in one checkout overwrite each other's edits.")
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
def _writing(agent, owns):
|
|
1291
|
+
return bool(owns) or not _READ_ONLY_AGENT.search(agent or "")
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
def _writes(rec):
|
|
1295
|
+
"""A writing record: its own task's `Owns:`, else an agent not named for a
|
|
1296
|
+
read-only role. A reviewer whose lease came from a shared brief is not one."""
|
|
1297
|
+
return _writing(rec.get("agent"), owned_globs(rec.get("task") or ""))
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def worktree_clash(root, tool, inp, parent=""):
|
|
1301
|
+
"""SHARED_WORKTREE when two writing slices - of this spawn call, or this
|
|
1302
|
+
call and an open record `parent` spawned - name one worktree, else None.
|
|
1303
|
+
Each task's own text decides its worktree; one that names none takes the
|
|
1304
|
+
shared context's, which is how ten slices landed in one checkout."""
|
|
1305
|
+
main = _journal_root(root)
|
|
1306
|
+
seen = {}
|
|
1307
|
+
for rec in slices(root):
|
|
1308
|
+
if parent and rec.get("parent") == parent and rec.get("status") == "open" \
|
|
1309
|
+
and rec.get("worktree") and _writes(rec):
|
|
1310
|
+
seen.setdefault(rec["worktree"], set()).add(rec.get("name") or rec["id"])
|
|
1311
|
+
context = str(inp.get("context") or "").strip() if isinstance(inp, dict) else ""
|
|
1312
|
+
for sid, agent, brief in spawned(tool, inp):
|
|
1313
|
+
task = brief[len(context):] if context else brief
|
|
1314
|
+
tree = _worktree_in(main, task, brief)
|
|
1315
|
+
if tree and _writing(agent, owned_globs(task)):
|
|
1316
|
+
seen.setdefault(tree, set()).add(sid)
|
|
1317
|
+
clash = sorted((t, sorted(n)) for t, n in seen.items() if len(n) > 1)
|
|
1318
|
+
return SHARED_WORKTREE % (", ".join(clash[0][1]), clash[0][0]) if clash else None
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
# `plan 093` in a brief: the plan a slice works on when its checkout says none
|
|
1322
|
+
_BRIEF_PLAN = re.compile(r"(?i)\bplan\s+(\d{3})\b")
|
|
1323
|
+
|
|
1324
|
+
|
|
1325
|
+
def open_slice(root, sid, *, kind="agent", host="", agent="", brief="", parent="",
|
|
1326
|
+
cwd="", repo="", task="", child=""):
|
|
1327
|
+
"""Open (or re-open) the record `sid` and return it. The worktree and branch
|
|
1328
|
+
are the ones the slice's own task names (else the brief, `_worktree_in`);
|
|
1329
|
+
`declared` is the worktree a `Worktree:`/`Checkout`/`Cwd:` line declares
|
|
1330
|
+
(`_declared_in`); the base is the main checkout's HEAD, and `owns` holds the
|
|
1331
|
+
`Owns:` globs of the task (else the brief, `owned_globs`). A re-open
|
|
1332
|
+
of an existing id counts one more attempt, sets the status back to open and
|
|
1333
|
+
clears the failure; a field the new call leaves empty keeps its old value.
|
|
1334
|
+
A record another session holds under another brief is not re-opened: the
|
|
1335
|
+
spawn gets its own id (`_own_id`), and `name` keeps the spawned name.
|
|
1336
|
+
`repo` names the repository the slice works in when that is not this
|
|
1337
|
+
journal's (`_brief_repo`); `child` is the host's id for the running child
|
|
1338
|
+
(Claude's background `agentId`), the key its own writes carry."""
|
|
1339
|
+
base = slice_id(sid, brief)
|
|
1340
|
+
main = _journal_root(root)
|
|
1341
|
+
own = task or brief or ""
|
|
1342
|
+
brief = (brief or "")[:SLICE_BRIEF]
|
|
1343
|
+
now = _now()
|
|
1344
|
+
sid = _own_id(root, base, brief, parent)
|
|
1345
|
+
rec = _read_record(root, sid)
|
|
1346
|
+
if rec is None:
|
|
1347
|
+
rec = {"id": sid, "name": base, "kind": kind if kind in SLICE_KINDS else "agent",
|
|
1348
|
+
"host": host, "agent": agent, "brief": brief, "parent": parent,
|
|
1349
|
+
"cwd": cwd, "repo": repo, "worktree": "", "branch": "", "attempts": 1,
|
|
1350
|
+
"opened": now, "child": child}
|
|
1351
|
+
else:
|
|
1352
|
+
rec["attempts"] = int(rec.get("attempts") or 0) + 1
|
|
1353
|
+
if parent and parent != rec.get("parent"):
|
|
1354
|
+
# a re-dispatch from another session (after a /clear, a crash, a
|
|
1355
|
+
# resume) writes its child under that session's directory: the old
|
|
1356
|
+
# one holds the first run, whose end must not close this record
|
|
1357
|
+
rec["transcript_dir"] = ""
|
|
1358
|
+
for key, value in (("host", host), ("agent", agent), ("brief", brief),
|
|
1359
|
+
("parent", parent), ("cwd", cwd), ("repo", repo),
|
|
1360
|
+
("child", child)):
|
|
1361
|
+
if value:
|
|
1362
|
+
rec[key] = value
|
|
1363
|
+
if task: # the slice's own task, without a batch's shared context
|
|
1364
|
+
rec["task"] = task[:SLICE_BRIEF]
|
|
1365
|
+
if brief or not rec.get("worktree"):
|
|
1366
|
+
rec["worktree"], rec["branch"] = _named_worktree(main, task, rec.get("brief"))
|
|
1367
|
+
rec["declared"] = _declared_in(main, task, rec.get("brief"))
|
|
1368
|
+
if own:
|
|
1369
|
+
rec["owns"] = owned_globs(own, (rec["worktree"], main))
|
|
1370
|
+
if rec.get("host") == "omp" and rec.get("parent") and not rec.get("transcript_dir"):
|
|
1371
|
+
# one glob over every omp project at open, so `pending` - run on every
|
|
1372
|
+
# prompt and Stop - never globs (40 ms on 1393 project dirs, measured)
|
|
1373
|
+
rec["transcript_dir"] = _omp_session_dir(rec["parent"]) or ""
|
|
1374
|
+
# the slice's own plan: the review guard reads its own plan's records only.
|
|
1375
|
+
# Its worktree's plan branch, else the plan its task or shared brief names,
|
|
1376
|
+
# else the tag a re-open already carries, else the task the main checkout
|
|
1377
|
+
# reads - with two plans running that is only one of them
|
|
1378
|
+
named = _BRIEF_PLAN.search(own) or _BRIEF_PLAN.search(brief or "")
|
|
1379
|
+
rec["plan"] = (plan_of_branch(rec.get("branch")) or (named and named.group(1))
|
|
1380
|
+
or rec.get("plan") or (active(main, main) or {}).get("id") or "")
|
|
1381
|
+
rec.update(base=_git(main, "rev-parse", "HEAD") or "", status="open",
|
|
1382
|
+
failure="", updated=now)
|
|
1383
|
+
return _write_record(root, rec)
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def close_slice(root, sid, status, failure="", stop=None):
|
|
1387
|
+
"""Close the record `sid` as done, stopped or resumed; None when it has none.
|
|
1388
|
+
A stopped record takes `failure` (a FAILURES class or ""); done and resumed
|
|
1389
|
+
keep the class the slice stopped with, so the stats still count the event.
|
|
1390
|
+
A quota or rate-limit stop whose message `stop` ({errorMessage, provider,
|
|
1391
|
+
model, default}) names its family holds that family down
|
|
1392
|
+
(`tezgah_models.note_limit`; a rate limit briefly);
|
|
1393
|
+
that push fails open."""
|
|
1394
|
+
if status not in SLICE_CLOSED:
|
|
1395
|
+
raise ValueError("status %r is not one of %s" % (status, ", ".join(SLICE_CLOSED)))
|
|
1396
|
+
rec = _read_record(root, sid)
|
|
1397
|
+
if rec is None:
|
|
1398
|
+
return None
|
|
1399
|
+
if status == "stopped" or failure:
|
|
1400
|
+
rec["failure"] = failure if failure in FAILURES else ""
|
|
1401
|
+
rec.update(status=status, updated=_now())
|
|
1402
|
+
if failure in ("quota", "rate_limit") and isinstance(stop, dict):
|
|
1403
|
+
try:
|
|
1404
|
+
import tezgah_models
|
|
1405
|
+
tezgah_models.note_limit(
|
|
1406
|
+
stop.get("errorMessage") or "", stop.get("provider") or "",
|
|
1407
|
+
stop.get("model") or "", transient=failure == "rate_limit",
|
|
1408
|
+
default=stop.get("default") or "")
|
|
1409
|
+
except Exception: # noqa: BLE001 - health is advice: the close still lands
|
|
1410
|
+
pass
|
|
1411
|
+
return _write_record(root, rec)
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
def slices(root):
|
|
1415
|
+
"""Every readable record, newest `updated` first (the later write first on a
|
|
1416
|
+
tie); a row that does not parse, or a journal that cannot be opened, is
|
|
1417
|
+
skipped - readers fail open."""
|
|
1418
|
+
import tezgah_store
|
|
1419
|
+
try:
|
|
1420
|
+
with _journal(root, create=False) as db:
|
|
1421
|
+
rows = db.execute("SELECT row FROM slices ORDER BY updated DESC, "
|
|
1422
|
+
"seq DESC").fetchall() if db else []
|
|
1423
|
+
except tezgah_store.ERRORS:
|
|
1424
|
+
return []
|
|
1425
|
+
return [rec for rec in (_row(r[0]) for r in rows) if rec]
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
def classify(text):
|
|
1429
|
+
"""The failure class of an error or result text: rate_limit, quota, aborted,
|
|
1430
|
+
timeout, cut_off, else error for any other non-empty text, "" for none."""
|
|
1431
|
+
text = str(text or "").strip()
|
|
1432
|
+
if not text:
|
|
1433
|
+
return ""
|
|
1434
|
+
for name, pattern in _CLASSES:
|
|
1435
|
+
if pattern.search(text):
|
|
1436
|
+
return name
|
|
1437
|
+
return "error"
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def _brief(*parts):
|
|
1441
|
+
return "\n\n".join(str(p).strip() for p in parts if str(p or "").strip())
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
def spawned(tool, inp):
|
|
1445
|
+
"""[(id, agent, brief)] for one spawn call's input, [] for any other tool.
|
|
1446
|
+
omp `task` carries a batch ({tasks: [{name, agent, task}], context}, brief =
|
|
1447
|
+
context + task); Claude, dsh and Cursor `Agent`/`Task` and opencode `task`
|
|
1448
|
+
carry one ({description, prompt, subagent_type}); Codex `spawn_agent` carries
|
|
1449
|
+
{task_name, message|prompt, agent_type|agent} - its `task_name` is the id the
|
|
1450
|
+
user addresses the child by."""
|
|
1451
|
+
if not isinstance(inp, dict):
|
|
1452
|
+
return []
|
|
1453
|
+
name = str(tool or "").lower()
|
|
1454
|
+
out = []
|
|
1455
|
+
if name == "task" and isinstance(inp.get("tasks"), list):
|
|
1456
|
+
context = inp.get("context") or ""
|
|
1457
|
+
for item in inp["tasks"]:
|
|
1458
|
+
if not isinstance(item, dict):
|
|
1459
|
+
continue
|
|
1460
|
+
brief = _brief(context, item.get("task") or "")
|
|
1461
|
+
out.append((slice_id(item.get("name"), brief), str(item.get("agent") or ""),
|
|
1462
|
+
brief))
|
|
1463
|
+
elif name in ("task", "agent"):
|
|
1464
|
+
brief = _brief(inp.get("description") or "", inp.get("prompt") or "")
|
|
1465
|
+
out.append((slice_id(inp.get("name"), brief),
|
|
1466
|
+
str(inp.get("subagent_type") or ""), brief))
|
|
1467
|
+
elif name == "spawn_agent":
|
|
1468
|
+
brief = str(inp.get("message") or inp.get("prompt") or "")
|
|
1469
|
+
out.append((slice_id(inp.get("task_name") or inp.get("name"), brief),
|
|
1470
|
+
str(inp.get("agent_type") or inp.get("agent") or ""), brief))
|
|
1471
|
+
return [row for row in out if row[2]]
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
def note_spawn(cwd, session_id, host, tool, inp, failed=None, result_text="",
|
|
1475
|
+
child=""):
|
|
1476
|
+
"""Open one record per slice a spawn call started, and return them. A host
|
|
1477
|
+
that runs the subagent synchronously (Claude's PostToolUse on Agent/Task)
|
|
1478
|
+
passes `failed` as a bool and the record closes at once: stopped with the
|
|
1479
|
+
class of `result_text` (error when it names none), else done. No-op ([])
|
|
1480
|
+
under `slices-off` or outside a git checkout. The record stays in cwd's
|
|
1481
|
+
journal, the one the owner's Stop rule, resume block and review guard
|
|
1482
|
+
read, and names the repository its brief works in as `repo`
|
|
1483
|
+
(`_brief_repo`) when that is another one. `child` is the id the host
|
|
1484
|
+
returned for a background child (Claude's `agentId`)."""
|
|
1485
|
+
from tezgah_paths import _toplevel, off
|
|
1486
|
+
if off("slices-off") or not cwd:
|
|
1487
|
+
return []
|
|
1488
|
+
root = _toplevel(os.path.realpath(cwd))
|
|
1489
|
+
if not root:
|
|
1490
|
+
return []
|
|
1491
|
+
here = _journal_root(root)
|
|
1492
|
+
out = []
|
|
1493
|
+
context = str(inp.get("context") or "").strip() if isinstance(inp, dict) else ""
|
|
1494
|
+
for sid, agent, brief in spawned(tool, inp):
|
|
1495
|
+
repo = _brief_repo(brief, here)
|
|
1496
|
+
rec = open_slice(root, sid, host=host, agent=agent, brief=brief,
|
|
1497
|
+
parent=session_id or "", cwd=cwd,
|
|
1498
|
+
repo="" if repo == here else repo,
|
|
1499
|
+
task=brief[len(context):].strip() if context else brief,
|
|
1500
|
+
child=child)
|
|
1501
|
+
if failed is True:
|
|
1502
|
+
# a Claude subagent runs on Anthropic unless its model names another
|
|
1503
|
+
# vendor; another host's input may name a model
|
|
1504
|
+
stop = {"errorMessage": result_text, "model": str(inp.get("model") or ""),
|
|
1505
|
+
"default": "anthropic" if host == "claude" else ""}
|
|
1506
|
+
rec = close_slice(root, rec["id"], "stopped",
|
|
1507
|
+
classify(result_text) or "error", stop)
|
|
1508
|
+
elif failed is False:
|
|
1509
|
+
rec = close_slice(root, rec["id"], "done")
|
|
1510
|
+
out.append(rec)
|
|
1511
|
+
return out
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
# An absolute path in a brief: a slash after a space, a quote or a bracket, up
|
|
1515
|
+
# to the next space, quote, bracket or separator.
|
|
1516
|
+
_ABS_PATH = re.compile(r"(?<![\w.~/-])/[^\s'\"`<>()\[\]{},;|*?]+")
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
def main_repo(path):
|
|
1520
|
+
"""The main checkout of the repository holding `path`, else None. A path
|
|
1521
|
+
under a repository's own `.tezgah` belongs to the repository around it
|
|
1522
|
+
(`_toplevel`), and a worktree to its main checkout."""
|
|
1523
|
+
from tezgah_paths import _toplevel
|
|
1524
|
+
top = _toplevel(os.path.realpath(path))
|
|
1525
|
+
return _journal_root(top) if top else None
|
|
1526
|
+
|
|
1527
|
+
|
|
1528
|
+
def _brief_repo(brief, root):
|
|
1529
|
+
"""The main checkout of the one armed repository every absolute path the
|
|
1530
|
+
brief names lies in, else `root`. A brief naming two repositories, or none,
|
|
1531
|
+
keeps `root` (research orchestrator-flow-audit S7, finding 2)."""
|
|
1532
|
+
from tezgah_paths import root_for
|
|
1533
|
+
found = set()
|
|
1534
|
+
for path in _ABS_PATH.findall(brief or ""):
|
|
1535
|
+
top = main_repo(path.rstrip(".:"))
|
|
1536
|
+
if top and root_for(top):
|
|
1537
|
+
found.add(top)
|
|
1538
|
+
return found.pop() if len(found) == 1 else root
|
|
1539
|
+
|
|
1540
|
+
|
|
1541
|
+
def _omp_session_dir(parent):
|
|
1542
|
+
"""The omp session directory of parent session `parent`
|
|
1543
|
+
(`~/.omp/agent/sessions/<project>/<stamp>_<parent>/`, one `<agent id>.jsonl`
|
|
1544
|
+
per subagent), or None. One glob over every project, so it runs once per
|
|
1545
|
+
record (`open_slice`), never per prompt."""
|
|
1546
|
+
import glob
|
|
1547
|
+
if not parent or "/" in parent or os.sep in parent:
|
|
1548
|
+
return None
|
|
1549
|
+
home = os.path.expanduser("~")
|
|
1550
|
+
hits = glob.glob(os.path.join(home, ".omp", "agent", "sessions", "*",
|
|
1551
|
+
"*_" + glob.escape(parent)))
|
|
1552
|
+
return hits[0] if hits else None
|
|
1553
|
+
|
|
1554
|
+
|
|
1555
|
+
def _omp_transcript(rec):
|
|
1556
|
+
"""The newest omp child transcript of the record: omp names a re-dispatch of
|
|
1557
|
+
the same agent name in one session `<name>-2.jsonl`, `<name>-3.jsonl`, so the
|
|
1558
|
+
highest suffix is the attempt the record now tracks - reading `<name>.jsonl`
|
|
1559
|
+
would close a re-opened record with the first run's end. None when the
|
|
1560
|
+
record names no session directory or the directory holds no such file. The
|
|
1561
|
+
file is named by the spawned `name`, which a record's own id may extend."""
|
|
1562
|
+
folder, sid = rec.get("transcript_dir"), rec.get("name") or rec.get("id")
|
|
1563
|
+
if not folder or not sid:
|
|
1564
|
+
return None
|
|
1565
|
+
try:
|
|
1566
|
+
names = os.listdir(folder)
|
|
1567
|
+
except OSError:
|
|
1568
|
+
return None
|
|
1569
|
+
best, rank = None, -1
|
|
1570
|
+
attempt = re.compile(re.escape(sid) + r"(?:-(\d+))?\.jsonl")
|
|
1571
|
+
for name in names:
|
|
1572
|
+
hit = attempt.fullmatch(name)
|
|
1573
|
+
n = int(hit.group(1) or 1) if hit else -1
|
|
1574
|
+
if n > rank:
|
|
1575
|
+
best, rank = os.path.join(folder, name), n
|
|
1576
|
+
return best
|
|
1577
|
+
|
|
1578
|
+
|
|
1579
|
+
def _transcript_end(path):
|
|
1580
|
+
"""How an omp child transcript ended, read from its last SLICE_TAIL bytes:
|
|
1581
|
+
("stopped", class) for an error, abort or length stop on the last assistant
|
|
1582
|
+
message, an error with that message third (`close_slice`'s `stop`);
|
|
1583
|
+
("done", "") once it yielded, stopped normally or exited; None while
|
|
1584
|
+
it may still be running or cannot be read."""
|
|
1585
|
+
import json
|
|
1586
|
+
try:
|
|
1587
|
+
with open(path, "rb") as fh:
|
|
1588
|
+
fh.seek(0, os.SEEK_END)
|
|
1589
|
+
size = fh.tell()
|
|
1590
|
+
fh.seek(max(0, size - SLICE_TAIL))
|
|
1591
|
+
data = fh.read().decode("utf-8", "replace")
|
|
1592
|
+
except OSError:
|
|
1593
|
+
return None
|
|
1594
|
+
rows = []
|
|
1595
|
+
for line in data.splitlines():
|
|
1596
|
+
try:
|
|
1597
|
+
row = json.loads(line)
|
|
1598
|
+
except ValueError:
|
|
1599
|
+
continue # the first line of a cut tail, or a torn last one
|
|
1600
|
+
if isinstance(row, dict):
|
|
1601
|
+
rows.append(row)
|
|
1602
|
+
exited = yielded = False
|
|
1603
|
+
for row in reversed(rows):
|
|
1604
|
+
if row.get("type") == "custom" and row.get("customType") == "session_exit":
|
|
1605
|
+
exited = True
|
|
1606
|
+
msg = row.get("message") if isinstance(row.get("message"), dict) else None
|
|
1607
|
+
if not msg:
|
|
1608
|
+
continue
|
|
1609
|
+
if msg.get("role") == "toolResult" and msg.get("toolName") in ("yield",
|
|
1610
|
+
"submit_result"):
|
|
1611
|
+
yielded = True
|
|
1612
|
+
if msg.get("role") != "assistant":
|
|
1613
|
+
continue
|
|
1614
|
+
stop = msg.get("stopReason")
|
|
1615
|
+
if stop == "error":
|
|
1616
|
+
return "stopped", classify(msg.get("errorMessage")) or "error", msg
|
|
1617
|
+
if stop == "aborted":
|
|
1618
|
+
return "stopped", "aborted"
|
|
1619
|
+
if stop == "length":
|
|
1620
|
+
return "stopped", "cut_off"
|
|
1621
|
+
if yielded or exited or stop == "stop":
|
|
1622
|
+
return "done", ""
|
|
1623
|
+
return None
|
|
1624
|
+
return ("done", "") if yielded or exited else None
|
|
1625
|
+
|
|
1626
|
+
|
|
1627
|
+
# How long a `job` record stays pending: omp clamps a background call's
|
|
1628
|
+
# deadline to 3600 s, so a job opened longer ago has ended either way and its
|
|
1629
|
+
# line would only repeat itself on every prompt. Its result stays in the parent
|
|
1630
|
+
# transcript the record names, so it closes `stale`, not done.
|
|
1631
|
+
SLICE_JOB_SECONDS = 3600
|
|
1632
|
+
|
|
1633
|
+
|
|
1634
|
+
def _age(rec):
|
|
1635
|
+
"""Seconds since the record last changed (`updated`, else `opened`), or None
|
|
1636
|
+
when neither reads as a record time."""
|
|
1637
|
+
import calendar
|
|
1638
|
+
import time
|
|
1639
|
+
stamp = str(rec.get("updated") or rec.get("opened") or "")
|
|
1640
|
+
try:
|
|
1641
|
+
then = calendar.timegm(time.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ"))
|
|
1642
|
+
except ValueError:
|
|
1643
|
+
return None
|
|
1644
|
+
return time.time() - then
|
|
1645
|
+
|
|
1646
|
+
|
|
1647
|
+
def pending(root):
|
|
1648
|
+
"""The records still owed work - status open or stopped - newest first. An
|
|
1649
|
+
omp record still open whose child transcript has ended is closed first:
|
|
1650
|
+
stopped with its failure class, or done when it ended normally. An open
|
|
1651
|
+
record nothing has moved for SLICE_IDLE_SECONDS (an omp child's transcript
|
|
1652
|
+
included) - or a `job` past SLICE_JOB_SECONDS - closes `stale`. One stat and
|
|
1653
|
+
at most one SLICE_TAIL read per open omp record."""
|
|
1654
|
+
out = []
|
|
1655
|
+
for rec in slices(root):
|
|
1656
|
+
if rec.get("status") != "open":
|
|
1657
|
+
if rec.get("status") == "stopped":
|
|
1658
|
+
out.append(rec)
|
|
1659
|
+
continue
|
|
1660
|
+
idle, limit = _age(rec), SLICE_IDLE_SECONDS
|
|
1661
|
+
if rec.get("kind") == "job":
|
|
1662
|
+
limit = SLICE_JOB_SECONDS
|
|
1663
|
+
elif rec.get("host") == "omp":
|
|
1664
|
+
if not rec.get("transcript_dir") and idle is not None and idle < 600:
|
|
1665
|
+
# the parent's directory did not exist yet when the record
|
|
1666
|
+
# opened: one more glob while the record is young, then kept
|
|
1667
|
+
found = _omp_session_dir(rec.get("parent"))
|
|
1668
|
+
if found:
|
|
1669
|
+
rec["transcript_dir"] = found
|
|
1670
|
+
_write_record(root, rec)
|
|
1671
|
+
path = _omp_transcript(rec)
|
|
1672
|
+
end = _transcript_end(path) if path else None
|
|
1673
|
+
if end:
|
|
1674
|
+
rec = close_slice(root, rec["id"], *end) or rec
|
|
1675
|
+
elif path:
|
|
1676
|
+
try:
|
|
1677
|
+
import time
|
|
1678
|
+
idle = min(idle if idle is not None else 1e18,
|
|
1679
|
+
time.time() - os.stat(path).st_mtime)
|
|
1680
|
+
except OSError:
|
|
1681
|
+
pass
|
|
1682
|
+
if rec.get("status") == "open" and idle is not None and idle > limit:
|
|
1683
|
+
rec = close_slice(root, rec["id"], "stale") or rec
|
|
1684
|
+
if rec.get("status") in ("open", "stopped"):
|
|
1685
|
+
out.append(rec)
|
|
1686
|
+
return out
|
|
1687
|
+
|
|
1688
|
+
|
|
1689
|
+
def _cut(line, keep=""):
|
|
1690
|
+
"""`line` + `keep` cut to SLICE_LINE characters, the cut taken from `line` so
|
|
1691
|
+
`keep` (the command that acts on the record) survives whole."""
|
|
1692
|
+
room = SLICE_LINE - len(keep)
|
|
1693
|
+
if len(line) > room:
|
|
1694
|
+
line = line[:max(0, room - 3)] + "..."
|
|
1695
|
+
return (line + keep)[:SLICE_LINE]
|
|
1696
|
+
|
|
1697
|
+
|
|
1698
|
+
def slice_line(rec):
|
|
1699
|
+
"""One bounded line (SLICE_LINE characters) for one record."""
|
|
1700
|
+
sid = rec.get("id") or ""
|
|
1701
|
+
status = rec.get("status") or ""
|
|
1702
|
+
if rec.get("kind") == "job":
|
|
1703
|
+
return _cut("- job %s %s: %s (parent session %s) - read its result there "
|
|
1704
|
+
"before re-running" % (sid, status,
|
|
1705
|
+
" ".join(str(rec.get("brief") or "").split())[:60],
|
|
1706
|
+
rec.get("parent") or "?"))
|
|
1707
|
+
head = "- slice %s (%s/%s) %s%s attempts %s" % (
|
|
1708
|
+
sid, rec.get("host") or "?", rec.get("agent") or "?", status,
|
|
1709
|
+
(" " + rec["failure"]) if rec.get("failure") else "", rec.get("attempts") or 1)
|
|
1710
|
+
tree = rec.get("worktree") or ""
|
|
1711
|
+
if rec.get("repo"):
|
|
1712
|
+
head += " (in %s)" % os.path.basename(rec["repo"])
|
|
1713
|
+
if tree and os.path.isdir(tree):
|
|
1714
|
+
head += " worktree %s @ %s" % (tree, _git(tree, "rev-parse", "--short", "HEAD") or "?")
|
|
1715
|
+
return _cut(head, ": tezgah-task slice resume %s" % sid)
|
|
1716
|
+
|
|
1717
|
+
|
|
1718
|
+
def slice_lines(root, limit=3, session=None, others=False):
|
|
1719
|
+
"""One line per pending record, newest first, at most `limit` of them; with
|
|
1720
|
+
`session`, only the records that session spawned (`parent`) or that no
|
|
1721
|
+
session owns (one opened by hand with no `--parent`) - another session's
|
|
1722
|
+
slices are its own to resume. With `others`, one closing line counts those
|
|
1723
|
+
others, so a new session after a crash or a host switch still learns they
|
|
1724
|
+
exist (the resume block asks for it, the per-prompt part does not)."""
|
|
1725
|
+
recs = pending(root)
|
|
1726
|
+
if session is None:
|
|
1727
|
+
return [slice_line(rec) for rec in recs[:limit]]
|
|
1728
|
+
mine = [r for r in recs if r.get("parent") in (session, "", None)]
|
|
1729
|
+
out = [slice_line(rec) for rec in mine[:limit]]
|
|
1730
|
+
if others and len(recs) > len(mine):
|
|
1731
|
+
out.append("- %d pending slice(s) of other sessions: tezgah-task slice reconcile"
|
|
1732
|
+
% (len(recs) - len(mine)))
|
|
1733
|
+
return out
|