alissa-tools-github-orcloop 0.18.2__tar.gz → 0.18.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {alissa_tools_github_orcloop-0.18.2/src/main/alissa_tools_github_orcloop.egg-info → alissa_tools_github_orcloop-0.18.4}/PKG-INFO +1 -1
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/__main__.py +18 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/alissa_client.py +13 -3
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/config.py +111 -1
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/loop.py +123 -9
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/merge.py +122 -30
- alissa_tools_github_orcloop-0.18.4/src/main/alissa/tools/github/orcloop/version +1 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/workplan.py +4 -2
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/workplan_html.py +4 -2
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4/src/main/alissa_tools_github_orcloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_orcloop-0.18.2/src/main/alissa/tools/github/orcloop/version +0 -1
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/LICENSE +0 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/MANIFEST.in +0 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/NOTICE +0 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/README.md +0 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/requirements.txt +0 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/setup.cfg +0 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/setup.py +0 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/alissa_cli.py +0 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/console.py +0 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/fleet_vitals.py +0 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/ghclient.py +0 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/lever.py +0 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/loop_events.py +0 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/markers.py +0 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/seats.py +0 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/sentinel.py +0 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/state.py +0 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/version.py +0 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/watchdog.py +0 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/workplan_state.py +0 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa_tools_github_orcloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa_tools_github_orcloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa_tools_github_orcloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa_tools_github_orcloop.egg-info/top_level.txt +0 -0
|
@@ -135,6 +135,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
135
135
|
over.add_argument("--release-blocked-floor-days", type=int, metavar="DAYS")
|
|
136
136
|
over.add_argument("--marker-key", metavar="KEY", help="Autonomous-Dev trailer key")
|
|
137
137
|
over.add_argument("--scope-key", metavar="KEY", help="Autonomous-Scope trailer key")
|
|
138
|
+
over.add_argument(
|
|
139
|
+
"--scope-lock-exempt",
|
|
140
|
+
dest="scope_lock_exempt",
|
|
141
|
+
action="append",
|
|
142
|
+
metavar="[OWNER/REPO:]GLOB",
|
|
143
|
+
help="a regenerable path the lane gate does not lock on: a literal path or a "
|
|
144
|
+
"`<dir>/**` subtree; two scopes overlapping only on entries inside it no "
|
|
145
|
+
"longer conflict (e.g. convex/_generated/**). Prefix `owner/repo:` to exempt "
|
|
146
|
+
"it in that repo only. Repeatable; default none",
|
|
147
|
+
)
|
|
138
148
|
over.add_argument("--alissa-endpoint", metavar="URL")
|
|
139
149
|
over.add_argument("--ledger-path", type=Path, metavar="PATH")
|
|
140
150
|
over.add_argument(
|
|
@@ -603,6 +613,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
603
613
|
"release_blocked_floor_days": args.release_blocked_floor_days,
|
|
604
614
|
"marker_key": args.marker_key,
|
|
605
615
|
"scope_key": args.scope_key,
|
|
616
|
+
"scope_lock_exempt": tuple(args.scope_lock_exempt) if args.scope_lock_exempt else None,
|
|
606
617
|
"alissa_endpoint": args.alissa_endpoint,
|
|
607
618
|
"ledger_path": args.ledger_path,
|
|
608
619
|
"close_loop_enabled": args.close_loop_enabled,
|
|
@@ -858,6 +869,13 @@ def log_effective_config(config: Config) -> None:
|
|
|
858
869
|
log.debug("fleet vitals: disabled")
|
|
859
870
|
log.debug("marker_key: %s", config.marker_key)
|
|
860
871
|
log.debug("scope_key: %s", config.scope_key)
|
|
872
|
+
if config.scope_lock_exempt:
|
|
873
|
+
log.info(
|
|
874
|
+
"scope lock: exempt paths %s — two scopes overlapping only on entries "
|
|
875
|
+
"inside them no longer conflict (entries prefixed owner/repo: apply to "
|
|
876
|
+
"that repo only)",
|
|
877
|
+
", ".join(config.scope_lock_exempt),
|
|
878
|
+
)
|
|
861
879
|
log.debug("release_blocked_floor_days: %s", config.release_blocked_floor_days)
|
|
862
880
|
log.debug("alissa_endpoint: %s", config.alissa_endpoint)
|
|
863
881
|
log.debug("ledger_db: %s", config.ledger_db)
|
|
@@ -1413,13 +1413,23 @@ class AlissaClient:
|
|
|
1413
1413
|
and return the new task's opaque id -- all the create answers
|
|
1414
1414
|
(`{"taskId": …}`; the number is a `get_task` away). The merge policy's
|
|
1415
1415
|
validation floor (issue #116) is the one caller: the "Validate after
|
|
1416
|
-
merge" task on the origin task,
|
|
1416
|
+
merge" task on the origin task, OWNED BY THE CALLER -- the fleet
|
|
1417
|
+
actor, the API's own default when `assign_to_actor_id` is left empty.
|
|
1418
|
+
|
|
1419
|
+
`assign_to_actor_id` seats another actor as owner and is subject to
|
|
1420
|
+
the API's owner-assign gate: a caller that is not an admin of a team
|
|
1421
|
+
the target belongs to (and does not pass `teamSlug`) is refused with
|
|
1422
|
+
`HTTP 403 FORBIDDEN: {'error': 'FORBIDDEN', 'message': 'Only a team
|
|
1423
|
+
admin can seat another member as owner; pass teamSlug'}`. The fleet
|
|
1424
|
+
actor is a member, not an admin, so the floor never passes it (issue
|
|
1425
|
+
#120); the parameter stays for a caller that can.
|
|
1417
1426
|
|
|
1418
1427
|
`criteria` is the API's shape verbatim (`{id, type, description}`
|
|
1419
1428
|
objects -- plain strings are refused with a 400). Permission is the
|
|
1420
1429
|
parent's OWNER, which the daemon's token is on the tasks it released;
|
|
1421
|
-
a refusal comes back as
|
|
1422
|
-
(the floor skips the merge
|
|
1430
|
+
a refusal comes back as an `AlissaError` subclass and the caller
|
|
1431
|
+
decides (the floor skips the merge -- and escalates once when the
|
|
1432
|
+
refusal is permanent)."""
|
|
1423
1433
|
body: dict = {
|
|
1424
1434
|
"title": title,
|
|
1425
1435
|
"definitionOfDone": definition_of_done,
|
|
@@ -148,6 +148,7 @@ CONFIG_KEYS = (
|
|
|
148
148
|
"release_blocked_floor_days",
|
|
149
149
|
"marker_key",
|
|
150
150
|
"scope_key",
|
|
151
|
+
"scope_lock_exempt",
|
|
151
152
|
"alissa_endpoint",
|
|
152
153
|
"dry_run",
|
|
153
154
|
"ledger_path",
|
|
@@ -220,7 +221,7 @@ _BOOL_KEYS = frozenset({
|
|
|
220
221
|
# it arrives as a single string, with the SAME separators (see LIST_SEPARATORS
|
|
221
222
|
# and `split_entries`) — a per-key syntax is exactly the drift O11 removed.
|
|
222
223
|
_LIST_KEYS = frozenset({
|
|
223
|
-
"repos", "bow_owners",
|
|
224
|
+
"repos", "bow_owners", "scope_lock_exempt",
|
|
224
225
|
"merge_author_logins", "merge_reviewer_logins", "merge_hold_paths",
|
|
225
226
|
"merge_yolo_merge_classes", "merge_yolo_hold_classes", "merge_allow_no_checks_repos",
|
|
226
227
|
})
|
|
@@ -667,6 +668,84 @@ def normalize_bow_owners(values: tuple[str, ...] | list[str]) -> tuple[str, ...]
|
|
|
667
668
|
return tuple(out)
|
|
668
669
|
|
|
669
670
|
|
|
671
|
+
# One `scope_lock_exempt` entry, split (issue #123): an optional `owner/repo:`
|
|
672
|
+
# prefix, then the exempt glob. Both halves are kept as written so the lane
|
|
673
|
+
# gate can match the repo case-insensitively and name the glob verbatim.
|
|
674
|
+
SCOPE_EXEMPT_SUBTREE = "/**"
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def split_scope_lock_exempt_entry(entry: str) -> tuple[str | None, str]:
|
|
678
|
+
"""`(owner/repo or None, glob)` for one exempt entry, unvalidated.
|
|
679
|
+
|
|
680
|
+
The prefix is the text before the FIRST `:` -- a repo name never carries
|
|
681
|
+
one and a repo-relative glob has no reason to, so the first colon is the
|
|
682
|
+
separator whenever there is one. Validation is `_validate_scope_lock_exempt_entry`'s
|
|
683
|
+
job; this only splits."""
|
|
684
|
+
repo, sep, glob = entry.partition(":")
|
|
685
|
+
if not sep:
|
|
686
|
+
return None, entry
|
|
687
|
+
return repo, glob
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _validate_scope_lock_exempt_entry(entry: str) -> None:
|
|
691
|
+
"""Refuse a `scope_lock_exempt` entry the lane gate could not read
|
|
692
|
+
EXACTLY, naming the entry (issue #123).
|
|
693
|
+
|
|
694
|
+
An exemption WEAKENS the scope lock, so unlike a scope glob (where a
|
|
695
|
+
conservative reading only serializes) a misread exemption lets two
|
|
696
|
+
sessions onto the same files. The accepted shapes are therefore the two
|
|
697
|
+
the coverage test (`loop.scope_lock_exempt_covers`) can decide without
|
|
698
|
+
guessing: a literal repo-relative path (`convex/_generated/api.d.ts`) and
|
|
699
|
+
a whole subtree (`convex/_generated/**`). Every other wildcard shape --
|
|
700
|
+
`convex/*.ts`, `**/generated/**`, a bare `**` that would exempt EVERY
|
|
701
|
+
path and silently switch the lock off -- is refused here rather than
|
|
702
|
+
read approximately at the gate.
|
|
703
|
+
"""
|
|
704
|
+
repo, glob = split_scope_lock_exempt_entry(entry)
|
|
705
|
+
where = f"scope_lock_exempt entry {entry!r}"
|
|
706
|
+
if repo is not None and not is_repo_name(repo):
|
|
707
|
+
raise ValueError(
|
|
708
|
+
f"{where}: the text before the `:` ({repo!r}) is not an owner/repo "
|
|
709
|
+
f"name. An entry is `<glob>` (every repo) or `<owner>/<repo>:<glob>` "
|
|
710
|
+
f"(that repo only), e.g. \"ali-fhr/studio.alissa.app:convex/_generated/**\"."
|
|
711
|
+
)
|
|
712
|
+
if not glob:
|
|
713
|
+
raise ValueError(f"{where}: no glob after the `:`")
|
|
714
|
+
if glob == "**":
|
|
715
|
+
literal, shape = "", "a subtree (`<dir>/**`)"
|
|
716
|
+
elif glob.endswith(SCOPE_EXEMPT_SUBTREE):
|
|
717
|
+
literal = glob[: -len(SCOPE_EXEMPT_SUBTREE)]
|
|
718
|
+
shape = "a subtree (`<dir>/**`)"
|
|
719
|
+
else:
|
|
720
|
+
literal = glob
|
|
721
|
+
shape = "a literal path"
|
|
722
|
+
if not literal:
|
|
723
|
+
raise ValueError(
|
|
724
|
+
f"{where}: {glob!r} would exempt every path, which is the scope lock "
|
|
725
|
+
f"switched off; name the regenerable directory (`<dir>/**`) or file."
|
|
726
|
+
)
|
|
727
|
+
if any(char in "*?[" for char in literal):
|
|
728
|
+
raise ValueError(
|
|
729
|
+
f"{where}: {glob!r} is neither a literal path nor a `<dir>/**` "
|
|
730
|
+
f"subtree. An exemption must say exactly what it covers -- a "
|
|
731
|
+
f"wildcard anywhere but a trailing `/**` cannot be decided from the "
|
|
732
|
+
f"literal prefix, and an exemption read approximately would let two "
|
|
733
|
+
f"sessions onto the same files."
|
|
734
|
+
)
|
|
735
|
+
if literal.startswith("/") or literal.endswith("/"):
|
|
736
|
+
raise ValueError(
|
|
737
|
+
f"{where}: {glob!r} must be repo-relative with no leading or "
|
|
738
|
+
f"trailing `/` (read as {shape})."
|
|
739
|
+
)
|
|
740
|
+
if any(part in ("", ".", "..") for part in literal.split("/")):
|
|
741
|
+
raise ValueError(
|
|
742
|
+
f"{where}: {glob!r} has an empty, `.` or `..` path segment (read as "
|
|
743
|
+
f"{shape}); write the path as the repository sees it."
|
|
744
|
+
)
|
|
745
|
+
if any(char.isspace() for char in glob):
|
|
746
|
+
raise ValueError(f"{where}: {glob!r} contains whitespace")
|
|
747
|
+
|
|
748
|
+
|
|
670
749
|
def normalize_entries(values: "tuple[str, ...] | list[str]") -> tuple[str, ...]:
|
|
671
750
|
"""A plain list key (the merge edge's logins and hold paths): every
|
|
672
751
|
element split on the family separators, stripped, empties dropped,
|
|
@@ -867,6 +946,14 @@ class Config:
|
|
|
867
946
|
marker_key: str = "Autonomous-Dev"
|
|
868
947
|
scope_key: str = "Autonomous-Scope"
|
|
869
948
|
|
|
949
|
+
# Regenerable paths the lane gate does not lock on (issue #123): an
|
|
950
|
+
# overlap between two `Autonomous-Scope` entries that both lie inside one
|
|
951
|
+
# of these globs is set aside before two lanes are compared; each glob is
|
|
952
|
+
# optionally prefixed `owner/repo:` to apply to that repo only.
|
|
953
|
+
# Empty (the default) leaves every decision exactly as before. Read
|
|
954
|
+
# through `scope_lock_exemptions`, never directly.
|
|
955
|
+
scope_lock_exempt: tuple[str, ...] = ()
|
|
956
|
+
|
|
870
957
|
alissa_endpoint: str = DEFAULT_ALISSA_ENDPOINT
|
|
871
958
|
|
|
872
959
|
# None means "derive from the workspace" — read `ledger_db` for the
|
|
@@ -1231,6 +1318,20 @@ class Config:
|
|
|
1231
1318
|
wanted = full_name.casefold()
|
|
1232
1319
|
return any(entry.casefold() == wanted for entry in self.merge_allow_no_checks_repos)
|
|
1233
1320
|
|
|
1321
|
+
def scope_lock_exemptions(self, full_name: str) -> tuple[str, ...]:
|
|
1322
|
+
"""The exempt globs that apply to owner/repo (issue #123): every
|
|
1323
|
+
unprefixed `scope_lock_exempt` entry plus the ones prefixed with THIS
|
|
1324
|
+
repo's name. Case-insensitive on the repo, like GitHub owner/repo
|
|
1325
|
+
names; the globs themselves are returned as written, in config order,
|
|
1326
|
+
so a log line can name them verbatim."""
|
|
1327
|
+
wanted = full_name.casefold()
|
|
1328
|
+
out: list[str] = []
|
|
1329
|
+
for entry in self.scope_lock_exempt:
|
|
1330
|
+
repo, glob = split_scope_lock_exempt_entry(entry)
|
|
1331
|
+
if (repo is None or repo.casefold() == wanted) and glob not in out:
|
|
1332
|
+
out.append(glob)
|
|
1333
|
+
return tuple(out)
|
|
1334
|
+
|
|
1234
1335
|
def watches_static(self, full_name: str) -> bool:
|
|
1235
1336
|
"""Whether owner/repo is on the STATIC allowlist. Case-insensitive,
|
|
1236
1337
|
like GitHub owner/repo names.
|
|
@@ -1421,6 +1522,14 @@ class Config:
|
|
|
1421
1522
|
if not value.strip():
|
|
1422
1523
|
raise ValueError(f"{name} must be a non-empty string")
|
|
1423
1524
|
|
|
1525
|
+
# Issue #123: validated entry by entry at load, because an exemption
|
|
1526
|
+
# the gate could not read exactly is a lock silently weakened.
|
|
1527
|
+
scope_lock_exempt = normalize_entries(
|
|
1528
|
+
_string_list(raw.get("scope_lock_exempt", cls.scope_lock_exempt), "scope_lock_exempt")
|
|
1529
|
+
)
|
|
1530
|
+
for entry in scope_lock_exempt:
|
|
1531
|
+
_validate_scope_lock_exempt_entry(entry)
|
|
1532
|
+
|
|
1424
1533
|
# NOT stripped: the convention's trailing space (`autodev: `) is load-
|
|
1425
1534
|
# bearing, so only an all-whitespace prefix is rejected. An empty prefix
|
|
1426
1535
|
# would make EVERY Body of Work a release feed.
|
|
@@ -1730,6 +1839,7 @@ class Config:
|
|
|
1730
1839
|
release_blocked_floor_days=floor_days,
|
|
1731
1840
|
marker_key=marker_key,
|
|
1732
1841
|
scope_key=scope_key,
|
|
1842
|
+
scope_lock_exempt=scope_lock_exempt,
|
|
1733
1843
|
alissa_endpoint=raw.get("alissa_endpoint", cls.alissa_endpoint),
|
|
1734
1844
|
ledger_path=Path(ledger_path).expanduser() if ledger_path else None,
|
|
1735
1845
|
close_loop_enabled=close_loop_enabled,
|
|
@@ -510,7 +510,7 @@ from .alissa_client import (
|
|
|
510
510
|
TaskRow,
|
|
511
511
|
row_for_task,
|
|
512
512
|
)
|
|
513
|
-
from .config import FEED_BOW, Config, is_repo_name
|
|
513
|
+
from .config import FEED_BOW, SCOPE_EXEMPT_SUBTREE, Config, is_repo_name
|
|
514
514
|
from .ghclient import (
|
|
515
515
|
CloseWalk,
|
|
516
516
|
GitHub,
|
|
@@ -1869,14 +1869,88 @@ def _newest_at(reviews: Sequence[Review]) -> float:
|
|
|
1869
1869
|
return max((r.submitted_epoch or 0.0 for r in reviews), default=0.0)
|
|
1870
1870
|
|
|
1871
1871
|
|
|
1872
|
-
def
|
|
1872
|
+
def scope_lock_exempt_covers(entry: str, exempt: str) -> bool:
|
|
1873
|
+
"""Whether the exempt glob covers a scope entry ENTIRELY (issue #123).
|
|
1874
|
+
|
|
1875
|
+
The direction matters: exemption WEAKENS the lock, so where `globs_overlap`
|
|
1876
|
+
says "overlap" when unsure, this says "not covered" when unsure. It reads
|
|
1877
|
+
the two shapes `config._validate_scope_lock_exempt_entry` admits and
|
|
1878
|
+
nothing else:
|
|
1879
|
+
|
|
1880
|
+
* a subtree `<dir>/**` covers `<dir>` itself and every entry whose text
|
|
1881
|
+
lies under `<dir>/` -- `convex/_generated/api.d.ts`,
|
|
1882
|
+
`convex/_generated/**`, `convex/_generated/sub/*.ts`;
|
|
1883
|
+
* a literal path covers that path and anything under it.
|
|
1884
|
+
|
|
1885
|
+
`convex/**` is NOT covered by `convex/_generated/**`: it claims far more
|
|
1886
|
+
than the exempt part, and dropping it would unlock `convex/schema.ts`.
|
|
1887
|
+
Neither is `convex/_gen*/**` -- its literal prefix stops short of the
|
|
1888
|
+
exempt root, so what it matches cannot be known from the text."""
|
|
1889
|
+
if exempt.endswith(SCOPE_EXEMPT_SUBTREE):
|
|
1890
|
+
root = exempt[: -len(SCOPE_EXEMPT_SUBTREE)]
|
|
1891
|
+
else:
|
|
1892
|
+
root = exempt
|
|
1893
|
+
return entry == root or entry.startswith(root + "/")
|
|
1894
|
+
|
|
1895
|
+
|
|
1896
|
+
def _exempt_entry(entry: str, exempt: Sequence[str]) -> bool:
|
|
1897
|
+
"""Whether some exempt glob covers this scope entry entirely."""
|
|
1898
|
+
return any(scope_lock_exempt_covers(entry, glob) for glob in exempt)
|
|
1899
|
+
|
|
1900
|
+
|
|
1901
|
+
def scopes_disjoint(
|
|
1902
|
+
first: list[str], second: list[str], exempt: Sequence[str] = (),
|
|
1903
|
+
) -> bool:
|
|
1873
1904
|
"""Whether two `Autonomous-Scope` glob lists are safe to run in parallel.
|
|
1874
1905
|
|
|
1875
1906
|
A scopeless (empty) side occupies the whole lane, so it is disjoint from
|
|
1876
|
-
nothing. Otherwise disjoint iff no glob pair overlaps
|
|
1907
|
+
nothing. Otherwise disjoint iff no glob pair overlaps -- except a pair
|
|
1908
|
+
whose entries BOTH lie inside an `exempt` glob (issue #123: a regenerable
|
|
1909
|
+
path is a pure function of the code, so two lanes that both declare it
|
|
1910
|
+
never truly conflict on it). Every exempt entry is set aside from both
|
|
1911
|
+
sides that way, and only that way: a non-exempt entry keeps its WHOLE
|
|
1912
|
+
claim, the exempt region included, so `convex/**` still conflicts with
|
|
1913
|
+
`convex/_generated/**` -- the wider lane declared it may touch the
|
|
1914
|
+
generated tree by a glob the exemption does not cover, and partial
|
|
1915
|
+
coverage is not exemption. A scope whose every entry is exempt is
|
|
1916
|
+
therefore disjoint from every scope that does not claim the exempt region
|
|
1917
|
+
non-exemptly: it declared paths, and each is one the lock does not
|
|
1918
|
+
protect. That is the opposite of scopeless, which is decided first, on
|
|
1919
|
+
the scopes as declared, so exemption can never turn a narrowly scoped
|
|
1920
|
+
lane into a whole-lane lock. With no exemptions no entry is exempt and
|
|
1921
|
+
this is bit for bit the pre-#123 predicate."""
|
|
1877
1922
|
if not first or not second:
|
|
1878
1923
|
return False
|
|
1879
|
-
return not any(
|
|
1924
|
+
return not any(
|
|
1925
|
+
globs_overlap(one, two)
|
|
1926
|
+
and not (_exempt_entry(one, exempt) and _exempt_entry(two, exempt))
|
|
1927
|
+
for one in first for two in second
|
|
1928
|
+
)
|
|
1929
|
+
|
|
1930
|
+
|
|
1931
|
+
def scope_lock_exemptions_used(
|
|
1932
|
+
first: list[str], second: list[str], exempt: Sequence[str],
|
|
1933
|
+
) -> tuple[str, ...]:
|
|
1934
|
+
"""The exempt globs that set an overlap aside between two scopes -- the
|
|
1935
|
+
ones a decision line names (issue #123). An exempt glob is "used" when a
|
|
1936
|
+
pair of exempt entries overlaps and it covers one of them; an exemption
|
|
1937
|
+
that covered nothing in conflict changed nothing and is not named. Config
|
|
1938
|
+
order, no duplicates. A scopeless side uses none: it is never compared."""
|
|
1939
|
+
if not first or not second or not exempt:
|
|
1940
|
+
return ()
|
|
1941
|
+
used: list[str] = []
|
|
1942
|
+
for one in first:
|
|
1943
|
+
for two in second:
|
|
1944
|
+
if not globs_overlap(one, two):
|
|
1945
|
+
continue
|
|
1946
|
+
if not (_exempt_entry(one, exempt) and _exempt_entry(two, exempt)):
|
|
1947
|
+
continue
|
|
1948
|
+
for glob in exempt:
|
|
1949
|
+
if glob not in used and (
|
|
1950
|
+
scope_lock_exempt_covers(one, glob) or scope_lock_exempt_covers(two, glob)
|
|
1951
|
+
):
|
|
1952
|
+
used.append(glob)
|
|
1953
|
+
return tuple(used)
|
|
1880
1954
|
|
|
1881
1955
|
|
|
1882
1956
|
def queue_by_target(
|
|
@@ -2688,10 +2762,14 @@ class Orchestrator:
|
|
|
2688
2762
|
claims.setdefault((issue.repo, issue.claim), set()).add(issue.slug)
|
|
2689
2763
|
for (repo, claim), slugs in claims.items():
|
|
2690
2764
|
occupant = [] if claim == SCOPELESS_CLAIM else claim.split(" ")
|
|
2765
|
+
# Under the same exemptions the gate applied (issue #123): an
|
|
2766
|
+
# overlap on regenerable paths alone held nobody, so it is not
|
|
2767
|
+
# pressure on the claim and the conflict view must not show it.
|
|
2768
|
+
exempt = self.config.scope_lock_exemptions(repo)
|
|
2691
2769
|
queued = sum(
|
|
2692
2770
|
1 for member in held
|
|
2693
2771
|
if member.bow_key == repo
|
|
2694
|
-
and not scopes_disjoint(list(member.scope), occupant)
|
|
2772
|
+
and not scopes_disjoint(list(member.scope), occupant, exempt)
|
|
2695
2773
|
)
|
|
2696
2774
|
self.workplan.record_lane(repo, claim, sorted(slugs), queued=queued)
|
|
2697
2775
|
|
|
@@ -4890,6 +4968,10 @@ class Orchestrator:
|
|
|
4890
4968
|
f"lane {target} occupied: {lane_reason}{note}",
|
|
4891
4969
|
task.task_number,
|
|
4892
4970
|
))
|
|
4971
|
+
# Issue #123: a release the exemption admitted says so, because the
|
|
4972
|
+
# overlap it set aside is exactly what an operator reading the log
|
|
4973
|
+
# beside the in-flight scopes would otherwise expect to have held.
|
|
4974
|
+
note += self._lane_exemption_note(target, scope, occupancy)
|
|
4893
4975
|
|
|
4894
4976
|
# Issue #78: what a scopeless release is about to cost. Computed here
|
|
4895
4977
|
# because this is the one point where the RESOLVED target and the pass's
|
|
@@ -4973,7 +5055,10 @@ class Orchestrator:
|
|
|
4973
5055
|
) -> str | None:
|
|
4974
5056
|
"""Why the target repo's lane cannot take this release now, or None to
|
|
4975
5057
|
admit it. Checks the parallelism cap, the scopeless-occupies-the-lane
|
|
4976
|
-
rule, and pairwise scope disjointness against the in-flight occupants
|
|
5058
|
+
rule, and pairwise scope disjointness against the in-flight occupants
|
|
5059
|
+
-- under the repo's scope-lock exemptions (issue #123), which the
|
|
5060
|
+
reason names when an exempt overlap was set aside beside the real one
|
|
5061
|
+
(`_lane_exemption_note` is the release-side counterpart)."""
|
|
4977
5062
|
occupants = occupancy.get(target, [])
|
|
4978
5063
|
if len(occupants) >= self.config.repo_parallelism:
|
|
4979
5064
|
return (
|
|
@@ -4987,14 +5072,41 @@ class Orchestrator:
|
|
|
4987
5072
|
f"issue(s) are in-flight"
|
|
4988
5073
|
)
|
|
4989
5074
|
return None
|
|
5075
|
+
exempt = self.config.scope_lock_exemptions(target)
|
|
4990
5076
|
for occupant in occupants:
|
|
4991
|
-
if not scopes_disjoint(scope, occupant):
|
|
5077
|
+
if not scopes_disjoint(scope, occupant, exempt):
|
|
5078
|
+
used = scope_lock_exemptions_used(scope, occupant, exempt)
|
|
5079
|
+
ignored = (
|
|
5080
|
+
f" (overlap on exempt paths set aside: {', '.join(used)}; "
|
|
5081
|
+
f"the conflict is on the rest)" if used else ""
|
|
5082
|
+
)
|
|
4992
5083
|
return (
|
|
4993
5084
|
f"scope {scope} conflicts with in-flight scope "
|
|
4994
|
-
f"{occupant or '(scopeless — whole lane)'}"
|
|
5085
|
+
f"{occupant or '(scopeless — whole lane)'}{ignored}"
|
|
4995
5086
|
)
|
|
4996
5087
|
return None
|
|
4997
5088
|
|
|
5089
|
+
def _lane_exemption_note(
|
|
5090
|
+
self, target: str, scope: list[str], occupancy: dict[str, list[list[str]]]
|
|
5091
|
+
) -> str:
|
|
5092
|
+
"""The narration for a release the scope-lock exemption ADMITTED
|
|
5093
|
+
(issue #123): ` (overlap only on exempt paths: <globs>)` when some
|
|
5094
|
+
occupant overlapped this scope on exempt paths alone, else ``. Called
|
|
5095
|
+
only after `_lane_blocked` admitted the release, so every overlap left
|
|
5096
|
+
is an exempt one; with no exemptions configured it is always empty,
|
|
5097
|
+
which keeps the unset log bit for bit."""
|
|
5098
|
+
exempt = self.config.scope_lock_exemptions(target)
|
|
5099
|
+
if not exempt or not scope:
|
|
5100
|
+
return ""
|
|
5101
|
+
used: list[str] = []
|
|
5102
|
+
for occupant in occupancy.get(target, []):
|
|
5103
|
+
for glob in scope_lock_exemptions_used(scope, occupant, exempt):
|
|
5104
|
+
if glob not in used:
|
|
5105
|
+
used.append(glob)
|
|
5106
|
+
if not used:
|
|
5107
|
+
return ""
|
|
5108
|
+
return f" (overlap only on exempt paths: {', '.join(used)})"
|
|
5109
|
+
|
|
4998
5110
|
def _trailer_dedupe(
|
|
4999
5111
|
self,
|
|
5000
5112
|
task: Task,
|
|
@@ -5165,7 +5277,9 @@ class Orchestrator:
|
|
|
5165
5277
|
labeled = self.config.label in issue.labels
|
|
5166
5278
|
if not labeled:
|
|
5167
5279
|
lane_reason = self._lane_blocked(target, issue_scope, occupancy)
|
|
5168
|
-
if lane_reason is
|
|
5280
|
+
if lane_reason is None:
|
|
5281
|
+
issue_note += self._lane_exemption_note(target, issue_scope, occupancy)
|
|
5282
|
+
else:
|
|
5169
5283
|
if not issue_scope:
|
|
5170
5284
|
# A scopeless issue needs an EMPTY lane, so this hold does
|
|
5171
5285
|
# not clear when one occupant closes -- it clears when the
|
|
@@ -84,14 +84,24 @@ messages). The gate, in order:
|
|
|
84
84
|
10. `dry_run` -> SKIPPED "would merge …", no writes -- the floor included.
|
|
85
85
|
9b. The FLOOR (issue #116, L3), only for a merge the policy downgraded from
|
|
86
86
|
an operator hold: BEFORE the merge call, once per (pr, head) (ledger
|
|
87
|
-
`merge:floor:<pr>:<head7>`), a comment on the origin Alissa task
|
|
88
|
-
the class and
|
|
89
|
-
downstream task
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
87
|
+
`merge:floor:<pr>:<head7>`), a comment on the origin Alissa task saying
|
|
88
|
+
the policy IS MERGING past the gate (class and reason), and a committed
|
|
89
|
+
`Validate after merge` downstream task OWNED BY THE CALLING ACTOR -- the
|
|
90
|
+
fleet actor, the identity that owns every fed task (issue #120: the API's
|
|
91
|
+
owner-assign gate refuses the fleet actor seating the origin's reporter
|
|
92
|
+
as owner, so the reporter is reached through the origin task, which is
|
|
93
|
+
the parent of the floor task and carries the floor comment). A write
|
|
94
|
+
failing TRANSIENTLY (5xx, 429, transport) -> SKIPPED "floor not posted",
|
|
95
|
+
retried next poll. A write failing PERMANENTLY (a 4xx that is not
|
|
96
|
+
transient: 403, 404, 422) -> one escalation comment on the origin task
|
|
97
|
+
(`merge:floor-failed:<pr>:<head7>`) saying the PR needs a human merge
|
|
98
|
+
until the floor can be written, one WARNING (L4), and every later poll
|
|
99
|
+
on this head is a quiet SKIP that writes nothing; a new head is a new
|
|
100
|
+
episode. Never a merge without the page. Three rows keep the retry from
|
|
101
|
+
repeating a write that landed: the comment's, the task's
|
|
102
|
+
(`merge:floor-task:<pr>:<head7>:<taskId>`, written the moment the create
|
|
103
|
+
returns, so a failed read-back or status set never creates a second
|
|
104
|
+
task), and the floor's.
|
|
95
105
|
11. Merge: `PUT /pulls/{n}/merge` with the configured method and
|
|
96
106
|
`sha=head_sha` -- the guard that makes GitHub refuse (405/409) when the
|
|
97
107
|
head moved between our reads and the merge; a refusal is SKIPPED and
|
|
@@ -133,7 +143,7 @@ from functools import lru_cache
|
|
|
133
143
|
from typing import Callable, Sequence
|
|
134
144
|
|
|
135
145
|
from . import loop_events
|
|
136
|
-
from .alissa_client import AlissaClient, AlissaError
|
|
146
|
+
from .alissa_client import AlissaClient, AlissaError, AlissaTransient
|
|
137
147
|
from .config import MERGE_POLICY_YOLO, MERGE_READINESS_CLASSES, Config
|
|
138
148
|
from .ghclient import (
|
|
139
149
|
CheckSummary,
|
|
@@ -249,6 +259,18 @@ def policy_verdict(config: Config, klass: str | None) -> tuple[bool, str]:
|
|
|
249
259
|
return (False, f"policy yolo holds class {klass} (in neither class list)")
|
|
250
260
|
|
|
251
261
|
|
|
262
|
+
def floor_failure_is_permanent(exc: AlissaError) -> bool:
|
|
263
|
+
"""Is this floor write refused for GOOD (issue #120) -- a 4xx that is not a
|
|
264
|
+
retry-later signal? 403 (the owner-assign gate, a missing role), 404 (the
|
|
265
|
+
origin task is gone or invisible), 422 (a payload the API refuses) do not
|
|
266
|
+
heal between polls; retrying them costs a WARNING and a refused write per
|
|
267
|
+
poll for as long as the head is approved. `AlissaTransient` (408/429/5xx
|
|
268
|
+
and transport failures, status 0) is excluded by CLASS, so the client's
|
|
269
|
+
taxonomy and this predicate cannot drift; a status-0 base error (the
|
|
270
|
+
create answered no `taskId`) is a malformed answer, retried too."""
|
|
271
|
+
return not isinstance(exc, AlissaTransient) and 400 <= exc.status < 500
|
|
272
|
+
|
|
273
|
+
|
|
252
274
|
# --- hard-stop path globs ----------------------------------------------------
|
|
253
275
|
#
|
|
254
276
|
# `fnmatch` has no `**`: `*` there crosses `/`, so `**/migrations/**` and
|
|
@@ -358,11 +380,16 @@ HELD_KIND_PREFIX = MERGE_KIND_PREFIX + "held:" # merge:held:<why>:<pr
|
|
|
358
380
|
# a failed read-back or status set finishes THAT task instead of creating a
|
|
359
381
|
# second (PR #117 round 1); the floor row is the one the merge reads. A
|
|
360
382
|
# fourth, `floor-unmerged`, is the once-per-head correction the origin task
|
|
361
|
-
# gets when the merge call it was paged about is then refused.
|
|
383
|
+
# gets when the merge call it was paged about is then refused. A fifth,
|
|
384
|
+
# `floor-failed` (issue #120), is the once-per-head escalation a floor write
|
|
385
|
+
# that failed PERMANENTLY (a non-transient 4xx) leaves: its row is what keeps
|
|
386
|
+
# every later poll on that head a quiet SKIP instead of a WARNING and a
|
|
387
|
+
# refused write per poll.
|
|
362
388
|
FLOOR_KIND_PREFIX = MERGE_KIND_PREFIX + "floor:" # merge:floor:<pr>:<head7>
|
|
363
389
|
FLOOR_COMMENT_KIND_PREFIX = MERGE_KIND_PREFIX + "floor-comment:" # merge:floor-comment:<pr>:<head7>
|
|
364
390
|
FLOOR_TASK_KIND_PREFIX = MERGE_KIND_PREFIX + "floor-task:" # merge:floor-task:<pr>:<head7>:<taskId>
|
|
365
391
|
FLOOR_UNMERGED_KIND_PREFIX = MERGE_KIND_PREFIX + "floor-unmerged:" # merge:floor-unmerged:<pr>:<head7>
|
|
392
|
+
FLOOR_FAILED_KIND_PREFIX = MERGE_KIND_PREFIX + "floor-failed:" # merge:floor-failed:<pr>:<head7>
|
|
366
393
|
|
|
367
394
|
# The hold reasons, as the short slugs the ledger kind and the event key carry.
|
|
368
395
|
HOLD_OPERATOR = "operator"
|
|
@@ -422,10 +449,14 @@ MERGED_BY_POLICY_COMMENT = (
|
|
|
422
449
|
"on {task_ref} as a comment and a `Validate after merge` task before the merge."
|
|
423
450
|
)
|
|
424
451
|
# The floor (issue #116, L3): what the origin task reads BEFORE the merge call
|
|
425
|
-
# when the policy downgrades an operator hold. The wording is
|
|
452
|
+
# when the policy downgrades an operator hold. The wording is issue #120's: it
|
|
453
|
+
# is posted before the merge call, so it says the merge IS HAPPENING -- the
|
|
454
|
+
# earlier "merged" read false at posting time and stayed false whenever the
|
|
455
|
+
# floor's second write then failed.
|
|
426
456
|
FLOOR_TASK_COMMENT = (
|
|
427
|
-
"**Merge policy {policy}
|
|
428
|
-
"{klass}: {reason}. Validate
|
|
457
|
+
"**Merge policy `{policy}` is merging PR #{pr} with an open operator gate** — "
|
|
458
|
+
"{klass}: {reason}. A `Validate after merge` task follows this comment; "
|
|
459
|
+
"validate that before validating this task. ({url})"
|
|
429
460
|
)
|
|
430
461
|
FLOOR_TASK_TITLE = "Validate after merge: {reason}"
|
|
431
462
|
FLOOR_TASK_DOD = (
|
|
@@ -433,14 +464,31 @@ FLOOR_TASK_DOD = (
|
|
|
433
464
|
"has been validated by a human after the merge, and the outcome is recorded "
|
|
434
465
|
"on this task."
|
|
435
466
|
)
|
|
467
|
+
# Owner: the calling actor (issue #120). The API defaults the owner of a
|
|
468
|
+
# downstream task to the caller, and the caller is the fleet actor -- the one
|
|
469
|
+
# identity that owns every task the loop feeds. The origin's reporter is not
|
|
470
|
+
# seated (the owner-assign gate refuses it) and does not need to be: the human
|
|
471
|
+
# validates the ORIGIN task, which is this task's parent and carries the floor
|
|
472
|
+
# comment; this row exists so the item cannot vanish.
|
|
436
473
|
FLOOR_TASK_DESCRIPTION = (
|
|
437
|
-
"The orchestrator's merge edge
|
|
474
|
+
"The orchestrator's merge edge is merging {pr_url} under merge policy `{policy}` "
|
|
438
475
|
"although the reviewer's approve read `Merge-Readiness: operator — {klass}: "
|
|
439
|
-
"{reason}`. The class is in `merge_yolo_merge_classes`, so the merge
|
|
476
|
+
"{reason}`. The class is in `merge_yolo_merge_classes`, so the merge does not "
|
|
440
477
|
"wait -- but the gate is still open: validate it before validating the origin "
|
|
441
|
-
"task
|
|
478
|
+
"task. Owner: the fleet actor; the human validates the ORIGIN task — this row "
|
|
479
|
+
"exists so the item cannot vanish.\n\nAlissa-Task: {task_ref}"
|
|
442
480
|
)
|
|
443
481
|
FLOOR_TASK_CRITERION = "The open operator gate ({klass}) has been validated by a human after the merge."
|
|
482
|
+
# The escalation (issue #120): a floor write refused for good -- a 4xx that is
|
|
483
|
+
# not transient, the owner-assign gate's 403 being the one that was seen in
|
|
484
|
+
# production -- is not a next-poll retry. Once per (pr, head), on the origin
|
|
485
|
+
# task, naming the HTTP error; the merge edge then leaves this head alone.
|
|
486
|
+
FLOOR_FAILED_TASK_COMMENT = (
|
|
487
|
+
"**Merge policy `{policy}` cannot write the validation floor for PR #{pr} on "
|
|
488
|
+
"`{head7}`** — {detail}. This PR needs a human merge until the floor can be "
|
|
489
|
+
"written; the merge edge skips this head without retrying (a new head is a "
|
|
490
|
+
"new attempt). ({url})"
|
|
491
|
+
)
|
|
444
492
|
# The correction (PR #117 round 1): the floor is worded as the issue words it
|
|
445
493
|
# and posted BEFORE the merge call, so a refused merge leaves the origin task
|
|
446
494
|
# paged about a merge that did not happen. Once per (pr, head), best-effort.
|
|
@@ -941,10 +989,21 @@ class MergeEdge:
|
|
|
941
989
|
) -> "MergeDecision | None":
|
|
942
990
|
"""Page the origin task about the gate the policy is about to merge
|
|
943
991
|
past: one comment, then one committed `Validate after merge`
|
|
944
|
-
downstream task owned by the
|
|
945
|
-
call, once per (pr, head). None when the floor stands (posted
|
|
946
|
-
on an earlier poll for this head); a SKIPPED decision when any
|
|
947
|
-
failed
|
|
992
|
+
downstream task owned by the calling actor (issue #120) -- BEFORE the
|
|
993
|
+
merge call, once per (pr, head). None when the floor stands (posted
|
|
994
|
+
now or on an earlier poll for this head); a SKIPPED decision when any
|
|
995
|
+
write failed: a TRANSIENT failure (5xx, 429, transport) is retried
|
|
996
|
+
next poll; a PERMANENT one (a 4xx that is not transient -- 403, 404,
|
|
997
|
+
422) is escalated once on the origin task and every later poll on
|
|
998
|
+
this head is a quiet SKIP (`_floor_failed`).
|
|
999
|
+
|
|
1000
|
+
The task's owner is whoever the API defaults to -- the caller, i.e.
|
|
1001
|
+
the fleet actor. `assign_to_actor_id` is deliberately NOT passed: the
|
|
1002
|
+
owner-assign gate refuses the fleet actor seating another actor as
|
|
1003
|
+
owner ("Only a team admin can seat another member as owner; pass
|
|
1004
|
+
teamSlug"), which is what kept every yolo merge skipped forever
|
|
1005
|
+
(issue #120). The reporter still sees the floor through the origin
|
|
1006
|
+
task, this task's parent, which carries the floor comment.
|
|
948
1007
|
|
|
949
1008
|
Three ledger rows: the comment's lands after the comment so a failed
|
|
950
1009
|
task create does not re-post it; the task's -- carrying the created
|
|
@@ -957,6 +1016,14 @@ class MergeEdge:
|
|
|
957
1016
|
floor_kind = f"{FLOOR_KIND_PREFIX}{pr.number}:{pr.head7}"
|
|
958
1017
|
if self.state.escalated(candidate.task_number, floor_kind):
|
|
959
1018
|
return None
|
|
1019
|
+
failed_kind = f"{FLOOR_FAILED_KIND_PREFIX}{pr.number}:{pr.head7}"
|
|
1020
|
+
if self.state.escalated(candidate.task_number, failed_kind):
|
|
1021
|
+
# Escalated on an earlier poll: quiet, no write, no WARNING (L4).
|
|
1022
|
+
return self._skipped(
|
|
1023
|
+
candidate,
|
|
1024
|
+
f"{pr.pr_slug}: floor failed for good on {pr.head7} (escalated on {ref}) — "
|
|
1025
|
+
f"needs a human merge; a new head retries",
|
|
1026
|
+
)
|
|
960
1027
|
if self.alissa is None:
|
|
961
1028
|
return self._skipped(
|
|
962
1029
|
candidate,
|
|
@@ -973,15 +1040,7 @@ class MergeEdge:
|
|
|
973
1040
|
self.state.record_escalation(candidate.task_number, comment_kind, at=now)
|
|
974
1041
|
task_id = self._floor_task_id(candidate, task_prefix)
|
|
975
1042
|
if task_id is None:
|
|
976
|
-
#
|
|
977
|
-
# task. A payload without one falls back to the calling actor
|
|
978
|
-
# (the API's own default) and says so in the log.
|
|
979
|
-
origin = self.alissa.get_task(ref)
|
|
980
|
-
if not origin.reporter_id:
|
|
981
|
-
log.warning(
|
|
982
|
-
"merge: %s carries no reporterId — the validation task defaults to the "
|
|
983
|
-
"daemon's own actor", ref,
|
|
984
|
-
)
|
|
1043
|
+
# No owner override (issue #120): the API seats the caller.
|
|
985
1044
|
task_id = self.alissa.create_downstream_task(
|
|
986
1045
|
ref,
|
|
987
1046
|
title=FLOOR_TASK_TITLE.format(reason=reason or klass)[:200],
|
|
@@ -995,7 +1054,6 @@ class MergeEdge:
|
|
|
995
1054
|
"id": "vc-1", "type": "manual",
|
|
996
1055
|
"description": FLOOR_TASK_CRITERION.format(klass=klass),
|
|
997
1056
|
}],
|
|
998
|
-
assign_to_actor_id=origin.reporter_id,
|
|
999
1057
|
)
|
|
1000
1058
|
# The task exists from here on: its row lands BEFORE the
|
|
1001
1059
|
# read-back and the status set, so a failure in either
|
|
@@ -1010,6 +1068,8 @@ class MergeEdge:
|
|
|
1010
1068
|
if created.status != "committed":
|
|
1011
1069
|
self.alissa.set_status(task_id, "committed")
|
|
1012
1070
|
except AlissaError as exc:
|
|
1071
|
+
if floor_failure_is_permanent(exc):
|
|
1072
|
+
return self._floor_failed(candidate, now, exc=exc, kind=failed_kind)
|
|
1013
1073
|
log.warning(
|
|
1014
1074
|
"merge: floor on %s for %s not posted (%s) — merge skipped this poll",
|
|
1015
1075
|
ref, pr.pr_slug, exc,
|
|
@@ -1037,6 +1097,38 @@ class MergeEdge:
|
|
|
1037
1097
|
task_id = str(rows[0]["kind"])[len(task_prefix):]
|
|
1038
1098
|
return task_id or None
|
|
1039
1099
|
|
|
1100
|
+
def _floor_failed(
|
|
1101
|
+
self, candidate: MergeCandidate, now: int, *, exc: AlissaError, kind: str,
|
|
1102
|
+
) -> MergeDecision:
|
|
1103
|
+
"""A floor write refused for good (issue #120): one escalation comment
|
|
1104
|
+
on the origin task naming the HTTP error, one WARNING, the
|
|
1105
|
+
`merge:floor-failed:<pr>:<head7>` row -- and SKIPPED. The row is what
|
|
1106
|
+
makes every later poll on this head a quiet SKIP before any write
|
|
1107
|
+
(`_floor`'s second check). The comment is best-effort in the same
|
|
1108
|
+
sense as the correction: one that did not post leaves no row, so the
|
|
1109
|
+
next poll escalates again (and re-tries the floor, which fails the
|
|
1110
|
+
same way) until the page lands -- an escalation that never posted is
|
|
1111
|
+
not an escalation."""
|
|
1112
|
+
pr = candidate.pr
|
|
1113
|
+
ref = candidate.ref
|
|
1114
|
+
log.warning(
|
|
1115
|
+
"merge: floor on %s for %s failed for good (%s) — escalated once, this head "
|
|
1116
|
+
"needs a human merge; skipped quietly from here on",
|
|
1117
|
+
ref, pr.pr_slug, exc,
|
|
1118
|
+
)
|
|
1119
|
+
posted = self._task_comment(candidate, FLOOR_FAILED_TASK_COMMENT.format(
|
|
1120
|
+
policy=self.config.merge_policy, pr=pr.number, head7=pr.head7, detail=exc,
|
|
1121
|
+
url=pr.url or pr.pr_slug,
|
|
1122
|
+
))
|
|
1123
|
+
if posted:
|
|
1124
|
+
self.state.record_escalation(candidate.task_number, kind, at=now)
|
|
1125
|
+
return self._skipped(
|
|
1126
|
+
candidate,
|
|
1127
|
+
f"{pr.pr_slug}: floor failed for good on {ref} ({exc}) — escalated"
|
|
1128
|
+
+ ("" if posted else " (escalation comment pending — retries next poll)")
|
|
1129
|
+
+ ", needs a human merge; a new head retries",
|
|
1130
|
+
)
|
|
1131
|
+
|
|
1040
1132
|
def _floor_unmerged(self, candidate: MergeCandidate, now: int, *, detail: str) -> None:
|
|
1041
1133
|
"""The correction the origin task gets when the merge call fails on a
|
|
1042
1134
|
head it was paged about: once per (pr, head), best-effort (a comment
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.18.4
|
|
@@ -2122,8 +2122,10 @@ def _queued_behind(card: Card, lanes: Sequence[LaneRow], today: str) -> int:
|
|
|
2122
2122
|
"""How many members are queued behind THIS card's lane claim today.
|
|
2123
2123
|
|
|
2124
2124
|
Read straight off the lane map rather than recounted: `record_lane` computes
|
|
2125
|
-
the pressure with `scopes_disjoint
|
|
2126
|
-
|
|
2125
|
+
the pressure with `scopes_disjoint` under the repo's scope-lock exemptions
|
|
2126
|
+
(issue #123: an overlap on exempt, regenerable paths alone is not a
|
|
2127
|
+
conflict here either), the same predicate the lane gate refuses on, so a
|
|
2128
|
+
leverage figure and the holds behind it cannot disagree
|
|
2127
2129
|
(the rule [[o21-workplan-console]] learned the hard way -- two definitions of
|
|
2128
2130
|
one word is a bug even when both are right).
|
|
2129
2131
|
|
|
@@ -103,8 +103,10 @@ QUEUE_GUIDE = (
|
|
|
103
103
|
LANE_GUIDE = (
|
|
104
104
|
"Rows are SCOPE CLAIMS, not directories: each is an `Autonomous-Scope` "
|
|
105
105
|
"value some released issue declared. Two rows that overlap are a conflict "
|
|
106
|
-
"— that is what the lane gate refuses
|
|
107
|
-
"
|
|
106
|
+
"— that is what the lane gate refuses — unless the overlap is only on a "
|
|
107
|
+
"regenerable path the operator exempted from the lock "
|
|
108
|
+
"(`scope_lock_exempt`; an overlap between two exempt entries is set aside). ▲ N is "
|
|
109
|
+
"queue pressure: committed feed members held behind that claim on that day."
|
|
108
110
|
)
|
|
109
111
|
|
|
110
112
|
_STAGE_HINTS: dict[Stage, str] = {
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.18.2
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|