From 39556a0360b9052fe2c326f1c085754b3e11499c Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Mon, 19 Oct 2026 21:51:19 +0000 Subject: [PATCH 01/10] update_branch keeps both sides of a CHANGELOG conflict; a conflict on an imported branch points at the copy --- .github/time/time.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/time/time.py b/.github/time/time.py index 17b28b3..fa737c8 100644 --- a/.github/time/time.py +++ b/.github/time/time.py @@ -906,6 +906,9 @@ def dispatch_ci(pr: dict) -> bool: 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: """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 @@ -915,9 +918,12 @@ 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"] sha, hunks = update_branch(branch, base, f"card #{n}: merge {base}") if sha is None: + # a branch sync.py brought in from a copy is the copy's to write: the worker's commits would be overwritten + then = ("The branch is the copy's to write (sync.py brought it in), so the fix goes there: the cloud lane sends " + "its session back with these hunks, or 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: " - f"I left `{branch}` as it was. Merge `origin/{base}` into it and resolve:\n\n{hunks}\n\n" - "Comment `/time retry` to send the worker back to the branch with these hunks.") + f"I left `{branch}` as it was. Merge `origin/{base}` into it and resolve:\n\n{hunks}\n\n{then}") gh("POST", f"/repos/{REPO}/issues/{pr['number']}/comments", {"body": text}) # reviewer_feedback reads the PR's comments label(pr["number"], "needs-human") comment(n, text, {"beat": "needs-human", "pr": pr["number"], "conflict": True}) @@ -1162,6 +1168,9 @@ def git_commit(branch: str, files: dict[str, bytes | None], message: str, *, bas shutil.rmtree(d, ignore_errors=True) +UNION_MERGE = ("CHANGELOG.md",) # files whose conflicts update_branch settles by keeping both sides' lines + + def update_branch(branch: str, base: str, message: str) -> tuple[str | None, str]: """Merge origin/ into 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. @@ -1180,6 +1189,11 @@ def update_branch(branch: str, base: str, message: str) -> tuple[str | None, str try: git("clone", "-q", "--branch", branch, url, ".") 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 always the + # answer: git's union driver does that (the repo's .gitattributes says the same, but an old branch may lack it) + 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() git("fetch", "-q", url, base) if git("merge", "--no-ff", "-q", "-m", message, "FETCH_HEAD", check=False).returncode: -- 2.54.0 From d69acab7f7d607b292fd94aebb1481e95678c8b4 Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Mon, 19 Oct 2026 21:51:34 +0000 Subject: [PATCH 02/10] sync: Time's merge on an imported branch goes back to the copy fast-forward instead of being overwritten --- .github/time/sync.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/time/sync.py b/.github/time/sync.py index 5e4556d..d1019fa 100644 --- a/.github/time/sync.py +++ b/.github/time/sync.py @@ -13,6 +13,10 @@ 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 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. +- 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 (the copy's tip is in it), those commits go back to the copy + fast-forward, so the copy's next session builds on them instead of overwriting them. When both sides have commits of + their own, the copy wins, as its writer: Time merges the base again on a later beat. time/sync.json: {"repos": [{"repo": "teahubxyz/time", "out": ["main"], @@ -204,7 +208,7 @@ def ensure_pr(repo: str, work: str, branch: str, base: str, copy: str, where: st title = (log.splitlines() or [branch])[0][:200] 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 "" - 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\nsync.py: edit this branch on {copy}; it is the copy's to write, and the " f"next sync brings the change here.") n = tm.gh("POST", f"/repos/{repo}/pulls", {"head": branch, "base": base, "title": title, "body": body})["number"] @@ -257,6 +261,15 @@ def sync_repo(entry: dict, home_url: str, force: bool = False) -> dict: continue git("fetch", "-q", url, f"+refs/heads/{branch}:refs/copy/{branch}", cwd=work) moved = mine.get(branch) != sha + if moved and mine.get(branch): + git("fetch", "-q", home_url, f"+refs/heads/{branch}:refs/back/{branch}", cwd=work) + if landed(work, f"refs/copy/{branch}", f"refs/back/{branch}"): # the home is ahead: Time's merge + p = git("push", "-q", 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 git("push", "-q", "--force", home_url, f"refs/copy/{branch}:refs/heads/{branch}", cwd=work) r["imported"].append(branch) @@ -296,7 +309,7 @@ def main() -> int: rep = sync_repo(e, authed(f"{tm.SERVER}/{repo}.git", token), force) for c in rep["copies"]: 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(): if state == "diverged" and repo == tm.REPO: flag(repo, c["copy"], b) -- 2.54.0 From 86e845037bb6f11f54d2d8245c46e205beb87c94 Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Mon, 19 Oct 2026 21:51:57 +0000 Subject: [PATCH 03/10] tests: sync sends Time's merge back, the copy wins a divergence, CHANGELOG conflicts union-merge, the imported hint --- .github/time/test_time.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/.github/time/test_time.py b/.github/time/test_time.py index cb28426..98ca51e 100644 --- a/.github/time/test_time.py +++ b/.github/time/test_time.py @@ -1520,6 +1520,28 @@ comments83[7] = []; notes83.clear() 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 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) +commit83(wb83, "m.txt", "card #7: merge main"); g83("push", "-q", "origin", "claude/card-7-cloud", cwd=wb83) +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) +g83("fetch", "-q", "origin", "claude/card-7-cloud", cwd=wb83); g83("merge", "-q", "--ff-only", "FETCH_HEAD", cwd=wb83) +commit83(wb83, "m.txt", "card #7: merge main again"); g83("push", "-q", "origin", "claude/card-7-cloud", cwd=wb83) +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) +assert sy.tm.IMPORTED in made83[0]["body"], "time.py knows a PR sync.py opened by its body" sy.tm.gh = real_gh83 # 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"}]}, @@ -1590,6 +1612,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 "<<<<<<< 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)" +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 w.comments[1].append({"created_at": iso(-1), "user": {"login": "owner"}, "body": "/time retry"}) w.issues[7]["labels"] = ["needs-human"] @@ -1621,6 +1647,16 @@ try: 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 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: tm.SERVER = old_server print("ALL OK 123") -- 2.54.0 From c95e1accc15f63849afdfcfebdc870d7dc819bb4 Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Mon, 19 Oct 2026 21:52:07 +0000 Subject: [PATCH 04/10] CHANGELOG: sync sends Time's merge back; CHANGELOG conflicts union-merge --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13ffd51..4dca87c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. ### 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. When both sides have commits of their own the copy, the branch's writer, still wins and Time merges again later. Time's merge keeps both sides' lines when 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. 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 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". -- 2.54.0 From 51f8e484e9dbfc36f69c50a027796624bbf74943 Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Mon, 19 Oct 2026 21:52:10 +0000 Subject: [PATCH 05/10] .gitattributes: a CHANGELOG conflict keeps both sides' lines --- .gitattributes | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a19ade0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +CHANGELOG.md merge=union -- 2.54.0 From 75433e5bb3bdb9330a675d5d0955aff01acb03be Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Mon, 19 Oct 2026 22:15:18 +0000 Subject: [PATCH 06/10] 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 --- .github/time/time.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/time/time.py b/.github/time/time.py index fa737c8..f1cb595 100644 --- a/.github/time/time.py +++ b/.github/time/time.py @@ -918,9 +918,10 @@ 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"] sha, hunks = update_branch(branch, base, f"card #{n}: merge {base}") if sha is None: - # a branch sync.py brought in from a copy is the copy's to write: the worker's commits would be overwritten - then = ("The branch is the copy's to write (sync.py brought it in), so the fix goes there: the cloud lane sends " - "its session back with these hunks, or a human merges on the copy." if IMPORTED in (pr.get("body") or "") + # 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: " f"I left `{branch}` as it was. Merge `origin/{base}` into it and resolve:\n\n{hunks}\n\n{then}") @@ -1168,7 +1169,8 @@ def git_commit(branch: str, files: dict[str, bytes | None], message: str, *, bas shutil.rmtree(d, ignore_errors=True) -UNION_MERGE = ("CHANGELOG.md",) # files whose conflicts update_branch settles by keeping both sides' lines +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]: @@ -1180,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" 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) if check and p.returncode: err = (p.stderr or p.stdout)[-400:] @@ -1189,8 +1191,9 @@ def update_branch(branch: str, base: str, message: str) -> tuple[str | None, str try: git("clone", "-q", "--branch", branch, url, ".") 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 always the - # answer: git's union driver does that (the repo's .gitattributes says the same, but an old branch may lack it) + # 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)) -- 2.54.0 From 3ab388154e9dcdc23c16bd549d32c87de3ab7a14 Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Mon, 19 Oct 2026 22:15:26 +0000 Subject: [PATCH 07/10] review: send back only Time's own merges, under a lease on the copy's branch; a failed home fetch falls through --- .github/time/sync.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/.github/time/sync.py b/.github/time/sync.py index d1019fa..d81f8fc 100644 --- a/.github/time/sync.py +++ b/.github/time/sync.py @@ -14,9 +14,10 @@ cloud session's branch on the copy), and a pull mirror makes the receiving repo 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. - 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 (the copy's tip is in it), those commits go back to the copy - fast-forward, so the copy's next session builds on them instead of overwriting them. When both sides have commits of - their own, the copy wins, as its writer: Time merges the base again on a later beat. + 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: {"repos": [{"repo": "teahubxyz/time", "out": ["main"], @@ -138,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 +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) @@ -261,10 +272,12 @@ def sync_repo(entry: dict, home_url: str, force: bool = False) -> dict: continue git("fetch", "-q", url, f"+refs/heads/{branch}:refs/copy/{branch}", cwd=work) moved = mine.get(branch) != sha - if moved and mine.get(branch): - git("fetch", "-q", home_url, f"+refs/heads/{branch}:refs/back/{branch}", cwd=work) - if landed(work, f"refs/copy/{branch}", f"refs/back/{branch}"): # the home is ahead: Time's merge - p = git("push", "-q", url, f"refs/back/{branch}:refs/heads/{branch}", cwd=work, check=False) + 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 -- 2.54.0 From 3ea828983353935340253f379d22d6398687af2e Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Mon, 19 Oct 2026 22:15:49 +0000 Subject: [PATCH 08/10] review: tests use Time's real merges, and a push at the home or a rewind on the copy never goes back --- .github/time/test_time.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/.github/time/test_time.py b/.github/time/test_time.py index 98ca51e..e1757a1 100644 --- a/.github/time/test_time.py +++ b/.github/time/test_time.py @@ -1523,7 +1523,15 @@ assert sy.revised("o/r", "claude/card-7-cloud", None) == [] and sy.revised("x/y" # 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) -commit83(wb83, "m.txt", "card #7: merge main"); g83("push", "-q", "origin", "claude/card-7-cloud", cwd=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 @@ -1535,12 +1543,25 @@ 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) -g83("fetch", "-q", "origin", "claude/card-7-cloud", cwd=wb83); g83("merge", "-q", "--ff-only", "FETCH_HEAD", cwd=wb83) -commit83(wb83, "m.txt", "card #7: merge main again"); g83("push", "-q", "origin", "claude/card-7-cloud", cwd=wb83) +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 # a copy whose token isn't set is skipped; tokens go in the URL and never into anything printed -- 2.54.0 From 72f89982e68044ee2e2bd147f8a27477a99214bd Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Mon, 19 Oct 2026 22:15:52 +0000 Subject: [PATCH 09/10] review: CHANGELOG says only Time's merges go back, and what union merge costs --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dca87c..16e9cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +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. ### 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. When both sides have commits of their own the copy, the branch's writer, still wins and Time merges again later. Time's merge keeps both sides' lines when 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. 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. +- 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 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". -- 2.54.0 From 7f3042ee28c7733dafe4741e47662e8e6a6f7f42 Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Mon, 19 Oct 2026 22:15:55 +0000 Subject: [PATCH 10/10] review: .gitattributes names the root CHANGELOG only --- .gitattributes | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index a19ade0..3d432e0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1 @@ -CHANGELOG.md merge=union +/CHANGELOG.md merge=union -- 2.54.0