alissa-tools-github-devloop 0.8.3__tar.gz → 0.8.5__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 (32) hide show
  1. {alissa_tools_github_devloop-0.8.3/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.5}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/src/main/alissa/tools/github/devloop/ghclient.py +47 -0
  3. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/src/main/alissa/tools/github/devloop/loop.py +197 -3
  4. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/src/main/alissa/tools/github/devloop/state.py +358 -57
  5. alissa_tools_github_devloop-0.8.5/src/main/alissa/tools/github/devloop/version +1 -0
  6. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  7. alissa_tools_github_devloop-0.8.3/src/main/alissa/tools/github/devloop/version +0 -1
  8. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/LICENSE +0 -0
  9. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/MANIFEST.in +0 -0
  10. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/NOTICE +0 -0
  11. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/README.md +0 -0
  12. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/requirements.txt +0 -0
  13. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/setup.cfg +0 -0
  14. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/setup.py +0 -0
  15. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  16. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
  17. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  18. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/src/main/alissa/tools/github/devloop/config.py +0 -0
  19. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
  20. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  21. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/src/main/alissa/tools/github/devloop/version.py +0 -0
  22. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  23. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  24. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  25. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  26. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  27. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
  28. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  29. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
  30. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  31. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  32. {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.5}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.3
3
+ Version: 0.8.5
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -135,6 +135,17 @@ class PullRequest:
135
135
  # instant that window runs from. Defaulted for the same reason as node_id:
136
136
  # the three spawn edges never ask, and neither do their fakes.
137
137
  closed_at: "int | None" = None
138
+ # The branch this PR would merge INTO (`base.ref`). Carried for the
139
+ # stacked-base guard: a PR based on a sibling task's branch rather than on
140
+ # the repository's default branch strands its diff the moment that parent
141
+ # merges -- GitHub retargets the child, the child merges into a branch that
142
+ # is no longer anywhere, and the PR reads "merged" while the default branch
143
+ # never receives the work (fahera-mx/studio.alissa.app#330/#333/#334).
144
+ # Empty when the payload carried no readable base: that reads as UNKNOWN,
145
+ # never as a mismatch, so a thin payload can never page an operator about a
146
+ # base nobody observed. Defaulted like node_id and closed_at -- the edges
147
+ # that only spawn never ask.
148
+ base_ref: str = ""
138
149
 
139
150
  @property
140
151
  def full_name(self) -> str:
