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.
Files changed (23) hide show
  1. {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
  2. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/ghclient.py +91 -2
  3. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/loop.py +523 -22
  4. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/state.py +43 -3
  5. alissa_tools_github_orcloop-0.3.3/src/main/alissa/tools/github/orcloop/version +1 -0
  6. {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
  7. alissa_tools_github_orcloop-0.3.2/src/main/alissa/tools/github/orcloop/version +0 -1
  8. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/MANIFEST.in +0 -0
  9. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/README.md +0 -0
  10. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/requirements.txt +0 -0
  11. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/setup.cfg +0 -0
  12. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/setup.py +0 -0
  13. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
  14. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/__main__.py +0 -0
  15. {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
  16. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/config.py +0 -0
  17. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/markers.py +0 -0
  18. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
  19. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/version.py +0 -0
  20. {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
  21. {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
  22. {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
  23. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-orcloop
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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
- text = body or ""
83
- match = TASK_TRAILER_RE.search(text) or TASK_REF_RE.search(text)
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: one search of
20
- the watched repos for open labeled issues, each issue's body parsed for its
21
- `Alissa-Task:` trailer, gives the set of tasks already released. A task in
22
- that set (or in the ledger) is never released twice. The scan also ADOPTS
23
- hand-released issues (see the §8 decision below) and BACKFILLS the ledger
24
- for orcloop's own releases GitHub confirms but the ledger has lost.
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`, and `closeloop-failed:a<j>` (a
171
- close-loop mutation failed; retried with a floor between attempts, each attempt
172
- paging once).
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
- close-loop only): an issue a human files by hand carrying BOTH the trigger
181
- label AND an `Alissa-Task:` trailer is ADOPTED into the ledger the first time
182
- the release scan sees it open, so the close edge will drive its origin task
183
- forward on merge exactly like an orcloop-filed one. The release edge never
184
- files a fresh issue for it (dedupe covers it). The one limit: adoption needs to
185
- see the issue OPEN at least once, so a hand-released issue that was already
186
- closed before orcloop ever ran is not retro-adopted. Recorded in the PR body
187
- and on the origin task.
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
- GitHub-authoritative dedupe set -- and, if the ledger does not know it
540
- yet, is ADOPTED (§8: a hand-released or ledger-lost issue) so the close
541
- edge tracks it."""
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`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-orcloop
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera