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.
- {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
- {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
- {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/loop.py +195 -6
- alissa_tools_github_revloop-0.31.11/src/main/alissa/tools/github/revloop/parking.py +222 -0
- {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/state.py +124 -0
- alissa_tools_github_revloop-0.31.11/src/main/alissa/tools/github/revloop/version +1 -0
- {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
- {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
- {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
- {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
- {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
- alissa_tools_github_revloop-0.31.10/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/README.md +0 -0
- {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/setup.py +0 -0
- {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
- {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
- {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
- {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/bows.py +0 -0
- {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/config.py +0 -0
- {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
- {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
- {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/prompts.py +0 -0
- {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/trust.py +0 -0
- {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.31.11
|
|
@@ -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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
@@ -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
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.31.10
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.11}/requirements.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|