alissa-tools-github-devloop 0.8.4__tar.gz → 0.8.6__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.4/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.6}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/src/main/alissa/tools/github/devloop/ghclient.py +47 -0
  3. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/src/main/alissa/tools/github/devloop/loop.py +176 -3
  4. alissa_tools_github_devloop-0.8.6/src/main/alissa/tools/github/devloop/version +1 -0
  5. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/src/main/alissa/tools/github/devloop/webui/sources.py +62 -13
  6. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  7. alissa_tools_github_devloop-0.8.4/src/main/alissa/tools/github/devloop/version +0 -1
  8. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/LICENSE +0 -0
  9. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/MANIFEST.in +0 -0
  10. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/NOTICE +0 -0
  11. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/README.md +0 -0
  12. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/requirements.txt +0 -0
  13. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/setup.cfg +0 -0
  14. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/setup.py +0 -0
  15. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  16. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
  17. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  18. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/src/main/alissa/tools/github/devloop/config.py +0 -0
  19. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
  20. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  21. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/src/main/alissa/tools/github/devloop/state.py +0 -0
  22. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/src/main/alissa/tools/github/devloop/version.py +0 -0
  23. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  24. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  25. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  26. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  27. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  28. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  29. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
  30. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  31. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  32. {alissa_tools_github_devloop-0.8.4 → alissa_tools_github_devloop-0.8.6}/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.4
3
+ Version: 0.8.6
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, ...]":
@@ -503,6 +503,30 @@ _FIX_DELIVERY_NOTE = (
503
503
  + _DELIVERY_NOTE_SECTIONS
504
504
  )
505
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
+
506
530
  # The approve-verdict rule (issue #68). An APPROVE hands the branch to the
507
531
  # operator, who may merge it the very next second -- observed live on
508
532
  # fahera-mx/studio.alissa.app#275: round 4 approved with three [minor]s
@@ -513,7 +537,9 @@ _FIX_DELIVERY_NOTE = (
513
537
  # The remedy is a rule about WHERE the work goes, not about how fast the worker
514
538
  # is: after an approve the branch is merge-racy BY DEFINITION, so no amount of
515
539
  # hurrying makes a push to it safe. Approval-attached [pursue] findings become a
516
- # 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
517
543
  # improvised, made the FIRST move instead of the fallback -- and that PR gets
518
544
  # driven to a completed handoff in the same session, because a follow-up nobody
519
545
  # reviews is the orphan-draft half of this same issue.
@@ -527,8 +553,10 @@ _APPROVE_FOLLOWUP = (
527
553
  "branch. An approved branch is merge-racy by definition: the operator may "
528
554
  "merge it at any moment, and a commit pushed after the squash-merge is "
529
555
  "silently lost. Findings attached to an approve that you triage "
530
- "`[pursue]` are implemented as a NEW branch off main and a FOLLOW-UP pull "
531
- "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 "
532
560
  "the source PR and review round plus BOTH task refs (origin + "
533
561
  "implementation), and you drive that follow-up's handoff to completion in "
534
562
  "THIS session (ready + review re-request), never leaving it a draft. "
@@ -562,6 +590,7 @@ DEV_DIRECTIVE = (
562
590
  "AND your implementation task: the PR body is the canonical cross-actor "
563
591
  "record, and each actor's tasks live in its own partition — and attach "
564
592
  "the PR URL to the task as evidence. "
593
+ + _DEFAULT_BASE
565
594
  + _DELIVERY_NOTE
566
595
  + " Then drive the review handoff with "
567
596
  "the alissa-pr-review skill, requesting reviewer(s): {reviewers}. In "
@@ -857,6 +886,23 @@ ORPHAN_REJECTED_COMMENT = (
857
886
  "park the work."
858
887
  )
859
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
+
860
906
  ASSIGNMENT_REJECTED_COMMENT = (
861
907
  "**Dev loop cannot start** — the daemon tried to self-assign this issue "
862
908
  "as its in-flight marker, but GitHub silently dropped the assignment: "
@@ -1004,6 +1050,19 @@ ESCALATION_ORPHAN = "orphan"
1004
1050
  ESCALATION_ORPHAN_READY = "orphan-ready"
1005
1051
  ESCALATION_ORPHAN_REJECTED = "orphan-rejected"
1006
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
+
1007
1066
  # CR6 verdict words, spelled exactly as reviewloop's alissa.py spells them --
1008
1067
  # the envelope tie-breaker (see DevWatcher.envelope_verdict) must return
1009
1068
  # these strings, so the two daemons read one vocabulary.
@@ -2712,6 +2771,14 @@ class DevWatcher:
2712
2771
  pr = self.github.pull_request(issue.owner, issue.repo, int(tail))
2713
2772
  if pr.state != "open":
2714
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)
2715
2782
  if not pr.draft or pr.requested_reviewers:
2716
2783
  log.info(
2717
2784
  "%s: %s has been handed off (draft=%s, reviewers=%s) — "
@@ -2987,6 +3054,16 @@ class DevWatcher:
2987
3054
  if pr.state != "open":
2988
3055
  return Decision(Action.SKIPPED, "PR closed between search and fetch")
2989
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
+
2990
3067
  # A draft is out of review by definition (same belt-and-braces as
2991
3068
  # reviewloop's CR1 re-check -- a PR can flip back to draft between
2992
3069
  # search and fetch even when the search excluded drafts). It is also
@@ -4410,6 +4487,102 @@ class DevWatcher:
4410
4487
  task_ref=task_ref,
4411
4488
  )
4412
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
+
4413
4586
  def _escalate_fix_cap(self, pr: PullRequest, round_: int, attempts: int) -> None:
4414
4587
  """Operator page for a fix cap-out, posted ON the PR (the issue
4415
4588
  edge's _escalate shape): log, comment (dry-run gated), and record
@@ -8,10 +8,12 @@ in strict budget order:
8
8
  call: the daemon already wrote everything down.
9
9
  2. **Local process state** -- `alissa tmux ls --json` for the session list, and
10
10
  a `/proc` walk (sysinfo) of each session's pane-PID tree for CPU%/RSS.
11
- 3. **Two cached remote checks** -- `gh api rate_limit` (60s cache) for the rate
12
- meter, and the PyPI version JSON (10m cache) for the running-vs-latest drift
13
- chip. These are the *only* network calls, and both are cached so a room full
14
- of refreshing operators cannot move the daemon's rate budget.
11
+ 3. **Three cached remote checks** -- `gh api rate_limit` (60s cache) for the
12
+ rate meter, the PyPI version JSON (10m cache) for the running-vs-latest
13
+ drift chip, and `alissa task list` (5m cache, self-scoped where the CLI
14
+ allows it) for the tasks pane. These are the *only* network calls, and all
15
+ three are cached so a room full of refreshing operators cannot move the
16
+ daemon's rate budget -- nor the sponsor's.
15
17
  4. **Config echo + log tail** -- the effective config the daemon resolved, and
16
18
  the tail of its log file.
17
19
 
@@ -49,7 +51,14 @@ RATE_CACHE_TTL = 60.0
49
51
  VERSION_CACHE_TTL = 600.0
50
52
  # `alissa task list` rides the shared ALISSA_API_TOKEN, so it is cached like the
51
53
  # other remote checks to keep a room of open tabs off the daemon's rate budget.
52
- TASKS_CACHE_TTL = 60.0
54
+ # Five minutes rather than the rate meter's sixty seconds: the pane is a
55
+ # monitoring glance, not a live feed, and every uncached read is served
56
+ # sponsor-scoped -- a tab left open on the 60s TTL paid the sponsor's entire
57
+ # non-terminal task corpus 1,440x/day.
58
+ TASKS_CACHE_TTL = 300.0
59
+ # The flag that narrows `alissa task list` to the calling actor's own rows
60
+ # instead of the sponsor's corpus. Newer CLIs only -- see `_supports_self_scope`.
61
+ TASKS_SELF_FLAG = "--self"
53
62
  # PyPI JSON endpoint for the running-vs-latest drift chip.
54
63
  PYPI_URL = "https://pypi.org/pypi/alissa-tools-github-devloop/json"
55
64
  # How many minutes past the stale window retry-now ages a row: the daemon
@@ -132,6 +141,8 @@ class Sources:
132
141
  self._rate_cache = _Cache(RATE_CACHE_TTL, clock)
133
142
  self._version_cache = _Cache(VERSION_CACHE_TTL, clock)
134
143
  self._tasks_cache = _Cache(TASKS_CACHE_TTL, clock)
144
+ # Tri-state: None = not probed yet (see `_supports_self_scope`).
145
+ self._self_scope: "bool | None" = None
135
146
  self.boot_wall = int(wall_clock())
136
147
 
137
148
  # -- thin, failure-tolerant subprocess helpers -------------------------
@@ -160,9 +171,9 @@ class Sources:
160
171
  with a daemon write and raise `sqlite3.OperationalError` (State's
161
172
  default 5s busy_timeout narrows this window but does not close it, and
162
173
  a corrupt db raises regardless). Catching here honors this module's
163
- contract (line 19): a state access never raises an exception that would
164
- blank the dashboard or 500 the retry action -- it degrades like every
165
- other source."""
174
+ failure-tolerance contract (the module docstring): a state access never
175
+ raises an exception that would blank the dashboard or 500 the retry
176
+ action -- it degrades like every other source."""
166
177
  try:
167
178
  with State(self.config.state_db) as st:
168
179
  return fn(st)
@@ -239,19 +250,57 @@ class Sources:
239
250
  )
240
251
  return out
241
252
 
253
+ def _supports_self_scope(self) -> bool:
254
+ """Whether the installed `alissa` CLI understands `task list --self`.
255
+
256
+ Read off the CLI's own `--help`, which is local, tokenless and free.
257
+ The alternative -- send the flag and fall back when the call fails --
258
+ cannot tell an unknown flag from an auth hiccup, so it would spend a
259
+ second sponsor-scoped call on every unlucky refresh, which is the cost
260
+ this whole path exists to avoid.
261
+
262
+ A probe that *answers* is memoized for the process (the CLI cannot
263
+ change under a running console). A probe that fails is deliberately
264
+ NOT memoized: a transient `alissa` failure then degrades this refresh
265
+ only, instead of pinning the console to the expensive unscoped call
266
+ until someone restarts it."""
267
+ if self._self_scope is None:
268
+ helptext = self._safe_run(
269
+ ["alissa", "task", "list", "--help"], timeout=20
270
+ )
271
+ if helptext is None:
272
+ return False
273
+ self._self_scope = TASKS_SELF_FLAG in helptext
274
+ return self._self_scope
275
+
242
276
  def worker_tasks(self, limit: int = 25) -> "list[dict]":
243
277
  """The worker actor's own tasks via `alissa task list` -- the
244
278
  actor-partition bridge (the console runs as the operator, whose actor
245
279
  owns the implementation tasks the dev sessions created). Best-effort:
246
280
  an old CLI or an auth hiccup just yields an empty list.
247
281
 
248
- Cached (TASKS_CACHE_TTL) exactly like the two remote checks: this call
249
- rides the shared ALISSA_API_TOKEN the daemon uses, so on the ~10s poll
250
- an unbounded call per open tab would contend with the daemon for the
251
- API budget. A fetch that fails returns None and keeps the last good
282
+ Self-scoped (`--self`) wherever the installed CLI offers it, so a poll
283
+ reads the console actor's own rows rather than the sponsor's whole
284
+ non-terminal corpus -- the pane only ever displays the former. An
285
+ older CLI, or a `--self` call that comes back empty-handed, degrades to
286
+ the unflagged call: a slower, larger read beats a blank pane.
287
+
288
+ Cached (TASKS_CACHE_TTL) exactly like the two other remote checks: this
289
+ call rides the shared ALISSA_API_TOKEN the daemon uses, so on the ~10s
290
+ poll an unbounded call per open tab would contend with the daemon for
291
+ the API budget. A fetch that fails returns None and keeps the last good
252
292
  list (the _Cache contract), never overwriting it with junk."""
253
293
  def fetch() -> "list[dict] | None":
254
- raw = self._safe_json(["alissa", "task", "list", "--json"], timeout=45)
294
+ unscoped = ["alissa", "task", "list", "--json"]
295
+ self_scoped = self._supports_self_scope()
296
+ argv = (unscoped + [TASKS_SELF_FLAG]) if self_scoped else unscoped
297
+ raw = self._safe_json(argv, timeout=45)
298
+ if raw is None and self_scoped:
299
+ # The CLI advertised the flag but the call brought nothing
300
+ # back -- an API that does not serve the scope yet, say. One
301
+ # unflagged retry keeps the pane populated. A valid but empty
302
+ # `[]` is a real answer, not a failure, and is never retried.
303
+ raw = self._safe_json(unscoped, timeout=45)
255
304
  if not isinstance(raw, list):
256
305
  return None
257
306
  rows = [t for t in raw if isinstance(t, dict)]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.4
3
+ Version: 0.8.6
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera