Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f3042ee28 | ||
|
|
72f89982e6 | ||
|
|
3ea8289833 | ||
|
|
3ab388154e | ||
|
|
75433e5bb3 | ||
|
|
51f8e484e9 | ||
|
|
c95e1accc1 | ||
|
|
86e845037b | ||
|
|
d69acab7f7 | ||
|
|
39556a0360 |
No files matched your search
@@ -0,0 +1 @@
|
||||
/CHANGELOG.md merge=union
|
||||
+28
-2
@@ -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
|
||||
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 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"],
|
||||
@@ -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
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -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]
|
||||
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\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>")
|
||||
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
|
||||
git("fetch", "-q", url, f"+refs/heads/{branch}:refs/copy/{branch}", cwd=work)
|
||||
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
|
||||
git("push", "-q", "--force", home_url, f"refs/copy/{branch}:refs/heads/{branch}", cwd=work)
|
||||
r["imported"].append(branch)
|
||||
@@ -296,7 +322,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)
|
||||
|
||||
@@ -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)
|
||||
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)
|
||||
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
|
||||
# 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 +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 "<<<<<<< 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 +1668,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")
|
||||
|
||||
+20
-3
@@ -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,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"]
|
||||
sha, hunks = update_branch(branch, base, f"card #{n}: merge {base}")
|
||||
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: "
|
||||
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 +1169,10 @@ 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
|
||||
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]:
|
||||
"""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.
|
||||
@@ -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"
|
||||
|
||||
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:]
|
||||
@@ -1180,6 +1191,12 @@ 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 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()
|
||||
git("fetch", "-q", url, base)
|
||||
if git("merge", "--no-ff", "-q", "-m", message, "FETCH_HEAD", check=False).returncode:
|
||||
|
||||
@@ -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. 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".
|
||||
|
||||
Reference in new issue
Block a user