alissa-tools-github-orcloop 0.3.4__tar.gz → 0.3.6__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.4/src/main/alissa_tools_github_orcloop.egg-info → alissa_tools_github_orcloop-0.3.6}/PKG-INFO +1 -1
  2. {alissa_tools_github_orcloop-0.3.4 → alissa_tools_github_orcloop-0.3.6}/src/main/alissa/tools/github/orcloop/__main__.py +3 -1
  3. {alissa_tools_github_orcloop-0.3.4 → alissa_tools_github_orcloop-0.3.6}/src/main/alissa/tools/github/orcloop/config.py +109 -56
  4. {alissa_tools_github_orcloop-0.3.4 → alissa_tools_github_orcloop-0.3.6}/src/main/alissa/tools/github/orcloop/loop.py +234 -31
  5. alissa_tools_github_orcloop-0.3.6/src/main/alissa/tools/github/orcloop/version +1 -0
  6. {alissa_tools_github_orcloop-0.3.4 → alissa_tools_github_orcloop-0.3.6/src/main/alissa_tools_github_orcloop.egg-info}/PKG-INFO +1 -1
  7. alissa_tools_github_orcloop-0.3.4/src/main/alissa/tools/github/orcloop/version +0 -1
  8. {alissa_tools_github_orcloop-0.3.4 → alissa_tools_github_orcloop-0.3.6}/MANIFEST.in +0 -0
  9. {alissa_tools_github_orcloop-0.3.4 → alissa_tools_github_orcloop-0.3.6}/README.md +0 -0
  10. {alissa_tools_github_orcloop-0.3.4 → alissa_tools_github_orcloop-0.3.6}/requirements.txt +0 -0
  11. {alissa_tools_github_orcloop-0.3.4 → alissa_tools_github_orcloop-0.3.6}/setup.cfg +0 -0
  12. {alissa_tools_github_orcloop-0.3.4 → alissa_tools_github_orcloop-0.3.6}/setup.py +0 -0
  13. {alissa_tools_github_orcloop-0.3.4 → alissa_tools_github_orcloop-0.3.6}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
  14. {alissa_tools_github_orcloop-0.3.4 → alissa_tools_github_orcloop-0.3.6}/src/main/alissa/tools/github/orcloop/alissa_client.py +0 -0
  15. {alissa_tools_github_orcloop-0.3.4 → alissa_tools_github_orcloop-0.3.6}/src/main/alissa/tools/github/orcloop/ghclient.py +0 -0
  16. {alissa_tools_github_orcloop-0.3.4 → alissa_tools_github_orcloop-0.3.6}/src/main/alissa/tools/github/orcloop/markers.py +0 -0
  17. {alissa_tools_github_orcloop-0.3.4 → alissa_tools_github_orcloop-0.3.6}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
  18. {alissa_tools_github_orcloop-0.3.4 → alissa_tools_github_orcloop-0.3.6}/src/main/alissa/tools/github/orcloop/state.py +0 -0
  19. {alissa_tools_github_orcloop-0.3.4 → alissa_tools_github_orcloop-0.3.6}/src/main/alissa/tools/github/orcloop/version.py +0 -0
  20. {alissa_tools_github_orcloop-0.3.4 → alissa_tools_github_orcloop-0.3.6}/src/main/alissa_tools_github_orcloop.egg-info/SOURCES.txt +0 -0
  21. {alissa_tools_github_orcloop-0.3.4 → alissa_tools_github_orcloop-0.3.6}/src/main/alissa_tools_github_orcloop.egg-info/dependency_links.txt +0 -0
  22. {alissa_tools_github_orcloop-0.3.4 → alissa_tools_github_orcloop-0.3.6}/src/main/alissa_tools_github_orcloop.egg-info/entry_points.txt +0 -0
  23. {alissa_tools_github_orcloop-0.3.4 → alissa_tools_github_orcloop-0.3.6}/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.4
3
+ Version: 0.3.6
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -126,7 +126,9 @@ def build_parser() -> argparse.ArgumentParser:
126
126
  action="append",
127
127
  metavar="ACTOR_ID",
128
128
  help="bow feed only: trust bodies of work OWNED by this Alissa actor "
129
- "as release feeds; repeatable. When given, REPLACES the config "
129
+ "as release feeds; repeatable, and one flag may carry several ids "
130
+ "separated by `|` (the daemon-family convention) or `,`, the same as "
131
+ "--repo. When given, REPLACES the config "
130
132
  "`bow_owners` list. OPTIONAL — left unset, the daemon resolves its own "
131
133
  "token's actor at boot and trusts only its own containers; give this "
132
134
  "flag only when the containers belong to someone else. Being SHARED a "
@@ -21,19 +21,36 @@ watch nothing, but boot anyway), an empty orcloop allowlist is **fatal at
21
21
  startup** — the orchestrator releases work into `alissa:develop` issues and
22
22
  closes the loop on merge, so it never runs against an unbounded repo set.
23
23
 
