alissa-tools-github-orcloop 0.3.5__tar.gz → 0.3.7__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.5/src/main/alissa_tools_github_orcloop.egg-info → alissa_tools_github_orcloop-0.3.7}/PKG-INFO +1 -1
  2. {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/loop.py +345 -41
  3. {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/markers.py +59 -0
  4. alissa_tools_github_orcloop-0.3.7/src/main/alissa/tools/github/orcloop/version +1 -0
  5. {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7/src/main/alissa_tools_github_orcloop.egg-info}/PKG-INFO +1 -1
  6. alissa_tools_github_orcloop-0.3.5/src/main/alissa/tools/github/orcloop/version +0 -1
  7. {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/MANIFEST.in +0 -0
  8. {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/README.md +0 -0
  9. {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/requirements.txt +0 -0
  10. {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/setup.cfg +0 -0
  11. {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/setup.py +0 -0
  12. {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
  13. {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/__main__.py +0 -0
  14. {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/alissa_client.py +0 -0
  15. {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/config.py +0 -0
  16. {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/ghclient.py +0 -0
  17. {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
  18. {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/state.py +0 -0
  19. {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/version.py +0 -0
  20. {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa_tools_github_orcloop.egg-info/SOURCES.txt +0 -0
  21. {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa_tools_github_orcloop.egg-info/dependency_links.txt +0 -0
  22. {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa_tools_github_orcloop.egg-info/entry_points.txt +0 -0
  23. {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/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.5
3
+ Version: 0.3.7
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -60,6 +60,17 @@ issues the develop daemon can pick up:
60
60
  crash BETWEEN create and the ledger write: it leaves an unlabeled (and so
61
61
  un-triggered, harmless) orphan issue; this is the accepted trade the O2 state
62
62
  design already documents.
63
+ * **The origin task goes `committed -> in_progress`** as soon as its issue
64
+ exists (issue #30) -- on the create path, on the adopt path, and on the
65
+ crash-recovery path that completes an earlier release's label -- through the
66
+ same `AlissaClient.set_status` the close edge opens its pending_validation
67
+ walk with. `committed` means scoped-in TODO and released work is being worked,
68
+ so without this a task would read TODO for the entire implementation window
69
+ and only jump to `pending_validation` at merge. It is a strictly-from-
70
+ `committed` move (anything further along is left alone -- never a downgrade)
71
+ and it is NON-BLOCKING: a failed set is a warning, the release stands, and the
72
+ close edge's walk applies the same transition at merge. See
73
+ `_mark_in_progress`.
63
74
  * **Operator park**: if the operator removes the trigger label from a released
64
75
  issue, orcloop NEVER re-labels it (that would override the operator) and
65
76
  raises the `parked` escalation once. Park is detected on the close edge,
@@ -92,8 +103,11 @@ titled `autodev: <owner>/<repo>` (`bow_prefix`).
92
103
  one line at a time).
93
104
  * **Discovery is O(total BOW membership)**, never O(all committed tasks): ONE
94
105
  `GET /v1/bodies-of-work`, then one membership read per matching BOW, then one
95
- `get_task` per *committed* member (membership rows carry a status but no task
96
- number, so the committed gate is applied before spending the detail read).
106
+ `get_task` per member whose row reads `committed` or `in_progress`
107
+ (`BOW_RESOLVE_STATES` -- membership rows carry a status but no task number, so
108
+ that gate is applied before spending the detail read). The second status is
109
+ not a release candidate: it is how a released task whose detach did not land
110
+ reads, and the read is what makes the retry reachable at all.
97
111
  `list_marked_committed_tasks` is never called on the release path.
98
112
  * **All the gates are unchanged**: committed status, the dependsOn gate, lane
99
113
  occupancy, priority-then-age ordering, the same mechanical rendering. A
@@ -119,8 +133,10 @@ titled `autodev: <owner>/<repo>` (`bow_prefix`).
119
133
  naming the unreadable count, the reason on every held decision, and a skipped
120
134
  fallback sweep (which likewise cannot distinguish unattached from unread).
121
135
  * **Detach is the TERMINAL act of a successful release** (create-issue ->
122
- ledger -> label -> detach), never the first: a crash mid-release leaves the
123
- task attached and therefore still discoverable. orcloop NEVER attaches.
136
+ ledger -> label -> status -> detach), never the first: a crash mid-release
137
+ leaves the task attached and therefore still discoverable. orcloop NEVER
138
+ attaches. The status step sits before it and is not coupled to it in either
139
+ direction, so a status failure can never leave a task attached.
124
140
  * **Operator detach = park, and it is terminal.** A detached task simply stops
125
141
  being a candidate; nothing re-attaches it and no later pass resurrects it. If
126
142
  the operator RE-attaches a task orcloop already released, the dedupe path
@@ -138,7 +154,12 @@ CRASH STORY, per step of the bow-mode release (all resumed by the next poll):
138
154
  3. crash after the label, before the detach -- the row is `labeled=1` and the
139
155
  task is still attached; the next pass takes the dedupe path and detaches.
140
156
  Detach is idempotent server-side (`removed: false` when already gone), so a
141
- crash *during* the detach is equally safe.
157
+ crash *during* the detach is equally safe. Note the status set landed first,
158
+ so the task arrives at that pass at `in_progress`: the feed's committed gates
159
+ would judge it as a non-candidate and hold it forever, which is why an
160
+ already-released member is routed to the dedupe path on the LEDGER's answer
161
+ before its status is judged at all (`_bow_feed_pass`, and `BOW_RESOLVE_STATES`
162
+ for the membership-row half of the same gate).
142
163
 
143
164
  THE SCAN-FALLBACK SWEEP (`scan_fallback_interval`, default hourly, 0 = off)
144
165
  is the `bow` feed's level-based safety net: every interval the loop ALSO runs
@@ -241,7 +262,13 @@ from .alissa_client import (
241
262
  )
242
263
  from .config import FEED_BOW, Config
243
264
  from .ghclient import GitHub, Issue, PullRequest, RateLimited, parse_task_ref
244
- from .markers import is_bow_feed, parse_bow_repo, parse_marker, parse_scope
265
+ from .markers import (
266
+ find_mid_line_trailer,
267
+ is_bow_feed,
268
+ parse_bow_repo,
269
+ parse_marker,
270
+ parse_scope,
271
+ )
245
272
  from .proc import CommandError
246
273
  from .state import State
247
274
 
@@ -252,10 +279,28 @@ log = logging.getLogger(__name__)
252
279
  # the dependency (soft/hard) does not enter here -- both hold identically.
253
280
  DEP_CLEAR_STATES = frozenset({"pending_validation", "validated"})
254
281
 
255
- # The close edge only mutates a task it left `committed` (or one mid-transition
256
- # at `in_progress`, from a partially-applied prior pass). Any other status means
257
- # the operator moved the task by hand -- skip, never fight them.
258
- CLOSE_EXPECTED_STATES = frozenset({"committed", "in_progress"})
282
+ # The two origin-task statuses the `committed in_progress` transition moves
283
+ # between. `committed` = scoped-in TODO; `in_progress` = being worked.
284
+ #
285
+ # BOTH edges make exactly this move, through the same `AlissaClient.set_status`:
286
+ # the close edge as the first step of its pending_validation walk, and (issue
287
+ # #30) the release edge the moment the GitHub issue exists. Released to the
288
+ # pipeline IS being worked -- devloop claims a labeled issue within minutes, so
289
+ # a released task that kept reading `committed` until merge would read as TODO
290
+ # to every observer for the whole implementation window. One definition of the
291
+ # transition, so the two edges can never drift on which one they mean.
292
+ TASK_TODO_STATE = "committed"
293
+ TASK_IN_FLIGHT_STATE = "in_progress"
294
+
295
+ # The close edge only mutates a task the loop itself left in one of these two
296
+ # states. `in_progress` is the NORMAL one since issue #30 -- the release edge
297
+ # sets it the moment the issue exists, so that is how a task in flight reads.
298
+ # `committed` is now the exception rather than the rule: a release-edge status
299
+ # set that failed, a release that crashed before reaching it, or a task released
300
+ # by a dist older than 0.3.6. Either way there is work left to do, and the walk
301
+ # in `_mutate_close` starts from whichever it finds. Any OTHER status means the
302
+ # operator moved the task by hand -- skip, never fight them.
303
+ CLOSE_EXPECTED_STATES = frozenset({TASK_TODO_STATE, TASK_IN_FLIGHT_STATE})
259
304
 
260
305
  # Priority ordering for the release queue. Unknown/blank priority sorts last
261
306
  # (rank 0); ties break on age (older first).
@@ -330,6 +375,24 @@ CLOSE_HANDOFF_STATE = "pending_validation"
330
375
  # gate still decides whether the work is ready to leave the queue.
331
376
  BOW_RELEASABLE_STATE = "committed"
332
377
 
378
+ # The member statuses that earn a `get_task` before being judged. Wider than
379
+ # BOW_RELEASABLE_STATE by exactly one entry, and only since the release edge
380
+ # started setting `in_progress` (issue #30): an ATTACHED task at that status is
381
+ # most likely one of orcloop's own releases whose terminal detach did not land,
382
+ # and the detach retry lives behind the detail read (`_dedupe_result`). Gating
383
+ # it out on the membership row alone would strand it -- attached forever, held
384
+ # once per poll, invisible to the marker sweep (which lists COMMITTED tasks),
385
+ # and unreachable by the only `detach_task` caller there is. The detail read is
386
+ # the price of that recovery, and the bound is one read per poll per member at
387
+ # this status -- no writes, and nothing that scales with the task graph. Two
388
+ # shapes reach it, and only one is transient: an unfinished detach clears on the
389
+ # pass that resolves it (in steady state a released task leaves the feed on the
390
+ # pass that released it), while a task an operator ATTACHED at `in_progress` is
391
+ # held and stays attached, so it earns that read every poll until they detach
392
+ # it. Still the right trade against a permanent strand. Every other status
393
+ # (draft, blocked, cancelled, validated...) is held off the row, unread.
394
+ BOW_RESOLVE_STATES = frozenset({BOW_RELEASABLE_STATE, TASK_IN_FLIGHT_STATE})
395
+
333
396
  # `state.meta` key: unix seconds of the last scan-fallback sweep, so the
334
397
  # low-frequency safety net keeps its cadence across daemon restarts (a restart
335
398
  # loop must not turn an hourly sweep into a per-boot full scan).
@@ -440,6 +503,18 @@ DEGRADED_DISCOVERY_REASON = (
440
503
  "— held rather than released to a possibly-wrong repo; retries next poll"
441
504
  )
442
505
 
506
+ # The one wording every missed-trailer warning uses (O13, issue #32). Appended
507
+ # to a reason or a warning that was ALREADY going to be emitted, never a line of
508
+ # its own: a missed trailer is not a new condition, it is the likeliest
509
+ # EXPLANATION of a degradation the operator is already reading about. It states
510
+ # the anchoring rule because that rule is the fix -- the operator does not have
511
+ # to read the parser to learn why their text was ignored.
512
+ MALFORMED_TRAILER_NOTE = (
513
+ "NOTE: `{key}:` found mid-line — malformed trailer? {fragment!r} (a "
514
+ "trailer is only read at the START of its own line, so that text was "
515
+ "ignored)"
516
+ )
517
+
443
518
  SWEEP_INVISIBLE_REASON = (
444
519
  "carries the release marker but is attached to no {prefix!r} body of work "
445
520
  "— the bow feed will never release it (attachment is the only trigger); "
@@ -583,6 +658,24 @@ class Orchestrator:
583
658
  log.log(level, "%s → %s (%s)", slug, decision.action.value, decision.reason)
584
659
  return (slug, decision)
585
660
 
661
+ def _malformed_trailer(self, text: str, key: str) -> str:
662
+ """The `; NOTE: ...` suffix naming trailer-like text the anchored parser
663
+ ignored -- empty string when there is nothing to name (the usual case).
664
+
665
+ Ask this ONLY where the anchored parse has already answered "absent",
666
+ and only for the suffix: the detector behind it is lenient by design
667
+ (case-insensitive, fires on prose) and its answer may never reach a
668
+ decision, a dedupe or a lane. Every caller computes it once per subject
669
+ per pass and appends it to a line it was already emitting, which is what
670
+ keeps the no-new-spam-channel rule true.
671
+
672
+ The `key` is passed in by the caller from `Config`, so a deployment that
673
+ renamed its trailers gets warnings about ITS names, not the defaults."""
674
+ fragment = find_mid_line_trailer(text, key)
675
+ if fragment is None:
676
+ return ""
677
+ return "; " + MALFORMED_TRAILER_NOTE.format(key=key, fragment=fragment)
678
+
586
679
  # -- escalation --------------------------------------------------------
587
680
 
588
681
  def _escalate(
@@ -653,6 +746,24 @@ class Orchestrator:
653
746
  ):
654
747
  issue = self.github.issue(owner, repo, number)
655
748
  scope = parse_scope(issue.body, self.config.scope_key)
749
+ if not scope:
750
+ # This issue is about to occupy its repo's WHOLE lane, which is
751
+ # the right reading of a scopeless body and the wrong one of a
752
+ # malformed trailer -- and the two are indistinguishable in
753
+ # every later line, because from here on the lane only carries
754
+ # `[]`. Said once per issue per pass, here, where the difference
755
+ # is still visible. (A released body keeps the operator's prose
756
+ # verbatim, mid-line fragment and all: `_strip_control_lines`
757
+ # drops trailers by the same anchored rule, so what was missed
758
+ # in the task is missed in the issue too.)
759
+ note = self._malformed_trailer(issue.body, self.config.scope_key)
760
+ if note:
761
+ log.warning(
762
+ "%s declares no `%s:` trailer, so it occupies the WHOLE "
763
+ "%s lane%s",
764
+ issue.issue_slug, self.config.scope_key,
765
+ issue.full_name, note,
766
+ )
656
767
  occupancy.setdefault(issue.full_name, []).append(scope)
657
768
  task_number = parse_task_ref(issue.body)
658
769
  if task_number is None:
@@ -698,9 +809,13 @@ class Orchestrator:
698
809
  sweep needs to measure itself against this pass (see `FeedPass`).
699
810
 
700
811
  Cost is O(total BOW membership): one list call, one membership read per
701
- feed BOW, and one `get_task` per committed member. Uncommitted members
702
- are held off the membership row's own status, so the detail read is
703
- never spent on work that cannot release anyway.
812
+ feed BOW, and one `get_task` per member the row reports at a status in
813
+ `BOW_RESOLVE_STATES`. Every other status is held off the row alone, so
814
+ the detail read is never spent on work nothing could do anything with.
815
+ The read IS spent on `in_progress` members, which cannot release --
816
+ deliberately: that is how an already-released task whose detach failed
817
+ reads, and the gate below routes it to `_dedupe_result` to finish the
818
+ detach. Recovering it is worth one read.
704
819
 
705
820
  Membership is read for EVERY feed BOW before any member is evaluated,
706
821
  because a task attached to two feed BOWs has an ambiguous target repo
@@ -840,7 +955,7 @@ class Orchestrator:
840
955
  ambiguous = self._ambiguous_claim(owners)
841
956
  reason = ambiguous or broken
842
957
 
843
- if member.status != BOW_RELEASABLE_STATE:
958
+ if member.status not in BOW_RESOLVE_STATES:
844
959
  if ambiguous is not None:
845
960
  log.warning("%s is %s", self._member_slug(member), ambiguous)
846
961
  # Held off the membership row, so no `get_task` and no task
@@ -871,11 +986,31 @@ class Orchestrator:
871
986
  # it: a task moved out of `committed` between the two reads
872
987
  # must not release off a stale membership row.
873
988
  if task.status != BOW_RELEASABLE_STATE:
989
+ if task.task_number in released_github or self.state.is_released(
990
+ task.task_number
991
+ ):
992
+ # Not a release candidate at all -- an ALREADY-RELEASED
993
+ # task that is still attached is an unfinished detach,
994
+ # and since the release edge leaves what it releases at
995
+ # `in_progress` (issue #30) this is the shape that
996
+ # arrives here. Judging it on its status would hold the
997
+ # very task whose detach is waiting to be retried, in a
998
+ # state nothing else in the daemon can leave: the ledger
999
+ # answer has to come first. Same call the committed path
1000
+ # makes below, so the retry is the one already
1001
+ # documented.
1002
+ results.append(
1003
+ self._dedupe_result(task.task_number, bow=bow, task=task)
1004
+ )
1005
+ continue
1006
+ stale = (
1007
+ " — the membership row was stale"
1008
+ if member.status == BOW_RELEASABLE_STATE else ""
1009
+ )
874
1010
  results.append(self._logged(task.ref, Decision(
875
1011
  Action.HELD,
876
1012
  f"attached to {bow.title!r} but its task detail says "
877
- f"{task.status!r}, not {BOW_RELEASABLE_STATE!r} — the "
878
- f"membership row was stale",
1013
+ f"{task.status!r}, not {BOW_RELEASABLE_STATE!r}{stale}",
879
1014
  task.task_number,
880
1015
  )))
881
1016
  continue
@@ -911,7 +1046,9 @@ class Orchestrator:
911
1046
  if task.task_number in released_github or self.state.is_released(
912
1047
  task.task_number
913
1048
  ):
914
- results.append(self._dedupe_result(task.task_number, bow=bow))
1049
+ results.append(
1050
+ self._dedupe_result(task.task_number, bow=bow, task=task)
1051
+ )
915
1052
  continue
916
1053
  assert target is not None # `broken` is None, so it parsed
917
1054
  pending.append((task, bow, target))
@@ -1178,7 +1315,11 @@ class Orchestrator:
1178
1315
  return results
1179
1316
 
1180
1317
  def _dedupe_result(
1181
- self, task_number: int, *, bow: BodyOfWork | None = None
1318
+ self,
1319
+ task_number: int,
1320
+ *,
1321
+ bow: BodyOfWork | None = None,
1322
+ task: Task | None = None,
1182
1323
  ) -> tuple[str, Decision]:
1183
1324
  """A task the dedupe set already covers. Usually a plain SKIPPED, but a
1184
1325
  ledger row still at `labeled=0` is a create->ledger crash survivor: the
@@ -1190,7 +1331,18 @@ class Orchestrator:
1190
1331
  Seeing an already-released task in the feed at all means it is still
1191
1332
  attached: either a crash landed between the label and the detach, or the
1192
1333
  operator re-attached it. Either way the answer is the same -- never a
1193
- second issue, just finish the release by detaching."""
1334
+ second issue, just finish the release by detaching.
1335
+
1336
+ The crash-recovery branch completes the ORIGIN STATUS too, because it is
1337
+ completing the very release that never reached it: it applies the
1338
+ trigger label, which arms devloop, so leaving the task `committed` would
1339
+ reproduce exactly the symptom issue #30 was filed for. `task` is passed
1340
+ in by the `bow` feed (which has already read the detail); the `scan`
1341
+ feed reaches this method with a task NUMBER only -- deliberately, so a
1342
+ dedupe hit costs no detail read -- so on that path the read is spent
1343
+ HERE, inside the rare branch. The hot branch below returns first and
1344
+ still reads nothing, and a failed read is a warning, never a hold: the
1345
+ label is what matters and it has already landed."""
1194
1346
  ref = f"TASK-{task_number}"
1195
1347
  row = self.state.release_for(task_number)
1196
1348
  if row is None or row["labeled"]:
@@ -1208,6 +1360,7 @@ class Orchestrator:
1208
1360
  return self._logged(ref, Decision(
1209
1361
  Action.RELEASED,
1210
1362
  f"[dry-run] would complete labeling of {slug} (crash recovery)"
1363
+ f"{self._resolved_in_progress(ref, task)}"
1211
1364
  f"{self._detach(ref, bow)}",
1212
1365
  task_number,
1213
1366
  ))
@@ -1222,12 +1375,89 @@ class Orchestrator:
1222
1375
  return self._logged(ref, Decision(
1223
1376
  Action.HELD, f"crash-recovery labeling of {slug} failed: {exc}", task_number,
1224
1377
  ))
1378
+ in_flight = self._resolved_in_progress(ref, task)
1225
1379
  return self._logged(ref, Decision(
1226
1380
  Action.RELEASED,
1227
- f"crash recovery: applied label to {slug}{self._detach(ref, bow)}",
1381
+ f"crash recovery: applied label to {slug}{in_flight}"
1382
+ f"{self._detach(ref, bow)}",
1228
1383
  task_number,
1229
1384
  ))
1230
1385
 
1386
+ def _resolved_in_progress(self, ref: str, task: Task | None) -> str:
1387
+ """`_mark_in_progress` for a caller that may not hold the task detail.
1388
+
1389
+ The read is spent only where it is called from (the rare crash-recovery
1390
+ branch), and only when the caller did not already have the task. A
1391
+ failed read cannot hold anything -- the issue is filed and labeled by
1392
+ now -- so it degrades exactly like a failed status set: one warning, and
1393
+ the close edge's walk corrects the status at merge."""
1394
+ if task is None:
1395
+ try:
1396
+ task = self.alissa.get_task(ref)
1397
+ except AlissaError as exc:
1398
+ log.warning(
1399
+ "could not read %s to set it %s after completing its "
1400
+ "release: %s — the release stands and the close edge "
1401
+ "applies the same transition at merge",
1402
+ ref, TASK_IN_FLIGHT_STATE, exc,
1403
+ )
1404
+ return f"; STATUS SET PENDING (→ {TASK_IN_FLIGHT_STATE}: {exc})"
1405
+ return self._mark_in_progress(task)
1406
+
1407
+ def _mark_in_progress(self, task: Task) -> str:
1408
+ """Move a released origin task `committed → in_progress` and return a
1409
+ suffix for the decision's reason.
1410
+
1411
+ Released IS being worked (see `TASK_IN_FLIGHT_STATE`): by the time this
1412
+ runs the issue exists and carries the trigger label, so a developer
1413
+ session is minutes away, while the close edge's status walk only runs at
1414
+ merge -- hours or days later. Without this step the task reads TODO for
1415
+ that entire window.
1416
+
1417
+ Three disciplines, each deliberate:
1418
+
1419
+ * **Transition matrix.** Attempted ONLY from `committed`. A task some
1420
+ other surface has already advanced (a human, a race with the close
1421
+ edge) is left exactly as it is: no downgrade, no illegal
1422
+ `in_progress → in_progress` re-issue, no error -- a silent no-op that
1423
+ returns an empty suffix, so the release reads the same as it always
1424
+ did.
1425
+ * **Never blocking.** A failed set logs a WARNING naming the task and
1426
+ the release proceeds unchanged: the issue exists, the label is on, the
1427
+ detach still runs, and the decision is still `released`. No escalation
1428
+ kind, because the close edge's walk applies this same transition at
1429
+ merge -- the failure is cosmetic and self-healing, and a page that
1430
+ resolves itself is a page that teaches operators to ignore pages.
1431
+ * **Before the detach.** Callers sequence this ahead of `_detach` so the
1432
+ release's terminal act stays terminal. Nothing here couples to the
1433
+ detach in either direction, so a status failure can never leave a task
1434
+ attached to its feed. The converse -- a detach failure meeting a task
1435
+ this method has already advanced -- is why `_bow_feed_pass` consults
1436
+ the ledger before it judges an attached task's status (see `_detach`).
1437
+
1438
+ The mechanism is the close edge's, not a second one: the same
1439
+ `AlissaClient.set_status` call and the same two status constants that
1440
+ `_mutate_close` opens its pending_validation walk with."""
1441
+ if task.status != TASK_TODO_STATE:
1442
+ return ""
1443
+ if self.config.dry_run:
1444
+ return f"; [dry-run] would set {task.ref} {TASK_IN_FLIGHT_STATE}"
1445
+ try:
1446
+ self.alissa.set_status(task.ref, TASK_IN_FLIGHT_STATE)
1447
+ except AlissaError as exc:
1448
+ log.warning(
1449
+ "released %s but could not set it %s: %s — the release stands "
1450
+ "(the issue exists and is labeled) and the close edge applies "
1451
+ "the same transition at merge, so the task is only mis-reported "
1452
+ "as TODO until then",
1453
+ task.ref, TASK_IN_FLIGHT_STATE, exc,
1454
+ )
1455
+ return f"; STATUS SET PENDING (→ {TASK_IN_FLIGHT_STATE}: {exc})"
1456
+ log.info(
1457
+ "%s → %s (released to the pipeline)", task.ref, TASK_IN_FLIGHT_STATE
1458
+ )
1459
+ return f"; task → {TASK_IN_FLIGHT_STATE}"
1460
+
1231
1461
  def _detach(self, ref: str, bow: BodyOfWork | None) -> str:
1232
1462
  """Detach a released task from its feed BOW -- the TERMINAL act of a
1233
1463
  successful release -- and return a suffix for the decision's reason.
@@ -1237,7 +1467,14 @@ class Orchestrator:
1237
1467
  durable in the ledger, and leaving the task attached is the SAFE
1238
1468
  failure -- it stays discoverable, and the next pass's dedupe path
1239
1469
  retries the detach. The reverse (detaching first) would be a take-then-
1240
- act that could lose the work entirely."""
1470
+ act that could lose the work entirely.
1471
+
1472
+ That retry is only reachable because `_bow_feed_pass` asks the LEDGER
1473
+ before it judges an attached task's status: the release edge runs
1474
+ `_mark_in_progress` before this call, so the task waiting for a retry is
1475
+ `in_progress`, which every committed-shaped gate would otherwise treat
1476
+ as "not a candidate" -- a hold nothing could ever clear, since this is
1477
+ `detach_task`'s only caller and the close edge never detaches."""
1241
1478
  if bow is None:
1242
1479
  return ""
1243
1480
  if self.config.dry_run:
@@ -1274,11 +1511,21 @@ class Orchestrator:
1274
1511
  if target is None:
1275
1512
  marker = parse_marker(task.description, self.config.marker_key)
1276
1513
  if marker is None or not self.config.watches(marker):
1277
- reason = (
1278
- f"no valid `{self.config.marker_key}: <owner>/<repo>` trailer"
1279
- if marker is None
1280
- else f"target {marker} is not in the watched repos allowlist"
1281
- )
1514
+ if marker is None:
1515
+ # The commonest way to reach `unreleasable` by accident: the
1516
+ # trailer IS there, just not at the start of a line. The
1517
+ # page and the log line both carry the reason, so extending
1518
+ # it here is what puts the suspect text in front of the
1519
+ # operator on the one channel this condition has.
1520
+ reason = (
1521
+ f"no valid `{self.config.marker_key}: <owner>/<repo>` "
1522
+ f"trailer"
1523
+ + self._malformed_trailer(
1524
+ task.description, self.config.marker_key
1525
+ )
1526
+ )
1527
+ else:
1528
+ reason = f"target {marker} is not in the watched repos allowlist"
1282
1529
  self._escalate(
1283
1530
  task.task_number,
1284
1531
  ESCALATION_UNRELEASABLE,
@@ -1308,13 +1555,24 @@ class Orchestrator:
1308
1555
  return self._release_blocked(task)
1309
1556
 
1310
1557
  scope = parse_scope(task.description, self.config.scope_key)
1558
+ # A missed scope trailer is never neutral: scopeless means "occupies the
1559
+ # whole lane", so it both HOLDS this candidate behind any occupant and,
1560
+ # once released, holds everything else behind it. Both outcomes are
1561
+ # decided right below, and both are worth explaining -- so the note is
1562
+ # computed once here, on the task body, and carried into whichever line
1563
+ # this candidate produces.
1564
+ note = "" if scope else self._malformed_trailer(
1565
+ task.description, self.config.scope_key
1566
+ )
1311
1567
  lane_reason = self._lane_blocked(target, scope, occupancy)
1312
1568
  if lane_reason is not None:
1313
1569
  return self._logged(task.ref, Decision(
1314
- Action.HELD, f"lane {target} occupied: {lane_reason}", task.task_number,
1570
+ Action.HELD,
1571
+ f"lane {target} occupied: {lane_reason}{note}",
1572
+ task.task_number,
1315
1573
  ))
1316
1574
 
1317
- return self._release(task, target, scope, occupancy, bow=bow)
1575
+ return self._release(task, target, scope, occupancy, bow=bow, note=note)
1318
1576
 
1319
1577
  def _release_blocked(self, task: Task) -> tuple[str, Decision]:
1320
1578
  """The dependency gate holds. Below the floor this is a quiet per-poll
@@ -1513,6 +1771,11 @@ class Orchestrator:
1513
1771
  in-flight whatever orcloop thinks, so there is no trigger to withhold,
1514
1772
  only a ledger row to record and a lane to account for.
1515
1773
 
1774
+ Both branches end at `_mark_in_progress`, like the create path: adoption
1775
+ means the work is (back) in flight, and the already-labeled branch is
1776
+ the strongest case of all -- that issue has been armed for devloop since
1777
+ before this pass ran.
1778
+
1516
1779
  An adopted body is a human's, so it is not the renderer's: only the
1517
1780
  trailer is contractual (it is all the develop daemon reads too). A body
1518
1781
  without the `Autonomous-Dev` trailer is adopted anyway -- the target
@@ -1532,6 +1795,23 @@ class Orchestrator:
1532
1795
  )
1533
1796
 
1534
1797
  issue_scope = parse_scope(issue.body, self.config.scope_key)
1798
+ # Same read and same degradation as `_scan_released`, on a body a human
1799
+ # wrote by hand -- the likeliest place of all for a trailer to sit in a
1800
+ # sentence. Computed once and appended to each of the four exits that
1801
+ # REPORT A LANE (hold, dry-run, adopt, already-labeled skip), so the
1802
+ # pass never carries the same note twice.
1803
+ #
1804
+ # The fifth exit -- the `except` below, where the adoption itself failed
1805
+ # -- deliberately does not carry it: its story is the exception, and it
1806
+ # is not the recurring line it looks like. That branch wraps
1807
+ # `record_release` AND `add_label`, so its common shape (row written,
1808
+ # label call failed) leaves a `labeled=0` row, and the next pass routes
1809
+ # to `_dedupe_result`'s crash-recovery branch rather than back here.
1810
+ # What recurs in the malformed-trailer case is the lane hold above,
1811
+ # which does carry it.
1812
+ issue_note = "" if issue_scope else self._malformed_trailer(
1813
+ issue.body, self.config.scope_key
1814
+ )
1535
1815
  labeled = self.config.label in issue.labels
1536
1816
  if not labeled:
1537
1817
  lane_reason = self._lane_blocked(target, issue_scope, occupancy)
@@ -1554,7 +1834,7 @@ class Orchestrator:
1554
1834
  f"cannot adopt {issue.issue_slug} yet: lane {target} "
1555
1835
  f"occupied: {lane_reason} — the issue declares "
1556
1836
  f"{issue_scope or '(scopeless — needs an empty lane)'}, "
1557
- f"whatever the task declares; retries next poll",
1837
+ f"whatever the task declares; retries next poll{issue_note}",
1558
1838
  task.task_number,
1559
1839
  ))
1560
1840
 
@@ -1576,11 +1856,12 @@ class Orchestrator:
1576
1856
  verb = "would record" if labeled else "would adopt"
1577
1857
  unlabeled_note = "" if labeled else f", no {self.config.label!r} label"
1578
1858
  occupancy.setdefault(target, []).append(issue_scope)
1859
+ in_flight = self._mark_in_progress(task)
1579
1860
  return self._logged(task.ref, Decision(
1580
1861
  Action.SKIPPED if labeled else Action.RELEASED,
1581
1862
  f"[dry-run] {verb} pre-existing {issue.issue_slug} (carries "
1582
1863
  f"this task's `Alissa-Task:` trailer{unlabeled_note})"
1583
- f"{self._detach(task.ref, bow)}",
1864
+ f"{in_flight}{self._detach(task.ref, bow)}{issue_note}",
1584
1865
  task.task_number,
1585
1866
  ))
1586
1867
 
@@ -1598,11 +1879,16 @@ class Orchestrator:
1598
1879
  # ran before this issue existed, so no later candidate would
1599
1880
  # otherwise measure itself against it.
1600
1881
  occupancy.setdefault(target, []).append(issue_scope)
1882
+ # `skipped` describes what was FILED (nothing), not what is
1883
+ # happening to the work: an armed issue for this task is live,
1884
+ # so the task is in flight exactly like an adopted one.
1885
+ in_flight = self._mark_in_progress(task)
1601
1886
  return self._logged(task.ref, Decision(
1602
1887
  Action.SKIPPED,
1603
1888
  f"already released by hand as {issue.issue_slug} (trailer "
1604
1889
  f"and {self.config.label!r} label present); recorded in the "
1605
- f"ledger, no second issue{self._detach(task.ref, bow)}",
1890
+ f"ledger, no second issue{in_flight}"
1891
+ f"{self._detach(task.ref, bow)}{issue_note}",
1606
1892
  task.task_number,
1607
1893
  ))
1608
1894
  self.github.add_label(issue.owner, issue.repo, issue.number, self.config.label)
@@ -1619,11 +1905,13 @@ class Orchestrator:
1619
1905
  ))
1620
1906
 
1621
1907
  occupancy.setdefault(target, []).append(issue_scope)
1908
+ in_flight = self._mark_in_progress(task)
1622
1909
  return self._logged(task.ref, Decision(
1623
1910
  Action.RELEASED,
1624
1911
  f"adopted pre-existing {issue.issue_slug} (carries this task's "
1625
1912
  f"trailer, was unlabeled): ledger row recorded and "
1626
- f"{self.config.label!r} applied{self._detach(task.ref, bow)}",
1913
+ f"{self.config.label!r} applied{in_flight}"
1914
+ f"{self._detach(task.ref, bow)}{issue_note}",
1627
1915
  task.task_number,
1628
1916
  ))
1629
1917
 
@@ -1803,18 +2091,28 @@ class Orchestrator:
1803
2091
  occupancy: dict[str, list[list[str]]],
1804
2092
  *,
1805
2093
  bow: BodyOfWork | None = None,
2094
+ note: str = "",
1806
2095
  ) -> tuple[str, Decision]:
1807
2096
  """Create the issue (unlabeled), record the ledger row, stamp the label,
1808
- then -- under the `bow` feed -- detach the task from its Body of Work.
1809
- In that order, and detach strictly last: the label is the develop
1810
- daemon's trigger and the detach is the release's terminal act, so a
1811
- crash at any point leaves the work discoverable rather than lost.
2097
+ move the origin task `committed in_progress`, then -- under the `bow`
2098
+ feed -- detach the task from its Body of Work. In that order, and detach
2099
+ strictly last: the label is the develop daemon's trigger and the detach
2100
+ is the release's terminal act, so a crash at any point leaves the work
2101
+ discoverable rather than lost.
1812
2102
  Occupancy is updated in-memory so a later candidate in THIS pass sees
1813
2103
  the new occupant and respects the lane.
1814
2104
 
1815
2105
  Nothing is created until the TRAILER dedupe answers: the pass-level
1816
2106
  scan is label-scoped and cannot see an unlabeled issue that already
1817
- carries this task's trailer (see `_trailer_dedupe`)."""
2107
+ carries this task's trailer (see `_trailer_dedupe`).
2108
+
2109
+ `note` is the caller's missed-trailer warning (empty unless this is a
2110
+ SCOPELESS release with trailer-like text in the body). It rides on the
2111
+ release line rather than being logged separately, because that line is
2112
+ the record of a task taking the whole lane -- possibly on the strength
2113
+ of a trailer nobody parsed. It is passed in rather than recomputed so
2114
+ one candidate can never produce two of them; the dedupe path returns
2115
+ above with its own note, computed on the ISSUE body it acts on."""
1818
2116
  owner, _, repo = target.partition("/")
1819
2117
  preexisting = self._trailer_dedupe(task, target, occupancy, bow=bow)
1820
2118
  if preexisting is not None:
@@ -1824,10 +2122,12 @@ class Orchestrator:
1824
2122
 
1825
2123
  if self.config.dry_run:
1826
2124
  occupancy.setdefault(target, []).append(scope)
2125
+ in_flight = self._mark_in_progress(task)
1827
2126
  return self._logged(task.ref, Decision(
1828
2127
  Action.RELEASED,
1829
2128
  f"[dry-run] would release to {target} "
1830
- f"(label {self.config.label!r}){self._detach(task.ref, bow)}",
2129
+ f"(label {self.config.label!r}){in_flight}"
2130
+ f"{self._detach(task.ref, bow)}{note}",
1831
2131
  task.task_number,
1832
2132
  ))
1833
2133
 
@@ -1848,9 +2148,13 @@ class Orchestrator:
1848
2148
  ))
1849
2149
 
1850
2150
  occupancy.setdefault(target, []).append(scope)
2151
+ # Status BEFORE detach, and outside the try above: the issue is filed
2152
+ # and armed, so nothing from here on may turn this into a non-release.
2153
+ in_flight = self._mark_in_progress(task)
1851
2154
  return self._logged(task.ref, Decision(
1852
2155
  Action.RELEASED,
1853
- f"released to {issue.issue_slug}{self._detach(task.ref, bow)}",
2156
+ f"released to {issue.issue_slug}{in_flight}"
2157
+ f"{self._detach(task.ref, bow)}{note}",
1854
2158
  task.task_number,
1855
2159
  ))
1856
2160
 
@@ -2025,8 +2329,8 @@ class Orchestrator:
2025
2329
 
2026
2330
  Criteria satisfaction is naturally idempotent (already-satisfied ones are
2027
2331
  skipped), so it needs no gate."""
2028
- if task.status == "committed":
2029
- self.alissa.set_status(ref, "in_progress")
2332
+ if task.status == TASK_TODO_STATE:
2333
+ self.alissa.set_status(ref, TASK_IN_FLIGHT_STATE)
2030
2334
  for criterion in task.criteria:
2031
2335
  if not criterion.satisfied:
2032
2336
  self.alissa.satisfy_criterion(ref, criterion.id)
@@ -35,6 +35,17 @@ IS the repo declaration. `Autonomous-Scope` still comes from the task body. The
35
35
  owner/repo shape is validated with the same strictness as the trailer, so a
36
36
  mistyped BOW title reads as "no repo" (→ `unreleasable`) rather than silently
37
37
  targeting something unexpected.
38
+
39
+ O13 adds `find_mid_line_trailer`, which is deliberately NOT one of the parsers
40
+ above: it is a LENIENT detector whose only output is a warning. The anchoring
41
+ rule is correct and stays, but its failure mode is silent — a trailer written at
42
+ the end of a paragraph reads as absent, and "absent" is a meaningful answer
43
+ everywhere the loop asks (a scopeless task occupies a whole lane; a task with no
44
+ `Autonomous-Dev` is unreleasable). The detector exists so those degradations can
45
+ NAME the text they ignored. Its answer must never feed a decision: it fires on
46
+ prose (`see Autonomous-Scope: x for the format`) by design, because judging
47
+ intent is the operator's job and a false positive in a log line costs nothing
48
+ while a false positive in a release decision cannot be taken back.
38
49
  """
39
50
 
40
51
  from __future__ import annotations
@@ -52,6 +63,23 @@ _SCOPE_TEMPLATE = r"^{key}:[ \t]*(.*?)\s*$"
52
63
  # Scope items are separated by commas and/or any run of whitespace.
53
64
  _SCOPE_SPLIT = re.compile(r"[,\s]+")
54
65
 
66
+ # The lenient detector (O13). `(?!^)` is the whole point: under MULTILINE, `^`
67
+ # matches at the start of every line, so refusing it leaves EXACTLY the
68
+ # positions the anchored parsers cannot see -- mid-sentence, and after an indent
69
+ # (` Autonomous-Scope: src/**` is not a trailer either, and is the same
70
+ # mistake). The key is matched case-insensitively via a scoped inline flag, so
71
+ # an operator-typed `autonomous-scope:` is caught without loosening anything
72
+ # else. `[ \t]*\S` requires CONTENT after the colon: a bare `... Autonomous-Dev:`
73
+ # ending a sentence declares nothing, so it is not evidence of a lost
74
+ # declaration and reporting it would be noise.
75
+ _MID_LINE_TEMPLATE = r"(?!^)(?i:{key}):[ \t]*\S"
76
+
77
+ # How much of the offending line the warning quotes. The fragment goes into log
78
+ # lines and escalation comments that already carry a reason, so it is a pointer
79
+ # ("here is the text I ignored"), not the body -- enough to find with a search,
80
+ # short enough to keep one decision on one line.
81
+ MID_LINE_FRAGMENT_LIMIT = 80
82
+
55
83
  # The whole BOW title after its prefix must be exactly one owner/repo — the same
56
84
  # shape the `Autonomous-Dev` trailer accepts, anchored to the whole remainder so
57
85
  # a title like `autodev: acme/widgets (paused)` does NOT parse as a feed.
@@ -90,6 +118,37 @@ def parse_scope(text: str, scope_key: str = "Autonomous-Scope") -> list[str]:
90
118
  return [glob for glob in _SCOPE_SPLIT.split(match.group(1).strip()) if glob]
91
119
 
92
120
 
121
+ @lru_cache(maxsize=None)
122
+ def _mid_line_re(key: str) -> re.Pattern[str]:
123
+ return re.compile(_MID_LINE_TEMPLATE.format(key=re.escape(key)), re.MULTILINE)
124
+
125
+
126
+ def find_mid_line_trailer(
127
+ text: str, key: str, *, limit: int = MID_LINE_FRAGMENT_LIMIT
128
+ ) -> str | None:
129
+ """A quotable fragment of the first `<key>:` written MID-LINE, or None.
130
+
131
+ Warning input only -- see the module docstring. Callers must ask this only
132
+ where the anchored parser already answered "absent", and must never let the
133
+ answer change what they do; `None` means "nothing here looks like a missed
134
+ trailer", not "no trailer".
135
+
136
+ The fragment starts at the key (what the operator has to go and fix), stops
137
+ at the end of that line (a decision line stays one line), and is truncated
138
+ to `limit` characters so a wall of prose cannot swamp the reason it is
139
+ appended to.
140
+ """
141
+ match = _mid_line_re(key).search(text)
142
+ if match is None:
143
+ return None
144
+ line_end = text.find("\n", match.start())
145
+ end = len(text) if line_end < 0 else line_end
146
+ fragment = text[match.start():end].strip()
147
+ if len(fragment) > limit:
148
+ fragment = fragment[:limit].rstrip() + "..."
149
+ return fragment
150
+
151
+
93
152
  def is_bow_feed(title: str, prefix: str) -> bool:
94
153
  """Whether a Body of Work's title claims to be a release feed.
95
154
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-orcloop
3
- Version: 0.3.5
3
+ Version: 0.3.7
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera