Compare commits

...
Author SHA1 Message Date
teahubxyz 7f3042ee28 review: .gitattributes names the root CHANGELOG only
ci / test (pull_request) Successful in 45s
2026-10-19 22:15:55 +00:00
teahubxyz 72f89982e6 review: CHANGELOG says only Time's merges go back, and what union merge costs
ci / test (pull_request) Successful in 46s
2026-10-19 22:15:52 +00:00
teahubxyz 3ea8289833 review: tests use Time's real merges, and a push at the home or a rewind on the copy never goes back
ci / test (pull_request) Successful in 45s
2026-10-19 22:15:49 +00:00
teahubxyz 3ab388154e review: send back only Time's own merges, under a lease on the copy's branch; a failed home fetch falls through
ci / test (pull_request) Failing after 44s
2026-10-19 22:15:26 +00:00
teahubxyz 75433e5bb3 review: name Time's git identity for sync; anchor the union pattern to the root CHANGELOG; say what union costs; the conflict hint names who acts
ci / test (pull_request) Successful in 46s
2026-10-19 22:15:18 +00:00
teahubxyz 51f8e484e9 .gitattributes: a CHANGELOG conflict keeps both sides' lines
ci / test (pull_request) Successful in 44s
2026-10-19 21:52:10 +00:00
teahubxyz c95e1accc1 CHANGELOG: sync sends Time's merge back; CHANGELOG conflicts union-merge 2026-10-19 21:52:07 +00:00
teahubxyz 86e845037b tests: sync sends Time's merge back, the copy wins a divergence, CHANGELOG conflicts union-merge, the imported hint 2026-10-19 21:51:57 +00:00
teahubxyz d69acab7f7 sync: Time's merge on an imported branch goes back to the copy fast-forward instead of being overwritten 2026-10-19 21:51:34 +00:00
teahubxyz 39556a0360 update_branch keeps both sides of a CHANGELOG conflict; a conflict on an imported branch points at the copy 2026-10-19 21:51:19 +00:00
time a4e052d248 ledger: 1 calls, $0.00 2026-10-19 20:08:03 +00:00
teahubxyzandClaude Opus 5.5 e0c2955d74 Cloud revisions: a reviewed cloud PR that gets new commits is reviewed again (#201)
ci / test (push) Successful in 44s
sync / sync (push) Successful in 5s
Cloud revisions: a reviewed cloud PR that gets new commits is reviewed again (#201)

When the copy moves a branch whose PR is open and already reviewed, sync posts a `dispatched` beat marked `revised` on each card it closes and takes `needs-human` off the PR, so the heartbeat reviews the new head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-19 19:00:42 +00:00
time 99a9230d27 ledger: 1 calls, $0.00 2026-10-19 18:13:24 +00:00
time 2e2329dd86 ledger: 39 calls, $0.71 2026-10-19 18:12:13 +00:00
time da2ea2d6a8 ledger: 41 calls, $1.14 2026-10-19 17:12:51 +00:00
time 157c91f06d ledger: 1 calls, $0.17
time/worker-run python3 /tmp/p.py && git diff --stat && cd .github/time && python3 test_time.py 2>&1 | tail -12
2026-10-19 16:47:53 +00:00
6 changed files with 252 additions and 6 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
/CHANGELOG.md merge=union
+67 -3
View File
@@ -13,6 +13,11 @@ cloud session's branch on the copy), and a pull mirror makes the receiving repo
write. They are pushed to the home under the same name and get a PR there, which Time's council reviews and lands
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)
+79
View File
@@ -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
View File
@@ -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:
+2
View File
@@ -17,6 +17,7 @@
- The Venice worker's shell no longer sees secrets: its environment drops tokens and keys, its checkout keeps no token in `.git/config`, and the workflows check out without persisted credentials.
### 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`.
+83
View File
@@ -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}