alissa-tools-github-revloop 0.31.17__tar.gz → 0.31.19__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 (40) hide show
  1. {alissa_tools_github_revloop-0.31.17/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.19}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa/tools/github/revloop/janitor.py +65 -1
  3. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa/tools/github/revloop/loop.py +95 -39
  4. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa/tools/github/revloop/parking.py +3 -1
  5. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa/tools/github/revloop/state.py +6 -3
  6. alissa_tools_github_revloop-0.31.19/src/main/alissa/tools/github/revloop/version +1 -0
  7. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa/tools/github/revloop/webui/server.py +3 -1
  8. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  9. alissa_tools_github_revloop-0.31.17/src/main/alissa/tools/github/revloop/version +0 -1
  10. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/LICENSE +0 -0
  11. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/MANIFEST.in +0 -0
  12. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/NOTICE +0 -0
  13. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/README.md +0 -0
  14. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/requirements.txt +0 -0
  15. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/setup.cfg +0 -0
  16. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/setup.py +0 -0
  17. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  18. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  19. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
  20. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
  21. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  22. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa/tools/github/revloop/config.py +0 -0
  23. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
  24. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  25. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa/tools/github/revloop/loop_events.py +0 -0
  26. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  27. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa/tools/github/revloop/prompts.py +0 -0
  28. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  29. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa/tools/github/revloop/trust.py +0 -0
  30. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa/tools/github/revloop/version.py +0 -0
  31. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  32. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  33. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  34. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  35. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  36. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  37. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
  38. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  39. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  40. {alissa_tools_github_revloop-0.31.17 → alissa_tools_github_revloop-0.31.19}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.17
3
+ Version: 0.31.19
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -35,6 +35,12 @@ Three properties everything here is written around:
35
35
  * **Nothing raises past its own concern.** A folder that vanishes mid-sweep,
36
36
  an unreadable hub, a failed removal: logged and skipped, because this runs
37
37
  at the end of a poll pass that must complete.
38
+
39
+ Two more bound it (issue #175). The layout is fixed: hubs are found at
40
+ `<root>/<repo>` with the session cwd at `<hub>/main`, so under any other
41
+ `hub_template` (`layout_supported` says no) the caller sweeps nothing rather
42
+ than mis-key the hold. And one sweep removes at most `MAX_REMOVALS` folders in
43
+ `MAX_SWEEP_SECONDS`; the rest wait for the next interval (`deferred`).
38
44
  """
39
45
 
40
46
  from __future__ import annotations
@@ -43,6 +49,7 @@ import logging
43
49
  import os
44
50
  import re
45
51
  import shutil
52
+ import time
46
53
  from dataclasses import dataclass, field
47
54
  from pathlib import Path
48
55
  from typing import Callable, Iterable
@@ -70,6 +77,23 @@ SOURCE_DIR = ".source"
70
77
  KIND_SCRATCH = "scratch"
71
78
  KIND_REVIEW = "review-checkout"
72
79
 
80
+ # One sweep's bound (issue #175): at most this many removals, and no removal
81
+ # started past this many seconds into the sweep. It runs synchronously at the
82
+ # end of a poll pass, so a large backlog is drained over several intervals
83
+ # instead of stalling one pass for minutes.
84
+ MAX_REMOVALS = 25
85
+ MAX_SWEEP_SECONDS = 120.0
86
+
87
+ # The one hub layout the janitor knows: `discover_hubs` reads the root's
88
+ # direct children and `scan_hub` reads `<hub>/main`. Named in the caller's
89
+ # refusal WARNING.
90
+ SUPPORTED_TEMPLATE = "{root}/{repo}/main"
91
+
92
+ # What `layout_supported` probes a template with: two owners that differ, so a
93
+ # template that puts the owner in the path resolves to two different places.
94
+ _PROBE_OWNERS = ("janitor-probe-owner-a", "janitor-probe-owner-b")
95
+ _PROBE_REPO = "janitor-probe-repo"
96
+
73
97
  Runner = Callable[..., str]
74
98
 
75
99
 
@@ -158,12 +182,34 @@ class SweepResult:
158
182
  fresh: int = 0
159
183
  failed: "list[tuple[Path, str]]" = field(default_factory=list)
160
184
  pruned: int = 0
185
+ # Unheld stale candidates the per-sweep bound left for the next interval.
186
+ deferred: int = 0
161
187
 
162
188
  @property
163
189
  def freed(self) -> int:
164
190
  return sum(size for _, size in self.removed)
165
191
 
166
192
 
193
+ def layout_supported(
194
+ hub_for: "Callable[[str, str], Path]", workspace_root: "Path | str"
195
+ ) -> bool:
196
+ """Whether `hub_for` (`Config.hub_for`) places every hub at
197
+ `<root>/<repo>` with the session cwd at `<hub>/main` -- the one layout
198
+ `discover_hubs` and `scan_hub` read, and the one in which a hub's
199
+ directory name is the repo slug the `Guard` keys on.
200
+
201
+ Probed with two distinct owners and one repo name: both must resolve to
202
+ `workspace_root / repo / "main"`. A template that cannot be formatted is
203
+ unsupported. Pure: nothing is read off the disk."""
204
+ expected = Path(os.fspath(workspace_root)).expanduser() / _PROBE_REPO / "main"
205
+ try:
206
+ return all(
207
+ Path(hub_for(owner, _PROBE_REPO)) == expected for owner in _PROBE_OWNERS
208
+ )
209
+ except (KeyError, IndexError, ValueError, AttributeError, TypeError):
210
+ return False
211
+
212
+
167
213
  def discover_hubs(workspace_root: "Path | str") -> "list[Path]":
168
214
  """Every worktree hub directly under the workspace root: a non-symlink
169
215
  directory holding a `.source` directory. Read off the disk rather than the
@@ -323,13 +369,25 @@ def sweep(
323
369
  now: float,
324
370
  dry_run: bool = False,
325
371
  runner: Runner = run,
372
+ max_removals: int = MAX_REMOVALS,
373
+ max_seconds: float = MAX_SWEEP_SECONDS,
374
+ clock: "Callable[[], float]" = time.monotonic,
326
375
  ) -> SweepResult:
327
376
  """Remove every candidate under `hubs` older than `grace_seconds` that
328
377
  `guard` does not hold, then prune each hub's worktree records.
329
378
 
379
+ Bounded (issue #175): before each removal, a sweep that has already
380
+ removed `max_removals` folders, or has run `max_seconds` by `clock`,
381
+ stops removing. Every unheld stale candidate past that point is counted
382
+ in `deferred` and left for the next interval; every hub is still scanned
383
+ and pruned.
384
+
330
385
  Dry-run removes nothing and prunes nothing: it logs each folder it WOULD
331
- remove (with its size) and reports them in `would_remove`."""
386
+ remove (with its size) and reports them in `would_remove`, under the
387
+ same bound -- so it reports what one live sweep would do."""
332
388
  result = SweepResult()
389
+ started = clock()
390
+ bounded = False
333
391
  for hub in hubs:
334
392
  result.hubs += 1
335
393
  for candidate in scan_hub(hub):
@@ -344,6 +402,12 @@ def sweep(
344
402
  log.debug("janitor: keeping %s — %s", candidate.path, why)
345
403
  result.held.append((candidate.path, why))
346
404
  continue
405
+ if not bounded:
406
+ taken = len(result.would_remove if dry_run else result.removed)
407
+ bounded = taken >= max_removals or clock() - started >= max_seconds
408
+ if bounded:
409
+ result.deferred += 1
410
+ continue
347
411
  if not _contained(candidate):
348
412
  result.failed.append((candidate.path, "not contained in its hub"))
349
413
  continue
@@ -2619,6 +2619,9 @@ class ReviewWatcher:
2619
2619
  # When the workspace janitor last swept (monotonic), or None before the
2620
2620
  # first sweep -- which therefore runs at the end of the first pass.
2621
2621
  self._janitor_last: "float | None" = None
2622
+ # Whether the janitor has said, once for this process, that it will
2623
+ # not sweep under this `hub_template` (issue #175).
2624
+ self._janitor_layout_warned = False
2622
2625
  # The (repo, number, round) whose alive session the stale-round probe
2623
2626
  # just killed as `wedged:first-run-dialog` (issue #136), so the
2624
2627
  # respawn site logs its re-enqueue at INFO instead of a second
@@ -6302,20 +6305,49 @@ class ReviewWatcher:
6302
6305
  #157): claim the spawn of this round's session against the drain
6303
6306
  flag, run `spawn` under the claim, and drop the claim however it ends.
6304
6307
 
6305
- The gate sits ABOVE every side effect of the spawn -- the hub add,
6306
- the `alissa tmux queue add`, the census bump, the ledger row, the
6307
- activity line -- so a drained seat touches nothing, and the round
6308
- stays owed: a QUEUED `drained` decision writes no spawn row, burns no
6309
- round number and spends no attempt, exactly like the slot gate. The
6310
- claim and the flag are read and written in one immediate transaction
6311
- on each side (`State.claim_spawn` / `State.set_drain`): a drain that
6308
+ The gate sits ABOVE every side effect of the spawn but one -- the
6309
+ `alissa tmux queue add`, the census bump, the ledger row and the
6310
+ activity line all come after it -- and a round it stands down stays
6311
+ owed: a QUEUED `drained` decision writes no spawn row, burns no round
6312
+ number and spends no attempt, exactly like the slot gate. The claim
6313
+ and the flag are read and written in one immediate transaction on
6314
+ each side (`State.claim_spawn` / `State.set_drain`): a drain that
6312
6315
  lands while this spawn is between its gate and its ledger write sees
6313
- the claim and refuses itself, so a park can never race a spawn. A
6314
- ledger that cannot answer stands the spawn down too -- a spawn that
6315
- cannot prove the seat is not drained does not happen.
6316
+ the claim and refuses itself. A ledger that cannot answer stands the
6317
+ spawn down too -- a spawn that cannot prove the seat is not drained
6318
+ does not happen.
6319
+
6320
+ The claim only counts for `parking.SPAWN_CLAIM_TTL`, and the hub add
6321
+ (`_ensure_hub`, a clone on a PR's first round) can outlast it. So
6322
+ `_spawn_claimed` re-takes the claim (`_claim_spawn` again, issue
6323
+ #173) between the hub add and the enqueue, which refreshes it and
6324
+ re-reads the flag: a drain that held while the hub was provisioned
6325
+ stands the round down there, the same QUEUED `drained` way. That late
6326
+ re-check is the one stand-down that happens AFTER a side effect --
6327
+ the hub is provisioned and stays -- which is harmless, because the
6328
+ hub is a reusable cache the round's next spawn finds in place.
6329
+ Together the two claims mean a park can never race a spawn, whatever
6330
+ the drain's freshness rule allows.
6316
6331
 
6317
6332
  Dry-run READS the flag and claims nothing: it shares the production
6318
6333
  state path, and a dry pass must write no durable row."""
6334
+ refused = self._claim_spawn(pr, round_, name)
6335
+ if refused is not None:
6336
+ return refused
6337
+ try:
6338
+ return spawn()
6339
+ finally:
6340
+ if not self.config.dry_run:
6341
+ self.state.release_spawn(name)
6342
+
6343
+ def _claim_spawn(
6344
+ self, pr: PullRequest, round_: int, name: str, *, late: bool = False,
6345
+ ) -> "Decision | None":
6346
+ """Claim (or, `late`, re-claim) the spawn of `name` against the drain
6347
+ flag: None when the spawn may proceed, else the QUEUED `drained`
6348
+ decision that stands the round down. Dry-run reads the flag and
6349
+ claims nothing. See `_drain_gated`."""
6350
+ where = " after its hub was provisioned" if late else ""
6319
6351
  try:
6320
6352
  if self.config.dry_run:
6321
6353
  drain = self.state.drain()
@@ -6323,36 +6355,32 @@ class ReviewWatcher:
6323
6355
  drain = self.state.claim_spawn(name)
6324
6356
  except sqlite3.Error as exc:
6325
6357
  log.warning(
6326
- "%s round %d: could not claim the spawn against the drain flag "
6327
- "(%s) — standing it down this pass", pr.slug, round_, exc,
6358
+ "%s round %d: could not claim the spawn against the drain flag%s "
6359
+ "(%s) — standing it down this pass", pr.slug, round_, where, exc,
6328
6360
  )
6329
6361
  return Decision(
6330
6362
  Action.QUEUED,
6331
- f"round {round_} stood down — the state ledger could not prove "
6332
- f"the seat is not drained ({exc}); retrying next poll",
6363
+ f"round {round_} stood down{where} — the state ledger could not "
6364
+ f"prove the seat is not drained ({exc}); retrying next poll",
6333
6365
  round_,
6334
6366
  drained=True,
6335
6367
  )
6336
- if drain is not None:
6337
- since = parking_mod.iso(drain["drained_at"])
6338
- until = parking_mod.iso(drain["expires_at"])
6339
- log.info(
6340
- "%s round %d: not spawning — the seat is drained for parking "
6341
- "since %s (until %s)", pr.slug, round_, since, until,
6342
- )
6343
- return Decision(
6344
- Action.QUEUED,
6345
- f"round {round_} held — the seat is drained for parking since "
6346
- f"{since} (until {until}: {drain['reason'] or 'no reason given'}); "
6347
- f"retrying once it is undrained",
6348
- round_,
6349
- drained=True,
6350
- )
6351
- try:
6352
- return spawn()
6353
- finally:
6354
- if not self.config.dry_run:
6355
- self.state.release_spawn(name)
6368
+ if drain is None:
6369
+ return None
6370
+ since = parking_mod.iso(drain["drained_at"])
6371
+ until = parking_mod.iso(drain["expires_at"])
6372
+ log.info(
6373
+ "%s round %d: not spawning%s — the seat is drained for parking "
6374
+ "since %s (until %s)", pr.slug, round_, where, since, until,
6375
+ )
6376
+ return Decision(
6377
+ Action.QUEUED,
6378
+ f"round {round_} held{where} — the seat is drained for parking since "
6379
+ f"{since} (until {until}: {drain['reason'] or 'no reason given'}); "
6380
+ f"retrying once it is undrained",
6381
+ round_,
6382
+ drained=True,
6383
+ )
6356
6384
 
6357
6385
  def _spawn_claimed(
6358
6386
  self,
@@ -6427,6 +6455,14 @@ class ReviewWatcher:
6427
6455
  # provisioned -- unknown, never settled.
6428
6456
  return Decision(Action.SKIPPED, problem, round_, owed=True)
6429
6457
 
6458
+ # The hub add can outlast the gate's claim (a clone on a PR's first
6459
+ # round): re-take it, refreshing it and re-reading the drain flag, so
6460
+ # a drain that landed meanwhile stands the round down here (issue
6461
+ # #173). The claim is still released in `_drain_gated`'s finally.
6462
+ refused = self._claim_spawn(pr, round_, name, late=True)
6463
+ if refused is not None:
6464
+ return refused
6465
+
6430
6466
  self.alissa.enqueue_reviewer(
6431
6467
  session=name,
6432
6468
  directive=directive,
@@ -7913,8 +7949,9 @@ class ReviewWatcher:
7913
7949
  One INFO line per sweep (folders removed and bytes freed, or what a
7914
7950
  dry run WOULD remove), and one WARNING when the workspace volume is
7915
7951
  at or above `janitor_warn_pct`. The disk read runs even when the
7916
- roster cannot be listed and the removal half is skipped: a full
7917
- volume is worth saying whatever else went wrong."""
7952
+ roster cannot be listed, or the hub layout is not the one the janitor
7953
+ reads, and the removal half is skipped: a full volume is worth saying
7954
+ whatever else went wrong."""
7918
7955
  if not self.config.janitor_enabled:
7919
7956
  return None
7920
7957
  mono = time.monotonic()
@@ -7936,6 +7973,22 @@ class ReviewWatcher:
7936
7973
  return result
7937
7974
 
7938
7975
  def _sweep_workspace(self, now: float) -> "janitor_mod.SweepResult | None":
7976
+ if not janitor_mod.layout_supported(
7977
+ self.config.hub_for, self.config.workspace_root
7978
+ ):
7979
+ # Fail closed (issue #175): under another layout the hubs on disk
7980
+ # are not keyed by the slug the Guard holds (or are not found at
7981
+ # all), so a sweep could remove a live round's folders. Nothing is
7982
+ # removed or pruned; said once per process, not once per interval.
7983
+ if not self._janitor_layout_warned:
7984
+ self._janitor_layout_warned = True
7985
+ log.warning(
7986
+ "janitor: hub_template %r is not supported — the janitor "
7987
+ "supports only %s, so it removes and prunes nothing in "
7988
+ "this process",
7989
+ self.config.hub_template, janitor_mod.SUPPORTED_TEMPLATE,
7990
+ )
7991
+ return None
7939
7992
  guard = self._janitor_guard(now)
7940
7993
  if guard is None:
7941
7994
  log.warning(
@@ -7953,17 +8006,20 @@ class ReviewWatcher:
7953
8006
  if self.config.dry_run:
7954
8007
  log.info(
7955
8008
  "[dry-run] janitor: would remove %d folder(s) (%s) across %d "
7956
- "hub(s); %d held by a live round, %d inside the grace",
8009
+ "hub(s); %d held by a live round, %d inside the grace, %d "
8010
+ "deferred to the next interval",
7957
8011
  len(result.would_remove),
7958
8012
  janitor_mod.human(sum(size for _, size in result.would_remove)),
7959
- result.hubs, len(result.held), result.fresh,
8013
+ result.hubs, len(result.held), result.fresh, result.deferred,
7960
8014
  )
7961
8015
  else:
7962
8016
  log.info(
7963
8017
  "janitor: removed %d folder(s), freed %s across %d hub(s); "
7964
- "%d held by a live round, %d inside the grace, %d failed",
8018
+ "%d held by a live round, %d inside the grace, %d failed, %d "
8019
+ "deferred to the next interval",
7965
8020
  len(result.removed), janitor_mod.human(result.freed),
7966
8021
  result.hubs, len(result.held), result.fresh, len(result.failed),
8022
+ result.deferred,
7967
8023
  )
7968
8024
  return result
7969
8025
 
@@ -26,7 +26,9 @@ handshake):
26
26
  reads every null as a refusal.
27
27
  * **The drain.** A flag with a TTL in `state.db` (`State.set_drain`), cleared
28
28
  when the daemon boots. While it holds, the round spawn stands down before
29
- its first side effect (`ReviewWatcher._drain_gated`).
29
+ its first side effect (`ReviewWatcher._drain_gated`), or, when the drain
30
+ lands while the hub is provisioned, after the hub add and before the
31
+ enqueue (the post-hub re-claim).
30
32
  """
31
33
 
32
34
  from __future__ import annotations
@@ -39,6 +39,8 @@ from collections.abc import Callable, Iterable
39
39
  from dataclasses import dataclass
40
40
  from pathlib import Path
41
41
 
42
+ from .parking import SPAWN_CLAIM_TTL
43
+
42
44
  log = logging.getLogger(__name__)
43
45
 
44
46
  # Poll-snapshot retention: the newest N rows are kept, older ones pruned on
@@ -2067,11 +2069,12 @@ class State:
2067
2069
 
2068
2070
  def set_drain(
2069
2071
  self, ttl: int, reason: str, now: "float | None" = None,
2070
- claim_ttl: int = 600,
2072
+ claim_ttl: int = SPAWN_CLAIM_TTL,
2071
2073
  ) -> "tuple[dict, list[dict]]":
2072
2074
  """Raise the drain flag for `ttl` seconds and return it with the spawn
2073
- claims standing at that instant (younger than `claim_ttl` -- an older
2074
- one is a spawn whose process died mid-way, cleared at the next boot).
2075
+ claims standing at that instant (younger than `claim_ttl`, by default
2076
+ `parking.SPAWN_CLAIM_TTL` -- an older one is a spawn whose process
2077
+ died mid-way, cleared at the next boot).
2075
2078
  ONE immediate transaction, so a spawn either claimed before this (and
2076
2079
  is in the list) or claims after it (and sees the flag). A drain that
2077
2080
  is already up is replaced: a re-drain restarts the TTL. Raises
@@ -332,7 +332,9 @@ class App:
332
332
  cleared = self.sources.clear_drain()
333
333
  except sqlite3.Error as exc:
334
334
  self._audit("undrain", {"ok": False, "error": str(exc)})
335
- return 503, {"drained": True, "error": "state_unavailable"}
335
+ # The flag's state is unknown here, and unknown is never a value
336
+ # (D2): the 503 already says the undrain did not land.
337
+ return 503, {"drained": None, "error": "state_unavailable"}
336
338
  self._audit("undrain", {"ok": True, "cleared": cleared})
337
339
  return 200, {"drained": False, "cleared": cleared}
338
340
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.17
3
+ Version: 0.31.19
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera