Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
810af91368 | ||
|
|
7089285a3e | ||
|
|
4a33074b6a | ||
|
|
99de8ce1be | ||
|
|
a1dcdadde2 | ||
|
|
56bc364832 | ||
|
|
8bcf6a81b0 | ||
|
|
d1121f6f52 | ||
|
|
01b0129c2e | ||
|
|
7841fa2bcb | ||
|
|
5de0145683 | ||
|
|
ab2c567ea4 | ||
|
|
61f1ca63e8 | ||
|
|
675080634e | ||
|
|
0e060d863b | ||
|
|
41c4f75b76 |
No files matched your search
@@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
- name: Auto-bump if the PR did not carry a version bump
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
RELEASE_SERVER_URL: ${{ gitea.server_url }}
|
||||
RELEASE_REPO: ${{ gitea.repository }}
|
||||
PR_TITLE: ${{ gitea.event.pull_request.title }}
|
||||
@@ -126,20 +126,31 @@ jobs:
|
||||
drifts = []
|
||||
for rel, pat in checks:
|
||||
if rel not in edited:
|
||||
# unchanged surface: must ALREADY carry NEW
|
||||
# unchanged surface: must ALREADY carry NEW — if it carries a
|
||||
# DIFFERENT version (base drift, e.g. pyproject at 0.49.0 while
|
||||
# master is 0.54.1), RECOVER by force-editing to NEW. The gate
|
||||
# stays: nothing commits unless every surface carries NEW.
|
||||
status, cur = api(f"/contents/{rel}?ref=master")
|
||||
text = base64.b64decode(cur.get("content", "")).decode("utf-8", "replace") if status == 200 else ""
|
||||
if status != 200:
|
||||
drifts.append(f"{rel}: unreadable ({status})")
|
||||
continue
|
||||
text = base64.b64decode(cur.get("content", "")).decode("utf-8", "replace")
|
||||
hits = re.findall(pat, text)
|
||||
if NEW not in hits:
|
||||
drifts.append(f"{rel}: unchanged but does not carry {NEW} (found {sorted(set(hits))})")
|
||||
fixed = re.sub(pat, lambda m: m.group(0).replace(m.group(1), NEW), text, count=1)
|
||||
if fixed != text:
|
||||
edited[rel] = (fixed, cur["sha"])
|
||||
print(f" RECOVERED {rel}: base drift {sorted(set(hits))} -> {NEW}")
|
||||
else:
|
||||
drifts.append(f"{rel}: pattern present but unfixable (found {sorted(set(hits))})")
|
||||
else:
|
||||
hits = re.findall(pat, edited[rel][0])
|
||||
if NEW not in hits:
|
||||
drifts.append(f"{rel}: edited content does not carry {NEW}")
|
||||
if drifts:
|
||||
print("VERSION-SURFACE DRIFT — bump aborted, nothing committed:", file=sys.stderr)
|
||||
print("VERSION-SURFACE DRIFT — bump aborted, nothing committed:")
|
||||
for d in drifts:
|
||||
print(f" - {d}", file=sys.stderr)
|
||||
print(f" - {d}")
|
||||
sys.exit(1)
|
||||
|
||||
# Commit each surface DIRECTLY to master (bookkeeping, not reviewable code)
|
||||
@@ -162,7 +173,7 @@ jobs:
|
||||
PYBUMP
|
||||
- name: Create release from merged pull request
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
PR_TITLE: ${{ gitea.event.pull_request.title }}
|
||||
PR_BODY: ${{ gitea.event.pull_request.body }}
|
||||
RELEASE_API_URL: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Rune
|
||||
|
||||
**Current release: v0.54.1**
|
||||
|
||||
**Rune is a persistent personal agent designed to accumulate experience, develop reusable competence, and remain recognisably the same agent even when its underlying foundation model changes.**
|
||||
|
||||
Rune treats the language model as replaceable reasoning compute rather than as the whole agent. The durable parts of the system are intended to belong to Rune:
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"drift_cache",
|
||||
"drift_compression",
|
||||
"tier_manager",
|
||||
"graph_shard_bridge"
|
||||
"graph_shard_bridge",
|
||||
"memory_selector"
|
||||
],
|
||||
"canaries": [
|
||||
"canary_query_memory",
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"BLE001": 351,
|
||||
"S110": 79
|
||||
}
|
||||
"BLE001": 348,
|
||||
"S110": 80
|
||||
}
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rune"
|
||||
version = "0.49.0"
|
||||
version = "0.54.1"
|
||||
description = "Rune — a stateful coding assistant with persistent memory, orchestration, and TUI"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
|
||||
+52
-6
@@ -87,7 +87,29 @@ def decide(base: str, token: str, pr: dict) -> str:
|
||||
return f"#{n}: skipped (status={state})"
|
||||
status, detail = _api(base, token, f"/pulls/{n}")
|
||||
if not detail.get("mergeable", False):
|
||||
return f"#{n}: skipped (not mergeable)"
|
||||
# CONFLICT-FIRST (Nick policy): resolve staleness immediately —
|
||||
# attempt the API update NOW rather than waiting a sleep cycle.
|
||||
# 409 (genuine conflict) still skips with the reason named.
|
||||
ustatus, uresp = _api(base, token, f"/pulls/{n}/update", "POST", {"style": "merge"}, ok=(200, 409))
|
||||
if ustatus == 200:
|
||||
print(f"UPDATED {n} (conflict-first: mergeable=False resolved pre-merge)", flush=True)
|
||||
# classify the update: docs-only → carried green stands; code-path
|
||||
# touch → fresh CI required before merge (#208 slip prevention)
|
||||
try:
|
||||
_, fresh = _api(base, token, f"/pulls/{n}")
|
||||
new_sha = fresh.get("head", {}).get("sha", sha)
|
||||
if classify_update(base, token, pr, new_sha) == "RERUN_REQUIRED":
|
||||
return f"#{n}: skipped (updated; CI re-running on new head)"
|
||||
except MergeRefused:
|
||||
return f"#{n}: skipped (updated; CI re-running on new head)"
|
||||
status, detail = _api(base, token, f"/pulls/{n}")
|
||||
if not detail.get("mergeable", False):
|
||||
return f"#{n}: skipped (updated; CI re-running on new head)"
|
||||
elif ustatus == 409:
|
||||
cmsg = str(uresp.get("message", "")) if isinstance(uresp, dict) else str(uresp)
|
||||
return f"#{n}: skipped (genuine conflict — update 409: {cmsg[:120]})"
|
||||
else:
|
||||
return f"#{n}: skipped (not mergeable; update returned {ustatus})"
|
||||
mstatus, mresp = _api(base, token, f"/pulls/{n}/merge", "POST", {"Do": "merge"})
|
||||
if mstatus == 200:
|
||||
return f"#{n}: MERGED"
|
||||
@@ -116,11 +138,25 @@ def decide(base: str, token: str, pr: dict) -> str:
|
||||
if mstatus == 200:
|
||||
return f"#{n}: MERGED (after behind-base self-update)"
|
||||
if mstatus == 405 and "behind" in str(mresp.get("message", "")).lower():
|
||||
# CARRY-FORWARD PROTOCOL: the workflow inspects the tree diff
|
||||
# between the tested head and the new head; docs-only movement
|
||||
# carries the prior green (no redundant re-run — Nick's policy).
|
||||
print(f"UPDATED {n} awaiting-ci", flush=True)
|
||||
return f"#{n}: skipped (updated; CI re-running on new head)"
|
||||
# CARRY-FORWARD PROTOCOL: classify the tree diff between the
|
||||
# tested head and the new head. Docs-only movement carries the
|
||||
# prior green (no redundant re-run — Nick's policy); any
|
||||
# code-path touch requires fresh CI before merge (#208 slip:
|
||||
# required-checks satisfied by a stale green).
|
||||
try:
|
||||
_, fresh = _api(base, token, f"/pulls/{n}")
|
||||
new_sha = fresh.get("head", {}).get("sha", sha)
|
||||
verdict = classify_update(base, token, pr, new_sha)
|
||||
except MergeRefused:
|
||||
verdict = "RERUN_REQUIRED" # can't verify → safest default
|
||||
if verdict == "RERUN_REQUIRED":
|
||||
print(f"UPDATED {n} awaiting-ci (code-path touch)", flush=True)
|
||||
return f"#{n}: skipped (updated; CI re-running on new head)"
|
||||
print(f"UPDATED {n} carry-forward (docs-only diff)", flush=True)
|
||||
mstatus, mresp = _api(base, token, f"/pulls/{n}/merge", "POST", {"Do": "merge"})
|
||||
if mstatus == 200:
|
||||
return f"#{n}: MERGED (carry-forward: docs-only update)"
|
||||
return f"#{n}: skipped (carry-forward merge returned {mstatus})"
|
||||
raise MergeRefused(f"#{n} merge refused {mstatus} after update: {str(mresp)[:200]}")
|
||||
raise MergeRefused(f"#{n} merge refused 405: {msg}")
|
||||
if mstatus == 403:
|
||||
@@ -143,6 +179,16 @@ CODE_PATHS = (
|
||||
)
|
||||
|
||||
|
||||
def classify_update(base: str, token: str, pr: dict, new_sha: str) -> str:
|
||||
"""Classify a self-heal update: CARRY_FORWARD (docs-only tree diff — the
|
||||
tested code is byte-identical, prior green stands) or RERUN_REQUIRED
|
||||
(any code-path touch — semantic interactions are real, #208 slip)."""
|
||||
old_sha = pr["head"]["sha"]
|
||||
status, cmp_data = _api(base, token, f"/compare/{old_sha}...{new_sha}")
|
||||
files = [f.get("filename", "") for f in cmp_data.get("files", []) if isinstance(f, dict)]
|
||||
return "CARRY_FORWARD" if not touches_code_path(files) else "RERUN_REQUIRED"
|
||||
|
||||
|
||||
def touches_code_path(changed_files: list[str], code_paths=CODE_PATHS) -> bool:
|
||||
"""Carry-forward policy: True iff any changed file is a code path.
|
||||
Docs-only base movement must NOT trigger a redundant full re-run."""
|
||||
|
||||
@@ -54,6 +54,8 @@ class FakeForge:
|
||||
if path.endswith("/merge"):
|
||||
n = int(path.split("/")[2])
|
||||
return self.merge_results.get(n, (200, {}))
|
||||
if path.endswith("/update"):
|
||||
return 409, {"message": "There are conflicting files"} # fixture default: genuine conflict
|
||||
raise AssertionError(f"unexpected POST {path}")
|
||||
|
||||
|
||||
@@ -118,8 +120,13 @@ def test_conflict_pr_skipped(forge):
|
||||
forge.prs = [{"number": 11, "head": {"sha": "s11"}}]
|
||||
forge.statuses = {"s11": "success"}
|
||||
forge.mergeable = {11: False} # server reports conflict
|
||||
assert "skipped" in mr.decide("base", "tok", forge.prs[0])
|
||||
assert not any(m == "POST" for m, _ in forge.calls)
|
||||
# conflict-first: the robot attempts the API update; FakeForge 409s it
|
||||
# (genuine conflict) -> skip with reason
|
||||
result = mr.decide("base", "tok", forge.prs[0])
|
||||
assert "skipped" in result and "conflict" in result.lower()
|
||||
assert not any(m == "POST" and p.endswith("/merge") for m, p in forge.calls), (
|
||||
"must never attempt a merge on a conflicted PR"
|
||||
)
|
||||
|
||||
|
||||
def test_behind_base_405_self_heals_then_merges(forge, monkeypatch):
|
||||
@@ -133,6 +140,8 @@ def test_behind_base_405_self_heals_then_merges(forge, monkeypatch):
|
||||
return 200, {"state": "success"}
|
||||
if method == "GET" and path == "/pulls/12":
|
||||
return 200, {**forge.prs[0], "mergeable": True}
|
||||
if method == "GET" and path.startswith("/compare/"):
|
||||
return 200, {"files": [{"filename": "docs/notes.md"}]} # docs-only → carry
|
||||
if method == "POST" and path.endswith("/merge"):
|
||||
return 405, {"message": "The head branch is behind the base branch"}
|
||||
if method == "POST" and path.endswith("/update"):
|
||||
@@ -355,3 +364,70 @@ def test_transient_persisting_raises_loud(forge, monkeypatch):
|
||||
monkeypatch.setattr(mr.time, "sleep", lambda s: None)
|
||||
with pytest.raises(mr.MergeRefused, match="transient after 3 retries"):
|
||||
mr.decide("base", "tok", forge.prs[0])
|
||||
|
||||
|
||||
def test_conflict_first_policy_update_before_wait(forge, monkeypatch):
|
||||
"""Nick's policy: conflicts are resolved FIRST — the robot attempts the
|
||||
API update the moment mergeable=False is observed (before any wait),
|
||||
then a merge attempt follows within ~a minute. A PR should never sit
|
||||
through a sleep cycle while resolvable staleness is known."""
|
||||
forge.prs = [{"number": 40, "head": {"sha": "s40"}}]
|
||||
calls = {"update": 0, "merge": 0}
|
||||
|
||||
def scripted_api(base, token, path, method="GET", data=None, ok=(200,)):
|
||||
if method == "GET" and path.startswith("/commits/"):
|
||||
return 200, {"state": "success"}
|
||||
if method == "GET" and path == "/pulls/40":
|
||||
# mergeable=False until the update lands (that's the conflict-first trigger)
|
||||
return 200, {**forge.prs[0], "mergeable": calls["update"] > 0}
|
||||
if method == "GET" and path.startswith("/compare/"):
|
||||
return 200, {"files": [{"filename": "docs/notes.md"}]} # docs-only → carry
|
||||
if method == "POST" and path.endswith("/update"):
|
||||
calls["update"] += 1
|
||||
return 200, {}
|
||||
if method == "POST" and path.endswith("/merge"):
|
||||
calls["merge"] += 1
|
||||
if calls["merge"] == 1:
|
||||
return 405, {"message": "Please try again later"}
|
||||
return 200, {}
|
||||
raise AssertionError(f"unexpected {method} {path}")
|
||||
|
||||
monkeypatch.setattr(mr, "_api", scripted_api)
|
||||
monkeypatch.setattr(mr.time, "sleep", lambda s: None)
|
||||
line = mr.decide("base", "tok", forge.prs[0])
|
||||
assert calls["update"] == 1, "mergeable=False must trigger immediate update (conflict-first)"
|
||||
assert "MERGED" in line
|
||||
|
||||
|
||||
def test_carry_forward_checked_in_robot_update_path(forge, monkeypatch):
|
||||
"""After a self-heal update, the robot must classify the tree diff:
|
||||
docs-only → merge on carried green; code-path touch → skip (CI must
|
||||
re-run first). Prevents the #208 slip (merged on stale required-checks)."""
|
||||
forge.prs = [{"number": 50, "head": {"sha": "s50"}}]
|
||||
calls = {"update": 0, "merge": 0, "classify": 0}
|
||||
|
||||
def scripted_api(base, token, path, method="GET", data=None, ok=(200,)):
|
||||
if method == "GET" and path.startswith("/commits/"):
|
||||
return 200, {"state": "success"}
|
||||
if method == "GET" and path == "/pulls/50":
|
||||
return 200, {**forge.prs[0], "mergeable": calls["update"] > 0}
|
||||
if method == "GET" and path.startswith("/compare/"):
|
||||
return 200, {"files": [{"filename": "src/rune/x.py"}]} # code touch → RERUN
|
||||
if method == "POST" and path.endswith("/update"):
|
||||
calls["update"] += 1
|
||||
return 200, {}
|
||||
if method == "POST" and path.endswith("/merge"):
|
||||
calls["merge"] += 1
|
||||
return 200, {}
|
||||
raise AssertionError(f"unexpected {method} {path}")
|
||||
|
||||
monkeypatch.setattr(mr, "_api", scripted_api)
|
||||
monkeypatch.setattr(mr.time, "sleep", lambda s: None)
|
||||
monkeypatch.setattr(
|
||||
mr,
|
||||
"classify_update",
|
||||
lambda base, token, pr, new_sha: "RERUN_REQUIRED",
|
||||
)
|
||||
line = mr.decide("base", "tok", forge.prs[0])
|
||||
assert "skipped" in line and "CI re-running" in line, "code-path touch must wait for fresh CI"
|
||||
assert calls["merge"] == 0, "no merge on unverified carry"
|
||||
Reference in new issue
Block a user