alissa-tools-github-devloop 0.3.0__tar.gz → 0.4.0__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 (22) hide show
  1. {alissa_tools_github_devloop-0.3.0/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.4.0}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/src/main/alissa/tools/github/devloop/__main__.py +28 -1
  3. {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/src/main/alissa/tools/github/devloop/config.py +16 -1
  4. {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/src/main/alissa/tools/github/devloop/ghclient.py +138 -0
  5. {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/src/main/alissa/tools/github/devloop/loop.py +383 -30
  6. {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/src/main/alissa/tools/github/devloop/state.py +84 -0
  7. alissa_tools_github_devloop-0.4.0/src/main/alissa/tools/github/devloop/version +1 -0
  8. {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  9. alissa_tools_github_devloop-0.3.0/src/main/alissa/tools/github/devloop/version +0 -1
  10. {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/MANIFEST.in +0 -0
  11. {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/README.md +0 -0
  12. {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/requirements.txt +0 -0
  13. {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/setup.cfg +0 -0
  14. {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/setup.py +0 -0
  15. {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  16. {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  17. {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  18. {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/src/main/alissa/tools/github/devloop/version.py +0 -0
  19. {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
  20. {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  21. {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  22. {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/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.3.0
3
+ Version: 0.4.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -115,6 +115,30 @@ def build_parser() -> argparse.ArgumentParser:
115
115
  "even if the config enables it",
116
116
  )
117
117
 
118
+ over.add_argument(
119
+ "--maintain-label",
120
+ metavar="LABEL",
121
+ help="PR label an operator applies to request a maintenance session "
122
+ "(merge main into the branch / freshen it); the session removes the "
123
+ "label on completion",
124
+ )
125
+ maintain = over.add_mutually_exclusive_group()
126
+ maintain.add_argument(
127
+ "--maintain",
128
+ dest="maintain_enabled",
129
+ action="store_true",
130
+ default=None,
131
+ help="poll open PRs carrying the maintenance label and spawn "
132
+ "maintenance sessions (the default)",
133
+ )
134
+ maintain.add_argument(
135
+ "--no-maintain",
136
+ dest="maintain_enabled",
137
+ action="store_false",
138
+ help="never search for the maintenance label or spawn maintenance "
139
+ "sessions, even if the config enables it",
140
+ )
141
+
118
142
  dry = over.add_mutually_exclusive_group()
119
143
  dry.add_argument(
120
144
  "--dry-run",
@@ -149,6 +173,8 @@ def overrides_from(args: argparse.Namespace) -> dict:
149
173
  "on_missing_origin_task": args.on_missing_origin_task,
150
174
  "on_missing_hub": args.on_missing_hub,
151
175
  "fix_rounds_enabled": args.fix_rounds_enabled,
176
+ "maintain_label": args.maintain_label,
177
+ "maintain_enabled": args.maintain_enabled,
152
178
  "dry_run": args.dry_run,
153
179
  }
154
180
 
@@ -180,9 +206,10 @@ def log_effective_config(config: Config, login: str) -> None:
180
206
  log.info("reviewers: %s", ", ".join(config.reviewers) or "none")
181
207
  log.info(
182
208
  "poll every %ss; dry_run=%s; attempt_cap=%s; stale after %s min; "
183
- "fix_rounds=%s",
209
+ "fix_rounds=%s; maintain=%s (label %r)",
184
210
  config.poll_interval, config.dry_run, config.attempt_cap,
185
211
  config.stale_minutes, config.fix_rounds_enabled,
212
+ config.maintain_enabled, config.maintain_label,
186
213
  )
187
214
  log.debug("hub_template: %s", config.hub_template)
188
215
  log.debug("agent_profile: %s", config.agent_profile)
@@ -59,6 +59,8 @@ CONFIG_KEYS = (
59
59
  "on_missing_origin_task",
60
60
  "on_missing_hub",
61
61
  "fix_rounds_enabled",
62
+ "maintain_label",
63
+ "maintain_enabled",
62
64
  "dry_run",
63
65
  )
64
66
 
@@ -128,7 +130,7 @@ class Config:
128
130
  # resolved location, never this field.
129
131
  state_path: Path | None = None
130
132
 
131
- attempt_cap: int = 2
133
+ attempt_cap: int = 10
132
134
  stale_minutes: int = 120
133
135
 
134
136
  on_missing_origin_task: str = ON_MISSING_WARN
@@ -140,6 +142,15 @@ class Config:
140
142
  # still honors dry_run. Turning it off reverts to issues-only polling.
141
143
  fix_rounds_enabled: bool = True
142
144
 
145
+ # The maintenance edge (the third polled edge): an operator labels an
146
+ # open PR `maintain_label` to request an ad-hoc maintenance session
147
+ # (merge conflicts on approved-but-unmerged branches, freshening stale
148
+ # branches — the re-trigger the other two edges deliberately don't
149
+ # cover). Default ON like fix_rounds_enabled, and every side effect
150
+ # still honors dry_run.
151
+ maintain_label: str = "alissa:maintain"
152
+ maintain_enabled: bool = True
153
+
143
154
  dry_run: bool = False
144
155
 
145
156
  # Derived at build time: `repos` casefolded for matching. GitHub names are
@@ -282,6 +293,10 @@ class Config:
282
293
  fix_rounds_enabled=bool(
283
294
  raw.get("fix_rounds_enabled", cls.fix_rounds_enabled)
284
295
  ),
296
+ maintain_label=raw.get("maintain_label", cls.maintain_label),
297
+ maintain_enabled=bool(
298
+ raw.get("maintain_enabled", cls.maintain_enabled)
299
+ ),
285
300
  dry_run=bool(raw.get("dry_run", cls.dry_run)),
286
301
  )
287
302
 
@@ -6,11 +6,13 @@ through `gh api` against the REST v3 endpoints instead.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import calendar
9
10
  import json
10
11
  import logging
11
12
  import os
12
13
  import re
13
14
  import tempfile
15
+ import time
14
16
  from dataclasses import dataclass
15
17
 
16
18
  from . import proc
@@ -43,6 +45,19 @@ TIMELINE_MAX_PAGES = 5
43
45
  SUBMITTED_STATES = {"APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"}
44
46
 
45
47
 
48
+ def parse_github_timestamp(ts: "str | None") -> "int | None":
49
+ """A GitHub API timestamp (`2026-07-23T02:13:56Z`) as unix seconds, or
50
+ None when absent/unparseable. GitHub emits UTC `Z` timestamps
51
+ everywhere; anything else conservatively reads as "unknown" -- callers
52
+ treat an unknown boundary as "do not filter", never as an error."""
53
+ if not ts:
54
+ return None
55
+ try:
56
+ return calendar.timegm(time.strptime(ts, "%Y-%m-%dT%H:%M:%SZ"))
57
+ except ValueError:
58
+ return None
59
+
60
+
46
61
  def parse_task_ref(body: str) -> int | None:
47
62
  """Origin Alissa task number from an issue body, or None if absent.
48
63
 
@@ -383,6 +398,129 @@ class GitHub:
383
398
  out.append((parts[-2], parts[-1], number))
384
399
  return out
385
400
 
401
+ def search_maintain_prs(
402
+ self, label: str, repos: tuple[str, ...] = ()
403
+ ) -> list[tuple[str, str, int]]:
404
+ """Open PRs carrying the maintenance label across the watched repos,
405
+ as `(owner, repo, number)` hits -- the candidate set of the
406
+ operator-triggered maintenance edge.
407
+
408
+ The label IS the trigger and the visible in-flight state: an operator
409
+ applies it to request a maintenance session, and the session removes
410
+ it on completion -- so this search re-sees in-flight work every poll
411
+ (the ledger, not the label, dedupes) and the query deliberately has
412
+ no `author:@me` or `draft:false` qualifier: the operator labeled a
413
+ specific PR, and evaluate_maintain()'s re-fetch guards (author,
414
+ head-ref shape, closed-between-search-and-fetch) do the filtering.
415
+ The label is quoted because it contains a colon (`alissa:maintain`).
416
+
417
+ Fails closed on an empty/degenerate allowlist exactly like the two
418
+ sibling searches: an unscoped label query would run against ALL of
419
+ GitHub, and the caller spawns code-writing sessions against whatever
420
+ comes back.
421
+ """
422
+ repos = tuple(r.strip() for r in repos if r and r.strip())
423
+ if not repos:
424
+ log.warning(
425
+ "search_maintain_prs: repo allowlist is empty -- nothing is "
426
+ "watched. An unscoped search would match all of GitHub, so "
427
+ "this fails closed; configure `repos` to watch something."
428
+ )
429
+ return []
430
+
431
+ query = f'is:pr is:open label:"{label}"'
432
+ for full_name in repos:
433
+ query += f" repo:{full_name}"
434
+
435
+ payload = self._api(
436
+ "-X",
437
+ "GET",
438
+ "search/issues",
439
+ "-f",
440
+ f"q={query}",
441
+ "-f",
442
+ f"per_page={self.SEARCH_PER_PAGE}",
443
+ )
444
+ items = (payload or {}).get("items", [])
445
+ if len(items) == self.SEARCH_PER_PAGE:
446
+ log.warning(
447
+ "search_maintain_prs: got exactly %d items -- results are "
448
+ "likely truncated. Pagination is deliberately not implemented "
449
+ "(same hard ceiling as search_issues); the window only "
450
+ "drains as PRs close or lose the label.",
451
+ self.SEARCH_PER_PAGE,
452
+ )
453
+
454
+ out: list[tuple[str, str, int]] = []
455
+ for item in items:
456
+ # repository_url looks like https://api.github.com/repos/<owner>/<repo>
457
+ parts = item.get("repository_url", "").rstrip("/").split("/")
458
+ if len(parts) < 2:
459
+ log.warning("could not parse repo from %s", item.get("repository_url"))
460
+ continue
461
+ try:
462
+ number = int(item["number"])
463
+ except (KeyError, TypeError, ValueError):
464
+ log.warning("could not parse PR number from %r", item.get("number"))
465
+ continue
466
+ out.append((parts[-2], parts[-1], number))
467
+ return out
468
+
469
+ def maintain_labeled_at(
470
+ self, owner: str, repo: str, number: int, label: str
471
+ ) -> "str | None":
472
+ """When the maintenance label was MOST RECENTLY applied to this PR
473
+ (ISO timestamp), or None when no labeled event is visible -- the
474
+ epoch boundary of the current maintenance request.
475
+
476
+ Completion is a label removal the daemon never observes (a completed
477
+ PR drops out of the label search), so the maintain ledger cannot be
478
+ reset on completion -- instead the caller re-bases its attempt budget
479
+ on rows spawned AFTER the newest `labeled` event: an operator
480
+ re-labeling a PR weeks after a completed cycle starts a fresh budget,
481
+ while a dead session (label never removed, same labeling) keeps
482
+ accumulating attempts toward the cap.
483
+
484
+ Source: the issue-events feed (PRs are issues to this endpoint),
485
+ walked in the same bounded page loop as the timeline probe --
486
+ ascending chronological order, so the NEWEST matching event is the
487
+ last one seen; the walk stops at the first short page and hard-caps
488
+ at TIMELINE_MAX_PAGES pages. A labeled event past the ceiling is
489
+ invisible: the probe then reports an OLDER labeling (or none), which
490
+ under-filters -- attempts from an older request stay in the budget,
491
+ the same direction as the no-event fallback (lifetime accounting)
492
+ and never a fresh budget that should not exist. Malformed events
493
+ are skipped, never fatal.
494
+ """
495
+ newest: "str | None" = None
496
+ for page in range(1, TIMELINE_MAX_PAGES + 1):
497
+ events = (
498
+ self._api(
499
+ "-X",
500
+ "GET",
501
+ f"repos/{owner}/{repo}/issues/{number}/events",
502
+ "-f",
503
+ f"per_page={TIMELINE_PAGE_SIZE}",
504
+ "-f",
505
+ f"page={page}",
506
+ )
507
+ or []
508
+ )
509
+ if not isinstance(events, list):
510
+ break
511
+ for event in events:
512
+ if not isinstance(event, dict) or event.get("event") != "labeled":
513
+ continue
514
+ name = (event.get("label") or {}).get("name")
515
+ if name != label:
516
+ continue
517
+ created = event.get("created_at")
518
+ if created:
519
+ newest = created # ascending order: the last one wins
520
+ if len(events) < TIMELINE_PAGE_SIZE:
521
+ break # short page: the feed is exhausted
522
+ return newest
523
+
386
524
  def pull_request(self, owner: str, repo: str, number: int) -> PullRequest:
387
525
  """The evaluate_pr() re-fetch: draft state, head, author, and the
388
526
  pending review requests -- the consistency guard over search
@@ -57,6 +57,34 @@ CHANGES_REQUESTED); COMMENTED-mode reviews defer to the CR6 verdict envelope
57
57
  via an injectable reader, and with no readable verdict the edge stays closed
58
58
  -- a fix session is never spawned on an ambiguous verdict.
59
59
 
60
+ The pass's THIRD edge is the MAINTENANCE edge (maintain_enabled): open PRs
61
+ an operator labeled `maintain_label` (default `alissa:maintain`) get a
62
+ maintenance session -- merge conflicts on approved-but-unmerged branches,
63
+ freshening stale branches: the ad-hoc re-trigger the other two edges
64
+ deliberately don't cover. The label is BOTH the trigger and the visible
65
+ in-flight state, and its lifecycle is remove-on-completion by design: the
66
+ daemon never removes it -- the maintenance session does, as its terminal
67
+ act, after push -> re-request (the push stales any approve, so the
68
+ re-request re-arms the reviewer daemon). While the label stays, the
69
+ maintain_spawns ledger dedupes a fresh spawn, ages a stale one into attempt
70
+ k+1 (a dead session's label never clears itself, so the retry is what the
71
+ lifecycle buys), and caps attempts PER REQUEST -- at cap the daemon posts
72
+ ONE operator comment per request (the `maintain-cap` kind, epoch-folded)
73
+ and stops until the operator acts. "Per request" is load-bearing: the edge
74
+ exists to be RE-triggered, completion is a label removal the daemon never
75
+ observes (a completed PR drops out of the search), and ledger rows are
76
+ never deleted -- so the budget is re-based on the newest `labeled` event
77
+ (maintain_labeled_at, fetched lazily on the stale path only): attempts
78
+ spawned before the current labeling belong to a prior, completed request
79
+ and neither age into a false "presumed dead" retry warning nor count
80
+ toward a false cap-out. Attempt NUMBERING stays lifetime-monotonic
81
+ (maintain_max_attempt) even as the budget resets: session names fold the
82
+ attempt number in, and a fresh budget reusing attempt 1 could collide with
83
+ a prior epoch's zombie session. With no readable labeled event the budget
84
+ conservatively stays lifetime-wide (the pre-epoch behavior). The re-fetch
85
+ guards mirror the fix edge's: closed/merged between search and fetch, a
86
+ foreign author, and a foreign-shaped head ref all skip.
87
+
60
88
  Finally, every pass runs the SESSION REAPER: finished `develop-*`/`fix-*`
61
89
  worker sessions otherwise idle in tmux forever (observed live on the reviewer
62
90
  daemon, 2026-07-22 -- and dev workers are worse, because they block through
@@ -99,6 +127,7 @@ from .ghclient import (
99
127
  PullRequest,
100
128
  RateLimited,
101
129
  Review,
130
+ parse_github_timestamp,
102
131
  parse_task_ref,
103
132
  )
104
133
  from .proc import CommandError
@@ -182,6 +211,35 @@ FIX_DIRECTIVE = (
182
211
  "create further ali-* sessions — this loop is depth-1."
183
212
  )
184
213
 
214
+ MAINTAIN_DIRECTIVE = (
215
+ "You are an IMPLEMENTER on maintenance duty, not a reviewer. An operator "
216
+ "labeled PR {pr_url} `{label}` (maintenance attempt {attempt} of {cap}): "
217
+ "bring the branch current with main — resolve any merge conflict and "
218
+ "freshen it. "
219
+ "Golden path: your cwd is the repo's worktree hub ROOT and the PR's head "
220
+ "branch `{head_ref}` already exists — re-attach its worktree with `git "
221
+ "worktree add {head_ref} {head_ref}` if it is missing, and work ONLY on "
222
+ "that branch (never a new branch, never a clone, never main). "
223
+ "FIRST the idempotent no-op check: if the branch is already conflict-free "
224
+ "AND current vs origin/main, remove the `{label}` label from the PR "
225
+ "(`gh pr edit {number} --remove-label \"{label}\"`) and exit — push "
226
+ "nothing, re-request nobody; this no-op path is what makes retries safe. "
227
+ "Otherwise merge origin/main INTO the branch (`git merge origin/main`) — "
228
+ "NEVER rebase, never force-push: review threads keep their anchors only "
229
+ "while history is append-only — resolving any conflicts so BOTH sides' "
230
+ "intent is preserved, then run the repo's four scripts (unit tests, "
231
+ "coverage, style, types) and get ALL of them green before publishing. "
232
+ "The terminal sequence's ORDER is load-bearing: push the merge commit, "
233
+ "THEN re-request review from {reviewers} — the push stales any approve, "
234
+ "so the re-request is what re-arms the review daemon — THEN remove the "
235
+ "`{label}` label from the PR LAST: the label removal is the convergence "
236
+ "signal, and nothing may follow it except your last act: run "
237
+ "`alissa tmux kill {session}` to end this session and free the machine "
238
+ "(best-effort — a crashed session never reaches it). NEVER merge the PR, "
239
+ "never push to main, and do NOT create further ali-* sessions — this "
240
+ "loop is depth-1."
241
+ )
242
+
185
243
  ESCALATION_COMMENT = (
186
244
  "**Dev loop cap-out** — {attempts} developer attempt(s) were spawned on "
187
245
  "this issue without it converging (attempt cap: {cap}). The daemon will "
@@ -223,6 +281,16 @@ FIX_STALLED_COMMENT = (
223
281
  "close the PR to park the work."
224
282
  )
225
283
 
284
+ MAINTAIN_CAP_COMMENT = (
285
+ "**Maintain loop cap-out** — {attempts} maintenance attempt(s) were "
286
+ "spawned on this PR without any of them completing (attempt cap: {cap}; "
287
+ "completion = the `{label}` label removed, the session's terminal act). "
288
+ "The daemon will not spawn another while the label stays. This needs an "
289
+ "operator decision: finish the maintenance by hand (merge main into the "
290
+ "branch, push, re-request review) and remove the `{label}` label, raise "
291
+ "`attempt_cap` to re-enter the loop, or remove the label to park the PR."
292
+ )
293
+
226
294
  ASSIGNMENT_REJECTED_COMMENT = (
227
295
  "**Dev loop cannot start** — the daemon tried to self-assign this issue "
228
296
  "as its in-flight marker, but GitHub silently dropped the assignment: "
@@ -245,6 +313,11 @@ ESCALATION_STALLED = "stalled"
245
313
  ESCALATION_FIX_CAP = "fix-cap"
246
314
  ESCALATION_FIX_STALLED = "fix-stalled"
247
315
 
316
+ # The maintenance edge's cap-out kind prefix. The `maintain-` prefix keeps
317
+ # rows from ever colliding with the issue edge's `cap` when an issue and a
318
+ # PR share a number; the epoch suffix is maintain_cap_kind's job below.
319
+ ESCALATION_MAINTAIN_CAP = "maintain-cap"
320
+
248
321
  # CR6 verdict words, spelled exactly as reviewloop's alissa.py spells them --
249
322
  # the envelope tie-breaker (see DevWatcher.envelope_verdict) must return
250
323
  # these strings, so the two daemons read one vocabulary.
@@ -252,6 +325,21 @@ VERDICT_APPROVE = "approve"
252
325
  VERDICT_REQUEST_CHANGES = "request_changes"
253
326
 
254
327
 
328
+ def maintain_cap_kind(labeled_ts: "int | None") -> str:
329
+ """The ledger kind that dedupes ONE maintenance request's cap-out page.
330
+
331
+ Folded per request like fix_cap_kind is per round: request k capping
332
+ out must not silence request k+1's page -- a re-label re-opens the edge
333
+ with a fresh budget, and its cap-out is a fresh operator ask going
334
+ unanswered. The epoch boundary IS the newest `labeled` event's
335
+ timestamp, so it folds into the kind (`maintain-cap:e<ts>`). With no
336
+ readable labeled event (labeled_ts None) the kind stays bare -- the
337
+ budget is lifetime-wide there, so one lifetime page matches it."""
338
+ if labeled_ts is None:
339
+ return ESCALATION_MAINTAIN_CAP
340
+ return f"{ESCALATION_MAINTAIN_CAP}:e{labeled_ts}"
341
+
342
+
255
343
  def fix_cap_kind(round_: int) -> str:
256
344
  """The ledger kind that dedupes ONE round's fix cap-out operator page.
257
345
 
@@ -366,6 +454,22 @@ def fix_session_name(pr: PullRequest, round_: int, attempt: int) -> str:
366
454
  return f"fix-{owner}-{repo}-pr{pr.number}-r{round_}-a{attempt}"
367
455
 
368
456
 
457
+ def maintain_session_name(pr: PullRequest, attempt: int) -> str:
458
+ """A tmux-safe maintenance-session name:
459
+ `maintain-<owner>-<repo>-pr<n>-a<attempt>`.
460
+
461
+ Deterministic like its two siblings (no nonce): the attempt number comes
462
+ from the maintain ledger's row count, so every spawn gets a fresh name.
463
+ No round segment -- the edge has no rounds. The owner is folded in for
464
+ the same reason as the other edges (the P3 session-name lesson):
465
+ maintain counts are keyed per `owner/repo`, so two owners' PR #16 can
466
+ both sit at attempt 1, and a repo-only name would collide.
467
+ """
468
+ owner = re.sub(r"[^A-Za-z0-9-]", "-", pr.owner).strip("-").lower()
469
+ repo = re.sub(r"[^A-Za-z0-9-]", "-", pr.repo).strip("-").lower()
470
+ return f"maintain-{owner}-{repo}-pr{pr.number}-a{attempt}"
471
+
472
+
369
473
  def no_envelope_verdict(owner: str, repo: str, number: int) -> "str | None":
370
474
  """The default (stub) envelope tie-breaker: no verdict is ever readable.
371
475
 
@@ -990,31 +1094,39 @@ class DevWatcher:
990
1094
  return VERDICT_REQUEST_CHANGES
991
1095
  return self.envelope_verdict(pr.owner, pr.repo, pr.number)
992
1096
 
1097
+ def _foreign_head_skip(self, pr: PullRequest, attempt: int) -> "Decision | None":
1098
+ """The head-ref shape guard, shared by every PR-side spawn (fix and
1099
+ maintain): head_ref is interpolated into the directive as both a
1100
+ worktree path and a shell token (`git worktree add {head_ref}
1101
+ {head_ref}`), and a PR's head branch is not strictly self-authored
1102
+ (anyone with push access can move it). This daemon only ever creates
1103
+ flat `TASK-<n>-<DESC>` branches, so a ref outside that shape --
1104
+ slashes that nest the worktree path, whitespace or shell
1105
+ metacharacters that reach an agent prompt -- is not this daemon's
1106
+ work anyway: skip conservatively rather than sanitize. Returns the
1107
+ SKIPPED Decision, or None when the ref is safe to interpolate."""
1108
+ if re.fullmatch(r"[A-Za-z0-9._-]+", pr.head_ref):
1109
+ return None
1110
+ log.warning(
1111
+ "%s head ref %r does not match the daemon's own branch shape "
1112
+ "— not spawning a worker session on a foreign-shaped branch",
1113
+ pr.pr_slug,
1114
+ pr.head_ref,
1115
+ )
1116
+ return Decision(
1117
+ Action.SKIPPED,
1118
+ f"head ref {pr.head_ref!r} does not match the daemon's own "
1119
+ f"branch shape ([A-Za-z0-9._-]+) — foreign-shaped branch, "
1120
+ f"not this daemon's work",
1121
+ attempt,
1122
+ )
1123
+
993
1124
  def _spawn_fix(
994
1125
  self, pr: PullRequest, round_: int, attempt: int, review: Review
995
1126
  ) -> Decision:
996
- # head_ref is interpolated into FIX_DIRECTIVE as both a worktree
997
- # path and a shell token (`git worktree add {head_ref} {head_ref}`),
998
- # and a PR's head branch is not strictly self-authored (anyone with
999
- # push access can move it). This daemon only ever creates flat
1000
- # `TASK-<n>-<DESC>` branches, so a ref outside that shape -- slashes
1001
- # that nest the worktree path, whitespace or shell metacharacters
1002
- # that reach an agent prompt -- is not this daemon's work anyway:
1003
- # skip conservatively rather than sanitize.
1004
- if not re.fullmatch(r"[A-Za-z0-9._-]+", pr.head_ref):
1005
- log.warning(
1006
- "%s head ref %r does not match the daemon's own branch shape "
1007
- "— not spawning a fix session on a foreign-shaped branch",
1008
- pr.pr_slug,
1009
- pr.head_ref,
1010
- )
1011
- return Decision(
1012
- Action.SKIPPED,
1013
- f"head ref {pr.head_ref!r} does not match the daemon's own "
1014
- f"branch shape ([A-Za-z0-9._-]+) — foreign-shaped branch, "
1015
- f"not this daemon's work",
1016
- attempt,
1017
- )
1127
+ skipped = self._foreign_head_skip(pr, attempt)
1128
+ if skipped is not None:
1129
+ return skipped
1018
1130
 
1019
1131
  hub, problem = self._ensure_hub(pr.owner, pr.repo)
1020
1132
  if problem is not None:
@@ -1062,11 +1174,211 @@ class DevWatcher:
1062
1174
  attempt,
1063
1175
  )
1064
1176
 
1177
+ # -- the maintenance edge (operator-labeled PRs) ------------------------
1178
+
1179
+ def evaluate_maintain(self, owner: str, repo: str, number: int) -> Decision:
1180
+ """Is a maintenance session owed on this labeled PR? The label IS the
1181
+ ask -- an operator applied it -- so unlike the fix edge there is no
1182
+ verdict or court arithmetic here, only the re-fetch guards and the
1183
+ ledger. The label's lifecycle is remove-on-completion: the daemon
1184
+ NEVER removes it (the maintenance session does, as its terminal
1185
+ act), so the label doubles as the visible in-flight state -- the
1186
+ ledger dedupes a fresh spawn, a stale ledger row under a
1187
+ still-present label means the session died and attempt k+1 is owed,
1188
+ and the attempt cap pages the operator once PER REQUEST and stops.
1189
+
1190
+ The attempt budget is per maintenance REQUEST, not per PR lifetime:
1191
+ completion (the session's label removal) is invisible to the daemon
1192
+ and rows are never deleted, so on the stale path the budget is
1193
+ re-based on the newest `labeled` event -- attempts spawned before
1194
+ the current labeling belong to a prior, completed request, and must
1195
+ neither trigger the false "presumed dead" warning nor eat the new
1196
+ request's budget (the edge exists to be re-triggered). The probe is
1197
+ lazy: no prior rows, or a fresh in-flight spawn (always the current
1198
+ request's), never pays for the events fetch."""
1199
+ pr = self.github.pull_request(owner, repo, number)
1200
+
1201
+ # Closed/merged between search and fetch: nothing left to maintain.
1202
+ if pr.state != "open":
1203
+ return Decision(Action.SKIPPED, "PR closed between search and fetch")
1204
+
1205
+ # Mirror of the fix edge's author guard: a maintenance session
1206
+ # merges main into the head branch and pushes it, and a foreign PR
1207
+ # must never grow a session pushing to its branch -- whoever labeled
1208
+ # it, the daemon only maintains its own work.
1209
+ if pr.author != self.github.login:
1210
+ return Decision(
1211
+ Action.SKIPPED,
1212
+ f"PR author is {pr.author!r}, not the developer identity — "
1213
+ f"not this daemon's to maintain",
1214
+ )
1215
+
1216
+ attempts = self.state.maintain_attempt_count(pr.full_name, number)
1217
+ age = self.state.maintain_spawn_age(pr.full_name, number)
1218
+
1219
+ if age is not None and age < self.config.stale_minutes * 60:
1220
+ return Decision(
1221
+ Action.IN_FLIGHT,
1222
+ f"maintenance attempt {attempts} enqueued {int(age)}s ago "
1223
+ f"(the label stays until the session completes)",
1224
+ attempts,
1225
+ )
1226
+
1227
+ # Stale path with history: re-base the budget on the current
1228
+ # request. An unreadable boundary (no visible labeled event, or an
1229
+ # unparseable timestamp) conservatively leaves the lifetime budget
1230
+ # in place -- under-filtering can only page the operator early,
1231
+ # never spawn past a real request's cap.
1232
+ labeled_ts: "int | None" = None
1233
+ if attempts:
1234
+ labeled_ts = parse_github_timestamp(
1235
+ self.github.maintain_labeled_at(
1236
+ owner, repo, number, self.config.maintain_label
1237
+ )
1238
+ )
1239
+ if labeled_ts is not None:
1240
+ attempts = self.state.maintain_attempt_count(
1241
+ pr.full_name, number, since=labeled_ts
1242
+ )
1243
+ age = self.state.maintain_spawn_age(
1244
+ pr.full_name, number, since=labeled_ts
1245
+ )
1246
+
1247
+ # This request's budget ran dry while the label is still present:
1248
+ # page the operator ON the PR once (the epoch-folded maintain-cap
1249
+ # kind, so a later request's cap-out pages again) and stop. No
1250
+ # deferral tier above this, unlike the other edges: the label is an
1251
+ # explicit operator ask, and its removal -- by the session on
1252
+ # completion, or by the operator to park the PR -- is the only
1253
+ # convergence signal this edge has.
1254
+ if attempts >= self.config.attempt_cap:
1255
+ kind = maintain_cap_kind(labeled_ts)
1256
+ if self.state.escalated(pr.full_name, number, kind):
1257
+ return Decision(Action.CAPPED, "already escalated", attempts)
1258
+ self._escalate_maintain_cap(pr, attempts, kind)
1259
+ return Decision(
1260
+ Action.ESCALATED,
1261
+ f"{attempts} maintenance attempts this request, cap "
1262
+ f"{self.config.attempt_cap} — the label is still present; "
1263
+ f"operator paged on the PR",
1264
+ attempts,
1265
+ )
1266
+
1267
+ if age is not None:
1268
+ log.warning(
1269
+ "%s maintenance attempt %d has been in flight %.0f min and "
1270
+ "the %r label is still present — re-enqueuing (maintenance "
1271
+ "session presumed dead; a live one removes the label as its "
1272
+ "terminal act)",
1273
+ pr.pr_slug,
1274
+ attempts,
1275
+ age / 60,
1276
+ self.config.maintain_label,
1277
+ )
1278
+
1279
+ # Numbering is lifetime-monotonic even when the budget reset: the
1280
+ # attempt number is folded into the deterministic session name, and
1281
+ # a fresh request reusing attempt 1 could collide with a prior
1282
+ # epoch's still-idling session.
1283
+ return self._spawn_maintain(
1284
+ pr, self.state.maintain_max_attempt(pr.full_name, number) + 1
1285
+ )
1286
+
1287
+ def _spawn_maintain(self, pr: PullRequest, attempt: int) -> Decision:
1288
+ skipped = self._foreign_head_skip(pr, attempt)
1289
+ if skipped is not None:
1290
+ return skipped
1291
+
1292
+ hub, problem = self._ensure_hub(pr.owner, pr.repo)
1293
+ if problem is not None:
1294
+ return Decision(Action.SKIPPED, problem, attempt)
1295
+
1296
+ # The resolved reviewers, re-requested after the push (which stales
1297
+ # any approve). With none resolved the session falls back to the
1298
+ # PR's own review history -- always recoverable from the PR itself.
1299
+ reviewers = ", ".join(self.config.reviewers) or (
1300
+ "the PR's existing reviewers (none resolved in config — "
1301
+ "re-request whoever reviewed it)"
1302
+ )
1303
+ name = maintain_session_name(pr, attempt)
1304
+ directive = MAINTAIN_DIRECTIVE.format(
1305
+ pr_url=pr.url,
1306
+ number=pr.number,
1307
+ label=self.config.maintain_label,
1308
+ attempt=attempt,
1309
+ cap=self.config.attempt_cap,
1310
+ head_ref=pr.head_ref,
1311
+ reviewers=reviewers,
1312
+ session=name,
1313
+ )
1314
+
1315
+ # No self-assignment (authorship is the marker, like the fix edge)
1316
+ # and NO label removal: remove-on-completion is the design decision.
1317
+ # The label stays as the visible in-flight state; the ledger row
1318
+ # below is what dedupes the next poll.
1319
+ self.alissa.enqueue_developer(
1320
+ session=name,
1321
+ directive=directive,
1322
+ cwd=hub,
1323
+ agent=self.config.agent_profile,
1324
+ dry_run=self.config.dry_run,
1325
+ )
1326
+
1327
+ if not self.config.dry_run:
1328
+ self.state.record_maintain_spawn(
1329
+ repo_slug=pr.full_name,
1330
+ number=pr.number,
1331
+ attempt=attempt,
1332
+ session=name,
1333
+ )
1334
+
1335
+ return Decision(
1336
+ Action.SPAWNED,
1337
+ f"maintenance session {name} (label {self.config.maintain_label!r} "
1338
+ f"stays until the session removes it)",
1339
+ attempt,
1340
+ )
1341
+
1342
+ def _escalate_maintain_cap(
1343
+ self, pr: PullRequest, attempts: int, kind: str
1344
+ ) -> None:
1345
+ """Operator page for one maintenance request's cap-out, posted ON
1346
+ the PR (the fix edge's _escalate_fix_cap shape): log, comment
1347
+ (dry-run gated), and record the epoch-folded dedupe row (`kind` is
1348
+ maintain_cap_kind's output) even if the comment fails -- a
1349
+ terminal-state page, same trade as cap/assignment/fix-cap."""
1350
+ body = MAINTAIN_CAP_COMMENT.format(
1351
+ attempts=attempts,
1352
+ cap=self.config.attempt_cap,
1353
+ label=self.config.maintain_label,
1354
+ )
1355
+ log.error(
1356
+ "MAINTAIN CAP-OUT %s after %d maintenance attempt(s) with the %r "
1357
+ "label still present — escalating to operator on the PR",
1358
+ pr.pr_slug,
1359
+ attempts,
1360
+ self.config.maintain_label,
1361
+ )
1362
+
1363
+ if self.config.dry_run:
1364
+ log.info("[dry-run] would comment on %s:\n%s", pr.pr_slug, body)
1365
+ return
1366
+
1367
+ try:
1368
+ self.github.comment(pr.owner, pr.repo, pr.number, body)
1369
+ except CommandError as exc:
1370
+ log.error(
1371
+ "could not post the maintain cap-out comment on %s: %s",
1372
+ pr.pr_slug,
1373
+ exc,
1374
+ )
1375
+ self.state.record_escalation(pr.full_name, pr.number, kind)
1376
+
1065
1377
  def _ensure_hub(self, owner: str, repo: str) -> tuple[Path, str | None]:
1066
1378
  """Resolve a spawn's cwd (the hub ROOT), hub-ifying the repo first if
1067
- configured. Shared by both edges (issue spawns and fix spawns), so it
1068
- takes the bare owner/repo. Returns (hub, problem); `problem` is
1069
- non-None when the spawn cannot run."""
1379
+ configured. Shared by all three edges (issue, fix, and maintenance
1380
+ spawns), so it takes the bare owner/repo. Returns (hub, problem);
1381
+ `problem` is non-None when the spawn cannot run."""
1070
1382
  full_name = f"{owner}/{repo}"
1071
1383
  hub = self.config.hub_for(owner, repo)
1072
1384
  if hub.is_dir():
@@ -1280,16 +1592,21 @@ class DevWatcher:
1280
1592
  def poll_once(self) -> list[tuple[str, Decision]]:
1281
1593
  """One pass: the reaper sweep FIRST (unconditional and
1282
1594
  search-independent — a broken or empty search must never starve
1283
- it), then both edges: labeled issues (the primary trigger), then —
1284
- behind the fix_rounds_enabled gate — the review-response edge over
1285
- open PRs authored by the token. One Decision list covers both
1286
- edges; the slug shape tells them apart in the logs (`owner/repo#7`
1287
- vs `owner/repo#pr16`). The pass ends with one INFO summary line so
1595
+ it), then the three edges in order: labeled issues (the primary
1596
+ trigger), then — behind the fix_rounds_enabled gate — the
1597
+ review-response edge over open PRs authored by the token, then
1598
+ behind the maintain_enabled gate the maintenance edge over PRs
1599
+ the operator labeled. One Decision list covers all edges (the INFO
1600
+ summary counts them together); the slug shape tells issues from PRs
1601
+ in the logs (`owner/repo#7` vs `owner/repo#pr16`), and the decision
1602
+ reason names the edge. The pass ends with one INFO summary line so
1288
1603
  unattended logs stay legible without -v."""
1289
1604
  reaped = self.reap_finished()
1290
1605
  results = self._poll_issues()
1291
1606
  if self.config.fix_rounds_enabled:
1292
1607
  results.extend(self._poll_prs())
1608
+ if self.config.maintain_enabled:
1609
+ results.extend(self._poll_maintain())
1293
1610
 
1294
1611
  counts = Counter(decision.action for _, decision in results)
1295
1612
  log.info(
@@ -1388,6 +1705,42 @@ class DevWatcher:
1388
1705
  results.append((slug, decision))
1389
1706
  return results
1390
1707
 
1708
+ def _poll_maintain(self) -> list[tuple[str, Decision]]:
1709
+ found = self.github.search_maintain_prs(
1710
+ self.config.maintain_label, self.config.repos
1711
+ )
1712
+ # "candidates" like the other edges: every labeled PR comes back
1713
+ # each poll -- the label is the in-flight state by design (the
1714
+ # ledger, not the label, dedupes), so in-flight work is re-seen
1715
+ # until its session removes the label.
1716
+ log.info(
1717
+ "%d candidate PR(s) labeled %r (in-flight maintenance included "
1718
+ "by design — the session removes the label on completion)",
1719
+ len(found),
1720
+ self.config.maintain_label,
1721
+ )
1722
+
1723
+ results: list[tuple[str, Decision]] = []
1724
+ for owner, repo, number in found:
1725
+ slug = f"{owner}/{repo}#pr{number}"
1726
+ # Defense in depth, same as the other edges: the search is
1727
+ # already repo-scoped, but nothing outside the allowlist is
1728
+ # evaluated.
1729
+ if not self.config.watches(f"{owner}/{repo}"):
1730
+ continue
1731
+ try:
1732
+ decision = self.evaluate_maintain(owner, repo, number)
1733
+ except RateLimited:
1734
+ raise
1735
+ except CommandError as exc:
1736
+ log.error("%s: %s", slug, exc)
1737
+ decision = Decision(Action.SKIPPED, str(exc))
1738
+
1739
+ level = logging.INFO if decision.action != Action.SKIPPED else logging.DEBUG
1740
+ log.log(level, "%s → %s (%s)", slug, decision.action.value, decision.reason)
1741
+ results.append((slug, decision))
1742
+ return results
1743
+
1391
1744
  def run_forever(self) -> None:
1392
1745
  # preflight() is the caller's responsibility -- the CLI runs it once
1393
1746
  # for every mode, so calling it here too would double every check.
@@ -16,6 +16,23 @@ leaves every existing ledger untouched. Fix-round cap-outs reuse the
16
16
  prefixed (`fix-cap:r<k>`, see loop.fix_cap_kind) so an issue and a PR sharing
17
17
  a number can never collide on a row.
18
18
 
19
+ The maintenance edge keeps its own additive table too, `maintain_spawns`,
20
+ keyed (repo_slug, number, attempt): `number` is the PULL REQUEST number and
21
+ `attempt` retries a presumed-dead maintenance session while the operator's
22
+ label stays on the PR. No `round` column -- the edge has no review rounds:
23
+ the label is the whole trigger, applied and RE-applied by the operator, and
24
+ completion is the SESSION removing it -- a GitHub-side event this ledger
25
+ never observes, so rows are never deleted. Instead the count/age readers
26
+ take a `since` floor: the loop passes the newest `labeled` event's
27
+ timestamp, so each re-label starts a fresh attempt budget while the
28
+ lifetime rows stay put (maintain_max_attempt keeps session names monotonic
29
+ across those epochs -- a fresh budget must never reuse an old attempt
30
+ number, or the deterministic session name could collide with a zombie).
31
+ Cap-outs reuse the `escalations` table under `maintain-cap` kinds
32
+ (prefixed, so a PR can never collide with an issue-edge `cap` row when
33
+ they share a number; the epoch timestamp folds into the kind so each
34
+ request's cap-out pages the operator again -- see loop.maintain_cap_kind).
35
+
19
36
  Escalations are keyed per KIND ("cap", "assignment", "stalled", ...): each
20
37
  condition has its own operator remedy (raise attempt_cap; grant push access;
21
38
  check the developer session / close its PR), so each kind's comment must
@@ -62,6 +79,15 @@ CREATE TABLE IF NOT EXISTS fix_spawns (
62
79
  spawned_at INTEGER NOT NULL,
63
80
  PRIMARY KEY (repo_slug, number, round, attempt)
64
81
  );
82
+
83
+ CREATE TABLE IF NOT EXISTS maintain_spawns (
84
+ repo_slug TEXT NOT NULL,
85
+ number INTEGER NOT NULL,
86
+ attempt INTEGER NOT NULL,
87
+ session TEXT NOT NULL,
88
+ spawned_at INTEGER NOT NULL,
89
+ PRIMARY KEY (repo_slug, number, attempt)
90
+ );
65
91
  """
66
92
 
67
93
 
@@ -147,6 +173,64 @@ class State:
147
173
  ).fetchone()
148
174
  return None if row["ts"] is None else max(0.0, time.time() - row["ts"])
149
175
 
176
+ def record_maintain_spawn(
177
+ self, *, repo_slug: str, number: int, attempt: int, session: str
178
+ ) -> None:
179
+ """Record a maintenance-session spawn. `repo_slug` is `owner/repo`;
180
+ `number` is the PR number."""
181
+ self._db.execute(
182
+ "INSERT OR REPLACE INTO maintain_spawns "
183
+ "(repo_slug, number, attempt, session, spawned_at) "
184
+ "VALUES (?,?,?,?,?)",
185
+ (repo_slug, number, attempt, session, int(time.time())),
186
+ )
187
+ self._db.commit()
188
+
189
+ def maintain_attempt_count(
190
+ self, repo_slug: str, number: int, since: int = 0
191
+ ) -> int:
192
+ """Maintenance attempts for this PR spawned at or after `since` (unix
193
+ seconds). The default floor of 0 counts the PR's lifetime; the loop
194
+ passes the newest `labeled` event's timestamp so the budget covers
195
+ only the CURRENT maintenance request (completion never deletes
196
+ rows -- see the module docstring)."""
197
+ row = self._db.execute(
198
+ "SELECT COUNT(*) AS n FROM maintain_spawns "
199
+ "WHERE repo_slug=? AND number=? AND spawned_at>=?",
200
+ (repo_slug, number, since),
201
+ ).fetchone()
202
+ return int(row["n"])
203
+
204
+ def maintain_spawn_age(
205
+ self, repo_slug: str, number: int, since: int = 0
206
+ ) -> float | None:
207
+ """Seconds since the NEWEST maintenance spawn at or after `since` for
208
+ this PR, or None if none exists there. Newest, and clamped at 0.0,
209
+ for the same reasons as spawn_age: a retry resets the staleness
210
+ clock, and a wall-clock step backwards must read as "just spawned",
211
+ never as a negative age. With the epoch floor, spawns from a PRIOR
212
+ request read as None -- a fresh re-label must not inherit a
213
+ weeks-old "stale" age (and the presumed-dead warning behind it)."""
214
+ row = self._db.execute(
215
+ "SELECT MAX(spawned_at) AS ts FROM maintain_spawns "
216
+ "WHERE repo_slug=? AND number=? AND spawned_at>=?",
217
+ (repo_slug, number, since),
218
+ ).fetchone()
219
+ return None if row["ts"] is None else max(0.0, time.time() - row["ts"])
220
+
221
+ def maintain_max_attempt(self, repo_slug: str, number: int) -> int:
222
+ """The highest attempt number ever spawned for this PR (0 when
223
+ none) -- the LIFETIME horizon for numbering the next attempt.
224
+ Deliberately unfiltered: a fresh epoch resets the budget, not the
225
+ numbering, because the deterministic session name folds the attempt
226
+ number in and an old epoch's session can still be idling in tmux."""
227
+ row = self._db.execute(
228
+ "SELECT MAX(attempt) AS m FROM maintain_spawns "
229
+ "WHERE repo_slug=? AND number=?",
230
+ (repo_slug, number),
231
+ ).fetchone()
232
+ return int(row["m"] or 0)
233
+
150
234
  def spawn_for_session(self, session: str) -> "sqlite3.Row | None":
151
235
  """The spawn row behind a developer session name, or None when the
152
236
  name is not on THIS ledger. Session names are deterministic per
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera