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.
- alissa/tools/github/genloop/__init__.py +14 -0
- alissa/tools/github/genloop/__main__.py +89 -0
- alissa/tools/github/genloop/alissa_cli.py +89 -0
- alissa/tools/github/genloop/alissa_client.py +183 -0
- alissa/tools/github/genloop/config.py +294 -0
- alissa/tools/github/genloop/github.py +286 -0
- alissa/tools/github/genloop/loop.py +971 -0
- alissa/tools/github/genloop/loop_events.py +115 -0
- alissa/tools/github/genloop/pass_directive.py +325 -0
- alissa/tools/github/genloop/plan.py +461 -0
- alissa/tools/github/genloop/proc.py +61 -0
- alissa/tools/github/genloop/state.py +113 -0
- alissa/tools/github/genloop/version +1 -0
- alissa/tools/github/genloop/version.py +46 -0
- alissa_tools_github_genloop-0.1.0.dist-info/METADATA +45 -0
- alissa_tools_github_genloop-0.1.0.dist-info/RECORD +21 -0
- alissa_tools_github_genloop-0.1.0.dist-info/WHEEL +5 -0
- alissa_tools_github_genloop-0.1.0.dist-info/entry_points.txt +2 -0
- alissa_tools_github_genloop-0.1.0.dist-info/licenses/LICENSE +202 -0
- alissa_tools_github_genloop-0.1.0.dist-info/licenses/NOTICE +5 -0
- alissa_tools_github_genloop-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|