alissa-tools-github-revloop 0.31.10__tar.gz → 0.31.11__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 (39) hide show
  1. {alissa_tools_github_revloop-0.31.10/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.11}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/fleet_vitals.py +64 -0
  3. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/loop.py +195 -6
  4. alissa_tools_github_revloop-0.31.11/src/main/alissa/tools/github/revloop/parking.py +222 -0
  5. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/state.py +124 -0
  6. alissa_tools_github_revloop-0.31.11/src/main/alissa/tools/github/revloop/version +1 -0
  7. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/webui/__main__.py +20 -1
  8. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/webui/server.py +114 -2
  9. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/webui/sources.py +77 -3
  10. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  11. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +1 -0
  12. alissa_tools_github_revloop-0.31.10/src/main/alissa/tools/github/revloop/version +0 -1
  13. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/LICENSE +0 -0
  14. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/MANIFEST.in +0 -0
  15. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/NOTICE +0 -0
  16. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/README.md +0 -0
  17. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/requirements.txt +0 -0
  18. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/setup.cfg +0 -0
  19. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/setup.py +0 -0
  20. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  21. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  22. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
  23. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
  24. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  25. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/config.py +0 -0
  26. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  27. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/loop_events.py +0 -0
  28. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  29. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/prompts.py +0 -0
  30. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  31. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/trust.py +0 -0
  32. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/version.py +0 -0
  33. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  34. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  35. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  36. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  37. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  38. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  39. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/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.10
3
+ Version: 0.31.11
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -41,6 +41,15 @@ Design rules, all load-bearing:
41
41
  user has not activated the loop app) warns ONCE per boot with the activate
42
42
  URL and logs at DEBUG after, because it is a fixed condition the operator
43
43
  resolves out of band, and the next pass after activation lands on its own.
44
+
45
+ Seat parking (issue #157, studio.alissa.app seat-parking design §2.2/§2.4)
46
+ adds the `idle` block -- the seat's own idle verdict (`parking.idle_block`),
47
+ with `drainedAt` while a drain holds -- so Studio can re-check a park against
48
+ the seat's OWN snapshot. The console's drain pushes one snapshot before it
49
+ answers (`FleetVitalsPusher.push_now`). Until Studio's schema carries the
50
+ block (the design's lane L3), a strict 400 naming `idle` makes the pusher
51
+ resend without it and leave it out for IDLE_REPROBE_S: the vitals the Factory
52
+ already renders never go dark over a field nobody reads yet.
44
53
  """
45
54
 
46
55
  from __future__ import annotations
@@ -51,6 +60,7 @@ import time
51
60
  from datetime import datetime, timezone
52
61
  from typing import TYPE_CHECKING, Any, Callable
53
62
 
63
+ from . import parking
54
64
  from .alissa import parse_session_name, session_repo_slug
55
65
  from .alissa_client import (
56
66
  NOT_ACTIVATED,
@@ -87,6 +97,12 @@ VITALS_PUSHED = "pushed"
87
97
  VITALS_SKIPPED = "skipped"
88
98
  VITALS_FAILED = "failed"
89
99
 
100
+ # How long a snapshot leaves the `idle` block out after Studio refused it as
101
+ # an unknown key (a schema that predates seat parking), before it offers the
102
+ # block again. An hour: one extra POST an hour against an old Studio, and a
103
+ # newly deployed one starts receiving the block within the hour.
104
+ IDLE_REPROBE_S = 3600.0
105
+
90
106
  # The operator's lever per inbox kind, in the Factory's `lever` slot. The
91
107
  # cap-out and the stability hold are both lifted by the same re-entry ack;
92
108
  # a stalled episode is the console's retry-now (or a reap).
@@ -252,6 +268,11 @@ def build_snapshot(
252
268
  optional field degrades to null on its own -- a broken tmux does not
253
269
  blank the memory split, and vice versa -- and `kpis` is always null (an
254
270
  orcloop-only section).
271
+
272
+ `idle` (seat parking, issue #157) is the seat's idle block: owed and
273
+ timers from the newest poll snapshot, live / managed from the SAME roster
274
+ read as `sessions`, queued from the alissa queue, and `drainedAt` while
275
+ a drain holds.
255
276
  """
256
277
  stamp = time.time() if now is None else now
257
278
  snaps = sources.snapshots(DURATION_POINTS)
@@ -277,6 +298,12 @@ def build_snapshot(
277
298
  )
278
299
  session_list = [_session_row(r, repos) for r in ordered[:LIST_CAP]]
279
300
  inbox = [_inbox_row(item) for item in sources.inbox()["live"][:LIST_CAP]]
301
+ live, managed = parking.roster_counts(rows)
302
+ idle = parking.idle_block(
303
+ snaps[0] if snaps else None,
304
+ live=live, managed=managed, queued=sources.queued(),
305
+ drain=sources.drain(), now=stamp,
306
+ )
280
307
  return {
281
308
  "schemaVersion": SCHEMA_VERSION,
282
309
  "seat": SEAT,
@@ -297,6 +324,7 @@ def build_snapshot(
297
324
  "queueDepth": _int_or_none(queue_depth),
298
325
  "kpis": None,
299
326
  "inbox": inbox,
327
+ "idle": idle,
300
328
  }
301
329
 
302
330
 
@@ -366,6 +394,9 @@ class FleetVitalsPusher:
366
394
  # every later refusal is a DEBUG line. The push itself is still
367
395
  # attempted every pass -- the pass after activation lands on its own.
368
396
  self._not_activated_warned = False
397
+ # Until when the `idle` block is left out (Studio refused it as an
398
+ # unknown key); 0 = offer it.
399
+ self._idle_refused_until = 0.0
369
400
 
370
401
  def build(self, *, heartbeat_at: "int | float", queue_depth: "int | None") -> dict:
371
402
  """This pass's contract body, fitted to the API's caps."""
@@ -400,9 +431,31 @@ class FleetVitalsPusher:
400
431
  type(exc).__name__, exc,
401
432
  )
402
433
  return VITALS_FAILED
434
+ if self._clock() < self._idle_refused_until:
435
+ snapshot.pop("idle", None)
403
436
  if self.config.dry_run:
404
437
  log.info("[dry-run] would push fleet vitals (%s)", describe(snapshot))
405
438
  return VITALS_SKIPPED
439
+ return self._post(snapshot)
440
+
441
+ def push_now(self) -> str:
442
+ """The console's out-of-pass push (the drain handshake, issue #157):
443
+ the same snapshot, its `heartbeatAt` the daemon's LAST completed pass
444
+ -- the console completes none of its own, and stamping now would read
445
+ as a heartbeat the loop never gave -- and `asOf` now. `queueDepth` is
446
+ null: the slot queue lives in the daemon's memory, and the console
447
+ cannot say. One of VITALS_PUSHED, VITALS_SKIPPED or VITALS_FAILED."""
448
+ snaps = self._sources.snapshots(1)
449
+ heartbeat = snaps[0]["ts"] if snaps else self._clock()
450
+ return self.push_once(heartbeat_at=heartbeat, queue_depth=None)
451
+
452
+ @staticmethod
453
+ def _refuses_idle(exc: AlissaError) -> bool:
454
+ """A 400 that names the `idle` key: a Studio whose strict schema
455
+ predates seat parking (the design's lane L3 adds the field)."""
456
+ return exc.status == 400 and "idle" in str(exc.detail)
457
+
458
+ def _post(self, snapshot: dict) -> str:
406
459
  try:
407
460
  result = self._client.post_fleet_vitals(snapshot)
408
461
  except AlissaAuthError as exc:
@@ -412,6 +465,17 @@ class FleetVitalsPusher:
412
465
  self._warn_failed(exc)
413
466
  return VITALS_FAILED
414
467
  except AlissaError as exc:
468
+ if "idle" in snapshot and self._refuses_idle(exc):
469
+ self._idle_refused_until = self._clock() + IDLE_REPROBE_S
470
+ log.warning(
471
+ "fleet-vitals: Studio refused the seat-parking `idle` "
472
+ "block (status %s: %s) — its schema predates it; "
473
+ "resending without it, and leaving it out for %d min",
474
+ exc.status, exc.detail, int(IDLE_REPROBE_S // 60),
475
+ )
476
+ trimmed = dict(snapshot)
477
+ trimmed.pop("idle")
478
+ return self._post(trimmed)
415
479
  self._warn_failed(exc)
416
480
  return VITALS_FAILED
417
481
  log.info(
@@ -24,6 +24,7 @@ from enum import Enum
24
24
  from pathlib import Path
25
25
  from typing import Callable
26
26
 
27
+ from . import parking as parking_mod
27
28
  from . import prompts as prompts_mod
28
29
  from .alissa import (
29
30
  COMMIT_READINESS_TRAILER_LABEL,
@@ -2201,6 +2202,32 @@ class Decision:
2201
2202
  # exactly as `checks_held` is -- the operator's remedy is the account,
2202
2203
  # not a session slot.
2203
2204
  prompt_held: bool = False
2205
+ # `drained` marks a QUEUED that the seat-parking drain held back (issue
2206
+ # #157): orcloop asked the console to drain the seat for a park, so the
2207
+ # round is owed and stands down before its first side effect. Same column,
2208
+ # its own stage, excluded from the gate's capacity summary like the two
2209
+ # holds above -- the remedy is the park/wake cycle, not a session slot.
2210
+ drained: bool = False
2211
+ # The seat-parking verdict (issue #157): whether this PR is OWED -- the
2212
+ # seat's own work, or a clock the seat owns, is still running on it -- or
2213
+ # settled. None takes the action's default (`decision_owed`); the sites
2214
+ # that know better say so. `timer` is the seat-owned clock behind an owed
2215
+ # verdict, `(kind, until)` with `kind` one of parking.TIMER_KINDS and
2216
+ # `until` a unix second. Observational: only the poll snapshot (and from
2217
+ # it the idle block) reads them.
2218
+ owed: bool | None = None
2219
+ timer: "tuple[str, int] | None" = None
2220
+
2221
+
2222
+ def decision_owed(decision: Decision) -> bool:
2223
+ """Whether a decision leaves its PR OWED (seat-parking design §2.1): an
2224
+ explicit verdict on the decision wins; otherwise every action but a
2225
+ convergence, a cap or a skip is the seat's own work in progress -- a
2226
+ round spawned, queued, in flight or held, a verdict still to post, an
2227
+ escalation posted this pass."""
2228
+ if decision.owed is not None:
2229
+ return decision.owed
2230
+ return decision.action.value in parking_mod.OWED_STAGES
2204
2231
 
2205
2232
 
2206
2233
  @dataclass(frozen=True)
@@ -3095,7 +3122,15 @@ class ReviewWatcher:
3095
3122
 
3096
3123
  age = self.state.spawn_age(pr.full_name, number, round_)
3097
3124
  if age is not None and age < STALE_ROUND_SECONDS:
3098
- return Decision(Action.IN_FLIGHT, f"round {round_} enqueued {int(age)}s ago", round_)
3125
+ return Decision(
3126
+ Action.IN_FLIGHT,
3127
+ f"round {round_} enqueued {int(age)}s ago",
3128
+ round_,
3129
+ timer=(
3130
+ parking_mod.TIMER_STALE_WINDOW,
3131
+ int(time.time() - age + STALE_ROUND_SECONDS),
3132
+ ),
3133
+ )
3099
3134
  if age is not None:
3100
3135
  deferred = self._defer_stale_round(pr, round_, age, cap)
3101
3136
  if deferred is not None:
@@ -3345,11 +3380,19 @@ class ReviewWatcher:
3345
3380
  "the review-request snapshot says",
3346
3381
  pr.slug, round_, pr.head_sha[:8], since, _stamp(verdict_at), cooldown,
3347
3382
  )
3383
+ # Owed (issue #157): the request still stands, and whether the
3384
+ # round is admitted is decided when the cooldown ends -- a moment
3385
+ # nothing on GitHub marks, so a seat parked across it would never
3386
+ # be woken for a round it owes.
3348
3387
  return Decision(
3349
3388
  Action.SKIPPED,
3350
3389
  f"head {pr.head_sha[:8]} had a verdict {int(since)}s ago — inside "
3351
3390
  f"the {cooldown}s post-verdict cooldown",
3352
3391
  round_,
3392
+ owed=True,
3393
+ timer=(
3394
+ parking_mod.TIMER_STALE_WINDOW, int(verdict_at + cooldown)
3395
+ ),
3353
3396
  )
3354
3397
 
3355
3398
  requested = self._fresh_request_at(pr)
@@ -3506,7 +3549,10 @@ class ReviewWatcher:
3506
3549
  # PR that was deferred while the fleet was full, and is refused once the
3507
3550
  # census clears, keeps its seat forever.
3508
3551
  self._waiting.pop((pr.full_name, pr.number), None)
3509
- return Decision(Action.SKIPPED, problem, round_)
3552
+ # Owed (issue #157): an owed round the seat cannot start for a reason
3553
+ # of its own -- no review task yet, no hub -- is not settled, and its
3554
+ # remedy (a task, a hub) moves nothing a parked seat is woken by.
3555
+ return Decision(Action.SKIPPED, problem, round_, owed=True)
3510
3556
 
3511
3557
  # -- the product-stability guard (issue #105) ---------------------------
3512
3558
 
@@ -3858,6 +3904,10 @@ class ReviewWatcher:
3858
3904
  f"{rollup.summary} ({int(waited)}s of {bound}s)",
3859
3905
  round_,
3860
3906
  checks_held=True,
3907
+ timer=(
3908
+ parking_mod.TIMER_CHECKS_HOLD,
3909
+ int(time.time() - waited + bound),
3910
+ ),
3861
3911
  )
3862
3912
  )
3863
3913
 
@@ -4064,10 +4114,16 @@ class ReviewWatcher:
4064
4114
  # The responder's account hold is excluded for the same reason: the
4065
4115
  # remedy is the account, not a freed slot, and the hold has its own
4066
4116
  # WARNING every pass it stands (see poll_once).
4117
+ # A drained round is excluded too (issue #157): a seat being parked
4118
+ # holds every spawn by design, and a park outlasting the stall window
4119
+ # must not page as a review outage.
4067
4120
  held = [
4068
4121
  (slug, d)
4069
4122
  for slug, d in results
4070
- if d.action is Action.QUEUED and not d.checks_held and not d.prompt_held
4123
+ if d.action is Action.QUEUED
4124
+ and not d.checks_held
4125
+ and not d.prompt_held
4126
+ and not d.drained
4071
4127
  ]
4072
4128
  if not held:
4073
4129
  self._gate_stall.clear()
@@ -4231,6 +4287,7 @@ class ReviewWatcher:
4231
4287
  f"yet — {reason}",
4232
4288
  round_,
4233
4289
  task_ref=task.ref,
4290
+ timer=(parking_mod.TIMER_STALE_WINDOW, int(time.time() + left)),
4234
4291
  )
4235
4292
 
4236
4293
  judged = str(row["head_sha"] or pr.head_sha)
@@ -4482,6 +4539,10 @@ class ReviewWatcher:
4482
4539
  f"({int(held // 60)}m held in total)",
4483
4540
  round_,
4484
4541
  task_ref=task.ref,
4542
+ timer=(
4543
+ parking_mod.TIMER_CHECKS_HOLD,
4544
+ int(time.time() - waited + bound),
4545
+ ),
4485
4546
  ),
