alissa-tools-github-devloop 0.5.0__tar.gz → 0.5.1__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 (29) hide show
  1. {alissa_tools_github_devloop-0.5.0/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.5.1}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa/tools/github/devloop/ghclient.py +61 -0
  3. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa/tools/github/devloop/loop.py +235 -9
  4. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa/tools/github/devloop/state.py +54 -0
  5. alissa_tools_github_devloop-0.5.1/src/main/alissa/tools/github/devloop/version +1 -0
  6. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  7. alissa_tools_github_devloop-0.5.0/src/main/alissa/tools/github/devloop/version +0 -1
  8. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/MANIFEST.in +0 -0
  9. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/README.md +0 -0
  10. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/requirements.txt +0 -0
  11. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/setup.cfg +0 -0
  12. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/setup.py +0 -0
  13. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  14. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
  15. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  16. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa/tools/github/devloop/config.py +0 -0
  17. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  18. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa/tools/github/devloop/version.py +0 -0
  19. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  20. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  21. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  22. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  23. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  24. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
  25. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  26. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
  27. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  28. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  29. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/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.5.0
3
+ Version: 0.5.1
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -147,6 +147,20 @@ class Review:
147
147
  return bool(self.body.strip())
148
148
 
149
149
 
150
+ @dataclass(frozen=True)
151
+ class IssueComment:
152
+ """An issue comment on an issue OR a PR (a PR is an issue on the issues
153
+ endpoints). Distinct from Review: issue comments live on the
154
+ `/issues/{n}/comments` endpoints and never create review records, so
155
+ posting or PATCHing one can never disturb the fix edge's round counting
156
+ or its author-activity liveness probe -- the non-interference the
157
+ mechanical activity comment leans on."""
158
+
159
+ id: int
160
+ author: str
161
+ body: str
162
+
163
+
150
164
  class RateLimited(RuntimeError):
151
165
  pass
152
166
 
@@ -739,3 +753,50 @@ class GitHub:
739
753
  "-f",
740
754
  f"body={body}",
741
755
  )
756
+
757
+ def issue_comments(
758
+ self, owner: str, repo: str, number: int
759
+ ) -> list[IssueComment]:
760
+ """Issue comments on an issue or PR (find-or-create reads these to
761
+ locate THE activity comment). per_page=100 is a documented single-page
762
+ window like the searches -- the activity comment is authored by the
763
+ daemon itself, so it lands on the first page unless a hundred other
764
+ comments were filed after it, and even then find-or-create degrades to
765
+ creating a second activity comment, never to touching a foreign one.
766
+ Malformed entries are tolerated (never fatal): one odd comment must not
767
+ wedge a spawn's telemetry."""
768
+ data = (
769
+ self._api(
770
+ "-X",
771
+ "GET",
772
+ f"repos/{owner}/{repo}/issues/{number}/comments",
773
+ "-f",
774
+ "per_page=100",
775
+ )
776
+ or []
777
+ )
778
+ if not isinstance(data, list):
779
+ return []
780
+ return [
781
+ IssueComment(
782
+ id=int(c.get("id") or 0),
783
+ author=(c.get("user") or {}).get("login", ""),
784
+ body=c.get("body") or "",
785
+ )
786
+ for c in data
787
+ if isinstance(c, dict)
788
+ ]
789
+
790
+ def update_comment(
791
+ self, owner: str, repo: str, comment_id: int, body: str
792
+ ) -> None:
793
+ """PATCH an existing issue comment's body -- the append half of the
794
+ activity comment's find-or-create. Keyed on the comment id, so it only
795
+ ever rewrites a comment the caller already located and authored."""
796
+ self._api(
797
+ "-X",
798
+ "PATCH",
799
+ f"repos/{owner}/{repo}/issues/comments/{comment_id}",
800
+ "-f",
801
+ f"body={body}",
802
+ )
@@ -101,6 +101,22 @@ new), oldest-first within each edge, so a scarce budget is spent by priority
101
101
  and the deferrals fall on the newest, lowest-priority items. Deferrals are
102
102
  counted in the INFO poll summary and in the snapshot exhaust.
103
103
 