24
- REPOS SEPARATOR (O7). A repo list written as one string `ALISSA_ORC_REPOS`,
25
- a `--repo a/b|c/d` flag, or a single-element list in the config fileis split
26
- on **both `|` and `,`**, mixed freely. `|` is the daemon **family convention**
27
- (devloop's `ALISSA_DEV_REPOS` has always been pipe-separated); comma stays
28
- accepted for backward compatibility with every orcloop release up to 0.3.0.
29
- The convention matters because the operator points several Railway services at
30
- ONE list through a variable reference `ALISSA_ORC_REPOS=${{dev.ALISSA_DEV_REPOS}}`
31
- which only works if both daemons read the same separator. Entries are
32
- whitespace-stripped, empties from splitting are dropped (so a trailing
33
- separator is harmless), duplicates collapse, and each surviving entry must look
34
- like `owner/repo` (a bare word or a pasted URL is a fatal startup error naming
35
- the offending entry). An allowlist that is empty *after* parsing stays fatal —
36
- fail-closed is unchanged.
24
+ LIST SEPARATORS (O7, O11). **Every** list-valued key parses the same way. A
25
+ list written as one string`ALISSA_ORC_REPOS`, `ALISSA_ORC_BOW_OWNERS`, a
26
+ `--repo a/b|c/d` flag, or a single element of the config file's list — is split
27
+ on **both `|` and `,`**, mixed freely, by the one shared splitter
28
+ (`split_entries`). `|` is the daemon **family convention** (devloop's
29
+ `ALISSA_DEV_REPOS` has always been pipe-separated); comma stays accepted for
30
+ backward compatibility with every orcloop release up to 0.3.0. The convention
31
+ matters because the operator points several Railway services at ONE list
32
+ through a variable reference
33
+ `ALISSA_ORC_REPOS=${{dev.ALISSA_DEV_REPOS}}` which only works if both daemons
34
+ read the same separator. Entries are whitespace-stripped, empties from
35
+ splitting are dropped (so a trailing separator is harmless), and duplicates
36
+ collapse.
37
+
38
+ What differs per key is the SHAPE check and what an empty result means — never
39
+ the syntax:
40
+
41
+ * `repos` entries must look like `owner/repo` (a bare word or a pasted URL is a
42
+ fatal startup error naming the offending entry), and an allowlist that is
43
+ empty *after* parsing stays fatal — fail-closed is unchanged.
44
+ * `bow_owners` entries must look like an Alissa actor id (see below), and an
45
+ empty result means "not overridden", not "trust everyone" (see FEED
46
+ AUTHORITY).
47
+
48
+ That the two keys once disagreed is the defect O11 closed: through 0.3.4
49
+ `bow_owners` split on `,` only, so an operator following the documented
50
+ convention got a single entry containing a `|`. Since 0.3.2's shape check that
51
+ at least aborted startup, but phrased as though a username had been typed;
52
+ before it, the entry simply matched no actor and the fail-closed feed released
53
+ nothing at all. Both are why the splitter is shared rather than copied.
37
54
 
38
55
  The marker contract (`Autonomous-Dev` / `Autonomous-Scope` issue-body
39
56
  trailers) is parsed in `markers.py`; the keys that name those trailers live
@@ -119,17 +136,17 @@ RELEASE_FEEDS = (FEED_SCAN, FEED_BOW)
119
136
  # Keys carrying a boolean; env strings for these need real parsing (bool("false")
120
137
  # is True, which would silently invert the setting).
121
138
  _BOOL_KEYS = frozenset({"dry_run", "close_loop_enabled"})
122
- # Keys carrying a list of strings. In an env value they are separator-delimited;
123
- # `repos` accepts the family's `|` as well as `,` (see REPO_SEPARATORS), every
124
- # other list key is comma-separated.
139
+ # Keys carrying a list of strings. Every one of them is separator-delimited when
140
+ # it arrives as a single string, with the SAME separators (see LIST_SEPARATORS
141
+ # and `split_entries`) — a per-key syntax is exactly the drift O11 removed.
125
142
  _LIST_KEYS = frozenset({"repos", "bow_owners"})
126
143
 
127
- # The separators a one-string repo list may use, in preference order. `|` is the
128
- # daemon-family convention (devloop's ALISSA_DEV_REPOS), which is what lets one
129
- # Railway variable reference feed both daemons; `,` is what orcloop shipped
144
+ # The separators any one-string list value may use, in preference order. `|` is
145
+ # the daemon-family convention (devloop's ALISSA_DEV_REPOS), which is what lets
146
+ # one Railway variable reference feed both daemons; `,` is what orcloop shipped
130
147
  # through 0.3.0 and stays accepted. Mixing them in one value is fine.
131
- REPO_SEPARATORS = ("|", ",")
132
- _REPO_SPLIT_RE = re.compile("[" + re.escape("".join(REPO_SEPARATORS)) + "]")
148
+ LIST_SEPARATORS = ("|", ",")
149
+ _LIST_SPLIT_RE = re.compile("[" + re.escape("".join(LIST_SEPARATORS)) + "]")
133
150
 
134
151
  # What an allowlist entry must look like once split: exactly one `/`, a GitHub
135
152
  # owner (alphanumerics and hyphens, not leading/trailing) and a repo name
@@ -205,19 +222,41 @@ def _string_list(value: Any, key: str) -> tuple[str, ...]:
205
222
  return tuple(value)
206
223
 
207
224
 
208
- def split_repo_entries(raw: str) -> list[str]:
209
- """Split one repo-list string into entries on `|` and/or `,`.
225
+ def split_entries(raw: str) -> list[str]:
226
+ """Split one list-valued string into entries on `|` and/or `,`.
210
227
 
211
228
  Whitespace around an entry is stripped and empties are dropped, so a
212
229
  trailing separator, a doubled one, and `a/b | c/d` all behave. This is the
213
- ONE place the separator set is applied: the library's env layer, the CLI
214
- flag, the config file, and the container's `render_config.py` all route
215
- through it, so no surface can end up accepting a different syntax.
216
-
217
- Splitting only — shape validation is `normalize_repos`, because the config
218
- file may also hand over an already-split JSON list.
230
+ ONE place the separator set is applied, for EVERY list key: the library's
231
+ env layer, the CLI flags, the config file, and the container's
232
+ `render_config.py` all route through it, so neither a surface nor a key can
233
+ end up accepting a different syntax (through 0.3.4 `bow_owners` did — see
234
+ the module docstring).
235
+
236
+ Splitting only — the per-key shape check is `normalize_repos` /
237
+ `normalize_bow_owners`, because the config file may also hand over an
238
+ already-split JSON list.
219
239
  """
220
- return [entry for part in _REPO_SPLIT_RE.split(raw) if (entry := part.strip())]
240
+ return [entry for part in _LIST_SPLIT_RE.split(raw) if (entry := part.strip())]
241
+
242
+
243
+ # The 0.3.1-0.3.4 name, kept as an alias rather than deleted. The container's
244
+ # `render_config.py` resolves the splitter through `getattr(cfg, ...)` so that a
245
+ # dist too OLD to carry it dies naming it; an image built from an older
246
+ # docker/claude directory against a NEWER dist would hit that same branch on a
247
+ # pure rename and be told its library is too old, which is both false and the
248
+ # opposite of the fix. One line keeps that message honest. New code uses
249
+ # `split_entries`; there is no second implementation to drift.
250
+ #
251
+ # `REPO_SEPARATORS` — the constant renamed in the same release — deliberately
252
+ # gets NO alias, because the two names fail differently. The function is
253
+ # resolved dynamically, so a missing one produces a MISDIRECTING message; the
254
+ # constant was never resolved at runtime by any shipped renderer (0.3.1-0.3.4
255
+ # only name it in a comment), so a stale import of it is an ImportError naming
256
+ # the symbol — loud and self-explanatory. There is no lie for an alias to
257
+ # prevent there, and a `REPO_*` name for a list-neutral concept would invite the
258
+ # next reader straight back into the drift this release closed.
259
+ split_repo_entries = split_entries
221
260
 
222
261
 
223
262
  def normalize_repos(values: tuple[str, ...] | list[str]) -> tuple[str, ...]:
@@ -236,7 +275,7 @@ def normalize_repos(values: tuple[str, ...] | list[str]) -> tuple[str, ...]:
236
275
  out: list[str] = []
237
276
  seen: set[str] = set()
238
277
  for value in values:
239
- for entry in split_repo_entries(value):
278
+ for entry in split_entries(value):
240
279
  _validate_repo_entry(entry)
241
280
  if (key := entry.casefold()) not in seen:
242
281
  seen.add(key)
@@ -288,11 +327,14 @@ def _validate_repo_entry(entry: str) -> None:
288
327
  def normalize_bow_owners(values: tuple[str, ...] | list[str]) -> tuple[str, ...]:
289
328
  """Turn the raw `bow_owners` layer value into the validated authority list.
290
329
 
291
- Entries are whitespace-stripped, empties dropped (so a trailing comma is
292
- harmless, matching `repos`), duplicates collapsed EXACTLY, with no
293
- casefolding, because actor ids are opaque: two spellings that differ in case
294
- are two different strings on the wire, and folding them would silently widen
295
- the match. Every survivor must look like an actor id.
330
+ Parsed EXACTLY like `repos`, through the same `split_entries`: an element
331
+ may itself carry a whole `|`- and/or `,`-separated list (that is what
332
+ `ALISSA_ORC_BOW_OWNERS` and a Railway variable reference look like),
333
+ whitespace is stripped and empties dropped, so a trailing separator is
334
+ harmless. Duplicates collapse EXACTLY, with no casefolding unlike `repos`,
335
+ because actor ids are opaque: two spellings that differ in case are two
336
+ different strings on the wire, and folding them would silently widen the
337
+ match. Every survivor must look like an actor id.
296
338
 
297
339
  Emptiness is fine here and means "not overridden": the boot path resolves
298
340
  the authority to this token's own actor (see the module docstring). There is
@@ -303,13 +345,11 @@ def normalize_bow_owners(values: tuple[str, ...] | list[str]) -> tuple[str, ...]
303
345
  out: list[str] = []
304
346
  seen: set[str] = set()
305
347
  for value in values:
306
- entry = value.strip()
307
- if not entry:
308
- continue
309
- _validate_actor_id(entry)
310
- if entry not in seen:
311
- seen.add(entry)
312
- out.append(entry)
348
+ for entry in split_entries(value):
349
+ _validate_actor_id(entry)
350
+ if entry not in seen:
351
+ seen.add(entry)
352
+ out.append(entry)
313
353
  return tuple(out)
314
354
 
315
355
 
@@ -353,7 +393,18 @@ def _validate_actor_id(entry: str) -> None:
353
393
  f"it contains {offending!r}, which no actor id does"
354
394
  )
355
395
  hint = " — " + " and ".join(problems)
356
- if offending:
396
+ parts = entry.split()
397
+ if len(parts) > 1 and all(_ACTOR_ID_RE.match(part) for part in parts):
398
+ # Splitting has already run, so an entry that STILL holds whitespace
399
+ # was never separated at all. Say so ONLY when every part is itself
400
+ # an id, which is the only evidence that this is two ids and not a
401
+ # display name: "Alissa Code PR Review" also contains spaces, and
402
+ # telling its author to fix their separator loses the hint below —
403
+ # the diagnostic O8 added, in the change that is about diagnostics.
404
+ # A mixed `<id> not-an-id` falls through here on purpose: that
405
+ # operator's problem is the entry, not the separator.
406
+ hint += "; separate several ids with `|` or `,`, not a space"
407
+ elif offending:
357
408
  hint += "; that looks like a username or a display name"
358
409
  raise ValueError(
359
410
  f"bow_owners entry {entry!r} is not an Alissa actor id{hint}. Use the "
@@ -362,8 +413,9 @@ def _validate_actor_id(entry: str) -> None:
362
413
  f"username or display name: names are renameable, so an ownership "
363
414
  f"check against one is a spoof surface, and since no id on the wire "
364
415
  f"ever equals a name it would fail as a silently empty feed rather "
365
- f"than an error. Leave bow_owners unset to trust this token's own "
366
- f"actor, which needs no id at all."
416
+ f"than an error. Several ids are separated by `|` (the daemon-family "
417
+ f"convention) or `,`, the same as repos. Leave bow_owners unset to "
418
+ f"trust this token's own actor, which needs no id at all."
367
419
  )
368
420
 
369
421
 
@@ -397,15 +449,13 @@ def env_overrides(environ: Mapping[str, str] | None = None) -> dict[str, Any]:
397
449
  raw = env[env_name]
398
450
  if key in _BOOL_KEYS:
399
451
  out[key] = _env_bool(env_name, raw)
400
- elif key == "repos":
401
- # Separator-tolerant (`|` and `,`); shape-validated in `build`
402
- # alongside the file and CLI layers, so one bad entry reports the
403
- # same way wherever it came from.
404
- out[key] = tuple(split_repo_entries(raw))
405
452
  elif key in _LIST_KEYS:
406
- out[key] = tuple(
407
- item for part in raw.split(",") if (item := part.strip())
408
- )
453
+ # ONE branch for every list key, separator-tolerant (`|` and `,`).
454
+ # `repos` had its own until 0.3.4 while the rest split on `,` alone,
455
+ # which is precisely how the two syntaxes drifted apart. Shape is
456
+ # validated in `build` alongside the file and CLI layers, so one bad
457
+ # entry reports the same way wherever it came from.
458
+ out[key] = tuple(split_entries(raw))
409
459
  else:
410
460
  out[key] = raw
411
461
  return out
@@ -465,8 +515,11 @@ class Config:
465
515
  # allowed to OWN a feed container. The title tells the daemon which repo a
466
516
  # container targets; it says nothing about who created it, and the daemon
467
517
  # lists containers it merely collaborates on, which anyone in the tenant can
468
- # arrange unilaterally. So provenance is decided here. Ids are opaque and
469
- # compared EXACTLY (unlike `repos`, which casefolds GitHub names).
518
+ # arrange unilaterally. So provenance is decided here. Entries reach `build`
519
+ # as `|`- and/or `,`-separated text from any layer, exactly like `repos`, and
520
+ # land here split, shape-checked and de-duplicated — but de-duplicated
521
+ # EXACTLY: ids are opaque, so unlike `repos` (case-insensitive GitHub names)
522
+ # nothing here is casefolded, in the comparison or in the collapse.
470
523
  #
471
524
  # Empty means "not overridden", NOT "trust everyone": the boot path resolves
472
525
  # it to this token's own actor and dies if it cannot (O8), and until it does
@@ -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
@@ -252,10 +273,28 @@ log = logging.getLogger(__name__)
252
273
  # the dependency (soft/hard) does not enter here -- both hold identically.
253
274
  DEP_CLEAR_STATES = frozenset({"pending_validation", "validated"})
254
275
 
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"})
276
+ # The two origin-task statuses the `committed in_progress` transition moves
277
+ # between. `committed` = scoped-in TODO; `in_progress` = being worked.
278
+ #
279
+ # BOTH edges make exactly this move, through the same `AlissaClient.set_status`:
280
+ # the close edge as the first step of its pending_validation walk, and (issue
281
+ # #30) the release edge the moment the GitHub issue exists. Released to the
282
+ # pipeline IS being worked -- devloop claims a labeled issue within minutes, so
283
+ # a released task that kept reading `committed` until merge would read as TODO
284
+ # to every observer for the whole implementation window. One definition of the
285
+ # transition, so the two edges can never drift on which one they mean.
286
+ TASK_TODO_STATE = "committed"
287
+ TASK_IN_FLIGHT_STATE = "in_progress"
288
+
289
+ # The close edge only mutates a task the loop itself left in one of these two
290
+ # states. `in_progress` is the NORMAL one since issue #30 -- the release edge
291
+ # sets it the moment the issue exists, so that is how a task in flight reads.
292
+ # `committed` is now the exception rather than the rule: a release-edge status
293
+ # set that failed, a release that crashed before reaching it, or a task released
294
+ # by a dist older than 0.3.6. Either way there is work left to do, and the walk
295
+ # in `_mutate_close` starts from whichever it finds. Any OTHER status means the
296
+ # operator moved the task by hand -- skip, never fight them.
297
+ CLOSE_EXPECTED_STATES = frozenset({TASK_TODO_STATE, TASK_IN_FLIGHT_STATE})
259
298
 
260
299
  # Priority ordering for the release queue. Unknown/blank priority sorts last
261
300
  # (rank 0); ties break on age (older first).
@@ -330,6 +369,24 @@ CLOSE_HANDOFF_STATE = "pending_validation"
330
369
  # gate still decides whether the work is ready to leave the queue.
331
370
  BOW_RELEASABLE_STATE = "committed"
332
371
 
372
+ # The member statuses that earn a `get_task` before being judged. Wider than
373
+ # BOW_RELEASABLE_STATE by exactly one entry, and only since the release edge
374
+ # started setting `in_progress` (issue #30): an ATTACHED task at that status is
375
+ # most likely one of orcloop's own releases whose terminal detach did not land,
376
+ # and the detach retry lives behind the detail read (`_dedupe_result`). Gating
377
+ # it out on the membership row alone would strand it -- attached forever, held
378
+ # once per poll, invisible to the marker sweep (which lists COMMITTED tasks),
379
+ # and unreachable by the only `detach_task` caller there is. The detail read is
380
+ # the price of that recovery, and the bound is one read per poll per member at
381
+ # this status -- no writes, and nothing that scales with the task graph. Two
382
+ # shapes reach it, and only one is transient: an unfinished detach clears on the
383
+ # pass that resolves it (in steady state a released task leaves the feed on the
384
+ # pass that released it), while a task an operator ATTACHED at `in_progress` is
385
+ # held and stays attached, so it earns that read every poll until they detach
386
+ # it. Still the right trade against a permanent strand. Every other status
387
+ # (draft, blocked, cancelled, validated...) is held off the row, unread.
388
+ BOW_RESOLVE_STATES = frozenset({BOW_RELEASABLE_STATE, TASK_IN_FLIGHT_STATE})
389
+
333
390
  # `state.meta` key: unix seconds of the last scan-fallback sweep, so the
334
391
  # low-frequency safety net keeps its cadence across daemon restarts (a restart
335
392
  # loop must not turn an hourly sweep into a per-boot full scan).
@@ -698,9 +755,13 @@ class Orchestrator:
698
755
  sweep needs to measure itself against this pass (see `FeedPass`).
699
756
 
700
757
  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.
758
+ feed BOW, and one `get_task` per member the row reports at a status in
759
+ `BOW_RESOLVE_STATES`. Every other status is held off the row alone, so
760
+ the detail read is never spent on work nothing could do anything with.
761
+ The read IS spent on `in_progress` members, which cannot release --
762
+ deliberately: that is how an already-released task whose detach failed
763
+ reads, and the gate below routes it to `_dedupe_result` to finish the
764
+ detach. Recovering it is worth one read.
704
765
 
705
766
  Membership is read for EVERY feed BOW before any member is evaluated,
706
767
  because a task attached to two feed BOWs has an ambiguous target repo
@@ -840,7 +901,7 @@ class Orchestrator:
840
901
  ambiguous = self._ambiguous_claim(owners)
841
902
  reason = ambiguous or broken
842
903
 
843
- if member.status != BOW_RELEASABLE_STATE:
904
+ if member.status not in BOW_RESOLVE_STATES:
844
905
  if ambiguous is not None:
845
906
  log.warning("%s is %s", self._member_slug(member), ambiguous)
846
907
  # Held off the membership row, so no `get_task` and no task
@@ -871,11 +932,31 @@ class Orchestrator:
871
932
  # it: a task moved out of `committed` between the two reads
872
933
  # must not release off a stale membership row.
873
934
  if task.status != BOW_RELEASABLE_STATE:
935
+ if task.task_number in released_github or self.state.is_released(
936
+ task.task_number
937
+ ):
938
+ # Not a release candidate at all -- an ALREADY-RELEASED
939
+ # task that is still attached is an unfinished detach,
940
+ # and since the release edge leaves what it releases at
941
+ # `in_progress` (issue #30) this is the shape that
942
+ # arrives here. Judging it on its status would hold the
943
+ # very task whose detach is waiting to be retried, in a
944
+ # state nothing else in the daemon can leave: the ledger
945
+ # answer has to come first. Same call the committed path
946
+ # makes below, so the retry is the one already
947
+ # documented.
948
+ results.append(
949
+ self._dedupe_result(task.task_number, bow=bow, task=task)
950
+ )
951
+ continue
952
+ stale = (
953
+ " — the membership row was stale"
954
+ if member.status == BOW_RELEASABLE_STATE else ""
955
+ )
874
956
  results.append(self._logged(task.ref, Decision(
875
957
  Action.HELD,
876
958
  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",
959
+ f"{task.status!r}, not {BOW_RELEASABLE_STATE!r}{stale}",
879
960
  task.task_number,
880
961
  )))
881
962
  continue
@@ -911,7 +992,9 @@ class Orchestrator:
911
992
  if task.task_number in released_github or self.state.is_released(
912
993
  task.task_number
913
994
  ):
914
- results.append(self._dedupe_result(task.task_number, bow=bow))
995
+ results.append(
996
+ self._dedupe_result(task.task_number, bow=bow, task=task)
997
+ )
915
998
  continue
916
999
  assert target is not None # `broken` is None, so it parsed
917
1000
  pending.append((task, bow, target))
@@ -1178,7 +1261,11 @@ class Orchestrator:
1178
1261
  return results
1179
1262
 
1180
1263
  def _dedupe_result(
1181
- self, task_number: int, *, bow: BodyOfWork | None = None
1264
+ self,
1265
+ task_number: int,
1266
+ *,
1267
+ bow: BodyOfWork | None = None,
1268
+ task: Task | None = None,
1182
1269
  ) -> tuple[str, Decision]:
1183
1270
  """A task the dedupe set already covers. Usually a plain SKIPPED, but a
1184
1271
  ledger row still at `labeled=0` is a create->ledger crash survivor: the
@@ -1190,7 +1277,18 @@ class Orchestrator:
1190
1277
  Seeing an already-released task in the feed at all means it is still
1191
1278
  attached: either a crash landed between the label and the detach, or the
1192
1279
  operator re-attached it. Either way the answer is the same -- never a
1193
- second issue, just finish the release by detaching."""
1280
+ second issue, just finish the release by detaching.
1281
+
1282
+ The crash-recovery branch completes the ORIGIN STATUS too, because it is
1283
+ completing the very release that never reached it: it applies the
1284
+ trigger label, which arms devloop, so leaving the task `committed` would
1285
+ reproduce exactly the symptom issue #30 was filed for. `task` is passed
1286
+ in by the `bow` feed (which has already read the detail); the `scan`
1287
+ feed reaches this method with a task NUMBER only -- deliberately, so a
1288
+ dedupe hit costs no detail read -- so on that path the read is spent
1289
+ HERE, inside the rare branch. The hot branch below returns first and
1290
+ still reads nothing, and a failed read is a warning, never a hold: the
1291
+ label is what matters and it has already landed."""
1194
1292
  ref = f"TASK-{task_number}"
1195
1293
  row = self.state.release_for(task_number)
1196
1294
  if row is None or row["labeled"]:
@@ -1208,6 +1306,7 @@ class Orchestrator:
1208
1306
  return self._logged(ref, Decision(
1209
1307
  Action.RELEASED,
1210
1308
  f"[dry-run] would complete labeling of {slug} (crash recovery)"
1309
+ f"{self._resolved_in_progress(ref, task)}"
1211
1310
  f"{self._detach(ref, bow)}",
1212
1311
  task_number,
1213
1312
  ))
@@ -1222,12 +1321,89 @@ class Orchestrator:
1222
1321
  return self._logged(ref, Decision(
1223
1322
  Action.HELD, f"crash-recovery labeling of {slug} failed: {exc}", task_number,
1224
1323
  ))
1324
+ in_flight = self._resolved_in_progress(ref, task)
1225
1325
  return self._logged(ref, Decision(
1226
1326
  Action.RELEASED,
1227
- f"crash recovery: applied label to {slug}{self._detach(ref, bow)}",
1327
+ f"crash recovery: applied label to {slug}{in_flight}"
1328
+ f"{self._detach(ref, bow)}",
1228
1329
  task_number,
1229
1330
  ))
1230
1331
 
1332
+ def _resolved_in_progress(self, ref: str, task: Task | None) -> str:
1333
+ """`_mark_in_progress` for a caller that may not hold the task detail.
1334
+
1335
+ The read is spent only where it is called from (the rare crash-recovery
1336
+ branch), and only when the caller did not already have the task. A
1337
+ failed read cannot hold anything -- the issue is filed and labeled by
1338
+ now -- so it degrades exactly like a failed status set: one warning, and
1339
+ the close edge's walk corrects the status at merge."""
1340
+ if task is None:
1341
+ try:
1342
+ task = self.alissa.get_task(ref)
1343
+ except AlissaError as exc:
1344
+ log.warning(
1345
+ "could not read %s to set it %s after completing its "
1346
+ "release: %s — the release stands and the close edge "
1347
+ "applies the same transition at merge",
1348
+ ref, TASK_IN_FLIGHT_STATE, exc,
1349
+ )
1350
+ return f"; STATUS SET PENDING (→ {TASK_IN_FLIGHT_STATE}: {exc})"
1351
+ return self._mark_in_progress(task)
1352
+
1353
+ def _mark_in_progress(self, task: Task) -> str:
1354
+ """Move a released origin task `committed → in_progress` and return a
1355
+ suffix for the decision's reason.
1356
+
1357
+ Released IS being worked (see `TASK_IN_FLIGHT_STATE`): by the time this
1358
+ runs the issue exists and carries the trigger label, so a developer
1359
+ session is minutes away, while the close edge's status walk only runs at
1360
+ merge -- hours or days later. Without this step the task reads TODO for
1361
+ that entire window.
1362
+
1363
+ Three disciplines, each deliberate:
1364
+
1365
+ * **Transition matrix.** Attempted ONLY from `committed`. A task some
1366
+ other surface has already advanced (a human, a race with the close
1367
+ edge) is left exactly as it is: no downgrade, no illegal
1368
+ `in_progress → in_progress` re-issue, no error -- a silent no-op that
1369
+ returns an empty suffix, so the release reads the same as it always
1370
+ did.
1371
+ * **Never blocking.** A failed set logs a WARNING naming the task and
1372
+ the release proceeds unchanged: the issue exists, the label is on, the
1373
+ detach still runs, and the decision is still `released`. No escalation
1374
+ kind, because the close edge's walk applies this same transition at
1375
+ merge -- the failure is cosmetic and self-healing, and a page that
1376
+ resolves itself is a page that teaches operators to ignore pages.
1377
+ * **Before the detach.** Callers sequence this ahead of `_detach` so the
1378
+ release's terminal act stays terminal. Nothing here couples to the
1379
+ detach in either direction, so a status failure can never leave a task
1380
+ attached to its feed. The converse -- a detach failure meeting a task
1381
+ this method has already advanced -- is why `_bow_feed_pass` consults
1382
+ the ledger before it judges an attached task's status (see `_detach`).
1383
+
1384
+ The mechanism is the close edge's, not a second one: the same
1385
+ `AlissaClient.set_status` call and the same two status constants that
1386
+ `_mutate_close` opens its pending_validation walk with."""
1387
+ if task.status != TASK_TODO_STATE:
1388
+ return ""
1389
+ if self.config.dry_run:
1390
+ return f"; [dry-run] would set {task.ref} {TASK_IN_FLIGHT_STATE}"
1391
+ try:
1392
+ self.alissa.set_status(task.ref, TASK_IN_FLIGHT_STATE)
1393
+ except AlissaError as exc:
1394
+ log.warning(
1395
+ "released %s but could not set it %s: %s — the release stands "
1396
+ "(the issue exists and is labeled) and the close edge applies "
1397
+ "the same transition at merge, so the task is only mis-reported "
1398
+ "as TODO until then",
1399
+ task.ref, TASK_IN_FLIGHT_STATE, exc,
1400
+ )
1401
+ return f"; STATUS SET PENDING (→ {TASK_IN_FLIGHT_STATE}: {exc})"
1402
+ log.info(
1403
+ "%s → %s (released to the pipeline)", task.ref, TASK_IN_FLIGHT_STATE
1404
+ )
1405
+ return f"; task → {TASK_IN_FLIGHT_STATE}"
1406
+
1231
1407
  def _detach(self, ref: str, bow: BodyOfWork | None) -> str:
1232
1408
  """Detach a released task from its feed BOW -- the TERMINAL act of a
1233
1409
  successful release -- and return a suffix for the decision's reason.
@@ -1237,7 +1413,14 @@ class Orchestrator:
1237
1413
  durable in the ledger, and leaving the task attached is the SAFE
1238
1414
  failure -- it stays discoverable, and the next pass's dedupe path
1239
1415
  retries the detach. The reverse (detaching first) would be a take-then-
1240
- act that could lose the work entirely."""
1416
+ act that could lose the work entirely.
1417
+
1418
+ That retry is only reachable because `_bow_feed_pass` asks the LEDGER
1419
+ before it judges an attached task's status: the release edge runs
1420
+ `_mark_in_progress` before this call, so the task waiting for a retry is
1421
+ `in_progress`, which every committed-shaped gate would otherwise treat
1422
+ as "not a candidate" -- a hold nothing could ever clear, since this is
1423
+ `detach_task`'s only caller and the close edge never detaches."""
1241
1424
  if bow is None:
1242
1425
  return ""
1243
1426
  if self.config.dry_run:
@@ -1513,6 +1696,11 @@ class Orchestrator:
1513
1696
  in-flight whatever orcloop thinks, so there is no trigger to withhold,
1514
1697
  only a ledger row to record and a lane to account for.
1515
1698
 
1699
+ Both branches end at `_mark_in_progress`, like the create path: adoption
1700
+ means the work is (back) in flight, and the already-labeled branch is
1701
+ the strongest case of all -- that issue has been armed for devloop since
1702
+ before this pass ran.
1703
+
1516
1704
  An adopted body is a human's, so it is not the renderer's: only the
1517
1705
  trailer is contractual (it is all the develop daemon reads too). A body
1518
1706
  without the `Autonomous-Dev` trailer is adopted anyway -- the target
@@ -1576,11 +1764,12 @@ class Orchestrator:
1576
1764
  verb = "would record" if labeled else "would adopt"
1577
1765
  unlabeled_note = "" if labeled else f", no {self.config.label!r} label"
1578
1766
  occupancy.setdefault(target, []).append(issue_scope)
1767
+ in_flight = self._mark_in_progress(task)
1579
1768
  return self._logged(task.ref, Decision(
1580
1769
  Action.SKIPPED if labeled else Action.RELEASED,
1581
1770
  f"[dry-run] {verb} pre-existing {issue.issue_slug} (carries "
1582
1771
  f"this task's `Alissa-Task:` trailer{unlabeled_note})"
1583
- f"{self._detach(task.ref, bow)}",
1772
+ f"{in_flight}{self._detach(task.ref, bow)}",
1584
1773
  task.task_number,
1585
1774
  ))
1586
1775
 
@@ -1598,11 +1787,16 @@ class Orchestrator:
1598
1787
  # ran before this issue existed, so no later candidate would
1599
1788
  # otherwise measure itself against it.
1600
1789
  occupancy.setdefault(target, []).append(issue_scope)
1790
+ # `skipped` describes what was FILED (nothing), not what is
1791
+ # happening to the work: an armed issue for this task is live,
1792
+ # so the task is in flight exactly like an adopted one.
1793
+ in_flight = self._mark_in_progress(task)
1601
1794
  return self._logged(task.ref, Decision(
1602
1795
  Action.SKIPPED,
1603
1796
  f"already released by hand as {issue.issue_slug} (trailer "
1604
1797
  f"and {self.config.label!r} label present); recorded in the "
1605
- f"ledger, no second issue{self._detach(task.ref, bow)}",
1798
+ f"ledger, no second issue{in_flight}"
1799
+ f"{self._detach(task.ref, bow)}",
1606
1800
  task.task_number,
1607
1801
  ))
1608
1802
  self.github.add_label(issue.owner, issue.repo, issue.number, self.config.label)
@@ -1619,11 +1813,13 @@ class Orchestrator:
1619
1813
  ))
1620
1814
 
1621
1815
  occupancy.setdefault(target, []).append(issue_scope)
1816
+ in_flight = self._mark_in_progress(task)
1622
1817
  return self._logged(task.ref, Decision(
1623
1818
  Action.RELEASED,
1624
1819
  f"adopted pre-existing {issue.issue_slug} (carries this task's "
1625
1820
  f"trailer, was unlabeled): ledger row recorded and "
1626
- f"{self.config.label!r} applied{self._detach(task.ref, bow)}",
1821
+ f"{self.config.label!r} applied{in_flight}"
1822
+ f"{self._detach(task.ref, bow)}",
1627
1823
  task.task_number,
1628
1824
  ))
1629
1825
 
@@ -1805,10 +2001,11 @@ class Orchestrator:
1805
2001
  bow: BodyOfWork | None = None,
1806
2002
  ) -> tuple[str, Decision]:
1807
2003
  """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.
2004
+ move the origin task `committed in_progress`, then -- under the `bow`
2005
+ feed -- detach the task from its Body of Work. In that order, and detach
2006
+ strictly last: the label is the develop daemon's trigger and the detach
2007
+ is the release's terminal act, so a crash at any point leaves the work
2008
+ discoverable rather than lost.
1812
2009
  Occupancy is updated in-memory so a later candidate in THIS pass sees
1813
2010
  the new occupant and respects the lane.
1814
2011
 
@@ -1824,10 +2021,12 @@ class Orchestrator:
1824
2021
 
1825
2022
  if self.config.dry_run:
1826
2023
  occupancy.setdefault(target, []).append(scope)
2024
+ in_flight = self._mark_in_progress(task)
1827
2025
  return self._logged(task.ref, Decision(
1828
2026
  Action.RELEASED,
1829
2027
  f"[dry-run] would release to {target} "
1830
- f"(label {self.config.label!r}){self._detach(task.ref, bow)}",
2028
+ f"(label {self.config.label!r}){in_flight}"
2029
+ f"{self._detach(task.ref, bow)}",
1831
2030
  task.task_number,
1832
2031
  ))
1833
2032
 
@@ -1848,9 +2047,13 @@ class Orchestrator:
1848
2047
  ))
1849
2048
 
1850
2049
  occupancy.setdefault(target, []).append(scope)
2050
+ # Status BEFORE detach, and outside the try above: the issue is filed
2051
+ # and armed, so nothing from here on may turn this into a non-release.
2052
+ in_flight = self._mark_in_progress(task)
1851
2053
  return self._logged(task.ref, Decision(
1852
2054
  Action.RELEASED,
1853
- f"released to {issue.issue_slug}{self._detach(task.ref, bow)}",
2055
+ f"released to {issue.issue_slug}{in_flight}"
2056
+ f"{self._detach(task.ref, bow)}",
1854
2057
  task.task_number,
1855
2058
  ))
1856
2059
 
@@ -2025,8 +2228,8 @@ class Orchestrator:
2025
2228
 
2026
2229
  Criteria satisfaction is naturally idempotent (already-satisfied ones are
2027
2230
  skipped), so it needs no gate."""
2028
- if task.status == "committed":
2029
- self.alissa.set_status(ref, "in_progress")
2231
+ if task.status == TASK_TODO_STATE:
2232
+ self.alissa.set_status(ref, TASK_IN_FLIGHT_STATE)
2030
2233
  for criterion in task.criteria:
2031
2234
  if not criterion.satisfied:
2032
2235
  self.alissa.satisfy_criterion(ref, criterion.id)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-orcloop
3
- Version: 0.3.4
3
+ Version: 0.3.6
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera