alissa-tools-github-orcloop 0.3.2__tar.gz → 0.3.4__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.4}/PKG-INFO +1 -1
  2. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.4}/src/main/alissa/tools/github/orcloop/alissa_client.py +71 -10
  3. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.4}/src/main/alissa/tools/github/orcloop/ghclient.py +91 -2
  4. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.4}/src/main/alissa/tools/github/orcloop/loop.py +572 -37
  5. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.4}/src/main/alissa/tools/github/orcloop/state.py +43 -3
  6. alissa_tools_github_orcloop-0.3.4/src/main/alissa/tools/github/orcloop/version +1 -0
  7. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.4/src/main/alissa_tools_github_orcloop.egg-info}/PKG-INFO +1 -1
  8. alissa_tools_github_orcloop-0.3.2/src/main/alissa/tools/github/orcloop/version +0 -1
  9. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.4}/MANIFEST.in +0 -0
  10. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.4}/README.md +0 -0
  11. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.4}/requirements.txt +0 -0
  12. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.4}/setup.cfg +0 -0
  13. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.4}/setup.py +0 -0
  14. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.4}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
  15. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.4}/src/main/alissa/tools/github/orcloop/__main__.py +0 -0
  16. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.4}/src/main/alissa/tools/github/orcloop/config.py +0 -0
  17. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.4}/src/main/alissa/tools/github/orcloop/markers.py +0 -0
  18. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.4}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
  19. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.4}/src/main/alissa/tools/github/orcloop/version.py +0 -0
  20. {alissa_tools_github_orcloop-0.3.2 → alissa_tools_github_orcloop-0.3.4}/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.4}/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.4}/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.4}/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.4
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -95,6 +95,38 @@ Body of Work (otherwise 403 ``FORBIDDEN`` on attach/detach) -- an operator setup
95
95
  step, documented in the README. orcloop never attaches: attaching is the
96
96
  operator's trigger, detaching is orcloop's terminal release act.
97
97
 
98
+ THE DEPENDENCY GATE'S DECLARED COUNT (O10)
99
+ ==========================================
100
+ A task detail declares its dependencies in **two** arrays, not one, and the
101
+ split is by dependency TYPE -- verified live against ``api.alissa.app`` while
102
+ implementing issue #26:
103
+
104
+ * ``dependsOn`` -- the ids of the **soft**-typed dependencies.
105
+ * ``hardDependsOn`` -- the ids of the **hard**-typed ones (key ABSENT, not
106
+ empty, when there are none).
107
+ * ``upstreamTasks`` -- the RESOLVED view of both, deduplicated, soft rows
108
+ first. There is no ``softDependsOn``.
109
+
110
+ Reading only ``dependsOn`` -- which is what shipped from O2 through O9 -- sized
111
+ the gate off the soft dependencies alone, so a task blocked purely by hard ones
112
+ declared zero and released (the O4 dry-run finding, repo issue #11 -> #26). The
113
+ count is now the size of the **union of the two id sets**.
114
+
115
+ Why the union of ids and not ``len(upstreamTasks)``, the other option the issue
116
+ offered: ``upstreamTasks`` is the resolved list, so counting it makes
117
+ ``loop.dependencies_clear``'s visibility guard (`resolved != declared`)
118
+ tautologically true and an upstream this actor cannot see disappears from the
119
+ gate entirely -- trading one silent release for another. The declared ids are
120
+ the only count that comes from a source the actor's visibility cannot shrink.
121
+
122
+ Why a SET union and not the sum of the two lengths: the same upstream can be
123
+ linked BOTH ways (verified live -- the API accepts a second link of the other
124
+ type and lists that one id in both arrays), while ``upstreamTasks`` reports it
125
+ once. Summing would declare 2 against 1 resolved, and the conservative guard
126
+ would then hold a task whose only dependency is fully validated -- forever,
127
+ eventually paging ``release-blocked``. Deduplicating matches what the resolved
128
+ view actually does.
129
+
98
130
  WHO AM I (O8)
99
131
  =============
100
132
  ``GET /v1/ping`` -- ``{"pong", "timestamp", "userId", "actorId",
@@ -254,10 +286,12 @@ class Task:
254
286
  criteria: tuple[Criterion, ...]
255
287
  priority: str = ""
256
288
  created_at: int = 0 # ms since epoch; 0 when the payload omits createdAt
257
- # The resolved upstream tasks (statuses the dependsOn gate reads).
289
+ # The resolved upstream tasks (statuses the dependency gate reads).
258
290
  dependencies: tuple[Dependency, ...] = ()
259
- # len(dependsOn): may EXCEED len(dependencies) when an upstream is invisible
260
- # to this actor -- the gate treats that mismatch as "not provably clear".
291
+ # How many dependencies the payload DECLARES: |dependsOn ∪ hardDependsOn|,
292
+ # both types, deduplicated (see the module docstring, O10). May EXCEED
293
+ # len(dependencies) when an upstream is invisible to this actor -- the gate
294
+ # treats that mismatch as "not provably clear".
261
295
  depends_on_count: int = 0
262
296
 
263
297
 
@@ -542,11 +576,14 @@ class AlissaClient:
542
576
  )
543
577
  )
544
578
 
545
- # The dependsOn gate reads upstream statuses. `dependsOn` is the id
546
- # list; `upstreamTasks` is the resolved view (id/title/status). We key
547
- # the gate on the resolved statuses but remember the raw dependsOn
548
- # count so an upstream this actor cannot see (present in dependsOn,
549
- # absent from upstreamTasks) reads as "not provably clear".
579
+ # The dependency gate reads upstream statuses. `dependsOn` (soft) and
580
+ # `hardDependsOn` (hard) are the declared id lists; `upstreamTasks` is
581
+ # the resolved view of BOTH (id/title/status). We key the gate on the
582
+ # resolved statuses but remember the declared count so an upstream this
583
+ # actor cannot see (declared, absent from upstreamTasks) reads as "not
584
+ # provably clear". Both arrays are counted, deduplicated -- see the
585
+ # module docstring for why neither `len(upstreamTasks)` nor a plain sum
586
+ # of the two lengths is the right size.
550
587
  dependencies: list[Dependency] = []
551
588
  for up in task.get("upstreamTasks") or []:
552
589
  if not isinstance(up, dict):
@@ -558,8 +595,32 @@ class AlissaClient:
558
595
  else str(up.get("_id") or "")
559
596
  )
560
597
  dependencies.append(Dependency(ref=up_ref, status=up.get("status") or ""))
561
- depends_on = task.get("dependsOn")
562
- depends_on_count = len(depends_on) if isinstance(depends_on, list) else 0
598
+ declared: set[str] = set()
599
+ unrecognised = 0
600
+ for raw in (task.get("dependsOn"), task.get("hardDependsOn")):
601
+ if not isinstance(raw, list):
602
+ continue # absent (the common case for hardDependsOn) or junk
603
+ for entry in raw:
604
+ if isinstance(entry, str):
605
+ declared.add(entry)
606
+ continue
607
+ # These arrays hold bare id strings today, but they could grow
608
+ # into objects the way `upstreamTasks` already is -- so take the
609
+ # id when there is one, using the same extraction as the loop
610
+ # above. Counting an id we DID parse as unknown would inflate
611
+ # the declared count past the resolved one and hold a finished
612
+ # dependency forever, which is the very outcome the module
613
+ # docstring gives for rejecting a plain sum of the two lengths.
614
+ entry_id = entry.get("_id") if isinstance(entry, dict) else None
615
+ if isinstance(entry_id, str) and entry_id:
616
+ declared.add(entry_id)
617
+ else:
618
+ # Nothing parseable. DROPPING it would shrink the declared
619
+ # count -- exactly the failure this whole change is about --
620
+ # so count it: over-counting only holds the release, and it
621
+ # cannot be deduplicated against anything anyway.
622
+ unrecognised += 1
623
+ depends_on_count = len(declared) + unrecognised
563
624
 
564
625
  created_raw = task.get("createdAt")
565
626
  created_at = int(created_raw) if isinstance(created_raw, (int, float)) else 0
@@ -74,13 +74,26 @@ def _closes_re(number: int) -> re.Pattern[str]:
74
74
  return pattern
75
75
 
76
76
 
77
+ def parse_task_trailer(body: str) -> int | None:
78
+ """Origin Alissa task number from a line-anchored `Alissa-Task: TASK-<n>`
79
+ TRAILER only -- never the loose `TASK-<n>` scan `parse_task_ref` falls back
80
+ to. This is the strict read, for the one caller that is about to MUTATE the
81
+ matched issue: the release edge's pre-create dedupe applies the trigger
82
+ label to whatever it matches, so "the body mentions TASK-<n> somewhere" is
83
+ not a good enough claim. A passing mention in prose must not adopt."""
84
+ match = TASK_TRAILER_RE.search(body or "")
85
+ return int(match.group(1)) if match else None
86
+
87
+
77
88
  def parse_task_ref(body: str) -> int | None:
78
89
  """Origin Alissa task number from an issue body, or None. A line-anchored
79
90
  `Alissa-Task: TASK-<n>` trailer wins wherever it sits; only absent that does
80
91
  the loose first-`TASK-<n>` scan run. Mirrors devloop's parser so both
81
92
  daemons read the trailer orcloop writes identically."""
82
- 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,16 +16,27 @@ 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.
25
- * **The dependsOn gate**: a candidate is releasable only once every upstream
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.
34
+ * **The dependency gate**: a candidate is releasable only once every upstream
26
35
  task it depends on is `pending_validation` or `validated` (an unfinished
27
- dependency holds the release). An upstream this actor cannot even see counts
28
- as not-clear -- the gate is conservative.
36
+ dependency holds the release). BOTH dependency types count -- soft
37
+ (`dependsOn`) and hard (`hardDependsOn`); see `dependencies_clear` and the
38
+ declared-count rationale in `alissa_client`. An upstream this actor cannot
39
+ even see counts as not-clear -- the gate is conservative.
29
40
  * **Per-repo lane occupancy** bounds how much autonomous work runs at once in
30
41
  one repo: at most `repo_parallelism` in-flight released issues, AND -- when
31
42
  parallelism > 1 -- their `Autonomous-Scope` globs must be pairwise disjoint
@@ -167,24 +178,43 @@ the Alissa task (primary) and best-effort on the GitHub issue, and are all
167
178
  dry-run gated: `unreleasable` (a marked task with no valid target repo),
168
179
  `release-blocked` (the dependsOn gate held past the `release_blocked_floor_days`
169
180
  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).
181
+ never a page), `parked`, `closed-unmerged`, `already-merged` (a task came up
182
+ for release but its trailer already sits on an issue closed by a merged PR),
183
+ `dedupe-unreadable` (the pre-create trailer check has been unable to answer for
184
+ longer than the same `release_blocked_floor_days` floor -- measured on the
185
+ FAILURE, not the task's age, so a transient rate limit on a long-queued task
186
+ never pages -- because a hold that could never page is how a task goes quiet
187
+ forever), and `closeloop-failed:a<j>` (a close-loop mutation failed; retried
188
+ with a floor between attempts, each attempt paging once).
173
189
 
174
190
  NO SILENT STATES: every candidate and every released issue produces exactly one
175
191
  logged `Decision` each pass -- a deferral (`held`) always leaves a per-poll log
176
192
  line, and a floored escalation is the only thing allowed to defer without a
177
193
  per-poll page. `test_loop.py` pins this.
178
194
 
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.
195
+ THE §8 OPEN DECISION -- adoption of hand-released issues (RESOLVED here): an
196
+ issue a human files by hand carrying an `Alissa-Task:` trailer is ADOPTED
197
+ rather than duplicated, in whichever of two places sees it first:
198
+
199
+ * WITH the trigger label -- the release scan adopts it into the ledger the
200
+ first time it sees it open, so the close edge drives its origin task forward
201
+ on merge exactly like an orcloop-filed one.
202
+ * WITHOUT the label -- the pre-create trailer check adopts it when the task
203
+ comes up for release: the ledger row is written against the EXISTING issue
204
+ and the label is applied, completing a release a human started. Both halves
205
+ of the alternative were wrong before this: filing a second issue duplicates
206
+ the work, and skipping on the match would leave the trigger unarmed (an
207
+ unlabeled issue is invisible to the develop daemon), so the task would
208
+ simply starve.
209
+
210
+ CLOSED trailer matches never permanently block a release: a closed-unmerged
211
+ one is ignored (a superseded or not-planned issue is not evidence the work
212
+ happened), and only a match closed by a MERGED closing PR stops the release --
213
+ as an `already-merged` escalation, because a task still queued for release
214
+ whose work already merged is an anomaly for the operator, not a silent skip.
215
+ The one limit that remains: adoption needs to see an issue at all, so a task
216
+ whose only trace is a closed, never-merged issue releases fresh. Recorded in
217
+ the PR body and on the origin task.
188
218
 
189
219
  orcloop spawns NO worker sessions (it files issues; the develop daemon spawns
190
220
  the sessions), so there is no session reaper here -- the family's "sweep" is
@@ -217,8 +247,9 @@ from .state import State
217
247
 
218
248
  log = logging.getLogger(__name__)
219
249
 
220
- # The dependsOn gate: an upstream task is "clear" (no longer holding the
221
- # release) once it has reached validation. Anything earlier holds.
250
+ # The dependency gate: an upstream task is "clear" (no longer holding the
251
+ # release) once it has reached validation. Anything earlier holds. The type of
252
+ # the dependency (soft/hard) does not enter here -- both hold identically.
222
253
  DEP_CLEAR_STATES = frozenset({"pending_validation", "validated"})
223
254
 
224
255
  # The close edge only mutates a task it left `committed` (or one mid-transition
@@ -244,6 +275,34 @@ ESCALATION_RELEASE_BLOCKED = "release-blocked"
244
275
  ESCALATION_PARKED = "parked"
245
276
  ESCALATION_CLOSED_UNMERGED = "closed-unmerged"
246
277
  ESCALATION_CLOSELOOP_FAILED = "closeloop-failed"
278
+ ESCALATION_ALREADY_MERGED = "already-merged"
279
+ ESCALATION_DEDUPE_UNREADABLE = "dedupe-unreadable"
280
+
281
+ # NOT an operator page: the timestamp of the FIRST dedupe check that could not
282
+ # answer for this task, so the floor above it measures how long the CONDITION
283
+ # has lasted rather than how old the task is. Task age is a fair proxy for a
284
+ # persistent gate (that is why `release-blocked` uses it); a single failed I/O
285
+ # read is not, and without this an old task would page on its first transient
286
+ # rate limit -- permanently, since the page kind would then be spent. Both this
287
+ # marker and the page are cleared the moment a check answers again.
288
+ #
289
+ # The `marker:` prefix is load-bearing, not decoration: `escalations_matching`
290
+ # is a PREFIX reader (that is how `closeloop-failed:a<j>` counts its attempts),
291
+ # so a marker named `dedupe-unreadable-since` (the name this one had before
292
+ # the namespace) would silently answer a future
293
+ # `escalations_matching(task, ESCALATION_DEDUPE_UNREADABLE)` -- a non-page row
294
+ # inflating a page count in whatever report asked. Namespacing puts it out of
295
+ # reach of every prefix read of a page kind.
296
+ DEDUPE_UNREADABLE_SINCE = "marker:dedupe-unreadable"
297
+
298
+ # How many CLOSED trailer matches the pre-create dedupe will walk looking for a
299
+ # merged closing PR. Each check is a bounded timeline walk (see
300
+ # `GitHub.closing_merged_pr`), and the matches are walked newest-first, so this
301
+ # caps the cost of a task whose trailer has been re-filed many times. A task
302
+ # with more closed matches than this releases -- the closed-match rule is
303
+ # "closed work must not permanently block release", so an unresolved tail
304
+ # errs toward releasing, never toward a task that can never be released again.
305
+ MAX_CLOSED_TRAILER_CHECKS = 3
247
306
 
248
307
  # NOT an operator page: a completion marker so a fully-closed loop is never
249
308
  # re-processed. Lives in the escalations table (kind is free-form TEXT by the
@@ -335,6 +394,33 @@ CLOSED_UNMERGED_ISSUE_COMMENT = (
335
394
  "closing PR, so the orchestrator left its origin Alissa task untouched."
336
395
  )
337
396
 
