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.
Files changed (37) hide show
  1. {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
  2. {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/__main__.py +18 -0
  3. {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
  4. {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/config.py +111 -1
  5. {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/loop.py +123 -9
  6. {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/merge.py +122 -30
  7. alissa_tools_github_orcloop-0.18.4/src/main/alissa/tools/github/orcloop/version +1 -0
  8. {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/workplan.py +4 -2
  9. {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
  10. {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
  11. alissa_tools_github_orcloop-0.18.2/src/main/alissa/tools/github/orcloop/version +0 -1
  12. {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/LICENSE +0 -0
  13. {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/MANIFEST.in +0 -0
  14. {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/NOTICE +0 -0
  15. {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/README.md +0 -0
  16. {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/requirements.txt +0 -0
  17. {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/setup.cfg +0 -0
  18. {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/setup.py +0 -0
  19. {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
  20. {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
  21. {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/console.py +0 -0
  22. {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
  23. {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/ghclient.py +0 -0
  24. {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/lever.py +0 -0
  25. {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
  26. {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/markers.py +0 -0
  27. {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
  28. {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/seats.py +0 -0
  29. {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/sentinel.py +0 -0
  30. {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/state.py +0 -0
  31. {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/version.py +0 -0
  32. {alissa_tools_github_orcloop-0.18.2 → alissa_tools_github_orcloop-0.18.4}/src/main/alissa/tools/github/orcloop/watchdog.py +0 -0
  33. {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
  34. {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
  35. {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
  36. {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
  37. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-orcloop
3
- Version: 0.18.2
3
+ Version: 0.18.4
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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, assigned to the origin's reporter.
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 the base `AlissaError` and the caller decides
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 scopes_disjoint(first: list[str], second: list[str]) -> bool:
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(globs_overlap(one, two) for one in first for two in second)
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 not None:
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 naming
88
- the class and the reason, and a committed `Validate after merge`
89
- downstream task owned by the origin's reporter. Either write failing ->
90
- SKIPPED "floor not posted", retried next poll. Never a merge without
91
- the page. Three rows keep the retry from repeating a write that landed:
92
- the comment's, the task's (`merge:floor-task:<pr>:<head7>:<taskId>`,
93
- written the moment the create returns, so a failed read-back or status
94
- set never creates a second task), and the floor's.
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 the issue's.
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} merged PR #{pr} with an open operator gate** — "
428
- "{klass}: {reason}. Validate this before validating the task. ({url})"
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 merged {pr_url} under merge policy `{policy}` "
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 did not "
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.\n\nAlissa-Task: {task_ref}"
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 origin's reporter -- BEFORE the merge
945
- call, once per (pr, head). None when the floor stands (posted now or
946
- on an earlier poll for this head); a SKIPPED decision when any write
947
- failed, so this poll merges nothing and the next retries the floor.
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
- # The reporter owns the validation: read it off the origin
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
@@ -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`, the same predicate the lane gate
2126
- refuses on, so a leverage figure and the holds behind it cannot disagree
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. ▲ N is queue pressure: committed "
107
- "feed members held behind that claim on that day."
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-orcloop
3
- Version: 0.18.2
3
+ Version: 0.18.4
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera