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.
- {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
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa/tools/github/devloop/ghclient.py +61 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa/tools/github/devloop/loop.py +235 -9
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa/tools/github/devloop/state.py +54 -0
- alissa_tools_github_devloop-0.5.1/src/main/alissa/tools/github/devloop/version +1 -0
- {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
- alissa_tools_github_devloop-0.5.0/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/README.md +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/setup.py +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa/tools/github/devloop/config.py +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.5.1}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
|
@@ -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(
|
|
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(
|
|
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,
|
|
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(
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.5.1
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.5.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|