alissa-tools-github-genloop 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,971 @@
1
+ """The genloop seat: a mechanical daemon shaped like orcloop's sentinel edge
2
+ (design D14, §1.1). One decision line per poll, `genloop: <term>; <term>; …`.
3
+
4
+ ONE POLL, IN ORDER
5
+ ==================
6
+ 0. **Enabled.** With `ALISSA_GEN_ENABLED` unset the seat idles, logs why, and
7
+ touches nothing -- no listing, no read, no write (L9).
8
+ 1. **Ingest.** Every `<state>/passes/<stamp>.json` not yet ingested is parsed
9
+ against the directive's schema. A `plan` verdict is DELIVERED by the seat:
10
+ the plan's frontmatter is read from the pushed branch, the program's budget
11
+ re-checked, ONE PR opened (or the open one for that branch reused, so a
12
+ crash re-run never opens a second), the marker and both trailers in its
13
+ body, the reviewer requested, one `POST /v1/loop/plans` (`proposed`) and one
14
+ `plan.proposed`. Only then is the record marked ingested and moved to
15
+ `passes/ingested/`, so it is never replayed once the ledger row ages out
16
+ (a record older than the ledger window is archived unread). `idle` records
17
+ emit their `plan.skipped` lines; `amended` PRs get the reviewer re-requested;
18
+ `skill-missing` holds every further spawn until the image is fixed.
19
+ 2. **Lint watch.** A plan PR of ours whose `plan-lint` run concluded `failure`
20
+ is the generator's to amend on its next pass (D13). One still red after a
21
+ pass SPAWNED after the red was first seen has ended is `plan.invalid`: a
22
+ comment, the PR closed, the branch deleted, the plan row `rejected`.
23
+ 3. **Rejections.** A plan PR closed unmerged with a comment by an operator
24
+ login, or an operator comment opening `[do-not-propose]`, writes one
25
+ `proposalRules` row per source key and one for the anchor (§4.4, c7).
26
+ 4. **Spawn gate**, walked in the design's order with one term each --
27
+ enabled, not parked, budget, the day bound, the lane bound, no live
28
+ `ali-genloop-*` session (a failed listing holds), a worker present -- then
29
+ ONE `genloop-<stamp>` session is enqueued under the byte-pinned directive.
30
+ The clock and the `spawn:<stamp>` row are written only AFTER the enqueue
31
+ (L5: claim on spawn only); a held poll writes nothing to the ledger, and
32
+ the four named skips (budget, parked, the day bound, the lane bound) emit
33
+ one event per interval.
34
+ 5. **Stale floor.** A live pass older than `stale_intervals x interval` is
35
+ killed (only ever our own) and `pass.stalled` emitted once.
36
+ 6. **Silence floor.** A spawned pass that ended (absent from a SUCCESSFUL
37
+ listing and from the queue, past the grace) with no record is
38
+ `pass.silent`, once.
39
+
40
+ Every side effect is `dry_run`-gated.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import datetime
46
+ import json
47
+ import logging
48
+ import re
49
+ import time
50
+ from pathlib import Path
51
+ from typing import Callable, Sequence
52
+
53
+ from .alissa_cli import AlissaCli
54
+ from .alissa_client import AlissaAuthError, AlissaClient, AlissaError, AlissaTransient
55
+ from .config import BOW_PREFIX, REPOS_SOURCE_STATIC, Config
56
+ from .github import GitHub, PullRequest
57
+ from .loop_events import (
58
+ KIND_PASS_IDLE,
59
+ KIND_PASS_SILENT,
60
+ KIND_PASS_STALLED,
61
+ KIND_PLAN_INVALID,
62
+ KIND_PLAN_PROPOSED,
63
+ KIND_PLAN_SKIPPED,
64
+ LoopEventRecorder,
65
+ )
66
+ from .pass_directive import (
67
+ DIRECTIVE_SHA256,
68
+ PASSES_DIRNAME,
69
+ SESSION_PREFIX,
70
+ SESSION_TMUX_PREFIX,
71
+ STAMP_FORMAT,
72
+ VERDICT_IDLE,
73
+ VERDICT_PLAN,
74
+ VERDICT_SKILL_MISSING,
75
+ PassRecord,
76
+ PassRecordError,
77
+ parse_pass_record,
78
+ render_directive,
79
+ session_name,
80
+ )
81
+ from .plan import (
82
+ PLAN_BRANCH_RE,
83
+ PLAN_MARKER,
84
+ Plan,
85
+ PlanError,
86
+ anchor_rule_pattern,
87
+ parse_plan,
88
+ render_pr_body,
89
+ render_pr_title,
90
+ task_file_names,
91
+ )
92
+ from .proc import CommandError
93
+ from .state import LEDGER_MAX_AGE_SECONDS, State
94
+
95
+ log = logging.getLogger(__name__)
96
+
97
+ # A spawned session that left the listing this recently may still be writing.
98
+ SPAWN_GRACE_SECONDS = 120
99
+
100
+ META_LAST_SPAWN = "last_spawn"
101
+ META_LAST_SETTLED = "last_settled_spawn"
102
+ META_RED_SINCE = "red_since:" # red_since:<pr> -> epoch the lint was first seen red
103
+
104
+ # An ingested record is moved here, so ingestion outlives the ledger's
105
+ # `ingested:<stamp>` row (which ages out); archived records age out with it.
106
+ INGESTED_DIRNAME = "ingested"
107
+
108
+ SPAWN_PREFIX = "spawn:"
109
+ INGESTED_PREFIX = "ingested:"
110
+ STALLED_PREFIX = "stalled:"
111
+ SILENT_PREFIX = "silent:"
112
+ INVALID_PREFIX = "invalid:"
113
+ REJECTED_PREFIX = "rejected:"
114
+ RULE_PREFIX = "rule:"
115
+
116
+ # The named skip reasons (c6) and the event each one emits.
117
+ SKIP_BUDGET = "budget"
118
+ SKIP_PARKED = "parked"
119
+ SKIP_BOUND = "bound"
120
+ BOUND_DAY = "plans-per-day"
121
+ BOUND_LANES = "open-lanes"
122
+
123
+ DO_NOT_PROPOSE = "[do-not-propose]"
124
+ RULE_REASON_MAX = 2000
125
+ CLOSED_PLANS_PER_POLL = 30
126
+ REJECTION_WINDOW_SECONDS = 30 * 86400
127
+ # An operator comment counts as the rejection's reason only when it was
128
+ # written at the close or just before it ("closing because …", then close).
129
+ REJECTION_COMMENT_SKEW_SECONDS = 600
130
+
131
+ INVALID_COMMENT = (
132
+ "**genloop: plan.invalid** — `plan-lint` concluded `failure` on this plan and was still "
133
+ "red after the next genloop pass ended (design D14). The seat closes the PR and deletes "
134
+ "`{branch}`; the sources it carried are free to be proposed again."
135
+ )
136
+
137
+
138
+ def stamp_at(epoch: float) -> str:
139
+ return datetime.datetime.fromtimestamp(int(epoch), datetime.timezone.utc).strftime(STAMP_FORMAT)
140
+
141
+
142
+ def stamp_epoch(stamp: str) -> int | None:
143
+ try:
144
+ parsed = datetime.datetime.strptime(stamp, STAMP_FORMAT)
145
+ except ValueError:
146
+ return None
147
+ return int(parsed.replace(tzinfo=datetime.timezone.utc).timestamp())
148
+
149
+
150
+ def utc_day(epoch: float) -> str:
151
+ return datetime.datetime.fromtimestamp(int(epoch), datetime.timezone.utc).strftime("%Y-%m-%d")
152
+
153
+
154
+ def iso_epoch(text: str) -> int | None:
155
+ try:
156
+ return int(datetime.datetime.strptime(text, "%Y-%m-%dT%H:%M:%SZ")
157
+ .replace(tzinfo=datetime.timezone.utc).timestamp())
158
+ except ValueError:
159
+ return None
160
+
161
+
162
+ def session_stamp(entry: dict) -> str | None:
163
+ """The stamp of a listing row that is one of OUR sessions, else None."""
164
+ name = entry.get("name")
165
+ if isinstance(name, str) and name.startswith(SESSION_PREFIX):
166
+ return name[len(SESSION_PREFIX):]
167
+ session = entry.get("session")
168
+ if isinstance(session, str) and session.startswith(SESSION_TMUX_PREFIX):
169
+ return session[len(SESSION_TMUX_PREFIX):]
170
+ return None
171
+
172
+
173
+ def live_stamps(listing: Sequence[dict]) -> list[str]:
174
+ """Stamps of our sessions the listing shows LIVE (a row without `live`
175
+ counts as live -- the conservative reading for a double-spawn gate)."""
176
+ out = []
177
+ for entry in listing:
178
+ stamp = session_stamp(entry)
179
+ if stamp is not None and entry.get("live") is not False:
180
+ out.append(stamp)
181
+ return out
182
+
183
+
184
+ def _scope(row: dict) -> dict:
185
+ scope = row.get("scope")
186
+ return scope if isinstance(scope, dict) else {}
187
+
188
+
189
+ def fleet_row(records: Sequence[dict]) -> dict:
190
+ for row in records:
191
+ if _scope(row).get("kind") == "fleet":
192
+ return row
193
+ return {}
194
+
195
+
196
+ def program_row(records: Sequence[dict], project_id: str) -> dict:
197
+ for row in records:
198
+ scope = _scope(row)
199
+ if scope.get("kind") == "program" and scope.get("projectId") == project_id:
200
+ return row
201
+ return {}
202
+
203
+
204
+ def parse_bow_repo(title: str) -> str | None:
205
+ """`autodev: owner/repo` -> `owner/repo`."""
206
+ if not title.lower().startswith(BOW_PREFIX):
207
+ return None
208
+ repo = title[len(BOW_PREFIX):].strip()
209
+ return repo if re.match(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", repo) else None
210
+
211
+
212
+ def is_plan_pr(pr: PullRequest, login: str) -> bool:
213
+ """Ours: authored by the seat's login, on a `PLAN-` branch, with the marker
214
+ on a line of its own."""
215
+ return (
216
+ pr.author == login
217
+ and bool(PLAN_BRANCH_RE.match(pr.head_ref))
218
+ and any(line.strip() == PLAN_MARKER for line in pr.body.split("\n"))
219
+ )
220
+
221
+
222
+ class GenLoop:
223
+ def __init__(
224
+ self,
225
+ config: Config,
226
+ *,
227
+ state: State,
228
+ alissa: AlissaClient,
229
+ github: GitHub,
230
+ cli: AlissaCli,
231
+ events: LoopEventRecorder,
232
+ now: Callable[[], float] | None = None,
233
+ ):
234
+ self.config = config
235
+ self.state = state
236
+ self.alissa = alissa
237
+ self.github = github
238
+ self.cli = cli
239
+ self.events = events
240
+ self._now = now or time.time
241
+ self._login: str | None = config.login or None
242
+ # A `skill-missing` record holds every spawn for the life of the
243
+ # process: installing the skill means a new image, i.e. a restart.
244
+ self._skill_missing: str | None = None
245
+ self._rules_refused_logged = False
246
+ # One plan-PR listing per state per poll (the lint watch and the
247
+ # rejection step both read the open ones).
248
+ self._pulls: dict[str, list[PullRequest] | None] = {}
249
+
250
+ # -- paths -------------------------------------------------------------
251
+
252
+ @property
253
+ def passes_dir(self) -> Path:
254
+ return self.config.state_path / PASSES_DIRNAME
255
+
256
+ def record_path(self, stamp: str) -> Path:
257
+ return self.passes_dir / f"{stamp}.json"
258
+
259
+ @property
260
+ def archive_dir(self) -> Path:
261
+ return self.passes_dir / INGESTED_DIRNAME
262
+
263
+ @property
264
+ def repo(self) -> str:
265
+ return self.config.plans_repository
266
+
267
+ # -- the poll ----------------------------------------------------------
268
+
269
+ def poll(self) -> str:
270
+ if not self.config.enabled:
271
+ log.info(
272
+ "genloop: idle — ALISSA_GEN_ENABLED is not set (the seat is off by default, design "
273
+ "L9); set ALISSA_GEN_ENABLED=true to run passes"
274
+ )
275
+ return "disabled(ALISSA_GEN_ENABLED unset)"
276
+ now = int(self._now())
277
+ terms: list[str] = []
278
+ self._pulls = {}
279
+ listing = self._listing()
280
+ terms.extend(self._ingest(now))
281
+ terms.extend(self._lint_watch(now))
282
+ terms.extend(self._rejections(now))
283
+ terms.append(self._spawn_gate(now, listing))
284
+ terms.extend(self._stale_floor(now, listing))
285
+ terms.extend(self._silence_floor(now, listing))
286
+ self.events.flush()
287
+ if not self.config.dry_run:
288
+ self.state.age_out(now=now)
289
+ self._prune_archive(now)
290
+ line = "; ".join(t for t in terms if t)
291
+ log.info("genloop: %s", line)
292
+ return line
293
+
294
+ def _listing(self) -> list[dict] | None:
295
+ try:
296
+ return self.cli.list_sessions()
297
+ except CommandError as exc:
298
+ log.warning("genloop: `alissa tmux ls --json` did not answer (%s) — holding", exc)
299
+ return None
300
+
301
+ def login(self) -> str | None:
302
+ if self._login is None:
303
+ try:
304
+ self._login = self.github.current_login()
305
+ except CommandError as exc:
306
+ log.warning("genloop: could not read this seat's GitHub login (%s)", exc)
307
+ return None
308
+ return self._login
309
+
310
+ def _settle(self, stamp: str) -> None:
311
+ """A pass ended (ingested, stalled or silent): the lint watch's clock."""
312
+ epoch = stamp_epoch(stamp)
313
+ if epoch is None:
314
+ return
315
+ raw = self.state.get_meta(META_LAST_SETTLED)
316
+ if raw is None or int(raw) < epoch:
317
+ self.state.set_meta(META_LAST_SETTLED, str(epoch))
318
+
319
+ # -- 1. ingest ---------------------------------------------------------
320
+
321
+ def _ingest(self, now: int) -> list[str]:
322
+ try:
323
+ files = sorted(self.passes_dir.glob("*.json"))
324
+ except OSError:
325
+ return []
326
+ terms = []
327
+ for path in files:
328
+ stamp = path.stem
329
+ epoch = stamp_epoch(stamp)
330
+ if epoch is None:
331
+ continue
332
+ if self.state.has(INGESTED_PREFIX + stamp):
333
+ # Ingested, but the move to the archive failed: retry it.
334
+ if not self.config.dry_run:
335
+ self._archive(stamp)
336
+ continue
337
+ if now - epoch > LEDGER_MAX_AGE_SECONDS:
338
+ # Older than the ledger remembers: it may have been ingested
339
+ # already, and replaying it could undo an operator's later
340
+ # decision (re-request, `proposed` over `parked`). Never replay.
341
+ log.warning("genloop: the record of %s is older than the ledger window — archived unprocessed",
342
+ session_name(stamp))
343
+ if not self.config.dry_run:
344
+ self._archive(stamp)
345
+ continue
346
+ term = self._ingest_one(stamp, path, now)
347
+ if term:
348
+ terms.append(term)
349
+ return terms
350
+
351
+ def _ingest_one(self, stamp: str, path: Path, now: int) -> str:
352
+ session = session_name(stamp)
353
+ try:
354
+ payload = json.loads(path.read_text(encoding="utf-8"))
355
+ record = parse_pass_record(payload, stamp=stamp)
356
+ except (OSError, json.JSONDecodeError, PassRecordError) as exc:
357
+ log.error("genloop: the record of %s is malformed (%s) — ingested as idle", session, exc)
358
+ self.events.record(
359
+ KIND_PASS_IDLE, at=now, dedupe_key=f"idle:{stamp}", session=session,
360
+ reason=f"malformed pass record: {exc}",
361
+ )
362
+ return self._mark_ingested(stamp, f"ingested {session} (malformed record)")
363
+
364
+ for skip in record.skipped:
365
+ self.events.record(
366
+ KIND_PLAN_SKIPPED, at=now, dedupe_key=f"skip:{stamp}:{skip.key}", session=session,
367
+ reason=skip.reason, data={"key": skip.key, "source": "pass"},
368
+ )
369
+ amended = self._rerequest_amended(record)
370
+
371
+ if record.verdict == VERDICT_SKILL_MISSING:
372
+ self._skill_missing = stamp
373
+ log.error(
374
+ "genloop: %s could not load the skill %r — no further pass is spawned until the image "
375
+ "carries it (restart after installing)", session, self.config.skill,
376
+ )
377
+ self.events.record(
378
+ KIND_PASS_IDLE, at=now, dedupe_key=f"idle:{stamp}", session=session,
379
+ reason=f"skill-missing: {self.config.skill}",
380
+ )
381
+ return self._mark_ingested(stamp, f"ingested {session} (skill-missing){amended}")
382
+ if record.verdict == VERDICT_IDLE:
383
+ self.events.record(
384
+ KIND_PASS_IDLE, at=now, dedupe_key=f"idle:{stamp}", session=session,
385
+ reason="the pass proposed no plan", data={"skipped": len(record.skipped)},
386
+ )
387
+ return self._mark_ingested(stamp, f"ingested {session} (idle){amended}")
388
+ assert record.verdict == VERDICT_PLAN
389
+ outcome = self._deliver(record, now)
390
+ if outcome.startswith("held"):
391
+ return f"{session}: {outcome}"
392
+ return self._mark_ingested(stamp, f"{session}: {outcome}{amended}")
393
+
394
+ def _mark_ingested(self, stamp: str, term: str) -> str:
395
+ if self.config.dry_run:
396
+ return term + " [dry-run]"
397
+ self.state.mark(INGESTED_PREFIX + stamp, at=int(self._now()))
398
+ self._settle(stamp)
399
+ self._archive(stamp)
400
+ return term
401
+
402
+ def _archive(self, stamp: str) -> None:
403
+ """Move an ingested record out of the glob `_ingest` reads."""
404
+ try:
405
+ self.archive_dir.mkdir(parents=True, exist_ok=True)
406
+ self.record_path(stamp).replace(self.archive_dir / f"{stamp}.json")
407
+ except OSError as exc:
408
+ log.warning("genloop: could not archive the record of %s (%s)", session_name(stamp), exc)
409
+
410
+ def _prune_archive(self, now: int) -> None:
411
+ try:
412
+ files = list(self.archive_dir.glob("*.json"))
413
+ except OSError:
414
+ return
415
+ for path in files:
416
+ epoch = stamp_epoch(path.stem)
417
+ if epoch is not None and now - epoch > LEDGER_MAX_AGE_SECONDS:
418
+ try:
419
+ path.unlink()
420
+ except OSError as exc:
421
+ log.warning("genloop: could not prune %s (%s)", path, exc)
422
+
423
+ def _rerequest_amended(self, record: PassRecord) -> str:
424
+ """The reviewer re-request for each open plan PR the pass amended --
425
+ the seat's, never the session's (c3)."""
426
+ if not record.amended:
427
+ return ""
428
+ ours = {pr.number for pr in self._our_plan_prs("open") or []}
429
+ done = []
430
+ for number in record.amended:
431
+ if number not in ours:
432
+ log.warning("genloop: amended PR #%d is not an open plan PR of ours — ignored", number)
433
+ continue
434
+ if self.config.dry_run:
435
+ log.info("[dry-run] would re-request %s on %s#%d", ",".join(self.config.reviewers), self.repo, number)
436
+ else:
437
+ try:
438
+ self.github.request_reviewers(self.repo, number, self.config.reviewers)
439
+ except CommandError as exc:
440
+ log.warning("genloop: re-request on #%d failed (%s)", number, exc)
441
+ continue
442
+ done.append(f"#{number}")
443
+ return f", re-requested {' '.join(done)}" if done else ""
444
+
445
+ def _read_plan(self, branch: str, plan_id: str) -> tuple[Plan, str]:
446
+ """The plan at the branch head, and that head's sha. Raises
447
+ `PlanError` (unreadable: terminal) or `CommandError` (retry)."""
448
+ sha = self.github.branch_sha(self.repo, branch)
449
+ if sha is None:
450
+ raise PlanError(f"branch {branch} does not exist in {self.repo}")
451
+ return self._read_plan_at(sha, plan_id), sha
452
+
453
+ def _read_plan_at(self, ref: str, plan_id: str) -> Plan:
454
+ plan_dir = f"plans/{plan_id}"
455
+ plan_md = self.github.read_file(self.repo, f"{plan_dir}/plan.md", ref)
456
+ if plan_md is None:
457
+ raise PlanError(f"{plan_dir}/plan.md is absent at {ref[:7]}")
458
+ names = self.github.list_dir(self.repo, f"{plan_dir}/tasks", ref) or []
459
+ tasks: dict[str, str] = {}
460
+ for name in task_file_names(names):
461
+ text = self.github.read_file(self.repo, f"{plan_dir}/tasks/{name}", ref)
462
+ if text is None:
463
+ raise PlanError(f"{plan_dir}/tasks/{name} vanished at {ref[:7]}")
464
+ tasks[name] = text
465
+ return parse_plan(plan_md, tasks, expect_id=plan_id)
466
+
467
+ def _program_budget(self, plan: Plan) -> tuple[bool, str]:
468
+ """(skip, why) for the plan's program (§1.7: the generator checks the
469
+ program scope it generates into); when it does not skip, `why` is the
470
+ budget line for the PR body. Raises AlissaError on a failed read."""
471
+ status = self.alissa.budget_status("program", project_id=plan.program)
472
+ state = status.get("state")
473
+ if state == "exhausted":
474
+ return True, f"program {plan.program} budget exhausted"
475
+ try:
476
+ records = self.alissa.factory_steering()
477
+ except AlissaError:
478
+ records = []
479
+ share = program_row(records, plan.program).get("budgetShare")
480
+ spent, cap = status.get("spentUsd"), status.get("capUsd")
481
+ if isinstance(share, (int, float)) and isinstance(spent, (int, float)) and isinstance(cap, (int, float)):
482
+ if spent >= share * cap:
483
+ return True, f"program {plan.program} spent ${spent:.2f} >= budgetShare {share} x cap ${cap:.2f}"
484
+ return False, f"program budget `{state}`, ${spent:.2f} of budgetShare {share} x cap ${cap:.2f}"
485
+ return False, f"program budget `{state}`"
486
+
487
+ def _deliver(self, record: PassRecord, now: int) -> str:
488
+ """Open the plan PR for a `plan` record. Returns `held(...)` to retry
489
+ next poll, or the outcome to mark ingested."""
490
+ assert record.branch is not None and record.plan_id is not None
491
+ branch, plan_id, stamp = record.branch, record.plan_id, record.stamp
492
+ session = session_name(stamp)
493
+ try:
494
+ plan, sha = self._read_plan(branch, plan_id)
495
+ except CommandError as exc:
496
+ return f"held(plan read failed: {exc})"
497
+ except PlanError as exc:
498
+ log.error("genloop: %s pushed a plan the seat cannot read (%s) — no PR", session, exc)
499
+ self.events.record(
500
+ KIND_PLAN_SKIPPED, at=now, dedupe_key=f"unreadable:{stamp}", session=session,
501
+ reason="unreadable", data={"branch": branch, "detail": str(exc)[:500]},
502
+ )
503
+ return f"plan unreadable ({exc})"
504
+ try:
505
+ skip, budget_note = self._program_budget(plan)
506
+ except AlissaError as exc:
507
+ return f"held(budget: status unavailable — {exc})"
508
+ if skip:
509
+ self.events.record(
510
+ KIND_PLAN_SKIPPED, at=now, dedupe_key=f"budget:{stamp}", session=session,
511
+ reason=SKIP_BUDGET, data={"planId": plan_id, "detail": budget_note},
512
+ )
513
+ return f"skipped(budget — {budget_note}; {branch} left unopened)"
514
+
515
+ try:
516
+ existing = self.github.pulls_for_branch(self.repo, branch)
517
+ except CommandError as exc:
518
+ return f"held(PR lookup failed: {exc})"
519
+ pr = next((p for p in existing if p.state == "open"), None)
520
+ if pr is None and existing:
521
+ # L13: a plan PR someone closed is never reopened.
522
+ return f"not reopened (#{existing[0].number} was closed)"
523
+ if pr is None:
524
+ if self.config.dry_run:
525
+ log.info("[dry-run] would open the plan PR for %s and request %s", branch,
526
+ ",".join(self.config.reviewers))
527
+ return f"would open {branch}"
528
+ try:
529
+ base = self.github.default_branch(self.repo)
530
+ pr = self.github.open_pr(
531
+ self.repo, head=branch, base=base, title=render_pr_title(plan),
532
+ body=render_pr_body(plan, branch=branch, plans_dir=f"plans/{plan_id}", budget_note=budget_note),
533
+ )
534
+ except CommandError as exc:
535
+ return f"held(PR open failed: {exc})"
536
+ log.info("genloop: opened %s#%d for %s (head %s)", self.repo, pr.number, plan_id, sha[:7])
537
+ if not self.config.dry_run:
538
+ try:
539
+ self.github.request_reviewers(self.repo, pr.number, self.config.reviewers)
540
+ except CommandError as exc:
541
+ return f"held(reviewer request on #{pr.number} failed: {exc})"
542
+ try:
543
+ self.alissa.post_loop_plan(plan.loop_plan_record(
544
+ repo=self.repo, pr_number=pr.number, status="proposed", at_ms=now * 1000,
545
+ ))
546
+ except AlissaTransient as exc:
547
+ return f"held(plan row for #{pr.number}: {exc})"
548
+ except AlissaError as exc:
549
+ log.error("genloop: POST /v1/loop/plans for #%d refused (%s) — the PR stands", pr.number, exc)
550
+ self.events.record(
551
+ KIND_PLAN_PROPOSED, at=now, dedupe_key=f"proposed:{self.repo}#{pr.number}", repo=self.repo,
552
+ pr_number=pr.number, session=session,
553
+ data={"planId": plan_id, "branch": branch, "class": plan.plan_class, "anchor": plan.anchor_ref},
554
+ )
555
+ return f"proposed #{pr.number} ({plan_id}, {plan.plan_class})"
556
+
557
+ # -- 2. lint watch -----------------------------------------------------
558
+
559
+ def _our_plan_prs(self, state: str) -> list[PullRequest] | None:
560
+ if state in self._pulls:
561
+ return self._pulls[state]
562
+ login = self.login()
563
+ if login is None:
564
+ return None
565
+ try:
566
+ pulls: list[PullRequest] | None = [
567
+ pr for pr in self.github.list_pulls(self.repo, state=state) if is_plan_pr(pr, login)
568
+ ]
569
+ except CommandError as exc:
570
+ log.warning("genloop: could not list %s plan PRs (%s)", state, exc)
571
+ pulls = None
572
+ self._pulls[state] = pulls
573
+ return pulls
574
+
575
+ def _lint_watch(self, now: int) -> list[str]:
576
+ pulls = self._our_plan_prs("open")
577
+ if pulls is None:
578
+ return ["lint watch: held(plan PR listing failed)"]
579
+ terms = []
580
+ if not self.config.dry_run:
581
+ # A red PR closed by someone else leaves its clock behind.
582
+ open_numbers = {str(pr.number) for pr in pulls}
583
+ for key in [k for k in self.state.meta if k.startswith(META_RED_SINCE)]:
584
+ if key[len(META_RED_SINCE):] not in open_numbers:
585
+ self.state.del_meta(key)
586
+ settled = int(self.state.get_meta(META_LAST_SETTLED) or 0)
587
+ for pr in pulls:
588
+ key = META_RED_SINCE + str(pr.number)
589
+ try:
590
+ run = self.github.lint_run(self.repo, pr.head_sha)
591
+ except CommandError as exc:
592
+ log.warning("genloop: could not read plan-lint on #%d (%s)", pr.number, exc)
593
+ continue
594
+ conclusion = run.get("conclusion") if run else None
595
+ if conclusion == "success":
596
+ if self.state.get_meta(key) is not None and not self.config.dry_run:
597
+ self.state.del_meta(key)
598
+ continue
599
+ if conclusion != "failure":
600
+ continue
601
+ raw = self.state.get_meta(key)
602
+ if raw is None:
603
+ if not self.config.dry_run:
604
+ self.state.set_meta(key, str(now))
605
+ terms.append(f"#{pr.number} plan-lint red — the next pass amends it")
606
+ continue
607
+ if settled <= int(raw):
608
+ continue
609
+ terms.append(self._invalidate(pr, now))
610
+ return terms
611
+
612
+ def _invalidate(self, pr: PullRequest, now: int) -> str:
613
+ if self.config.dry_run:
614
+ log.info("[dry-run] would close %s#%d as plan.invalid and delete %s", self.repo, pr.number, pr.head_ref)
615
+ return f"would invalidate #{pr.number}"
616
+ try:
617
+ self.github.comment(self.repo, pr.number, INVALID_COMMENT.format(branch=pr.head_ref))
618
+ self.github.close_pr(self.repo, pr.number)
619
+ self.github.delete_branch(self.repo, pr.head_ref)
620
+ except CommandError as exc:
621
+ return f"held(invalidate #{pr.number}: {exc})"
622
+ self.state.mark(INVALID_PREFIX + str(pr.number), at=now)
623
+ self.state.del_meta(META_RED_SINCE + str(pr.number))
624
+ self._post_status(pr, "rejected", now)
625
+ self.events.record(
626
+ KIND_PLAN_INVALID, at=now, dedupe_key=f"invalid:{self.repo}#{pr.number}", repo=self.repo,
627
+ pr_number=pr.number, reason="plan-lint still failure after the next pass",
628
+ data={"branch": pr.head_ref, "headSha": pr.head_sha},
629
+ )
630
+ return f"plan.invalid #{pr.number} (closed, {pr.head_ref} deleted)"
631
+
632
+ def _plan_of(self, pr: PullRequest) -> Plan | None:
633
+ # The LAST trailer line: the trailers close the body, and the copied
634
+ # prose above them could carry a line that merely looks like one.
635
+ matches = re.findall(r"^Alissa-Plan:[ \t]*(\S+)[ \t]*$", pr.body, re.MULTILINE)
636
+ if not matches or not pr.head_sha:
637
+ return None
638
+ try:
639
+ return self._read_plan_at(pr.head_sha, matches[-1])
640
+ except (CommandError, PlanError) as exc:
641
+ log.warning("genloop: could not read the plan of #%d (%s)", pr.number, exc)
642
+ return None
643
+
644
+ def _post_status(self, pr: PullRequest, status: str, now: int) -> None:
645
+ plan = self._plan_of(pr)
646
+ if plan is None:
647
+ return
648
+ try:
649
+ self.alissa.post_loop_plan(plan.loop_plan_record(
650
+ repo=self.repo, pr_number=pr.number, status=status, at_ms=now * 1000,
651
+ ))
652
+ except AlissaError as exc:
653
+ log.warning("genloop: plan row #%d -> %s refused (%s)", pr.number, status, exc)
654
+
655
+ # -- 3. rejections -----------------------------------------------------
656
+
657
+ def _rejections(self, now: int) -> list[str]:
658
+ if not self.config.operators:
659
+ return []
660
+ terms: list[str] = []
661
+ closed = self._our_plan_prs("closed")
662
+ for pr in (closed or [])[:CLOSED_PLANS_PER_POLL]:
663
+ if pr.merged or self.state.has(REJECTED_PREFIX + str(pr.number)) \
664
+ or self.state.has(INVALID_PREFIX + str(pr.number)):
665
+ continue
666
+ closed_at = iso_epoch(pr.closed_at)
667
+ if closed_at is None or now - closed_at > REJECTION_WINDOW_SECONDS:
668
+ continue
669
+ term = self._reject(pr, now)
670
+ if term:
671
+ terms.append(term)
672
+ for pr in self._our_plan_prs("open") or []:
673
+ terms.extend(self._do_not_propose(pr, now))
674
+ return terms
675
+
676
+ def _operator_comments(self, pr: PullRequest) -> list:
677
+ operators = {login.lower() for login in self.config.operators}
678
+ return [c for c in self.github.issue_comments(self.repo, pr.number) if c.author.lower() in operators]
679
+
680
+ def _reject(self, pr: PullRequest, now: int) -> str:
681
+ try:
682
+ comments = self._operator_comments(pr)
683
+ except CommandError as exc:
684
+ return f"held(comments of #{pr.number}: {exc})"
685
+ if self.config.dry_run:
686
+ return f"would record the rejection of #{pr.number}"
687
+ reason_comment = self._closing_comment(pr, comments)
688
+ if reason_comment is None or not reason_comment.body.strip():
689
+ log.info("genloop: #%d was rejected without a reason — no rule is written (say why and it becomes one)",
690
+ pr.number)
691
+ self._post_status(pr, "rejected", now)
692
+ self.state.mark(REJECTED_PREFIX + str(pr.number), at=now)
693
+ return f"#{pr.number} rejected without a reason"
694
+ outcome = self._write_rules(pr, reason_comment, now)
695
+ if outcome.startswith("held"):
696
+ return outcome
697
+ self._post_status(pr, "rejected", now)
698
+ self.state.mark(REJECTED_PREFIX + str(pr.number), at=now)
699
+ return f"#{pr.number} rejected: {outcome}"
700
+
701
+ def _closing_comment(self, pr: PullRequest, comments: list):
702
+ """The newest operator comment written at the close (within the
703
+ skew) that has not already become rules -- not merely the newest
704
+ one on the thread, which may predate the close by weeks."""
705
+ closed_at = iso_epoch(pr.closed_at)
706
+ if closed_at is None:
707
+ return None
708
+ candidates = []
709
+ for comment in comments:
710
+ at = iso_epoch(comment.created_at)
711
+ if at is None or at < closed_at - REJECTION_COMMENT_SKEW_SECONDS:
712
+ continue
713
+ if self.state.has(RULE_PREFIX + str(comment.id)):
714
+ continue
715
+ candidates.append((at, comment.id, comment))
716
+ return max(candidates, key=lambda c: (c[0], c[1]))[2] if candidates else None
717
+
718
+ def _do_not_propose(self, pr: PullRequest, now: int) -> list[str]:
719
+ try:
720
+ comments = self._operator_comments(pr)
721
+ except CommandError:
722
+ return []
723
+ terms = []
724
+ for comment in comments:
725
+ if not comment.body.lstrip().startswith(DO_NOT_PROPOSE) or self.state.has(RULE_PREFIX + str(comment.id)):
726
+ continue
727
+ if self.config.dry_run:
728
+ terms.append(f"would write rules from #{pr.number} comment {comment.id}")
729
+ continue
730
+ terms.append(f"#{pr.number} {DO_NOT_PROPOSE}: {self._write_rules(pr, comment, now)}")
731
+ return terms
732
+
733
+ def _write_rules(self, pr: PullRequest, comment, now: int) -> str:
734
+ """One rule per source key and one for the anchor (§4.4). The
735
+ comment's id marks it written -- or refused, which is not retried."""
736
+ plan = self._plan_of(pr)
737
+ if plan is None:
738
+ return f"held(plan of #{pr.number} unreadable)"
739
+ reason = comment.body.strip()
740
+ if reason.startswith(DO_NOT_PROPOSE):
741
+ reason = reason[len(DO_NOT_PROPOSE):].strip()
742
+ if not reason:
743
+ self.state.mark(RULE_PREFIX + str(comment.id), at=now)
744
+ return "no reason given — no rule"
745
+ patterns = list(plan.source_keys)
746
+ anchor_pattern = anchor_rule_pattern(plan.anchor)
747
+ if anchor_pattern:
748
+ patterns.append(anchor_pattern)
749
+ written = 0
750
+ for pattern in patterns:
751
+ try:
752
+ self.alissa.put_proposal_rule(
753
+ pattern=pattern, scope="fleet", scope_key="", reason=reason[:RULE_REASON_MAX],
754
+ source_url=comment.url,
755
+ )
756
+ except AlissaTransient as exc:
757
+ return f"held(rule write: {exc})"
758
+ except AlissaAuthError as exc:
759
+ if not self._rules_refused_logged:
760
+ log.warning(
761
+ "genloop: the planner token may not write proposal rules (%s) — Studio documents "
762
+ "rule writes as forbidden for an agent-bound token; the operator records the rule "
763
+ "on the Fleet screen", exc,
764
+ )
765
+ self._rules_refused_logged = True
766
+ self.state.mark(RULE_PREFIX + str(comment.id), at=now)
767
+ return f"rules refused ({exc.status})"
768
+ except AlissaError as exc:
769
+ log.warning("genloop: rule %s refused (%s)", pattern, exc)
770
+ continue
771
+ written += 1
772
+ self.state.mark(RULE_PREFIX + str(comment.id), at=now)
773
+ return f"{written} rule(s) written"
774
+
775
+ # -- 4. spawn gate -----------------------------------------------------
776
+
777
+ def _skip(self, now: int, kind: str, reason: str, term: str, data: dict | None = None) -> str:
778
+ """A named skip: one event per interval slot, nothing in the ledger."""
779
+ slot = now // self.config.interval
780
+ bound = (data or {}).get("bound", "")
781
+ self.events.record(kind, at=now, dedupe_key=f"gate:{reason}:{bound}:{slot}", reason=reason, data=data or {})
782
+ return term
783
+
784
+ def _spawn_gate(self, now: int, listing: list[dict] | None) -> str:
785
+ config = self.config
786
+ if self._skill_missing is not None:
787
+ return f"held(skill-missing — install {config.skill!r} in the image and restart)"
788
+ raw_last = self.state.get_meta(META_LAST_SPAWN)
789
+ if raw_last is not None and now - int(raw_last) < config.interval:
790
+ return f"held(interval — next pass in {config.interval - (now - int(raw_last))}s)"
791
+
792
+ # Not parked (D11). A steering read that failed holds: blind is closed.
793
+ try:
794
+ records = self.alissa.factory_steering()
795
+ except AlissaError as exc:
796
+ return f"held(steering unavailable — {exc})"
797
+ fleet = fleet_row(records)
798
+ if fleet.get("parked") is True:
799
+ return self._skip(now, KIND_PLAN_SKIPPED, SKIP_PARKED,
800
+ "skipped(parked — the operator parked generated work)")
801
+
802
+ # Budget (D10, §8.1): a generator that cannot see its ceiling generates nothing.
803
+ try:
804
+ budget = self.alissa.budget_status("fleet")
805
+ except AlissaError as exc:
806
+ return self._skip(
807
+ now, KIND_PLAN_SKIPPED, SKIP_BUDGET, f"skipped(budget — status unavailable: {exc})",
808
+ {"state": "unavailable"},
809
+ )
810
+ state = budget.get("state")
811
+ if state == "exhausted":
812
+ return self._skip(now, KIND_PLAN_SKIPPED, SKIP_BUDGET, "skipped(budget — fleet exhausted)",
813
+ {"state": state})
814
+ if state == "unmetered" and fleet.get("holdWhenUnmetered") is True:
815
+ return self._skip(now, KIND_PLAN_SKIPPED, SKIP_BUDGET,
816
+ "skipped(budget — unmetered and holdWhenUnmetered)", {"state": state})
817
+ budget_note = f"fleet budget `{state}`"
818
+ if state == "warn":
819
+ budget_note += f", ${budget.get('remainingUsd')} remaining — say so in the plan's *Why now*"
820
+
821
+ # The day bound.
822
+ login = self.login()
823
+ if login is None:
824
+ return "held(GitHub login unknown)"
825
+ try:
826
+ today = self.github.plans_opened_on(self.repo, login, utc_day(now))
827
+ except CommandError as exc:
828
+ return f"held(day count unavailable — {exc})"
829
+ if today >= config.plans_per_day:
830
+ return self._skip(
831
+ now, KIND_PASS_IDLE, SKIP_BOUND, f"idle(bound — plans per day {today}/{config.plans_per_day})",
832
+ {"bound": BOUND_DAY, "count": today, "max": config.plans_per_day},
833
+ )
834
+
835
+ # The lane bound.
836
+ try:
837
+ lanes = int(self.alissa.loop_generated().get("openLanes") or 0)
838
+ except AlissaError as exc:
839
+ return f"held(open lanes unavailable — {exc})"
840
+ if lanes >= config.open_lanes_max:
841
+ return self._skip(
842
+ now, KIND_PASS_IDLE, SKIP_BOUND, f"idle(bound — open lanes {lanes}/{config.open_lanes_max})",
843
+ {"bound": BOUND_LANES, "count": lanes, "max": config.open_lanes_max},
844
+ )
845
+
846
+ # No live pass; a listing that failed is unknown, and unknown holds (L2).
847
+ if listing is None:
848
+ return "held(session listing failed — a pass I cannot see may be live)"
849
+ live = live_stamps(listing)
850
+ if live:
851
+ return f"held(live session {', '.join(session_name(s) for s in live)})"
852
+ if not self.cli.worker_running():
853
+ return "held(no worker — the queue would never drain)"
854
+
855
+ repos = self._fed_repos()
856
+ if not repos:
857
+ return "held(no fed repositories — nothing to read exhaust from)"
858
+ if not self.config.plans_hub_path.is_dir():
859
+ return f"held(plans hub {self.config.plans_hub_path} missing)"
860
+
861
+ stamp = stamp_at(now)
862
+ session = session_name(stamp)
863
+ directive = render_directive(
864
+ stamp=stamp, day=utc_day(now), skill=config.skill, plans_repo=self.repo,
865
+ plans_hub=str(config.plans_hub_path), repos=repos, window_days=config.window_days,
866
+ record_path=str(self.record_path(stamp)), budget_note=budget_note,
867
+ )
868
+ if config.dry_run:
869
+ log.info("[dry-run] would enqueue %s (--agent %s --cwd %s)", session, config.agent, config.plans_hub_path)
870
+ return f"would spawn {session}"
871
+ try:
872
+ self.passes_dir.mkdir(parents=True, exist_ok=True)
873
+ except OSError as exc:
874
+ return f"held(state dir unwritable: {exc})"
875
+ try:
876
+ self.cli.enqueue_session(session=session, directive=directive, cwd=config.plans_hub_path,
877
+ agent=config.agent)
878
+ except CommandError as exc:
879
+ return f"held(enqueue failed: {exc})"
880
+ # Claim ONLY now (L5).
881
+ self.state.set_meta(META_LAST_SPAWN, str(now))
882
+ self.state.mark(SPAWN_PREFIX + stamp, at=now)
883
+ log.info("genloop: spawned %s (skill %s, directive sha256 %s)", session, config.skill, DIRECTIVE_SHA256[:12])
884
+ return f"spawned {session}"
885
+
886
+ def _fed_repos(self) -> list[str]:
887
+ if self.config.repos_source == REPOS_SOURCE_STATIC:
888
+ return list(self.config.repos)
889
+ try:
890
+ rows = self.alissa.list_bodies_of_work()
891
+ except AlissaError as exc:
892
+ log.warning("genloop: could not list the feed bodies of work (%s)", exc)
893
+ return []
894
+ owners = set(self.config.bow_owners)
895
+ repos = []
896
+ for row in rows:
897
+ if owners and row.get("ownerActorId") not in owners:
898
+ continue
899
+ repo = parse_bow_repo(str(row.get("title") or ""))
900
+ if repo and repo.lower() != self.repo.lower() and repo not in repos:
901
+ repos.append(repo)
902
+ return sorted(repos)
903
+
904
+ # -- 5. stale floor ----------------------------------------------------
905
+
906
+ def _stale_floor(self, now: int, listing: list[dict] | None) -> list[str]:
907
+ if listing is None:
908
+ return []
909
+ limit = self.config.stale_intervals * self.config.interval
910
+ terms = []
911
+ for stamp in live_stamps(listing):
912
+ started = stamp_epoch(stamp)
913
+ if started is None or now - started <= limit or self.state.has(STALLED_PREFIX + stamp):
914
+ continue
915
+ session = session_name(stamp)
916
+ if self.config.dry_run:
917
+ terms.append(f"would kill stalled {session}")
918
+ continue
919
+ # Recorded BEFORE the kill, so a kill that lands cannot lose the event.
920
+ self.state.mark(STALLED_PREFIX + stamp, at=now)
921
+ self._settle(stamp)
922
+ try:
923
+ self.cli.kill_session(session)
924
+ except CommandError as exc:
925
+ log.warning("genloop: kill of stalled %s failed (%s)", session, exc)
926
+ self.events.record(
927
+ KIND_PASS_STALLED, at=now, dedupe_key=f"stalled:{stamp}", session=session,
928
+ reason=f"live {now - started}s, over {self.config.stale_intervals} x interval",
929
+ )
930
+ log.error("genloop: %s stalled (%ds) — killed", session, now - started)
931
+ terms.append(f"stalled {session} killed")
932
+ return terms
933
+
934
+ # -- 6. silence floor --------------------------------------------------
935
+
936
+ def _silence_floor(self, now: int, listing: list[dict] | None) -> list[str]:
937
+ terms = []
938
+ live = set(live_stamps(listing)) if listing is not None else set()
939
+ for key in self.state.keys_with_prefix(SPAWN_PREFIX):
940
+ stamp = key[len(SPAWN_PREFIX):]
941
+ if any(self.state.has(p + stamp) for p in (INGESTED_PREFIX, STALLED_PREFIX, SILENT_PREFIX)):
942
+ if not self.config.dry_run:
943
+ self.state.unmark(key)
944
+ continue
945
+ if listing is None or stamp in live:
946
+ continue
947
+ spawned = self.state.ledger.get(key, now)
948
+ if now - spawned < SPAWN_GRACE_SECONDS or self.record_path(stamp).exists():
949
+ continue
950
+ session = session_name(stamp)
951
+ # Absent from the listing is not "ended": a pass still waiting in
952
+ # the queue (worker busy, concurrency cap) has not started. A
953
+ # queue that cannot be read is unknown, and unknown holds.
954
+ try:
955
+ if self.cli.queued(session):
956
+ continue
957
+ except CommandError as exc:
958
+ log.warning("genloop: could not read the queue of %s (%s) — not marking it silent", session, exc)
959
+ continue
960
+ if self.config.dry_run:
961
+ terms.append(f"would record {session} silent")
962
+ continue
963
+ self.state.mark(SILENT_PREFIX + stamp, at=now)
964
+ self._settle(stamp)
965
+ self.events.record(
966
+ KIND_PASS_SILENT, at=now, dedupe_key=f"silent:{stamp}", session=session,
967
+ reason="the pass ended without writing its record",
968
+ )
969
+ log.error("genloop: %s ended without a record — pass.silent", session)
970
+ terms.append(f"silent {session}")
971
+ return terms