alissa-tools-github-devloop 0.8.58__tar.gz → 0.8.59__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 (41) hide show
  1. {alissa_tools_github_devloop-0.8.58/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.59}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/loop.py +177 -8
  3. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/loop_events.py +1 -1
  4. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/state.py +17 -0
  5. alissa_tools_github_devloop-0.8.59/src/main/alissa/tools/github/devloop/version +1 -0
  6. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  7. alissa_tools_github_devloop-0.8.58/src/main/alissa/tools/github/devloop/version +0 -1
  8. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/LICENSE +0 -0
  9. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/MANIFEST.in +0 -0
  10. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/NOTICE +0 -0
  11. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/README.md +0 -0
  12. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/requirements.txt +0 -0
  13. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/setup.cfg +0 -0
  14. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/setup.py +0 -0
  15. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  16. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
  17. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  18. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
  19. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  20. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/config.py +0 -0
  21. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/fleet_vitals.py +0 -0
  22. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
  23. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/hubs.py +0 -0
  24. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/hungtests.py +0 -0
  25. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/integration_check.py +0 -0
  26. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
  27. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/parking.py +0 -0
  28. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  29. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/prompts.py +0 -0
  30. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/version.py +0 -0
  31. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  32. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  33. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  34. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  35. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  36. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
  37. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  38. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
  39. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  40. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  41. {alissa_tools_github_devloop-0.8.58 → alissa_tools_github_devloop-0.8.59}/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.58
3
+ Version: 0.8.59
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -1889,6 +1889,16 @@ LIVE_STALLED_COMMENT = (
1889
1889
  "`{classified}`."
1890
1890
  )
1891
1891
 
1892
+ ASSIGNED_OUTSIDE_COMMENT = (
1893
+ "**Dev loop skipping this issue** — it has been assigned to `{login}` for "
1894
+ "at least {minutes} min, but this daemon has no spawn for it on its "
1895
+ "ledger and no spawn line of its own on the activity comment, so the "
1896
+ "assignment was made outside the loop (by hand, or under another "
1897
+ "workspace's ledger). The daemon will not spawn over it. Operator "
1898
+ "options: unassign `{login}` to let the loop take the issue, or leave it "
1899
+ "if someone is working it by hand. (One-time notice.)"
1900
+ )
1901
+
1892
1902
  # The operator page for an account-level prompt kind (issue #127): an
1893
1903
  # expired login, a hit usage limit, an empty credit balance. No keystroke
1894
1904
  # answers these, so the page is the act -- one per kind per
@@ -2253,6 +2263,16 @@ ESCALATION_FIX_HANDOVER = "fix-handover"
2253
2263
  # neither ping may silence the other.
2254
2264
  ESCALATION_LIVE_STALLED = "live-stalled"
2255
2265
 
2266
+ # The assigned-outside skip's once-per-issue operator page (issue #200): the
2267
+ # issue is assigned to this login, the ledger has no row and its activity
2268
+ # comment no spawn line of ours, so nothing the daemon can see explains the
2269
+ # assignment. Once-ever like ESCALATION_ASSIGNMENT -- the remedy (unassign,
2270
+ # or leave it to whoever is working it by hand) does not change per poll.
2271
+ # ASSIGNED_OUTSIDE_SEEN is the episode's durable first-seen clock, kept in the
2272
+ # activity_log ledger so a daemon restart does not restart the wait.
2273
+ ESCALATION_ASSIGNED_OUTSIDE = "assigned-outside"
2274
+ ASSIGNED_OUTSIDE_SEEN = "assigned-outside-seen"
2275
+
2256
2276
  # The wedge kill's once-per-episode marker (see wedge_kind). Not an operator
2257
2277
  # page -- the daemon acted instead of asking -- but, like ESCALATION_REREQUEST,
2258
2278
  # a once-per-key act the `escalations` table is the ledger of: it dedupes the
@@ -4832,14 +4852,21 @@ class DevWatcher:
4832
4852
  return self._claim_deferral(claim, attempts)
4833
4853
 
4834
4854
  if login in issue.assignees and age is None:
4835
- # Self-assigned but the ledger never spawned: assigned outside this
4836
- # daemon (by hand, or under another workspace's ledger). Not a
4837
- # fresh self-spawn, so not ours to double-book.
4838
- return Decision(
4839
- Action.SKIPPED,
4840
- f"assigned to {login} outside this daemon (no spawn on the "
4841
- f"ledger) — unassign the issue to let the loop take it",
4842
- )
4855
+ # Self-assigned but the ledger never spawned. Issue #200: when the
4856
+ # issue's own activity comment carries an EXPIRED spawn line by
4857
+ # this login on this lane (the claim above already let it age
4858
+ # out), the assignment is a dead attempt of ours whose ledger row
4859
+ # was lost (an overlapping deploy wrote it) -- adopt it into the
4860
+ # ledger and let the ordinary stale matrix below decide, exactly
4861
+ # as if the row had been there all along.
4862
+ orphans = self._orphaned_spawns(issue, session_name(issue, 1))
4863
+ if orphans:
4864
+ attempts, age = self._adopt_orphaned_spawns(issue, orphans)
4865
+ else:
4866
+ # No spawn line at all: assigned outside this daemon (by
4867
+ # hand, or under another workspace's ledger). Not ours to
4868
+ # double-book -- but not silent forever either.
4869
+ return self._assigned_outside(issue, login)
4843
4870
 
4844
4871
  # Staleness needs THREE signals, not one: the ledger timer says the
4845
4872
  # newest spawn is old, but elapsed time alone cannot tell a dead
@@ -10177,6 +10204,148 @@ class DevWatcher:
10177
10204
  defer_token=loop_events_mod.DEFER_CLAIM,
10178
10205
  )
10179
10206
 
10207
+ def _orphaned_spawns(
10208
+ self, issue: Issue, session: str
10209
+ ) -> "list[ActivityClaim]":
10210
+ """The EXPIRED spawn lines this login wrote on the issue's activity
10211
+ comment for `session`'s lane, oldest first and one per session name
10212
+ (issue #200). Called only when the issue is self-assigned and the
10213
+ ledger has no row for it, i.e. after `external_claim` has already
10214
+ turned down every line as not fresh: what is left is a dead attempt
10215
+ of ours whose ledger row was lost (written by an overlapping
10216
+ deployment, or before a ledger loss).
10217
+
10218
+ Own authorship comes from `marked_comment`, the lane from
10219
+ `activity_lane`. Same switch and fail-open rule as `external_claim`:
10220
+ with claims disabled, or on any read failure, the answer is "no
10221
+ lines", which keeps today's skip."""
10222
+ if not self.config.activity_claims_enabled:
10223
+ return []
10224
+ try:
10225
+ comment = self.github.marked_comment(
10226
+ issue.owner, issue.repo, issue.number, ACTIVITY_MARKER
10227
+ )
10228
+ except Exception as exc:
10229
+ log.warning(
10230
+ "could not read the activity comment on %s (%s) — no "
10231
+ "orphaned attempt to adopt this poll",
10232
+ issue.issue_slug,
10233
+ exc,
10234
+ )
10235
+ return []
10236
+ if comment is None:
10237
+ return []
10238
+ lane = activity_lane(session)
10239
+ window = self.config.stale_minutes * 60
10240
+ oldest: "dict[str, ActivityClaim]" = {}
10241
+ for claim in activity_claims(comment.body):
10242
+ if activity_lane(claim.session) != lane or claim.age < window:
10243
+ continue
10244
+ known = oldest.get(claim.session)
10245
+ if known is None or claim.at < known.at:
10246
+ oldest[claim.session] = claim
10247
+ return sorted(oldest.values(), key=lambda claim: claim.at)
10248
+
10249
+ def _adopt_orphaned_spawns(
10250
+ self, issue: Issue, orphans: "list[ActivityClaim]"
10251
+ ) -> "tuple[int, float]":
10252
+ """Record each orphaned spawn line as a ledger row stamped with the
10253
+ line's own time, so attempt_cap counts it and the ordinary stale
10254
+ matrix (linked-PR probe, tmux liveness, resume) judges it exactly as
10255
+ a row that was never lost. Returns the (attempts, age) the caller
10256
+ continues with.
10257
+
10258
+ Rows are numbered 1..k in line order (the ledger has none for this
10259
+ issue), keeping the session name the line carries; numbering by the
10260
+ name's own suffix could REPLACE a row when a line is missing. Not
10261
+ written on a dry run (no ledger write is), but the returned values
10262
+ let the dry pass reach the same decision."""
10263
+ for attempt, orphan in enumerate(orphans, start=1):
10264
+ log.warning(
10265
+ "%s: adopted orphaned attempt %d from activity log (`%s`, "
10266
+ "spawned %d min ago by this login, no local ledger row) — "
10267
+ "taking the stale path",
10268
+ issue.issue_slug,
10269
+ attempt,
10270
+ orphan.session,
10271
+ int(orphan.age / 60),
10272
+ )
10273
+ if self.config.dry_run:
10274
+ continue
10275
+ self.state.record_spawn(
10276
+ repo_slug=issue.full_name,
10277
+ issue=issue.number,
10278
+ attempt=attempt,
10279
+ session=orphan.session,
10280
+ )
10281
+ # The row just written is the newest; move its clock back to the
10282
+ # line's, so its age says what the line says.
10283
+ self.state.age_out_spawn(issue.full_name, issue.number, orphan.at)
10284
+ return len(orphans), orphans[-1].age
10285
+
10286
+ def _assigned_outside(self, issue: Issue, login: str) -> Decision:
10287
+ """The skip for an issue assigned to this login with nothing on the
10288
+ ledger or the activity comment to explain it -- assigned by hand.
10289
+ Not silent forever (issue #200): once the skip has persisted for
10290
+ STALLED_DEFER_MULTIPLE stale windows (clock: the episode's durable
10291
+ first-seen row) it posts ONE operator comment, recorded only after
10292
+ it lands so a failed post retries next poll."""
10293
+ full_name, number = issue.full_name, issue.number
10294
+ reason = (
10295
+ f"assigned to {login} outside this daemon (no spawn on the "
10296
+ f"ledger) — unassign the issue to let the loop take it"
10297
+ )
10298
+ if self.state.escalated(full_name, number, ESCALATION_ASSIGNED_OUTSIDE):
10299
+ return Decision(Action.SKIPPED, reason)
10300
+ if self.config.dry_run:
10301
+ return Decision(Action.SKIPPED, reason)
10302
+ first_seen = self.state.activity_logged_at(
10303
+ full_name, number, ASSIGNED_OUTSIDE_SEEN
10304
+ )
10305
+ if first_seen is None:
10306
+ self.state.record_activity(full_name, number, ASSIGNED_OUTSIDE_SEEN)
10307
+ first_seen = int(time.time())
10308
+ stalled_after = STALLED_DEFER_MULTIPLE * self.config.stale_minutes * 60
10309
+ waited = time.time() - first_seen
10310
+ if waited < stalled_after:
10311
+ # The page's clock is the seat's: no parking across it, or it
10312
+ # would fire on the first pass after the wake (issue #153).
10313
+ return Decision(
10314
+ Action.SKIPPED,
10315
+ reason,
10316
+ owed=True,
10317
+ timer=(
10318
+ parking_mod.TIMER_STALE_WINDOW, first_seen + stalled_after
10319
+ ),
10320
+ )
10321
+ body = ASSIGNED_OUTSIDE_COMMENT.format(
10322
+ login=login, minutes=int(waited / 60)
10323
+ )
10324
+ log.warning(
10325
+ "%s has been assigned to %s outside this daemon for %.0f min — "
10326
+ "escalating to operator (once)",
10327
+ issue.issue_slug,
10328
+ login,
10329
+ waited / 60,
10330
+ )
10331
+ try:
10332
+ self.github.comment(issue.owner, issue.repo, number, body)
10333
+ except CommandError as exc:
10334
+ log.error(
10335
+ "could not post the assigned-outside comment on %s: %s — "
10336
+ "the page retries next poll",
10337
+ issue.issue_slug,
10338
+ exc,
10339
+ )
10340
+ return Decision(Action.SKIPPED, reason, owed=True)
10341
+ self.state.record_escalation(
10342
+ full_name, number, ESCALATION_ASSIGNED_OUTSIDE
10343
+ )
10344
+ return Decision(
10345
+ Action.ESCALATED,
10346
+ f"{reason}; operator paged after {int(waited / 60)} min",
10347
+ )
10348
+
10180
10349
  def _log_deferral(
10181
10350
  self,
10182
10351
  owner: str,
@@ -189,7 +189,7 @@ _LIMIT_LIFTED_RE = re.compile(r"^usage-limit-lifted:(?P<session>[^@]+)@(?P<since
189
189
  # points the operator at an unrelated PR or at nothing.
190
190
  _ISSUE_ANCHORED = (
191
191
  "cap", "assignment", "stalled", "live-stalled", "wedged", "first-run-dialog",
192
- "claude-exited",
192
+ "claude-exited", "assigned-outside",
193
193
  )
194
194
 
195
195
 
@@ -1245,6 +1245,23 @@ class State:
1245
1245
  ).fetchone()
1246
1246
  return row is not None
1247
1247
 
1248
+ def activity_logged_at(
1249
+ self, repo_slug: str, number: int, kind: str
1250
+ ) -> "int | None":
1251
+ """When this activity KIND was first recorded (unix seconds), or None.
1252
+ The durable first-seen clock of an episode that must outlive a daemon
1253
+ restart (issue #200: the assigned-outside skip escalates once it has
1254
+ persisted long enough). A row held in memory by a failed write answers
1255
+ with the timestamp it would have carried."""
1256
+ pending = self._pending_gates.get(("activity_log", repo_slug, number, kind))
1257
+ if pending is not None:
1258
+ return int(pending)
1259
+ row = self._db.execute(
1260
+ "SELECT logged_at FROM activity_log WHERE repo_slug=? AND number=? AND kind=?",
1261
+ (repo_slug, number, kind),
1262
+ ).fetchone()
1263
+ return None if row is None else int(row["logged_at"])
1264
+
1248
1265
  def record_activity(self, repo_slug: str, number: int, kind: str) -> None:
1249
1266
  """Idempotent per kind: OR IGNORE keeps the first line's timestamp.
1250
1267
  Recorded by the caller only AFTER the append lands, so a transient
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.58
3
+ Version: 0.8.59
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera