Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/now/2026-10-04-tri-tick-released-claim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# NOW -- tri tick: a released claim is not a held one (2026-10-04)

## What was read

- Tick 18 of cron 8782e5f8 started with `tri tick --stale-minutes 30`. The card said `claim is 39m old (> 30.0m) and 2 file(s) are dirty: the previous tick died mid-work`, but tick 17's claim already had `released` set: that tick had finished. `tick.py` read `claim.item` and `claim.since` and never read `released`.
- The two dirty files were `cron_tracking/` and `.claude/launch.json`. Both are untracked on purpose, and the worktree is the branch of pull request #5839. The fix line was `git -C <worktree> add -A && git -C <worktree> commit -m 'wip(tick 17): recovered'`. Following it would have committed the loop's state into that pull request.
- `git add -A` sweeping in something nobody meant to stage is recorded four times in docs/now: 2026-08-23 and 2026-08-24 (a live mutant reached master), 2026-08-31 (another session's compiler.rs) and 2026-09-04 (a conflicted ledger).

## What changed (Closes #5863)

- `claim.released` is read. A release that parses and is not older than `since` (with the 120 s clock slack) means the claim is not held: no staleness check, the card prints `released <time> (none held)` and the claim it ended, and `--json` gains `claim.released` and `claim.held`. A release older than the claim is a leftover from a re-taken claim, so the claim stays held. A release that does not parse leaves the claim held and is reported as `claim-unparseable` on `claim.released`.
- Dirty is counted from `git status --porcelain=v1 -z --untracked-files=all`, one entry per file, so `?? .claude/` cannot hide what sits beside a kept file. These are not counted: the state directory itself, when it sits inside the worktree, and paths listed in that worktree's `keep_untracked` in the state. Every other untracked file still counts. `--json` gains `dirty_paths` and `kept_untracked`.
- The `stale-claim-dirty` fix is `git add -- <each file, shell-quoted>` and then the commit. It shows 8 paths, with a pointer to `--json` for the rest, and adds "read the list first: a second session's edits look the same". It never says `add -A`. A staged rename is listed by its new name.
- Test: 20 -> 36 checks. New cases: released, held (the negative control), leftover, unparseable release, loop state with `keep_untracked` and without it, a fix line naming `wip.txt`, and the never-write check on a worktree's own state directory. The old 20 checks pass unchanged against the new tool. 12 mutations, each red: release ignored, leftover release counted, unparseable release silent, state directory counted, `keep_untracked` ignored, `add -A` restored, rename source kept, paths unquoted, kept by exact name only, untracked directories folded, card hiding the release, `held` always true.

## Measured, 2026-10-04

- On the live state of cron 8782e5f8 while tick 18's claim was held (`released` null): `ANOMALIES 0`. The t27 worktree reads `dirty 1, 2 kept untracked`: the state's two files are kept, and `.claude/launch.json` is dirty until the state lists it in `keep_untracked`. At `--stale-minutes 0`, the fix line was `git -C <worktree> add -- .claude/launch.json && git -C <worktree> commit -m 'wip(tick 18): recovered' (read the list first: ...)`. It names one file, and not the state directory.

## Not established

- That a file the fix names is the dead tick's work and not another session's. The line asks a person to read the list. It cannot tell the two apart.
- A tick that dies after writing `released` and before committing. The tree is dirty, the claim reads as ended, and nothing is reported.
- Submodules and ignored files: `git status` is read as it prints them.

Refs #5786 #5823
172 changes: 172 additions & 0 deletions scripts/ci/test_a_tick_resumes_from_what_it_reads.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,26 @@
broken claim 2 h old + dirty tree, branch mismatch, unknown base, a
missing worktree, ledger one tick behind -> each code, exit 1
future claim 30 min ahead of the clock -> claim-in-future
released claim 2 h old, RELEASED 90 min ago, dirty tree -> 0 anomalies:
the tick ended, nobody is mid-work (tick 18 of cron 8782e5f8
read its own finished claim as a dead tick)
held the same claim and tree, released null -> stale-claim-dirty,
its fix names each file (a staged rename by its new name) and
never says `add -A` (the negative control for `released`)
leftover released BEFORE since (a re-taken claim, release not cleared)
-> still held, stale-claim-dirty
bad release released is not a time -> claim-unparseable on claim.released
orphaned claim RELEASED, a tree dirty since 2 h ago, no process in it,
held_by naming an agent -> orphaned-dirty, the fix names the
files and the holder (tick 22 of cron 8782e5f8: two dead
delegates' work read ANOMALIES 0); no claim at all -> the same
delegate the same tree written just now -> no anomaly, a DELEGATE line
(negative control: freshness decides); and the 2 h old tree
with a live process standing in it -> no anomaly (negative
control: the process decides)
loop state a worktree holding the state directory and a keep_untracked
file, nothing else -> dirty 0, 5 kept, no anomaly; without
keep_untracked -> dirty 1, the fix names only that file
junk tick-state.json is not JSON -> state-unparseable, exit 1
no --id several cron ids -> exit 2 (usage), ids listed
bad --id an id that is not there -> exit 2
Expand Down Expand Up @@ -146,11 +166,70 @@ def main():
"worktrees": {"w": {"path": good, "branch": "feat", "base": "main"}}}, ledger3)
cron(root, "junk", "{not json", ledger3)

# A dirty tree on the right branch: a staged rename and a new file.
wip = os.path.join(t, "wip")
repo(wip, "feat")
git(wip, "mv", "b.txt", "c.txt")
with open(os.path.join(wip, "my notes.txt"), "w") as fh:
fh.write("half-done\n")
wip_tree = {"w": {"path": wip, "branch": "feat", "base": "main"}}
for cid, claim in (
("released", {"item": "x", "since": iso(-120), "released": iso(-90)}),
("held", {"item": "x", "since": iso(-120), "released": None}),
("leftover", {"item": "x", "since": iso(-120), "released": iso(-200)}),
("badrelease", {"item": "x", "since": iso(-120), "released": "yesterday"})):
cron(root, cid, {"cron_id": cid, "tick": 3, "claim": claim,
"worktrees": wip_tree}, ledger3)

# A delegate's worktree: dirty, last written 2 h ago.
orphan = os.path.join(t, "orphan")
repo(orphan, "feat")
with open(os.path.join(orphan, "a.txt"), "a") as fh:
fh.write("half a fix\n")
with open(os.path.join(orphan, "new.css"), "w") as fh:
fh.write("x {}\n")
old = datetime.now().timestamp() - 7200
for f in ("a.txt", "new.css"):
os.utime(os.path.join(orphan, f), (old, old))
fresh = os.path.join(t, "fresh")
repo(fresh, "feat")
with open(os.path.join(fresh, "new.css"), "w") as fh:
fh.write("x {}\n")
agent = "background agent a1, live at tick 2 end"
for cid, claim, path in (
("orphaned", {"item": "x", "since": iso(-180), "released": iso(-170)}, orphan),
("noclaim", None, orphan),
("delegate", {"item": "x", "since": iso(-180), "released": iso(-170)}, fresh),
("standing", {"item": "x", "since": iso(-180), "released": iso(-170)}, orphan)):
st = {"cron_id": cid, "tick": 3,
"worktrees": {"w": {"path": path, "branch": "feat", "base": "main",
"held_by": agent}}}
if claim:
st["claim"] = claim
cron(root, cid, st, ledger3)

# The loop's own files inside a worktree: the state directory and one
# file kept untracked by name. Nothing else is dirty.
home = os.path.join(t, "home")
repo(home, "feat")
home_root = os.path.join(home, "cron_tracking")
os.makedirs(os.path.join(home, ".claude"))
with open(os.path.join(home, ".claude", "launch.json"), "w") as fh:
fh.write("{}\n")
for cid, keep in (("kept", [".claude/launch.json"]), ("unkept", None)):
spec = {"path": home, "branch": "feat", "base": "main"}
if keep:
spec["keep_untracked"] = keep
cron(home_root, cid, {"cron_id": cid, "tick": 3,
"claim": {"item": "x", "since": iso(-120)},
"worktrees": {"w": spec}}, ledger3)

# Make a plain `git status` want to rewrite the index of the good repo.
st = os.stat(os.path.join(good, "a.txt"))
os.utime(os.path.join(good, "a.txt"), ns=(st.st_atime_ns, st.st_mtime_ns - 10**10))
idx_before = index_id(good)
cron_before = tree_digest(root)
home_before = tree_digest(home_root)

code, data, _ = run(root, "--id", "healthy", "--json")
check(code == 0, "healthy: exit 0", f"exit {code}")
Expand All @@ -171,6 +250,97 @@ def main():
check(want in got, f"broken: {want} is reported", got)
check(all(a["fix"].strip() for a in data["anomalies"]),
"broken: every anomaly carries a fix line")
fix = [a["fix"] for a in data["anomalies"] if a["code"] == "stale-claim-dirty"]
check(fix and "add -- wip.txt" in fix[0] and "add -A" not in fix[0],
"broken: the recovery names wip.txt and does not say `add -A`", fix)

code, data, _ = run(root, "--id", "released", "--json")
check(code == 0 and data["anomalies"] == [],
"released: a released claim on a dirty tree is no anomaly",
codes(data) if data else code)
check(data["claim"]["held"] is False and data["worktrees"][0]["dirty"] == 2,
"released: held false, and the tree IS dirty (2) -- the release decided it",
(data["claim"], data["worktrees"][0]["dirty"]))
code, _, r = run(root, "--id", "released")
check("released " in r.stdout and "(none held)" in r.stdout,
"released: the human card says the claim was released", r.stdout[:400])

code, data, _ = run(root, "--id", "held", "--json")
check(code == 1 and codes(data) == ["stale-claim-dirty"],
"held (negative control): the same claim, not released, is a dead tick",
codes(data) if data else code)
w = data["worktrees"][0]
check(sorted(w["dirty_paths"]) == ["c.txt", "my notes.txt"],
"held: dirty paths are the rename's new name and the new file, nothing else",
w["dirty_paths"])
fix = data["anomalies"][0]["fix"]
check("add -A" not in fix and "c.txt" in fix and "'my notes.txt'" in fix,
"held: the fix names each file, quoted, and never says `add -A`", fix)
check(data["claim"]["held"] is True, "held: held true", data["claim"])

code, data, _ = run(root, "--id", "leftover", "--json")
check(code == 1 and codes(data) == ["stale-claim-dirty"],
"leftover: a release older than the claim does not release it",
codes(data) if data else code)

code, data, _ = run(root, "--id", "badrelease", "--json")
got = [(a["code"], a["subject"]) for a in data["anomalies"]] if data else code
check(code == 1 and ("claim-unparseable", "claim.released") in got,
"bad release: claim-unparseable names claim.released", got)
check(("stale-claim-dirty", "worktrees.w") in got,
"bad release: an unreadable release does not release the claim", got)

code, data, _ = run(root, "--id", "orphaned", "--json")
check(code == 1 and codes(data) == ["orphaned-dirty"],
"orphaned: a released claim and a tree nobody has written for 2 h is orphaned",
codes(data) if data else code)
a = data["anomalies"][0] if data and data["anomalies"] else {"detail": "", "fix": ""}
check("a1" in a["detail"] and "no process" in a["detail"],
"orphaned: the detail names the holder and that no process stands in it", a)
check("add -- a.txt new.css" in a["fix"] and "add -A" not in a["fix"]
and "held_by" in a["fix"],
"orphaned: the fix names the files, never `add -A`, and clears held_by", a["fix"])
w = data["worktrees"][0]
check(w["processes"] == [] and w["newest_write_age_s"] > 7000,
"orphaned: processes [] (measured), newest write ~2 h", w)
code, data, _ = run(root, "--id", "noclaim", "--json")
check(code == 1 and codes(data) == ["orphaned-dirty"],
"no claim at all: the same tree is orphaned", codes(data) if data else code)
code, data, r = run(root, "--id", "delegate", "--json")
check(code == 0 and data["anomalies"] == [],
"delegate (negative control): written just now, no anomaly",
codes(data) if data else code)
code, _, r = run(root, "--id", "delegate")
check("DELEGATE:" in r.stdout and "held_by: background agent a1" in r.stdout,
"delegate: the card prints a DELEGATE line with the holder", r.stdout[-900:])
sleeper = subprocess.Popen(["sleep", "60"], cwd=orphan)
try:
code, data, _ = run(root, "--id", "standing", "--json")
finally:
sleeper.kill()
sleeper.wait()
check(code == 0 and data["anomalies"] == [],
"standing (negative control): a live process in the tree, no anomaly",
codes(data) if data else code)
check(data and sleeper.pid in data["worktrees"][0]["processes"],
"standing: the process standing in the tree is listed by pid",
data["worktrees"][0] if data else None)

code, data, _ = run(home_root, "--id", "kept", "--json")
w = data["worktrees"][0] if data else {}
check(code == 0 and data["anomalies"] == [],
"loop state: the state directory and a kept file are not a dead tick",
codes(data) if data else code)
check((w.get("dirty"), w.get("kept_untracked")) == (0, 5),
"loop state: dirty 0, 5 kept untracked (two ids' state + ledger, and launch.json)",
w)
code, data, _ = run(home_root, "--id", "unkept", "--json")
fix = [a["fix"] for a in data["anomalies"] if a["code"] == "stale-claim-dirty"] if data else []
check(code == 1 and fix and data["worktrees"][0]["dirty_paths"] == [".claude/launch.json"],
"loop state (negative control): without keep_untracked launch.json is dirty",
data["worktrees"][0] if data else code)
check(fix and "cron_tracking" not in fix[0],
"loop state: the fix never names the state directory", fix)

code, data, _ = run(root, "--id", "future", "--json")
check(code == 1 and codes(data) == ["claim-in-future"],
Expand All @@ -191,6 +361,8 @@ def main():
"the human card prints fixes and what it does not establish")

check(tree_digest(root) == cron_before, "nothing under cron_tracking was written")
check(tree_digest(home_root) == home_before,
"nothing under a worktree's own cron_tracking was written")
check(index_id(good) == idx_before, "the worktree's index was not rewritten")
# Negative control: a plain status rewrites it, so the sameness above is earned.
git(good, "status", "--porcelain")
Expand Down
Loading
Loading