alissa-tools-github-devloop 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,620 @@
1
+ """The watcher loop.
2
+
3
+ One pass = poll GitHub for open issues carrying the trigger label, decide per
4
+ issue whether a fresh developer session is owed, and enqueue it. The search
5
+ deliberately returns assigned issues too: the daemon must re-see its own
6
+ in-flight work to age it, retry a dead session, and escalate a cap-out.
7
+ GitHub assignment is the server-side in-flight marker (the daemon
8
+ self-assigns BEFORE spawning); assignment-based filtering happens in
9
+ evaluate() via the issue() re-fetch -- a foreign assignee is skipped, a
10
+ fresh self-spawn is in flight. (poll_once pre-skips issues whose search
11
+ payload already shows a foreign assignee, sparing the re-fetch; the
12
+ authoritative re-fetch guard covers everything the daemon might act on.)
13
+ Staleness is a two-signal rule: a spawn is presumed dead only when it is
14
+ older than stale_minutes AND the issue has no linked open PR -- elapsed
15
+ time alone cannot tell a dead session from a live implement→PR→review
16
+ cycle that outlasts the timer. The linked-PR deferral has a floor: once it
17
+ outlasts STALLED_DEFER_MULTIPLE stale windows, a "stalled" operator comment
18
+ is posted -- once per deferral episode (per attempt), so a later attempt
19
+ dying behind its own open PR pings again -- while the loop keeps
20
+ deferring: a session that died with its draft PR open must not be silent
21
+ forever. The local
22
+ ledger only counts attempts, ages the newest spawn so a dead session can
23
+ be retried, and remembers which escalation kinds were already raised.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ import re
30
+ import time
31
+ from dataclasses import dataclass
32
+ from enum import Enum
33
+ from pathlib import Path
34
+
35
+ from .alissa import Alissa
36
+ from .config import HUB_ADD, ON_MISSING_SKIP, ON_MISSING_WARN, Config
37
+ from .ghclient import AssignmentRejected, GitHub, Issue, RateLimited, parse_task_ref
38
+ from .proc import CommandError
39
+ from .state import State
40
+
41
+ log = logging.getLogger(__name__)
42
+
43
+ # The origin-task instruction is split in two because the downstream rule is
44
+ # the same either way -- only the starting point differs: a present ref is
45
+ # authoritative; an absent one (spawn_anyway / warn_and_spawn) makes locating
46
+ # or creating the origin the developer's first job.
47
+ _TASK_WITH_ORIGIN = (
48
+ "The origin Alissa task is {task_ref} — before writing code, create your "
49
+ "implementation task DOWNSTREAM of it (create_downstream_task), and keep "
50
+ "its status honest: in_progress while you work, pending_validation at "
51
+ "handoff."
52
+ )
53
+
54
+ _TASK_WITHOUT_ORIGIN = (
55
+ "The issue carries NO origin Alissa task ref — locate the origin task "
56
+ "behind this issue, or create one, then create your implementation task "
57
+ "DOWNSTREAM of it before writing code, and keep its status honest: "
58
+ "in_progress while you work, pending_validation at handoff."
59
+ )
60
+
61
+ DEV_DIRECTIVE = (
62
+ "You are an IMPLEMENTER, not a reviewer. Implement GitHub issue "
63
+ "{issue_url} (attempt {attempt} of {cap}) — the issue is your "
64
+ "self-contained spec and its acceptance criteria are authoritative. "
65
+ "{task_instruction} "
66
+ "Golden path: your cwd is the repo's worktree hub ROOT — create a task "
67
+ "worktree with `git worktree add TASK-<n>-<DESC>` from there (one task = "
68
+ "one branch = one worktree; never clone, never work directly on main). "
69
+ "Implement to the issue's acceptance criteria and VERIFY locally (run the "
70
+ "repo's test/style/type scripts) before publishing. Open a DRAFT PR whose "
71
+ "body contains `Closes #{number}` and the task ref, and attach the PR URL "
72
+ "to the task as evidence. Then drive the review handoff with the "
73
+ "alissa-pr-review skill, requesting reviewer(s): {reviewers}. In every "
74
+ "review round the ORDER is load-bearing: post your CR8 triage replies "
75
+ "FIRST, then push the fixes and re-request review — the review daemon "
76
+ "re-enters on head movement, so pushing before the triage lands orphans "
77
+ "your replies. NEVER merge, never push to main, and do NOT create further "
78
+ "ali-* sessions — this loop is depth-1."
79
+ )
80
+
81
+ ESCALATION_COMMENT = (
82
+ "**Dev loop cap-out** — {attempts} developer attempt(s) were spawned on "
83
+ "this issue without it converging (attempt cap: {cap}). The daemon will "
84
+ "not spawn another; this needs an operator decision: finish the work by "
85
+ "hand, raise `attempt_cap` to re-enter the loop, or remove the "
86
+ "`{label}` label to park the issue."
87
+ )
88
+
89
+ STALLED_COMMENT = (
90
+ "**Dev loop stalled?** — attempt {attempts} has been in flight "
91
+ "{minutes} min; PR {urls} is still open — is that session alive? The "
92
+ "daemon keeps deferring the retry while the PR stays open. Operator "
93
+ "options: check (and if dead, kill) the developer session and close the "
94
+ "PR to let the retry proceed, or finish the work by hand. Unassigning "
95
+ "the issue alone is not enough — the daemon's ledger keeps aging this "
96
+ "attempt while the PR is open."
97
+ )
98
+
99
+ ASSIGNMENT_REJECTED_COMMENT = (
100
+ "**Dev loop cannot start** — the daemon tried to self-assign this issue "
101
+ "as its in-flight marker, but GitHub silently dropped the assignment: "
102
+ "`{login}` most likely lacks push access on `{full_name}`. Grant the "
103
+ "account push access, or remove the repo from the daemon's allowlist; "
104
+ "until then no developer session will be spawned for this issue."
105
+ )
106
+
107
+
108
+ # Escalation-ledger kinds. Cap-out, assignment-rejection, and a stalled
109
+ # linked-PR deferral have different operator remedies (raise attempt_cap vs
110
+ # grant push access vs check the session / close the PR), so each kind
111
+ # dedupes its operator comment independently -- one must never silence
112
+ # another. Cap and assignment dedupe once-ever per issue: a cap-out is
113
+ # terminal and a push-access failure is permanent. A stall is neither --
114
+ # see stalled_kind() below.
115
+ ESCALATION_CAP = "cap"
116
+ ESCALATION_ASSIGNMENT = "assignment"
117
+ ESCALATION_STALLED = "stalled"
118
+
119
+
120
+ def stalled_kind(attempt: int) -> str:
121
+ """The ledger kind for the stalled ping of ONE deferral episode.
122
+
123
+ Unlike cap/assignment, a stall can recur: every attempt can die behind
124
+ its own open PR, and attempt k's ping must not silence attempt k+1's --
125
+ keyed on the bare kind, the TERMINAL attempt stalling would defer
126
+ silently forever, with the cap-out unreachable behind the deferral.
127
+ `kind` is free-form TEXT, so the attempt number folds into the key (no
128
+ schema change): one ping per (issue, attempt). A side benefit: the
129
+ record-even-if-the-comment-fails contract now costs at most one lost
130
+ ping per EPISODE, where a bare-kind row would have silenced the issue
131
+ permanently after a single transient comment failure.
132
+ """
133
+ return f"{ESCALATION_STALLED}:a{attempt}"
134
+
135
+
136
+ # The floor under the linked-PR deferral: an open linked PR defers the stale
137
+ # retry indefinitely -- correct for a live review loop, silent forever for a
138
+ # session that died with its draft PR open (the deferral sits ABOVE the cap
139
+ # check, so even ESCALATION_COMMENT is unreachable while the PR stays open).
140
+ # Once the newest spawn's age reaches this multiple of stale_minutes, the
141
+ # loop posts one "stalled" operator comment per deferral episode and keeps
142
+ # deferring. 2 means the
143
+ # deferral itself has lasted a full extra stale window beyond the point the
144
+ # timer first fired -- long enough that a healthy review loop has usually
145
+ # converged, early enough that a dead session surfaces the same day.
146
+ STALLED_DEFER_MULTIPLE = 2
147
+
148
+
149
+ class Action(str, Enum):
150
+ SPAWNED = "spawned"
151
+ IN_FLIGHT = "in-flight"
152
+ CAPPED = "capped"
153
+ ESCALATED = "escalated"
154
+ SKIPPED = "skipped"
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class Decision:
159
+ action: Action
160
+ reason: str = ""
161
+ attempt: int | None = None
162
+
163
+
164
+ def session_name(issue: Issue, attempt: int) -> str:
165
+ """A tmux-safe developer session name:
166
+ `develop-<owner>-<repo>-i<n>-a<attempt>`.
167
+
168
+ Deterministic on purpose (no nonce, unlike reviewloop): the attempt number
169
+ comes from the ledger's row count, so every spawn gets a fresh number and
170
+ the name cannot collide with a still-live session. The owner is folded in
171
+ because the attempt count is keyed per `owner/repo` -- two owners' ledgers
172
+ count independently, so `acme/widgets#7` and `other/widgets#7` can both
173
+ legitimately sit at attempt 1 and a repo-only name would collide.
174
+ """
175
+ owner = re.sub(r"[^A-Za-z0-9-]", "-", issue.owner).strip("-").lower()
176
+ repo = re.sub(r"[^A-Za-z0-9-]", "-", issue.repo).strip("-").lower()
177
+ return f"develop-{owner}-{repo}-i{issue.number}-a{attempt}"
178
+
179
+
180
+ class DevWatcher:
181
+ def __init__(
182
+ self,
183
+ config: Config,
184
+ github: GitHub | None = None,
185
+ alissa: Alissa | None = None,
186
+ state: State | None = None,
187
+ ):
188
+ self.config = config
189
+ self.github = github or GitHub(config.developer_login)
190
+ self.alissa = alissa or Alissa()
191
+ self.state = state or State(config.state_db)
192
+
193
+ # -- per-issue decision ------------------------------------------------
194
+
195
+ def evaluate(self, owner: str, repo: str, number: int) -> Decision:
196
+ issue = self.github.issue(owner, repo, number)
197
+ login = self.github.login
198
+
199
+ # Closed between search and fetch: nothing left to implement.
200
+ if issue.state != "open":
201
+ return Decision(Action.SKIPPED, "issue closed between search and fetch")
202
+
203
+ # The re-fetch is the consistency guard: the search deliberately
204
+ # returns assigned issues (so the daemon re-sees its own in-flight
205
+ # work), and the fetched assignment is authoritative over any search
206
+ # staleness -- a foreign assignee means someone else owns the work.
207
+ others = tuple(a for a in issue.assignees if a and a != login)
208
+ if others:
209
+ return Decision(
210
+ Action.SKIPPED,
211
+ f"assigned to {', '.join(others)} — not this daemon's to work",
212
+ )
213
+
214
+ attempts = self.state.attempt_count(issue.full_name, number)
215
+ age = self.state.spawn_age(issue.full_name, number)
216
+
217
+ if age is not None and age < self.config.stale_minutes * 60:
218
+ return Decision(
219
+ Action.IN_FLIGHT,
220
+ f"attempt {attempts} enqueued {int(age)}s ago",
221
+ attempts,
222
+ )
223
+
224
+ if login in issue.assignees and age is None:
225
+ # Self-assigned but the ledger never spawned: assigned outside this
226
+ # daemon (by hand, or under another workspace's ledger). Not a
227
+ # fresh self-spawn, so not ours to double-book.
228
+ return Decision(
229
+ Action.SKIPPED,
230
+ f"assigned to {login} outside this daemon (no spawn on the "
231
+ f"ledger) — unassign the issue to let the loop take it",
232
+ )
233
+
234
+ # Staleness needs TWO signals, not one: the ledger timer says the
235
+ # newest spawn is old, but elapsed time alone cannot tell a dead
236
+ # session from a live one -- a healthy implement → draft-PR → review
237
+ # loop routinely outlasts stale_minutes. An open PR linked to the
238
+ # issue is external evidence the work is alive, so the retry (and any
239
+ # cap-out it would feed) is deferred until that PR closes. The probe
240
+ # runs ONLY on the stale path: the fresh-in-flight return above keeps
241
+ # its per-poll cost off the common case. The deferral is floored, not
242
+ # unbounded: past STALLED_DEFER_MULTIPLE stale windows it posts a
243
+ # "stalled" operator comment (a dead session's draft PR stays open
244
+ # forever, and this deferral sits above the cap check, so no other
245
+ # escalation can reach the issue) -- and then keeps deferring. The
246
+ # ping dedupes per deferral EPISODE (stalled_kind), not per issue:
247
+ # every attempt can stall behind its own PR, including the terminal
248
+ # one.
249
+ if age is not None:
250
+ linked = self.github.linked_open_prs(issue.owner, issue.repo, number)
251
+ if linked:
252
+ stalled_after = STALLED_DEFER_MULTIPLE * self.config.stale_minutes * 60
253
+ if age >= stalled_after and not self.state.escalated(
254
+ issue.full_name, number, stalled_kind(attempts)
255
+ ):
256
+ self._escalate_stalled(issue, attempts, age, linked)
257
+ return Decision(
258
+ Action.IN_FLIGHT,
259
+ f"attempt {attempts} is stale ({int(age / 60)} min) but an "
260
+ f"open PR is linked ({', '.join(linked)}) — work is alive "
261
+ f"(the review loop can outlast stale_minutes); not "
262
+ f"respawning",
263
+ attempts,
264
+ )
265
+
266
+ # Never spawn attempt cap+1. Escalate ONCE; `escalated()` dedupes, and
267
+ # raising attempt_cap re-enters the loop without clearing the ledger.
268
+ if attempts >= self.config.attempt_cap:
269
+ if self.state.escalated(issue.full_name, number, ESCALATION_CAP):
270
+ return Decision(Action.CAPPED, "already escalated", attempts)
271
+ self._escalate(issue, attempts)
272
+ return Decision(
273
+ Action.ESCALATED,
274
+ f"{attempts} attempts, cap {self.config.attempt_cap}",
275
+ attempts,
276
+ )
277
+
278
+ if age is not None:
279
+ log.warning(
280
+ "%s attempt %d has been in flight %.0f min with no completion "
281
+ "and no linked open PR — re-enqueuing as attempt %d (developer "
282
+ "session presumed dead)",
283
+ issue.issue_slug,
284
+ attempts,
285
+ age / 60,
286
+ attempts + 1,
287
+ )
288
+ attempt = attempts + 1
289
+
290
+ task_number = parse_task_ref(issue.body)
291
+ if task_number is None:
292
+ if self.config.on_missing_origin_task == ON_MISSING_SKIP:
293
+ return Decision(
294
+ Action.SKIPPED,
295
+ "no origin Alissa task ref on the issue body "
296
+ "(on_missing_origin_task=skip)",
297
+ attempt,
298
+ )
299
+ if self.config.on_missing_origin_task == ON_MISSING_WARN:
300
+ log.warning(
301
+ "%s has no origin Alissa task ref — spawning anyway; the "
302
+ "developer must locate or create the origin task first",
303
+ issue.issue_slug,
304
+ )
305
+
306
+ return self._spawn(issue, attempt, task_number)
307
+
308
+ # -- actions -----------------------------------------------------------
309
+
310
+ def _spawn(self, issue: Issue, attempt: int, task_number: int | None) -> Decision:
311
+ hub, problem = self._ensure_hub(issue)
312
+ if problem is not None:
313
+ return Decision(Action.SKIPPED, problem, attempt)
314
+
315
+ task_ref = f"TASK-{task_number}" if task_number is not None else None
316
+ task_instruction = (
317
+ _TASK_WITH_ORIGIN.format(task_ref=task_ref)
318
+ if task_ref is not None
319
+ else _TASK_WITHOUT_ORIGIN
320
+ )
321
+ reviewers = ", ".join(self.config.reviewers) or (
322
+ "none resolved — follow the alissa-pr-review skill's reviewer "
323
+ "resolution"
324
+ )
325
+ directive = DEV_DIRECTIVE.format(
326
+ issue_url=issue.url,
327
+ number=issue.number,
328
+ attempt=attempt,
329
+ cap=self.config.attempt_cap,
330
+ task_instruction=task_instruction,
331
+ reviewers=reviewers,
332
+ )
333
+ name = session_name(issue, attempt)
334
+
335
+ # assign_self FIRST: the assignment is the server-side in-flight
336
+ # marker, so it must land before anything is enqueued -- a crash
337
+ # between the two leaves an assigned-but-sessionless issue (safe:
338
+ # evaluate() sees a self-assignment with no ledger spawn and skips;
339
+ # the operator unassigns to retry), never an unmarked session racing
340
+ # a second spawn.
341
+ if self.config.dry_run:
342
+ log.info(
343
+ "[dry-run] would assign %s to %s", self.github.login, issue.issue_slug
344
+ )
345
+ else:
346
+ try:
347
+ self.github.assign_self(issue.owner, issue.repo, issue.number)
348
+ except AssignmentRejected as exc:
349
+ return self._escalate_rejection(issue, exc)
350
+
351
+ self.alissa.enqueue_developer(
352
+ session=name,
353
+ directive=directive,
354
+ cwd=hub,
355
+ agent=self.config.agent_profile,
356
+ dry_run=self.config.dry_run,
357
+ )
358
+
359
+ if not self.config.dry_run:
360
+ self.state.record_spawn(
361
+ repo_slug=issue.full_name,
362
+ issue=issue.number,
363
+ attempt=attempt,
364
+ session=name,
365
+ )
366
+
367
+ return Decision(
368
+ Action.SPAWNED,
369
+ f"session {name} → {task_ref or 'no origin task'}",
370
+ attempt,
371
+ )
372
+
373
+ def _escalate_rejection(self, issue: Issue, exc: AssignmentRejected) -> Decision:
374
+ """Escalate-don't-retry: a silently dropped assignment is a permanent,
375
+ operator-fixable condition (push access), and without the in-flight
376
+ marker the daemon would respawn a developer against the issue every
377
+ cycle. No spawn record, no enqueue -- comment once and stop."""
378
+ log.error("%s: assignment rejected — %s", issue.issue_slug, exc)
379
+ if self.state.escalated(issue.full_name, issue.number, ESCALATION_ASSIGNMENT):
380
+ return Decision(Action.CAPPED, "assignment rejected; already escalated")
381
+
382
+ body = ASSIGNMENT_REJECTED_COMMENT.format(
383
+ login=self.github.login, full_name=issue.full_name
384
+ )
385
+ try:
386
+ self.github.comment(issue.owner, issue.repo, issue.number, body)
387
+ except CommandError as comment_exc:
388
+ log.error(
389
+ "could not post the assignment-rejected comment on %s: %s",
390
+ issue.issue_slug,
391
+ comment_exc,
392
+ )
393
+ self.state.record_escalation(
394
+ issue.full_name, issue.number, ESCALATION_ASSIGNMENT
395
+ )
396
+ return Decision(
397
+ Action.ESCALATED,
398
+ "assignment rejected — the account likely lacks push access",
399
+ )
400
+
401
+ def _escalate_stalled(
402
+ self, issue: Issue, attempts: int, age: float, linked: list[str]
403
+ ) -> None:
404
+ """Operator ping when the linked-PR deferral itself runs long: the PR
405
+ being open is the only thing holding the retry (and the cap-out)
406
+ back, so a human must check whether the session behind it is still
407
+ alive. Posted once per deferral EPISODE (the ledger row is keyed
408
+ stalled_kind(attempts), so a later attempt stalling pings again).
409
+ The decision stays IN_FLIGHT either way -- this comments, it never
410
+ respawns."""
411
+ body = STALLED_COMMENT.format(
412
+ attempts=attempts, minutes=int(age / 60), urls=", ".join(linked)
413
+ )
414
+ log.warning(
415
+ "STALLED %s attempt %d has been deferred %.0f min behind open "
416
+ "PR(s) %s — escalating to operator (once per attempt)",
417
+ issue.issue_slug,
418
+ attempts,
419
+ age / 60,
420
+ ", ".join(linked),
421
+ )
422
+
423
+ if self.config.dry_run:
424
+ log.info("[dry-run] would comment on %s:\n%s", issue.issue_slug, body)
425
+ return
426
+
427
+ try:
428
+ self.github.comment(issue.owner, issue.repo, issue.number, body)
429
+ except CommandError as exc:
430
+ log.error(
431
+ "could not post the stalled-deferral comment on %s: %s",
432
+ issue.issue_slug,
433
+ exc,
434
+ )
435
+ self.state.record_escalation(
436
+ issue.full_name, issue.number, stalled_kind(attempts)
437
+ )
438
+
439
+ def _escalate(self, issue: Issue, attempts: int) -> None:
440
+ body = ESCALATION_COMMENT.format(
441
+ attempts=attempts, cap=self.config.attempt_cap, label=self.config.label
442
+ )
443
+ log.error(
444
+ "CAP-OUT %s after %d attempt(s) — escalating to operator",
445
+ issue.issue_slug,
446
+ attempts,
447
+ )
448
+
449
+ if self.config.dry_run:
450
+ log.info("[dry-run] would comment on %s:\n%s", issue.issue_slug, body)
451
+ return
452
+
453
+ try:
454
+ self.github.comment(issue.owner, issue.repo, issue.number, body)
455
+ except CommandError as exc:
456
+ log.error(
457
+ "could not post escalation comment on %s: %s", issue.issue_slug, exc
458
+ )
459
+ self.state.record_escalation(issue.full_name, issue.number, ESCALATION_CAP)
460
+
461
+ def _ensure_hub(self, issue: Issue) -> tuple[Path, str | None]:
462
+ """Resolve the developer's cwd (the hub ROOT), hub-ifying the repo
463
+ first if configured. Returns (hub, problem); `problem` is non-None
464
+ when the spawn cannot run."""
465
+ hub = self.config.hub_for(issue.owner, issue.repo)
466
+ if hub.is_dir():
467
+ return hub, None
468
+
469
+ if self.config.on_missing_hub != HUB_ADD:
470
+ return hub, (
471
+ f"no worktree hub at {hub} — add the repo with "
472
+ f"`alissa code workspace add {issue.full_name}`, or set "
473
+ f"on_missing_hub='add' (requires a repos allowlist)"
474
+ )
475
+
476
+ # Guarded twice: config.build() rejects 'add' without an allowlist,
477
+ # and poll_once() only reaches here for watched repos. Belt and
478
+ # braces -- this path clones code onto the machine and opens it as an
479
+ # agent cwd.
480
+ if not self.config.watches(issue.full_name):
481
+ return hub, f"{issue.full_name} is not in the repos allowlist"
482
+
483
+ if not self.config.manifest_path.is_file():
484
+ return hub, (
485
+ f"{self.config.workspace_root} is not an Alissa Code Workspace "
486
+ f"(no alissa-workspace.yaml) — run `alissa code workspace init`"
487
+ )
488
+
489
+ try:
490
+ self.alissa.add_repo_to_workspace(
491
+ issue.owner,
492
+ issue.repo,
493
+ self.config.workspace_root,
494
+ dry_run=self.config.dry_run,
495
+ )
496
+ except CommandError as exc:
497
+ return hub, f"could not hub-ify {issue.full_name}: {exc}"
498
+
499
+ if self.config.dry_run:
500
+ return hub, None
501
+ if not hub.is_dir():
502
+ return hub, (
503
+ f"`alissa code workspace add {issue.full_name}` reported "
504
+ f"success but {hub} still does not exist — check hub_template "
505
+ f"against the manifest's `dir:` override"
506
+ )
507
+ return hub, None
508
+
509
+ # -- preflight ---------------------------------------------------------
510
+
511
+ def preflight(self) -> list[str]:
512
+ """Startup checks. Returns warnings; raises on anything fatal."""
513
+ warnings: list[str] = []
514
+
515
+ # Fatal: a mismatched identity would search one account's issues and
516
+ # assign / push branches as another, so the in-flight marker breaks.
517
+ # (The CLI logs the resolved identity via log_effective_config.)
518
+ login = self.github.verify_identity()
519
+
520
+ for reviewer in self.config.reviewers:
521
+ if reviewer == login:
522
+ warnings.append(
523
+ f"reviewer {reviewer!r} is the developer identity — GitHub "
524
+ f"refuses review requests from the PR author, so this "
525
+ f"reviewer will never be requested"
526
+ )
527
+
528
+ if not self.config.workspace_root.is_dir():
529
+ warnings.append(
530
+ f"workspace_root {self.config.workspace_root} does not exist"
531
+ )
532
+ elif not self.config.manifest_path.is_file():
533
+ warnings.append(
534
+ f"{self.config.workspace_root} has no alissa-workspace.yaml — "
535
+ f"it is not an Alissa Code Workspace yet "
536
+ f"(`alissa code workspace init`)"
537
+ )
538
+
539
+ if not self.config.dry_run and not self.alissa.worker_running():
540
+ warnings.append(
541
+ "`alissa worker` does not appear to be running — queued "
542
+ "developer sessions will not spawn until it is "
543
+ "(`alissa worker start`)"
544
+ )
545
+
546
+ return warnings
547
+
548
+ # -- polling -----------------------------------------------------------
549
+
550
+ def poll_once(self) -> list[tuple[str, Decision]]:
551
+ found = self.github.search_issues(self.config.label, self.config.repos)
552
+ log.info(
553
+ "%d open issue(s) labeled %r awaiting a developer",
554
+ len(found),
555
+ self.config.label,
556
+ )
557
+
558
+ results = []
559
+ for owner, repo, number, assignees in found:
560
+ slug = f"{owner}/{repo}#{number}"
561
+ # Defense in depth: the search is already repo-scoped, but nothing
562
+ # outside the allowlist is ever evaluated.
563
+ if not self.config.watches(f"{owner}/{repo}"):
564
+ continue
565
+ # Cheap pre-filter on the search payload's own assignee data: a
566
+ # foreign login visible in the search item means the work is
567
+ # someone else's, so the issue() re-fetch is skipped (an issue
568
+ # parked on a human would otherwise cost one re-fetch per poll,
569
+ # forever). Search data can lag the live issue; the worst case is
570
+ # a mis-skip that self-heals on a later poll, which is why ONLY
571
+ # the foreign case short-circuits -- self-assigned, unassigned,
572
+ # and assignee-data-free items all still go through evaluate()'s
573
+ # authoritative re-fetch guard.
574
+ foreign = tuple(
575
+ a for a in (assignees or ()) if a and a != self.github.login
576
+ )
577
+ if foreign:
578
+ decision = Decision(
579
+ Action.SKIPPED,
580
+ f"search already shows assignee(s) "
581
+ f"{', '.join(foreign)} — foreign-owned, skipped without "
582
+ f"the issue() re-fetch",
583
+ )
584
+ else:
585
+ try:
586
+ decision = self.evaluate(owner, repo, number)
587
+ except RateLimited:
588
+ raise
589
+ except CommandError as exc:
590
+ log.error("%s: %s", slug, exc)
591
+ decision = Decision(Action.SKIPPED, str(exc))
592
+
593
+ level = logging.INFO if decision.action != Action.SKIPPED else logging.DEBUG
594
+ log.log(level, "%s → %s (%s)", slug, decision.action.value, decision.reason)
595
+ results.append((slug, decision))
596
+ return results
597
+
598
+ def run_forever(self) -> None:
599
+ # preflight() is the caller's responsibility -- the CLI runs it once
600
+ # for every mode, so calling it here too would double every check.
601
+ backoff = self.config.poll_interval
602
+ while True:
603
+ # The sleep lives INSIDE the KeyboardInterrupt guard: with a 60s
604
+ # poll interval (up to 900s backing off) the loop spends nearly
605
+ # all its wall-clock sleeping, so a real Ctrl-C almost always
606
+ # lands there and must hit the same clean-exit path.
607
+ try:
608
+ try:
609
+ self.poll_once()
610
+ backoff = self.config.poll_interval
611
+ except RateLimited as exc:
612
+ backoff = min(backoff * 2, 900)
613
+ log.warning("rate limited (%s) — backing off %ds", exc, backoff)
614
+ except CommandError as exc:
615
+ backoff = min(backoff * 2, 900)
616
+ log.error("poll failed: %s — retrying in %ds", exc, backoff)
617
+ time.sleep(backoff)
618
+ except KeyboardInterrupt:
619
+ log.info("stopping")
620
+ return
@@ -0,0 +1,56 @@
1
+ """Subprocess helpers. Everything shells out to `gh` and `alissa` so both keep
2
+ their own auth handling; we never touch tokens ourselves."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import logging
8
+ import os
9
+ import subprocess
10
+ from typing import Sequence
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+
15
+ class CommandError(RuntimeError):
16
+ def __init__(self, argv: Sequence[str], returncode: int, stderr: str):
17
+ self.argv = list(argv)
18
+ self.returncode = returncode
19
+ self.stderr = stderr
20
+ super().__init__(f"{argv[0]} exited {returncode}: {stderr.strip()[:400]}")
21
+
22
+
23
+ def run(
24
+ argv: Sequence[str],
25
+ *,
26
+ timeout: int = 60,
27
+ check: bool = True,
28
+ cwd: "str | os.PathLike[str] | None" = None,
29
+ ) -> str:
30
+ """Run a command, return stdout. Never uses shell=True."""
31
+ log.debug("exec: %s", " ".join(argv))
32
+ try:
33
+ proc = subprocess.run(
34
+ list(argv),
35
+ capture_output=True,
36
+ text=True,
37
+ timeout=timeout,
38
+ cwd=str(cwd) if cwd is not None else None,
39
+ )
40
+ except subprocess.TimeoutExpired as exc:
41
+ raise CommandError(argv, -1, f"timed out after {timeout}s") from exc
42
+
43
+ if check and proc.returncode != 0:
44
+ raise CommandError(argv, proc.returncode, proc.stderr)
45
+ return proc.stdout
46
+
47
+
48
+ def run_json(argv: Sequence[str], *, timeout: int = 60):
49
+ """Run a command whose stdout is JSON."""
50
+ out = run(argv, timeout=timeout).strip()
51
+ if not out:
52
+ return None
53
+ try:
54
+ return json.loads(out)
55
+ except json.JSONDecodeError as exc:
56
+ raise CommandError(argv, 0, f"expected JSON, got: {out[:300]}") from exc