Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f3042ee28 | ||
|
|
72f89982e6 | ||
|
|
3ea8289833 | ||
|
|
3ab388154e | ||
|
|
75433e5bb3 | ||
|
|
51f8e484e9 | ||
|
|
c95e1accc1 | ||
|
|
86e845037b | ||
|
|
d69acab7f7 | ||
|
|
39556a0360 | ||
|
|
a4e052d248 | ||
|
|
e0c2955d74 | ||
|
|
99a9230d27 | ||
|
|
2e2329dd86 | ||
|
|
da2ea2d6a8 | ||
|
|
157c91f06d |
No files matched your search
@@ -0,0 +1 @@
|
||||
/CHANGELOG.md merge=union
|
||||
+67
-3
@@ -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)
|
||||
|
||||
|
||||
@@ -158,6 +173,37 @@ def hand_back(repo: str, card: int, pr: int) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
REVIEWING = ("pr", "needs-human", "no-checks", "checks-failed") # a card whose PR is being or has been reviewed
|
||||
|
||||
|
||||
def revised(repo: str, branch: str, before: str | None) -> list[int]:
|
||||
"""The copy pushed new commits to a branch whose PR is open and already reviewed (a cloud session answering the
|
||||
review): each card the PR closes gets a `dispatched` beat marked `revised`, so the heartbeat reviews the new head
|
||||
and a verdict from before the push no longer counts, and the PR loses `needs-human`. A card not yet reviewed (the
|
||||
heartbeat hasn't adopted the PR) gets nothing: its first review sees the new head anyway. `before` is the home's
|
||||
head of the branch before this import; a branch new to the home is never a revision."""
|
||||
if repo != tm.REPO or not before:
|
||||
return []
|
||||
pr = next((p for p in tm.gh("GET", f"/repos/{repo}/pulls?state=open&limit=50") or []
|
||||
if (p.get("head") or {}).get("ref") == branch), None)
|
||||
if not pr:
|
||||
return []
|
||||
cards = {card_of(branch)} | {int(x) for x in re.findall(r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)\b", pr.get("body") or "", re.I)}
|
||||
done = []
|
||||
for c in sorted(x for x in cards if x):
|
||||
beats = [b for b in tm.beats_of(tm.gh("GET", f"/repos/{repo}/issues/{c}/comments?limit=100") or [])
|
||||
if b["beat"] not in ("blocked", "capped")]
|
||||
if not beats or beats[-1]["beat"] not in REVIEWING:
|
||||
continue
|
||||
tm.comment(c, f"☁️ The cloud session pushed a revision to PR #{pr['number']}. The heartbeat reviews it again; "
|
||||
f"a verdict from before the push no longer counts.",
|
||||
{"beat": "dispatched", "pr": pr["number"], "revised": True, "cloud": True})
|
||||
done.append(c)
|
||||
if done and tm.has_label(pr, "needs-human"):
|
||||
tm.unlabel(pr["number"], "needs-human")
|
||||
return done
|
||||
|
||||
|
||||
def ensure_pr(repo: str, work: str, branch: str, base: str, copy: str, where: str) -> int | None:
|
||||
"""A PR on the home for an imported branch, unless one is open for it, it is already in the base, or a PR for it
|
||||
was closed at this same commit (a human or the council said no; a new push brings it back)."""
|
||||
@@ -173,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"]
|
||||
@@ -225,12 +271,29 @@ def sync_repo(entry: dict, home_url: str, force: bool = False) -> dict:
|
||||
if branch in out or not any(fnmatch.fnmatch(branch, p) for p in pats):
|
||||
continue
|
||||
git("fetch", "-q", url, f"+refs/heads/{branch}:refs/copy/{branch}", cwd=work)
|
||||
if mine.get(branch) != sha: # the copy writes this branch: the home takes it as it is
|
||||
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)
|
||||
n = ensure_pr(repo, work, branch, base, name, where)
|
||||
if n:
|
||||
r["prs"].append(n)
|
||||
elif moved:
|
||||
try:
|
||||
r.setdefault("revised", []).extend(revised(repo, branch, mine.get(branch)))
|
||||
except Exception as e: # noqa: BLE001 the commits are home either way; the next review sees them
|
||||
print(f"revised {branch}: {scrub(str(e))[:200]}")
|
||||
ENV.clear()
|
||||
return report
|
||||
|
||||
@@ -258,7 +321,8 @@ def main() -> int:
|
||||
repo = e.get("repo") or tm.REPO
|
||||
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']} {c['note']}".rstrip())
|
||||
print(f"sync {repo} -> {c['copy']}: out {c['out']} imported {c['imported']} prs {c['prs']} "
|
||||
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)
|
||||
|
||||
@@ -1498,6 +1498,71 @@ g83("checkout", "-qb", "claude/session-abc123", cwd=cw83); commit83(cw83, "i.txt
|
||||
g83("push", "-q", "origin", "claude/session-abc123", cwd=cw83)
|
||||
sync83()
|
||||
assert moves83 == [("-", 9, "cloud"), ("+", 9, "ready")], "a card the commits close leaves the lane too"
|
||||
# the cloud session answers a review on the same branch: the card gets a `dispatched` beat marked `revised` (so the
|
||||
# heartbeat reviews the new head and the old objection stops counting) and the PR loses `needs-human`
|
||||
beat83 = lambda b: {"user": {"login": sy.tm.TIME_BOT}, "created_at": "2026-10-19T18:13:21Z", "body": "x\n<!-- time " + json.dumps({"beat": b, "pr": 104}) + " -->"}
|
||||
comments83 = {7: [beat83("pr"), beat83("needs-human")], 9: []}
|
||||
pr7_open = {"number": 104, "state": "open", "head": {"ref": "claude/card-7-cloud", "sha": "?"}, "body": pr7["body"], "labels": [{"name": "needs-human"}]}
|
||||
def gh83r(m, path, body=None, **k):
|
||||
if path.endswith("/comments?limit=100"):
|
||||
return comments83.get(int(path.split("/issues/")[1].split("/")[0]), [])
|
||||
if path.startswith("/repos/o/r/pulls?state=open"):
|
||||
return [pr7_open]
|
||||
return sy.tm.gh_inner(m, path, body, **k)
|
||||
sy.tm.gh_inner, sy.tm.gh = sy.tm.gh, gh83r
|
||||
notes83.clear(); moves83.clear()
|
||||
prs83[:] = [dict(p, state="open") if p["head"]["ref"] == "claude/card-7-cloud" else p for p in prs83]
|
||||
g83("checkout", "-q", "claude/card-7-cloud", cwd=cw83); commit83(cw83, "g.txt", "Answer the review\n\nCloses #7"); g83("push", "-q", "origin", "claude/card-7-cloud", cwd=cw83)
|
||||
c83 = sync83()
|
||||
assert c83["revised"] == [7] and notes83[-1][0] == 7 and notes83[-1][2] == {"beat": "dispatched", "pr": 104, "revised": True, "cloud": True}, (c83, notes83)
|
||||
assert ("-", 104, "needs-human") in moves83, moves83
|
||||
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"}]},
|
||||
@@ -1568,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"]
|
||||
@@ -1599,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".
|
||||
@@ -28,6 +29,7 @@
|
||||
- The second fix round escalates to a different model from the first round: `claude:opus` with the Claude seats (it was `venice:kimi code`, and grok 4.7 before that). `TIME_VENTURE_ESCALATE` overrides it.
|
||||
|
||||
### Added
|
||||
- A cloud PR's revision comes from the cloud, and Time reviews it again. The branch of a cloud-lane PR is the copy's to write, so `/time retry` (the API worker pushing to it) would be overwritten by the next sync. When the reviewer objects instead, the launcher on hb-5 starts one more cloud session on the same branch with the review in its prompt (once; then a human). When the copy's branch moves under a PR that is open and already reviewed, sync.py posts a `dispatched` beat marked `revised` on each card the PR closes and takes `needs-human` off the PR, so the heartbeat reviews the new head and a verdict from before the push no longer counts. A PR not reviewed yet gets nothing; its first review sees the new head.
|
||||
- The cloud lane: Claude Code cloud sessions build cards on the owner's cloud-session credit, beside the API worker. With `cloud: {"per_day": N, "sizes": ["M"]}` in `time/router.json`, Astra's live run labels the best-ranked actionable cards of those sizes `cloud`, until N cards are in the lane, before the API worker's picks; a `cloud` card is lined up, so it is never queued for the API worker. A launcher on hb-5 (a user agent in the desktop session, where the claude.ai sign-in lives; it reads the public issue list and holds no Teahub credentials) starts one `claude --cloud` session per card, with the card as the prompt, on the GitHub copy. The session pushes a `claude/card-N-*` branch; sync.py brings it home as a PR that closes the card (adding `Closes #N` when the commits don't), moves the card from `cloud` to `ready` with a note that carries no beat, and the heartbeat adopts the PR, so the council reviews and lands it like any card PR. A card the commits close leaves the lane too, whatever the branch is called. `per_day: 0`, the default, is no lane.
|
||||
- Time brings a PR branch up to date with its base (#123). An approved PR Gitea reports as not `mergeable` (or whose merge call answers 409) gets `git merge origin/<base>` (the PR's own base) pushed as one commit by Time, `card #N: merge <base>`; CI runs on the new head and landing follows on a later beat (a `ci-dispatched` or `updated` beat now reaches `land()` again). A conflict is not guessed at: the merge is aborted, the branch left as it was, and the conflicting files and `<<<<<<<` blocks (trimmed) are posted on the card and the PR with the `needs-human` label; `/time retry` hands them to the worker as the reviewer's text. Known limit: the worker's `write_file` of a resolved file is a plain commit, not the merge, so Time's next try can conflict again.
|
||||
- A card waiting on a person holds no `ready` slot (#135). `promote_next` counts toward `TIME_PARALLEL` only the `ready` cards that are moving; one whose last real beat is `needs-human`, `error` or `stuck` (an objection, or a failure Time won't retry again) keeps its label, so `/time retry` still finds it, but frees its slot. Three such cards held every slot twice, the second time for ten hours on 2026-10-19 while 22 cards sat `queued`. The run log prints `promote: ready=N moving=M`.
|
||||
|
||||
@@ -873,3 +873,86 @@
|
||||
{"at":"2026-10-19T16:15:05Z","model":"claude-sonnet-5-5","pool":"anthropic","in":8983,"out":1995,"usd":0.024501,"cached":7785,"written":1196}
|
||||
{"at":"2026-10-19T16:15:12Z","model":"claude-sonnet-5-5","pool":"anthropic","in":11165,"out":205,"usd":0.009305,"cached":8981,"written":2182}
|
||||
{"at":"2026-10-19T16:15:20Z","model":"claude-sonnet-5-5","pool":"anthropic","in":11715,"out":415,"usd":0.007762,"cached":11163,"written":550}
|
||||
{"at":"2026-10-19T16:47:50Z","model":"claude-opus-5-5","pool":"anthropic","in":19637,"out":3822,"usd":0.174621,"cached":0,"written":19633}
|
||||
{"at":"2026-10-19T17:07:47Z","model":"claude-haiku-5-5","pool":"anthropic","in":2625,"out":1376,"usd":0.001016,"cached":0,"written":2621}
|
||||
{"at":"2026-10-19T17:07:54Z","model":"claude-sonnet-5-5","pool":"anthropic","in":2684,"out":26,"usd":0.006968,"cached":0,"written":2680}
|
||||
{"at":"2026-10-19T17:07:56Z","model":"claude-sonnet-5-5","pool":"anthropic","in":3411,"out":108,"usd":0.003442,"cached":2680,"written":729}
|
||||
{"at":"2026-10-19T17:07:59Z","model":"claude-sonnet-5-5","pool":"anthropic","in":55764,"out":267,"usd":0.134238,"cached":3409,"written":52353}
|
||||
{"at":"2026-10-19T17:08:04Z","model":"claude-sonnet-5-5","pool":"anthropic","in":58665,"out":304,"usd":0.021449,"cached":55762,"written":2901}
|
||||
{"at":"2026-10-19T17:08:12Z","model":"claude-sonnet-5-5","pool":"anthropic","in":59800,"out":110,"usd":0.015674,"cached":58663,"written":1135}
|
||||
{"at":"2026-10-19T17:08:20Z","model":"claude-sonnet-5-5","pool":"anthropic","in":62584,"out":295,"usd":0.021874,"cached":59798,"written":2784}
|
||||
{"at":"2026-10-19T17:08:32Z","model":"claude-sonnet-5-5","pool":"anthropic","in":66723,"out":438,"usd":0.027248,"cached":62582,"written":4139}
|
||||
{"at":"2026-10-19T17:08:39Z","model":"claude-sonnet-5-5","pool":"anthropic","in":69506,"out":633,"usd":0.026636,"cached":66721,"written":2783}
|
||||
{"at":"2026-10-19T17:08:41Z","model":"claude-sonnet-5-5","pool":"anthropic","in":72669,"out":160,"usd":0.023412,"cached":69504,"written":3163}
|
||||
{"at":"2026-10-19T17:08:44Z","model":"claude-sonnet-5-5","pool":"anthropic","in":74101,"out":75,"usd":0.018867,"cached":72667,"written":1432}
|
||||
{"at":"2026-10-19T17:08:47Z","model":"claude-sonnet-5-5","pool":"anthropic","in":76475,"out":141,"usd":0.022169,"cached":74099,"written":2374}
|
||||
{"at":"2026-10-19T17:08:50Z","model":"claude-sonnet-5-5","pool":"anthropic","in":77789,"out":283,"usd":0.021414,"cached":76473,"written":1314}
|
||||
{"at":"2026-10-19T17:08:55Z","model":"claude-sonnet-5-5","pool":"anthropic","in":80571,"out":343,"usd":0.025946,"cached":77787,"written":2782}
|
||||
{"at":"2026-10-19T17:09:02Z","model":"claude-sonnet-5-5","pool":"anthropic","in":81909,"out":702,"usd":0.026483,"cached":80569,"written":1338}
|
||||
{"at":"2026-10-19T17:09:05Z","model":"claude-sonnet-5-5","pool":"anthropic","in":84108,"out":239,"usd":0.024273,"cached":81907,"written":2199}
|
||||
{"at":"2026-10-19T17:09:08Z","model":"claude-sonnet-5-5","pool":"anthropic","in":86112,"out":176,"usd":0.023595,"cached":84106,"written":2004}
|
||||
{"at":"2026-10-19T17:09:21Z","model":"claude-sonnet-5-5","pool":"anthropic","in":86939,"out":1468,"usd":0.033973,"cached":86110,"written":827}
|
||||
{"at":"2026-10-19T17:09:38Z","model":"claude-sonnet-5-5","pool":"anthropic","in":90090,"out":2800,"usd":0.053269,"cached":86937,"written":3151}
|
||||
{"at":"2026-10-19T17:09:42Z","model":"claude-sonnet-5-5","pool":"anthropic","in":93002,"out":168,"usd":0.026982,"cached":90088,"written":2912}
|
||||
{"at":"2026-10-19T17:09:46Z","model":"claude-sonnet-5-5","pool":"anthropic","in":93189,"out":242,"usd":0.021492,"cached":93000,"written":187}
|
||||
{"at":"2026-10-19T17:10:02Z","model":"claude-sonnet-5-5","pool":"anthropic","in":93824,"out":998,"usd":0.030209,"cached":93187,"written":635}
|
||||
{"at":"2026-10-19T17:10:11Z","model":"claude-sonnet-5-5","pool":"anthropic","in":95156,"out":169,"usd":0.023788,"cached":93822,"written":1332}
|
||||
{"at":"2026-10-19T17:10:14Z","model":"claude-sonnet-5-5","pool":"anthropic","in":97577,"out":166,"usd":0.026747,"cached":95154,"written":2421}
|
||||
{"at":"2026-10-19T17:10:26Z","model":"claude-sonnet-5-5","pool":"anthropic","in":98056,"out":146,"usd":0.022176,"cached":97575,"written":479}
|
||||
{"at":"2026-10-19T17:10:33Z","model":"claude-sonnet-5-5","pool":"anthropic","in":99804,"out":758,"usd":0.031565,"cached":98054,"written":1748}
|
||||
{"at":"2026-10-19T17:10:42Z","model":"claude-sonnet-5-5","pool":"anthropic","in":100901,"out":144,"usd":0.024147,"cached":99802,"written":1097}
|
||||
{"at":"2026-10-19T17:10:44Z","model":"claude-sonnet-5-5","pool":"anthropic","in":103079,"out":99,"usd":0.026619,"cached":100899,"written":2178}
|
||||
{"at":"2026-10-19T17:10:47Z","model":"claude-sonnet-5-5","pool":"anthropic","in":104128,"out":165,"usd":0.024892,"cached":103077,"written":1049}
|
||||
{"at":"2026-10-19T17:10:57Z","model":"claude-sonnet-5-5","pool":"anthropic","in":104331,"out":265,"usd":0.023987,"cached":104126,"written":203}
|
||||
{"at":"2026-10-19T17:11:07Z","model":"claude-sonnet-5-5","pool":"anthropic","in":105530,"out":375,"usd":0.027617,"cached":104329,"written":1199}
|
||||
{"at":"2026-10-19T17:11:15Z","model":"claude-sonnet-5-5","pool":"anthropic","in":107389,"out":1116,"usd":0.036917,"cached":105528,"written":1859}
|
||||
{"at":"2026-10-19T17:11:23Z","model":"claude-sonnet-5-5","pool":"anthropic","in":109277,"out":207,"usd":0.028271,"cached":107387,"written":1888}
|
||||
{"at":"2026-10-19T17:11:29Z","model":"claude-sonnet-5-5","pool":"anthropic","in":109978,"out":318,"usd":0.026791,"cached":109275,"written":701}
|
||||
{"at":"2026-10-19T17:11:42Z","model":"claude-sonnet-5-5","pool":"anthropic","in":111036,"out":965,"usd":0.034294,"cached":109976,"written":1058}
|
||||
{"at":"2026-10-19T17:11:53Z","model":"claude-sonnet-5-5","pool":"anthropic","in":112476,"out":361,"usd":0.029421,"cached":111034,"written":1440}
|
||||
{"at":"2026-10-19T17:12:05Z","model":"claude-sonnet-5-5","pool":"anthropic","in":113035,"out":451,"usd":0.028406,"cached":112474,"written":559}
|
||||
{"at":"2026-10-19T17:12:15Z","model":"claude-sonnet-5-5","pool":"anthropic","in":113666,"out":402,"usd":0.028208,"cached":113033,"written":631}
|
||||
{"at":"2026-10-19T17:12:24Z","model":"claude-sonnet-5-5","pool":"anthropic","in":114352,"out":184,"usd":0.026292,"cached":113664,"written":686}
|
||||
{"at":"2026-10-19T17:12:34Z","model":"claude-sonnet-5-5","pool":"anthropic","in":116189,"out":276,"usd":0.030226,"cached":114350,"written":1837}
|
||||
{"at":"2026-10-19T17:12:39Z","model":"claude-sonnet-5-5","pool":"anthropic","in":117082,"out":619,"usd":0.031664,"cached":116187,"written":893}
|
||||
{"at":"2026-10-19T18:07:16Z","model":"claude-sonnet-5-5","pool":"anthropic","in":2684,"out":26,"usd":0.006968,"cached":0,"written":2680}
|
||||
{"at":"2026-10-19T18:07:18Z","model":"claude-sonnet-5-5","pool":"anthropic","in":3411,"out":176,"usd":0.004123,"cached":2680,"written":729}
|
||||
{"at":"2026-10-19T18:07:22Z","model":"claude-sonnet-5-5","pool":"anthropic","in":30779,"out":317,"usd":0.072276,"cached":3409,"written":27368}
|
||||
{"at":"2026-10-19T18:07:25Z","model":"claude-sonnet-5-5","pool":"anthropic","in":33959,"out":186,"usd":0.015969,"cached":30777,"written":3180}
|
||||
{"at":"2026-10-19T18:07:35Z","model":"claude-sonnet-5-5","pool":"anthropic","in":35225,"out":383,"usd":0.01379,"cached":33957,"written":1266}
|
||||
{"at":"2026-10-19T18:07:40Z","model":"claude-sonnet-5-5","pool":"anthropic","in":38375,"out":466,"usd":0.019584,"cached":35223,"written":3150}
|
||||
{"at":"2026-10-19T18:07:44Z","model":"claude-sonnet-5-5","pool":"anthropic","in":40461,"out":366,"usd":0.016554,"cached":38373,"written":2086}
|
||||
{"at":"2026-10-19T18:07:47Z","model":"claude-sonnet-5-5","pool":"anthropic","in":44028,"out":272,"usd":0.019733,"cached":40459,"written":3567}
|
||||
{"at":"2026-10-19T18:07:52Z","model":"claude-sonnet-5-5","pool":"anthropic","in":45091,"out":486,"usd":0.016327,"cached":44026,"written":1063}
|
||||
{"at":"2026-10-19T18:08:00Z","model":"claude-sonnet-5-5","pool":"anthropic","in":46357,"out":75,"usd":0.012937,"cached":45089,"written":1266}
|
||||
{"at":"2026-10-19T18:08:05Z","model":"claude-sonnet-5-5","pool":"anthropic","in":47540,"out":537,"usd":0.017603,"cached":46355,"written":1183}
|
||||
{"at":"2026-10-19T18:08:15Z","model":"claude-sonnet-5-5","pool":"anthropic","in":48430,"out":307,"usd":0.014807,"cached":47538,"written":890}
|
||||
{"at":"2026-10-19T18:08:25Z","model":"claude-sonnet-5-5","pool":"anthropic","in":49161,"out":131,"usd":0.012827,"cached":48428,"written":731}
|
||||
{"at":"2026-10-19T18:08:27Z","model":"claude-sonnet-5-5","pool":"anthropic","in":52221,"out":67,"usd":0.018156,"cached":49159,"written":3060}
|
||||
{"at":"2026-10-19T18:08:31Z","model":"claude-sonnet-5-5","pool":"anthropic","in":52763,"out":473,"usd":0.016533,"cached":52219,"written":542}
|
||||
{"at":"2026-10-19T18:08:35Z","model":"claude-sonnet-5-5","pool":"anthropic","in":53973,"out":337,"usd":0.016951,"cached":52761,"written":1210}
|
||||
{"at":"2026-10-19T18:08:45Z","model":"claude-sonnet-5-5","pool":"anthropic","in":54635,"out":91,"usd":0.013363,"cached":53971,"written":662}
|
||||
{"at":"2026-10-19T18:08:48Z","model":"claude-sonnet-5-5","pool":"anthropic","in":56713,"out":255,"usd":0.018676,"cached":54633,"written":2078}
|
||||
{"at":"2026-10-19T18:08:57Z","model":"claude-sonnet-5-5","pool":"anthropic","in":57189,"out":93,"usd":0.013466,"cached":56711,"written":476}
|
||||
{"at":"2026-10-19T18:09:00Z","model":"claude-sonnet-5-5","pool":"anthropic","in":58578,"out":160,"usd":0.016514,"cached":57187,"written":1389}
|
||||
{"at":"2026-10-19T18:09:03Z","model":"claude-sonnet-5-5","pool":"anthropic","in":60951,"out":257,"usd":0.020222,"cached":58576,"written":2373}
|
||||
{"at":"2026-10-19T18:09:14Z","model":"claude-sonnet-5-5","pool":"anthropic","in":61636,"out":393,"usd":0.017836,"cached":60949,"written":685}
|
||||
{"at":"2026-10-19T18:09:24Z","model":"claude-sonnet-5-5","pool":"anthropic","in":62638,"out":162,"usd":0.016456,"cached":61634,"written":1002}
|
||||
{"at":"2026-10-19T18:09:33Z","model":"claude-sonnet-5-5","pool":"anthropic","in":62900,"out":211,"usd":0.015296,"cached":62636,"written":262}
|
||||
{"at":"2026-10-19T18:09:43Z","model":"claude-sonnet-5-5","pool":"anthropic","in":63121,"out":139,"usd":0.014526,"cached":62898,"written":221}
|
||||
{"at":"2026-10-19T18:09:53Z","model":"claude-sonnet-5-5","pool":"anthropic","in":63412,"out":219,"usd":0.015545,"cached":63119,"written":291}
|
||||
{"at":"2026-10-19T18:10:02Z","model":"claude-sonnet-5-5","pool":"anthropic","in":64061,"out":164,"usd":0.015949,"cached":63410,"written":649}
|
||||
{"at":"2026-10-19T18:10:08Z","model":"claude-sonnet-5-5","pool":"anthropic","in":65886,"out":553,"usd":0.022908,"cached":64059,"written":1825}
|
||||
{"at":"2026-10-19T18:10:17Z","model":"claude-sonnet-5-5","pool":"anthropic","in":66743,"out":121,"usd":0.016533,"cached":65884,"written":857}
|
||||
{"at":"2026-10-19T18:10:21Z","model":"claude-sonnet-5-5","pool":"anthropic","in":69297,"out":300,"usd":0.022737,"cached":66741,"written":2554}
|
||||
{"at":"2026-10-19T18:10:24Z","model":"claude-sonnet-5-5","pool":"anthropic","in":70018,"out":237,"usd":0.018035,"cached":69295,"written":721}
|
||||
{"at":"2026-10-19T18:10:36Z","model":"claude-sonnet-5-5","pool":"anthropic","in":70501,"out":239,"usd":0.017605,"cached":70016,"written":483}
|
||||
{"at":"2026-10-19T18:10:51Z","model":"claude-sonnet-5-5","pool":"anthropic","in":71129,"out":807,"usd":0.023744,"cached":70499,"written":628}
|
||||
{"at":"2026-10-19T18:10:57Z","model":"claude-sonnet-5-5","pool":"anthropic","in":74344,"out":630,"usd":0.028567,"cached":71127,"written":3215}
|
||||
{"at":"2026-10-19T18:11:09Z","model":"claude-sonnet-5-5","pool":"anthropic","in":75212,"out":263,"usd":0.019672,"cached":74342,"written":868}
|
||||
{"at":"2026-10-19T18:11:19Z","model":"claude-sonnet-5-5","pool":"anthropic","in":75609,"out":313,"usd":0.019169,"cached":75210,"written":397}
|
||||
{"at":"2026-10-19T18:11:31Z","model":"claude-sonnet-5-5","pool":"anthropic","in":76050,"out":259,"usd":0.018818,"cached":75607,"written":441}
|
||||
{"at":"2026-10-19T18:11:46Z","model":"claude-sonnet-5-5","pool":"anthropic","in":76399,"out":868,"usd":0.024766,"cached":76048,"written":349}
|
||||
{"at":"2026-10-19T18:12:11Z","model":"claude-haiku-5-5","pool":"anthropic","in":19003,"out":4000,"usd":0.004375,"cached":0,"written":18999}
|
||||
{"at":"2026-10-19T18:13:18Z","model":"claude-haiku-5-5","pool":"anthropic","in":14045,"out":2031,"usd":0.002771,"cached":0,"written":14041}
|
||||
{"at":"2026-10-19T20:07:59Z","model":"claude-haiku-5-5","pool":"anthropic","in":21511,"out":3154,"usd":0.004266,"cached":0,"written":21507}
|
||||
Reference in new issue
Block a user