4486
4547
  state=rollup.state,
4487
4548
  )
@@ -6071,6 +6132,95 @@ class ReviewWatcher:
6071
6132
  reenqueued: bool = False,
6072
6133
  checks: str = "",
6073
6134
  stability: "StabilityNotice | None" = None,
6135
+ ) -> Decision:
6136
+ """The round spawn, behind the drain gate (`_drain_gated`). The
6137
+ session name is drawn ONCE, here: it carries a nonce, and the claim
6138
+ must name the session the spawn then enqueues."""
6139
+ name = session_name(pr, round_)
6140
+ return self._drain_gated(
6141
+ pr,
6142
+ round_,
6143
+ name,
6144
+ lambda: self._spawn_claimed(
6145
+ pr, round_, task, cap, name,
6146
+ reenqueued=reenqueued, checks=checks, stability=stability,
6147
+ ),
6148
+ )
6149
+
6150
+ def _drain_gated(
6151
+ self,
6152
+ pr: PullRequest,
6153
+ round_: int,
6154
+ name: str,
6155
+ spawn: "Callable[[], Decision]",
6156
+ ) -> Decision:
6157
+ """The round spawn's drain gate (seat-parking design §2.4, issue
6158
+ #157): claim the spawn of this round's session against the drain
6159
+ flag, run `spawn` under the claim, and drop the claim however it ends.
6160
+
6161
+ The gate sits ABOVE every side effect of the spawn -- the hub add,
6162
+ the `alissa tmux queue add`, the census bump, the ledger row, the
6163
+ activity line -- so a drained seat touches nothing, and the round
6164
+ stays owed: a QUEUED `drained` decision writes no spawn row, burns no
6165
+ round number and spends no attempt, exactly like the slot gate. The
6166
+ claim and the flag are read and written in one immediate transaction
6167
+ on each side (`State.claim_spawn` / `State.set_drain`): a drain that
6168
+ lands while this spawn is between its gate and its ledger write sees
6169
+ the claim and refuses itself, so a park can never race a spawn. A
6170
+ ledger that cannot answer stands the spawn down too -- a spawn that
6171
+ cannot prove the seat is not drained does not happen.
6172
+
6173
+ Dry-run READS the flag and claims nothing: it shares the production
6174
+ state path, and a dry pass must write no durable row."""
6175
+ try:
6176
+ if self.config.dry_run:
6177
+ drain = self.state.drain()
6178
+ else:
6179
+ drain = self.state.claim_spawn(name)
6180
+ except sqlite3.Error as exc:
6181
+ log.warning(
6182
+ "%s round %d: could not claim the spawn against the drain flag "
6183
+ "(%s) — standing it down this pass", pr.slug, round_, exc,
6184
+ )
6185
+ return Decision(
6186
+ Action.QUEUED,
6187
+ f"round {round_} stood down — the state ledger could not prove "
6188
+ f"the seat is not drained ({exc}); retrying next poll",
6189
+ round_,
6190
+ drained=True,
6191
+ )
6192
+ if drain is not None:
6193
+ since = parking_mod.iso(drain["drained_at"])
6194
+ until = parking_mod.iso(drain["expires_at"])
6195
+ log.info(
6196
+ "%s round %d: not spawning — the seat is drained for parking "
6197
+ "since %s (until %s)", pr.slug, round_, since, until,
6198
+ )
6199
+ return Decision(
6200
+ Action.QUEUED,
6201
+ f"round {round_} held — the seat is drained for parking since "
6202
+ f"{since} (until {until}: {drain['reason'] or 'no reason given'}); "
6203
+ f"retrying once it is undrained",
6204
+ round_,
6205
+ drained=True,
6206
+ )
6207
+ try:
6208
+ return spawn()
6209
+ finally:
6210
+ if not self.config.dry_run:
6211
+ self.state.release_spawn(name)
6212
+
6213
+ def _spawn_claimed(
6214
+ self,
6215
+ pr: PullRequest,
6216
+ round_: int,
6217
+ task: Task | None,
6218
+ cap: int,
6219
+ name: str,
6220
+ *,
6221
+ reenqueued: bool = False,
6222
+ checks: str = "",
6223
+ stability: "StabilityNotice | None" = None,
6074
6224
  ) -> Decision:
6075
6225
  # `task is None` here means spawn_anyway/warn_and_spawn: the skip mode
6076
6226
  # was decided in _refused_before_start, above the CI gate, so a round
@@ -6111,7 +6261,6 @@ class ReviewWatcher:
6111
6261
  else:
6112
6262
  assignment = f"You've been assigned Alissa review task {task.ref}."
6113
6263
 
6114
- name = session_name(pr, round_)
6115
6264
  if plan:
6116
6265
  template = PLAN_ROUND_1_DIRECTIVE if round_ == 1 else PLAN_ROUND_K_DIRECTIVE
6117
6266
  else:
@@ -6130,7 +6279,9 @@ class ReviewWatcher:
6130
6279
 
6131
6280
  hub, problem = self._ensure_hub(pr, task_ref=task.ref if task else None)
6132
6281
  if problem is not None:
6133
- return Decision(Action.SKIPPED, problem, round_)
6282
+ # Owed (issue #157): the round is owed and the hub could not be
6283
+ # provisioned -- unknown, never settled.
6284
+ return Decision(Action.SKIPPED, problem, round_, owed=True)
6134
6285
 
6135
6286
  self.alissa.enqueue_reviewer(
6136
6287
  session=name,
@@ -7504,7 +7655,9 @@ class ReviewWatcher:
7504
7655
  raise
7505
7656
  except CommandError as exc:
7506
7657
  log.error("%s: %s", slug, exc)
7507
- decision = Decision(Action.SKIPPED, str(exc))
7658
+ # Owed (issue #157): an evaluation that failed is unknown,
7659
+ # never settled.
7660
+ decision = Decision(Action.SKIPPED, str(exc), owed=True)
7508
7661
 
7509
7662
  level = logging.INFO if decision.action != Action.SKIPPED else logging.DEBUG
7510
7663
  log.log(level, "%s → %s (%s)", slug, decision.action.value, decision.reason)
@@ -7668,6 +7821,10 @@ class ReviewWatcher:
7668
7821
  # Same column again; its own stage because the operator's remedy
7669
7822
  # is different from both: the reviewer ACCOUNT (issue #138).
7670
7823
  stage = "prompt-held"
7824
+ elif decision.drained:
7825
+ # Same column once more; its own stage because nothing is wrong:
7826
+ # the seat is being parked (issue #157).
7827
+ stage = "drained"
7671
7828
  return {
7672
7829
  "slug": slug,
7673
7830
  "number": int(tail),
@@ -7677,6 +7834,14 @@ class ReviewWatcher:
7677
7834
  "stage": stage,
7678
7835
  "reason": decision.reason,
7679
7836
  "task_ref": decision.task_ref,
7837
+ # The seat-parking verdict (issue #157): read back by the idle
7838
+ # block (`parking.idle_block`), so the block costs no GitHub call.
7839
+ "owed": decision_owed(decision),
7840
+ "timer": (
7841
+ {"kind": decision.timer[0], "until": decision.timer[1]}
7842
+ if decision.timer is not None
7843
+ else None
7844
+ ),
7680
7845
  }
7681
7846
 
7682
7847
  def _write_snapshot(
@@ -7744,6 +7909,7 @@ class ReviewWatcher:
7744
7909
  # SystemExit pass through (neither is an `Exception`), and only startup
7745
7910
  # -- resolve_config, before this loop is ever entered -- still exits
7746
7911
  # fast.
7912
+ self._reset_parking()
7747
7913
  backoff = self.config.poll_interval
7748
7914
  failures = PollFailures()
7749
7915
  while True:
@@ -7789,6 +7955,29 @@ class ReviewWatcher:
7789
7955
  log.info("stopping")
7790
7956
  return
7791
7957
 
7958
+ def _reset_parking(self) -> None:
7959
+ """The daemon's boot clears the drain flag and every spawn claim
7960
+ (seat-parking design §2.4, issue #157): a drain -- and any claim a
7961
+ dead process left behind -- never outlives the daemon that saw it, so
7962
+ a redeploy (the wake) or a restart starts with the flag down.
7963
+
7964
+ Here, at the top of `run_forever`, and not in the constructor: the
7965
+ one-shot modes (`--once`, `--pr`) build a watcher too, and a hand-run
7966
+ diagnostic must not lift a drain orcloop is holding on the running
7967
+ daemon. Never under --dry-run, which shares the production state path
7968
+ and writes no durable state. A ledger that cannot be written leaves
7969
+ the flag to its TTL, and says so."""
7970
+ if self.config.dry_run:
7971
+ return
7972
+ try:
7973
+ self.state.reset_parking()
7974
+ except sqlite3.Error as exc:
7975
+ log.warning(
7976
+ "parking: could not clear the drain flag at boot (%s) — a "
7977
+ "drain left by the previous process holds until its TTL runs "
7978
+ "out", exc,
7979
+ )
7980
+
7792
7981
  def _note_poll_failure(
7793
7982
  self, failures: PollFailures, exc: Exception, backoff: int
7794
7983
  ) -> None:
@@ -0,0 +1,222 @@
1
+ """Seat parking -- the reviewer seat's own idle verdict and the drain it
2
+ enforces (studio.alissa.app docs/design/managed-dark-factory-seat-parking.md
3
+ §2.1, §2.2, §2.4; issue #157, lane L5 -- devloop's L4 contract, for rounds).
4
+
5
+ orcloop parks this seat (removes its Railway deployment) only when the seat
6
+ itself says there is nothing to do, and wakes it on demand. This module holds
7
+ the revloop half of that contract, shared by the daemon (which records the
8
+ verdict per pass on its poll snapshot and pushes it with the fleet vitals) and
9
+ the console (which serves it on ``GET /api/state`` and runs the drain
10
+ handshake):
11
+
12
+ * **The idle block.** ``{asOf, passAt, owed, live, managed, queued, timers,
13
+ drainedAt}``. `owed` counts the PRs whose LAST evaluation was an action or
14
+ a clock the seat owns: an owed round (spawned, queued for a slot, held for
15
+ the head's checks, held by the drain), a round in flight inside its stale
16
+ window, a verdict still to be posted natively, an escalation posted this
17
+ pass, the post-verdict cooldown, an evaluation that failed. A PR the seat
18
+ has settled -- converged, capped, already stability-held, waiting on a
19
+ fresh re-request, out of scope -- is not owed. Each pass records the
20
+ verdict per PR on its poll snapshot's stage (`owed`, `timer`), so the block
21
+ is read back from the snapshot and costs no GitHub call. `live` / `managed`
22
+ / `queued` are read from tmux and the alissa queue by whoever builds the
23
+ block.
24
+ * **Unknown is never idle.** No snapshot yet -> `owed` is null; a listing
25
+ that failed -> `live` / `managed` / `queued` are null. `idle_refusals`
26
+ reads every null as a refusal.
27
+ * **The drain.** A flag with a TTL in `state.db` (`State.set_drain`), cleared
28
+ when the daemon boots. While it holds, the round spawn stands down before
29
+ its first side effect (`ReviewWatcher._drain_gated`).
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from collections.abc import Iterable, Mapping, Sequence
35
+ from datetime import datetime, timezone
36
+
37
+ # The timer kinds of the design's idle block (§2.1), the union both seats
38
+ # share. revloop emits two: `stale_window` (a round in flight inside its stale
39
+ # window, the post-verdict cooldown, a native post's grace or back-off) and
40
+ # `checks_hold` (a round, or an approve, waiting on the head's CI rollup). A
41
+ # stability hold carries no clock -- it waits on an operator's ack, a comment
42
+ # that wakes a parked seat by itself -- so `stability_hold` is never emitted.
43
+ TIMER_STALE_WINDOW = "stale_window"
44
+ TIMER_CHECKS_HOLD = "checks_hold"
45
+ TIMER_KINDS = (
46
+ TIMER_STALE_WINDOW, "respawn", "limited_hold", "stability_hold",
47
+ TIMER_CHECKS_HOLD,
48
+ )
49
+
50
+ # The drain's TTL: the design's handshake asks for 300 s. The ceiling bounds a
51
+ # drain nobody undrains -- a seat that refuses every spawn is an outage, so no
52
+ # caller may hold it for longer than this without asking again.
53
+ DRAIN_TTL_DEFAULT = 300
54
+ DRAIN_TTL_MAX = 900
55
+ DRAIN_REASON_MAX = 200
56
+
57
+ # A spawn claim older than this is a spawn whose daemon died between its drain
58
+ # gate and its ledger write; it stops refusing drains (the boot clears it).
59
+ SPAWN_CLAIM_TTL = 600
60
+
61
+ # D2's freshness rule: the seat's last pass is within this many poll
62
+ # intervals, or its loop is wedged -- stalled, not idle.
63
+ FRESH_PASS_INTERVALS = 2
64
+
65
+ # The stages that are owed when the stage carries no verdict of its own (a
66
+ # snapshot written before the field existed): every action but a convergence,
67
+ # a cap or a skip is the seat's own work in progress. The refined stage names
68
+ # `_stage_record` writes (`stale-re-enqueued`, `deferred`, `checks-held`,
69
+ # `prompt-held`, `drained`) are refinements of owed actions, so they are
70
+ # owed too.
71
+ OWED_STAGES = frozenset({
72
+ "spawned", "stale-re-enqueued", "in-flight", "deferred", "queued",
73
+ "checks-held", "prompt-held", "drained", "awaiting-post", "posted",
74
+ "abandoned", "escalated",
75
+ })
76
+
77
+
78
+ def iso(ts: "float | int | None") -> "str | None":
79
+ """A unix timestamp as an ISO-8601 UTC instant (whole seconds, `Z`)."""
80
+ if ts is None:
81
+ return None
82
+ return datetime.fromtimestamp(int(ts), tz=timezone.utc).strftime(
83
+ "%Y-%m-%dT%H:%M:%SZ"
84
+ )
85
+
86
+
87
+ def stage_owed(stage: Mapping) -> bool:
88
+ """Whether one poll-snapshot stage is owed: its own `owed` verdict, or,
89
+ on a stage written before the verdict existed, its stage's default."""
90
+ owed = stage.get("owed")
91
+ if isinstance(owed, bool):
92
+ return owed
93
+ return str(stage.get("stage") or "") in OWED_STAGES
94
+
95
+
96
+ def stage_timer(stage: Mapping) -> "dict | None":
97
+ """One stage's timer as the block's `{kind, until, ref}`, or None. `ref`
98
+ is the PR's slug (`owner/repo#16`)."""
99
+ timer = stage.get("timer")
100
+ if not isinstance(timer, Mapping):
101
+ return None
102
+ kind = timer.get("kind")
103
+ until = timer.get("until")
104
+ if (
105
+ kind not in TIMER_KINDS
106
+ or isinstance(until, bool)
107
+ or not isinstance(until, (int, float))
108
+ ):
109
+ return None
110
+ return {"kind": kind, "until": iso(until), "ref": stage.get("slug")}
111
+
112
+
113
+ def queued_items(queues: object) -> "int | None":
114
+ """How many items wait in the alissa tmux queue (`alissa tmux queue ls
115
+ --json`, every queue) and have not been dispatched yet -- a dispatched
116
+ item stays listed with its `lastDispatchedAt`, and its session is then
117
+ in the tmux listing instead. None when the listing is not a list (it
118
+ could not be read: unknown, not empty)."""
119
+ if not isinstance(queues, list):
120
+ return None
121
+ count = 0
122
+ for queue in queues:
123
+ if not isinstance(queue, Mapping):
124
+ continue
125
+ for item in queue.get("items") or ():
126
+ if isinstance(item, Mapping) and not item.get("lastDispatchedAt"):
127
+ count += 1
128
+ return count
129
+
130
+
131
+ def roster_counts(
132
+ rows: "Sequence[Mapping] | None",
133
+ ) -> "tuple[int | None, int | None]":
134
+ """`(live, managed)` from the console's session rows
135
+ (`Sources.session_rows`) -- the fleet-vitals `sessions` rule, so the idle
136
+ block and the snapshot's `sessions` can never disagree. `(None, None)`
137
+ when the roster could not be listed."""
138
+ if rows is None:
139
+ return None, None
140
+ return (
141
+ sum(1 for r in rows if r.get("live")),
142
+ sum(1 for r in rows if r.get("managed")),
143
+ )
144
+
145
+
146
+ def idle_block(
147
+ snapshot: "Mapping | None",
148
+ *,
149
+ live: "int | None",
150
+ managed: "int | None",
151
+ queued: "int | None",
152
+ drain: "Mapping | None",
153
+ now: "float | int",
154
+ ) -> dict:
155
+ """The seat's idle block from its latest poll snapshot (None before the
156
+ first pass) and the caller's live readings. `drain` is the holding drain
157
+ row (`State.drain`) or None."""
158
+ stages: "Iterable[Mapping]" = (snapshot or {}).get("stages") or ()
159
+ owed: "int | None" = None
160
+ timers: "list[dict]" = []
161
+ if snapshot is not None:
162
+ owed = 0
163
+ for stage in stages:
164
+ if stage_owed(stage):
165
+ owed += 1
166
+ timer = stage_timer(stage)
167
+ if timer is not None:
168
+ timers.append(timer)
169
+ timers.sort(key=lambda t: (t["until"] or "", t["ref"] or ""))
170
+ return {
171
+ "asOf": iso(now),
172
+ "passAt": iso(snapshot.get("ts")) if snapshot else None,
173
+ "owed": owed,
174
+ "live": live,
175
+ "managed": managed,
176
+ "queued": queued,
177
+ "timers": timers,
178
+ "drainedAt": iso(drain.get("drained_at")) if drain else None,
179
+ }
180
+
181
+
182
+ def _parse_iso(value: object) -> "float | None":
183
+ if not isinstance(value, str):
184
+ return None
185
+ try:
186
+ return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(
187
+ tzinfo=timezone.utc
188
+ ).timestamp()
189
+ except ValueError:
190
+ return None
191
+
192
+
193
+ def idle_refusals(
194
+ idle: Mapping, *, poll_interval: "float | int", now: "float | int",
195
+ ) -> "list[str]":
196
+ """D2's invariant over one idle block: the reasons the seat is NOT idle,
197
+ empty when it is. Every unknown (a null count, a missing pass) is a
198
+ refusal -- unknown is not idle."""
199
+ out: "list[str]" = []
200
+ for key in ("live", "managed", "queued"):
201
+ value = idle.get(key)
202
+ if value is None:
203
+ out.append(f"{key} unknown")
204
+ elif value:
205
+ out.append(f"{key}={value}")
206
+ owed = idle.get("owed")
207
+ if owed is None:
208
+ out.append("owed unknown (no completed pass)")
209
+ elif owed:
210
+ out.append(f"owed={owed}")
211
+ timers = idle.get("timers") or []
212
+ if timers:
213
+ out.append(f"{len(timers)} timer(s) running")
214
+ pass_at = _parse_iso(idle.get("passAt"))
215
+ if pass_at is None:
216
+ out.append("no completed pass")
217
+ elif now - pass_at > FRESH_PASS_INTERVALS * float(poll_interval):
218
+ out.append(
219
+ f"last pass {int(now - pass_at)}s ago (over "
220
+ f"{FRESH_PASS_INTERVALS} × {int(poll_interval)}s)"
221
+ )
222
+ return out
@@ -351,6 +351,29 @@ CREATE TABLE IF NOT EXISTS poll_snapshots (
351
351
  -- and never read by the daemon itself -- it is the console's window onto the
352
352
  -- effective allowlist (`/api/state` renders it beside `repos`), so it is
353
353
  -- telemetry-class: a write that fails costs a warning, not the refresh.
354
+ -- Seat parking (studio.alissa.app seat-parking design §2.4, issue #157).
355
+ -- `seat_drain` holds at most ONE row -- the drain orcloop asked the console
356
+ -- for, its TTL as an absolute `expires_at` -- and `spawn_claims` one row per
357
+ -- round spawn between its drain gate and its ledger write. The daemon clears
358
+ -- both when it boots (`reset_parking`), so neither outlives the process that
359
+ -- saw it: the design calls the flag "in memory", and the console and the
360
+ -- daemon are two processes whose only shared memory is this file. A drain and
361
+ -- a claim are each taken inside ONE `BEGIN IMMEDIATE` transaction that also
362
+ -- reads the other table, so sqlite's writer lock serialises them: either the
363
+ -- claim lands first and the drain sees it (and refuses), or the drain lands
364
+ -- first and the claim sees it (and the spawn stands down).
365
+ CREATE TABLE IF NOT EXISTS seat_drain (
366
+ id INTEGER PRIMARY KEY CHECK (id = 1),
367
+ drained_at INTEGER NOT NULL,
368
+ expires_at INTEGER NOT NULL,
369
+ reason TEXT NOT NULL DEFAULT ''
370
+ );
371
+
372
+ CREATE TABLE IF NOT EXISTS spawn_claims (
373
+ session TEXT NOT NULL PRIMARY KEY,
374
+ claimed_at INTEGER NOT NULL
375
+ );
376
+
354
377
  CREATE TABLE IF NOT EXISTS derived_repos (
355
378
  repo TEXT NOT NULL PRIMARY KEY,
356
379
  bow_id TEXT NOT NULL DEFAULT '',
@@ -2008,3 +2031,104 @@ class State:
2008
2031
  )
2009
2032
  self._db.commit()
2010
2033
  return True
2034
+
2035
+ # -- seat parking: the drain flag and the spawn claims (issue #157) ------
2036
+
2037
+ def drain(self, now: "float | None" = None) -> "dict | None":
2038
+ """The drain that holds now -- `{drained_at, expires_at, reason}` --
2039
+ or None when there is none or its TTL has run out. An expired row is
2040
+ not deleted here (a read never writes); it simply stops holding."""
2041
+ at = int(time.time() if now is None else now)
2042
+ row = self._db.execute(
2043
+ "SELECT drained_at, expires_at, reason FROM seat_drain WHERE id=1"
2044
+ ).fetchone()
2045
+ if row is None or int(row["expires_at"]) <= at:
2046
+ return None
2047
+ return dict(row)
2048
+
2049
+ def set_drain(
2050
+ self, ttl: int, reason: str, now: "float | None" = None,
2051
+ claim_ttl: int = 600,
2052
+ ) -> "tuple[dict, list[dict]]":
2053
+ """Raise the drain flag for `ttl` seconds and return it with the spawn
2054
+ claims standing at that instant (younger than `claim_ttl` -- an older
2055
+ one is a spawn whose process died mid-way, cleared at the next boot).
2056
+ ONE immediate transaction, so a spawn either claimed before this (and
2057
+ is in the list) or claims after it (and sees the flag). A drain that
2058
+ is already up is replaced: a re-drain restarts the TTL. Raises
2059
+ sqlite3.Error -- the caller answers it as a drain that did not
2060
+ happen."""
2061
+ at = int(time.time() if now is None else now)
2062
+ self._db.commit()
2063
+ self._db.execute("BEGIN IMMEDIATE")
2064
+ try:
2065
+ self._db.execute(
2066
+ "INSERT OR REPLACE INTO seat_drain "
2067
+ "(id, drained_at, expires_at, reason) VALUES (1,?,?,?)",
2068
+ (at, at + int(ttl), reason),
2069
+ )
2070
+ claims = [
2071
+ dict(row) for row in self._db.execute(
2072
+ "SELECT session, claimed_at FROM spawn_claims "
2073
+ "WHERE claimed_at > ? ORDER BY claimed_at",
2074
+ (at - int(claim_ttl),),
2075
+ ).fetchall()
2076
+ ]
2077
+ self._db.commit()
2078
+ except sqlite3.Error:
2079
+ self._db.rollback()
2080
+ raise
2081
+ drain = {"drained_at": at, "expires_at": at + int(ttl), "reason": reason}
2082
+ return drain, claims
2083
+
2084
+ def clear_drain(self) -> bool:
2085
+ """Lower the drain flag. True when a row was there to clear (expired
2086
+ or not). Raises sqlite3.Error."""
2087
+ cur = self._db.execute("DELETE FROM seat_drain")
2088
+ self._db.commit()
2089
+ return cur.rowcount > 0
2090
+
2091
+ def claim_spawn(self, session: str, now: "float | None" = None) -> "dict | None":
2092
+ """Claim the right to spawn `session`: None when the claim landed,
2093
+ else the drain that refused it. The read of the flag and the claim
2094
+ are one immediate transaction (see `set_drain`). Raises sqlite3.Error:
2095
+ a spawn that cannot prove the seat is not drained does not happen."""
2096
+ at = int(time.time() if now is None else now)
2097
+ self._db.commit()
2098
+ self._db.execute("BEGIN IMMEDIATE")
2099
+ try:
2100
+ row = self._db.execute(
2101
+ "SELECT drained_at, expires_at, reason FROM seat_drain "
2102
+ "WHERE id=1 AND expires_at > ?",
2103
+ (at,),
2104
+ ).fetchone()
2105
+ if row is not None:
2106
+ self._db.rollback()
2107
+ return dict(row)
2108
+ self._db.execute(
2109
+ "INSERT OR REPLACE INTO spawn_claims (session, claimed_at) "
2110
+ "VALUES (?,?)",
2111
+ (session, at),
2112
+ )
2113
+ self._db.commit()
2114
+ except sqlite3.Error:
2115
+ self._db.rollback()
2116
+ raise
2117
+ return None
2118
+
2119
+ def release_spawn(self, session: str) -> None:
2120
+ """Drop `session`'s spawn claim once its spawn has landed or stood
2121
+ down. Best-effort: a claim that cannot be dropped only makes a drain
2122
+ refuse until it ages out (or the next boot clears it)."""
2123
+ def write() -> None:
2124
+ self._db.execute("DELETE FROM spawn_claims WHERE session=?", (session,))
2125
+ self._db.commit()
2126
+
2127
+ self._write_telemetry(write, "release_spawn")
2128
+
2129
+ def reset_parking(self) -> None:
2130
+ """Boot: no drain and no spawn claim survives a daemon restart (the
2131
+ design's "clears at boot"). Raises sqlite3.Error."""
2132
+ self._db.execute("DELETE FROM seat_drain")
2133
+ self._db.execute("DELETE FROM spawn_claims")
2134
+ self._db.commit()
@@ -13,8 +13,11 @@ import argparse
13
13
  import os
14
14
  import sys
15
15
  from pathlib import Path
16
+ from typing import Callable
16
17
 
18
+ from ..alissa_client import AlissaClient
17
19
  from ..config import Config, load_config_file, resolve_config_path
20
+ from ..fleet_vitals import FleetVitalsPusher
18
21
  from ..version import version
19
22
  from .auth import Auth, PasscodeUnset, require_passcode
20
23
  from .server import App, make_server
@@ -77,6 +80,22 @@ def resolve_config(args: argparse.Namespace) -> Config:
77
80
  return Config.build(workspace_root, file_data, overrides)
78
81
 
79
82
 
83
+ def _drain_pusher(
84
+ config: Config, sources: Sources,
85
+ ) -> "Callable[[], str] | None":
86
+ """The drain handshake's vitals push (seat parking, issue #157): the
87
+ daemon's own pusher, built on the console's Sources, when the push is on
88
+ and the config is not a dry run (a dry daemon pushes nothing either).
89
+ The token is the CLI's own `ALISSA_API_TOKEN`, read by `AlissaClient`,
90
+ exactly as the daemon's pusher reads it."""
91
+ if not config.fleet_vitals_enabled or config.dry_run:
92
+ return None
93
+ pusher = FleetVitalsPusher(
94
+ config, sources, AlissaClient(base=config.alissa_endpoint)
95
+ )
96
+ return pusher.push_now
97
+
98
+
80
99
  def main(argv: "list[str] | None" = None) -> int:
81
100
  args = build_parser().parse_args(argv)
82
101
 
@@ -106,7 +125,7 @@ def main(argv: "list[str] | None" = None) -> int:
106
125
  secure_cookie = _env_flag(os.environ.get("ALISSA_UI_SECURE_COOKIE"))
107
126
  app = App(
108
127
  auth=auth, sources=sources, version=version.value,
109
- secure_cookie=secure_cookie,
128
+ secure_cookie=secure_cookie, push_vitals=_drain_pusher(config, sources),
110
129
  )
111
130
  server = make_server(app, args.host, args.port)
112
131
 
@@ -20,6 +20,12 @@ Route map:
20
20
  (accept | decline | escape; session + CSRF required;
21
21
  409 not_waiting when the pane is not parked on one,
22
22
  409 not_answerable for an account-level notice)
23
+ POST /action/drain -> seat parking's drain handshake (issue #157): raise
24
+ the drain flag for `ttlS` seconds, answer the idle
25
+ block, and keep the flag only if the seat is idle --
26
+ then push a vitals snapshot BEFORE answering
27
+ (session + CSRF required)
28
+ POST /action/undrain -> lower the drain flag (session + CSRF required)
23
29
 
24
30
  Every action POST is gated on BOTH the signed session cookie AND a CSRF token
25
31
  bound to it, and every action is audit-logged to stdout as a JSON line.
@@ -29,14 +35,16 @@ from __future__ import annotations
29
35
 
30
36
  import json
31
37
  import re
38
+ import sqlite3
32
39
  import sys
40
+ import time
33
41
  from http import HTTPStatus
34
42
  from http.cookies import SimpleCookie
35
43
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
36
44
  from typing import Any, Callable
37
45
  from urllib.parse import parse_qs
38
46
 
39
- from .. import prompts
47
+ from .. import parking, prompts
40
48
  from ..alissa import SAFE_SESSION, capture_pane_argv, send_keys_argv
41
49
  from ..proc import CommandError, run as proc_run
42
50
  from .auth import SESSION_COOKIE, Auth
@@ -105,12 +113,18 @@ class App:
105
113
  run: "Callable[..., str]" = proc_run,
106
114
  audit: "Callable[[str, dict], None]" = _default_audit,
107
115
  secure_cookie: bool = False,
116
+ push_vitals: "Callable[[], str] | None" = None,
117
+ clock: "Callable[[], float]" = time.time,
108
118
  ) -> None:
109
119
  self.auth = auth
110
120
  self.sources = sources
111
121
  self.version = version
112
122
  self._run = run
113
123
  self._audit = audit
124
+ # The drain's vitals push (`FleetVitalsPusher.push_now`), or None when
125
+ # the push is off (issue #157).
126
+ self._push_vitals = push_vitals
127
+ self._clock = clock
114
128
  # Add `Secure` to the session cookie. Off by default (the localhost-HTTP
115
129
  # posture, where Secure would break the cookie), switched on for the
116
130
  # reverse-proxied-under-TLS posture auth.py contemplates so the cookie
@@ -236,6 +250,91 @@ class App:
236
250
  return 200, {"ok": True, "message": "answered", "kind": finding.kind,
237
251
  "keys": list(keys)}
238
252
 
253
+ def drain(self, ttl_s: Any = None, reason: Any = None) -> "tuple[int, dict]":
254
+ """`POST /action/drain` (seat-parking design §2.4, issue #157): the
255
+ first step of a park. (status, payload); every outcome is audited.
256
+
257
+ Raises the drain flag for `ttl_s` seconds (default 300, at most
258
+ parking.DRAIN_TTL_MAX) and only THEN reads the idle block -- so a
259
+ round spawn that slips in before the flag is either a claim the drain
260
+ sees or a session (or an owed stage) the block counts. When the block
261
+ breaks D2 (a live, managed or queued session, anything owed, a timer,
262
+ a stale pass, an unknown) or a spawn holds a claim, the flag comes
263
+ straight back down and the answer is `drained: false` with the
264
+ reasons. When the drain holds, a vitals snapshot carrying
265
+ `idle.drainedAt` is pushed BEFORE the answer, because that snapshot
266
+ is what Studio re-checks before it removes anything; the answer says
267
+ how the push went."""
268
+ if ttl_s is None:
269
+ ttl = parking.DRAIN_TTL_DEFAULT
270
+ elif (
271
+ isinstance(ttl_s, int) and not isinstance(ttl_s, bool)
272
+ and 0 < ttl_s <= parking.DRAIN_TTL_MAX
273
+ ):
274
+ ttl = ttl_s
275
+ else:
276
+ self._audit("drain", {"ok": False, "error": "bad ttlS", "ttlS": ttl_s})
277
+ return 400, {"drained": False, "error": (
278
+ f"ttlS must be an integer in 1..{parking.DRAIN_TTL_MAX}"
279
+ )}
280
+ if reason is None:
281
+ reason = ""
282
+ if not isinstance(reason, str) or len(reason) > parking.DRAIN_REASON_MAX:
283
+ self._audit("drain", {"ok": False, "error": "bad reason"})
284
+ return 400, {"drained": False, "error": (
285
+ f"reason must be a string of at most "
286
+ f"{parking.DRAIN_REASON_MAX} characters"
287
+ )}
288
+ try:
289
+ drain, claims = self.sources.set_drain(ttl, reason)
290
+ except sqlite3.Error as exc:
291
+ self._audit("drain", {"ok": False, "error": str(exc)})
292
+ return 503, {"drained": False, "error": "state_unavailable"}
293
+ idle = self.sources.idle()
294
+ refusals = parking.idle_refusals(
295
+ idle, poll_interval=self.sources.config.poll_interval,
296
+ now=self._clock(),
297
+ )
298
+ refusals += [f"spawn in progress: {c['session']}" for c in claims]
299
+ if idle.get("drainedAt") is None:
300
+ refusals.append("the drain flag could not be read back")
301
+ if refusals:
302
+ try:
303
+ self.sources.clear_drain()
304
+ except sqlite3.Error as exc:
305
+ # The TTL still bounds it, and the spawn gate reads the flag
306
+ # strictly; say so rather than pretend it is down.
307
+ refusals.append(f"the flag could not be cleared ({exc})")
308
+ else:
309
+ idle = dict(idle, drainedAt=None)
310
+ self._audit("drain", {
311
+ "ok": False, "ttlS": ttl, "reason": reason, "refusals": refusals,
312
+ })
313
+ return 200, {"drained": False, "refusals": refusals, "idle": idle}
314
+ vitals = self._push_vitals() if self._push_vitals is not None else "skipped"
315
+ self._audit("drain", {
316
+ "ok": True, "ttlS": ttl, "reason": reason, "vitals": vitals,
317
+ })
318
+ return 200, {
319
+ "drained": True,
320
+ "drainedAt": parking.iso(drain["drained_at"]),
321
+ "expiresAt": parking.iso(drain["expires_at"]),
322
+ "idle": idle,
323
+ "vitals": vitals,
324
+ }
325
+
326
+ def undrain(self) -> "tuple[int, dict]":
327
+ """`POST /action/undrain`: lower the drain flag -- orcloop's step 4
328
+ on any failure of a park. Idempotent: undraining a seat that is not
329
+ drained answers `cleared: false`. (status, payload), audited."""
330
+ try:
331
+ cleared = self.sources.clear_drain()
332
+ except sqlite3.Error as exc:
333
+ self._audit("undrain", {"ok": False, "error": str(exc)})
334
+ return 503, {"drained": True, "error": "state_unavailable"}
335
+ self._audit("undrain", {"ok": True, "cleared": cleared})
336
+ return 200, {"drained": False, "cleared": cleared}
337
+
239
338
  def retry(
240
339
  self, repo_slug: "str | None", number: Any, round_: Any
241
340
  ) -> "tuple[bool, str]":
@@ -388,7 +487,10 @@ class Handler(BaseHTTPRequestHandler):
388
487
  )
389
488
  return
390
489
  # Everything below is a state-changing action: session + CSRF required.
391
- if path in ("/action/kill", "/action/retry", "/action/answer"):
490
+ if path in (
491
+ "/action/kill", "/action/retry", "/action/answer",
492
+ "/action/drain", "/action/undrain",
493
+ ):
392
494
  if not self._authed():
393
495
  self._send_json({"error": "unauthorized"},
394
496
  status=HTTPStatus.UNAUTHORIZED)
@@ -397,6 +499,16 @@ class Handler(BaseHTTPRequestHandler):
397
499
  self._send_json({"error": "csrf"}, status=HTTPStatus.FORBIDDEN)
398
500
  return
399
501
  payload = self._json_body()
502
+ if path == "/action/drain":
503
+ status, body = self.app.drain(
504
+ payload.get("ttlS"), payload.get("reason")
505
+ )
506
+ self._send_json(body, status=status)
507
+ return
508
+ if path == "/action/undrain":
509
+ status, body = self.app.undrain()
510
+ self._send_json(body, status=status)
511
+ return
400
512
  if path == "/action/answer":
401
513
  status, body = self.app.answer(
402
514
  payload.get("session"), payload.get("verb")
@@ -1,4 +1,5 @@
1
- """The console's read-only data layer (plus the one retry-now UPDATE).
1
+ """The console's read-only data layer (plus the retry-now UPDATE and the
2
+ seat-parking drain flag).
2
3
 
3
4
  Every panel the dashboard renders is assembled here from four kinds of source,
4
5
  in strict budget order:
@@ -36,7 +37,13 @@ page) instead of quietly conjuring an empty state.db that renders exactly like
36
37
  an idle daemon -- the one distinction the console exists to make, and easy to
37
38
  get wrong because `--workspace-root` defaults to the cwd.
38
39
 
39
- The single mutation is `retry_now`: it ages the newest ledger row of one round
40
+ Seat parking (issue #157) adds the idle block (`idle`, the seat's own verdict
41
+ for orcloop's park rule) and the drain flag's two writes, `set_drain` /
42
+ `clear_drain`, which the console's `/action/drain` and `/action/undrain` call.
43
+ Unlike every read here those RAISE on a ledger they cannot reach: a drain that
44
+ did not happen must never be answered as one that did.
45
+
46
+ The other mutation is `retry_now`: it ages the newest ledger row of one round
40
47
  past the stale window (an UPDATE, via `State.age_out_spawn`), so the daemon's
41
48
  own re-enqueue path can respawn it on its next pass. No new retry logic lives
42
49
  here -- the console only moves a clock the daemon already reads. It reports a
@@ -54,6 +61,7 @@ import urllib.request
54
61
  from pathlib import Path
55
62
  from typing import Callable
56
63
 
64
+ from .. import parking
57
65
  from ..alissa import REVIEW_SESSION_PREFIX
58
66
  from ..config import Config
59
67
  from ..loop import (
@@ -613,6 +621,66 @@ class Sources:
613
621
  tail = text.splitlines()[-lines:]
614
622
  return {"path": str(path), "lines": tail}
615
623
 
624
+ # -- seat parking (issue #157) -----------------------------------------
625
+
626
+ def queued(self) -> "int | None":
627
+ """Undispatched items in the alissa tmux queue (every queue on the
628
+ machine), or None when `alissa tmux queue ls --json` cannot be read
629
+ -- unknown, never zero."""
630
+ return parking.queued_items(
631
+ self._safe_json(["alissa", "tmux", "queue", "ls", "--json"])
632
+ )
633
+
634
+ def drain(self) -> "dict | None":
635
+ """The drain that holds now (`State.drain`), or None. A state.db
636
+ that cannot be read answers None: the console then shows no drain,
637
+ and the daemon's own gate reads the ledger strictly."""
638
+ now = self._wall()
639
+ return self._read_state(None, lambda st: st.drain(now))
640
+
641
+ def idle(self, rows: object = None, *, read_rows: bool = True) -> dict:
642
+ """The seat's idle block (`parking.idle_block`): owed and timers from
643
+ the newest poll snapshot, live / managed from the session rows (read
644
+ here unless the caller hands in the rows it already holds, None
645
+ meaning the roster could not be listed), queued from the alissa
646
+ queue, and `drainedAt` while a drain holds. A failed listing is null
647
+ counts, never zeros."""
648
+ if read_rows:
649
+ rows = self.session_rows()
650
+ snaps = self.snapshots(1)
651
+ live, managed = parking.roster_counts(
652
+ rows if isinstance(rows, list) else None
653
+ )
654
+ return parking.idle_block(
655
+ snaps[0] if snaps else None,
656
+ live=live, managed=managed, queued=self.queued(),
657
+ drain=self.drain(), now=self._wall(),
658
+ )
659
+
660
+ def set_drain(self, ttl: int, reason: str) -> "tuple[dict, list[dict]]":
661
+ """Raise the drain flag (`State.set_drain`, with the spawn claims
662
+ standing at that instant). Raises sqlite3.Error -- an absent state.db
663
+ included: a console with no daemon state has nothing to drain, and a
664
+ drain must never be the thing that creates one."""
665
+ if not self.state_present():
666
+ raise sqlite3.OperationalError(
667
+ f"no state.db at {self.config.state_db}"
668
+ )
669
+ with State(self.config.state_db) as st:
670
+ return st.set_drain(
671
+ ttl, reason, now=self._wall(),
672
+ claim_ttl=parking.SPAWN_CLAIM_TTL,
673
+ )
674
+
675
+ def clear_drain(self) -> bool:
676
+ """Lower the drain flag (`State.clear_drain`): True when a row was
677
+ cleared. Raises sqlite3.Error; an absent state.db has no flag up, so
678
+ it answers False."""
679
+ if not self.state_present():
680
+ return False
681
+ with State(self.config.state_db) as st:
682
+ return st.clear_drain()
683
+
616
684
  # -- the retry-now mutation --------------------------------------------
617
685
 
618
686
  def retry_now(self, repo_slug: str, number: int, round_: int) -> str:
@@ -662,7 +730,10 @@ class Sources:
662
730
  # missing from `stats` and its row shows "--" for CPU%/RSS for one
663
731
  # poll. Self-healing in ~10s, and cheaper than walking /proc twice.
664
732
  proc_index = sysinfo.build_index(self._proc_root)
665
- sessions = self.sessions(index=proc_index)
733
+ # One roster read feeds the session table and the idle block; the
734
+ # block needs the unlistable None the table degrades to [].
735
+ rows = self.session_rows(index=proc_index)
736
+ sessions = rows or []
666
737
  rate = self.rate_limit()
667
738
  disk = sysinfo.disk_usage(self.config.workspace_root)
668
739
  memory = sysinfo.cgroup_memory(self._cgroup_root)
@@ -752,6 +823,9 @@ class Sources:
752
823
  # IS resident, this is what names the holder.
753
824
  "top_procs": top_procs,
754
825
  "log": self.log_tail(),
826
+ # Seat parking (issue #157): the seat's own idle verdict, read by
827
+ # orcloop's park rule (design §2.1, §2.2).
828
+ "idle": self.idle(rows, read_rows=False),
755
829
  }
756
830
 
757
831
  def inbox(self) -> dict:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.10
3
+ Version: 0.31.11
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -14,6 +14,7 @@ src/main/alissa/tools/github/revloop/fleet_vitals.py
14
14
  src/main/alissa/tools/github/revloop/ghclient.py
15
15
  src/main/alissa/tools/github/revloop/loop.py
16
16
  src/main/alissa/tools/github/revloop/loop_events.py
17
+ src/main/alissa/tools/github/revloop/parking.py
17
18
  src/main/alissa/tools/github/revloop/proc.py
18
19
  src/main/alissa/tools/github/revloop/prompts.py
19
20
  src/main/alissa/tools/github/revloop/prreview.py