alissa-tools-github-devloop 0.8.31__tar.gz → 0.8.32__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 (38) hide show
  1. {alissa_tools_github_devloop-0.8.31/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.32}/PKG-INFO +53 -16
  2. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/README.md +52 -15
  3. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/ghclient.py +61 -9
  4. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/loop.py +564 -86
  5. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/loop_events.py +47 -16
  6. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/prompts.py +140 -5
  7. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/state.py +56 -0
  8. alissa_tools_github_devloop-0.8.32/src/main/alissa/tools/github/devloop/version +1 -0
  9. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +53 -16
  10. alissa_tools_github_devloop-0.8.31/src/main/alissa/tools/github/devloop/version +0 -1
  11. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/LICENSE +0 -0
  12. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/MANIFEST.in +0 -0
  13. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/NOTICE +0 -0
  14. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/requirements.txt +0 -0
  15. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/setup.cfg +0 -0
  16. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/setup.py +0 -0
  17. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  18. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
  19. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  20. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
  21. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  22. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/config.py +0 -0
  23. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/fleet_vitals.py +0 -0
  24. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/hubs.py +0 -0
  25. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
  26. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  27. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/version.py +0 -0
  28. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  29. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  30. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  31. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  32. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  33. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
  34. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  35. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
  36. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  37. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  38. {alissa_tools_github_devloop-0.8.31 → alissa_tools_github_devloop-0.8.32}/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.31
3
+ Version: 0.8.32
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -72,10 +72,14 @@ pane conditions as the ACCOUNT's, not the worker's — nothing a keystroke can
72
72
  answer: `login_expired`, `auth_rejected` (a 401), `usage_limit` and
73
73
  `out_of_credits`. Each pages the operator once per kind per 6 h on the lane's
74
74
  issue/PR and holds every new spawn while it stands (at most 1 h on one pane,
75
- then kill-and-retry re-confirms).
76
-
77
- **Usage limits** (issue #142). `usage_limit` admits the window wordings
78
- (`You've hit your weekly limit`, `Usage limit reached`, …) and the
75
+ then kill-and-retry re-confirms) — except `usage_limit`, whose session is
76
+ never killed and whose hold is the ledger's (below).
77
+
78
+ **Usage limits** (issues #142, #144). `usage_limit` admits the window wordings
79
+ (`You've hit your weekly limit`, `Usage limit reached`, …), the "limit
80
+ reached … resets …" family (`Claude usage limit reached. Your limit will
81
+ reset at 5pm (America/New_York).`, `5-hour limit reached ∙ resets 3pm`,
82
+ `Opus weekly limit reached ∙ resets Oct 6, 1pm`) and the
79
83
  MODEL-named one Claude Code prints now — `You've reached your Fable limit.
80
84
  Run /usage-credits to continue or switch models with /model.` (also `Opus`,
81
85
  `Sonnet`, …): one capitalised model token, a known family name or any token
@@ -88,28 +92,61 @@ this ledger inside the last `stale_minutes`): the notice, above an idle
88
92
  prompt, with no other `●` turn since the directive. A match
89
93
  (`source: "first_turn"`, `matched: "model_limit"`, the model) is:
90
94
 
91
- - **held** — the daemon-wide spawn hold, clocked on the ledger for 1 h from
92
- the newest limit (it outlives the killed session and a restart); when it
93
- expires the retry edges respawn as they would have, and a fresh pane still
94
- showing the notice re-arms it;
95
+ - **held** — the daemon-wide spawn hold, clocked on the ledger from the
96
+ newest limit (it survives a restart and a listing failure) and lifted only
97
+ when a spawn made AFTER the limit gets through its first turn: after 1 h
98
+ ONE probe spawn is admitted at a time (none while a probe is in flight,
99
+ none while another account notice holds; under `max_sessions` the probe
100
+ is judged without the kept limited sessions, which would otherwise fill the
101
+ cap for good); a probe whose first turn is the notice again is refunded,
102
+ re-arms the clock and is **killed** — it did no work, and keeping it would
103
+ add one session per hour of the limit (past `max_sessions` too), so the
104
+ live sessions stay at the ones the limit found at work plus at most the one
105
+ probe in flight; the first probe that does
106
+ real work lifts the hold (`usage-limit-lifted:` on the escalation ledger,
107
+ `escalation.usage_limit_lifted` on the wire) and each kept limited
108
+ session's lane gets a line saying to resume it;
95
109
  - **paged** — once per 6 h window, through the account-kind page;
96
- - **killed** — the session is idle and useless;
110
+ - **never killed** (issue #144; a failed probe, above, is the one exception) —
111
+ a retry would hit the same wall, and the idle session is the lane's own work for the operator to resume once the
112
+ account is back (or `alissa tmux kill`, to let the retry edge respawn);
113
+ the wedge kills skip a limited session too;
97
114
  - **refunded** — the spawn row is stamped `limited_at`, and every
98
115
  `attempt_cap` count leaves limited rows out (numbering stays monotonic, so
99
116
  session names never repeat). An account over its limit never caps a lane.
100
117
 
101
- The same notice after real worker output (a tool call, the worker's own
102
- prose, a quoted tool row) stays worker output. Each observation is one
103
- `usage.limited` loop event per session: `data: { kind: "usage_limit", source:
104
- "pane" | "first_turn", matched: "model_limit" | "usage_limit", model }` — no
105
- pane text, no credential material.
118
+ A usage-limit BANNER on a working session (`source: "pane"`) is held the same
119
+ way: its row is stamped (refunded), the hold is the ledger's, and the session
120
+ is never killed. The same notice after real worker output (a tool call, the
121
+ worker's own prose, a quoted tool row) stays worker output. Each limited
122
+ session is one `usage.limited` loop event: `data: { kind: "usage_limit",
123
+ source: "pane" | "first_turn", matched: "model_limit" | "usage_limit", model,
124
+ resetsAt? }` — `resetsAt` only when the notice states a reset, as an ISO-8601
125
+ UTC instant when that needs no guess (the legacy `|<epoch>` form, or a clock
126
+ time with an explicit zone) and as the stated phrase otherwise; no pane text,
127
+ no credential material.
106
128
 
107
129
  **The fix edge stops presuming.** A stale fix round with no triage reply from
108
130
  the author used to be re-enqueued on the timer alone ("presumed dead"). It now
109
131
  reads the tmux roster first: ALIVE is in flight (the responder owns a live
110
132
  idle pane; a once-per-episode "Fix loop stalled?" ping floors the wait),
111
133
  INDETERMINATE defers one pass, and only DEAD respawns — "CONFIRMED dead", or,
112
- after a refunded limit, "previous session limited (… attempt refunded)".
134
+ after a refunded limit, "previous session limited (usage limit; attempt
135
+ refunded)". A kept limited session reads ALIVE, so no edge respawns over it.
136
+
137
+ ## The re-enqueue guard (issue #144)
138
+
139
+ The issue edge's respawn rule reads the linked-PR signal over **every** PR
140
+ state: `GitHub.linked_pull_requests` walks the timeline once and reports each
141
+ same-repo cross-referenced PR as open, merged or closed. When a stale
142
+ attempt's session is CONFIRMED dead with no open PR, a merged PR of the
143
+ daemon's own that closes the issue is `SKIPPED` "finished (PR #n merged)" and
144
+ a closed-unmerged one is `SKIPPED` "closed unmerged — orchestrator's page" —
145
+ never a respawn (provenance: token author and a closing keyword, fetched; an
146
+ unreadable candidate stands the respawn down). And `_spawn` re-reads the
147
+ issue immediately before every enqueue: a closed issue is `SKIPPED` "issue
148
+ closed between search and spawn", nothing is claimed, and the reserved slot
149
+ is handed back. The respawn-with-resume on an open draft PR is unchanged.
113
150
 
114
151
  ## License
115
152
 
@@ -50,10 +50,14 @@ pane conditions as the ACCOUNT's, not the worker's — nothing a keystroke can
50
50
  answer: `login_expired`, `auth_rejected` (a 401), `usage_limit` and
51
51
  `out_of_credits`. Each pages the operator once per kind per 6 h on the lane's
52
52
  issue/PR and holds every new spawn while it stands (at most 1 h on one pane,
53
- then kill-and-retry re-confirms).
54
-
55
- **Usage limits** (issue #142). `usage_limit` admits the window wordings
56
- (`You've hit your weekly limit`, `Usage limit reached`, …) and the
53
+ then kill-and-retry re-confirms) — except `usage_limit`, whose session is
54
+ never killed and whose hold is the ledger's (below).
55
+
56
+ **Usage limits** (issues #142, #144). `usage_limit` admits the window wordings
57
+ (`You've hit your weekly limit`, `Usage limit reached`, …), the "limit
58
+ reached … resets …" family (`Claude usage limit reached. Your limit will
59
+ reset at 5pm (America/New_York).`, `5-hour limit reached ∙ resets 3pm`,
60
+ `Opus weekly limit reached ∙ resets Oct 6, 1pm`) and the
57
61
  MODEL-named one Claude Code prints now — `You've reached your Fable limit.
58
62
  Run /usage-credits to continue or switch models with /model.` (also `Opus`,
59
63
  `Sonnet`, …): one capitalised model token, a known family name or any token
@@ -66,28 +70,61 @@ this ledger inside the last `stale_minutes`): the notice, above an idle
66
70
  prompt, with no other `●` turn since the directive. A match
67
71
  (`source: "first_turn"`, `matched: "model_limit"`, the model) is:
68
72
 
69
- - **held** — the daemon-wide spawn hold, clocked on the ledger for 1 h from
70
- the newest limit (it outlives the killed session and a restart); when it
71
- expires the retry edges respawn as they would have, and a fresh pane still
72
- showing the notice re-arms it;
73
+ - **held** — the daemon-wide spawn hold, clocked on the ledger from the
74
+ newest limit (it survives a restart and a listing failure) and lifted only
75
+ when a spawn made AFTER the limit gets through its first turn: after 1 h
76
+ ONE probe spawn is admitted at a time (none while a probe is in flight,
77
+ none while another account notice holds; under `max_sessions` the probe
78
+ is judged without the kept limited sessions, which would otherwise fill the
79
+ cap for good); a probe whose first turn is the notice again is refunded,
80
+ re-arms the clock and is **killed** — it did no work, and keeping it would
81
+ add one session per hour of the limit (past `max_sessions` too), so the
82
+ live sessions stay at the ones the limit found at work plus at most the one
83
+ probe in flight; the first probe that does
84
+ real work lifts the hold (`usage-limit-lifted:` on the escalation ledger,
85
+ `escalation.usage_limit_lifted` on the wire) and each kept limited
86
+ session's lane gets a line saying to resume it;
73
87
  - **paged** — once per 6 h window, through the account-kind page;
74
- - **killed** — the session is idle and useless;
88
+ - **never killed** (issue #144; a failed probe, above, is the one exception) —
89
+ a retry would hit the same wall, and the idle session is the lane's own work for the operator to resume once the
90
+ account is back (or `alissa tmux kill`, to let the retry edge respawn);
91
+ the wedge kills skip a limited session too;
75
92
  - **refunded** — the spawn row is stamped `limited_at`, and every
76
93
  `attempt_cap` count leaves limited rows out (numbering stays monotonic, so
77
94
  session names never repeat). An account over its limit never caps a lane.
78
95
 
79
- The same notice after real worker output (a tool call, the worker's own
80
- prose, a quoted tool row) stays worker output. Each observation is one
81
- `usage.limited` loop event per session: `data: { kind: "usage_limit", source:
82
- "pane" | "first_turn", matched: "model_limit" | "usage_limit", model }` — no
83
- pane text, no credential material.
96
+ A usage-limit BANNER on a working session (`source: "pane"`) is held the same
97
+ way: its row is stamped (refunded), the hold is the ledger's, and the session
98
+ is never killed. The same notice after real worker output (a tool call, the
99
+ worker's own prose, a quoted tool row) stays worker output. Each limited
100
+ session is one `usage.limited` loop event: `data: { kind: "usage_limit",
101
+ source: "pane" | "first_turn", matched: "model_limit" | "usage_limit", model,
102
+ resetsAt? }` — `resetsAt` only when the notice states a reset, as an ISO-8601
103
+ UTC instant when that needs no guess (the legacy `|<epoch>` form, or a clock
104
+ time with an explicit zone) and as the stated phrase otherwise; no pane text,
105
+ no credential material.
84
106
 
85
107
  **The fix edge stops presuming.** A stale fix round with no triage reply from
86
108
  the author used to be re-enqueued on the timer alone ("presumed dead"). It now
87
109
  reads the tmux roster first: ALIVE is in flight (the responder owns a live
88
110
  idle pane; a once-per-episode "Fix loop stalled?" ping floors the wait),
89
111
  INDETERMINATE defers one pass, and only DEAD respawns — "CONFIRMED dead", or,
90
- after a refunded limit, "previous session limited (… attempt refunded)".
112
+ after a refunded limit, "previous session limited (usage limit; attempt
113
+ refunded)". A kept limited session reads ALIVE, so no edge respawns over it.
114
+
115
+ ## The re-enqueue guard (issue #144)
116
+
117
+ The issue edge's respawn rule reads the linked-PR signal over **every** PR
118
+ state: `GitHub.linked_pull_requests` walks the timeline once and reports each
119
+ same-repo cross-referenced PR as open, merged or closed. When a stale
120
+ attempt's session is CONFIRMED dead with no open PR, a merged PR of the
121
+ daemon's own that closes the issue is `SKIPPED` "finished (PR #n merged)" and
122
+ a closed-unmerged one is `SKIPPED` "closed unmerged — orchestrator's page" —
123
+ never a respawn (provenance: token author and a closing keyword, fetched; an
124
+ unreadable candidate stands the respawn down). And `_spawn` re-reads the
125
+ issue immediately before every enqueue: a closed issue is `SKIPPED` "issue
126
+ closed between search and spawn", nothing is claimed, and the reserved slot
127
+ is handed back. The respawn-with-resume on an open draft PR is unchanged.
91
128
 
92
129
  ## License
93
130
 
@@ -53,6 +53,13 @@ FIRST_PARENT_MAX_PAGES = 3
53
53
  SUBMITTED_STATES = {"APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"}
54
54
 
55
55
 
56
+ # The states `GitHub.linked_pull_requests` reports a cross-referenced pull request in
57
+ # (issue #144): still open, merged, or closed without a merge.
58
+ PR_OPEN = "open"
59
+ PR_MERGED = "merged"
60
+ PR_CLOSED = "closed"
61
+
62
+
56
63
  def parse_github_timestamp(ts: "str | None") -> "int | None":
57
64
  """A GitHub API timestamp (`2026-07-23T02:13:56Z`) as unix seconds, or
58
65
  None when absent/unparseable. GitHub emits UTC `Z` timestamps
@@ -1325,8 +1332,45 @@ class GitHub:
1325
1332
  is invisible either way, so `exhaustive` means complete UP TO the
1326
1333
  documented ceiling, not unbounded.
1327
1334
  """
1335
+ return [
1336
+ url
1337
+ for url, state in self.linked_pull_requests(
1338
+ owner, repo, number, exhaustive=exhaustive
1339
+ )
1340
+ if state == PR_OPEN
1341
+ ]
1342
+
1343
+ def linked_pull_requests(
1344
+ self,
1345
+ owner: str,
1346
+ repo: str,
1347
+ number: int,
1348
+ *,
1349
+ exhaustive: bool = False,
1350
+ ) -> "list[tuple[str, str]]":
1351
+ """Every same-repo pull request the issue's timeline cross-references,
1352
+ in ANY state, as `(url, state)` pairs -- `state` is `PR_OPEN`,
1353
+ `PR_MERGED` or `PR_CLOSED` (closed without a merge). The one walk
1354
+ behind `linked_open_prs` (which filters it to the open ones) and the
1355
+ issue edge's respawn rule (issue #144, design studio.alissa.app
1356
+ docs/design/loop-operator-surface.md §8.4): a DEAD session's lane
1357
+ with no open PR is only an implement-phase death when no MERGED or
1358
+ closed PR claims the issue either -- 2026-09-28, an operator's hand
1359
+ merge closed issue #1620 two seconds before the daemon read "no PR
1360
+ opened" off the open-only probe and spawned a duplicate lane.
1361
+
1362
+ Merged is read off the cross-reference's own payload
1363
+ (`source.issue.pull_request.merged_at`), so the walk costs nothing
1364
+ more than it did; the caller still fetches a candidate before acting
1365
+ on it, because a cross-reference proves only a MENTION (provenance is
1366
+ the fetched body's closing keyword and author). The walk, its bounds,
1367
+ its same-repo filter and the `exhaustive` switch are exactly
1368
+ `linked_open_prs`'s: the default stops at the first page that yields
1369
+ an OPEN PR (closed ones never stop it), so a lane with no open PR
1370
+ walks the whole timeline -- up to TIMELINE_MAX_PAGES -- and sees
1371
+ every closed one."""
1328
1372
  same_repo_pull = f"https://github.com/{owner}/{repo}/pull/"
1329
- urls: list[str] = []
1373
+ found: "dict[str, str]" = {}
1330
1374
  for page in range(1, TIMELINE_MAX_PAGES + 1):
1331
1375
  events = (
1332
1376
  self._api(
@@ -1352,15 +1396,25 @@ class GitHub:
1352
1396
  src = source.get("issue") if isinstance(source, dict) else None
1353
1397
  if not isinstance(src, dict):
1354
1398
  continue
1355
- if not src.get("pull_request"):
1399
+ pull = src.get("pull_request")
1400
+ if not pull:
1356
1401
  continue # the cross-reference came from a plain issue
1357
- if src.get("state") != "open":
1358
- continue
1359
1402
  url = src.get("html_url") or ""
1360
1403
  if not url.startswith(same_repo_pull):
1361
1404
  continue # a mention from another repo says nothing here
1362
- urls.append(url)
1363
- if urls and not exhaustive:
1405
+ if src.get("state") == "open":
1406
+ state = PR_OPEN
1407
+ elif isinstance(pull, dict) and pull.get("merged_at"):
1408
+ state = PR_MERGED
1409
+ elif src.get("state") == "closed":
1410
+ state = PR_CLOSED
1411
+ else:
1412
+ continue # no readable state: never guess
1413
+ # One PR can cross-reference the issue more than once (body
1414
+ # edit, comment mention); report it once, in its first
1415
+ # position -- the state is the PR's, not the event's.
1416
+ found.setdefault(url, state)
1417
+ if not exhaustive and PR_OPEN in found.values():
1364
1418
  # The DEFAULT caller's question is boolean -- is ANY open
1365
1419
  # same-repo PR linked? -- so once a page yields one, later
1366
1420
  # pages can only add more URLs to a message, never change the
@@ -1370,9 +1424,7 @@ class GitHub:
1370
1424
  break
1371
1425
  if len(events) < TIMELINE_PAGE_SIZE:
1372
1426
  break # short page: the timeline is exhausted
1373
- # One PR can cross-reference the issue more than once (body edit,
1374
- # comment mention); report it once.
1375
- return list(dict.fromkeys(urls))
1427
+ return list(found.items())
1376
1428
 
1377
1429
  def assign_self(self, owner: str, repo: str, number: int) -> None:
1378
1430
  """Assign the token identity -- the server-side in-flight marker.