397
+ ALREADY_MERGED_TASK_COMMENT = (
398
+ "**Orchestrator: already merged** — this task was about to be released, "
399
+ "but issue {slug} already carries its `Alissa-Task:` trailer and was "
400
+ "closed by merged pull request {pr_url}. The work looks genuinely done, "
401
+ "so the orchestrator has NOT filed a second issue and has NOT advanced "
402
+ "this task (it never held a ledger row for that issue, so the close edge "
403
+ "could not drive it). Validate and close this task by hand if the merge "
404
+ "covers it; if it does not, remove the release marker or edit the trailer "
405
+ "off that issue so the orchestrator can release fresh work for it."
406
+ )
407
+ DEDUPE_UNREADABLE_COMMENT = (
408
+ "**Orchestrator: dedupe unreadable** — before filing a develop issue for "
409
+ "this task the orchestrator checks {target} for an issue that already "
410
+ "carries its `Alissa-Task:` trailer, and that check has been failing "
411
+ "continuously for over {floor_days} day(s): {reason}. The release is held rather "
412
+ "than risk a duplicate issue, and it will keep retrying. Usual causes: the "
413
+ "GitHub token lost access to {target}, or a closed issue carrying this "
414
+ "trailer was transferred or deleted. Restore the read (or release this "
415
+ "work by hand) to clear it."
416
+ )
417
+
418
+ ALREADY_MERGED_ISSUE_COMMENT = (
419
+ "**Orchestrator: already merged** — this closed issue carries the "
420
+ "`Alissa-Task:` trailer of a task that is still queued for release. No "
421
+ "second issue was filed; the origin task needs an operator decision."
422
+ )
423
+
338
424
  CLOSELOOP_FAILED_TASK_COMMENT = (
339
425
  "**Orchestrator: close-loop failed (attempt {attempt})** — the released "
340
426
  "issue {slug} merged, but advancing this task to `pending_validation` "
@@ -414,12 +500,27 @@ def priority_rank(priority: str) -> int:
414
500
  def dependencies_clear(task: Task) -> bool:
415
501
  """Whether every dependency of `task` has reached validation.
416
502
 
417
- Vacuously true with no dependencies. Conservative on partial visibility: if
418
- the resolved `dependencies` are fewer than the raw `depends_on_count`, some
419
- upstream is invisible to this actor and the gate must NOT pass on the ones
420
- it can see -- an unfinished-but-hidden dependency would otherwise leak."""
421
- if task.depends_on_count == 0:
422
- return True
503
+ Two conditions, in this order, and the order is the whole point:
504
+
505
+ 1. The declared count must match what was RESOLVED. `depends_on_count` is
506
+ what the payload declared (soft + hard, deduplicated); `dependencies` is
507
+ what came back resolved. Fewer resolved means an upstream is invisible to
508
+ this actor, and the gate must NOT pass on the ones it can see -- an
509
+ unfinished-but-hidden dependency would otherwise leak.
510
+ 2. Every resolved upstream must have reached validation.
511
+
512
+ Vacuously true with no dependencies: 0 declared and 0 resolved match, and
513
+ `all()` over an empty tuple is True. That is deliberately reached THROUGH
514
+ the visibility guard rather than by an `if depends_on_count == 0: return
515
+ True` shortcut ahead of it, which is what made this function release a task
516
+ whose only blockers were hard dependencies (issue #26): `dependsOn` held the
517
+ soft ids only, so a hard-only task declared zero and took the shortcut --
518
+ while `upstreamTasks`, and therefore `dependencies`, listed the blocker the
519
+ whole time. The declared count is fixed in `alissa_client.get_task`; the
520
+ shortcut is gone from here so that a count that ever again understates
521
+ reality is HELD by the mismatch instead of waved through. Do not reintroduce
522
+ an early return above the guard.
523
+ """
423
524
  if len(task.dependencies) != task.depends_on_count:
424
525
  return False
425
526
  return all(dep.status in DEP_CLEAR_STATES for dep in task.dependencies)
@@ -536,9 +637,15 @@ class Orchestrator:
536
637
  Every open labeled issue occupies its repo's lane (its `Autonomous-Scope`
537
638
  globs recorded for disjointness checks). An issue whose body carries an
538
639
  `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."""
640
+ in-flight dedupe set -- and, if the ledger does not know it yet, is
641
+ ADOPTED (§8: a hand-released or ledger-lost issue) so the close edge
642
+ tracks it.
643
+
644
+ This search is LABEL-scoped, which is what lane occupancy needs and is
645
+ deliberately NOT the whole dedupe story: an issue carrying the trailer
646
+ but no label is in neither the occupancy nor the dedupe set here. That
647
+ case is settled per-candidate by `_trailer_dedupe`, immediately before
648
+ anything is filed."""
542
649
  released: dict[int, Issue] = {}
543
650
  occupancy: dict[str, list[list[str]]] = {}
544
651
  for owner, repo, number in self.github.search_issues(
@@ -1210,14 +1317,30 @@ class Orchestrator:
1210
1317
  return self._release(task, target, scope, occupancy, bow=bow)
1211
1318
 
1212
1319
  def _release_blocked(self, task: Task) -> tuple[str, Decision]:
1213
- """The dependsOn gate holds. Below the floor this is a quiet per-poll
1320
+ """The dependency gate holds. Below the floor this is a quiet per-poll
1214
1321
  hold; past `release_blocked_floor_days` (measured on the task's own age)
1215
1322
  it pages the operator once. `created_at` is ms since epoch; a task with
1216
1323
  no timestamp (created_at 0) is treated as old enough to page, so a stuck
1217
1324
  release is never silent forever."""
1218
- deps = ", ".join(f"{d.ref}={d.status or '?'}" for d in task.dependencies) or (
1219
- f"{task.depends_on_count} dependency(ies) not visible to this actor"
1220
- )
1325
+ parts = [f"{d.ref}={d.status or '?'}" for d in task.dependencies]
1326
+ hidden = task.depends_on_count - len(task.dependencies)
1327
+ if hidden > 0:
1328
+ # Report the invisible remainder ALONGSIDE the resolved ones: a
1329
+ # partially-visible task holds on something the operator cannot see
1330
+ # in the list, and naming only the resolved deps reads as "these are
1331
+ # all of them" when it is precisely the missing one doing the hold.
1332
+ # When NOTHING resolved there is no list to be a remainder OF, and
1333
+ # "3 more" would be more than what -- so that shape keeps its own
1334
+ # count-only wording. This string goes onto the `release-blocked`
1335
+ # page an operator reads to decide what to unblock.
1336
+ parts.append(
1337
+ f"{hidden} more not visible to this actor" if parts
1338
+ else f"{hidden} dependency(ies) not visible to this actor"
1339
+ )
1340
+ # `parts` is never empty here: this runs only after `dependencies_clear`
1341
+ # returned False, which needs either a count mismatch (hidden > 0,
1342
+ # handled just above) or a resolved dep that has not validated.
1343
+ deps = ", ".join(parts)
1221
1344
  floor_seconds = self.config.release_blocked_floor_days * 86400
1222
1345
  age_seconds = self._now() - (task.created_at / 1000.0 if task.created_at else 0.0)
1223
1346
  if age_seconds >= floor_seconds:
@@ -1268,6 +1391,410 @@ class Orchestrator:
1268
1391
  )
1269
1392
  return None
1270
1393
 
1394
+ def _trailer_dedupe(
1395
+ self,
1396
+ task: Task,
1397
+ target: str,
1398
+ occupancy: dict[str, list[list[str]]],
1399
+ *,
1400
+ bow: BodyOfWork | None = None,
1401
+ ) -> tuple[str, Decision] | None:
1402
+ """The trailer-authoritative dedupe, run in the target repo immediately
1403
+ before an issue would be created. Returns the decision that replaces
1404
+ the create, or None to go ahead and create.
1405
+
1406
+ WHY IT EXISTS (the duplicate this fixes): the pass-level scan searches
1407
+ `is:issue is:open label:"<label>"` and only then parses the matched
1408
+ bodies for a trailer, so its dedupe set is LABEL-scoped -- an open
1409
+ issue that already carries a task's `Alissa-Task:` trailer but has not
1410
+ been labeled is never fetched and never parsed. A hand-staged issue in
1411
+ exactly that state (open, unlabeled, trailer present for days) was
1412
+ missed and a second issue was filed for the same task. Skipping on the
1413
+ match would have been just as wrong the other way: unlabeled means
1414
+ invisible to the develop daemon, so the trigger would have starved.
1415
+
1416
+ The rule matrix, per match in the target repo:
1417
+
1418
+ * OPEN + unlabeled -> **adopt**: record the ledger row against the
1419
+ EXISTING issue and apply the label. That is the same
1420
+ ledger-then-label ordering (and the same idempotent crash
1421
+ completion) a fresh release uses; it never files a second issue and
1422
+ never leaves the trigger unarmed.
1423
+ * OPEN + already labeled -> **skip**, and record the ledger row so the
1424
+ close edge tracks it (the §8 adoption the scan would do next pass;
1425
+ reaching here at all means the issue appeared after this pass's
1426
+ scan).
1427
+ * CLOSED with no merged closing PR -> **ignored**. A superseded,
1428
+ not-planned or hand-closed issue must never make a task permanently
1429
+ unreleasable, so the release proceeds and files a fresh issue.
1430
+ * CLOSED by a merged closing PR -> **skip + escalate**
1431
+ (`already-merged`): the work is genuinely done, so a second issue
1432
+ would be duplicate work -- but the origin task is still queued, which
1433
+ means no ledger row ever drove the close edge for it. That anomaly is
1434
+ an operator decision, not something to guess at.
1435
+
1436
+ A dedupe read that does not ANSWER (search or timeline failure) holds
1437
+ the release -- "the check failed" must never be read as "no duplicate"
1438
+ -- but never silently forever: past the release-blocked floor the hold
1439
+ pages once (`_dedupe_unreadable`), because a task that can neither
1440
+ release nor complain is the same dead end the closed-match rule above
1441
+ exists to prevent.
1442
+ """
1443
+ owner, _, repo = target.partition("/")
1444
+ try:
1445
+ matches = self.github.issues_with_task_trailer(owner, repo, task.task_number)
1446
+ except (CommandError, RateLimited) as exc:
1447
+ return self._dedupe_unreadable(
1448
+ task, target, f"the trailer search in {target} failed: {exc}",
1449
+ )
1450
+
1451
+ open_matches = [issue for issue in matches if issue.state == "open"]
1452
+ if open_matches:
1453
+ self._dedupe_answered(task.task_number)
1454
+ return self._adopt(task, open_matches, target, occupancy, bow=bow)
1455
+ return self._closed_trailer_matches(task, matches, target)
1456
+
1457
+ def _adopt(
1458
+ self,
1459
+ task: Task,
1460
+ open_matches: list[Issue],
1461
+ target: str,
1462
+ occupancy: dict[str, list[list[str]]],
1463
+ *,
1464
+ bow: BodyOfWork | None = None,
1465
+ ) -> tuple[str, Decision]:
1466
+ """Complete the release ON an open issue that already carries this
1467
+ task's trailer, instead of filing a second one.
1468
+
1469
+ The EARLIEST filed match wins (matches arrive lowest-number-first): if
1470
+ a repo somehow carries two open issues for one task, the first one is
1471
+ the one people have been reading, and picking deterministically beats
1472
+ picking the newest. The extras are named in a warning -- one task
1473
+ cannot have two live issues without someone having made a mistake.
1474
+
1475
+ Lane occupancy is recorded from the ISSUE's own `Autonomous-Scope`, not
1476
+ the candidate task's: the issue is the in-flight artifact, it is what
1477
+ `_scan_released` will read on every later pass, and on an adopted
1478
+ (human-written) body the two can differ. Taking the task's globs here
1479
+ would leave this pass and the next deciding disjointness on different
1480
+ input. A body with no scope trailer is therefore SCOPELESS -- it
1481
+ occupies the whole lane, the conservative reading and the one the next
1482
+ scan reaches independently.
1483
+
1484
+ Which is exactly why the lane is RE-GATED here on `issue_scope` before
1485
+ the label goes on. `_evaluate_release` admitted this candidate on the
1486
+ TASK's globs; adoption then arms a different artifact, whose globs can
1487
+ conflict with the lane the task's did not (an absent scope trailer is
1488
+ the common shape -- scopeless means "needs an empty lane", and the
1489
+ adopt path is the one release path that could otherwise land it beside
1490
+ an occupant). Gating on the input this method treats as authoritative
1491
+ keeps `scopes_disjoint`'s guarantee true of what actually runs. A
1492
+ conflict HOLDS: the issue keeps its trailer, so a later pass adopts it
1493
+ once the lane clears -- nothing is lost and nothing is duplicated.
1494
+
1495
+ That guarantee is one-directional, deliberately. The EARLIER gate still
1496
+ decides on the task's globs, so a candidate the issue's globs would
1497
+ have admitted can be held before it ever reaches here (task claims
1498
+ `src/**` against an `src/**` occupant; the staged issue claims
1499
+ `docs/**`). Letting a lane-blocked candidate fall through to be
1500
+ re-decided here would close it, and is not worth the extra path: that
1501
+ direction costs THROUGHPUT -- one poll, self-clearing when the lane
1502
+ drains -- while the direction gated above costs SAFETY, two sessions on
1503
+ overlapping files.
1504
+
1505
+ Adoption holds are quiet, exactly like every other lane hold: one log
1506
+ line per poll, no floor, no page. The scopeless case is the one worth
1507
+ knowing about -- "needs an empty lane" conflicts with EVERY occupant,
1508
+ so in a busy repo a scopeless staged issue can wait a long time -- so
1509
+ that hold logs a WARNING naming the issue and the one-line remedy,
1510
+ rather than only the per-poll decision line.
1511
+
1512
+ The already-labeled branch is not gated: that issue is armed and
1513
+ in-flight whatever orcloop thinks, so there is no trigger to withhold,
1514
+ only a ledger row to record and a lane to account for.
1515
+
1516
+ An adopted body is a human's, so it is not the renderer's: only the
1517
+ trailer is contractual (it is all the develop daemon reads too). A body
1518
+ without the `Autonomous-Dev` trailer is adopted anyway -- the target
1519
+ repo is the one this search was scoped to, not something inferred from
1520
+ the body -- but it is logged, because "an operator staged something
1521
+ incomplete" should be visible in the poll log rather than in devloop's
1522
+ behaviour."""
1523
+ issue = open_matches[0]
1524
+ if len(open_matches) > 1:
1525
+ log.warning(
1526
+ "%s: %d OPEN issues carry this task's trailer (%s) — adopting "
1527
+ "the earliest, %s; the others are duplicates a human should "
1528
+ "close",
1529
+ task.ref, len(open_matches),
1530
+ ", ".join(found.issue_slug for found in open_matches),
1531
+ issue.issue_slug,
1532
+ )
1533
+
1534
+ issue_scope = parse_scope(issue.body, self.config.scope_key)
1535
+ labeled = self.config.label in issue.labels
1536
+ if not labeled:
1537
+ lane_reason = self._lane_blocked(target, issue_scope, occupancy)
1538
+ if lane_reason is not None:
1539
+ if not issue_scope:
1540
+ # A scopeless issue needs an EMPTY lane, so this hold does
1541
+ # not clear when one occupant closes -- it clears when the
1542
+ # repo goes quiet. Loud enough to act on: whoever staged
1543
+ # the issue can end the wait with one trailer.
1544
+ log.warning(
1545
+ "%s: %s carries this task's trailer but declares no "
1546
+ "`%s:` trailer, so adopting it would need an EMPTY "
1547
+ "%s lane (%s) — held. Add an `%s:` trailer to let it "
1548
+ "land beside in-flight work",
1549
+ task.ref, issue.issue_slug, self.config.scope_key,
1550
+ target, lane_reason, self.config.scope_key,
1551
+ )
1552
+ return self._logged(task.ref, Decision(
1553
+ Action.HELD,
1554
+ f"cannot adopt {issue.issue_slug} yet: lane {target} "
1555
+ f"occupied: {lane_reason} — the issue declares "
1556
+ f"{issue_scope or '(scopeless — needs an empty lane)'}, "
1557
+ f"whatever the task declares; retries next poll",
1558
+ task.task_number,
1559
+ ))
1560
+
1561
+ # Past every gate that can refuse, and worded as STATE rather than act:
1562
+ # the three paths below it are a live adoption, a dry-run report, and an
1563
+ # already-labeled skip that arms nothing, so a line claiming "adopting"
1564
+ # would overstate two of them. What the operator needs is the same on
1565
+ # all three -- what the body left out, and what was inferred instead.
1566
+ if parse_marker(issue.body, self.config.marker_key) is None:
1567
+ log.warning(
1568
+ "%s: %s carries this task's trailer but no `%s:` trailer — "
1569
+ "target repo read from the search scope (%s), lane scope from "
1570
+ "the body (%s). Edit the issue if that is not what it means",
1571
+ task.ref, issue.issue_slug, self.config.marker_key, target,
1572
+ issue_scope or "scopeless — occupies the whole lane",
1573
+ )
1574
+
1575
+ if self.config.dry_run:
1576
+ verb = "would record" if labeled else "would adopt"
1577
+ unlabeled_note = "" if labeled else f", no {self.config.label!r} label"
1578
+ occupancy.setdefault(target, []).append(issue_scope)
1579
+ return self._logged(task.ref, Decision(
1580
+ Action.SKIPPED if labeled else Action.RELEASED,
1581
+ f"[dry-run] {verb} pre-existing {issue.issue_slug} (carries "
1582
+ f"this task's `Alissa-Task:` trailer{unlabeled_note})"
1583
+ f"{self._detach(task.ref, bow)}",
1584
+ task.task_number,
1585
+ ))
1586
+
1587
+ try:
1588
+ self.state.record_release(
1589
+ task_number=task.task_number,
1590
+ owner_repo=target,
1591
+ issue_number=issue.number,
1592
+ labeled=labeled,
1593
+ )
1594
+ if labeled:
1595
+ # Already armed: there is no trigger left to complete, only the
1596
+ # ledger row (written above) so the close edge tracks it. It
1597
+ # still occupies the lane for the REST OF THIS PASS -- the scan
1598
+ # ran before this issue existed, so no later candidate would
1599
+ # otherwise measure itself against it.
1600
+ occupancy.setdefault(target, []).append(issue_scope)
1601
+ return self._logged(task.ref, Decision(
1602
+ Action.SKIPPED,
1603
+ f"already released by hand as {issue.issue_slug} (trailer "
1604
+ f"and {self.config.label!r} label present); recorded in the "
1605
+ f"ledger, no second issue{self._detach(task.ref, bow)}",
1606
+ task.task_number,
1607
+ ))
1608
+ self.github.add_label(issue.owner, issue.repo, issue.number, self.config.label)
1609
+ self.state.mark_labeled(task.task_number)
1610
+ except (CommandError, RateLimited) as exc:
1611
+ # Same recovery as a create that died before its label: the ledger
1612
+ # row exists at labeled=0, so the next pass's dedupe path completes
1613
+ # the labeling of THIS issue rather than filing anything. The
1614
+ # detach is not attempted -- the release is not complete yet.
1615
+ return self._logged(task.ref, Decision(
1616
+ Action.HELD,
1617
+ f"adoption of {issue.issue_slug} failed: {exc}",
1618
+ task.task_number,
1619
+ ))
1620
+
1621
+ occupancy.setdefault(target, []).append(issue_scope)
1622
+ return self._logged(task.ref, Decision(
1623
+ Action.RELEASED,
1624
+ f"adopted pre-existing {issue.issue_slug} (carries this task's "
1625
+ f"trailer, was unlabeled): ledger row recorded and "
1626
+ f"{self.config.label!r} applied{self._detach(task.ref, bow)}",
1627
+ task.task_number,
1628
+ ))
1629
+
1630
+ def _closed_trailer_matches(
1631
+ self, task: Task, matches: list[Issue], target: str
1632
+ ) -> tuple[str, Decision] | None:
1633
+ """Apply the closed-match rule to the CLOSED trailer matches (there are
1634
+ no open ones by the time this runs). Returns a decision that replaces
1635
+ the create, or None to release.
1636
+
1637
+ Closed-unmerged matches are ignored on purpose: a superseded or
1638
+ not-planned issue is not evidence the work happened, and letting it
1639
+ veto the release would make one hand-closed issue enough to render a
1640
+ task permanently unreleasable. Only a MERGED closing PR is evidence,
1641
+ and that is escalated rather than silently skipped.
1642
+
1643
+ An UNREADABLE match (transferred, deleted, or in a repo the token lost
1644
+ access to) does not speak for the others: the walk continues, and the
1645
+ pass holds only if NO checked match could be resolved. One permanently
1646
+ unreadable issue would otherwise hold the task forever -- through the
1647
+ error branch, the same dead end the rule above rules out. When every
1648
+ checked match is unreadable the hold is floored and pages once (see
1649
+ `_dedupe_unreadable`)."""
1650
+ if not matches:
1651
+ self._dedupe_answered(task.task_number)
1652
+ return None
1653
+
1654
+ # Newest first: the most recent closed attempt is the likeliest to
1655
+ # carry the merge, and the walk is bounded.
1656
+ newest_first = sorted(matches, key=lambda found: found.number, reverse=True)
1657
+ checked = newest_first[:MAX_CLOSED_TRAILER_CHECKS]
1658
+ unreadable: list[str] = []
1659
+ first_error: Exception | None = None
1660
+ for issue in checked:
1661
+ try:
1662
+ pr = self.github.closing_merged_pr(issue.owner, issue.repo, issue.number)
1663
+ except (CommandError, RateLimited) as exc:
1664
+ unreadable.append(issue.issue_slug)
1665
+ first_error = first_error or exc
1666
+ continue
1667
+ if pr is None:
1668
+ continue
1669
+ self._dedupe_answered(task.task_number)
1670
+ self._escalate(
1671
+ task.task_number,
1672
+ ESCALATION_ALREADY_MERGED,
1673
+ ALREADY_MERGED_TASK_COMMENT.format(slug=issue.issue_slug, pr_url=pr.url),
1674
+ issue=issue,
1675
+ issue_comment=ALREADY_MERGED_ISSUE_COMMENT,
1676
+ )
1677
+ return self._logged(task.ref, Decision(
1678
+ Action.ESCALATED,
1679
+ f"already merged: {issue.issue_slug} carries this task's "
1680
+ f"trailer and was closed by merged {pr.url} — no second issue "
1681
+ f"filed, task left for the operator",
1682
+ task.task_number,
1683
+ ))
1684
+
1685
+ if unreadable and len(unreadable) == len(checked):
1686
+ # Nothing was resolved, so the walk made no claim at all -- this is
1687
+ # the same "the check did not answer" state as a failed search.
1688
+ return self._dedupe_unreadable(
1689
+ task, target,
1690
+ f"none of the closed trailer matches in {target} could be read "
1691
+ f"({', '.join(unreadable)}): {first_error}",
1692
+ )
1693
+
1694
+ # At least one match resolved, so the check DID answer: any recorded
1695
+ # failure is over.
1696
+ self._dedupe_answered(task.task_number)
1697
+ unchecked = max(0, len(newest_first) - MAX_CLOSED_TRAILER_CHECKS)
1698
+ log.info(
1699
+ "%s: %d closed issue(s) in %s carry this task's trailer (%s) but "
1700
+ "none of the %d checked was closed by a merged PR%s%s — closed work "
1701
+ "that never merged does not block a release; filing a fresh issue",
1702
+ task.ref, len(newest_first), target,
1703
+ ", ".join(found.issue_slug for found in newest_first),
1704
+ len(checked),
1705
+ f" ({unchecked} older match(es) not checked)" if unchecked else "",
1706
+ f" ({len(unreadable)} unreadable: {', '.join(unreadable)})"
1707
+ if unreadable else "",
1708
+ )
1709
+ return None
1710
+
1711
+ def _dedupe_unreadable(
1712
+ self, task: Task, target: str, reason: str
1713
+ ) -> tuple[str, Decision]:
1714
+ """The trailer dedupe could not answer, so the release is held rather
1715
+ than risk a duplicate -- with the floor-then-page shape
1716
+ `_release_blocked` uses, because a hold that can never page is how a
1717
+ task goes quiet forever: a token that lost access to the target repo,
1718
+ or a closed match that was transferred or deleted, fails identically on
1719
+ every poll.
1720
+
1721
+ The floor measures THE FAILURE, not the task. `_release_blocked` can
1722
+ floor on task age because the dependsOn gate is a persistent condition,
1723
+ so an old blocked task has plausibly been blocked a while; this
1724
+ condition is a single I/O outcome, and `search/issues` is GitHub's
1725
+ tightest limit -- a routine, self-clearing rate limit on a
1726
+ long-queued task would otherwise page on contact, with a message
1727
+ claiming days of failure, and burn the kind so a genuine outage later
1728
+ had no page left. So the first failure records
1729
+ `marker:dedupe-unreadable` (stamped from the loop's own clock) and holds
1730
+ quietly; only once THAT is older than `release_blocked_floor_days` does
1731
+ one `dedupe-unreadable` escalation name the repo and the failing read.
1732
+ `_escalate` dedupes per (task, kind), so a long outage pages once.
1733
+
1734
+ `_trailer_dedupe` clears both rows the moment a check answers again, so
1735
+ a blip that clears leaves nothing behind and a later, unrelated outage
1736
+ starts its own clock and gets its own page.
1737
+
1738
+ The floor knob is shared with `release-blocked` on purpose: both say
1739
+ "how long may a release be quietly held before an operator is told",
1740
+ and a second knob for the same question is a config surface (and a
1741
+ container floor bump) that buys nothing."""
1742
+ floor_seconds = self.config.release_blocked_floor_days * 86400
1743
+ held = f"dedupe check could not answer — {reason}; held rather than risk "
1744
+ rows = self.state.escalations_matching(task.task_number, DEDUPE_UNREADABLE_SINCE)
1745
+ now = int(self._now())
1746
+ if not rows:
1747
+ if not self.config.dry_run:
1748
+ self.state.record_escalation(
1749
+ task.task_number, DEDUPE_UNREADABLE_SINCE, at=now,
1750
+ )
1751
+ return self._logged(task.ref, Decision(
1752
+ Action.HELD,
1753
+ held + "filing a duplicate issue; first failure, retries next poll",
1754
+ task.task_number,
1755
+ ))
1756
+
1757
+ failing_for = now - int(rows[0]["escalated_at"])
1758
+ if failing_for < floor_seconds:
1759
+ return self._logged(task.ref, Decision(
1760
+ Action.HELD,
1761
+ held + f"filing a duplicate issue; failing for "
1762
+ f"{failing_for // 3600}h, within the "
1763
+ f"{self.config.release_blocked_floor_days}d floor",
1764
+ task.task_number,
1765
+ ))
1766
+
1767
+ self._escalate(
1768
+ task.task_number,
1769
+ ESCALATION_DEDUPE_UNREADABLE,
1770
+ DEDUPE_UNREADABLE_COMMENT.format(
1771
+ target=target,
1772
+ reason=reason,
1773
+ floor_days=self.config.release_blocked_floor_days,
1774
+ ),
1775
+ )
1776
+ return self._logged(task.ref, Decision(
1777
+ Action.ESCALATED,
1778
+ held + f"filing a duplicate issue, and failing for "
1779
+ f"{failing_for // 86400}d — past the "
1780
+ f"{self.config.release_blocked_floor_days}d floor",
1781
+ task.task_number,
1782
+ ))
1783
+
1784
+ def _dedupe_answered(self, task_number: int) -> None:
1785
+ """The dedupe check answered, so any recorded failure is OVER: drop the
1786
+ first-seen marker and the page kind together.
1787
+
1788
+ Clearing the page kind is the point -- `_escalate`'s per-(task, kind)
1789
+ dedupe is what stops an outage paging every poll, and without a clear
1790
+ it would also stop a SECOND, unrelated outage paging at all. Guarded by
1791
+ a read so the common (working) path does not write on every release."""
1792
+ if self.config.dry_run:
1793
+ return
1794
+ for kind in (DEDUPE_UNREADABLE_SINCE, ESCALATION_DEDUPE_UNREADABLE):
1795
+ if self.state.escalated(task_number, kind):
1796
+ self.state.clear_escalation(task_number, kind)
1797
+
1271
1798
  def _release(
1272
1799
  self,
1273
1800
  task: Task,
@@ -1283,8 +1810,16 @@ class Orchestrator:
1283
1810
  daemon's trigger and the detach is the release's terminal act, so a
1284
1811
  crash at any point leaves the work discoverable rather than lost.
1285
1812
  Occupancy is updated in-memory so a later candidate in THIS pass sees
1286
- the new occupant and respects the lane."""
1813
+ the new occupant and respects the lane.
1814
+
1815
+ Nothing is created until the TRAILER dedupe answers: the pass-level
1816
+ scan is label-scoped and cannot see an unlabeled issue that already
1817
+ carries this task's trailer (see `_trailer_dedupe`)."""
1287
1818
  owner, _, repo = target.partition("/")
1819
+ preexisting = self._trailer_dedupe(task, target, occupancy, bow=bow)
1820
+ if preexisting is not None:
1821
+ return preexisting
1822
+
1288
1823
  body = render_issue_body(task, target, scope, self.config)
1289
1824
 
1290
1825
  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.4
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera