alissa-tools-github-orcloop 0.3.9__tar.gz → 0.3.11__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.9/src/main/alissa_tools_github_orcloop.egg-info → alissa_tools_github_orcloop-0.3.11}/PKG-INFO +1 -1
- {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.11}/src/main/alissa/tools/github/orcloop/__main__.py +13 -0
- {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.11}/src/main/alissa/tools/github/orcloop/config.py +35 -0
- {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.11}/src/main/alissa/tools/github/orcloop/ghclient.py +207 -20
- {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.11}/src/main/alissa/tools/github/orcloop/loop.py +625 -33
- alissa_tools_github_orcloop-0.3.11/src/main/alissa/tools/github/orcloop/version +1 -0
- {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.11/src/main/alissa_tools_github_orcloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_orcloop-0.3.9/src/main/alissa/tools/github/orcloop/version +0 -1
- {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.11}/MANIFEST.in +0 -0
- {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.11}/README.md +0 -0
- {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.11}/requirements.txt +0 -0
- {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.11}/setup.cfg +0 -0
- {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.11}/setup.py +0 -0
- {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.11}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
- {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.11}/src/main/alissa/tools/github/orcloop/alissa_client.py +0 -0
- {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.11}/src/main/alissa/tools/github/orcloop/markers.py +0 -0
- {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.11}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
- {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.11}/src/main/alissa/tools/github/orcloop/state.py +0 -0
- {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.11}/src/main/alissa/tools/github/orcloop/version.py +0 -0
- {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.11}/src/main/alissa_tools_github_orcloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.11}/src/main/alissa_tools_github_orcloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.11}/src/main/alissa_tools_github_orcloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.11}/src/main/alissa_tools_github_orcloop.egg-info/top_level.txt +0 -0
|
@@ -145,6 +145,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
145
145
|
"disables it). The sweep reports, it never releases.",
|
|
146
146
|
)
|
|
147
147
|
|
|
148
|
+
over.add_argument(
|
|
149
|
+
"--autoclose-grace-seconds",
|
|
150
|
+
type=int,
|
|
151
|
+
metavar="SECONDS",
|
|
152
|
+
help="close edge only: how long to let GitHub close a released issue "
|
|
153
|
+
"itself, measured from the closing PR's mergedAt, before the daemon "
|
|
154
|
+
"closes it mechanically (default 300; 0 = no grace). GitHub's "
|
|
155
|
+
"`Closes #n` auto-close is best-effort and can silently never fire, "
|
|
156
|
+
"which would otherwise leave the issue open and its origin task stuck "
|
|
157
|
+
"forever.",
|
|
158
|
+
)
|
|
159
|
+
|
|
148
160
|
close = over.add_mutually_exclusive_group()
|
|
149
161
|
close.add_argument(
|
|
150
162
|
"--close-loop",
|
|
@@ -191,6 +203,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
191
203
|
"alissa_endpoint": args.alissa_endpoint,
|
|
192
204
|
"ledger_path": args.ledger_path,
|
|
193
205
|
"close_loop_enabled": args.close_loop_enabled,
|
|
206
|
+
"autoclose_grace_seconds": args.autoclose_grace_seconds,
|
|
194
207
|
"dry_run": args.dry_run,
|
|
195
208
|
"release_feed": args.release_feed,
|
|
196
209
|
"bow_prefix": args.bow_prefix,
|
|
@@ -121,6 +121,7 @@ CONFIG_KEYS = (
|
|
|
121
121
|
"dry_run",
|
|
122
122
|
"ledger_path",
|
|
123
123
|
"close_loop_enabled",
|
|
124
|
+
"autoclose_grace_seconds",
|
|
124
125
|
"release_feed",
|
|
125
126
|
"bow_prefix",
|
|
126
127
|
"bow_owners",
|
|
@@ -499,6 +500,25 @@ class Config:
|
|
|
499
500
|
# half; every side effect still honors dry_run. Off reverts to release-only.
|
|
500
501
|
close_loop_enabled: bool = True
|
|
501
502
|
|
|
503
|
+
# How long to let GitHub close a released issue itself, measured from the
|
|
504
|
+
# closing PR's `mergedAt`, before the close edge closes it mechanically.
|
|
505
|
+
#
|
|
506
|
+
# GitHub's `Closes #n` auto-close is best-effort, not a contract: it has been
|
|
507
|
+
# observed to silently never fire on a properly-linked PR while a sibling
|
|
508
|
+
# merge seconds earlier auto-closed normally. Past this window an issue whose
|
|
509
|
+
# closing PR has MERGED is treated as a flaked platform step and completed by
|
|
510
|
+
# the daemon (issue #40). Within it, nothing happens -- the daemon never
|
|
511
|
+
# races the platform for a step the platform usually performs.
|
|
512
|
+
#
|
|
513
|
+
# Minutes, not seconds or hours: auto-close normally lands within seconds, so
|
|
514
|
+
# a few minutes is far outside the healthy distribution, while an hour would
|
|
515
|
+
# leave the released work's lane occupied for an hour on every flake. `0`
|
|
516
|
+
# means "no grace" (close on the first pass that sees the merge) and is legal
|
|
517
|
+
# -- it is a race with the platform, not a correctness problem, since the
|
|
518
|
+
# close is idempotent -- but it makes a normal auto-close look like an
|
|
519
|
+
# anomaly, so it is not the default.
|
|
520
|
+
autoclose_grace_seconds: int = 300
|
|
521
|
+
|
|
502
522
|
dry_run: bool = False
|
|
503
523
|
|
|
504
524
|
# Where release candidates come from: `scan` (all committed tasks, filtered
|
|
@@ -651,6 +671,20 @@ class Config:
|
|
|
651
671
|
f"release_blocked_floor_days must be >= 1, got {floor_days}"
|
|
652
672
|
)
|
|
653
673
|
|
|
674
|
+
grace = int(raw.get("autoclose_grace_seconds", cls.autoclose_grace_seconds))
|
|
675
|
+
if grace < 0:
|
|
676
|
+
# No upper bound and no MIN_POLL_INTERVAL floor, unlike the sweep:
|
|
677
|
+
# this is a wait, not a cadence, so a small value only makes the
|
|
678
|
+
# daemon act sooner (`0` = act on the first pass that sees the
|
|
679
|
+
# merge), and a large one only makes it wait longer. Neither can
|
|
680
|
+
# produce the runaway a sub-poll INTERVAL can. Negative is the one
|
|
681
|
+
# value with no reading at all -- it would say "close it before the
|
|
682
|
+
# PR merged" -- so it is rejected rather than clamped.
|
|
683
|
+
raise ValueError(
|
|
684
|
+
f"autoclose_grace_seconds must be >= 0 (0 = no grace, close as "
|
|
685
|
+
f"soon as the merge is seen), got {grace}"
|
|
686
|
+
)
|
|
687
|
+
|
|
654
688
|
marker_key = str(raw.get("marker_key", cls.marker_key))
|
|
655
689
|
scope_key = str(raw.get("scope_key", cls.scope_key))
|
|
656
690
|
for name, value in (("marker_key", marker_key), ("scope_key", scope_key)):
|
|
@@ -721,6 +755,7 @@ class Config:
|
|
|
721
755
|
close_loop_enabled=bool(
|
|
722
756
|
raw.get("close_loop_enabled", cls.close_loop_enabled)
|
|
723
757
|
),
|
|
758
|
+
autoclose_grace_seconds=grace,
|
|
724
759
|
dry_run=bool(raw.get("dry_run", cls.dry_run)),
|
|
725
760
|
release_feed=release_feed,
|
|
726
761
|
bow_prefix=bow_prefix,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"""GitHub access via `gh api`.
|
|
2
2
|
|
|
3
3
|
orcloop's GitHub side: create the `alissa:develop` issue for a released task,
|
|
4
|
-
manage its label, read issues back, search the watched repos, comment,
|
|
5
|
-
|
|
4
|
+
manage its label, read issues back, search the watched repos, comment, close an
|
|
5
|
+
issue the platform should have closed itself, and -- for the close-loop -- find
|
|
6
|
+
the MERGED pull request that closed a released issue.
|
|
6
7
|
|
|
7
8
|
Targets gh 2.4.0, which predates `gh search` and `gh issue`-level JSON we can
|
|
8
9
|
rely on: every call goes through `gh api` against REST v3. The gh-2.4.0-safe
|
|
@@ -24,6 +25,7 @@ import os
|
|
|
24
25
|
import re
|
|
25
26
|
import tempfile
|
|
26
27
|
import urllib.parse
|
|
28
|
+
from datetime import datetime, timezone
|
|
27
29
|
from dataclasses import dataclass
|
|
28
30
|
|
|
29
31
|
from . import proc
|
|
@@ -97,6 +99,33 @@ def parse_task_ref(body: str) -> int | None:
|
|
|
97
99
|
return int(match.group(1)) if match else None
|
|
98
100
|
|
|
99
101
|
|
|
102
|
+
def parse_timestamp(value: str | None) -> float | None:
|
|
103
|
+
"""A GitHub RFC 3339 timestamp (`2026-07-28T04:42:55Z`) as unix seconds, or
|
|
104
|
+
None when it is missing or unparseable.
|
|
105
|
+
|
|
106
|
+
`fromisoformat` handles the `Z` suffix from 3.11 on, which `setup.py`
|
|
107
|
+
already requires. A timestamp carrying no zone is read as UTC rather than as
|
|
108
|
+
the daemon's local time: every timestamp GitHub sends is UTC, and a
|
|
109
|
+
container running in a non-UTC zone would otherwise shift an age by hours --
|
|
110
|
+
silently, and in whichever direction the deployment happens to sit.
|
|
111
|
+
|
|
112
|
+
None rather than an exception, for the same reason `pull_request` degrades a
|
|
113
|
+
malformed payload to None: the callers here are decisions, not parsers, and
|
|
114
|
+
a field GitHub omitted must not be able to crash a poll. What "unknown"
|
|
115
|
+
MEANS is the caller's to decide, which is why it is a distinct answer and
|
|
116
|
+
not, say, 0.
|
|
117
|
+
"""
|
|
118
|
+
if not value:
|
|
119
|
+
return None
|
|
120
|
+
try:
|
|
121
|
+
parsed = datetime.fromisoformat(str(value).strip())
|
|
122
|
+
except ValueError:
|
|
123
|
+
return None
|
|
124
|
+
if parsed.tzinfo is None:
|
|
125
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
126
|
+
return parsed.timestamp()
|
|
127
|
+
|
|
128
|
+
|
|
100
129
|
@dataclass(frozen=True)
|
|
101
130
|
class Issue:
|
|
102
131
|
owner: str
|
|
@@ -107,6 +136,17 @@ class Issue:
|
|
|
107
136
|
body: str
|
|
108
137
|
url: str
|
|
109
138
|
labels: tuple[str, ...]
|
|
139
|
+
# `state_reason`, verbatim: `completed` / `not_planned` / `reopened`, or ""
|
|
140
|
+
# when GitHub did not state one. Defaulted like the PullRequest fields below
|
|
141
|
+
# -- it is a late addition to the API and an issue reopened long ago (or
|
|
142
|
+
# through an unusual path) can carry nothing here.
|
|
143
|
+
#
|
|
144
|
+
# `reopened` is the one value the close edge acts on, and it is the cheapest
|
|
145
|
+
# answer to "has this issue ever been closed?" -- a question `state` cannot
|
|
146
|
+
# answer and, verified against a real reopened issue (golang/go#79623),
|
|
147
|
+
# `closed_at` cannot either: GitHub CLEARS `closed_at` on reopen, so it is
|
|
148
|
+
# null on exactly the issues that matter. See `_evaluate_open_released`.
|
|
149
|
+
state_reason: str = ""
|
|
110
150
|
|
|
111
151
|
@property
|
|
112
152
|
def full_name(self) -> str:
|
|
@@ -116,6 +156,11 @@ class Issue:
|
|
|
116
156
|
def issue_slug(self) -> str:
|
|
117
157
|
return f"{self.owner}/{self.repo}#{self.number}"
|
|
118
158
|
|
|
159
|
+
@property
|
|
160
|
+
def reopened(self) -> bool:
|
|
161
|
+
"""Whether GitHub says this issue was re-opened after being closed."""
|
|
162
|
+
return self.state_reason == "reopened"
|
|
163
|
+
|
|
119
164
|
|
|
120
165
|
@dataclass(frozen=True)
|
|
121
166
|
class PullRequest:
|
|
@@ -132,6 +177,20 @@ class PullRequest:
|
|
|
132
177
|
# meaningful only on a merged PR, and a payload that omits it must degrade
|
|
133
178
|
# to "unknown" in the evidence rather than fail the close walk.
|
|
134
179
|
merge_sha: str = ""
|
|
180
|
+
# The branch this PR merged INTO (`base.ref`), verbatim. GitHub only
|
|
181
|
+
# auto-closes a linked issue when the PR merges into the repository's
|
|
182
|
+
# DEFAULT branch, so this is what separates "the auto-close flaked" from
|
|
183
|
+
# "the platform correctly declined to auto-close a merge into `develop`".
|
|
184
|
+
# Defaulted like the fields below: a payload that omits it says nothing, and
|
|
185
|
+
# the close edge refuses to repair on a base it cannot read.
|
|
186
|
+
base_ref: str = ""
|
|
187
|
+
# `merged_at`, verbatim as GitHub sent it (RFC 3339, `2026-07-28T04:42:55Z`).
|
|
188
|
+
# Kept as the RAW string rather than a parsed timestamp for the same reason
|
|
189
|
+
# `merge_sha` is defaulted: this dataclass mirrors a payload, and a field the
|
|
190
|
+
# payload omitted must read as "not stated" rather than as an epoch the
|
|
191
|
+
# daemon invented. `merged_epoch` below is the parsed view, and it answers
|
|
192
|
+
# None on exactly the payloads that omit or malform it.
|
|
193
|
+
merged_at: str = ""
|
|
135
194
|
|
|
136
195
|
@property
|
|
137
196
|
def full_name(self) -> str:
|
|
@@ -141,6 +200,31 @@ class PullRequest:
|
|
|
141
200
|
def pr_slug(self) -> str:
|
|
142
201
|
return f"{self.owner}/{self.repo}#{self.number}"
|
|
143
202
|
|
|
203
|
+
@property
|
|
204
|
+
def merged_epoch(self) -> float | None:
|
|
205
|
+
"""`merged_at` as unix seconds, or None when it is absent/unparseable.
|
|
206
|
+
|
|
207
|
+
The close edge measures GitHub's auto-close grace window from this, so
|
|
208
|
+
"unknown" must be distinguishable from "long ago" -- see
|
|
209
|
+
`Orchestrator._evaluate_open_released`, which is what decides how an
|
|
210
|
+
unknown merge time behaves."""
|
|
211
|
+
return parse_timestamp(self.merged_at)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass(frozen=True)
|
|
215
|
+
class CloseWalk:
|
|
216
|
+
"""What ONE walk of an issue's timeline establishes about how it closed --
|
|
217
|
+
or failed to.
|
|
218
|
+
|
|
219
|
+
Two facts, deliberately together: the merged PR that declares it closes the
|
|
220
|
+
issue (None if there is none), and whether the issue has ever been closed at
|
|
221
|
+
all. The close edge's repair needs both and must not pay for two walks to
|
|
222
|
+
get them; keeping them in one record is also what stops a caller from
|
|
223
|
+
answering "was it closed?" with the cheaper, wrong `state == "closed"`."""
|
|
224
|
+
|
|
225
|
+
pr: "PullRequest | None"
|
|
226
|
+
ever_closed: bool
|
|
227
|
+
|
|
144
228
|
|
|
145
229
|
class RateLimited(RuntimeError):
|
|
146
230
|
"""A `gh` rate-limit signal -- a retry-later condition, distinct from a
|
|
@@ -155,6 +239,11 @@ def _is_rate_limited(exc: proc.CommandError) -> bool:
|
|
|
155
239
|
class GitHub:
|
|
156
240
|
SEARCH_PER_PAGE = 100
|
|
157
241
|
|
|
242
|
+
def __init__(self) -> None:
|
|
243
|
+
# Per-repo default branch, resolved lazily and kept for the process.
|
|
244
|
+
# See `default_branch`; only successful reads land here.
|
|
245
|
+
self._default_branches: dict[str, str] = {}
|
|
246
|
+
|
|
158
247
|
def _api(self, *args: str, timeout: int = 60):
|
|
159
248
|
try:
|
|
160
249
|
return proc.run_json(["gh", "api", *args], timeout=timeout)
|
|
@@ -348,6 +437,38 @@ class GitHub:
|
|
|
348
437
|
f"repos/{owner}/{repo}/issues/{number}/comments", "-f", f"body={body}"
|
|
349
438
|
)
|
|
350
439
|
|
|
440
|
+
def close_issue(
|
|
441
|
+
self, owner: str, repo: str, number: int, reason: str = "completed"
|
|
442
|
+
) -> Issue:
|
|
443
|
+
"""Close an issue, stating WHY, and return it as GitHub now reports it.
|
|
444
|
+
|
|
445
|
+
The only issue-state mutation orcloop performs, and it exists for one
|
|
446
|
+
anomaly: GitHub's `Closes #n` auto-close is best-effort, so a merged
|
|
447
|
+
closing PR can leave its issue open forever (see the close edge's
|
|
448
|
+
`_evaluate_open_released`). `reason` maps to `state_reason`, which is
|
|
449
|
+
what distinguishes a completed issue from an abandoned one in GitHub's
|
|
450
|
+
own UI -- the daemon only ever completes work the platform confirmed
|
|
451
|
+
merged, so the default is `completed` and no caller passes anything else
|
|
452
|
+
today.
|
|
453
|
+
|
|
454
|
+
PATCH, through the same `--input <tempfile>` path `_post_json` uses:
|
|
455
|
+
gh 2.4.0's `-f` sends everything as a string, and `state_reason` is a
|
|
456
|
+
closed enum whose values would still be strings but whose siblings on
|
|
457
|
+
this endpoint (`labels`, `assignees`) are arrays -- one JSON body is
|
|
458
|
+
both simpler and the pattern the rest of this file already follows.
|
|
459
|
+
|
|
460
|
+
Idempotent on GitHub's side: closing an already-closed issue is a 200
|
|
461
|
+
that leaves it closed, so a retry after a lost response is harmless.
|
|
462
|
+
"""
|
|
463
|
+
data = self._send_json(
|
|
464
|
+
"PATCH",
|
|
465
|
+
f"repos/{owner}/{repo}/issues/{number}",
|
|
466
|
+
{"state": "closed", "state_reason": reason},
|
|
467
|
+
)
|
|
468
|
+
return self._issue_from_payload(
|
|
469
|
+
owner, repo, data if isinstance(data, dict) else {}
|
|
470
|
+
)
|
|
471
|
+
|
|
351
472
|
# --- the close-loop: the merged-PR walk --------------------------------
|
|
352
473
|
|
|
353
474
|
def closing_merged_pr(self, owner: str, repo: str, number: int) -> PullRequest | None:
|
|
@@ -372,15 +493,55 @@ class GitHub:
|
|
|
372
493
|
wedge the close decision. Newest-referenced PR wins ties -- the last
|
|
373
494
|
cross-reference is the one most likely to be the real closer.
|
|
374
495
|
"""
|
|
375
|
-
|
|
496
|
+
return self.close_walk(owner, repo, number).pr
|
|
497
|
+
|
|
498
|
+
def close_walk(self, owner: str, repo: str, number: int) -> "CloseWalk":
|
|
499
|
+
"""`closing_merged_pr`'s answer PLUS whether the issue was ever closed,
|
|
500
|
+
both from the SAME timeline walk.
|
|
501
|
+
|
|
502
|
+
The close edge's flaked-auto-close repair (O16) needs both facts, and
|
|
503
|
+
needs them not to cost two walks: "is there a merged closer?" decides
|
|
504
|
+
whether there is anything to repair, and "was this issue ever closed?"
|
|
505
|
+
decides whether there is anything WRONG -- an issue that was closed once
|
|
506
|
+
and is open again was re-opened by a human, which is indistinguishable
|
|
507
|
+
from a flake on every other signal (same open state, same merged PR,
|
|
508
|
+
same elapsed time since `mergedAt`).
|
|
509
|
+
|
|
510
|
+
Split out from `closing_merged_pr` rather than folded into it so the
|
|
511
|
+
closed-issue path keeps its exact signature and semantics: that caller
|
|
512
|
+
wants a PR, has no use for the history, and must not have to learn a new
|
|
513
|
+
return shape for a question it never asks."""
|
|
514
|
+
candidates, ever_closed = self._timeline_facts(owner, repo, number)
|
|
376
515
|
pattern = _closes_re(number)
|
|
377
516
|
for pr_number in candidates[:MAX_PR_CHECKS]:
|
|
378
517
|
pr = self.pull_request(owner, repo, pr_number)
|
|
379
518
|
if pr is None or not pr.merged:
|
|
380
519
|
continue
|
|
381
520
|
if pattern.search(pr.body):
|
|
382
|
-
return pr
|
|
383
|
-
return None
|
|
521
|
+
return CloseWalk(pr=pr, ever_closed=ever_closed)
|
|
522
|
+
return CloseWalk(pr=None, ever_closed=ever_closed)
|
|
523
|
+
|
|
524
|
+
def default_branch(self, owner: str, repo: str) -> str:
|
|
525
|
+
"""The repository's default branch, memoised for this process.
|
|
526
|
+
|
|
527
|
+
GitHub auto-closes a linked issue only on a merge into this branch, so
|
|
528
|
+
the close edge needs it to tell a flaked auto-close from a merge the
|
|
529
|
+
platform correctly declined to act on. Memoised because a default branch
|
|
530
|
+
does not move within a poll (or most weeks) and the daemon watches a
|
|
531
|
+
handful of repos: one read per repo per process, never per poll. A
|
|
532
|
+
failure is NOT cached -- it must be retried, not turned into a permanent
|
|
533
|
+
empty answer that would look like an unreadable base forever."""
|
|
534
|
+
key = f"{owner}/{repo}"
|
|
535
|
+
cached = self._default_branches.get(key)
|
|
536
|
+
if cached is not None:
|
|
537
|
+
return cached
|
|
538
|
+
data = self._api(f"repos/{owner}/{repo}")
|
|
539
|
+
branch = str((data or {}).get("default_branch") or "") if isinstance(
|
|
540
|
+
data, dict
|
|
541
|
+
) else ""
|
|
542
|
+
if branch:
|
|
543
|
+
self._default_branches[key] = branch
|
|
544
|
+
return branch
|
|
384
545
|
|
|
385
546
|
def pull_request(self, owner: str, repo: str, number: int) -> PullRequest | None:
|
|
386
547
|
"""One PR's close-loop-relevant fields, or None if it cannot be read.
|
|
@@ -388,12 +549,12 @@ class GitHub:
|
|
|
388
549
|
-- the close-loop treats "unreadable" as "not the closer, keep
|
|
389
550
|
looking", never as a merge.
|
|
390
551
|
|
|
391
|
-
`body` and `
|
|
392
|
-
close edge's delivery-record evidence (O15)
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
fetched a second time."""
|
|
552
|
+
`body`, `merge_sha` and `merged_at` all come from this ONE payload,
|
|
553
|
+
which is why the close edge's delivery-record evidence (O15) and its
|
|
554
|
+
auto-close grace window (O16) cost no extra round-trip: the PR the close
|
|
555
|
+
walk already resolved carries everything both need. A field the payload
|
|
556
|
+
omits degrades to the empty string here and is reported as missing at
|
|
557
|
+
the use site -- never fabricated, never fetched a second time."""
|
|
397
558
|
data = self._api(f"repos/{owner}/{repo}/pulls/{number}")
|
|
398
559
|
if not isinstance(data, dict) or not data:
|
|
399
560
|
return None
|
|
@@ -407,13 +568,29 @@ class GitHub:
|
|
|
407
568
|
merged=bool(data.get("merged") or data.get("merged_at")),
|
|
408
569
|
url=data.get("html_url") or "",
|
|
409
570
|
merge_sha=str(data.get("merge_commit_sha") or ""),
|
|
571
|
+
merged_at=str(data.get("merged_at") or ""),
|
|
572
|
+
base_ref=str(((data.get("base") or {}) if isinstance(
|
|
573
|
+
data.get("base"), dict) else {}).get("ref") or ""),
|
|
410
574
|
)
|
|
411
575
|
|
|
412
|
-
def
|
|
413
|
-
|
|
414
|
-
|
|
576
|
+
def _timeline_facts(
|
|
577
|
+
self, owner: str, repo: str, number: int
|
|
578
|
+
) -> tuple[list[int], bool]:
|
|
579
|
+
"""One timeline walk, two answers: the same-repo PR numbers it
|
|
580
|
+
cross-references (newest first, deduped -- the candidate set for
|
|
581
|
+
`close_walk`) and whether it carries a `closed` event.
|
|
582
|
+
|
|
583
|
+
The `closed` event is what makes "has this issue EVER been closed?"
|
|
584
|
+
answerable. It is durable in a way the issue's own fields are not: a
|
|
585
|
+
reopen clears `closed_at` and leaves `state` at `open`, so the timeline
|
|
586
|
+
is the only place the history survives (`golang/go#79623`, a real
|
|
587
|
+
reopened issue, reports `closed_at: null` while its timeline still
|
|
588
|
+
carries `closed`). Bounded by the same TIMELINE_MAX_PAGES page cap as
|
|
589
|
+
the cross-reference scan, which is why the caller does not rely on it
|
|
590
|
+
alone -- see `Issue.reopened`."""
|
|
415
591
|
same_repo_pull = f"https://github.com/{owner}/{repo}/pull/"
|
|
416
592
|
numbers: list[int] = []
|
|
593
|
+
ever_closed = False
|
|
417
594
|
for page in range(1, TIMELINE_MAX_PAGES + 1):
|
|
418
595
|
events = self._api(
|
|
419
596
|
"-X", "GET", f"repos/{owner}/{repo}/issues/{number}/timeline",
|
|
@@ -422,7 +599,11 @@ class GitHub:
|
|
|
422
599
|
if not isinstance(events, list):
|
|
423
600
|
break
|
|
424
601
|
for event in events:
|
|
425
|
-
if not isinstance(event, dict)
|
|
602
|
+
if not isinstance(event, dict):
|
|
603
|
+
continue
|
|
604
|
+
if event.get("event") == "closed":
|
|
605
|
+
ever_closed = True
|
|
606
|
+
if event.get("event") != "cross-referenced":
|
|
426
607
|
continue
|
|
427
608
|
source = event.get("source")
|
|
428
609
|
src = source.get("issue") if isinstance(source, dict) else None
|
|
@@ -438,19 +619,24 @@ class GitHub:
|
|
|
438
619
|
if len(events) < TIMELINE_PAGE_SIZE:
|
|
439
620
|
break # short page: the timeline is exhausted
|
|
440
621
|
# Dedupe keeping LAST occurrence: the most recent cross-reference is the
|
|
441
|
-
# likeliest closer, and
|
|
442
|
-
return list(dict.fromkeys(reversed(numbers)))
|
|
622
|
+
# likeliest closer, and close_walk checks candidates in order.
|
|
623
|
+
return list(dict.fromkeys(reversed(numbers))), ever_closed
|
|
443
624
|
|
|
444
625
|
# --- helpers -----------------------------------------------------------
|
|
445
626
|
|
|
446
627
|
def _post_json(self, path: str, payload: dict):
|
|
447
|
-
"""POST a JSON body via `--input <tempfile
|
|
448
|
-
|
|
628
|
+
"""POST a JSON body via `--input <tempfile>`."""
|
|
629
|
+
return self._send_json("POST", path, payload)
|
|
630
|
+
|
|
631
|
+
def _send_json(self, method: str, path: str, payload: dict):
|
|
632
|
+
"""Send a JSON body via `--input <tempfile>`, cleaning the file up on
|
|
633
|
+
every path (success or raise). One implementation for every verb: the
|
|
634
|
+
tempfile lifetime is the part worth having exactly once."""
|
|
449
635
|
fd, body_path = tempfile.mkstemp(prefix="orcloop-", suffix=".json")
|
|
450
636
|
try:
|
|
451
637
|
with os.fdopen(fd, "w") as handle:
|
|
452
638
|
handle.write(json.dumps(payload))
|
|
453
|
-
return self._api("-X",
|
|
639
|
+
return self._api("-X", method, path, "--input", body_path)
|
|
454
640
|
finally:
|
|
455
641
|
os.unlink(body_path)
|
|
456
642
|
|
|
@@ -469,6 +655,7 @@ class GitHub:
|
|
|
469
655
|
(lb.get("name") or "") if isinstance(lb, dict) else str(lb)
|
|
470
656
|
for lb in (data.get("labels") or [])
|
|
471
657
|
),
|
|
658
|
+
state_reason=str(data.get("state_reason") or ""),
|
|
472
659
|
)
|
|
473
660
|
|
|
474
661
|
|
|
@@ -213,7 +213,19 @@ so it behaves identically under `scan` and `bow` (and keeps driving issues
|
|
|
213
213
|
released before a feed switch):
|
|
214
214
|
|
|
215
215
|
* Poll each released issue (the ledger is the set to re-check; GitHub is the
|
|
216
|
-
truth for its state). Still open -> in flight,
|
|
216
|
+
truth for its state). Still open with no merged closing PR -> in flight,
|
|
217
|
+
nothing to do.
|
|
218
|
+
* Still OPEN but a merged pull request already declares `Closes #n` (O16, issue
|
|
219
|
+
#40) -> GitHub's auto-close is best-effort and has been observed to silently
|
|
220
|
+
never fire, leaving a phantom "in-flight" issue that holds its lane and an
|
|
221
|
+
origin task stuck at `in_progress` with nothing escalated. Inside
|
|
222
|
+
`autoclose_grace_seconds` of the merge, nothing happens -- the daemon does not
|
|
223
|
+
race the platform. Past it, the daemon closes the issue itself (`completed`,
|
|
224
|
+
commented, WARNING-logged) and falls into the close walk below in the SAME
|
|
225
|
+
pass. A close that FAILS holds the row untouched; an open issue with no merged
|
|
226
|
+
closer is never touched; and the repair runs EXACTLY once, so an issue an
|
|
227
|
+
operator re-opens afterwards is left alone for good. See
|
|
228
|
+
`_evaluate_open_released`.
|
|
217
229
|
* Closed with a MERGED pull request that declares `Closes #n` -> close the
|
|
218
230
|
loop on the ORIGIN task: satisfy every validation criterion, attach the
|
|
219
231
|
merged-PR evidence, and move the task `committed -> in_progress ->
|
|
@@ -285,7 +297,7 @@ import logging
|
|
|
285
297
|
import re
|
|
286
298
|
import time
|
|
287
299
|
from collections import Counter
|
|
288
|
-
from dataclasses import dataclass
|
|
300
|
+
from dataclasses import dataclass, replace
|
|
289
301
|
from enum import Enum
|
|
290
302
|
from functools import lru_cache
|
|
291
303
|
from typing import Callable, Sequence
|
|
@@ -416,6 +428,27 @@ ESCALATION_CLOSELOOP_FAILED = "closeloop-failed"
|
|
|
416
428
|
ESCALATION_ALREADY_MERGED = "already-merged"
|
|
417
429
|
ESCALATION_DEDUPE_UNREADABLE = "dedupe-unreadable"
|
|
418
430
|
|
|
431
|
+
# The close edge could not write its evidence AT ALL -- not the enriched
|
|
432
|
+
# delivery record, not the bare confirmation line -- for longer than the
|
|
433
|
+
# close-loop retry floor, so the hand-off completed WITHOUT evidence (O17,
|
|
434
|
+
# issue #44). Terminal by construction: it is raised on the pass that also
|
|
435
|
+
# records `CLOSELOOP_EVIDENCE_DONE`, so there is never a second attempt to page
|
|
436
|
+
# about. That is the whole point -- see `_close_evidence_unwritable`.
|
|
437
|
+
ESCALATION_CLOSE_EVIDENCE_FAILED = "close-evidence-failed"
|
|
438
|
+
|
|
439
|
+
# NOT an operator page: the timestamp of the FIRST permanent evidence rejection
|
|
440
|
+
# on this task, so the floor above it measures how long THE FAILURE has lasted.
|
|
441
|
+
# Same shape and same reasoning as `DEDUPE_UNREADABLE_SINCE` (including the
|
|
442
|
+
# load-bearing `marker:` namespace, which keeps it out of every prefix read of
|
|
443
|
+
# the page kind above), with one difference: this clock is never CLEARED. The
|
|
444
|
+
# dedupe/context clocks time a condition that recurs on every poll of a task
|
|
445
|
+
# still waiting to be released, so a blip that clears must not leave a stale
|
|
446
|
+
# start time behind. The close-evidence step settles exactly once per task
|
|
447
|
+
# (`CLOSELOOP_EVIDENCE_DONE` closes it for good, on every path), so this clock
|
|
448
|
+
# can never be re-read after it settles -- leaving the row is free, and it is
|
|
449
|
+
# the audit record of when the failure began.
|
|
450
|
+
CLOSE_EVIDENCE_FAILED_SINCE = "marker:close-evidence-failed"
|
|
451
|
+
|
|
419
452
|
# NOT an operator page: the timestamp of the FIRST dedupe check that could not
|
|
420
453
|
# answer for this task, so the floor above it measures how long the CONDITION
|
|
421
454
|
# has lasted rather than how old the task is. Task age is a fair proxy for a
|
|
@@ -459,15 +492,33 @@ MAX_CLOSED_TRAILER_CHECKS = 3
|
|
|
459
492
|
# O2 design) purely for its per-(task, kind) dedupe; no comment is ever posted.
|
|
460
493
|
CLOSELOOP_DONE = "closeloop-done"
|
|
461
494
|
|
|
462
|
-
# NOT an operator page: an idempotency marker recording that the close-loop
|
|
463
|
-
#
|
|
464
|
-
#
|
|
465
|
-
#
|
|
466
|
-
#
|
|
467
|
-
#
|
|
468
|
-
#
|
|
495
|
+
# NOT an operator page: an idempotency marker recording that the close-loop's
|
|
496
|
+
# evidence step is SETTLED on the origin task. Gating the evidence write on THIS
|
|
497
|
+
# (rather than on the task still being `committed`) survives a partial-failure
|
|
498
|
+
# retry that died AFTER the `committed → in_progress` step but BEFORE the
|
|
499
|
+
# evidence write -- otherwise the retry re-enters at `in_progress`, reads
|
|
500
|
+
# `first_entry` as False, and skips the evidence permanently.
|
|
501
|
+
#
|
|
502
|
+
# "Settled", not "attached", since O17 (issue #44): the marker is recorded when
|
|
503
|
+
# the enriched record lands, when the DEGRADED bare confirmation lands in its
|
|
504
|
+
# place, and when both have been rejected permanently past the floor and the
|
|
505
|
+
# hand-off gave up on evidence entirely. All three are ends of the same step --
|
|
506
|
+
# there is nothing left for a later poll to attempt -- and one marker for the
|
|
507
|
+
# step is what makes "no double write, no re-page" true on every path. It is
|
|
508
|
+
# never recorded while a retry is still owed: a transient failure, or a
|
|
509
|
+
# permanent one inside the floor, raises instead, leaving the marker absent so
|
|
510
|
+
# the next poll re-enters exactly as before.
|
|
469
511
|
CLOSELOOP_EVIDENCE_DONE = "closeloop-evidence-done"
|
|
470
512
|
|
|
513
|
+
# NOT an operator page: an idempotency marker recording that the daemon closed a
|
|
514
|
+
# released issue GitHub's `Closes #n` auto-close never closed (O16, issue #40).
|
|
515
|
+
# It is what makes that repair happen EXACTLY once: an issue seen open again
|
|
516
|
+
# with this marker set was re-opened by an operator -- the very escape hatch the
|
|
517
|
+
# posted comment offers -- and is left alone for good, the close-edge twin of
|
|
518
|
+
# park's "never re-labeling". Recorded only after the close succeeds, so a
|
|
519
|
+
# failed close retries next poll.
|
|
520
|
+
AUTOCLOSE_HEALED = "autoclose-healed"
|
|
521
|
+
|
|
471
522
|
# The origin status the close-loop mutation itself hands off to. Seeing the task
|
|
472
523
|
# already here (before CLOSELOOP_DONE was recorded) means a prior pass completed
|
|
473
524
|
# the mutation but died before the completion marker -- orcloop's OWN transition,
|
|
@@ -613,6 +664,78 @@ CLOSELOOP_FAILED_ISSUE_COMMENT = (
|
|
|
613
664
|
"Retrying; operator help may be needed."
|
|
614
665
|
)
|
|
615
666
|
|
|
667
|
+
# The TERMINAL page of the close edge (O17, issue #44). Everything a page has to
|
|
668
|
+
# say when the daemon stops: what it could not do, what it is doing anyway, that
|
|
669
|
+
# it will not try that again, and what is left for a human. The last part
|
|
670
|
+
# matters most -- the operator's soft validator is what reads evidence, and this
|
|
671
|
+
# task now reaches it without any, so the comment has to say where the record
|
|
672
|
+
# went.
|
|
673
|
+
#
|
|
674
|
+
# EVERY CLAIM IS SCOPED TO WHAT IS TRUE AT POST TIME, and that is a constraint
|
|
675
|
+
# the wording had to be rewritten for (review round 1). The page is posted from
|
|
676
|
+
# `_close_evidence_unwritable`, which runs BEFORE `_mutate_close`'s
|
|
677
|
+
# `set_status(pending_validation)` -- it has to, because the page landing is
|
|
678
|
+
# what licenses the walk to continue. So it must not report the hand-off in the
|
|
679
|
+
# past tense (a hand-off write can fail, transiently or for good, and then the
|
|
680
|
+
# task sits at `in_progress` under a comment claiming otherwise), and it must
|
|
681
|
+
# not promise silence on behalf of the whole loop (a failed hand-off pages
|
|
682
|
+
# `closeloop-failed:a<j>` on the very next line, retracting the retraction).
|
|
683
|
+
#
|
|
684
|
+
# What IS terminal here is exactly the step this page is about: the evidence
|
|
685
|
+
# will never be attempted again -- `CLOSELOOP_EVIDENCE_DONE` is recorded on this
|
|
686
|
+
# same pass, on every path out of it. So the terminality is stated about the
|
|
687
|
+
# evidence, and the close-loop's own retries are named as the separate condition
|
|
688
|
+
# they are. Same standard `_autoclose_heal`'s comment holds for a mere WARNING
|
|
689
|
+
# ("AFTER the close, never before it"), applied to something that outranks it.
|
|
690
|
+
CLOSE_EVIDENCE_FAILED_TASK_COMMENT = (
|
|
691
|
+
"**Orchestrator: close evidence could not be attached** — the released "
|
|
692
|
+
"issue {slug} merged, but attaching the merged-pull-request evidence to "
|
|
693
|
+
"this task was rejected permanently (not a rate limit or an outage) for "
|
|
694
|
+
"over {floor_hours}h, with the full delivery record AND a bare merge "
|
|
695
|
+
"confirmation both refused: {error}. **The orchestrator has given up on "
|
|
696
|
+
"the evidence — it will not be attempted again — and is completing the "
|
|
697
|
+
"close hand-off without it now**, moving this task to `pending_validation`: "
|
|
698
|
+
"the merge is a fact, and holding it at `in_progress` over a missing "
|
|
699
|
+
"write-up is strictly worse than thin evidence. The delivery record is on "
|
|
700
|
+
"the merged pull request {pr_url}; attach it by hand if validating this "
|
|
701
|
+
"task needs it. Usual causes: the orchestrator's token lost its role on "
|
|
702
|
+
"this task, or the evidence endpoint is rejecting the request outright. "
|
|
703
|
+
"(If that hand-off write fails too it is a separate condition and pages "
|
|
704
|
+
"separately as `close-loop failed`; those retries are about the status "
|
|
705
|
+
"transition, never about this evidence.)"
|
|
706
|
+
)
|
|
707
|
+
CLOSE_EVIDENCE_FAILED_ISSUE_COMMENT = (
|
|
708
|
+
"**Orchestrator: close evidence could not be attached** — this issue's "
|
|
709
|
+
"merge is being handed off to its origin Alissa task WITHOUT the "
|
|
710
|
+
"merged-pull-request evidence: writing it was refused permanently and the "
|
|
711
|
+
"orchestrator has stopped trying. Nothing is needed here — the record is "
|
|
712
|
+
"on the merged pull request, and any remaining problem is on the task."
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
# Posted on the issue the daemon closes on GitHub's behalf (O16, issue #40). It
|
|
716
|
+
# says what was true, what the daemon did, and what it did NOT do -- a reader
|
|
717
|
+
# arriving at a mechanically-closed issue must be able to tell this apart from
|
|
718
|
+
# an operator closing it by hand, which is the whole reason it is commented and
|
|
719
|
+
# not just logged. It names the merged PR because that PR is the entire
|
|
720
|
+
# justification: without one this path is unreachable.
|
|
721
|
+
AUTOCLOSE_HEALED_ISSUE_COMMENT = (
|
|
722
|
+
"**Orchestrator: completed a flaked auto-close** — this issue was closed by "
|
|
723
|
+
"the orchestrator, not by GitHub. Pull request {pr_url} merged {merged} and "
|
|
724
|
+
"declares that it closes this issue, but GitHub's own `Closes #n` "
|
|
725
|
+
"auto-close never fired (best-effort, not a contract), and the issue was "
|
|
726
|
+
"still open more than {grace}s later. The work is merged, so the "
|
|
727
|
+
"orchestrator completed the step the platform skipped and is now driving "
|
|
728
|
+
"the origin Alissa task forward. Nothing was reverted and no code changed; "
|
|
729
|
+
"if this issue was genuinely NOT finished by that pull request, reopen it "
|
|
730
|
+
"and correct the PR's closing keyword."
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
# The merge time as the comment above states it when the payload did not carry
|
|
734
|
+
# one. Spelled out rather than left blank: "merged " followed by nothing reads
|
|
735
|
+
# as a rendering bug, and this path is exactly the one a reader should treat
|
|
736
|
+
# with suspicion.
|
|
737
|
+
AUTOCLOSE_MERGED_AT_UNKNOWN = "at an unreported time"
|
|
738
|
+
|
|
616
739
|
DEGRADED_DISCOVERY_REASON = (
|
|
617
740
|
"release discovery was incomplete this pass ({count} feed body(ies) of "
|
|
618
741
|
"work unreadable), so the two-body-of-work ambiguity check could not run "
|
|
@@ -688,14 +811,19 @@ DELIVERY_NOTES_HEADING = "## Delivery notes"
|
|
|
688
811
|
# compatibility commitment.
|
|
689
812
|
#
|
|
690
813
|
# THE HEADROOM IS MEASURED, not assumed, because the size of this write is the
|
|
691
|
-
# one way this feature could newly BLOCK a close
|
|
692
|
-
#
|
|
693
|
-
#
|
|
694
|
-
#
|
|
695
|
-
#
|
|
696
|
-
#
|
|
697
|
-
#
|
|
698
|
-
#
|
|
814
|
+
# one way this feature could newly BLOCK a close -- and O17 (issue #44) did NOT
|
|
815
|
+
# retire that argument, it narrowed it. A permanent rejection now degrades to
|
|
816
|
+
# the bare confirmation line and, past the floor, gives up on evidence rather
|
|
817
|
+
# than stranding the task (`_attach_close_evidence`), but that ladder is driven
|
|
818
|
+
# by the client's status taxonomy: `AlissaTransient` is 408/429/5xx, and the
|
|
819
|
+
# oversize failure below answers **HTTP 500**. An over-cap body is therefore
|
|
820
|
+
# deterministic AND classified transient -- the one shape that retries forever
|
|
821
|
+
# with the identical payload and never reaches the degrade. Nothing here is
|
|
822
|
+
# wrong: statuses are the contract and codes are advisory (see `_classify`), so
|
|
823
|
+
# the guard against that shape is THIS CAP, not the ladder.
|
|
824
|
+
# `AddEvidenceRequest` declares `markdownContent` with a `minLength` and NO
|
|
825
|
+
# `maxLength`, so the ceiling was probed directly against the live endpoint
|
|
826
|
+
# (2026-07-28, disposable task, since cancelled):
|
|
699
827
|
#
|
|
700
828
|
# 32,300 -> 201 131,072 -> 201 524,288 -> 201
|
|
701
829
|
# 65,536 -> 201 262,144 -> 201 1,048,576 -> 500
|
|
@@ -911,20 +1039,31 @@ class Orchestrator:
|
|
|
911
1039
|
task_comment: str,
|
|
912
1040
|
issue: Issue | None = None,
|
|
913
1041
|
issue_comment: str | None = None,
|
|
914
|
-
) ->
|
|
915
|
-
"""Raise an operator escalation once per (task, kind).
|
|
1042
|
+
) -> bool:
|
|
1043
|
+
"""Raise an operator escalation once per (task, kind). Returns whether
|
|
1044
|
+
the page HAS BEEN DELIVERED -- True when a dedupe row exists for it
|
|
1045
|
+
after this call, which (see below) is exactly "the primary comment
|
|
1046
|
+
posted, now or on an earlier pass".
|
|
916
1047
|
|
|
917
1048
|
Primary channel is a comment on the Alissa task; the GitHub issue gets
|
|
918
1049
|
a best-effort comment too when one exists. All dry-run gated. The
|
|
919
1050
|
dedupe row is recorded only AFTER the primary comment posts, so a
|
|
920
1051
|
transient primary failure retries next poll (exactly-once primary
|
|
921
|
-
delivery) rather than latching a page that never landed.
|
|
1052
|
+
delivery) rather than latching a page that never landed.
|
|
1053
|
+
|
|
1054
|
+
That ordering is what makes the return value meaningful rather than
|
|
1055
|
+
decorative: a caller whose next step is IRREVERSIBLE if nobody was told
|
|
1056
|
+
(`_close_evidence_unwritable` settles the evidence step, which stops
|
|
1057
|
+
every future attempt AND every future page) can gate on the receipt
|
|
1058
|
+
instead of re-reading the ledger and hoping the two stay in step. Every
|
|
1059
|
+
pre-existing caller ignores it, which is unchanged behaviour: a page
|
|
1060
|
+
that could not be posted was already retried next poll."""
|
|
922
1061
|
if self.state.escalated(task_number, kind):
|
|
923
|
-
return
|
|
1062
|
+
return True
|
|
924
1063
|
ref = f"TASK-{task_number}"
|
|
925
1064
|
if self.config.dry_run:
|
|
926
1065
|
log.info("[dry-run] would escalate %s on %s:\n%s", kind, ref, task_comment)
|
|
927
|
-
return
|
|
1066
|
+
return False
|
|
928
1067
|
|
|
929
1068
|
try:
|
|
930
1069
|
self.alissa.add_comment(ref, task_comment)
|
|
@@ -933,7 +1072,7 @@ class Orchestrator:
|
|
|
933
1072
|
"could not post %s escalation on %s: %s — not recording; "
|
|
934
1073
|
"retries next poll", kind, ref, exc,
|
|
935
1074
|
)
|
|
936
|
-
return
|
|
1075
|
+
return False
|
|
937
1076
|
|
|
938
1077
|
if issue is not None and issue_comment is not None:
|
|
939
1078
|
try:
|
|
@@ -944,6 +1083,7 @@ class Orchestrator:
|
|
|
944
1083
|
kind, issue.issue_slug, exc,
|
|
945
1084
|
)
|
|
946
1085
|
self.state.record_escalation(task_number, kind)
|
|
1086
|
+
return True
|
|
947
1087
|
|
|
948
1088
|
# ======================================================================
|
|
949
1089
|
# RELEASE EDGE
|
|
@@ -2596,9 +2736,7 @@ class Orchestrator:
|
|
|
2596
2736
|
f"{slug}: label removed by operator — parked, never re-labeling",
|
|
2597
2737
|
task_number,
|
|
2598
2738
|
))
|
|
2599
|
-
return self.
|
|
2600
|
-
Action.SKIPPED, f"{slug}: released issue still open — in flight", task_number,
|
|
2601
|
-
))
|
|
2739
|
+
return self._evaluate_open_released(task_number, ref, slug, issue)
|
|
2602
2740
|
|
|
2603
2741
|
try:
|
|
2604
2742
|
pr = self.github.closing_merged_pr(owner, repo, number)
|
|
@@ -2624,6 +2762,290 @@ class Orchestrator:
|
|
|
2624
2762
|
|
|
2625
2763
|
return self._close_loop(task_number, ref, slug, issue, pr)
|
|
2626
2764
|
|
|
2765
|
+
def _evaluate_open_released(
|
|
2766
|
+
self, task_number: int, ref: str, slug: str, issue: Issue
|
|
2767
|
+
) -> tuple[str, Decision]:
|
|
2768
|
+
"""A released issue that is still OPEN: genuinely in flight, or a
|
|
2769
|
+
platform step that flaked and must be completed on GitHub's behalf.
|
|
2770
|
+
|
|
2771
|
+
Through 0.3.9 `open` was terminal for the pass -- "still open" meant
|
|
2772
|
+
"still being worked on", and the closing PR was resolved only AFTER the
|
|
2773
|
+
issue closed. That reading is wrong, and it cost a live incident
|
|
2774
|
+
(studio#262 / PR #265, 2026-07-28): the PR merged with a properly-linked
|
|
2775
|
+
`Closes #262` and GitHub's auto-close silently never fired, while the
|
|
2776
|
+
sibling merge 21 seconds earlier auto-closed normally. **GitHub's
|
|
2777
|
+
`Closes #n` auto-close is best-effort, not a contract.** Nothing
|
|
2778
|
+
distinguished that issue from real work: the origin task sat
|
|
2779
|
+
`in_progress` with no evidence, the phantom in-flight issue held its
|
|
2780
|
+
scope in the repo lane against a queued task, and nothing escalated.
|
|
2781
|
+
|
|
2782
|
+
So the merged-closer read moves ahead of the open/closed split, and this
|
|
2783
|
+
branch answers one of three ways:
|
|
2784
|
+
|
|
2785
|
+
* no merged closing PR -> in flight, exactly as before. This is the
|
|
2786
|
+
common case and it is untouched: the daemon never closes an issue it
|
|
2787
|
+
cannot point at a merged PR for.
|
|
2788
|
+
* merged, inside the grace window -> SKIPPED, with a reason that says
|
|
2789
|
+
which condition it is. The window exists so the daemon never RACES
|
|
2790
|
+
GitHub for a step GitHub usually performs; the healthy case lands in
|
|
2791
|
+
seconds, so a few minutes is well outside it.
|
|
2792
|
+
* merged, past the window -> complete the step: close the issue
|
|
2793
|
+
(`completed`, commented, WARNING-logged) and fall straight through
|
|
2794
|
+
into the same close walk a natural auto-close would have produced --
|
|
2795
|
+
in THIS pass, because an extra poll of latency is the thing the origin
|
|
2796
|
+
task was already waiting on.
|
|
2797
|
+
|
|
2798
|
+
THE COST, stated as measured rather than as hoped. `repo_parallelism`
|
|
2799
|
+
bounds how MANY issues reach here at once (1 in-flight released issue
|
|
2800
|
+
per repo at the default), but it says nothing about how OFTEN, and this
|
|
2801
|
+
walk now runs on every poll for as long as the issue stays open. Note
|
|
2802
|
+
the shape: the healthy, common case -- genuinely in flight, no merged
|
|
2803
|
+
closer -- is the EXPENSIVE one, because it never short-circuits and
|
|
2804
|
+
fetches every cross-referenced candidate, so the per-poll cost grows
|
|
2805
|
+
with an issue's discussion rather than with its work. Measured: 2 `gh`
|
|
2806
|
+
calls per poll with one cross-referenced PR, 11 at the MAX_PR_CHECKS
|
|
2807
|
+
cap, ~+120 calls/hour/repo at `poll_interval=60` and the default
|
|
2808
|
+
parallelism -- small against a 5000/hour REST budget, but not zero, and
|
|
2809
|
+
it was zero before.
|
|
2810
|
+
|
|
2811
|
+
There is deliberately no damper here yet. The obvious one (probe every
|
|
2812
|
+
Nth poll) needs a design this change should not smuggle in: N interacts
|
|
2813
|
+
with `poll_interval` to stretch the effective grace window, it needs
|
|
2814
|
+
somewhere to survive a restart, and a throttled poll needs its own
|
|
2815
|
+
decision reason so it cannot be mistaken for a healthy in-flight one.
|
|
2816
|
+
The nearby `ESCALATION_CLOSED_UNMERGED` short-circuit exists for exactly
|
|
2817
|
+
this reason on a TERMINAL row, whose cost is unbounded in time; this
|
|
2818
|
+
one ends when the issue closes.
|
|
2819
|
+
|
|
2820
|
+
This is the devloop self-heal doctrine transposed to the close edge --
|
|
2821
|
+
complete the dead protocol step on the platform's behalf, exactly once,
|
|
2822
|
+
loudly -- the same family as the pushed-but-never-re-requested fix.
|
|
2823
|
+
|
|
2824
|
+
Park detection runs BEFORE this, in `_evaluate_close`, and stays there:
|
|
2825
|
+
an operator who removed the label has said "stop acting on this", which
|
|
2826
|
+
outranks any repair the daemon might otherwise perform.
|
|
2827
|
+
|
|
2828
|
+
"Exactly once" is enforced by the AUTOCLOSE_HEALED marker, recorded
|
|
2829
|
+
after a successful close. Seeing an issue OPEN again with that marker
|
|
2830
|
+
already set means the close landed and someone re-opened it -- which is
|
|
2831
|
+
precisely what the comment the daemon posted invites a reader to do if
|
|
2832
|
+
the PR did not really finish the work. Re-closing there would fight the
|
|
2833
|
+
operator and make that invitation a lie, so the repair is refused for
|
|
2834
|
+
good, exactly like park's "never re-labeling". It also covers the
|
|
2835
|
+
cheaper case of a read-after-write that still reports the issue open:
|
|
2836
|
+
one close, one comment, whatever the API's timing.
|
|
2837
|
+
"""
|
|
2838
|
+
if self.state.escalated(task_number, AUTOCLOSE_HEALED):
|
|
2839
|
+
return self._logged(ref, Decision(
|
|
2840
|
+
Action.SKIPPED,
|
|
2841
|
+
f"{slug}: auto-close already completed once and the issue is "
|
|
2842
|
+
f"open again — operator reopened it, never re-closing",
|
|
2843
|
+
task_number,
|
|
2844
|
+
))
|
|
2845
|
+
|
|
2846
|
+
try:
|
|
2847
|
+
walk = self.github.close_walk(issue.owner, issue.repo, issue.number)
|
|
2848
|
+
except (CommandError, RateLimited) as exc:
|
|
2849
|
+
return self._logged(ref, Decision(
|
|
2850
|
+
Action.HELD,
|
|
2851
|
+
f"{slug}: could not resolve closing PR: {exc}",
|
|
2852
|
+
task_number,
|
|
2853
|
+
))
|
|
2854
|
+
|
|
2855
|
+
pr = walk.pr
|
|
2856
|
+
if pr is None:
|
|
2857
|
+
return self._logged(ref, Decision(
|
|
2858
|
+
Action.SKIPPED,
|
|
2859
|
+
f"{slug}: released issue still open — in flight",
|
|
2860
|
+
task_number,
|
|
2861
|
+
))
|
|
2862
|
+
|
|
2863
|
+
# HAS THIS ISSUE EVER BEEN CLOSED? -- the question the open/closed state
|
|
2864
|
+
# cannot answer and the one this branch actually turns on. A flaked
|
|
2865
|
+
# auto-close and a human's deliberate re-open are identical on every
|
|
2866
|
+
# other signal: same open state, same merged PR declaring `Closes #n`,
|
|
2867
|
+
# same elapsed time since the merge. Repairing the second is not a
|
|
2868
|
+
# missed opportunity, it is REVERTING AN OPERATOR -- and posting a
|
|
2869
|
+
# comment claiming the auto-close never fired when it did.
|
|
2870
|
+
#
|
|
2871
|
+
# Two independent signals, because each covers the other's blind spot:
|
|
2872
|
+
# `state_reason` is free (it rides the issue payload already fetched)
|
|
2873
|
+
# and exact, but it is a late API addition that can be empty on an issue
|
|
2874
|
+
# re-opened long ago; the timeline's `closed` event is durable history
|
|
2875
|
+
# but can fall past TIMELINE_MAX_PAGES on a very chatty issue. Either
|
|
2876
|
+
# one refuses the repair, for good.
|
|
2877
|
+
#
|
|
2878
|
+
# `closed_at` is deliberately NOT among them: GitHub CLEARS it on
|
|
2879
|
+
# reopen, so it is null on precisely the issues this must catch
|
|
2880
|
+
# (verified against golang/go#79623, a real reopened issue).
|
|
2881
|
+
#
|
|
2882
|
+
# The accepted cost is a false NEGATIVE: an issue an operator closed and
|
|
2883
|
+
# re-opened earlier in its life is never repaired, even by a genuinely
|
|
2884
|
+
# flaked later merge. That fails toward leaving an operator's issue
|
|
2885
|
+
# alone, which is the trade park already makes.
|
|
2886
|
+
if issue.reopened or walk.ever_closed:
|
|
2887
|
+
return self._logged(ref, Decision(
|
|
2888
|
+
Action.SKIPPED,
|
|
2889
|
+
f"{slug}: closed once already and open again — reopened, not a "
|
|
2890
|
+
f"flaked auto-close; never re-closing",
|
|
2891
|
+
task_number,
|
|
2892
|
+
))
|
|
2893
|
+
|
|
2894
|
+
# DID GITHUB EVEN OWE US AN AUTO-CLOSE? It fires only for a merge into
|
|
2895
|
+
# the repository's DEFAULT branch: a PR carrying `Closes #n` that merges
|
|
2896
|
+
# into `develop`, a release branch, or a stacked base leaves the issue
|
|
2897
|
+
# open ON PURPOSE, and that is the platform working correctly.
|
|
2898
|
+
#
|
|
2899
|
+
# Through 0.3.9 the merged-closer predicate (merged + a closing keyword)
|
|
2900
|
+
# only supplied ATTRIBUTION for a decision GitHub had already made, so
|
|
2901
|
+
# being loose was harmless. Promoting it to the TRIGGER for a state
|
|
2902
|
+
# mutation is what makes the missing check matter.
|
|
2903
|
+
try:
|
|
2904
|
+
default_branch = self.github.default_branch(issue.owner, issue.repo)
|
|
2905
|
+
except (CommandError, RateLimited) as exc:
|
|
2906
|
+
# Same posture as the closing-PR failure above: an unreadable input
|
|
2907
|
+
# is not permission to close someone's issue.
|
|
2908
|
+
return self._logged(ref, Decision(
|
|
2909
|
+
Action.HELD,
|
|
2910
|
+
f"{slug}: could not resolve the default branch of "
|
|
2911
|
+
f"{issue.full_name}: {exc}",
|
|
2912
|
+
task_number,
|
|
2913
|
+
))
|
|
2914
|
+
|
|
2915
|
+
if not default_branch:
|
|
2916
|
+
# A read that SUCCEEDED but answered nothing is the same species of
|
|
2917
|
+
# unknown as one that raised, and it must not fall through to the
|
|
2918
|
+
# comparison below: with a non-empty `base_ref` that mismatch would
|
|
2919
|
+
# be reported as "GitHub declined to auto-close a non-default
|
|
2920
|
+
# merge", asserting a fact not in evidence -- the base may well have
|
|
2921
|
+
# BEEN the default branch; the daemon just could not read it. Held,
|
|
2922
|
+
# not skipped, so the row retries instead of silently never
|
|
2923
|
+
# repairing. Recoverable because `default_branch` deliberately does
|
|
2924
|
+
# not cache an empty answer.
|
|
2925
|
+
return self._logged(ref, Decision(
|
|
2926
|
+
Action.HELD,
|
|
2927
|
+
f"{slug}: the default branch of {issue.full_name} could not be "
|
|
2928
|
+
f"read (empty answer) — holding rather than guessing whether "
|
|
2929
|
+
f"merged {pr.pr_slug} targeted it",
|
|
2930
|
+
task_number,
|
|
2931
|
+
))
|
|
2932
|
+
|
|
2933
|
+
if not pr.base_ref or pr.base_ref != default_branch:
|
|
2934
|
+
if not pr.base_ref:
|
|
2935
|
+
# Unknown provenance cannot authorise a state mutation. Unlike
|
|
2936
|
+
# an unmeasurable grace window -- which could only be granted
|
|
2937
|
+
# FOREVER, the very hang this path exists to end -- refusing
|
|
2938
|
+
# here hangs nothing: the issue stays exactly as GitHub left it.
|
|
2939
|
+
log.warning(
|
|
2940
|
+
"%s: %s reports no base branch, so it cannot be confirmed "
|
|
2941
|
+
"to have merged into %s — refusing to close the issue on a "
|
|
2942
|
+
"merge whose target is unknown",
|
|
2943
|
+
slug, pr.pr_slug, default_branch,
|
|
2944
|
+
)
|
|
2945
|
+
return self._logged(ref, Decision(
|
|
2946
|
+
Action.SKIPPED,
|
|
2947
|
+
f"{slug}: merged {pr.pr_slug} targeted "
|
|
2948
|
+
f"{pr.base_ref or '(unreported)'}, not the default branch "
|
|
2949
|
+
f"{default_branch!r} — GitHub does not auto-close on a "
|
|
2950
|
+
f"non-default merge, so there is nothing to repair",
|
|
2951
|
+
task_number,
|
|
2952
|
+
))
|
|
2953
|
+
|
|
2954
|
+
grace = self.config.autoclose_grace_seconds
|
|
2955
|
+
merged_epoch = pr.merged_epoch
|
|
2956
|
+
if merged_epoch is not None and self._now() - merged_epoch < grace:
|
|
2957
|
+
# Deliberately NOT a WARNING and not an escalation: for the first
|
|
2958
|
+
# few minutes after a merge this state is what a perfectly healthy
|
|
2959
|
+
# repo looks like. Only outliving the window makes it an anomaly.
|
|
2960
|
+
return self._logged(ref, Decision(
|
|
2961
|
+
Action.SKIPPED,
|
|
2962
|
+
f"{slug}: closed by merged {pr.pr_slug} but still open — "
|
|
2963
|
+
f"GitHub auto-close pending, within the {grace}s grace window",
|
|
2964
|
+
task_number,
|
|
2965
|
+
))
|
|
2966
|
+
|
|
2967
|
+
# An unreadable/absent `mergedAt` lands HERE rather than in the branch
|
|
2968
|
+
# above, i.e. it heals. The window is a courtesy extended to a platform
|
|
2969
|
+
# step whose age we can measure; when the payload does not say when the
|
|
2970
|
+
# merge happened, the courtesy cannot be granted without granting it
|
|
2971
|
+
# FOREVER -- which is precisely the indefinite hang this whole change
|
|
2972
|
+
# exists to end. The downside is bounded and self-announcing: at worst
|
|
2973
|
+
# the daemon completes a close GitHub was about to perform anyway (the
|
|
2974
|
+
# close is idempotent) and says so in a comment that names the merge
|
|
2975
|
+
# time as unreported, so a spurious anomaly report is visible as one.
|
|
2976
|
+
merged = (
|
|
2977
|
+
f"at {pr.merged_at}" if merged_epoch is not None
|
|
2978
|
+
else AUTOCLOSE_MERGED_AT_UNKNOWN
|
|
2979
|
+
)
|
|
2980
|
+
if self.config.dry_run:
|
|
2981
|
+
# Sequenced here rather than with the real one below because there
|
|
2982
|
+
# is no close to sequence it against, and its prefix already cannot
|
|
2983
|
+
# be misread as a completed repair.
|
|
2984
|
+
log.warning(
|
|
2985
|
+
"[dry-run] would complete a flaked GitHub auto-close: %s is "
|
|
2986
|
+
"still open but %s merged %s declaring it closed "
|
|
2987
|
+
"(grace %ds elapsed)",
|
|
2988
|
+
slug, pr.pr_slug, merged, grace,
|
|
2989
|
+
)
|
|
2990
|
+
else:
|
|
2991
|
+
try:
|
|
2992
|
+
self.github.close_issue(issue.owner, issue.repo, issue.number)
|
|
2993
|
+
except (CommandError, RateLimited) as exc:
|
|
2994
|
+
# HOLD, never walk: `_close_loop` and everything downstream of
|
|
2995
|
+
# it assume the issue is closed -- that is what makes moving the
|
|
2996
|
+
# origin task to `pending_validation` an honest report. An
|
|
2997
|
+
# unclosed issue with a validated task is a worse state than one
|
|
2998
|
+
# more poll of waiting, and the next poll re-enters here with
|
|
2999
|
+
# the same inputs (no marker was recorded, nothing was mutated).
|
|
3000
|
+
log.error("could not close %s on GitHub's behalf: %s", slug, exc)
|
|
3001
|
+
return self._logged(ref, Decision(
|
|
3002
|
+
Action.HELD,
|
|
3003
|
+
f"{slug}: merged {pr.pr_slug} never auto-closed the issue "
|
|
3004
|
+
f"and closing it failed: {exc} — holding, no task mutation",
|
|
3005
|
+
task_number,
|
|
3006
|
+
))
|
|
3007
|
+
# AFTER the close, never before it: this line is the anomaly report,
|
|
3008
|
+
# and "completing" is the one word a log-scraper or a half-read
|
|
3009
|
+
# alert picks up. Emitted ahead of the attempt it would announce a
|
|
3010
|
+
# repair that a following ERROR then has to retract -- the anomaly
|
|
3011
|
+
# stays just as visible logged here, and is never claimed falsely.
|
|
3012
|
+
log.warning(
|
|
3013
|
+
"completed a flaked GitHub auto-close: %s was still open but "
|
|
3014
|
+
"%s merged %s declaring it closed (grace %ds elapsed)",
|
|
3015
|
+
slug, pr.pr_slug, merged, grace,
|
|
3016
|
+
)
|
|
3017
|
+
# Recorded the moment the close lands, BEFORE the best-effort
|
|
3018
|
+
# comment and before the walk: from here on the repair is done and
|
|
3019
|
+
# must never be performed twice, whatever any later step does. A
|
|
3020
|
+
# marker written after the walk would be lost on a walk that raises,
|
|
3021
|
+
# which is the one path that re-enters this branch.
|
|
3022
|
+
self.state.record_escalation(task_number, AUTOCLOSE_HEALED)
|
|
3023
|
+
# Best-effort, and AFTER the close for idempotency: a comment that
|
|
3024
|
+
# posted before a close which then failed would post again on the
|
|
3025
|
+
# retry, so the audit trail would grow one copy per failure. Losing
|
|
3026
|
+
# the comment costs an explanation the WARNING above still carries;
|
|
3027
|
+
# losing the close would cost the fix.
|
|
3028
|
+
try:
|
|
3029
|
+
self.github.comment(
|
|
3030
|
+
issue.owner, issue.repo, issue.number,
|
|
3031
|
+
AUTOCLOSE_HEALED_ISSUE_COMMENT.format(
|
|
3032
|
+
pr_url=pr.url or pr.pr_slug, merged=merged, grace=grace,
|
|
3033
|
+
),
|
|
3034
|
+
)
|
|
3035
|
+
except (CommandError, RateLimited) as exc:
|
|
3036
|
+
log.warning(
|
|
3037
|
+
"best-effort auto-close explanation comment failed on %s: %s",
|
|
3038
|
+
slug, exc,
|
|
3039
|
+
)
|
|
3040
|
+
|
|
3041
|
+
# Walk in the SAME pass, with the issue's state corrected to what the
|
|
3042
|
+
# daemon just made true. `_close_loop` only reads the issue to address
|
|
3043
|
+
# its escalation comments, but handing a stale `state="open"` to
|
|
3044
|
+
# anything downstream is a trap for whatever reads it next.
|
|
3045
|
+
return self._close_loop(
|
|
3046
|
+
task_number, ref, slug, replace(issue, state="closed"), pr
|
|
3047
|
+
)
|
|
3048
|
+
|
|
2627
3049
|
def _close_loop(
|
|
2628
3050
|
self, task_number: int, ref: str, slug: str, issue: Issue, pr: PullRequest
|
|
2629
3051
|
) -> tuple[str, Decision]:
|
|
@@ -2676,7 +3098,7 @@ class Orchestrator:
|
|
|
2676
3098
|
))
|
|
2677
3099
|
|
|
2678
3100
|
try:
|
|
2679
|
-
self._mutate_close(task_number, ref, task, slug, pr)
|
|
3101
|
+
self._mutate_close(task_number, ref, task, slug, issue, pr)
|
|
2680
3102
|
except AlissaError as exc:
|
|
2681
3103
|
return self._closeloop_failed(task_number, slug, issue, exc, prior)
|
|
2682
3104
|
|
|
@@ -2688,7 +3110,13 @@ class Orchestrator:
|
|
|
2688
3110
|
))
|
|
2689
3111
|
|
|
2690
3112
|
def _mutate_close(
|
|
2691
|
-
self,
|
|
3113
|
+
self,
|
|
3114
|
+
task_number: int,
|
|
3115
|
+
ref: str,
|
|
3116
|
+
task: Task,
|
|
3117
|
+
slug: str,
|
|
3118
|
+
issue: Issue,
|
|
3119
|
+
pr: PullRequest,
|
|
2692
3120
|
) -> None:
|
|
2693
3121
|
"""The close-loop mutation, ordered for idempotency on partial-failure
|
|
2694
3122
|
retry. Two independent idempotency gates, each keyed on what it actually
|
|
@@ -2698,8 +3126,8 @@ class Orchestrator:
|
|
|
2698
3126
|
`committed`, so a retry that re-enters at `in_progress` never re-issues
|
|
2699
3127
|
the (illegal) `in_progress → in_progress` transition;
|
|
2700
3128
|
* the merged-PR evidence write is gated on the `CLOSELOOP_EVIDENCE_DONE`
|
|
2701
|
-
marker, recorded only
|
|
2702
|
-
the `in_progress` step and the evidence write re-enters at
|
|
3129
|
+
marker, recorded only once that step is SETTLED. A retry that died
|
|
3130
|
+
between the `in_progress` step and the evidence write re-enters at
|
|
2703
3131
|
`in_progress` with the marker absent, so it still attaches the evidence
|
|
2704
3132
|
-- where a shared `first_entry` flag would have skipped it forever
|
|
2705
3133
|
(the task would reach `pending_validation` with no merged-PR audit
|
|
@@ -2712,6 +3140,11 @@ class Orchestrator:
|
|
|
2712
3140
|
failure mode to the walk: a PR whose write-up cannot be read still
|
|
2713
3141
|
reaches `pending_validation`, carrying an explicit line saying so.
|
|
2714
3142
|
|
|
3143
|
+
And since O17 (issue #44), a rejected write cannot stop the walk either:
|
|
3144
|
+
`_attach_close_evidence` owns the whole ladder (degrade to the bare
|
|
3145
|
+
line, then give up past the floor), so THIS method's last line is
|
|
3146
|
+
reached on every path that is not still owed a retry.
|
|
3147
|
+
|
|
2715
3148
|
Criteria satisfaction is naturally idempotent (already-satisfied ones are
|
|
2716
3149
|
skipped), so it needs no gate."""
|
|
2717
3150
|
if task.status == TASK_TODO_STATE:
|
|
@@ -2719,14 +3152,173 @@ class Orchestrator:
|
|
|
2719
3152
|
for criterion in task.criteria:
|
|
2720
3153
|
if not criterion.satisfied:
|
|
2721
3154
|
self.alissa.satisfy_criterion(ref, criterion.id)
|
|
2722
|
-
|
|
3155
|
+
self._attach_close_evidence(task_number, ref, slug, issue, pr)
|
|
3156
|
+
self.alissa.set_status(ref, "pending_validation")
|
|
3157
|
+
|
|
3158
|
+
def _attach_close_evidence(
|
|
3159
|
+
self, task_number: int, ref: str, slug: str, issue: Issue, pr: PullRequest
|
|
3160
|
+
) -> None:
|
|
3161
|
+
"""Attach the merged-PR evidence, or settle the step without it. The
|
|
3162
|
+
one rule: never RAISE unless a retry is genuinely owed, because the
|
|
3163
|
+
caller's next line is the `pending_validation` hand-off and an
|
|
3164
|
+
exception here strands the task at `in_progress` (issue #44 -- the
|
|
3165
|
+
strand this method exists to remove).
|
|
3166
|
+
|
|
3167
|
+
The ladder, in the order a failure walks it:
|
|
3168
|
+
|
|
3169
|
+
1. **Transient** (`AlissaTransient`: 408/429/5xx or a transport
|
|
3170
|
+
failure) -- re-raised untouched, at every rung. This is the ONLY
|
|
3171
|
+
path that keeps today's behavior byte-for-byte: `_close_loop` routes
|
|
3172
|
+
it to `_closeloop_failed`, which pages `closeloop-failed:a<j>` and
|
|
3173
|
+
floors the retry. An outage must not spend the degrade.
|
|
3174
|
+
2. **Permanent rejection of the ENRICHED body** -- retried ONCE,
|
|
3175
|
+
immediately, with the bare `CLOSE_EVIDENCE_BODY`. The enriched record
|
|
3176
|
+
is the only *variable* input to this write (a PR body someone else
|
|
3177
|
+
authored, up to `CLOSE_EVIDENCE_RECORD_MAX_CHARS` of it), so it is
|
|
3178
|
+
the first suspect for a deterministic refusal, and dropping it is
|
|
3179
|
+
the same "degrade loudly, never block" trade `render_close_evidence`
|
|
3180
|
+
already makes for an unreadable body. Nothing is added to the bare
|
|
3181
|
+
payload to explain the degradation: the payload is the suspect, and
|
|
3182
|
+
the log line is where the explanation belongs.
|
|
3183
|
+
3. **Permanent rejection of the BARE line too** -- the payload was
|
|
3184
|
+
never the problem (a lost role, an endpoint refusing outright), so
|
|
3185
|
+
`_close_evidence_unwritable` decides between one more retry and
|
|
3186
|
+
giving up.
|
|
3187
|
+
|
|
3188
|
+
Why the split at transient/permanent is `AlissaTransient` and not a
|
|
3189
|
+
status test here: the client's taxonomy already IS that split (it keys
|
|
3190
|
+
on status, which is the contract), and `_dedupe_unreadable`'s lesson is
|
|
3191
|
+
the reason it matters -- a self-clearing rate limit must never spend a
|
|
3192
|
+
one-shot remedy that a real, permanent failure will need."""
|
|
3193
|
+
if self.state.escalated(task_number, CLOSELOOP_EVIDENCE_DONE):
|
|
3194
|
+
return
|
|
3195
|
+
try:
|
|
2723
3196
|
self.alissa.add_evidence(
|
|
2724
3197
|
ref,
|
|
2725
3198
|
CLOSE_EVIDENCE_TITLE,
|
|
2726
3199
|
render_close_evidence(slug, pr),
|
|
2727
3200
|
)
|
|
2728
|
-
|
|
2729
|
-
|
|
3201
|
+
except AlissaTransient:
|
|
3202
|
+
raise
|
|
3203
|
+
except AlissaError as rich_exc:
|
|
3204
|
+
log.warning(
|
|
3205
|
+
"%s: the close evidence was REJECTED permanently on %s (%s) — "
|
|
3206
|
+
"retrying once with the bare merge confirmation, without the "
|
|
3207
|
+
"merged pull request's delivery record",
|
|
3208
|
+
slug, ref, rich_exc,
|
|
3209
|
+
)
|
|
3210
|
+
try:
|
|
3211
|
+
self.alissa.add_evidence(
|
|
3212
|
+
ref,
|
|
3213
|
+
CLOSE_EVIDENCE_TITLE,
|
|
3214
|
+
CLOSE_EVIDENCE_BODY.format(slug=slug, pr_url=pr.url),
|
|
3215
|
+
)
|
|
3216
|
+
except AlissaTransient:
|
|
3217
|
+
raise
|
|
3218
|
+
except AlissaError as bare_exc:
|
|
3219
|
+
self._close_evidence_unwritable(
|
|
3220
|
+
task_number, ref, slug, issue, pr, bare_exc,
|
|
3221
|
+
)
|
|
3222
|
+
else:
|
|
3223
|
+
log.warning(
|
|
3224
|
+
"%s: DEGRADED close evidence attached to %s — the merge "
|
|
3225
|
+
"confirmation only; the delivery record was rejected (%s) "
|
|
3226
|
+
"and is on the pull request %s",
|
|
3227
|
+
slug, ref, rich_exc, pr.url or pr.pr_slug,
|
|
3228
|
+
)
|
|
3229
|
+
self.state.record_escalation(task_number, CLOSELOOP_EVIDENCE_DONE)
|
|
3230
|
+
|
|
3231
|
+
def _close_evidence_unwritable(
|
|
3232
|
+
self,
|
|
3233
|
+
task_number: int,
|
|
3234
|
+
ref: str,
|
|
3235
|
+
slug: str,
|
|
3236
|
+
issue: Issue,
|
|
3237
|
+
pr: PullRequest,
|
|
3238
|
+
exc: AlissaError,
|
|
3239
|
+
) -> None:
|
|
3240
|
+
"""Neither the enriched record nor the bare line could be written, and
|
|
3241
|
+
the refusal was permanent. Either raise (a retry is still owed) or
|
|
3242
|
+
return, having paged terminally -- the caller then settles the step and
|
|
3243
|
+
the hand-off finishes WITHOUT evidence.
|
|
3244
|
+
|
|
3245
|
+
Why give up at all: a permanent refusal does not self-heal, so the
|
|
3246
|
+
alternative is re-attempting the identical write every poll forever
|
|
3247
|
+
while the origin task sits at `in_progress` and the operator is paged
|
|
3248
|
+
on a schedule. The merge already happened; thin evidence on a task that
|
|
3249
|
+
reached `pending_validation` is strictly better than a full record on a
|
|
3250
|
+
task nobody can validate. That is the same trade the close edge already
|
|
3251
|
+
makes for an unreadable PR body, taken one rung further down.
|
|
3252
|
+
|
|
3253
|
+
Why not give up on the FIRST permanent refusal, since a deterministic
|
|
3254
|
+
failure is deterministic by definition: because "permanent" here is a
|
|
3255
|
+
CLASSIFICATION, not an observation. A 403 is permanent as a rule and
|
|
3256
|
+
transient in fact when a role is being re-granted, and this remedy is
|
|
3257
|
+
one-shot -- the evidence is dropped for good. So the first refusal
|
|
3258
|
+
records this clock and re-raises into today's unchanged path (page,
|
|
3259
|
+
floor, retry), and only a refusal that is STILL permanent
|
|
3260
|
+
`CLOSELOOP_RETRY_FLOOR_SECONDS` later spends the remedy. The floor
|
|
3261
|
+
measures THE FAILURE and not the task's age, for the reason
|
|
3262
|
+
`_dedupe_unreadable` gives at length.
|
|
3263
|
+
|
|
3264
|
+
Why the page must have LANDED before the step settles: settling is what
|
|
3265
|
+
stops every future attempt, so a give-up whose page never posted would
|
|
3266
|
+
be silent forever -- the exact failure mode issue #44 is about, moved
|
|
3267
|
+
one layer up. `_escalate` records its dedupe row only after the primary
|
|
3268
|
+
comment posts, so it can say whether the page was delivered and this
|
|
3269
|
+
gates on that; without it, this raises instead and the give-up is
|
|
3270
|
+
re-attempted next poll. (When the page cannot post because the token
|
|
3271
|
+
lost its role on the task, the `pending_validation` write below was
|
|
3272
|
+
going to fail anyway -- so nothing is stranded that was not already.)
|
|
3273
|
+
|
|
3274
|
+
What this page may NOT claim: the hand-off in the past tense, or
|
|
3275
|
+
silence from the close loop. Both were false in round 1 -- see
|
|
3276
|
+
`CLOSE_EVIDENCE_FAILED_TASK_COMMENT`, which now scopes its terminality
|
|
3277
|
+
to the evidence step, the one thing this method really does end."""
|
|
3278
|
+
rows = self.state.escalations_matching(task_number, CLOSE_EVIDENCE_FAILED_SINCE)
|
|
3279
|
+
now = int(self._now())
|
|
3280
|
+
if not rows:
|
|
3281
|
+
self.state.record_escalation(task_number, CLOSE_EVIDENCE_FAILED_SINCE, at=now)
|
|
3282
|
+
log.error(
|
|
3283
|
+
"%s: close evidence rejected permanently on %s, bare line too "
|
|
3284
|
+
"(%s) — first refusal, retrying behind the close-loop floor",
|
|
3285
|
+
slug, ref, exc,
|
|
3286
|
+
)
|
|
3287
|
+
raise exc
|
|
3288
|
+
failing_for = now - int(rows[0]["escalated_at"])
|
|
3289
|
+
if failing_for < CLOSELOOP_RETRY_FLOOR_SECONDS:
|
|
3290
|
+
log.error(
|
|
3291
|
+
"%s: close evidence rejected permanently on %s, bare line too "
|
|
3292
|
+
"(%s) — refused for %ds, within the %ds floor; retrying",
|
|
3293
|
+
slug, ref, exc, failing_for, CLOSELOOP_RETRY_FLOOR_SECONDS,
|
|
3294
|
+
)
|
|
3295
|
+
raise exc
|
|
3296
|
+
|
|
3297
|
+
delivered = self._escalate(
|
|
3298
|
+
task_number,
|
|
3299
|
+
ESCALATION_CLOSE_EVIDENCE_FAILED,
|
|
3300
|
+
CLOSE_EVIDENCE_FAILED_TASK_COMMENT.format(
|
|
3301
|
+
slug=slug,
|
|
3302
|
+
floor_hours=CLOSELOOP_RETRY_FLOOR_SECONDS // 3600,
|
|
3303
|
+
error=exc,
|
|
3304
|
+
pr_url=pr.url or pr.pr_slug,
|
|
3305
|
+
),
|
|
3306
|
+
issue,
|
|
3307
|
+
CLOSE_EVIDENCE_FAILED_ISSUE_COMMENT,
|
|
3308
|
+
)
|
|
3309
|
+
if not delivered:
|
|
3310
|
+
log.error(
|
|
3311
|
+
"%s: giving up on close evidence for %s, but the terminal page "
|
|
3312
|
+
"could not be delivered — NOT settling; retries next poll",
|
|
3313
|
+
slug, ref,
|
|
3314
|
+
)
|
|
3315
|
+
raise exc
|
|
3316
|
+
log.error(
|
|
3317
|
+
"%s: GAVE UP on close evidence for %s after %ds of permanent "
|
|
3318
|
+
"refusals (%s) — completing the hand-off without it; operator "
|
|
3319
|
+
"paged once, no further attempts",
|
|
3320
|
+
slug, ref, failing_for, exc,
|
|
3321
|
+
)
|
|
2730
3322
|
|
|
2731
3323
|
def _closeloop_failed(
|
|
2732
3324
|
self, task_number: int, slug: str, issue: Issue, exc: Exception, prior: list
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.3.11
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.3.9
|
|
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
|