104
+ Every spawn, retry, and capacity-deferral across all three edges also leaves a
105
+ MECHANICAL ACTIVITY line on its trigger artifact -- on the issue for issue-edge
106
+ spawns, on the PR for fix- and maintain-edge spawns -- via a single find-or-create
107
+ comment keyed on the hidden `<!-- alissa-devloop:activity -->` marker AND own
108
+ authorship (a spoofed marker from another author is never touched). This is the
109
+ devloop mirror of reviewloop's activity comment: from GitHub alone an operator
110
+ can see the daemon spawned, retried a presumed-dead session, or deferred at
111
+ capacity, instead of having to read the daemon's own logs. It is telemetry,
112
+ never a gate: appended AFTER the spawn's side effects, best-effort (a comment
113
+ failure logs and is swallowed), and fully dry-run gated. Capacity-deferral lines
114
+ are deduped to one per episode (the prospective session name is the episode key,
115
+ recorded in the separate `activity_log` ledger) so a re-decided deferral never
116
+ spams the comment; spawn lines self-dedupe on their own spawn ledger row. Being
117
+ plain issue comments, they create no review records, never move the fix edge's
118
+ author-activity liveness signal, and never register as operator escalations.
119
+
104
120
  Finally, every pass runs the SESSION REAPER: finished `develop-*`/`fix-*`
105
121
  worker sessions otherwise idle in tmux forever (observed live on the reviewer
106
122
  daemon, 2026-07-22 -- and dev workers are worse, because they block through
@@ -315,6 +331,30 @@ ASSIGNMENT_REJECTED_COMMENT = (
315
331
  "until then no developer session will be spawned for this issue."
316
332
  )
317
333
 
334
+ # The hidden marker that identifies THE mechanical activity comment on a
335
+ # trigger artifact (the issue for issue-edge spawns; the PR for fix- and
336
+ # maintain-edge spawns). Find-or-create keys on it AND on own authorship --
337
+ # anyone can paste the marker into their own comment, and a spoofed marker
338
+ # must never be PATCHed -- so every append lands in the same single comment
339
+ # however many spawns, retries, and deferrals the daemon logs. The mirror of
340
+ # reviewloop's `<!-- alissa-reviewloop:activity -->`; the two daemons keep
341
+ # distinct markers so their comments never collide on a shared PR.
342
+ ACTIVITY_MARKER = "<!-- alissa-devloop:activity -->"
343
+
344
+ ACTIVITY_HEADER = (
345
+ ACTIVITY_MARKER + "\n"
346
+ "**Dev-loop activity** — the daemon's mechanical spawn/retry/deferral "
347
+ "log; one line is appended each time it queues (or defers) a worker "
348
+ "session for this item. Not an operator page — escalations are separate "
349
+ "comments."
350
+ )
351
+
352
+ # The activity-log dedupe kind prefix for a capacity-deferral episode. Unlike
353
+ # a spawn (which records a ledger row and so logs its line exactly once), a
354
+ # deferral is re-decided every poll while the max_sessions cap holds, so its
355
+ # line is deduped per episode -- see deferral_activity_kind.
356
+ ACTIVITY_DEFERRED = "deferred"
357
+
318
358
 
319
359
  # Escalation-ledger kinds. Cap-out, assignment-rejection, and a stalled
320
360
  # linked-PR deferral have different operator remedies (raise attempt_cap vs
@@ -410,6 +450,22 @@ def stalled_kind(attempt: int) -> str:
410
450
  return f"{ESCALATION_STALLED}:a{attempt}"
411
451
 
412
452
 
453
+ def deferral_activity_kind(session: str) -> str:
454
+ """The activity-log kind that dedupes ONE capacity-deferral episode's line.
455
+
456
+ A max_sessions deferral is re-decided every poll and claims nothing (no
457
+ ledger row), so an unguarded append would grow the activity comment by one
458
+ identical "deferred — at capacity" line per poll for as long as the cap
459
+ holds. One line per episode carries the same information. The prospective
460
+ session name is the episode identity: while an item defers it records no
461
+ spawn, so its attempt/round -- and thus its deterministic session name --
462
+ does not advance across the episode; the moment a slot frees the item
463
+ spawns under that same name and logs its spawn line. Mirror of reviewloop's
464
+ deferral_activity_kind. The row lands only AFTER the line posts (see
465
+ DevWatcher._log_deferral), so a transient failure retries next poll."""
466
+ return f"{ACTIVITY_DEFERRED}:{session}"
467
+
468
+
413
469
  # The floor under the linked-PR deferral: an open linked PR defers the stale
414
470
  # retry indefinitely -- correct for a live review loop, silent forever for a
415
471
  # session that died with its draft PR open (the deferral sits ABOVE the cap
@@ -732,7 +788,9 @@ class DevWatcher:
732
788
  issue.issue_slug,
733
789
  )
734
790
 
735
- return self._spawn(issue, attempt, task_number, budget)
791
+ return self._spawn(
792
+ issue, attempt, task_number, budget, reenqueued=age is not None
793
+ )
736
794
 
737
795
  # -- actions -----------------------------------------------------------
738
796
 
@@ -742,13 +800,28 @@ class DevWatcher:
742
800
  attempt: int,
743
801
  task_number: int | None,
744
802
  budget: "SessionBudget | None" = None,
803
+ *,
804
+ reenqueued: bool = False,
745
805
  ) -> Decision:
806
+ name = session_name(issue, attempt)
746
807
  # The max_sessions gate, FIRST -- before the self-assign that marks
747
808
  # the issue in-flight, before any hub clone. A full budget defers:
748
809
  # the item claims nothing (no assignment, no ledger row, so no
749
810
  # staleness clock starts) and stays a clean candidate for the next
750
- # poll. reserve() consumes the slot only when it grants.
811
+ # poll. reserve() consumes the slot only when it grants. The deferral
812
+ # logs ONE mechanical activity line per episode (best-effort) so the
813
+ # backpressure is visible on the issue, not just in the daemon's logs.
751
814
  if budget is not None and not budget.reserve():
815
+ self._log_deferral(
816
+ issue.owner,
817
+ issue.repo,
818
+ issue.number,
819
+ issue.full_name,
820
+ name,
821
+ "issue edge",
822
+ budget,
823
+ issue.issue_slug,
824
+ )
752
825
  return Decision(Action.DEFERRED, budget.deferral(), attempt)
753
826
 
754
827
  hub, problem = self._ensure_hub(issue.owner, issue.repo)
@@ -765,7 +838,6 @@ class DevWatcher:
765
838
  "none resolved — follow the alissa-pr-review skill's reviewer "
766
839
  "resolution"
767
840
  )
768
- name = session_name(issue, attempt)
769
841
  directive = DEV_DIRECTIVE.format(
770
842
  issue_url=issue.url,
771
843
  number=issue.number,
@@ -808,6 +880,23 @@ class DevWatcher:
808
880
  session=name,
809
881
  )
810
882
 
883
+ # AFTER the spawn side effects on purpose: the activity comment is
884
+ # telemetry and must never gate the spawn it reports on. A retry
885
+ # (a previously in-flight attempt presumed dead) says so; attempt 1
886
+ # is a plain spawn.
887
+ context = (
888
+ "re-enqueued — previous session presumed dead"
889
+ if reenqueued
890
+ else "spawned"
891
+ )
892
+ self._append_activity(
893
+ issue.owner,
894
+ issue.repo,
895
+ issue.number,
896
+ self._activity_line(name, "issue edge", context),
897
+ issue.issue_slug,
898
+ )
899
+
811
900
  return Decision(
812
901
  Action.SPAWNED,
813
902
  f"session {name} → {task_ref or 'no origin task'}",
@@ -1104,7 +1193,9 @@ class DevWatcher:
1104
1193
  attempts + 1,
1105
1194
  )
1106
1195
 
1107
- return self._spawn_fix(pr, round_, attempts + 1, newest, budget)
1196
+ return self._spawn_fix(
1197
+ pr, round_, attempts + 1, newest, budget, reenqueued=age is not None
1198
+ )
1108
1199
 
1109
1200
  def _reviewer_reviews(
1110
1201
  self, pr: PullRequest, records: list[Review]
@@ -1257,17 +1348,30 @@ class DevWatcher:
1257
1348
  attempt: int,
1258
1349
  review: Review,
1259
1350
  budget: "SessionBudget | None" = None,
1351
+ *,
1352
+ reenqueued: bool = False,
1260
1353
  ) -> Decision:
1261
1354
  skipped = self._foreign_head_skip(pr, attempt)
1262
1355
  if skipped is not None:
1263
1356
  return skipped
1264
1357
 
1358
+ name = fix_session_name(pr, round_, attempt)
1265
1359
  # The max_sessions gate, after the foreign-head skip (that PR is not
1266
1360
  # this daemon's work, so it must not consume or defer a slot) and
1267
1361
  # before the ledger row. A full budget defers with nothing recorded --
1268
1362
  # the fix ledger stays untouched, so the round is re-evaluated cleanly
1269
- # next poll.
1363
+ # next poll. The deferral logs ONE activity line per episode on the PR.
1270
1364
  if budget is not None and not budget.reserve():
1365
+ self._log_deferral(
1366
+ pr.owner,
1367
+ pr.repo,
1368
+ pr.number,
1369
+ pr.full_name,
1370
+ name,
1371
+ "fix edge",
1372
+ budget,
1373
+ pr.pr_slug,
1374
+ )
1271
1375
  return Decision(
1272
1376
  Action.DEFERRED, budget.deferral(), attempt, round=round_
1273
1377
  )
@@ -1276,7 +1380,6 @@ class DevWatcher:
1276
1380
  if problem is not None:
1277
1381
  return Decision(Action.SKIPPED, problem, attempt)
1278
1382
 
1279
- name = fix_session_name(pr, round_, attempt)
1280
1383
  directive = FIX_DIRECTIVE.format(
1281
1384
  pr_url=pr.url,
1282
1385
  number=pr.number,
@@ -1311,6 +1414,21 @@ class DevWatcher:
1311
1414
  session=name,
1312
1415
  )
1313
1416
 
1417
+ # AFTER the spawn side effects: telemetry, never a gate. The session
1418
+ # name carries the round and attempt; the context marks a retry.
1419
+ context = (
1420
+ "re-enqueued — previous session presumed dead"
1421
+ if reenqueued
1422
+ else "spawned"
1423
+ )
1424
+ self._append_activity(
1425
+ pr.owner,
1426
+ pr.repo,
1427
+ pr.number,
1428
+ self._activity_line(name, "fix edge", context),
1429
+ pr.pr_slug,
1430
+ )
1431
+
1314
1432
  return Decision(
1315
1433
  Action.SPAWNED,
1316
1434
  f"fix session {name} → round {round_} ({review.author}: "
@@ -1434,7 +1552,10 @@ class DevWatcher:
1434
1552
  # a fresh request reusing attempt 1 could collide with a prior
1435
1553
  # epoch's still-idling session.
1436
1554
  return self._spawn_maintain(
1437
- pr, self.state.maintain_max_attempt(pr.full_name, number) + 1, budget
1555
+ pr,
1556
+ self.state.maintain_max_attempt(pr.full_name, number) + 1,
1557
+ budget,
1558
+ reenqueued=age is not None,
1438
1559
  )
1439
1560
 
1440
1561
  def _spawn_maintain(
@@ -1442,16 +1563,30 @@ class DevWatcher:
1442
1563
  pr: PullRequest,
1443
1564
  attempt: int,
1444
1565
  budget: "SessionBudget | None" = None,
1566
+ *,
1567
+ reenqueued: bool = False,
1445
1568
  ) -> Decision:
1446
1569
  skipped = self._foreign_head_skip(pr, attempt)
1447
1570
  if skipped is not None:
1448
1571
  return skipped
1449
1572
 
1573
+ name = maintain_session_name(pr, attempt)
1450
1574
  # The max_sessions gate, after the foreign-head skip and before the
1451
1575
  # ledger row (as in _spawn_fix). A full budget defers with nothing
1452
1576
  # recorded; the label stays as the operator's standing ask, so the
1453
- # request is picked up again next poll once a slot frees.
1577
+ # request is picked up again next poll once a slot frees. The deferral
1578
+ # logs ONE activity line per episode on the PR.
1454
1579
  if budget is not None and not budget.reserve():
1580
+ self._log_deferral(
1581
+ pr.owner,
1582
+ pr.repo,
1583
+ pr.number,
1584
+ pr.full_name,
1585
+ name,
1586
+ "maintain edge",
1587
+ budget,
1588
+ pr.pr_slug,
1589
+ )
1455
1590
  return Decision(Action.DEFERRED, budget.deferral(), attempt)
1456
1591
 
1457
1592
  hub, problem = self._ensure_hub(pr.owner, pr.repo)
@@ -1465,7 +1600,6 @@ class DevWatcher:
1465
1600
  "the PR's existing reviewers (none resolved in config — "
1466
1601
  "re-request whoever reviewed it)"
1467
1602
  )
1468
- name = maintain_session_name(pr, attempt)
1469
1603
  directive = MAINTAIN_DIRECTIVE.format(
1470
1604
  pr_url=pr.url,
1471
1605
  number=pr.number,
@@ -1497,6 +1631,21 @@ class DevWatcher:
1497
1631
  session=name,
1498
1632
  )
1499
1633
 
1634
+ # AFTER the spawn side effects: telemetry, never a gate. The session
1635
+ # name carries the attempt; the context marks a retry.
1636
+ context = (
1637
+ "re-enqueued — previous session presumed dead"
1638
+ if reenqueued
1639
+ else "spawned"
1640
+ )
1641
+ self._append_activity(
1642
+ pr.owner,
1643
+ pr.repo,
1644
+ pr.number,
1645
+ self._activity_line(name, "maintain edge", context),
1646
+ pr.pr_slug,
1647
+ )
1648
+
1500
1649
  return Decision(
1501
1650
  Action.SPAWNED,
1502
1651
  f"maintenance session {name} (label {self.config.maintain_label!r} "
@@ -1540,6 +1689,83 @@ class DevWatcher:
1540
1689
  )
1541
1690
  self.state.record_escalation(pr.full_name, pr.number, kind)
1542
1691
 
1692
+ # -- the mechanical activity comment -----------------------------------
1693
+
1694
+ def _activity_line(self, session: str, edge: str, context: str) -> str:
1695
+ """One activity-log line: a UTC timestamp, the session name, the edge,
1696
+ and the event's context. gmtime (UTC) so lines read unambiguously
1697
+ whatever timezone the daemon host runs in; the deterministic session
1698
+ name already carries the issue/PR, attempt, and (fix edge) round."""
1699
+ ts = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
1700
+ return f"- {ts} — `{session}` — {edge} — {context}"
1701
+
1702
+ def _append_activity(
1703
+ self, owner: str, repo: str, number: int, line: str, slug: str
1704
+ ) -> bool:
1705
+ """Append one line to THE activity comment on this issue/PR; True if it
1706
+ landed. Find-or-create: the artifact's issue comments are filtered to
1707
+ OWN authorship AND the hidden marker, and the line is PATCH-appended to
1708
+ the first match; with no match, one marker-carrying comment is created.
1709
+ A marker pasted by anyone else fails the author filter and is never
1710
+ touched.
1711
+
1712
+ Best-effort by contract: this is telemetry ABOUT a spawn/deferral the
1713
+ daemon has already decided, so no failure here may surface to the
1714
+ caller or gate the decision. Fully dry-run gated (a dry pass logs the
1715
+ would-be line and posts nothing). The except is deliberately broad --
1716
+ `_api` turns rate-limit errors into RateLimited (not CommandError), and
1717
+ letting that fly out of a spawn path would fail the whole poll over a
1718
+ log line; a client missing issue_comments degrades the same clean
1719
+ way."""
1720
+ if self.config.dry_run:
1721
+ log.info("[dry-run] would append activity line on %s: %s", slug, line)
1722
+ return False
1723
+ try:
1724
+ mine = [
1725
+ c
1726
+ for c in self.github.issue_comments(owner, repo, number)
1727
+ if c.author == self.github.login and ACTIVITY_MARKER in c.body
1728
+ ]
1729
+ if mine:
1730
+ self.github.update_comment(
1731
+ owner, repo, mine[0].id, mine[0].body + "\n" + line
1732
+ )
1733
+ else:
1734
+ self.github.comment(
1735
+ owner, repo, number, ACTIVITY_HEADER + "\n" + line
1736
+ )
1737
+ except Exception as exc:
1738
+ log.warning("could not append activity line on %s: %s", slug, exc)
1739
+ return False
1740
+ return True
1741
+
1742
+ def _log_deferral(
1743
+ self,
1744
+ owner: str,
1745
+ repo: str,
1746
+ number: int,
1747
+ slug_key: str,
1748
+ session: str,
1749
+ edge: str,
1750
+ budget: "SessionBudget",
1751
+ display_slug: str,
1752
+ ) -> None:
1753
+ """Append ONE activity line for a capacity-deferral episode, deduped on
1754
+ the prospective session name so the comment gains one line per episode,
1755
+ not one per poll. `slug_key` is the ledger key (`owner/repo`); `number`
1756
+ is the issue OR PR number; `display_slug` is the log slug. Best-effort
1757
+ and dry-run gated via _append_activity, and the dedupe row lands only
1758
+ AFTER the line posts, so a transient failure retries next poll --
1759
+ exactly once per episode."""
1760
+ kind = deferral_activity_kind(session)
1761
+ if self.state.activity_logged(slug_key, number, kind):
1762
+ return
1763
+ line = self._activity_line(
1764
+ session, edge, f"deferred — {budget.deferral()}"
1765
+ )
1766
+ if self._append_activity(owner, repo, number, line, display_slug):
1767
+ self.state.record_activity(slug_key, number, kind)
1768
+
1543
1769
  def _ensure_hub(self, owner: str, repo: str) -> tuple[Path, str | None]:
1544
1770
  """Resolve a spawn's cwd (the hub ROOT), hub-ifying the repo first if
1545
1771
  configured. Shared by all three edges (issue, fix, and maintenance
@@ -41,6 +41,15 @@ free-form TEXT: a caller can narrow a kind's dedupe scope by folding more
41
41
  key into the string (the stalled ping is keyed per deferral episode,
42
42
  "stalled:a<attempt>" -- see loop.stalled_kind).
43
43
 
44
+ The `activity_log` table is the mechanical activity comment's dedupe, kept
45
+ SEPARATE from `escalations` on purpose: an activity line is telemetry, never
46
+ an operator page, so it must not register as an escalation (activity_logged()
47
+ and escalated() are independent ledgers). Only capacity-DEFERRAL lines need a
48
+ row -- a deferral is re-decided every poll, so an undeduped append would spam
49
+ one line per poll -- keyed (repo_slug, number, kind) where kind folds the
50
+ prospective session name (loop.deferral_activity_kind). Spawn lines self-
51
+ dedupe on their own spawn ledger row and take no activity_log row.
52
+
44
53
  The `poll_snapshots` table is a different animal from the ledgers above: it
45
54
  records what each poll pass OBSERVED, not what the daemon must remember to
46
55
  avoid double-work. One row per pass carries the timing, the per-edge
@@ -106,6 +115,14 @@ CREATE TABLE IF NOT EXISTS maintain_spawns (
106
115
  PRIMARY KEY (repo_slug, number, attempt)
107
116
  );
108
117
 
118
+ CREATE TABLE IF NOT EXISTS activity_log (
119
+ repo_slug TEXT NOT NULL,
120
+ number INTEGER NOT NULL,
121
+ kind TEXT NOT NULL,
122
+ logged_at INTEGER NOT NULL,
123
+ PRIMARY KEY (repo_slug, number, kind)
124
+ );
125
+
109
126
  CREATE TABLE IF NOT EXISTS poll_snapshots (
110
127
  id INTEGER PRIMARY KEY AUTOINCREMENT,
111
128
  ts INTEGER NOT NULL,
@@ -350,6 +367,43 @@ class State:
350
367
  )
351
368
  self._db.commit()
352
369
 
370
+ # -- mechanical activity comment dedupe -------------------------------
371
+ #
372
+ # A SEPARATE table from `escalations` on purpose: an activity line is
373
+ # telemetry, not an operator page, so it must never register as an
374
+ # escalation (`escalated()` must stay blind to it -- the operator-comment
375
+ # dedupe and the activity dedupe are independent ledgers). Only the
376
+ # capacity-DEFERRAL line needs a dedupe row: a deferral is re-decided every
377
+ # poll, so an undeduped append would grow the activity comment by one
378
+ # identical line per poll for as long as the cap holds. Spawn lines append
379
+ # unconditionally -- an attempt spawns once (its spawn ledger row prevents a
380
+ # re-spawn), so it self-dedupes. `number` is the issue OR PR number; `kind`
381
+ # folds the prospective session name (deferral_activity_kind), which is
382
+ # stable across the deferral episode (nothing is recorded while deferring,
383
+ # so the attempt/round -- and thus the session name -- does not advance).
384
+
385
+ def activity_logged(self, repo_slug: str, number: int, kind: str) -> bool:
386
+ """Whether this activity KIND already landed a line -- the
387
+ once-per-episode gate for capacity-deferral telemetry. Independent of
388
+ `escalated()`: a deferral line is not an operator escalation."""
389
+ row = self._db.execute(
390
+ "SELECT 1 FROM activity_log WHERE repo_slug=? AND number=? AND kind=?",
391
+ (repo_slug, number, kind),
392
+ ).fetchone()
393
+ return row is not None
394
+
395
+ def record_activity(self, repo_slug: str, number: int, kind: str) -> None:
396
+ """Idempotent per kind: OR IGNORE keeps the first line's timestamp.
397
+ Recorded by the caller only AFTER the append lands, so a transient
398
+ comment failure retries next poll and the line lands exactly once per
399
+ episode."""
400
+ self._db.execute(
401
+ "INSERT OR IGNORE INTO activity_log "
402
+ "(repo_slug, number, kind, logged_at) VALUES (?,?,?,?)",
403
+ (repo_slug, number, kind, int(time.time())),
404
+ )
405
+ self._db.commit()
406
+
353
407
  # -- poll snapshots (the UI sidecar's exhaust buffer) ------------------
354
408
 
355
409
  def record_snapshot(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera