alissa-tools-github-orcloop 0.3.2__tar.gz → 0.3.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {alissa_tools_github_orcloop-0.3.2/src/main/alissa_tools_github_orcloop.egg-info → alissa_tools_github_orcloop-0.3.3}/PKG-INFO +1 -1
- {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/ghclient.py +91 -2
- {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/loop.py +523 -22
- {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/state.py +43 -3
- alissa_tools_github_orcloop-0.3.3/src/main/alissa/tools/github/orcloop/version +1 -0
- {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3/src/main/alissa_tools_github_orcloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_orcloop-0.3.2/src/main/alissa/tools/github/orcloop/version +0 -1
- {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/MANIFEST.in +0 -0
- {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/README.md +0 -0
- {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/requirements.txt +0 -0
- {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/setup.cfg +0 -0
- {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/setup.py +0 -0
- {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
- {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/__main__.py +0 -0
- {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/alissa_client.py +0 -0
- {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/config.py +0 -0
- {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/markers.py +0 -0
- {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
- {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/version.py +0 -0
- {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa_tools_github_orcloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa_tools_github_orcloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa_tools_github_orcloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa_tools_github_orcloop.egg-info/top_level.txt +0 -0
|
@@ -74,13 +74,26 @@ def _closes_re(number: int) -> re.Pattern[str]:
|
|
|
74
74
|
return pattern
|
|
75
75
|
|
|
76
76
|
|
|
77
|
+
def parse_task_trailer(body: str) -> int | None:
|
|
78
|
+
"""Origin Alissa task number from a line-anchored `Alissa-Task: TASK-<n>`
|
|
79
|
+
TRAILER only -- never the loose `TASK-<n>` scan `parse_task_ref` falls back
|
|
80
|
+
to. This is the strict read, for the one caller that is about to MUTATE the
|
|
81
|
+
matched issue: the release edge's pre-create dedupe applies the trigger
|
|
82
|
+
label to whatever it matches, so "the body mentions TASK-<n> somewhere" is
|
|
83
|
+
not a good enough claim. A passing mention in prose must not adopt."""
|
|
84
|
+
match = TASK_TRAILER_RE.search(body or "")
|
|
85
|
+
return int(match.group(1)) if match else None
|
|
86
|
+
|
|
87
|
+
|
|
77
88
|
def parse_task_ref(body: str) -> int | None:
|
|
78
89
|
"""Origin Alissa task number from an issue body, or None. A line-anchored
|
|
79
90
|
`Alissa-Task: TASK-<n>` trailer wins wherever it sits; only absent that does
|
|
80
91
|
the loose first-`TASK-<n>` scan run. Mirrors devloop's parser so both
|
|
81
92
|
daemons read the trailer orcloop writes identically."""
|
|
82
|
-
|
|
83
|
-
|
|
93
|
+
trailer = parse_task_trailer(body)
|
|
94
|
+
if trailer is not None:
|
|
95
|
+
return trailer
|
|
96
|
+
match = TASK_REF_RE.search(body or "")
|
|
84
97
|
return int(match.group(1)) if match else None
|
|
85
98
|
|
|
86
99
|
|
|
@@ -245,6 +258,82 @@ class GitHub:
|
|
|
245
258
|
out.append((parts[-2], parts[-1], number))
|
|
246
259
|
return out
|
|
247
260
|
|
|
261
|
+
def issues_with_task_trailer(
|
|
262
|
+
self, owner: str, repo: str, task_number: int
|
|
263
|
+
) -> list[Issue]:
|
|
264
|
+
"""Every issue in ONE repo whose body carries the `Alissa-Task:
|
|
265
|
+
TASK-<task_number>` trailer -- OPEN and CLOSED alike, lowest issue
|
|
266
|
+
number first (earliest filed first).
|
|
267
|
+
|
|
268
|
+
This is the trailer-authoritative dedupe read, and it exists because
|
|
269
|
+
`search_issues` above is not one: that query is `is:issue is:open
|
|
270
|
+
label:"<label>"`, so a pre-existing issue carrying the trailer but NOT
|
|
271
|
+
the label is invisible to it, and the release edge filed a duplicate
|
|
272
|
+
issue for a task a hand-staged issue already covered. The label answers
|
|
273
|
+
"what is in flight"; only the trailer answers "does an issue for this
|
|
274
|
+
task already exist".
|
|
275
|
+
|
|
276
|
+
No `is:open` qualifier: the caller decides what a CLOSED match means
|
|
277
|
+
(that rule cannot be applied to results the query never returned).
|
|
278
|
+
|
|
279
|
+
The search index is a HINT, never the verdict. GitHub tokenizes the
|
|
280
|
+
phrase, so a hit can be an issue that merely mentions the number in
|
|
281
|
+
prose; every hit is re-checked against the strict trailer regex on the
|
|
282
|
+
body the API returned, and anything not carrying THIS task's trailer is
|
|
283
|
+
dropped. In the other direction the index can be stale for a
|
|
284
|
+
just-created issue -- which is exactly what the release ledger covers,
|
|
285
|
+
and a miss here degrades to filing an issue (today's behaviour), never
|
|
286
|
+
to mutating the wrong one.
|
|
287
|
+
|
|
288
|
+
Fails closed on a blank owner/repo, the same posture as
|
|
289
|
+
`search_issues` on an empty allowlist: a missing `repo:` qualifier
|
|
290
|
+
would run the query against all of GitHub.
|
|
291
|
+
"""
|
|
292
|
+
owner, repo = owner.strip(), repo.strip()
|
|
293
|
+
if not owner or not repo:
|
|
294
|
+
log.warning(
|
|
295
|
+
"issues_with_task_trailer: blank owner/repo (%r/%r) -- an "
|
|
296
|
+
"unscoped search would match all of GitHub, so this fails "
|
|
297
|
+
"closed and reports no match",
|
|
298
|
+
owner, repo,
|
|
299
|
+
)
|
|
300
|
+
return []
|
|
301
|
+
|
|
302
|
+
query = (
|
|
303
|
+
f'is:issue repo:{owner}/{repo} in:body '
|
|
304
|
+
f'"Alissa-Task: TASK-{task_number}"'
|
|
305
|
+
)
|
|
306
|
+
payload = self._api(
|
|
307
|
+
"-X", "GET", "search/issues", "-f", f"q={query}", "-f",
|
|
308
|
+
f"per_page={self.SEARCH_PER_PAGE}",
|
|
309
|
+
)
|
|
310
|
+
items = payload.get("items", []) if isinstance(payload, dict) else []
|
|
311
|
+
if len(items) == self.SEARCH_PER_PAGE:
|
|
312
|
+
log.warning(
|
|
313
|
+
"issues_with_task_trailer: got exactly %d items for TASK-%d in "
|
|
314
|
+
"%s/%s -- results are likely truncated; the earliest matching "
|
|
315
|
+
"issue may not be among them",
|
|
316
|
+
self.SEARCH_PER_PAGE, task_number, owner, repo,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
out: list[Issue] = []
|
|
320
|
+
for item in items:
|
|
321
|
+
if not isinstance(item, dict) or item.get("pull_request"):
|
|
322
|
+
continue # `is:issue` should have done this; belt and braces
|
|
323
|
+
issue = self._issue_from_payload(owner, repo, item)
|
|
324
|
+
if not issue.number:
|
|
325
|
+
log.warning("could not parse issue number from %r", item.get("number"))
|
|
326
|
+
continue
|
|
327
|
+
if parse_task_trailer(issue.body) != task_number:
|
|
328
|
+
log.debug(
|
|
329
|
+
"%s matched the TASK-%d search but carries no such trailer "
|
|
330
|
+
"-- dropped (the index matches tokens, the trailer decides)",
|
|
331
|
+
issue.issue_slug, task_number,
|
|
332
|
+
)
|
|
333
|
+
continue
|
|
334
|
+
out.append(issue)
|
|
335
|
+
return sorted(out, key=lambda found: found.number)
|
|
336
|
+
|
|
248
337
|
def comment(self, owner: str, repo: str, number: int, body: str) -> None:
|
|
249
338
|
self._api(
|
|
250
339
|
f"repos/{owner}/{repo}/issues/{number}/comments", "-f", f"body={body}"
|
|
@@ -16,12 +16,21 @@ issues the develop daemon can pick up:
|
|
|
16
16
|
`Autonomous-Dev` trailer. One list read of ALL committed tasks per poll.
|
|
17
17
|
- `bow` -- the tasks attached to the per-repo Bodies of Work named
|
|
18
18
|
`<bow_prefix><owner>/<repo>`. See THE BOW FEED below.
|
|
19
|
-
* **Dedupe** is GitHub-authoritative with the ledger as a cache
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
* **Dedupe** is GitHub-authoritative with the ledger as a cache, and it runs in
|
|
20
|
+
TWO layers because they answer different questions:
|
|
21
|
+
|
|
22
|
+
- the **pass scan** searches the watched repos for open LABELED issues and
|
|
23
|
+
parses each body's `Alissa-Task:` trailer. That is the in-flight set: it
|
|
24
|
+
feeds lane occupancy, skips tasks already released, ADOPTS hand-released
|
|
25
|
+
issues into the ledger (§8 below) and BACKFILLS rows GitHub confirms but
|
|
26
|
+
the ledger has lost. Being label-scoped is correct for occupancy and
|
|
27
|
+
WRONG as the last word on "does an issue for this task exist" -- an
|
|
28
|
+
unlabeled issue carrying the trailer is invisible to it.
|
|
29
|
+
- the **pre-create trailer check** (`_trailer_dedupe`) closes exactly that
|
|
30
|
+
gap, in the target repo, immediately before an issue would be filed: an
|
|
31
|
+
open unlabeled trailer match is ADOPTED (ledger row + label, never a
|
|
32
|
+
second issue), an open labeled one is skipped, a closed one follows the
|
|
33
|
+
closed-match rule below. A check that cannot answer holds the release.
|
|
25
34
|
* **The dependsOn gate**: a candidate is releasable only once every upstream
|
|
26
35
|
task it depends on is `pending_validation` or `validated` (an unfinished
|
|
27
36
|
dependency holds the release). An upstream this actor cannot even see counts
|
|
@@ -167,24 +176,43 @@ the Alissa task (primary) and best-effort on the GitHub issue, and are all
|
|
|
167
176
|
dry-run gated: `unreleasable` (a marked task with no valid target repo),
|
|
168
177
|
`release-blocked` (the dependsOn gate held past the `release_blocked_floor_days`
|
|
169
178
|
floor -- below the floor the release is held quietly, one log line per poll,
|
|
170
|
-
never a page), `parked`, `closed-unmerged`,
|
|
171
|
-
|
|
172
|
-
|
|
179
|
+
never a page), `parked`, `closed-unmerged`, `already-merged` (a task came up
|
|
180
|
+
for release but its trailer already sits on an issue closed by a merged PR),
|
|
181
|
+
`dedupe-unreadable` (the pre-create trailer check has been unable to answer for
|
|
182
|
+
longer than the same `release_blocked_floor_days` floor -- measured on the
|
|
183
|
+
FAILURE, not the task's age, so a transient rate limit on a long-queued task
|
|
184
|
+
never pages -- because a hold that could never page is how a task goes quiet
|
|
185
|
+
forever), and `closeloop-failed:a<j>` (a close-loop mutation failed; retried
|
|
186
|
+
with a floor between attempts, each attempt paging once).
|
|
173
187
|
|
|
174
188
|
NO SILENT STATES: every candidate and every released issue produces exactly one
|
|
175
189
|
logged `Decision` each pass -- a deferral (`held`) always leaves a per-poll log
|
|
176
190
|
line, and a floored escalation is the only thing allowed to defer without a
|
|
177
191
|
per-poll page. `test_loop.py` pins this.
|
|
178
192
|
|
|
179
|
-
THE §8 OPEN DECISION -- adoption of hand-released issues (RESOLVED here
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
193
|
+
THE §8 OPEN DECISION -- adoption of hand-released issues (RESOLVED here): an
|
|
194
|
+
issue a human files by hand carrying an `Alissa-Task:` trailer is ADOPTED
|
|
195
|
+
rather than duplicated, in whichever of two places sees it first:
|
|
196
|
+
|
|
197
|
+
* WITH the trigger label -- the release scan adopts it into the ledger the
|
|
198
|
+
first time it sees it open, so the close edge drives its origin task forward
|
|
199
|
+
on merge exactly like an orcloop-filed one.
|
|
200
|
+
* WITHOUT the label -- the pre-create trailer check adopts it when the task
|
|
201
|
+
comes up for release: the ledger row is written against the EXISTING issue
|
|
202
|
+
and the label is applied, completing a release a human started. Both halves
|
|
203
|
+
of the alternative were wrong before this: filing a second issue duplicates
|
|
204
|
+
the work, and skipping on the match would leave the trigger unarmed (an
|
|
205
|
+
unlabeled issue is invisible to the develop daemon), so the task would
|
|
206
|
+
simply starve.
|
|
207
|
+
|
|
208
|
+
CLOSED trailer matches never permanently block a release: a closed-unmerged
|
|
209
|
+
one is ignored (a superseded or not-planned issue is not evidence the work
|
|
210
|
+
happened), and only a match closed by a MERGED closing PR stops the release --
|
|
211
|
+
as an `already-merged` escalation, because a task still queued for release
|
|
212
|
+
whose work already merged is an anomaly for the operator, not a silent skip.
|
|
213
|
+
The one limit that remains: adoption needs to see an issue at all, so a task
|
|
214
|
+
whose only trace is a closed, never-merged issue releases fresh. Recorded in
|
|
215
|
+
the PR body and on the origin task.
|
|
188
216
|
|
|
189
217
|
orcloop spawns NO worker sessions (it files issues; the develop daemon spawns
|
|
190
218
|
the sessions), so there is no session reaper here -- the family's "sweep" is
|
|
@@ -244,6 +272,34 @@ ESCALATION_RELEASE_BLOCKED = "release-blocked"
|
|
|
244
272
|
ESCALATION_PARKED = "parked"
|
|
245
273
|
ESCALATION_CLOSED_UNMERGED = "closed-unmerged"
|
|
246
274
|
ESCALATION_CLOSELOOP_FAILED = "closeloop-failed"
|
|
275
|
+
ESCALATION_ALREADY_MERGED = "already-merged"
|
|
276
|
+
ESCALATION_DEDUPE_UNREADABLE = "dedupe-unreadable"
|
|
277
|
+
|
|
278
|
+
# NOT an operator page: the timestamp of the FIRST dedupe check that could not
|
|
279
|
+
# answer for this task, so the floor above it measures how long the CONDITION
|
|
280
|
+
# has lasted rather than how old the task is. Task age is a fair proxy for a
|
|
281
|
+
# persistent gate (that is why `release-blocked` uses it); a single failed I/O
|
|
282
|
+
# read is not, and without this an old task would page on its first transient
|
|
283
|
+
# rate limit -- permanently, since the page kind would then be spent. Both this
|
|
284
|
+
# marker and the page are cleared the moment a check answers again.
|
|
285
|
+
#
|
|
286
|
+
# The `marker:` prefix is load-bearing, not decoration: `escalations_matching`
|
|
287
|
+
# is a PREFIX reader (that is how `closeloop-failed:a<j>` counts its attempts),
|
|
288
|
+
# so a marker named `dedupe-unreadable-since` (the name this one had before
|
|
289
|
+
# the namespace) would silently answer a future
|
|
290
|
+
# `escalations_matching(task, ESCALATION_DEDUPE_UNREADABLE)` -- a non-page row
|
|
291
|
+
# inflating a page count in whatever report asked. Namespacing puts it out of
|
|
292
|
+
# reach of every prefix read of a page kind.
|
|
293
|
+
DEDUPE_UNREADABLE_SINCE = "marker:dedupe-unreadable"
|
|
294
|
+
|
|
295
|
+
# How many CLOSED trailer matches the pre-create dedupe will walk looking for a
|
|
296
|
+
# merged closing PR. Each check is a bounded timeline walk (see
|
|
297
|
+
# `GitHub.closing_merged_pr`), and the matches are walked newest-first, so this
|
|
298
|
+
# caps the cost of a task whose trailer has been re-filed many times. A task
|
|
299
|
+
# with more closed matches than this releases -- the closed-match rule is
|
|
300
|
+
# "closed work must not permanently block release", so an unresolved tail
|
|
301
|
+
# errs toward releasing, never toward a task that can never be released again.
|
|
302
|
+
MAX_CLOSED_TRAILER_CHECKS = 3
|
|
247
303
|
|
|
248
304
|
# NOT an operator page: a completion marker so a fully-closed loop is never
|
|
249
305
|
# re-processed. Lives in the escalations table (kind is free-form TEXT by the
|
|
@@ -335,6 +391,33 @@ CLOSED_UNMERGED_ISSUE_COMMENT = (
|
|
|
335
391
|
"closing PR, so the orchestrator left its origin Alissa task untouched."
|
|
336
392
|
)
|
|
337
393
|
|
|
394
|
+
ALREADY_MERGED_TASK_COMMENT = (
|
|
395
|
+
"**Orchestrator: already merged** — this task was about to be released, "
|
|
396
|
+
"but issue {slug} already carries its `Alissa-Task:` trailer and was "
|
|
397
|
+
"closed by merged pull request {pr_url}. The work looks genuinely done, "
|
|
398
|
+
"so the orchestrator has NOT filed a second issue and has NOT advanced "
|
|
399
|
+
"this task (it never held a ledger row for that issue, so the close edge "
|
|
400
|
+
"could not drive it). Validate and close this task by hand if the merge "
|
|
401
|
+
"covers it; if it does not, remove the release marker or edit the trailer "
|
|
402
|
+
"off that issue so the orchestrator can release fresh work for it."
|
|
403
|
+
)
|
|
404
|
+
DEDUPE_UNREADABLE_COMMENT = (
|
|
405
|
+
"**Orchestrator: dedupe unreadable** — before filing a develop issue for "
|
|
406
|
+
"this task the orchestrator checks {target} for an issue that already "
|
|
407
|
+
"carries its `Alissa-Task:` trailer, and that check has been failing "
|
|
408
|
+
"continuously for over {floor_days} day(s): {reason}. The release is held rather "
|
|
409
|
+
"than risk a duplicate issue, and it will keep retrying. Usual causes: the "
|
|
410
|
+
"GitHub token lost access to {target}, or a closed issue carrying this "
|
|
411
|
+
"trailer was transferred or deleted. Restore the read (or release this "
|
|
412
|
+
"work by hand) to clear it."
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
ALREADY_MERGED_ISSUE_COMMENT = (
|
|
416
|
+
"**Orchestrator: already merged** — this closed issue carries the "
|
|
417
|
+
"`Alissa-Task:` trailer of a task that is still queued for release. No "
|
|
418
|
+
"second issue was filed; the origin task needs an operator decision."
|
|
419
|
+
)
|
|
420
|
+
|
|
338
421
|
CLOSELOOP_FAILED_TASK_COMMENT = (
|
|
339
422
|
"**Orchestrator: close-loop failed (attempt {attempt})** — the released "
|
|
340
423
|
"issue {slug} merged, but advancing this task to `pending_validation` "
|
|
@@ -536,9 +619,15 @@ class Orchestrator:
|
|
|
536
619
|
Every open labeled issue occupies its repo's lane (its `Autonomous-Scope`
|
|
537
620
|
globs recorded for disjointness checks). An issue whose body carries an
|
|
538
621
|
`Alissa-Task:` trailer maps that task number to the issue -- the
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
622
|
+
in-flight dedupe set -- and, if the ledger does not know it yet, is
|
|
623
|
+
ADOPTED (§8: a hand-released or ledger-lost issue) so the close edge
|
|
624
|
+
tracks it.
|
|
625
|
+
|
|
626
|
+
This search is LABEL-scoped, which is what lane occupancy needs and is
|
|
627
|
+
deliberately NOT the whole dedupe story: an issue carrying the trailer
|
|
628
|
+
but no label is in neither the occupancy nor the dedupe set here. That
|
|
629
|
+
case is settled per-candidate by `_trailer_dedupe`, immediately before
|
|
630
|
+
anything is filed."""
|
|
542
631
|
released: dict[int, Issue] = {}
|
|
543
632
|
occupancy: dict[str, list[list[str]]] = {}
|
|
544
633
|
for owner, repo, number in self.github.search_issues(
|
|
@@ -1268,6 +1357,410 @@ class Orchestrator:
|
|
|
1268
1357
|
)
|
|
1269
1358
|
return None
|
|
1270
1359
|
|
|
1360
|
+
def _trailer_dedupe(
|
|
1361
|
+
self,
|
|
1362
|
+
task: Task,
|
|
1363
|
+
target: str,
|
|
1364
|
+
occupancy: dict[str, list[list[str]]],
|
|
1365
|
+
*,
|
|
1366
|
+
bow: BodyOfWork | None = None,
|
|
1367
|
+
) -> tuple[str, Decision] | None:
|
|
1368
|
+
"""The trailer-authoritative dedupe, run in the target repo immediately
|
|
1369
|
+
before an issue would be created. Returns the decision that replaces
|
|
1370
|
+
the create, or None to go ahead and create.
|
|
1371
|
+
|
|
1372
|
+
WHY IT EXISTS (the duplicate this fixes): the pass-level scan searches
|
|
1373
|
+
`is:issue is:open label:"<label>"` and only then parses the matched
|
|
1374
|
+
bodies for a trailer, so its dedupe set is LABEL-scoped -- an open
|
|
1375
|
+
issue that already carries a task's `Alissa-Task:` trailer but has not
|
|
1376
|
+
been labeled is never fetched and never parsed. A hand-staged issue in
|
|
1377
|
+
exactly that state (open, unlabeled, trailer present for days) was
|
|
1378
|
+
missed and a second issue was filed for the same task. Skipping on the
|
|
1379
|
+
match would have been just as wrong the other way: unlabeled means
|
|
1380
|
+
invisible to the develop daemon, so the trigger would have starved.
|
|
1381
|
+
|
|
1382
|
+
The rule matrix, per match in the target repo:
|
|
1383
|
+
|
|
1384
|
+
* OPEN + unlabeled -> **adopt**: record the ledger row against the
|
|
1385
|
+
EXISTING issue and apply the label. That is the same
|
|
1386
|
+
ledger-then-label ordering (and the same idempotent crash
|
|
1387
|
+
completion) a fresh release uses; it never files a second issue and
|
|
1388
|
+
never leaves the trigger unarmed.
|
|
1389
|
+
* OPEN + already labeled -> **skip**, and record the ledger row so the
|
|
1390
|
+
close edge tracks it (the §8 adoption the scan would do next pass;
|
|
1391
|
+
reaching here at all means the issue appeared after this pass's
|
|
1392
|
+
scan).
|
|
1393
|
+
* CLOSED with no merged closing PR -> **ignored**. A superseded,
|
|
1394
|
+
not-planned or hand-closed issue must never make a task permanently
|
|
1395
|
+
unreleasable, so the release proceeds and files a fresh issue.
|
|
1396
|
+
* CLOSED by a merged closing PR -> **skip + escalate**
|
|
1397
|
+
(`already-merged`): the work is genuinely done, so a second issue
|
|
1398
|
+
would be duplicate work -- but the origin task is still queued, which
|
|
1399
|
+
means no ledger row ever drove the close edge for it. That anomaly is
|
|
1400
|
+
an operator decision, not something to guess at.
|
|
1401
|
+
|
|
1402
|
+
A dedupe read that does not ANSWER (search or timeline failure) holds
|
|
1403
|
+
the release -- "the check failed" must never be read as "no duplicate"
|
|
1404
|
+
-- but never silently forever: past the release-blocked floor the hold
|
|
1405
|
+
pages once (`_dedupe_unreadable`), because a task that can neither
|
|
1406
|
+
release nor complain is the same dead end the closed-match rule above
|
|
1407
|
+
exists to prevent.
|
|
1408
|
+
"""
|
|
1409
|
+
owner, _, repo = target.partition("/")
|
|
1410
|
+
try:
|
|
1411
|
+
matches = self.github.issues_with_task_trailer(owner, repo, task.task_number)
|
|
1412
|
+
except (CommandError, RateLimited) as exc:
|
|
1413
|
+
return self._dedupe_unreadable(
|
|
1414
|
+
task, target, f"the trailer search in {target} failed: {exc}",
|
|
1415
|
+
)
|
|
1416
|
+
|
|
1417
|
+
open_matches = [issue for issue in matches if issue.state == "open"]
|
|
1418
|
+
if open_matches:
|
|
1419
|
+
self._dedupe_answered(task.task_number)
|
|
1420
|
+
return self._adopt(task, open_matches, target, occupancy, bow=bow)
|
|
1421
|
+
return self._closed_trailer_matches(task, matches, target)
|
|
1422
|
+
|
|
1423
|
+
def _adopt(
|
|
1424
|
+
self,
|
|
1425
|
+
task: Task,
|
|
1426
|
+
open_matches: list[Issue],
|
|
1427
|
+
target: str,
|
|
1428
|
+
occupancy: dict[str, list[list[str]]],
|
|
1429
|
+
*,
|
|
1430
|
+
bow: BodyOfWork | None = None,
|
|
1431
|
+
) -> tuple[str, Decision]:
|
|
1432
|
+
"""Complete the release ON an open issue that already carries this
|
|
1433
|
+
task's trailer, instead of filing a second one.
|
|
1434
|
+
|
|
1435
|
+
The EARLIEST filed match wins (matches arrive lowest-number-first): if
|
|
1436
|
+
a repo somehow carries two open issues for one task, the first one is
|
|
1437
|
+
the one people have been reading, and picking deterministically beats
|
|
1438
|
+
picking the newest. The extras are named in a warning -- one task
|
|
1439
|
+
cannot have two live issues without someone having made a mistake.
|
|
1440
|
+
|
|
1441
|
+
Lane occupancy is recorded from the ISSUE's own `Autonomous-Scope`, not
|
|
1442
|
+
the candidate task's: the issue is the in-flight artifact, it is what
|
|
1443
|
+
`_scan_released` will read on every later pass, and on an adopted
|
|
1444
|
+
(human-written) body the two can differ. Taking the task's globs here
|
|
1445
|
+
would leave this pass and the next deciding disjointness on different
|
|
1446
|
+
input. A body with no scope trailer is therefore SCOPELESS -- it
|
|
1447
|
+
occupies the whole lane, the conservative reading and the one the next
|
|
1448
|
+
scan reaches independently.
|
|
1449
|
+
|
|
1450
|
+
Which is exactly why the lane is RE-GATED here on `issue_scope` before
|
|
1451
|
+
the label goes on. `_evaluate_release` admitted this candidate on the
|
|
1452
|
+
TASK's globs; adoption then arms a different artifact, whose globs can
|
|
1453
|
+
conflict with the lane the task's did not (an absent scope trailer is
|
|
1454
|
+
the common shape -- scopeless means "needs an empty lane", and the
|
|
1455
|
+
adopt path is the one release path that could otherwise land it beside
|
|
1456
|
+
an occupant). Gating on the input this method treats as authoritative
|
|
1457
|
+
keeps `scopes_disjoint`'s guarantee true of what actually runs. A
|
|
1458
|
+
conflict HOLDS: the issue keeps its trailer, so a later pass adopts it
|
|
1459
|
+
once the lane clears -- nothing is lost and nothing is duplicated.
|
|
1460
|
+
|
|
1461
|
+
That guarantee is one-directional, deliberately. The EARLIER gate still
|
|
1462
|
+
decides on the task's globs, so a candidate the issue's globs would
|
|
1463
|
+
have admitted can be held before it ever reaches here (task claims
|
|
1464
|
+
`src/**` against an `src/**` occupant; the staged issue claims
|
|
1465
|
+
`docs/**`). Letting a lane-blocked candidate fall through to be
|
|
1466
|
+
re-decided here would close it, and is not worth the extra path: that
|
|
1467
|
+
direction costs THROUGHPUT -- one poll, self-clearing when the lane
|
|
1468
|
+
drains -- while the direction gated above costs SAFETY, two sessions on
|
|
1469
|
+
overlapping files.
|
|
1470
|
+
|
|
1471
|
+
Adoption holds are quiet, exactly like every other lane hold: one log
|
|
1472
|
+
line per poll, no floor, no page. The scopeless case is the one worth
|
|
1473
|
+
knowing about -- "needs an empty lane" conflicts with EVERY occupant,
|
|
1474
|
+
so in a busy repo a scopeless staged issue can wait a long time -- so
|
|
1475
|
+
that hold logs a WARNING naming the issue and the one-line remedy,
|
|
1476
|
+
rather than only the per-poll decision line.
|
|
1477
|
+
|
|
1478
|
+
The already-labeled branch is not gated: that issue is armed and
|
|
1479
|
+
in-flight whatever orcloop thinks, so there is no trigger to withhold,
|
|
1480
|
+
only a ledger row to record and a lane to account for.
|
|
1481
|
+
|
|
1482
|
+
An adopted body is a human's, so it is not the renderer's: only the
|
|
1483
|
+
trailer is contractual (it is all the develop daemon reads too). A body
|
|
1484
|
+
without the `Autonomous-Dev` trailer is adopted anyway -- the target
|
|
1485
|
+
repo is the one this search was scoped to, not something inferred from
|
|
1486
|
+
the body -- but it is logged, because "an operator staged something
|
|
1487
|
+
incomplete" should be visible in the poll log rather than in devloop's
|
|
1488
|
+
behaviour."""
|
|
1489
|
+
issue = open_matches[0]
|
|
1490
|
+
if len(open_matches) > 1:
|
|
1491
|
+
log.warning(
|
|
1492
|
+
"%s: %d OPEN issues carry this task's trailer (%s) — adopting "
|
|
1493
|
+
"the earliest, %s; the others are duplicates a human should "
|
|
1494
|
+
"close",
|
|
1495
|
+
task.ref, len(open_matches),
|
|
1496
|
+
", ".join(found.issue_slug for found in open_matches),
|
|
1497
|
+
issue.issue_slug,
|
|
1498
|
+
)
|
|
1499
|
+
|
|
1500
|
+
issue_scope = parse_scope(issue.body, self.config.scope_key)
|
|
1501
|
+
labeled = self.config.label in issue.labels
|
|
1502
|
+
if not labeled:
|
|
1503
|
+
lane_reason = self._lane_blocked(target, issue_scope, occupancy)
|
|
1504
|
+
if lane_reason is not None:
|
|
1505
|
+
if not issue_scope:
|
|
1506
|
+
# A scopeless issue needs an EMPTY lane, so this hold does
|
|
1507
|
+
# not clear when one occupant closes -- it clears when the
|
|
1508
|
+
# repo goes quiet. Loud enough to act on: whoever staged
|
|
1509
|
+
# the issue can end the wait with one trailer.
|
|
1510
|
+
log.warning(
|
|
1511
|
+
"%s: %s carries this task's trailer but declares no "
|
|
1512
|
+
"`%s:` trailer, so adopting it would need an EMPTY "
|
|
1513
|
+
"%s lane (%s) — held. Add an `%s:` trailer to let it "
|
|
1514
|
+
"land beside in-flight work",
|
|
1515
|
+
task.ref, issue.issue_slug, self.config.scope_key,
|
|
1516
|
+
target, lane_reason, self.config.scope_key,
|
|
1517
|
+
)
|
|
1518
|
+
return self._logged(task.ref, Decision(
|
|
1519
|
+
Action.HELD,
|
|
1520
|
+
f"cannot adopt {issue.issue_slug} yet: lane {target} "
|
|
1521
|
+
f"occupied: {lane_reason} — the issue declares "
|
|
1522
|
+
f"{issue_scope or '(scopeless — needs an empty lane)'}, "
|
|
1523
|
+
f"whatever the task declares; retries next poll",
|
|
1524
|
+
task.task_number,
|
|
1525
|
+
))
|
|
1526
|
+
|
|
1527
|
+
# Past every gate that can refuse, and worded as STATE rather than act:
|
|
1528
|
+
# the three paths below it are a live adoption, a dry-run report, and an
|
|
1529
|
+
# already-labeled skip that arms nothing, so a line claiming "adopting"
|
|
1530
|
+
# would overstate two of them. What the operator needs is the same on
|
|
1531
|
+
# all three -- what the body left out, and what was inferred instead.
|
|
1532
|
+
if parse_marker(issue.body, self.config.marker_key) is None:
|
|
1533
|
+
log.warning(
|
|
1534
|
+
"%s: %s carries this task's trailer but no `%s:` trailer — "
|
|
1535
|
+
"target repo read from the search scope (%s), lane scope from "
|
|
1536
|
+
"the body (%s). Edit the issue if that is not what it means",
|
|
1537
|
+
task.ref, issue.issue_slug, self.config.marker_key, target,
|
|
1538
|
+
issue_scope or "scopeless — occupies the whole lane",
|
|
1539
|
+
)
|
|
1540
|
+
|
|
1541
|
+
if self.config.dry_run:
|
|
1542
|
+
verb = "would record" if labeled else "would adopt"
|
|
1543
|
+
unlabeled_note = "" if labeled else f", no {self.config.label!r} label"
|
|
1544
|
+
occupancy.setdefault(target, []).append(issue_scope)
|
|
1545
|
+
return self._logged(task.ref, Decision(
|
|
1546
|
+
Action.SKIPPED if labeled else Action.RELEASED,
|
|
1547
|
+
f"[dry-run] {verb} pre-existing {issue.issue_slug} (carries "
|
|
1548
|
+
f"this task's `Alissa-Task:` trailer{unlabeled_note})"
|
|
1549
|
+
f"{self._detach(task.ref, bow)}",
|
|
1550
|
+
task.task_number,
|
|
1551
|
+
))
|
|
1552
|
+
|
|
1553
|
+
try:
|
|
1554
|
+
self.state.record_release(
|
|
1555
|
+
task_number=task.task_number,
|
|
1556
|
+
owner_repo=target,
|
|
1557
|
+
issue_number=issue.number,
|
|
1558
|
+
labeled=labeled,
|
|
1559
|
+
)
|
|
1560
|
+
if labeled:
|
|
1561
|
+
# Already armed: there is no trigger left to complete, only the
|
|
1562
|
+
# ledger row (written above) so the close edge tracks it. It
|
|
1563
|
+
# still occupies the lane for the REST OF THIS PASS -- the scan
|
|
1564
|
+
# ran before this issue existed, so no later candidate would
|
|
1565
|
+
# otherwise measure itself against it.
|
|
1566
|
+
occupancy.setdefault(target, []).append(issue_scope)
|
|
1567
|
+
return self._logged(task.ref, Decision(
|
|
1568
|
+
Action.SKIPPED,
|
|
1569
|
+
f"already released by hand as {issue.issue_slug} (trailer "
|
|
1570
|
+
f"and {self.config.label!r} label present); recorded in the "
|
|
1571
|
+
f"ledger, no second issue{self._detach(task.ref, bow)}",
|
|
1572
|
+
task.task_number,
|
|
1573
|
+
))
|
|
1574
|
+
self.github.add_label(issue.owner, issue.repo, issue.number, self.config.label)
|
|
1575
|
+
self.state.mark_labeled(task.task_number)
|
|
1576
|
+
except (CommandError, RateLimited) as exc:
|
|
1577
|
+
# Same recovery as a create that died before its label: the ledger
|
|
1578
|
+
# row exists at labeled=0, so the next pass's dedupe path completes
|
|
1579
|
+
# the labeling of THIS issue rather than filing anything. The
|
|
1580
|
+
# detach is not attempted -- the release is not complete yet.
|
|
1581
|
+
return self._logged(task.ref, Decision(
|
|
1582
|
+
Action.HELD,
|
|
1583
|
+
f"adoption of {issue.issue_slug} failed: {exc}",
|
|
1584
|
+
task.task_number,
|
|
1585
|
+
))
|
|
1586
|
+
|
|
1587
|
+
occupancy.setdefault(target, []).append(issue_scope)
|
|
1588
|
+
return self._logged(task.ref, Decision(
|
|
1589
|
+
Action.RELEASED,
|
|
1590
|
+
f"adopted pre-existing {issue.issue_slug} (carries this task's "
|
|
1591
|
+
f"trailer, was unlabeled): ledger row recorded and "
|
|
1592
|
+
f"{self.config.label!r} applied{self._detach(task.ref, bow)}",
|
|
1593
|
+
task.task_number,
|
|
1594
|
+
))
|
|
1595
|
+
|
|
1596
|
+
def _closed_trailer_matches(
|
|
1597
|
+
self, task: Task, matches: list[Issue], target: str
|
|
1598
|
+
) -> tuple[str, Decision] | None:
|
|
1599
|
+
"""Apply the closed-match rule to the CLOSED trailer matches (there are
|
|
1600
|
+
no open ones by the time this runs). Returns a decision that replaces
|
|
1601
|
+
the create, or None to release.
|
|
1602
|
+
|
|
1603
|
+
Closed-unmerged matches are ignored on purpose: a superseded or
|
|
1604
|
+
not-planned issue is not evidence the work happened, and letting it
|
|
1605
|
+
veto the release would make one hand-closed issue enough to render a
|
|
1606
|
+
task permanently unreleasable. Only a MERGED closing PR is evidence,
|
|
1607
|
+
and that is escalated rather than silently skipped.
|
|
1608
|
+
|
|
1609
|
+
An UNREADABLE match (transferred, deleted, or in a repo the token lost
|
|
1610
|
+
access to) does not speak for the others: the walk continues, and the
|
|
1611
|
+
pass holds only if NO checked match could be resolved. One permanently
|
|
1612
|
+
unreadable issue would otherwise hold the task forever -- through the
|
|
1613
|
+
error branch, the same dead end the rule above rules out. When every
|
|
1614
|
+
checked match is unreadable the hold is floored and pages once (see
|
|
1615
|
+
`_dedupe_unreadable`)."""
|
|
1616
|
+
if not matches:
|
|
1617
|
+
self._dedupe_answered(task.task_number)
|
|
1618
|
+
return None
|
|
1619
|
+
|
|
1620
|
+
# Newest first: the most recent closed attempt is the likeliest to
|
|
1621
|
+
# carry the merge, and the walk is bounded.
|
|
1622
|
+
newest_first = sorted(matches, key=lambda found: found.number, reverse=True)
|
|
1623
|
+
checked = newest_first[:MAX_CLOSED_TRAILER_CHECKS]
|
|
1624
|
+
unreadable: list[str] = []
|
|
1625
|
+
first_error: Exception | None = None
|
|
1626
|
+
for issue in checked:
|
|
1627
|
+
try:
|
|
1628
|
+
pr = self.github.closing_merged_pr(issue.owner, issue.repo, issue.number)
|
|
1629
|
+
except (CommandError, RateLimited) as exc:
|
|
1630
|
+
unreadable.append(issue.issue_slug)
|
|
1631
|
+
first_error = first_error or exc
|
|
1632
|
+
continue
|
|
1633
|
+
if pr is None:
|
|
1634
|
+
continue
|
|
1635
|
+
self._dedupe_answered(task.task_number)
|
|
1636
|
+
self._escalate(
|
|
1637
|
+
task.task_number,
|
|
1638
|
+
ESCALATION_ALREADY_MERGED,
|
|
1639
|
+
ALREADY_MERGED_TASK_COMMENT.format(slug=issue.issue_slug, pr_url=pr.url),
|
|
1640
|
+
issue=issue,
|
|
1641
|
+
issue_comment=ALREADY_MERGED_ISSUE_COMMENT,
|
|
1642
|
+
)
|
|
1643
|
+
return self._logged(task.ref, Decision(
|
|
1644
|
+
Action.ESCALATED,
|
|
1645
|
+
f"already merged: {issue.issue_slug} carries this task's "
|
|
1646
|
+
f"trailer and was closed by merged {pr.url} — no second issue "
|
|
1647
|
+
f"filed, task left for the operator",
|
|
1648
|
+
task.task_number,
|
|
1649
|
+
))
|
|
1650
|
+
|
|
1651
|
+
if unreadable and len(unreadable) == len(checked):
|
|
1652
|
+
# Nothing was resolved, so the walk made no claim at all -- this is
|
|
1653
|
+
# the same "the check did not answer" state as a failed search.
|
|
1654
|
+
return self._dedupe_unreadable(
|
|
1655
|
+
task, target,
|
|
1656
|
+
f"none of the closed trailer matches in {target} could be read "
|
|
1657
|
+
f"({', '.join(unreadable)}): {first_error}",
|
|
1658
|
+
)
|
|
1659
|
+
|
|
1660
|
+
# At least one match resolved, so the check DID answer: any recorded
|
|
1661
|
+
# failure is over.
|
|
1662
|
+
self._dedupe_answered(task.task_number)
|
|
1663
|
+
unchecked = max(0, len(newest_first) - MAX_CLOSED_TRAILER_CHECKS)
|
|
1664
|
+
log.info(
|
|
1665
|
+
"%s: %d closed issue(s) in %s carry this task's trailer (%s) but "
|
|
1666
|
+
"none of the %d checked was closed by a merged PR%s%s — closed work "
|
|
1667
|
+
"that never merged does not block a release; filing a fresh issue",
|
|
1668
|
+
task.ref, len(newest_first), target,
|
|
1669
|
+
", ".join(found.issue_slug for found in newest_first),
|
|
1670
|
+
len(checked),
|
|
1671
|
+
f" ({unchecked} older match(es) not checked)" if unchecked else "",
|
|
1672
|
+
f" ({len(unreadable)} unreadable: {', '.join(unreadable)})"
|
|
1673
|
+
if unreadable else "",
|
|
1674
|
+
)
|
|
1675
|
+
return None
|
|
1676
|
+
|
|
1677
|
+
def _dedupe_unreadable(
|
|
1678
|
+
self, task: Task, target: str, reason: str
|
|
1679
|
+
) -> tuple[str, Decision]:
|
|
1680
|
+
"""The trailer dedupe could not answer, so the release is held rather
|
|
1681
|
+
than risk a duplicate -- with the floor-then-page shape
|
|
1682
|
+
`_release_blocked` uses, because a hold that can never page is how a
|
|
1683
|
+
task goes quiet forever: a token that lost access to the target repo,
|
|
1684
|
+
or a closed match that was transferred or deleted, fails identically on
|
|
1685
|
+
every poll.
|
|
1686
|
+
|
|
1687
|
+
The floor measures THE FAILURE, not the task. `_release_blocked` can
|
|
1688
|
+
floor on task age because the dependsOn gate is a persistent condition,
|
|
1689
|
+
so an old blocked task has plausibly been blocked a while; this
|
|
1690
|
+
condition is a single I/O outcome, and `search/issues` is GitHub's
|
|
1691
|
+
tightest limit -- a routine, self-clearing rate limit on a
|
|
1692
|
+
long-queued task would otherwise page on contact, with a message
|
|
1693
|
+
claiming days of failure, and burn the kind so a genuine outage later
|
|
1694
|
+
had no page left. So the first failure records
|
|
1695
|
+
`marker:dedupe-unreadable` (stamped from the loop's own clock) and holds
|
|
1696
|
+
quietly; only once THAT is older than `release_blocked_floor_days` does
|
|
1697
|
+
one `dedupe-unreadable` escalation name the repo and the failing read.
|
|
1698
|
+
`_escalate` dedupes per (task, kind), so a long outage pages once.
|
|
1699
|
+
|
|
1700
|
+
`_trailer_dedupe` clears both rows the moment a check answers again, so
|
|
1701
|
+
a blip that clears leaves nothing behind and a later, unrelated outage
|
|
1702
|
+
starts its own clock and gets its own page.
|
|
1703
|
+
|
|
1704
|
+
The floor knob is shared with `release-blocked` on purpose: both say
|
|
1705
|
+
"how long may a release be quietly held before an operator is told",
|
|
1706
|
+
and a second knob for the same question is a config surface (and a
|
|
1707
|
+
container floor bump) that buys nothing."""
|
|
1708
|
+
floor_seconds = self.config.release_blocked_floor_days * 86400
|
|
1709
|
+
held = f"dedupe check could not answer — {reason}; held rather than risk "
|
|
1710
|
+
rows = self.state.escalations_matching(task.task_number, DEDUPE_UNREADABLE_SINCE)
|
|
1711
|
+
now = int(self._now())
|
|
1712
|
+
if not rows:
|
|
1713
|
+
if not self.config.dry_run:
|
|
1714
|
+
self.state.record_escalation(
|
|
1715
|
+
task.task_number, DEDUPE_UNREADABLE_SINCE, at=now,
|
|
1716
|
+
)
|
|
1717
|
+
return self._logged(task.ref, Decision(
|
|
1718
|
+
Action.HELD,
|
|
1719
|
+
held + "filing a duplicate issue; first failure, retries next poll",
|
|
1720
|
+
task.task_number,
|
|
1721
|
+
))
|
|
1722
|
+
|
|
1723
|
+
failing_for = now - int(rows[0]["escalated_at"])
|
|
1724
|
+
if failing_for < floor_seconds:
|
|
1725
|
+
return self._logged(task.ref, Decision(
|
|
1726
|
+
Action.HELD,
|
|
1727
|
+
held + f"filing a duplicate issue; failing for "
|
|
1728
|
+
f"{failing_for // 3600}h, within the "
|
|
1729
|
+
f"{self.config.release_blocked_floor_days}d floor",
|
|
1730
|
+
task.task_number,
|
|
1731
|
+
))
|
|
1732
|
+
|
|
1733
|
+
self._escalate(
|
|
1734
|
+
task.task_number,
|
|
1735
|
+
ESCALATION_DEDUPE_UNREADABLE,
|
|
1736
|
+
DEDUPE_UNREADABLE_COMMENT.format(
|
|
1737
|
+
target=target,
|
|
1738
|
+
reason=reason,
|
|
1739
|
+
floor_days=self.config.release_blocked_floor_days,
|
|
1740
|
+
),
|
|
1741
|
+
)
|
|
1742
|
+
return self._logged(task.ref, Decision(
|
|
1743
|
+
Action.ESCALATED,
|
|
1744
|
+
held + f"filing a duplicate issue, and failing for "
|
|
1745
|
+
f"{failing_for // 86400}d — past the "
|
|
1746
|
+
f"{self.config.release_blocked_floor_days}d floor",
|
|
1747
|
+
task.task_number,
|
|
1748
|
+
))
|
|
1749
|
+
|
|
1750
|
+
def _dedupe_answered(self, task_number: int) -> None:
|
|
1751
|
+
"""The dedupe check answered, so any recorded failure is OVER: drop the
|
|
1752
|
+
first-seen marker and the page kind together.
|
|
1753
|
+
|
|
1754
|
+
Clearing the page kind is the point -- `_escalate`'s per-(task, kind)
|
|
1755
|
+
dedupe is what stops an outage paging every poll, and without a clear
|
|
1756
|
+
it would also stop a SECOND, unrelated outage paging at all. Guarded by
|
|
1757
|
+
a read so the common (working) path does not write on every release."""
|
|
1758
|
+
if self.config.dry_run:
|
|
1759
|
+
return
|
|
1760
|
+
for kind in (DEDUPE_UNREADABLE_SINCE, ESCALATION_DEDUPE_UNREADABLE):
|
|
1761
|
+
if self.state.escalated(task_number, kind):
|
|
1762
|
+
self.state.clear_escalation(task_number, kind)
|
|
1763
|
+
|
|
1271
1764
|
def _release(
|
|
1272
1765
|
self,
|
|
1273
1766
|
task: Task,
|
|
@@ -1283,8 +1776,16 @@ class Orchestrator:
|
|
|
1283
1776
|
daemon's trigger and the detach is the release's terminal act, so a
|
|
1284
1777
|
crash at any point leaves the work discoverable rather than lost.
|
|
1285
1778
|
Occupancy is updated in-memory so a later candidate in THIS pass sees
|
|
1286
|
-
the new occupant and respects the lane.
|
|
1779
|
+
the new occupant and respects the lane.
|
|
1780
|
+
|
|
1781
|
+
Nothing is created until the TRAILER dedupe answers: the pass-level
|
|
1782
|
+
scan is label-scoped and cannot see an unlabeled issue that already
|
|
1783
|
+
carries this task's trailer (see `_trailer_dedupe`)."""
|
|
1287
1784
|
owner, _, repo = target.partition("/")
|
|
1785
|
+
preexisting = self._trailer_dedupe(task, target, occupancy, bow=bow)
|
|
1786
|
+
if preexisting is not None:
|
|
1787
|
+
return preexisting
|
|
1788
|
+
|
|
1288
1789
|
body = render_issue_body(task, target, scope, self.config)
|
|
1289
1790
|
|
|
1290
1791
|
if self.config.dry_run:
|
|
@@ -67,6 +67,14 @@ CREATE TABLE IF NOT EXISTS meta (
|
|
|
67
67
|
"""
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
# The only `escalations` kinds `clear_escalation` will remove: the O9 dedupe
|
|
71
|
+
# outage clock and the page it raises, both of which describe a condition that
|
|
72
|
+
# can END (see `clear_escalation`). Spelled as literals here rather than
|
|
73
|
+
# imported from `loop`, which imports this module; `test_loop` pins that the
|
|
74
|
+
# loop's constants and this set stay the same two strings.
|
|
75
|
+
CLEARABLE_KINDS = frozenset({"marker:dedupe-unreadable", "dedupe-unreadable"})
|
|
76
|
+
|
|
77
|
+
|
|
70
78
|
class State:
|
|
71
79
|
def __init__(self, path: Path):
|
|
72
80
|
path = Path(path).expanduser()
|
|
@@ -153,16 +161,48 @@ class State:
|
|
|
153
161
|
).fetchone()
|
|
154
162
|
return row is not None
|
|
155
163
|
|
|
156
|
-
def record_escalation(self, task_number: int, kind: str) -> None:
|
|
164
|
+
def record_escalation(self, task_number: int, kind: str, at: int | None = None) -> None:
|
|
157
165
|
"""Idempotent per (task, kind): OR IGNORE keeps the FIRST escalation's
|
|
158
166
|
timestamp, so `escalated_at` is an audit field for when this kind was
|
|
159
|
-
first raised, not the most recent re-raise.
|
|
167
|
+
first raised, not the most recent re-raise.
|
|
168
|
+
|
|
169
|
+
`at` overrides the wall clock. Rows whose timestamp is only an audit
|
|
170
|
+
field can leave it None; a caller that MEASURES against `escalated_at`
|
|
171
|
+
must pass its own clock, because the loop's clock is injectable and a
|
|
172
|
+
row stamped from `time.time()` would be measured against a different
|
|
173
|
+
one (the O9 `marker:dedupe-unreadable` row is such a caller)."""
|
|
160
174
|
self._db.execute(
|
|
161
175
|
"INSERT OR IGNORE INTO escalations "
|
|
162
176
|
"(task_number, kind, escalated_at) VALUES (?,?,?)",
|
|
163
|
-
(task_number, kind, int(time.time())),
|
|
177
|
+
(task_number, kind, int(time.time()) if at is None else int(at)),
|
|
178
|
+
)
|
|
179
|
+
self._db.commit()
|
|
180
|
+
|
|
181
|
+
def clear_escalation(self, task_number: int, kind: str) -> bool:
|
|
182
|
+
"""Forget one (task, kind) row; True if a row was actually removed.
|
|
183
|
+
|
|
184
|
+
The only writer that UNDOES a row, and deliberately narrow: a condition
|
|
185
|
+
that has demonstrably ended (the O9 dedupe check answering again) must
|
|
186
|
+
not leave a first-seen timestamp that makes the NEXT outage look days
|
|
187
|
+
old, nor a spent page kind that can never fire again.
|
|
188
|
+
|
|
189
|
+
AUDIT kinds are never clearable, and that is enforced rather than
|
|
190
|
+
merely documented: clearing `closeloop-done` would let a fully-closed
|
|
191
|
+
loop be re-processed, and clearing an operator page would erase the
|
|
192
|
+
record that it was raised. Anything outside `CLEARABLE_KINDS` raises --
|
|
193
|
+
a caller reaching for it has a design error, not a runtime one."""
|
|
194
|
+
if kind not in CLEARABLE_KINDS:
|
|
195
|
+
raise ValueError(
|
|
196
|
+
f"{kind!r} is not clearable: only {sorted(CLEARABLE_KINDS)} are. "
|
|
197
|
+
"Escalation rows are an audit trail; a condition that ends "
|
|
198
|
+
"needs its own clearable kind, not the deletion of a page."
|
|
199
|
+
)
|
|
200
|
+
cursor = self._db.execute(
|
|
201
|
+
"DELETE FROM escalations WHERE task_number=? AND kind=?",
|
|
202
|
+
(task_number, kind),
|
|
164
203
|
)
|
|
165
204
|
self._db.commit()
|
|
205
|
+
return cursor.rowcount > 0
|
|
166
206
|
|
|
167
207
|
def escalations_matching(self, task_number: int, prefix: str) -> "list[sqlite3.Row]":
|
|
168
208
|
"""Escalation rows for this task whose `kind` starts with `prefix`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.3.3
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.3.2
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|