Compare commits

...
Author SHA1 Message Date
teahubxyz 7f3042ee28 review: .gitattributes names the root CHANGELOG only
ci / test (pull_request) Successful in 45s
2026-10-19 22:15:55 +00:00
teahubxyz 72f89982e6 review: CHANGELOG says only Time's merges go back, and what union merge costs
ci / test (pull_request) Successful in 46s
2026-10-19 22:15:52 +00:00
teahubxyz 3ea8289833 review: tests use Time's real merges, and a push at the home or a rewind on the copy never goes back
ci / test (pull_request) Successful in 45s
2026-10-19 22:15:49 +00:00
teahubxyz 3ab388154e review: send back only Time's own merges, under a lease on the copy's branch; a failed home fetch falls through
ci / test (pull_request) Failing after 44s
2026-10-19 22:15:26 +00:00
teahubxyz 75433e5bb3 review: name Time's git identity for sync; anchor the union pattern to the root CHANGELOG; say what union costs; the conflict hint names who acts
ci / test (pull_request) Successful in 46s
2026-10-19 22:15:18 +00:00
teahubxyz 51f8e484e9 .gitattributes: a CHANGELOG conflict keeps both sides' lines
ci / test (pull_request) Successful in 44s
2026-10-19 21:52:10 +00:00
teahubxyz c95e1accc1 CHANGELOG: sync sends Time's merge back; CHANGELOG conflicts union-merge 2026-10-19 21:52:07 +00:00
teahubxyz 86e845037b tests: sync sends Time's merge back, the copy wins a divergence, CHANGELOG conflicts union-merge, the imported hint 2026-10-19 21:51:57 +00:00
teahubxyz d69acab7f7 sync: Time's merge on an imported branch goes back to the copy fast-forward instead of being overwritten 2026-10-19 21:51:34 +00:00
teahubxyz 39556a0360 update_branch keeps both sides of a CHANGELOG conflict; a conflict on an imported branch points at the copy 2026-10-19 21:51:19 +00:00
5 changed files with 107 additions and 5 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
/CHANGELOG.md merge=union
+28 -2
View File
@@ -13,6 +13,11 @@ cloud session's branch on the copy), and a pull mirror makes the receiving repo
write. They are pushed to the home under the same name and get a PR there, which Time's council reviews and lands write. They are pushed to the home under the same name and get a PR there, which Time's council reviews and lands
like any other; the landed work then flows back out with main. A branch already in main, or one whose PR was closed like any other; the landed work then flows back out with main. A branch already in main, or one whose PR was closed
at the same commit, is left alone. at the same commit, is left alone.
- Back: Time itself commits to an imported branch in one case, merging the base into an approved PR that fell behind.
When the home's branch is ahead of the copy's by Time's merges and nothing else, they go back to the copy
fast-forward (and only if the copy's branch hasn't moved since it was read), so the copy's next session builds on
them instead of overwriting them. Anything else ahead on the home (the copy rewound its own branch, or someone pushed
to it at the home) is not sent out: the copy, the writer, wins, and Time merges the base again on a later beat.
time/sync.json: time/sync.json:
{"repos": [{"repo": "teahubxyz/time", "out": ["main"], {"repos": [{"repo": "teahubxyz/time", "out": ["main"],
@@ -134,6 +139,16 @@ def landed(work: str, ref: str, base: str) -> bool:
return git("merge-base", "--is-ancestor", ref, base, cwd=work, check=False).returncode == 0 return git("merge-base", "--is-ancestor", ref, base, cwd=work, check=False).returncode == 0
def times_merges(work: str, copy_ref: str, home_ref: str) -> bool:
"""The home's branch is ahead of the copy's by update_branch's merges alone: every first-parent commit between them
is a two-parent merge authored by Time. The copy rewinding its own branch leaves the home ahead too, by the
copy's own withdrawn commits, and those must not go back; nor must a human's or a worker's push at the home."""
if not landed(work, copy_ref, home_ref):
return False
rows = git("log", "--first-parent", "--format=%an%x09%P", f"{copy_ref}..{home_ref}", cwd=work).stdout.splitlines()
return bool(rows) and all(r.split("\t")[0] == tm.TIME_GIT_NAME and len(r.split("\t")[1].split()) == 2 for r in rows)
CARD_BRANCH = re.compile(r"^claude/card-(\d+)(?:\b|-)") # a cloud session building card N (router.py's cloud lane) CARD_BRANCH = re.compile(r"^claude/card-(\d+)(?:\b|-)") # a cloud session building card N (router.py's cloud lane)
@@ -204,7 +219,7 @@ def ensure_pr(repo: str, work: str, branch: str, base: str, copy: str, where: st
title = (log.splitlines() or [branch])[0][:200] title = (log.splitlines() or [branch])[0][:200]
card = card_of(branch) card = card_of(branch)
closes = f"Closes #{card}\n\n" if card and not re.search(rf"\b(close[sd]?|fix(e[sd])?|resolve[sd]?)\s+#{card}\b", log, re.I) else "" closes = f"Closes #{card}\n\n" if card and not re.search(rf"\b(close[sd]?|fix(e[sd])?|resolve[sd]?)\s+#{card}\b", log, re.I) else ""
body = (f"{closes}From {copy}: `{branch}` ({where}), brought in by sync.py. Time's council reviews it like any PR.\n\n" body = (f"{closes}From {copy}: `{branch}` ({where}), {tm.IMPORTED}. Time's council reviews it like any PR.\n\n"
f"Commits:\n\n{log[:6000]}\n\n<sub>sync.py: edit this branch on {copy}; it is the copy's to write, and the " f"Commits:\n\n{log[:6000]}\n\n<sub>sync.py: edit this branch on {copy}; it is the copy's to write, and the "
f"next sync brings the change here.</sub>") f"next sync brings the change here.</sub>")
n = tm.gh("POST", f"/repos/{repo}/pulls", {"head": branch, "base": base, "title": title, "body": body})["number"] n = tm.gh("POST", f"/repos/{repo}/pulls", {"head": branch, "base": base, "title": title, "body": body})["number"]
@@ -257,6 +272,17 @@ def sync_repo(entry: dict, home_url: str, force: bool = False) -> dict:
continue continue
git("fetch", "-q", url, f"+refs/heads/{branch}:refs/copy/{branch}", cwd=work) git("fetch", "-q", url, f"+refs/heads/{branch}:refs/copy/{branch}", cwd=work)
moved = mine.get(branch) != sha moved = mine.get(branch) != sha
if moved and mine.get(branch): # the home's branch may be ahead by Time's merge of the base
got = git("fetch", "-q", home_url, f"+refs/heads/{branch}:refs/back/{branch}", cwd=work, check=False)
if got.returncode == 0 and times_merges(work, f"refs/copy/{branch}", f"refs/back/{branch}"):
read = git("rev-parse", f"refs/copy/{branch}", cwd=work).stdout.strip()
p = git("push", "-q", f"--force-with-lease=refs/heads/{branch}:{read}", url,
f"refs/back/{branch}:refs/heads/{branch}", cwd=work, check=False)
if p.returncode == 0:
r.setdefault("back", []).append(branch)
else: # the copy moved meanwhile: the next run sorts it out
print(f"back {branch}: {scrub((p.stderr or '').strip())[-200:]}")
continue
if moved: # the copy writes this branch: the home takes it as it is if moved: # the copy writes this branch: the home takes it as it is
git("push", "-q", "--force", home_url, f"refs/copy/{branch}:refs/heads/{branch}", cwd=work) git("push", "-q", "--force", home_url, f"refs/copy/{branch}:refs/heads/{branch}", cwd=work)
r["imported"].append(branch) r["imported"].append(branch)
@@ -296,7 +322,7 @@ def main() -> int:
rep = sync_repo(e, authed(f"{tm.SERVER}/{repo}.git", token), force) rep = sync_repo(e, authed(f"{tm.SERVER}/{repo}.git", token), force)
for c in rep["copies"]: for c in rep["copies"]:
print(f"sync {repo} -> {c['copy']}: out {c['out']} imported {c['imported']} prs {c['prs']} " print(f"sync {repo} -> {c['copy']}: out {c['out']} imported {c['imported']} prs {c['prs']} "
f"revised {c.get('revised', [])} {c['note']}".rstrip()) f"revised {c.get('revised', [])} back {c.get('back', [])} {c['note']}".rstrip())
for b, state in c["out"].items(): for b, state in c["out"].items():
if state == "diverged" and repo == tm.REPO: if state == "diverged" and repo == tm.REPO:
flag(repo, c["copy"], b) flag(repo, c["copy"], b)
+57
View File
@@ -1520,6 +1520,49 @@ comments83[7] = []; notes83.clear()
commit83(cw83, "g.txt", "More before any review"); g83("push", "-q", "origin", "claude/card-7-cloud", cwd=cw83) commit83(cw83, "g.txt", "More before any review"); g83("push", "-q", "origin", "claude/card-7-cloud", cwd=cw83)
assert sync83().get("revised", []) == [] and not notes83, "a PR nobody has reviewed yet needs no revised beat" assert sync83().get("revised", []) == [] and not notes83, "a PR nobody has reviewed yet needs no revised beat"
assert sy.revised("o/r", "claude/card-7-cloud", None) == [] and sy.revised("x/y", "claude/card-7-cloud", "abc") == [] assert sy.revised("o/r", "claude/card-7-cloud", None) == [] and sy.revised("x/y", "claude/card-7-cloud", "abc") == []
# Time merges main into the imported branch at home (an approved PR that fell behind): the merge goes back to the copy
# fast-forward, nothing is imported over it and no revision is announced; the copy's next session builds on it
wb83 = os.path.join(root83, "wb"); g83("clone", "-q", "-b", "claude/card-7-cloud", home83, wb83)
def time_merge83(n): # what update_branch does: main has moved on, and Time merges it into the branch at the home
g83("fetch", "-q", "origin", "main", cwd=wk83); g83("reset", "-q", "--hard", "FETCH_HEAD", cwd=wk83)
commit83(wk83, f"main{n}.txt", f"Main moves on {n}"); g83("push", "-q", "origin", "HEAD:main", cwd=wk83)
g83("fetch", "-q", "origin", "claude/card-7-cloud", cwd=wb83); g83("checkout", "-q", "-B", "claude/card-7-cloud", "FETCH_HEAD", cwd=wb83)
g83("fetch", "-q", "origin", "main", cwd=wb83)
_sp83.run(["git", "-c", f"user.name={sy.tm.TIME_GIT_NAME}", "-c", "user.email=time@t", "merge", "-q", "--no-ff", "-m",
"card #7: merge main", "FETCH_HEAD"], cwd=wb83, check=True, capture_output=True)
g83("push", "-q", "origin", "claude/card-7-cloud", cwd=wb83)
time_merge83(1)
comments83[7] = [beat83("pr")]; notes83.clear()
c83 = sync83()
assert c83.get("back") == ["claude/card-7-cloud"] and c83["imported"] == [] and not c83.get("revised") and not notes83, c83
assert g83("rev-parse", "claude/card-7-cloud", cwd=copy83) == g83("rev-parse", "claude/card-7-cloud", cwd=home83)
assert not sync83().get("back"), "in step: nothing to send back"
g83("fetch", "-q", "origin", "claude/card-7-cloud", cwd=cw83); g83("merge", "-q", "--ff-only", "FETCH_HEAD", cwd=cw83)
commit83(cw83, "g.txt", "On top of Time's merge"); g83("push", "-q", "origin", "claude/card-7-cloud", cwd=cw83)
c83 = sync83()
assert c83["imported"] == ["claude/card-7-cloud"] and c83["revised"] == [7] and not c83.get("back"), c83
assert g83("rev-parse", "claude/card-7-cloud", cwd=home83) == g83("rev-parse", "claude/card-7-cloud", cwd=copy83)
# both sides wrote: the copy, the branch's one writer, wins (Time merges main again on a later beat)
time_merge83(2)
commit83(cw83, "g.txt", "The copy's own"); g83("push", "-q", "origin", "claude/card-7-cloud", cwd=cw83)
c83 = sync83()
assert c83["imported"] == ["claude/card-7-cloud"] and not c83.get("back"), c83
assert g83("rev-parse", "claude/card-7-cloud", cwd=home83) == g83("rev-parse", "claude/card-7-cloud", cwd=copy83)
# ahead at the home by anything but Time's merges: never sent out, the copy's tip wins. A push at the home (a human's,
# a worker's), and the copy rewinding its own branch (its withdrawn commit is still on the home)
g83("fetch", "-q", "origin", "claude/card-7-cloud", cwd=wb83); g83("checkout", "-q", "-B", "claude/card-7-cloud", "FETCH_HEAD", cwd=wb83)
commit83(wb83, "w.txt", "Pushed at the home"); g83("push", "-q", "origin", "claude/card-7-cloud", cwd=wb83)
copy_tip83 = g83("rev-parse", "claude/card-7-cloud", cwd=copy83)
c83 = sync83()
assert not c83.get("back") and c83["imported"] == ["claude/card-7-cloud"], c83
assert g83("rev-parse", "claude/card-7-cloud", cwd=home83) == g83("rev-parse", "claude/card-7-cloud", cwd=copy83) == copy_tip83
commit83(cw83, "g.txt", "A second thought"); g83("push", "-q", "origin", "claude/card-7-cloud", cwd=cw83)
assert sync83()["imported"] == ["claude/card-7-cloud"]
g83("reset", "-q", "--hard", "HEAD~1", cwd=cw83); g83("push", "-q", "--force", "origin", "claude/card-7-cloud", cwd=cw83)
c83 = sync83()
assert not c83.get("back") and c83["imported"] == ["claude/card-7-cloud"], c83
assert g83("rev-parse", "claude/card-7-cloud", cwd=home83) == g83("rev-parse", "claude/card-7-cloud", cwd=copy83) == copy_tip83, "the rewind stands"
assert sy.tm.IMPORTED in made83[0]["body"], "time.py knows a PR sync.py opened by its body"
sy.tm.gh = real_gh83 sy.tm.gh = real_gh83
# a copy whose token isn't set is skipped; tokens go in the URL and never into anything printed # a copy whose token isn't set is skipped; tokens go in the URL and never into anything printed
c83 = sy.sync_repo({"repo": "o/r", "copies": [{"name": "gh", "url": "https://github.com/o/r.git", "token": "NO_SUCH_TOKEN_83"}]}, c83 = sy.sync_repo({"repo": "o/r", "copies": [{"name": "gh", "url": "https://github.com/o/r.git", "token": "NO_SUCH_TOKEN_83"}]},
@@ -1590,6 +1633,10 @@ b = tm.beats_of(w.comments[1])[-1]
assert b["beat"] == "needs-human" and b["conflict"] is True and "x = 3" in w.posted[0][1] and "needs-human" in w.issues[7]["labels"], w.posted assert b["beat"] == "needs-human" and b["conflict"] is True and "x = 3" in w.posted[0][1] and "needs-human" in w.issues[7]["labels"], w.posted
assert "<<<<<<< HEAD" in w.comments[7][-1]["body"] and not merged(w) assert "<<<<<<< HEAD" in w.comments[7][-1]["body"] and not merged(w)
run(w, E123); assert len(ub_calls) == 1 and len(w.posted) == 2, "the next beat does not merge or post again (one on the PR, one on the card)" run(w, E123); assert len(ub_calls) == 1 and len(w.posted) == 2, "the next beat does not merge or post again (one on the PR, one on the card)"
assert "/time retry" in w.posted[0][1]
# a branch sync.py brought in is the copy's to write: the hunks go to its session or a human, not to `/time retry`
wi123 = run(stale123(body=f"From github: `claude/card-1-x` (git@github.com:o/r.git), {tm.IMPORTED}."), E123)
assert "copy's to write" in wi123.posted[0][1] and "/time retry" not in wi123.posted[0][1], wi123.posted
# /time retry sends the worker back to the branch with the hunks as the reviewer's text # /time retry sends the worker back to the branch with the hunks as the reviewer's text
w.comments[1].append({"created_at": iso(-1), "user": {"login": "owner"}, "body": "/time retry"}) w.comments[1].append({"created_at": iso(-1), "user": {"login": "owner"}, "body": "/time retry"})
w.issues[7]["labels"] = ["needs-human"] w.issues[7]["labels"] = ["needs-human"]
@@ -1621,6 +1668,16 @@ try:
sha, hunks = tm.update_branch("time/card-1", "main", "m") sha, hunks = tm.update_branch("time/card-1", "main", "m")
assert sha is None and "`app.py`" in hunks and "<<<<<<<" in hunks and "x = 2" in hunks and "x = 3" in hunks, hunks assert sha is None and "`app.py`" in hunks and "<<<<<<<" in hunks and "x = 2" in hunks and "x = 3" in hunks, hunks
assert g83("rev-parse", "time/card-1", cwd=bare123) == before, "the branch is as it was" assert g83("rev-parse", "time/card-1", cwd=bare123) == before, "the branch is as it was"
# two PRs that each add a CHANGELOG line at the same spot: both lines are kept, and no human is asked
g83("checkout", "-q", "main", cwd=wk123); commit83(wk123, "CHANGELOG.md", "# Changelog\n\n### Added\n- one\n")
g83("push", "-q", "origin", "main", cwd=wk123)
g83("checkout", "-qb", "time/card-2", cwd=wk123); commit83(wk123, "CHANGELOG.md", "# Changelog\n\n### Added\n- two, from the PR\n- one\n")
g83("push", "-q", "origin", "time/card-2", cwd=wk123)
g83("checkout", "-q", "main", cwd=wk123); commit83(wk123, "CHANGELOG.md", "# Changelog\n\n### Added\n- three, from main\n- one\n")
g83("push", "-q", "origin", "main", cwd=wk123)
sha, hunks = tm.update_branch("time/card-2", "main", "card #2: merge main")
cl123 = g83("show", f"{sha}:CHANGELOG.md", cwd=bare123) if sha else ""
assert sha and not hunks and "- two, from the PR" in cl123 and "- three, from main" in cl123 and "<<<<<<<" not in cl123, (hunks, cl123)
finally: finally:
tm.SERVER = old_server tm.SERVER = old_server
print("ALL OK 123") print("ALL OK 123")
+20 -3
View File
@@ -906,6 +906,9 @@ def dispatch_ci(pr: dict) -> bool:
return True return True
IMPORTED = "brought in by sync.py" # sync.py's words in the body of a PR it opened for a copy's branch
def update_branch_of(card: dict, pr: dict, last: dict) -> None: def update_branch_of(card: dict, pr: dict, last: dict) -> None:
"""An approved PR that no longer merges: merge its base into it (one commit by Time; CI runs on the new head and landing """An approved PR that no longer merges: merge its base into it (one commit by Time; CI runs on the new head and landing
follows on a later beat). A conflict is posted with its hunks on the card and the PR, labelled `needs-human`, and the follows on a later beat). A conflict is posted with its hunks on the card and the PR, labelled `needs-human`, and the
@@ -915,9 +918,13 @@ def update_branch_of(card: dict, pr: dict, last: dict) -> None:
n, base, branch = card["number"], (pr.get("base") or {}).get("ref") or default_branch(), pr["head"]["ref"] n, base, branch = card["number"], (pr.get("base") or {}).get("ref") or default_branch(), pr["head"]["ref"]
sha, hunks = update_branch(branch, base, f"card #{n}: merge {base}") sha, hunks = update_branch(branch, base, f"card #{n}: merge {base}")
if sha is None: if sha is None:
# a branch sync.py brought in is the copy's to write: a worker's commits here would be overwritten by the next sync
then = ("The branch is the copy's to write (sync.py brought it in), so the fix goes there: for a card the cloud "
"lane built, its launcher on hb-5 sends a session back with these hunks; otherwise a human merges on the "
"copy." if IMPORTED in (pr.get("body") or "")
else "Comment `/time retry` to send the worker back to the branch with these hunks.")
text = (f"⏱️ Time: PR #{pr['number']} is approved but conflicts with `{base}`, and I don't guess at conflicts: " text = (f"⏱️ Time: PR #{pr['number']} is approved but conflicts with `{base}`, and I don't guess at conflicts: "
f"I left `{branch}` as it was. Merge `origin/{base}` into it and resolve:\n\n{hunks}\n\n" f"I left `{branch}` as it was. Merge `origin/{base}` into it and resolve:\n\n{hunks}\n\n{then}")
"Comment `/time retry` to send the worker back to the branch with these hunks.")
gh("POST", f"/repos/{REPO}/issues/{pr['number']}/comments", {"body": text}) # reviewer_feedback reads the PR's comments gh("POST", f"/repos/{REPO}/issues/{pr['number']}/comments", {"body": text}) # reviewer_feedback reads the PR's comments
label(pr["number"], "needs-human") label(pr["number"], "needs-human")
comment(n, text, {"beat": "needs-human", "pr": pr["number"], "conflict": True}) comment(n, text, {"beat": "needs-human", "pr": pr["number"], "conflict": True})
@@ -1162,6 +1169,10 @@ def git_commit(branch: str, files: dict[str, bytes | None], message: str, *, bas
shutil.rmtree(d, ignore_errors=True) shutil.rmtree(d, ignore_errors=True)
UNION_MERGE = ("/CHANGELOG.md",) # files whose conflicts update_branch settles by keeping both sides' lines
TIME_GIT_NAME = "Time" # the author of update_branch's merges; sync.py sends those, and only those, back to a copy
def update_branch(branch: str, base: str, message: str) -> tuple[str | None, str]: def update_branch(branch: str, base: str, message: str) -> tuple[str | None, str]:
"""Merge origin/<base> into <branch> as Time and push: (new sha, "") or, on conflicts, (None, the `<<<<<<<` blocks, """Merge origin/<base> into <branch> as Time and push: (new sha, "") or, on conflicts, (None, the `<<<<<<<` blocks,
trimmed) with the merge aborted and the branch untouched. ("", "") when there was nothing to merge. trimmed) with the merge aborted and the branch untouched. ("", "") when there was nothing to merge.
@@ -1171,7 +1182,7 @@ def update_branch(branch: str, base: str, message: str) -> tuple[str | None, str
host = re.sub(r"^\w+://", "", SERVER).split("/")[0] or "localhost" host = re.sub(r"^\w+://", "", SERVER).split("/")[0] or "localhost"
def git(*args, check=True): def git(*args, check=True):
p = subprocess.run(["git", "-c", "user.name=Time", "-c", f"user.email=time@users.noreply.{host}", *args], p = subprocess.run(["git", "-c", f"user.name={TIME_GIT_NAME}", "-c", f"user.email=time@users.noreply.{host}", *args],
cwd=d, capture_output=True, text=True) cwd=d, capture_output=True, text=True)
if check and p.returncode: if check and p.returncode:
err = (p.stderr or p.stdout)[-400:] err = (p.stderr or p.stdout)[-400:]
@@ -1180,6 +1191,12 @@ def update_branch(branch: str, base: str, message: str) -> tuple[str | None, str
try: try:
git("clone", "-q", "--branch", branch, url, ".") git("clone", "-q", "--branch", branch, url, ".")
git("remote", "set-url", "origin", _git_url(False)) # the token must not stay in .git/config git("remote", "set-url", "origin", _git_url(False)) # the token must not stay in .git/config
# two PRs that each add a CHANGELOG line at the same spot conflict, and keeping both sides' lines is the answer:
# git's union driver does that (the repo's .gitattributes says the same, but an old branch may lack it). Its
# cost: a bullet one side reworded or deleted next to the other side's new one comes back beside it, unflagged
os.makedirs(os.path.join(d, ".git", "info"), exist_ok=True)
with open(os.path.join(d, ".git", "info", "attributes"), "a") as f:
f.write("".join(f"{x} merge=union\n" for x in UNION_MERGE))
before = git("rev-parse", "HEAD").stdout.strip() before = git("rev-parse", "HEAD").stdout.strip()
git("fetch", "-q", url, base) git("fetch", "-q", url, base)
if git("merge", "--no-ff", "-q", "-m", message, "FETCH_HEAD", check=False).returncode: if git("merge", "--no-ff", "-q", "-m", message, "FETCH_HEAD", check=False).returncode:
+1
View File
@@ -17,6 +17,7 @@
- The Venice worker's shell no longer sees secrets: its environment drops tokens and keys, its checkout keeps no token in `.git/config`, and the workflows check out without persisted credentials. - The Venice worker's shell no longer sees secrets: its environment drops tokens and keys, its checkout keeps no token in `.git/config`, and the workflows check out without persisted credentials.
### Changed ### Changed
- Time's merge commit on a cloud PR's branch survives sync. When Time merges the base into an approved PR that fell behind, on a branch sync.py brought in from the GitHub copy, sync now sends that commit back to the copy fast-forward (`back` in its log line); it used to see the home's branch differ from the copy's and force the copy's older tip over it on the next run, undoing the merge. Only Time's own merges go back, and only while the copy's branch is where sync read it: anything else ahead at the home (the copy rewinding its own branch, a push at the home) is not sent out, and the copy, the branch's writer, wins as before. Time's merge keeps both sides' lines when the root CHANGELOG.md conflicts (git's union driver; the new `.gitattributes` says the same to anyone merging), the conflict that sent #172's approved PR #203 to a human. The cost: a bullet one side reworded or deleted beside the other side's new one comes back next to it, with no flag. A conflict on a branch sync.py brought in now says the fix goes to the copy rather than `/time retry`, whose worker would commit to the home and be overwritten by the next sync.
- The monthly cap (`TIME_MONTHLY_USD`) counts the owner's own money only. Spend that an `expiring` pool's prepaid credit paid for (its routes, from its `as_of` until it lapses, up to the balance it had) is left out of the month, since the credit's own balance limits it; spend from before `as_of`, past the balance or the lapse, or on a route with no pool counts as before. With `anthropic-credits` pacing Claude at about $8/day, the $100 cap would have tripped around 2026-10-26 and put Time in dry-run for five days with credit left to lapse on 2026-11-08. `budget()`'s month, the cap's refusal, the andon, `doctor` and Astra's line all say "out of pocket". - The monthly cap (`TIME_MONTHLY_USD`) counts the owner's own money only. Spend that an `expiring` pool's prepaid credit paid for (its routes, from its `as_of` until it lapses, up to the balance it had) is left out of the month, since the credit's own balance limits it; spend from before `as_of`, past the balance or the lapse, or on a route with no pool counts as before. With `anthropic-credits` pacing Claude at about $8/day, the $100 cap would have tripped around 2026-10-26 and put Time in dry-run for five days with credit left to lapse on 2026-11-08. `budget()`'s month, the cap's refusal, the andon, `doctor` and Astra's line all say "out of pocket".
- The router never picks a card that waits, and promotes it after the ones that can move. A card waits when its `Depends on #N` card is still open, or when the seat marks it `blocked` with what it waits on (a PR that can't merge, a human): the router's first live run queued #84, whose PR #75 can't merge against main, because the seat scored it top while writing that it was blocked. The table shows what a card waits on. `astra.yml` now passes `TIME_WORKER` and `TIME_PACE`, without which Astra's money left fell back to the daily cap. - The router never picks a card that waits, and promotes it after the ones that can move. A card waits when its `Depends on #N` card is still open, or when the seat marks it `blocked` with what it waits on (a PR that can't merge, a human): the router's first live run queued #84, whose PR #75 can't merge against main, because the seat scored it top while writing that it was blocked. The table shows what a card waits on. `astra.yml` now passes `TIME_WORKER` and `TIME_PACE`, without which Astra's money left fell back to the daily cap.
- The router's money left is the worker's pool (#175): live mode queues against the allowance left in the pool that takes `TIME_WORKER`'s route (about $8/day from `anthropic-credits` while the credit lasts), or the daily cap's remainder without one, never more than the month has left; it was `TIME_DAILY_USD` minus today's spend, money the worker couldn't spend. The Astra line and the seat's prompt show the figure and its source. The seat also sees the cards closed in the last 14 days and is told not to write them again (it proposed the housekeeping label fix two days running after #137 had landed), and a new card whose title is an open or lately closed card's, or contains or is contained in one of four words or more, is not written; the log lists it under "Not written". - The router's money left is the worker's pool (#175): live mode queues against the allowance left in the pool that takes `TIME_WORKER`'s route (about $8/day from `anthropic-credits` while the credit lasts), or the daily cap's remainder without one, never more than the month has left; it was `TIME_DAILY_USD` minus today's spend, money the worker couldn't spend. The Astra line and the seat's prompt show the figure and its source. The seat also sees the cards closed in the last 14 days and is told not to write them again (it proposed the housekeeping label fix two days running after #137 had landed), and a new card whose title is an open or lately closed card's, or contains or is contained in one of four words or more, is not written; the log lists it under "Not written".