@@ -160,6 +171,7 @@ def _pull_request_from(
160
171
  the listing reads it from each entry.
161
172
  """
162
173
  head = data.get("head") or {}
174
+ base = data.get("base") or {}
163
175
  # Users carry `login`, teams `slug`; both hold the review-response edge
164
176
  # closed -- a pending TEAM re-request still means the ball is in a
165
177
  # reviewer's court, so it must not read as "awaiting the author".
@@ -188,6 +200,7 @@ def _pull_request_from(
188
200
  requested_reviewers=requested,
189
201
  node_id=data.get("node_id") or "",
190
202
  closed_at=parse_github_timestamp(data.get("closed_at")),
203
+ base_ref=base.get("ref") or "",
191
204
  )
192
205
 
193
206
 
@@ -316,6 +329,10 @@ def _is_rate_limited(exc: proc.CommandError) -> bool:
316
329
  class GitHub:
317
330
  def __init__(self, login: str | None = None):
318
331
  self._login = login
332
+ # Memoised `default_branch()` answers, keyed (owner, repo). See that
333
+ # method for why a repository's default branch is cached for the
334
+ # process lifetime while `pull_request()` deliberately is not.
335
+ self._default_branches: "dict[tuple[str, str], str]" = {}
319
336
 
320
337
  def token_login(self) -> str:
321
338
  """Who the gh token actually belongs to. `gh api --jq` prints scalars
@@ -672,6 +689,36 @@ class GitHub:
672
689
  data = self._api(f"repos/{owner}/{repo}/pulls/{number}") or {}
673
690
  return _pull_request_from(data, owner, repo, number=number)
674
691
 
692
+ def default_branch(self, owner: str, repo: str) -> str:
693
+ """The repository's default branch, as GitHub reports it.
694
+
695
+ The stacked-base guard's reference point, and it is FETCHED rather than
696
+ assumed: `main` is a convention, not a fact. Forks keep whatever the
697
+ upstream had, repositories created before 2020 default to `master`, and
698
+ a house style of `develop` is ordinary -- comparing a PR's base against
699
+ a hardcoded literal would page every operator on such a repo about
700
+ every correctly-based PR, which is worse than the failure it guards.
701
+ Empty when the payload carries no readable `default_branch`; the guard
702
+ reads that as UNKNOWN and stands down, never as a mismatch.
703
+
704
+ MEMOISED for the process lifetime, keyed (owner, repo). Unlike
705
+ `pull_request()` -- deliberately uncached, because draft state and
706
+ review requests change under the daemon by the minute -- a default
707
+ branch is quasi-immutable: it changes on a deliberate repository-
708
+ settings edit, which is rare and operator-driven. Without the memo this
709
+ would be one extra GET per own open PR per poll, forever, on a
710
+ comparison whose answer never moves. The stale window a rename opens is
711
+ bounded and cheap: until the daemon restarts, PRs based on the NEW
712
+ default read as stacked and each raises its one advisory comment --
713
+ noisy, never an action, and the guard performs no remediation by
714
+ design.
715
+ """
716
+ key = (owner, repo)
717
+ if key not in self._default_branches:
718
+ data = self._api(f"repos/{owner}/{repo}") or {}
719
+ self._default_branches[key] = data.get("default_branch") or ""
720
+ return self._default_branches[key]
721
+
675
722
  def branch_pulls(
676
723
  self, owner: str, repo: str, head_ref: str
677
724
  ) -> "tuple[PullRequest, ...]":
@@ -353,6 +353,7 @@ import calendar
353
353
  import dataclasses
354
354
  import logging
355
355
  import re
356
+ import sqlite3
356
357
  import time
357
358
  from collections import Counter
358
359
  from dataclasses import dataclass
@@ -502,6 +503,30 @@ _FIX_DELIVERY_NOTE = (
502
503
  + _DELIVERY_NOTE_SECTIONS
503
504
  )
504
505
 
506
+ # The base-branch rule (issue #78) -- the prevention half of the stacked-base
507
+ # guard, whose detection half pages an operator after the fact. A worker whose
508
+ # task depends on a sibling task's unmerged work is tempted to open its PR ON
509
+ # that branch, and the temptation is reasonable: it makes the diff readable.
510
+ # What it actually does is strand the work. When the parent merges first,
511
+ # GitHub retargets the child onto a branch nothing reads any more; the child
512
+ # then merges into that detached branch and reports success while the default
513
+ # branch never receives the diff (fahera-mx/studio.alissa.app#330/#333/#334 --
514
+ # re-landed by hand as a cherry-pick). The rule names the DEFAULT BRANCH rather
515
+ # than `main` for the same reason the daemon-side check reads it from the API:
516
+ # `main` is a convention, and a worker on a repo defaulting to `master` or
517
+ # `develop` would follow the letter of a `main` rule straight into the failure.
518
+ _DEFAULT_BASE = (
519
+ "One rule about the PR's BASE: open it against the repository's DEFAULT "
520
+ "branch (`gh repo view --json defaultBranchRef` if you are unsure) and "
521
+ "never against another task's branch. Basing on a sibling task's branch to "
522
+ "make your diff read cleanly is how work gets silently stranded: when that "
523
+ "parent merges first, GitHub retargets your PR onto a branch nothing reads "
524
+ "any more, your merge reports success, and the diff never reaches the "
525
+ "default branch. If your work depends on another task's, say so in the PR "
526
+ "body and let the operator sequence the merges — ordering between tasks is "
527
+ "release gating's job, not the PR graph's. "
528
+ )
529
+
505
530
  # The approve-verdict rule (issue #68). An APPROVE hands the branch to the
506
531
  # operator, who may merge it the very next second -- observed live on
507
532
  # fahera-mx/studio.alissa.app#275: round 4 approved with three [minor]s
@@ -512,7 +537,9 @@ _FIX_DELIVERY_NOTE = (
512
537
  # The remedy is a rule about WHERE the work goes, not about how fast the worker
513
538
  # is: after an approve the branch is merge-racy BY DEFINITION, so no amount of
514
539
  # hurrying makes a push to it safe. Approval-attached [pursue] findings become a
515
- # fresh branch off main and a follow-up PR -- exactly the recovery that incident
540
+ # fresh branch off the DEFAULT branch (issue #78: not off the approved branch,
541
+ # and not off a sibling task's) and a follow-up PR -- exactly the recovery that
542
+ # incident
516
543
  # improvised, made the FIRST move instead of the fallback -- and that PR gets
517
544
  # driven to a completed handoff in the same session, because a follow-up nobody
518
545
  # reviews is the orphan-draft half of this same issue.
@@ -526,8 +553,10 @@ _APPROVE_FOLLOWUP = (
526
553
  "branch. An approved branch is merge-racy by definition: the operator may "
527
554
  "merge it at any moment, and a commit pushed after the squash-merge is "
528
555
  "silently lost. Findings attached to an approve that you triage "
529
- "`[pursue]` are implemented as a NEW branch off main and a FOLLOW-UP pull "
530
- "request never as another push to the approved branch whose body names "
556
+ "`[pursue]` are implemented as a NEW branch off the repository's DEFAULT "
557
+ "branch (never off the approved branch, and never off another task's "
558
+ "branch) and a FOLLOW-UP pull request opened against that same default "
559
+ "branch — never as another push to the approved branch — whose body names "
531
560
  "the source PR and review round plus BOTH task refs (origin + "
532
561
  "implementation), and you drive that follow-up's handoff to completion in "
533
562
  "THIS session (ready + review re-request), never leaving it a draft. "
@@ -561,6 +590,7 @@ DEV_DIRECTIVE = (
561
590
  "AND your implementation task: the PR body is the canonical cross-actor "
562
591
  "record, and each actor's tasks live in its own partition — and attach "
563
592
  "the PR URL to the task as evidence. "
593
+ + _DEFAULT_BASE
564
594
  + _DELIVERY_NOTE
565
595
  + " Then drive the review handoff with "
566
596
  "the alissa-pr-review skill, requesting reviewer(s): {reviewers}. In "
@@ -856,6 +886,23 @@ ORPHAN_REJECTED_COMMENT = (
856
886
  "park the work."
857
887
  )
858
888
 
889
+ STACKED_BASE_COMMENT = (
890
+ "**Dev loop: this pull request is not based on the default branch** — it "
891
+ "targets `{base}`, but `{full_name}`'s default branch is `{default}`. A PR "
892
+ "stacked on another task's branch strands its own work: when the parent "
893
+ "merges, GitHub retargets this PR onto a branch that no longer receives "
894
+ "anything, the merge reports success, and the diff never reaches "
895
+ "`{default}` (fahera-mx/studio.alissa.app#330 / #333 / #334 — the work had "
896
+ "to be re-landed by hand as a cherry-pick). The daemon has changed "
897
+ "NOTHING: retargeting a PR after its parent merged silently widens the "
898
+ "diff to everything the parent carried, so the disposition is yours. "
899
+ "Operator options: retarget this PR to `{default}` while the parent is "
900
+ "still open (`gh pr edit {number} --base {default}`), or let it merge into "
901
+ "the parent knowing the work lands only when the parent does, or close it "
902
+ "and re-open the branch against `{default}`. Ordering between tasks "
903
+ "belongs to release gating, not to the PR graph."
904
+ )
905
+
859
906
  ASSIGNMENT_REJECTED_COMMENT = (
860
907
  "**Dev loop cannot start** — the daemon tried to self-assign this issue "
861
908
  "as its in-flight marker, but GitHub silently dropped the assignment: "
@@ -1003,6 +1050,19 @@ ESCALATION_ORPHAN = "orphan"
1003
1050
  ESCALATION_ORPHAN_READY = "orphan-ready"
1004
1051
  ESCALATION_ORPHAN_REJECTED = "orphan-rejected"
1005
1052
 
1053
+ # The stacked-base guard's operator page (issue #78). Keyed on the BARE kind --
1054
+ # once per PR for its whole life, not per head and not per base -- because the
1055
+ # page describes the PR's SHAPE, not an event on it: a mismatched base is one
1056
+ # standing condition with one operator decision behind it, and re-paging every
1057
+ # time the head moves would turn a structural warning into a per-push nag on a
1058
+ # PR whose operator is already engaged. The cost of the bare key is stated
1059
+ # rather than hidden: a PR retargeted from one non-default branch to another
1060
+ # does not page again, which is the right trade when the first page already
1061
+ # named the default branch and the risk. The `stacked-` prefix keeps these rows
1062
+ # from colliding with any issue-edge kind when an issue and a PR share a
1063
+ # number, like every other PR-side kind here.
1064
+ ESCALATION_STACKED_BASE = "stacked-base"
1065
+
1006
1066
  # CR6 verdict words, spelled exactly as reviewloop's alissa.py spells them --
1007
1067
  # the envelope tie-breaker (see DevWatcher.envelope_verdict) must return
1008
1068
  # these strings, so the two daemons read one vocabulary.
@@ -2711,6 +2771,14 @@ class DevWatcher:
2711
2771
  pr = self.github.pull_request(issue.owner, issue.repo, int(tail))
2712
2772
  if pr.state != "open":
2713
2773
  continue # closed between the timeline walk and now
2774
+ # The issue edge's own first sight: this is where a linked open PR
2775
+ # is admitted into the daemon's evaluation, and the stacked-base
2776
+ # guard rides the fetch that already happened. Placed ABOVE the
2777
+ # resumability tests on purpose -- a PR that is not a resume
2778
+ # candidate (handed off, foreign-shaped, another task's work) is
2779
+ # still a PR whose base can strand it, and the guard's own author
2780
+ # test is what keeps it off PRs that are not ours.
2781
+ self._check_stacked_base(pr)
2714
2782
  if not pr.draft or pr.requested_reviewers:
2715
2783
  log.info(
2716
2784
  "%s: %s has been handed off (draft=%s, reviewers=%s) — "
@@ -2986,6 +3054,16 @@ class DevWatcher:
2986
3054
  if pr.state != "open":
2987
3055
  return Decision(Action.SKIPPED, "PR closed between search and fetch")
2988
3056
 
3057
+ # FIRST SIGHT of an own open PR on this edge -- the earliest moment the
3058
+ # daemon holds a fetched PR object, and therefore where the stacked-base
3059
+ # guard belongs. Deliberately ABOVE the draft split: with the orphan
3060
+ # sweep on (the default) the search returns drafts, so a PR opened
3061
+ # against a sibling task's branch is paged on the first poll after it is
3062
+ # opened, while retargeting is still free. It is advisory only -- it
3063
+ # changes nothing about the decision below it, which is why its return
3064
+ # value is not consulted here.
3065
+ self._check_stacked_base(pr)
3066
+
2989
3067
  # A draft is out of review by definition (same belt-and-braces as
2990
3068
  # reviewloop's CR1 re-check -- a PR can flip back to draft between
2991
3069
  # search and fetch even when the search excluded drafts). It is also
@@ -4409,6 +4487,102 @@ class DevWatcher:
4409
4487
  task_ref=task_ref,
4410
4488
  )
4411
4489
 
4490
+ def _check_stacked_base(self, pr: PullRequest) -> bool:
4491
+ """Page the operator ONCE when an own open PR is not based on the
4492
+ repository's default branch. Returns whether the page was raised.
4493
+
4494
+ The 2026-07-30 studio incident in one line: a worker opened its PR with
4495
+ base `TASK-1628865936-FORMS-T5` -- its parent task's branch -- instead
4496
+ of the default branch. The parent merged first; GitHub retargeted the
4497
+ child onto a branch nothing reads any more; the child then "merged"
4498
+ into that detached branch, so the PR showed merged, the default branch
4499
+ never received the diff, and the daemon's own accounting degraded to
4500
+ "attempt has opened no pull request" (a PR merged into a branch is
4501
+ invisible to its issue's edge). The work had to be re-landed by hand.
4502
+
4503
+ This guard is ADVISORY BY CONSTRUCTION. It comments and returns; it
4504
+ never retargets, closes, parks, or changes what the calling edge
4505
+ decides. Retargeting is the tempting remedy and the wrong one: after
4506
+ the parent has merged, moving the base to the default branch re-opens
4507
+ the diff against a different merge-base and silently widens the PR to
4508
+ everything the parent carried. Which of the three dispositions is right
4509
+ depends on facts only a human has, so the daemon states the risk and
4510
+ stops.
4511
+
4512
+ Timing is the whole point: the page fires on FIRST SIGHT -- the first
4513
+ poll on which any edge fetches the PR -- because every remedy is cheap
4514
+ while the parent is still open and expensive afterwards. A check at
4515
+ merge time would always be too late.
4516
+
4517
+ Three guards below it, all fail-safe:
4518
+
4519
+ * the PR must be OURS (`author == login`). The callers reach here with
4520
+ own PRs by construction (`author:@me` search, and the resume probe
4521
+ re-checks), but a foreign PR's base is a foreign author's business
4522
+ and this must never comment on one;
4523
+ * a base or a default branch that reads EMPTY is UNKNOWN, not a
4524
+ mismatch. A thin payload, a repository fetch that came back without
4525
+ `default_branch` -- neither is evidence of anything, and paging on
4526
+ absent data is how a guard teaches operators to ignore it;
4527
+ * the ledger row is checked BEFORE the default-branch fetch, so a PR
4528
+ already paged costs nothing further per poll -- the steady state for
4529
+ a stacked PR that the operator has decided to let ride.
4530
+
4531
+ Delivery contract: the row lands only AFTER the comment posts (the
4532
+ stalled pings' contract, not the cap-outs'). This page is the only
4533
+ signal an operator gets for a condition that is otherwise invisible,
4534
+ and nothing else in the system will ever raise it again, so a transient
4535
+ POST failure must retry on the next poll rather than be recorded as
4536
+ delivered. Dry-run logs and records nothing, like every other page.
4537
+ """
4538
+ if pr.author != self.github.login:
4539
+ return False
4540
+ if not pr.base_ref:
4541
+ return False # no readable base: unknown, never a mismatch
4542
+ if self.state.escalated(
4543
+ pr.full_name, pr.number, ESCALATION_STACKED_BASE
4544
+ ):
4545
+ return False
4546
+ default = self.github.default_branch(pr.owner, pr.repo)
4547
+ if not default or pr.base_ref == default:
4548
+ return False
4549
+
4550
+ log.error(
4551
+ "STACKED BASE %s: the PR targets %r but %s's default branch is "
4552
+ "%r — a PR based on another task's branch strands its work when "
4553
+ "that parent merges; paging the operator on the PR (the daemon "
4554
+ "changes nothing: the disposition is the operator's)",
4555
+ pr.pr_slug,
4556
+ pr.base_ref,
4557
+ pr.full_name,
4558
+ default,
4559
+ )
4560
+
4561
+ body = STACKED_BASE_COMMENT.format(
4562
+ base=pr.base_ref,
4563
+ default=default,
4564
+ full_name=pr.full_name,
4565
+ number=pr.number,
4566
+ )
4567
+ if self.config.dry_run:
4568
+ log.info("[dry-run] would comment on %s:\n%s", pr.pr_slug, body)
4569
+ return False
4570
+
4571
+ try:
4572
+ self.github.comment(pr.owner, pr.repo, pr.number, body)
4573
+ except CommandError as exc:
4574
+ log.error(
4575
+ "could not post the stacked-base comment on %s: %s — not "
4576
+ "recording the page; it retries next poll",
4577
+ pr.pr_slug,
4578
+ exc,
4579
+ )
4580
+ return False
4581
+ self.state.record_escalation(
4582
+ pr.full_name, pr.number, ESCALATION_STACKED_BASE
4583
+ )
4584
+ return True
4585
+
4412
4586
  def _escalate_fix_cap(self, pr: PullRequest, round_: int, attempts: int) -> None:
4413
4587
  """Operator page for a fix cap-out, posted ON the PR (the issue
4414
4588
  edge's _escalate shape): log, comment (dry-run gated), and record
@@ -6330,6 +6504,26 @@ class DevWatcher:
6330
6504
  except CommandError as exc:
6331
6505
  backoff = min(backoff * 2, 900)
6332
6506
  log.error("poll failed: %s — retrying in %ds", exc, backoff)
6507
+ except sqlite3.Error as exc:
6508
+ # The BACKSTOP for the strict half of the state layer
6509
+ # (issue #76). The bookkeeping writes degrade on their own
6510
+ # (state._best_effort) and never arrive here; what does is
6511
+ # a spawn-ledger write, which must NOT be swallowed -- a
6512
+ # forgotten spawn row would let the daemon re-spawn over
6513
+ # live work. Abandoning the pass and retrying is the honest
6514
+ # middle: the ledger is intact, the cause is named, and a
6515
+ # volume that goes read-only under a running daemon costs a
6516
+ # backed-off retry instead of the process. That is the
6517
+ # whole lesson of the 2026-07-29 revloop incident -- a
6518
+ # local storage fault must never be terminal for an
6519
+ # unattended loop.
6520
+ backoff = min(backoff * 2, 900)
6521
+ log.error(
6522
+ "poll abandoned: the state ledger raised %s — this is "
6523
+ "a LOCAL storage fault (a read-only or wedged volume), "
6524
+ "not a GitHub or auth failure; retrying in %ds",
6525
+ exc, backoff,
6526
+ )
6333
6527
  time.sleep(backoff)
6334
6528
  except KeyboardInterrupt:
6335
6529
  log.info("stopping")
@@ -69,15 +69,26 @@ and a bare-name key would collide their ledgers into one row.
69
69
  from __future__ import annotations
70
70
 
71
71
  import json
72
+ import logging
72
73
  import sqlite3
73
74
  import time
74
75
  from pathlib import Path
76
+ from typing import Callable
77
+
78
+ log = logging.getLogger(__name__)
75
79
 
76
80
  # Poll-snapshot retention: the newest N rows are kept, older pruned on write.
77
81
  # Fixed, not a config key -- the table is an observation buffer for the UI,
78
82
  # and a bounded ring is all the console needs (it reads the recent tail).
79
83
  SNAPSHOT_RETENTION = 1000
80
84
 
85
+ # How many consecutive best-effort write failures get their own WARN before the
86
+ # stream is suppressed. A wedged volume fails EVERY write of EVERY poll, so an
87
+ # unbounded warning is a log flood that buries the daemon's actual work; the
88
+ # first few carry the diagnosis, and the recovery line (always logged) reports
89
+ # the total the streak reached. See State._best_effort.
90
+ DEGRADED_WARN_STREAK = 3
91
+
81
92
  SCHEMA = """
82
93
  CREATE TABLE IF NOT EXISTS spawns (
83
94
  repo_slug TEXT NOT NULL,
@@ -153,10 +164,46 @@ _COLUMN_MIGRATIONS = (
153
164
  )
154
165
 
155
166
 
167
+ # The dedupe-gate tables, and the column order _flush_pending_gates replays
168
+ # them in: (key column, number column, kind column, timestamp column). Both are
169
+ # `INSERT OR IGNORE` targets keyed on their first three columns, which is what
170
+ # makes a replay safe to run against rows that may already exist.
171
+ _GATE_REPLAY = {
172
+ "escalations": ("repo_slug", "issue", "kind", "escalated_at"),
173
+ "activity_log": ("repo_slug", "number", "kind", "logged_at"),
174
+ }
175
+
176
+
177
+ def _migrate_columns(db: sqlite3.Connection) -> None:
178
+ """Apply the additive column migrations `IF NOT EXISTS` cannot: add any
179
+ _COLUMN_MIGRATIONS column missing from an already-existing table.
180
+ Idempotent -- the live column set is checked first, so a fresh DB (the
181
+ column already in SCHEMA) and a re-open both no-op.
182
+
183
+ Module-level, taking the connection: State._reconnect has to bring a
184
+ REPLACEMENT handle up to the same schema as __init__ did, and a method
185
+ bound to self._db could not be used before the swap."""
186
+ for table, column, ddl in _COLUMN_MIGRATIONS:
187
+ live = {row["name"] for row in db.execute(f"PRAGMA table_info({table})")}
188
+ if column not in live:
189
+ db.execute(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}")
190
+
191
+
156
192
  class State:
157
193
  def __init__(self, path: Path):
158
194
  path = Path(path).expanduser()
159
195
  path.parent.mkdir(parents=True, exist_ok=True)
196
+ self._path = path
197
+ # Length of the current streak of failed best-effort writes; 0 while
198
+ # the store is healthy. Drives both the reconnect attempt and the
199
+ # warning suppression (see _best_effort).
200
+ self._degraded = 0
201
+ # Dedupe keys whose row could NOT be written, mapped to the timestamp
202
+ # the row would have carried. Consulted by escalated() and
203
+ # activity_logged() so a gate that exists to fire ONCE still fires once
204
+ # while the store is unwritable, and REPLAYED by _flush_pending_gates
205
+ # when writes work again -- see _degraded_gate.
206
+ self._pending_gates: "dict[tuple[str, str, int, str], int]" = {}
160
207
  self._db = sqlite3.connect(str(path))
161
208
  self._db.row_factory = sqlite3.Row
162
209
  self._db.executescript(SCHEMA)
@@ -164,19 +211,214 @@ class State:
164
211
  self._db.commit()
165
212
 
166
213
  def _migrate(self) -> None:
167
- """Apply the additive column migrations `IF NOT EXISTS` cannot: add any
168
- _COLUMN_MIGRATIONS column missing from an already-existing table.
169
- Idempotent -- the live column set is checked first, so a fresh DB (the
170
- column already in SCHEMA) and a re-open both no-op."""
171
- for table, column, ddl in _COLUMN_MIGRATIONS:
172
- live = {
173
- row["name"]
174
- for row in self._db.execute(f"PRAGMA table_info({table})")
175
- }
176
- if column not in live:
214
+ """Apply the additive column migrations to this State's connection."""
215
+ _migrate_columns(self._db)
216
+
217
+ # -- best-effort writes (the 2026-07-29 volume incident, issue #76) -----
218
+ #
219
+ # This ledger sits on the same platform volume the daemon's config does,
220
+ # and that volume can go read-only underneath a RUNNING process -- which is
221
+ # exactly what happened to the revloop sibling on 2026-07-29: a telemetry
222
+ # write raised `attempt to write a readonly database` and took the whole
223
+ # poll loop down with it. Losing telemetry is a nuisance; losing the daemon
224
+ # needs a human.
225
+ #
226
+ # So the writes are split by what a LOST ROW actually costs:
227
+ #
228
+ # best-effort record_snapshot / record_activity / record_escalation --
229
+ # observation and dedupe bookkeeping. Their side effect (the
230
+ # snapshot row, the posted comment) either does not exist yet
231
+ # or has ALREADY landed on GitHub by the time the row is
232
+ # written, so a dropped row degrades the ledger -- a missing
233
+ # console sample -- and nothing more.
234
+ #
235
+ # The two that are DEDUPE GATES are additionally contained
236
+ # in memory (_degraded_gate): "degrade" must mean a stale
237
+ # console, not the same operator comment re-posted to a real
238
+ # GitHub issue once per poll for the length of an outage this
239
+ # daemon is built to survive for hours (PR #77 review r1).
240
+ #
241
+ # strict record_spawn / record_fix_spawn / record_maintain_spawn --
242
+ # the attempt ledger. A dropped row makes the daemon forget a
243
+ # session it just launched: it would re-spawn over live work
244
+ # and mis-count the attempt cap. That must not be swallowed,
245
+ # so those keep raising -- and loop.run_forever now catches
246
+ # sqlite3.Error at the pass boundary, so raising costs a
247
+ # backed-off retry instead of the process.
248
+ #
249
+ # Callers do not choose: the classification lives on the method, so a new
250
+ # call site inherits the right posture from the method it calls.
251
+
252
+ def _reconnect(self) -> bool:
253
+ """Replace the connection after a failed write, then let the caller
254
+ retry it once.
255
+
256
+ Attempted on EVERY failure, not just the first of a streak, and that is
257
+ deliberate: when sqlite opens a file it cannot write, the handle is
258
+ read-only for its whole life, so a store that recovers underneath a
259
+ once-reconnected daemon would stay broken until someone restarted the
260
+ container -- the precise opposite of self-healing, and the same shape of
261
+ bug as the crash this whole change is undoing. The cost of being
262
+ generous is one sqlite3.connect() per failed write, i.e. a syscall or
263
+ two per poll while degraded, against a recovery that needs no human.
264
+
265
+ The new connection is opened BEFORE the old one is dropped, so a failed
266
+ reconnect leaves the working handle in place. Closing first would trade
267
+ a store that is merely unwritable for one that cannot even be READ --
268
+ and every staleness and dedupe decision the daemon makes is a read.
269
+
270
+ The replacement gets the SCHEMA and the column migrations applied, the
271
+ same as __init__. sqlite3.connect() CREATES an empty database at a
272
+ missing path, and the documented recovery for the 2026-07-29 incident
273
+ was a human wiping the volume -- do that under a running daemon and a
274
+ bare reconnect would report success on a schema-less handle, after
275
+ which every (strict) read raises `no such table` and the daemon spins
276
+ at the backoff cap forever: alive, warning, and unable to decide
277
+ anything. `CREATE TABLE IF NOT EXISTS` throughout means re-applying is
278
+ idempotent, and on an intact read-only database it needs no write at
279
+ all, so this stays cheap on the path that actually runs."""
280
+ try:
281
+ db = sqlite3.connect(str(self._path))
282
+ db.row_factory = sqlite3.Row
283
+ db.executescript(SCHEMA)
284
+ _migrate_columns(db)
285
+ db.commit()
286
+ except sqlite3.Error as exc:
287
+ log.debug("state: reconnect to %s failed: %s", self._path, exc)
288
+ return False
289
+ try:
290
+ self._db.close()
291
+ except sqlite3.Error:
292
+ pass
293
+ self._db = db
294
+ return True
295
+
296
+ def _degraded_gate(self, table: str, repo_slug: str, number: int,
297
+ kind: str, ts: int, wrote: bool) -> None:
298
+ """Remember a dedupe key whose row did not land, with the timestamp the
299
+ row would have carried. `escalated()` / `activity_logged()` consult it,
300
+ so a gate that exists to fire exactly once still fires exactly once
301
+ while the store is unwritable -- instead of re-posting its comment
302
+ every poll.
303
+
304
+ The key is released by _flush_pending_gates, NOT by a later call from
305
+ the same call site: every one of those is itself gated on this key, so
306
+ once it is held the code that would write the row is unreachable (PR
307
+ #77 review round 2). `ts` is captured here rather than at replay time
308
+ because `escalated_at` is an audit field for when the escalation was
309
+ first RAISED -- the row must not claim the volume's recovery time."""
310
+ key = (table, repo_slug, number, kind)
311
+ if wrote:
312
+ self._pending_gates.pop(key, None)
313
+ else:
314
+ self._pending_gates.setdefault(key, ts)
315
+
316
+ def _gate_pending(self, table: str, repo_slug: str, number: int,
317
+ kind: str) -> bool:
318
+ return (table, repo_slug, number, kind) in self._pending_gates
319
+
320
+ def _flush_pending_gates(self) -> None:
321
+ """Land the deferred dedupe rows now that a write has succeeded.
322
+
323
+ This is the half that makes the in-memory containment two-way, and
324
+ without it the containment is a TRAP: every call site reads
325
+ escalated() / activity_logged() first, the in-memory key already
326
+ answers True, so record_escalation is never called for that key again
327
+ and the row would never land at all. The costs are not hypothetical --
328
+ `escalations` is not only a comment receipt but the orphan sweep's
329
+ state breadcrumb (orphan_ready_kind records that THIS daemon flipped a
330
+ PR ready), so a row that never lands means a restarted daemon re-runs
331
+ mark_ready and request_reviews against that head; and the console
332
+ sidecar is a separate process that can only see the table.
333
+
334
+ `INSERT OR IGNORE` throughout, so replaying a row that already exists
335
+ is a no-op. A still-failing store keeps every key and tries again on
336
+ the next successful write."""
337
+ if not self._pending_gates:
338
+ return
339
+ landed = []
340
+ for key, ts in list(self._pending_gates.items()):
341
+ table, repo_slug, number, kind = key
342
+ columns = _GATE_REPLAY[table]
343
+ try:
177
344
  self._db.execute(
178
- f"ALTER TABLE {table} ADD COLUMN {column} {ddl}"
345
+ f"INSERT OR IGNORE INTO {table} "
346
+ f"({', '.join(columns)}) VALUES (?,?,?,?)",
347
+ (repo_slug, number, kind, ts),
348
+ )
349
+ self._db.commit()
350
+ except sqlite3.Error as exc:
351
+ log.debug(
352
+ "state: replay of %s deferred while the ledger is still "
353
+ "unwritable (%s)", table, exc,
179
354
  )
355
+ break
356
+ landed.append(key)
357
+ for key in landed:
358
+ self._pending_gates.pop(key, None)
359
+ if landed:
360
+ log.warning(
361
+ "state: replayed %d dedupe row(s) deferred while the ledger "
362
+ "was unwritable; %d still pending",
363
+ len(landed), len(self._pending_gates),
364
+ )
365
+
366
+ def _note_write_ok(self, what: str) -> None:
367
+ """Close out a degraded streak once a write lands again: report the
368
+ total it reached, so the suppressed middle of a long outage is still
369
+ accounted for in the log, and re-arm the warnings."""
370
+ if self._degraded:
371
+ log.warning(
372
+ "state: %s succeeded -- the ledger at %s is accepting writes "
373
+ "again after %d failed write(s)",
374
+ what, self._path, self._degraded,
375
+ )
376
+ self._degraded = 0
377
+ # Unconditional, not folded into the branch above: a partial replay
378
+ # leaves keys behind with the streak already reset, and they must still
379
+ # get their next chance.
380
+ self._flush_pending_gates()
381
+
382
+ def _best_effort(self, what: str, write: Callable[[], None]) -> bool:
383
+ """Run one bookkeeping write so a failing store degrades this ledger
384
+ instead of killing the caller. Returns whether the row landed.
385
+
386
+ `what` names the write for the log. Only sqlite3.Error is caught -- a
387
+ TypeError from a malformed call site is a bug and still raises."""
388
+ try:
389
+ write()
390
+ except sqlite3.Error as exc:
391
+ # Leave no half-open transaction behind: the READS are strict, and
392
+ # they are what every staleness and dedupe decision runs on.
393
+ try:
394
+ self._db.rollback()
395
+ except sqlite3.Error:
396
+ pass
397
+ if self._reconnect():
398
+ try:
399
+ write()
400
+ except sqlite3.Error as retry_exc:
401
+ exc = retry_exc
402
+ else:
403
+ self._note_write_ok(what)
404
+ return True
405
+ self._degraded += 1
406
+ if self._degraded <= DEGRADED_WARN_STREAK:
407
+ log.warning(
408
+ "state: %s failed (%s) -- the ledger at %s is not "
409
+ "accepting writes; the daemon keeps polling with degraded "
410
+ "bookkeeping (failure %d of this streak)",
411
+ what, exc, self._path, self._degraded,
412
+ )
413
+ if self._degraded == DEGRADED_WARN_STREAK:
414
+ log.warning(
415
+ "state: suppressing further write-failure warnings "
416
+ "until the ledger recovers (one line will report the "
417
+ "total)",
418
+ )
419
+ return False
420
+ self._note_write_ok(what)
421
+ return True
180
422
 
181
423
  def close(self) -> None:
182
424
  self._db.close()
@@ -423,7 +665,13 @@ class State:
423
665
  def escalated(self, repo_slug: str, issue: int, kind: str) -> bool:
424
666
  """Whether an escalation of this KIND was already raised. Kinds
425
667
  dedupe independently -- a cap-out row never silences an
426
- assignment-rejection comment, and vice versa."""
668
+ assignment-rejection comment, and vice versa.
669
+
670
+ Answers True as well when the row could not be WRITTEN this run (see
671
+ _degraded_gate): the comment went out, so the gate it stands for is
672
+ closed whether or not sqlite accepted the receipt."""
673
+ if self._gate_pending("escalations", repo_slug, issue, kind):
674
+ return True
427
675
  row = self._db.execute(
428
676
  "SELECT 1 FROM escalations WHERE repo_slug=? AND issue=? AND kind=?",
429
677
  (repo_slug, issue, kind),
@@ -433,13 +681,33 @@ class State:
433
681
  def record_escalation(self, repo_slug: str, issue: int, kind: str) -> None:
434
682
  """Idempotent per kind: OR IGNORE keeps the FIRST escalation's
435
683
  timestamp, so `escalated_at` is an audit field for when this kind of
436
- escalation was first raised, not the most recent re-raise."""
437
- self._db.execute(
438
- "INSERT OR IGNORE INTO escalations "
439
- "(repo_slug, issue, kind, escalated_at) VALUES (?,?,?,?)",
440
- (repo_slug, issue, kind, int(time.time())),
684
+ escalation was first raised, not the most recent re-raise.
685
+
686
+ BEST-EFFORT (see _best_effort): every caller posts the operator comment
687
+ BEFORE recording, so the row is a dedupe gate, never the escalation
688
+ itself. Raising here would abandon a poll pass over bookkeeping for a
689
+ comment that already landed. Dropping the row is not free either -- it
690
+ would re-open the gate and re-post the comment every poll -- so a
691
+ failed write is CONTAINED in memory (_degraded_gate) and REPLAYED by
692
+ _flush_pending_gates on the next write that succeeds. The gate holds
693
+ meanwhile, and the row lands with its original timestamp. The replay is
694
+ what makes that true: this call site is itself gated on the key, so it
695
+ will never be reached a second time to write the row itself."""
696
+ ts = int(time.time())
697
+
698
+ def write() -> None:
699
+ self._db.execute(
700
+ "INSERT OR IGNORE INTO escalations "
701
+ "(repo_slug, issue, kind, escalated_at) VALUES (?,?,?,?)",
702
+ (repo_slug, issue, kind, ts),
703
+ )
704
+ self._db.commit()
705
+
706
+ self._degraded_gate(
707
+ "escalations", repo_slug, issue, kind, ts,
708
+ self._best_effort(
709
+ f"record_escalation({repo_slug}#{issue}, {kind})", write),
441
710
  )
442
- self._db.commit()
443
711
 
444
712
  # -- mechanical activity comment dedupe -------------------------------
445
713
  #
@@ -459,7 +727,13 @@ class State:
459
727
  def activity_logged(self, repo_slug: str, number: int, kind: str) -> bool:
460
728
  """Whether this activity KIND already landed a line -- the
461
729
  once-per-episode gate for capacity-deferral telemetry. Independent of
462
- `escalated()`: a deferral line is not an operator escalation."""
730
+ `escalated()`: a deferral line is not an operator escalation.
731
+
732
+ Contained in memory on a failed write, exactly like `escalated()`: the
733
+ line is already on the comment, so an unwritable ledger must not turn
734
+ "one line per episode" into one line per poll."""
735
+ if self._gate_pending("activity_log", repo_slug, number, kind):
736
+ return True
463
737
  row = self._db.execute(
464
738
  "SELECT 1 FROM activity_log WHERE repo_slug=? AND number=? AND kind=?",
465
739
  (repo_slug, number, kind),
@@ -470,13 +744,28 @@ class State:
470
744
  """Idempotent per kind: OR IGNORE keeps the first line's timestamp.
471
745
  Recorded by the caller only AFTER the append lands, so a transient
472
746
  comment failure retries next poll and the line lands exactly once per
473
- episode."""
474
- self._db.execute(
475
- "INSERT OR IGNORE INTO activity_log "
476
- "(repo_slug, number, kind, logged_at) VALUES (?,?,?,?)",
477
- (repo_slug, number, kind, int(time.time())),
747
+ episode.
748
+
749
+ BEST-EFFORT (see _best_effort): this is the dedupe gate for a
750
+ MECHANICAL log line -- telemetry by construction, and already posted by
751
+ the time it is called. Contained in memory on failure like
752
+ record_escalation, so an unwritable store costs neither the poll pass
753
+ nor a repeated line."""
754
+ ts = int(time.time())
755
+
756
+ def write() -> None:
757
+ self._db.execute(
758
+ "INSERT OR IGNORE INTO activity_log "
759
+ "(repo_slug, number, kind, logged_at) VALUES (?,?,?,?)",
760
+ (repo_slug, number, kind, ts),
761
+ )
762
+ self._db.commit()
763
+
764
+ self._degraded_gate(
765
+ "activity_log", repo_slug, number, kind, ts,
766
+ self._best_effort(
767
+ f"record_activity({repo_slug}#{number}, {kind})", write),
478
768
  )
479
- self._db.commit()
480
769
 
481
770
  # -- poll snapshots (the UI sidecar's exhaust buffer) ------------------
482
771
 
@@ -503,38 +792,50 @@ class State:
503
792
  to JSON. `deferred` counts the max_sessions-deferred items of the
504
793
  pass (defaults to 0 so callers predating the valve keep working).
505
794
  Purely observational -- written on every pass, dry-run included --
506
- and pruned on write, so the table is self-bounding."""
507
- self._db.execute(
508
- "INSERT INTO poll_snapshots "
509
- "(ts, duration_ms, issue_candidates, pr_candidates, "
510
- "maintain_candidates, reaped, spawned, in_flight, skipped, "
511
- "escalated, capped, deferred, stages_json) "
512
- "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
513
- (
514
- int(time.time()),
515
- duration_ms,
516
- issue_candidates,
517
- pr_candidates,
518
- maintain_candidates,
519
- reaped,
520
- spawned,
521
- in_flight,
522
- skipped,
523
- escalated,
524
- capped,
525
- deferred,
526
- json.dumps(stages, separators=(",", ":")),
527
- ),
528
- )
529
- # Prune on write: keep the newest SNAPSHOT_RETENTION rows by id. The
530
- # autoincrement id is monotonic across prunes, so "newest" is well
531
- # defined even when a wall-clock step would leave `ts` unordered.
532
- self._db.execute(
533
- "DELETE FROM poll_snapshots WHERE id NOT IN "
534
- "(SELECT id FROM poll_snapshots ORDER BY id DESC LIMIT ?)",
535
- (SNAPSHOT_RETENTION,),
536
- )
537
- self._db.commit()
795
+ and pruned on write, so the table is self-bounding.
796
+
797
+ BEST-EFFORT (see _best_effort), and the most consequential of the
798
+ three: this is the ONE state write EVERY poll pass makes
799
+ unconditionally, so it was also the one guaranteed to meet a volume
800
+ that had gone read-only -- and to take the poll loop down with it on
801
+ the very first pass, exactly as it did to the revloop sibling on
802
+ 2026-07-29. A console that goes stale is the right price for a daemon
803
+ that keeps polling."""
804
+ def write() -> None:
805
+ self._db.execute(
806
+ "INSERT INTO poll_snapshots "
807
+ "(ts, duration_ms, issue_candidates, pr_candidates, "
808
+ "maintain_candidates, reaped, spawned, in_flight, skipped, "
809
+ "escalated, capped, deferred, stages_json) "
810
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
811
+ (
812
+ int(time.time()),
813
+ duration_ms,
814
+ issue_candidates,
815
+ pr_candidates,
816
+ maintain_candidates,
817
+ reaped,
818
+ spawned,
819
+ in_flight,
820
+ skipped,
821
+ escalated,
822
+ capped,
823
+ deferred,
824
+ json.dumps(stages, separators=(",", ":")),
825
+ ),
826
+ )
827
+ # Prune on write: keep the newest SNAPSHOT_RETENTION rows by id.
828
+ # The autoincrement id is monotonic across prunes, so "newest" is
829
+ # well defined even when a wall-clock step would leave `ts`
830
+ # unordered.
831
+ self._db.execute(
832
+ "DELETE FROM poll_snapshots WHERE id NOT IN "
833
+ "(SELECT id FROM poll_snapshots ORDER BY id DESC LIMIT ?)",
834
+ (SNAPSHOT_RETENTION,),
835
+ )
836
+ self._db.commit()
837
+
838
+ self._best_effort("record_snapshot", write)
538
839
 
539
840
  def read_snapshots(self, limit: "int | None" = None) -> "list[dict]":
540
841
  """The UI reader: poll snapshots newest-first, each with its per-item
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.3
3
+ Version: 0.8.5
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera