alissa-tools-github-revloop 0.31.27__tar.gz → 0.31.29__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.27/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.29}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/alissa.py +10 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/fleet_vitals.py +57 -5
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/loop.py +58 -2
- alissa_tools_github_revloop-0.31.29/src/main/alissa/tools/github/revloop/self_upgrade.py +623 -0
- alissa_tools_github_revloop-0.31.29/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +1 -0
- alissa_tools_github_revloop-0.31.27/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/README.md +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/setup.py +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/bows.py +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/config.py +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/janitor.py +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/loop_events.py +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/parking.py +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/prompts.py +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/state.py +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/trust.py +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -1576,6 +1576,16 @@ class Alissa:
|
|
|
1576
1576
|
)
|
|
1577
1577
|
return sessions
|
|
1578
1578
|
|
|
1579
|
+
def list_queues(self) -> object:
|
|
1580
|
+
"""`alissa tmux queue ls --json`, parsed, or None when the CLI does
|
|
1581
|
+
not answer (`parking.queued_items` reads None as unknown, never
|
|
1582
|
+
empty). Read by the self-upgrade idle check only (issue #193)."""
|
|
1583
|
+
try:
|
|
1584
|
+
return run_json(["alissa", "tmux", "queue", "ls", "--json"], timeout=60)
|
|
1585
|
+
except (CommandError, OSError, ValueError) as exc:
|
|
1586
|
+
log.debug("could not list the alissa tmux queue (%s)", exc)
|
|
1587
|
+
return None
|
|
1588
|
+
|
|
1579
1589
|
def kill_session(self, session: str) -> None:
|
|
1580
1590
|
"""Kill ONE finished reviewer's managed session to free its worker slot.
|
|
1581
1591
|
|
|
@@ -65,6 +65,16 @@ out-of-pass push leaves it out (that process never resolves the feed
|
|
|
65
65
|
authority nor refreshes the allowlist, so its answer would be a different
|
|
66
66
|
one). A Studio whose strict schema predates the block gets the `idle`
|
|
67
67
|
treatment: resent without it, offered again after BLOCK_REPROBE_S.
|
|
68
|
+
|
|
69
|
+
Fleet auto-upgrade (issue #193, Studio AU-1) makes the ANSWER matter: every
|
|
70
|
+
answer carries an `upgrade` block (`self_upgrade.parse_upgrade`; absent on an
|
|
71
|
+
older Studio). The pusher keeps the last one read (`FleetVitalsPusher.upgrade`)
|
|
72
|
+
and hands each answer to the daemon's `SelfUpgrader`, which drains and
|
|
73
|
+
upgrades on it. The request side gains `upgradeFailed: {version, reason}`,
|
|
74
|
+
the seat's report that a version would not start and was rolled back: sent
|
|
75
|
+
once, on the next snapshot after the rollback, and treated like the other
|
|
76
|
+
optional blocks by a Studio that refuses it. The console's pusher has no
|
|
77
|
+
upgrader: it reads the answer and acts on nothing.
|
|
68
78
|
"""
|
|
69
79
|
|
|
70
80
|
from __future__ import annotations
|
|
@@ -78,6 +88,7 @@ from datetime import datetime, timezone
|
|
|
78
88
|
from typing import TYPE_CHECKING, Any, Callable, Mapping, Sequence
|
|
79
89
|
|
|
80
90
|
from . import parking
|
|
91
|
+
from . import self_upgrade as self_upgrade_mod
|
|
81
92
|
from .alissa import parse_session_name, session_repo_slug
|
|
82
93
|
from .alissa_client import (
|
|
83
94
|
NOT_ACTIVATED,
|
|
@@ -120,8 +131,9 @@ VITALS_FAILED = "failed"
|
|
|
120
131
|
# naming one of them is an old Studio, not a bad snapshot.
|
|
121
132
|
#
|
|
122
133
|
# `diskUsedPct` (issue #167) joins them: the workspace volume's used percent,
|
|
123
|
-
# which Studio's schema may not carry yet.
|
|
124
|
-
|
|
134
|
+
# which Studio's schema may not carry yet. `upgradeFailed` (issue #193, the
|
|
135
|
+
# fleet auto-upgrade report) joins them for the same reason.
|
|
136
|
+
OPTIONAL_BLOCKS = ("idle", "config", "diskUsedPct", "upgradeFailed")
|
|
125
137
|
|
|
126
138
|
# How long a snapshot leaves an optional block out after Studio refused it as
|
|
127
139
|
# an unknown key, before it offers the block again. An hour: one extra POST
|
|
@@ -501,6 +513,7 @@ class FleetVitalsPusher:
|
|
|
501
513
|
*,
|
|
502
514
|
clock: Callable[[], float] = time.time,
|
|
503
515
|
config_reader: "Callable[[], Mapping | None] | None" = None,
|
|
516
|
+
upgrader: "self_upgrade_mod.SelfUpgrader | None" = None,
|
|
504
517
|
):
|
|
505
518
|
self.config = config
|
|
506
519
|
self._sources = sources
|
|
@@ -517,6 +530,12 @@ class FleetVitalsPusher:
|
|
|
517
530
|
# Per optional block: until when it is left out (Studio refused it
|
|
518
531
|
# as an unknown key). Absent = offer it.
|
|
519
532
|
self._refused_until: "dict[str, float]" = {}
|
|
533
|
+
# The daemon's self-upgrade state (issue #193), or None -- the
|
|
534
|
+
# console's pusher -- to read the answer and act on nothing.
|
|
535
|
+
self.upgrader = upgrader
|
|
536
|
+
# The last `upgrade` block a vitals answer carried (issue #193), kept
|
|
537
|
+
# across answers that lack one.
|
|
538
|
+
self.upgrade: "self_upgrade_mod.UpgradeAnswer | None" = None
|
|
520
539
|
|
|
521
540
|
def build(self, *, heartbeat_at: "int | float", queue_depth: "int | None") -> dict:
|
|
522
541
|
"""This pass's contract body, fitted to the API's caps."""
|
|
@@ -568,6 +587,9 @@ class FleetVitalsPusher:
|
|
|
568
587
|
type(exc).__name__, exc,
|
|
569
588
|
)
|
|
570
589
|
return VITALS_FAILED
|
|
590
|
+
report = self._upgrade_report()
|
|
591
|
+
if report is not None:
|
|
592
|
+
snapshot["upgradeFailed"] = report
|
|
571
593
|
now = self._clock()
|
|
572
594
|
for key, until in self._refused_until.items():
|
|
573
595
|
if now < until:
|
|
@@ -588,6 +610,34 @@ class FleetVitalsPusher:
|
|
|
588
610
|
heartbeat = snaps[0]["ts"] if snaps else self._clock()
|
|
589
611
|
return self.push_once(heartbeat_at=heartbeat, queue_depth=None)
|
|
590
612
|
|
|
613
|
+
def _upgrade_report(self) -> "dict | None":
|
|
614
|
+
"""The `upgradeFailed` report owed to Studio, or None. A reader that
|
|
615
|
+
fails costs the report this pass, never the snapshot."""
|
|
616
|
+
if self.upgrader is None:
|
|
617
|
+
return None
|
|
618
|
+
try:
|
|
619
|
+
return self.upgrader.report()
|
|
620
|
+
except Exception as exc:
|
|
621
|
+
log.warning("fleet-vitals: could not read the upgrade report (%s)", exc)
|
|
622
|
+
return None
|
|
623
|
+
|
|
624
|
+
def _answered(self, answer: object, snapshot: "Mapping") -> None:
|
|
625
|
+
"""Read one successful answer: keep its `upgrade` block and hand it
|
|
626
|
+
to the upgrader, then mark a carried `upgradeFailed` delivered.
|
|
627
|
+
Never raises: the push already succeeded."""
|
|
628
|
+
try:
|
|
629
|
+
parsed = self_upgrade_mod.parse_upgrade(answer)
|
|
630
|
+
if parsed is not None:
|
|
631
|
+
self.upgrade = parsed
|
|
632
|
+
if self.upgrader is None:
|
|
633
|
+
return
|
|
634
|
+
self.upgrader.observe(answer)
|
|
635
|
+
sent = snapshot.get("upgradeFailed")
|
|
636
|
+
if isinstance(sent, Mapping):
|
|
637
|
+
self.upgrader.reported(sent)
|
|
638
|
+
except Exception as exc:
|
|
639
|
+
log.warning("fleet-vitals: could not read the upgrade answer (%s)", exc)
|
|
640
|
+
|
|
591
641
|
@staticmethod
|
|
592
642
|
def _refused_blocks(exc: AlissaError, snapshot: dict) -> "list[str]":
|
|
593
643
|
"""The optional blocks of `snapshot` a 400 names: a Studio whose
|
|
@@ -630,6 +680,7 @@ class FleetVitalsPusher:
|
|
|
630
680
|
return self._post(trimmed)
|
|
631
681
|
self._warn_failed(exc)
|
|
632
682
|
return VITALS_FAILED
|
|
683
|
+
self._answered(result, snapshot)
|
|
633
684
|
log.info(
|
|
634
685
|
"fleet-vitals: pushed (%s; replaced=%s)",
|
|
635
686
|
describe(snapshot), result.get("replaced"),
|
|
@@ -698,6 +749,7 @@ def build_pusher(
|
|
|
698
749
|
github: object = None,
|
|
699
750
|
endpoint: "str | None" = None,
|
|
700
751
|
config_reader: "Callable[[], Mapping | None] | None" = None,
|
|
752
|
+
upgrader: "self_upgrade_mod.SelfUpgrader | None" = None,
|
|
701
753
|
) -> FleetVitalsPusher:
|
|
702
754
|
"""The pusher the watcher wires in when `fleet_vitals_enabled` is on.
|
|
703
755
|
|
|
@@ -709,8 +761,8 @@ def build_pusher(
|
|
|
709
761
|
at construction, because a daemon must boot and poll whether or not
|
|
710
762
|
vitals can authenticate. The import is local because `sources` imports
|
|
711
763
|
`loop`, which imports this module. `config_reader` is the daemon's
|
|
712
|
-
`config` block source (issue #159)
|
|
713
|
-
without
|
|
764
|
+
`config` block source (issue #159), and `upgrader` its self-upgrade
|
|
765
|
+
state (issue #193); the console builds its own pusher without either.
|
|
714
766
|
"""
|
|
715
767
|
from .version import version
|
|
716
768
|
from .webui.sources import Sources
|
|
@@ -722,5 +774,5 @@ def build_pusher(
|
|
|
722
774
|
)
|
|
723
775
|
return FleetVitalsPusher(
|
|
724
776
|
config, sources, AlissaClient(base=endpoint),
|
|
725
|
-
config_reader=config_reader,
|
|
777
|
+
config_reader=config_reader, upgrader=upgrader,
|
|
726
778
|
)
|
|
@@ -27,6 +27,7 @@ from typing import Callable
|
|
|
27
27
|
from . import janitor as janitor_mod
|
|
28
28
|
from . import parking as parking_mod
|
|
29
29
|
from . import prompts as prompts_mod
|
|
30
|
+
from . import self_upgrade as self_upgrade_mod
|
|
30
31
|
from .alissa import (
|
|
31
32
|
COMMIT_READINESS_TRAILER_LABEL,
|
|
32
33
|
MANAGED_PREFIX,
|
|
@@ -113,6 +114,7 @@ from .loop_events import (
|
|
|
113
114
|
)
|
|
114
115
|
from .proc import CommandError
|
|
115
116
|
from .state import State
|
|
117
|
+
from .version import version as running_version
|
|
116
118
|
|
|
117
119
|
log = logging.getLogger(__name__)
|
|
118
120
|
|
|
@@ -2730,6 +2732,7 @@ class ReviewWatcher:
|
|
|
2730
2732
|
alissa: Alissa | None = None,
|
|
2731
2733
|
state: State | None = None,
|
|
2732
2734
|
bow_client: "AlissaClient | None" = None,
|
|
2735
|
+
self_upgrade: "self_upgrade_mod.SelfUpgrader | None" = None,
|
|
2733
2736
|
):
|
|
2734
2737
|
self.config = config
|
|
2735
2738
|
# The BOW-derived allowlist (`repos_source: "bows"`, issue #119), or
|
|
@@ -2766,10 +2769,18 @@ class ReviewWatcher:
|
|
|
2766
2769
|
# built, read or logged for the disabled default. The pusher owns
|
|
2767
2770
|
# its own in-process `Sources` (the console's data layer), so the
|
|
2768
2771
|
# console sidecar need not be running.
|
|
2772
|
+
#
|
|
2773
|
+
# Fleet auto-upgrade (issue #193): the drain and self-upgrade state,
|
|
2774
|
+
# fed by the vitals answer. Built always: it reads nothing until an
|
|
2775
|
+
# answer arrives, so a seat without vitals never drains on it.
|
|
2776
|
+
self.self_upgrade = self_upgrade or self_upgrade_mod.SelfUpgrader(
|
|
2777
|
+
running_version.value, dry_run=config.dry_run,
|
|
2778
|
+
)
|
|
2769
2779
|
self._fleet_vitals: FleetVitalsPusher | None = (
|
|
2770
2780
|
build_pusher(
|
|
2771
2781
|
config, github=self.github, endpoint=config.alissa_endpoint,
|
|
2772
2782
|
config_reader=self._vitals_config,
|
|
2783
|
+
upgrader=self.self_upgrade,
|
|
2773
2784
|
)
|
|
2774
2785
|
if config.fleet_vitals_enabled
|
|
2775
2786
|
else None
|
|
@@ -6745,8 +6756,26 @@ class ReviewWatcher:
|
|
|
6745
6756
|
"""Claim (or, `late`, re-claim) the spawn of `name` against the drain
|
|
6746
6757
|
flag: None when the spawn may proceed, else the QUEUED `drained`
|
|
6747
6758
|
decision that stands the round down. Dry-run reads the flag and
|
|
6748
|
-
claims nothing. See `_drain_gated`.
|
|
6759
|
+
claims nothing. See `_drain_gated`.
|
|
6760
|
+
|
|
6761
|
+
The fleet auto-upgrade hold (issue #193) is read FIRST, on both
|
|
6762
|
+
claims: while Studio's vitals answer says drain, or a self-hosted
|
|
6763
|
+
seat is draining to upgrade, the round stands down the same QUEUED
|
|
6764
|
+
`drained` way a parking drain does -- owed, no spawn row, no round
|
|
6765
|
+
burned. Running reviews are not touched. The upgrader logs the state
|
|
6766
|
+
change once; each held round is a DEBUG line here."""
|
|
6749
6767
|
where = " after its hub was provisioned" if late else ""
|
|
6768
|
+
hold = self.self_upgrade.hold()
|
|
6769
|
+
if hold is not None:
|
|
6770
|
+
detail = self.self_upgrade.hold_detail()
|
|
6771
|
+
log.debug("%s round %d: not spawning%s — %s", pr.slug, round_, where, detail)
|
|
6772
|
+
return Decision(
|
|
6773
|
+
Action.QUEUED,
|
|
6774
|
+
f"round {round_} held{where} — {detail}; running reviews finish "
|
|
6775
|
+
f"normally, retrying once the drain lifts",
|
|
6776
|
+
round_,
|
|
6777
|
+
drained=True,
|
|
6778
|
+
)
|
|
6750
6779
|
try:
|
|
6751
6780
|
if self.config.dry_run:
|
|
6752
6781
|
drain = self.state.drain()
|
|
@@ -8278,19 +8307,40 @@ class ReviewWatcher:
|
|
|
8278
8307
|
# heartbeat is the completion time of a pass whose exhaust is already
|
|
8279
8308
|
# written and whose events are already on their way.
|
|
8280
8309
|
vitals = self._push_fleet_vitals(completed_at=time.time())
|
|
8310
|
+
# The self-upgrade step (issue #193), right after the push that read
|
|
8311
|
+
# this pass's answer, on the sweep's own roster.
|
|
8312
|
+
upgrade = self._after_pass_upgrade()
|
|
8281
8313
|
# The workspace janitor (issue #167), last: it is the one part of the
|
|
8282
8314
|
# pass that is pure housekeeping, and it never raises.
|
|
8283
8315
|
self._run_janitor()
|
|
8284
8316
|
log.info(
|
|
8285
8317
|
"poll summary: %d candidate(s), %d reaped, %d parked on a prompt, "
|
|
8286
|
-
"%d answered%s, %d ms, vitals: %s",
|
|
8318
|
+
"%d answered%s, %d ms, vitals: %s%s",
|
|
8287
8319
|
len(results), reaped, len(self._prompt_sweep.waiting),
|
|
8288
8320
|
self._prompt_sweep.answered,
|
|
8289
8321
|
" (spawns HELD)" if self._prompt_sweep.hold else "",
|
|
8290
8322
|
duration_ms, vitals,
|
|
8323
|
+
f"; upgrade: {upgrade}" if upgrade != self_upgrade_mod.NONE else "",
|
|
8291
8324
|
)
|
|
8292
8325
|
return results
|
|
8293
8326
|
|
|
8327
|
+
def _after_pass_upgrade(self) -> str:
|
|
8328
|
+
"""The pass's self-upgrade step (issue #193): `exit` once the seat
|
|
8329
|
+
has been idle for two polls (no live reviewer session, no
|
|
8330
|
+
undispatched queue item) with a self-hosted upgrade wanted -- the
|
|
8331
|
+
request file is then written and `run_forever` returns -- `wait`
|
|
8332
|
+
while draining towards one, `none` otherwise. The roster is the reap
|
|
8333
|
+
sweep's (`_pass_roster`, None when it could not list: never idle).
|
|
8334
|
+
Never raises."""
|
|
8335
|
+
try:
|
|
8336
|
+
return self.self_upgrade.after_pass(
|
|
8337
|
+
self._pass_roster,
|
|
8338
|
+
queued=lambda: parking_mod.queued_items(self.alissa.list_queues()),
|
|
8339
|
+
)
|
|
8340
|
+
except Exception as exc:
|
|
8341
|
+
log.warning("self-upgrade: the after-pass check failed (%s)", exc)
|
|
8342
|
+
return self_upgrade_mod.NONE
|
|
8343
|
+
|
|
8294
8344
|
def _emit_loop_events(self) -> None:
|
|
8295
8345
|
"""Push this pass's loop telemetry to Studio, when enabled (issue
|
|
8296
8346
|
#112). Last thing in the pass, after the snapshot, so the batch sees
|
|
@@ -8768,6 +8818,12 @@ class ReviewWatcher:
|
|
|
8768
8818
|
self.poll_once()
|
|
8769
8819
|
backoff = self.config.poll_interval
|
|
8770
8820
|
self._note_poll_recovered(failures)
|
|
8821
|
+
if self.self_upgrade.exit_requested:
|
|
8822
|
+
# Issue #193: return, so the CLI exits 0 and the
|
|
8823
|
+
# container restarts; the entrypoint installs the
|
|
8824
|
+
# target at boot.
|
|
8825
|
+
log.info("stopping for the self-upgrade")
|
|
8826
|
+
return
|
|
8771
8827
|
except LedgerUnwritable:
|
|
8772
8828
|
# Already logged, streak-limited and escalating, by the
|
|
8773
8829
|
# gate itself. What matters HERE is what is NOT done: the
|
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
"""Self-upgrade -- the revloop half of fleet auto-upgrade (MDF M6, AU-6,
|
|
2
|
+
issue #193; Studio's contract: studio.alissa.app docs/design/
|
|
3
|
+
fleet-auto-upgrade.md, AU-1). A port of devloop's module (AU-4, devloop
|
|
4
|
+
issue #206) and orcloop's (AU-5): the three seats behave the same way, and
|
|
5
|
+
only what makes a seat busy differs (here: a live reviewer session).
|
|
6
|
+
|
|
7
|
+
Every ``POST /v1/loop/fleet-vitals`` answer carries an ``upgrade`` block::
|
|
8
|
+
|
|
9
|
+
{"mode": "managed" | "self_hosted",
|
|
10
|
+
"target": {"version", "digest"} | null,
|
|
11
|
+
"autoUpgrade": bool, "paused": bool, "drain": bool}
|
|
12
|
+
|
|
13
|
+
This module reads it and acts on it in four steps:
|
|
14
|
+
|
|
15
|
+
1. **Read.** `parse_upgrade` turns the answer into an `UpgradeAnswer`, or
|
|
16
|
+
None when the block is absent or malformed (an older Studio). The
|
|
17
|
+
`SelfUpgrader` keeps the last good answer in memory. An answer without
|
|
18
|
+
the block leaves the last one in place.
|
|
19
|
+
2. **Drain.** `SelfUpgrader.hold` names why no new reviewer session may start:
|
|
20
|
+
``drain`` is true (either mode), or a self-hosted seat has a target to
|
|
21
|
+
move to. A seat has a target to move to when `autoUpgrade` is on, the
|
|
22
|
+
pause switch is off, `target.version` is strictly newer than the running
|
|
23
|
+
version (numeric ``X.Y.Z``; an older or equal pin, or a version that does
|
|
24
|
+
not parse, is never a target), and that version is not in the skip
|
|
25
|
+
list. The loop reads the hold in its seat-drain gate
|
|
26
|
+
(`ReviewWatcher._claim_spawn`), so a held round is the same QUEUED
|
|
27
|
+
``drained`` stage a parking drain gives. Running sessions are never
|
|
28
|
+
touched. One INFO line per change of state, not per poll.
|
|
29
|
+
3. **Exit at idle.** Self-hosted only (a managed seat is swapped by the
|
|
30
|
+
backend, AU-2). After two consecutive polls with no live reviewer
|
|
31
|
+
session (`alissa.parse_session_name`) and no undispatched item in the
|
|
32
|
+
alissa tmux queue, `after_pass` writes the
|
|
33
|
+
request file (`REQUEST_FILE` under `upgrade_dir()`) and asks the daemon
|
|
34
|
+
to exit with code 0. The container's restart policy brings it back, and
|
|
35
|
+
the entrypoint installs the target at boot.
|
|
36
|
+
4. **Confirm.** The first vitals POST answered on the new version marks the
|
|
37
|
+
request confirmed. The entrypoint rolls back an unconfirmed upgrade. It
|
|
38
|
+
reinstalls `previous`, adds the target to the file's skip list and leaves
|
|
39
|
+
an ``upgradeFailed`` report. `report` hands that report to the next
|
|
40
|
+
snapshot, and `reported` marks it sent once a POST carrying it succeeds.
|
|
41
|
+
5. **Unserved.** A process that finds an unconfirmed request for a target
|
|
42
|
+
it does not run, written by this same version (``previous``), knows its
|
|
43
|
+
predecessor's exit produced no upgrade: the boot step never ran (a
|
|
44
|
+
non-container or non-root start, a boot block that failed before its
|
|
45
|
+
first write) or installed somewhere the daemon does not import from.
|
|
46
|
+
It skips the target and reports it, so the seat never loops drain →
|
|
47
|
+
exit → restart.
|
|
48
|
+
|
|
49
|
+
Never while paused, never in managed mode, never with a live session: those
|
|
50
|
+
three are checked here, at the moment of the exit, not only when the hold
|
|
51
|
+
starts.
|
|
52
|
+
|
|
53
|
+
The request file is shared with ``docker/claude/entrypoint.sh`` (the
|
|
54
|
+
`self_upgrade_boot` block). Both sides keep every key they do not own, so
|
|
55
|
+
neither can drop the other's state. Its keys:
|
|
56
|
+
|
|
57
|
+
* ``target`` -- ``{"version", "digest"}``, written by the daemon.
|
|
58
|
+
* ``previous`` -- the version the daemon ran when it asked.
|
|
59
|
+
* ``requestedAt`` -- ISO-8601 UTC.
|
|
60
|
+
* ``confirmed`` / ``confirmedAt`` -- written by the daemon after the first
|
|
61
|
+
answered poll on the target.
|
|
62
|
+
* ``boots`` / ``firstBootAt`` / ``firstBootTs`` / ``lastError`` -- the
|
|
63
|
+
entrypoint's attempt record for this target. ``firstBootTs`` is the epoch
|
|
64
|
+
seconds its 10-minute window reads; ``firstBootAt`` the same as ISO-8601.
|
|
65
|
+
* ``rollbackReason`` -- why the entrypoint rolled back, kept across the
|
|
66
|
+
retries of a ``rollback_failed`` reinstall.
|
|
67
|
+
* ``state`` -- ``requested`` (daemon), then ``installed``,
|
|
68
|
+
``install_failed``, ``rollback_failed`` (retried at the next boot),
|
|
69
|
+
``rolled_back`` or ``not_newer`` (a target not newer than the installed
|
|
70
|
+
version, never installed) (entrypoint), then ``confirmed`` or
|
|
71
|
+
``unserved`` (daemon). A ``not_newer`` request this version wrote is
|
|
72
|
+
unserved too: the daemon runs an older copy than the boot step sees.
|
|
73
|
+
* ``skip`` -- versions never to move to again. A newer target clears it.
|
|
74
|
+
* ``upgradeFailed`` -- ``{"version", "reason"}``, the report owed to Studio.
|
|
75
|
+
``upgradeFailedReported`` is set once a POST carried it.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
from __future__ import annotations
|
|
79
|
+
|
|
80
|
+
import json
|
|
81
|
+
import logging
|
|
82
|
+
import os
|
|
83
|
+
import re
|
|
84
|
+
import tempfile
|
|
85
|
+
import time
|
|
86
|
+
from dataclasses import dataclass
|
|
87
|
+
from datetime import datetime, timezone
|
|
88
|
+
from typing import Callable, Mapping, Sequence
|
|
89
|
+
|
|
90
|
+
from .alissa import parse_session_name
|
|
91
|
+
|
|
92
|
+
log = logging.getLogger(__name__)
|
|
93
|
+
|
|
94
|
+
SEAT = "revloop"
|
|
95
|
+
MANAGED = "managed"
|
|
96
|
+
SELF_HOSTED = "self_hosted"
|
|
97
|
+
MODES = (MANAGED, SELF_HOSTED)
|
|
98
|
+
|
|
99
|
+
# The shared request file. The directory follows ALISSA_UPGRADE_DIR so a
|
|
100
|
+
# test or a non-container run can point it elsewhere. The entrypoint reads
|
|
101
|
+
# the same variable.
|
|
102
|
+
UPGRADE_DIR_ENV = "ALISSA_UPGRADE_DIR"
|
|
103
|
+
DEFAULT_UPGRADE_DIR = "/workspace/.alissa-upgrade"
|
|
104
|
+
REQUEST_FILE = f"{SEAT}.json"
|
|
105
|
+
|
|
106
|
+
# How many consecutive polls with no live reviewer session count as idle.
|
|
107
|
+
IDLE_POLLS = 2
|
|
108
|
+
|
|
109
|
+
# The hold reasons `hold` returns, and the poll summary quotes.
|
|
110
|
+
HOLD_DRAIN = "drain"
|
|
111
|
+
HOLD_UPGRADE = "upgrade"
|
|
112
|
+
|
|
113
|
+
# The request states in which an upgrade is still on its way: the only ones
|
|
114
|
+
# the daemon confirms or calls unserved. Every other state is final, or a
|
|
115
|
+
# failure the entrypoint owns.
|
|
116
|
+
PENDING_STATES = ("requested", "installed")
|
|
117
|
+
|
|
118
|
+
# The states in which a request this version wrote left the seat where it
|
|
119
|
+
# was: still pending, or refused at boot as not newer than the installed
|
|
120
|
+
# version (the daemon imports an older copy than the root pass sees). Any
|
|
121
|
+
# of them on a later process means the exit did not move the seat.
|
|
122
|
+
UNSERVED_STATES = PENDING_STATES + ("not_newer",)
|
|
123
|
+
|
|
124
|
+
# `after_pass` outcomes.
|
|
125
|
+
EXIT = "exit"
|
|
126
|
+
WAIT = "wait"
|
|
127
|
+
NONE = "none"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def upgrade_dir(environ: "Mapping[str, str] | None" = None) -> str:
|
|
131
|
+
env = os.environ if environ is None else environ
|
|
132
|
+
return (env.get(UPGRADE_DIR_ENV) or "").strip() or DEFAULT_UPGRADE_DIR
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def iso(ts: float) -> str:
|
|
136
|
+
return datetime.fromtimestamp(int(ts), tz=timezone.utc).strftime(
|
|
137
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def version_key(version: str) -> "tuple[int, ...] | None":
|
|
142
|
+
"""`X.Y.Z` as a comparable tuple, or None for anything else."""
|
|
143
|
+
parts = str(version).strip().split(".")
|
|
144
|
+
if len(parts) != 3 or not all(p.isdigit() for p in parts):
|
|
145
|
+
return None
|
|
146
|
+
return tuple(int(p) for p in parts)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class UpgradeAnswer:
|
|
151
|
+
"""One vitals answer's ``upgrade`` block."""
|
|
152
|
+
|
|
153
|
+
mode: str
|
|
154
|
+
target_version: "str | None"
|
|
155
|
+
target_digest: "str | None"
|
|
156
|
+
auto_upgrade: bool
|
|
157
|
+
paused: bool
|
|
158
|
+
drain: bool
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def target(self) -> "dict | None":
|
|
162
|
+
if self.target_version is None:
|
|
163
|
+
return None
|
|
164
|
+
return {"version": self.target_version, "digest": self.target_digest}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def parse_upgrade(payload: object) -> "UpgradeAnswer | None":
|
|
168
|
+
"""The ``upgrade`` block of a vitals answer, or None.
|
|
169
|
+
|
|
170
|
+
None when the answer is not an object or carries no ``upgrade`` (a Studio
|
|
171
|
+
older than AU-1), or when the block is not one this daemon can act on
|
|
172
|
+
(an unknown `mode`, a non-boolean flag). A malformed block is never read
|
|
173
|
+
as a partial one: a guessed `paused: false` would let a seat upgrade
|
|
174
|
+
through the operator's switch. A `target` without a string version is
|
|
175
|
+
read as no target."""
|
|
176
|
+
if not isinstance(payload, Mapping):
|
|
177
|
+
return None
|
|
178
|
+
block = payload.get("upgrade")
|
|
179
|
+
if not isinstance(block, Mapping):
|
|
180
|
+
return None
|
|
181
|
+
mode = block.get("mode")
|
|
182
|
+
flags = [block.get(k) for k in ("autoUpgrade", "paused", "drain")]
|
|
183
|
+
if mode not in MODES or not all(isinstance(f, bool) for f in flags):
|
|
184
|
+
return None
|
|
185
|
+
version = digest = None
|
|
186
|
+
target = block.get("target")
|
|
187
|
+
if isinstance(target, Mapping):
|
|
188
|
+
raw = target.get("version")
|
|
189
|
+
if isinstance(raw, str) and raw.strip():
|
|
190
|
+
version = raw.strip()
|
|
191
|
+
raw_digest = target.get("digest")
|
|
192
|
+
digest = raw_digest if isinstance(raw_digest, str) else None
|
|
193
|
+
auto_upgrade, paused, drain = flags
|
|
194
|
+
return UpgradeAnswer(
|
|
195
|
+
mode=str(mode), target_version=version, target_digest=digest,
|
|
196
|
+
auto_upgrade=bool(auto_upgrade), paused=bool(paused), drain=bool(drain),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def live_reviewer_sessions(roster: "Sequence[object] | None") -> "list[str] | None":
|
|
201
|
+
"""The live reviewer sessions in the pass's roster, or None when the
|
|
202
|
+
listing failed (unknown is never idle).
|
|
203
|
+
|
|
204
|
+
The roster is the reap sweep's own (`ReviewWatcher._pass_roster`): the
|
|
205
|
+
``alissa tmux ls --live`` rows whose name parses as a reviewer session,
|
|
206
|
+
minus the ones the sweep just killed. Each entry is a `ManagedSession`
|
|
207
|
+
or a bare name. The reviewer grammar is re-checked here, so a session
|
|
208
|
+
another daemon owns in a shared container never holds this seat."""
|
|
209
|
+
if roster is None:
|
|
210
|
+
return None
|
|
211
|
+
names: "list[str]" = []
|
|
212
|
+
for entry in roster:
|
|
213
|
+
name = entry if isinstance(entry, str) else getattr(entry, "name", None)
|
|
214
|
+
if isinstance(name, str) and parse_session_name(name) is not None:
|
|
215
|
+
names.append(name)
|
|
216
|
+
return names
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# -- the request file ---------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def read_request(path: str) -> "dict | None":
|
|
223
|
+
"""The request file, or None when it is absent. An unreadable or
|
|
224
|
+
non-object file is None too, with a WARNING: the daemon must not crash
|
|
225
|
+
on a file the entrypoint half-wrote."""
|
|
226
|
+
try:
|
|
227
|
+
with open(path, encoding="utf-8") as fh:
|
|
228
|
+
data = json.load(fh)
|
|
229
|
+
except FileNotFoundError:
|
|
230
|
+
return None
|
|
231
|
+
except (OSError, ValueError) as exc:
|
|
232
|
+
log.warning("self-upgrade: could not read %s (%s) — ignoring it", path, exc)
|
|
233
|
+
return None
|
|
234
|
+
if not isinstance(data, dict):
|
|
235
|
+
log.warning("self-upgrade: %s is not a JSON object — ignoring it", path)
|
|
236
|
+
return None
|
|
237
|
+
return data
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def write_request(path: str, data: "Mapping") -> None:
|
|
241
|
+
"""Write the request file atomically (a temp file in the same directory,
|
|
242
|
+
then a rename), so the entrypoint never reads half a file. Raises
|
|
243
|
+
OSError."""
|
|
244
|
+
directory = os.path.dirname(path) or "."
|
|
245
|
+
os.makedirs(directory, exist_ok=True)
|
|
246
|
+
fd, tmp = tempfile.mkstemp(prefix=".revloop-", suffix=".json", dir=directory)
|
|
247
|
+
try:
|
|
248
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
249
|
+
json.dump(dict(data), fh, indent=2, sort_keys=True)
|
|
250
|
+
fh.write("\n")
|
|
251
|
+
os.replace(tmp, path)
|
|
252
|
+
except BaseException:
|
|
253
|
+
try:
|
|
254
|
+
os.unlink(tmp)
|
|
255
|
+
except OSError:
|
|
256
|
+
pass
|
|
257
|
+
raise
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _target_version(data: "Mapping") -> "str | None":
|
|
261
|
+
target = data.get("target")
|
|
262
|
+
version = target.get("version") if isinstance(target, Mapping) else None
|
|
263
|
+
return version if isinstance(version, str) else None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _count(queued: "Callable[[], int | None]") -> "int | None":
|
|
267
|
+
"""`queued()`, with a failure read as unknown (never idle)."""
|
|
268
|
+
try:
|
|
269
|
+
return queued()
|
|
270
|
+
except Exception as exc:
|
|
271
|
+
log.warning("self-upgrade: could not count the alissa queue (%s)", exc)
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def skip_list(data: "Mapping | None") -> "list[str]":
|
|
276
|
+
if not data:
|
|
277
|
+
return []
|
|
278
|
+
skip = data.get("skip")
|
|
279
|
+
if not isinstance(skip, list):
|
|
280
|
+
return []
|
|
281
|
+
return [str(v) for v in skip if isinstance(v, str)]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# -- the upgrader -------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class SelfUpgrader:
|
|
288
|
+
"""The daemon's self-upgrade state: the last answer, the hold, the idle
|
|
289
|
+
streak and the request file.
|
|
290
|
+
|
|
291
|
+
Every method is best-effort and never raises. The daemon's job is
|
|
292
|
+
spawning reviewer sessions, and an upgrade that cannot be done must
|
|
293
|
+
leave the seat running."""
|
|
294
|
+
|
|
295
|
+
def __init__(
|
|
296
|
+
self,
|
|
297
|
+
running: str,
|
|
298
|
+
*,
|
|
299
|
+
directory: "str | None" = None,
|
|
300
|
+
clock: "Callable[[], float]" = time.time,
|
|
301
|
+
dry_run: bool = False,
|
|
302
|
+
):
|
|
303
|
+
self.running = running
|
|
304
|
+
self.path = os.path.join(directory or upgrade_dir(), REQUEST_FILE)
|
|
305
|
+
self._clock = clock
|
|
306
|
+
self.dry_run = dry_run
|
|
307
|
+
self.answer: "UpgradeAnswer | None" = None
|
|
308
|
+
self._hold: "str | None" = None
|
|
309
|
+
self._idle_polls = 0
|
|
310
|
+
# Targets this process could not request (the file would not
|
|
311
|
+
# write): never held for again in this process, or the seat would
|
|
312
|
+
# drain for ever without ever exiting.
|
|
313
|
+
self._unwritable: "set[str]" = set()
|
|
314
|
+
self.exit_requested = False
|
|
315
|
+
|
|
316
|
+
# -- reading the answer --
|
|
317
|
+
|
|
318
|
+
def observe(self, payload: object) -> "UpgradeAnswer | None":
|
|
319
|
+
"""Read one vitals answer. Keeps the last good block (an answer
|
|
320
|
+
without one changes nothing), confirms a pending upgrade the first
|
|
321
|
+
time an answer arrives on the target version, and clears the skip
|
|
322
|
+
list when a newer target arrives. Returns the block read, or None."""
|
|
323
|
+
answer = parse_upgrade(payload)
|
|
324
|
+
if answer is None:
|
|
325
|
+
if payload is not None:
|
|
326
|
+
log.debug("self-upgrade: the vitals answer carries no upgrade block")
|
|
327
|
+
return None
|
|
328
|
+
self.answer = answer
|
|
329
|
+
try:
|
|
330
|
+
self._confirm()
|
|
331
|
+
self._unserved()
|
|
332
|
+
self._clear_skip(answer)
|
|
333
|
+
except OSError as exc:
|
|
334
|
+
log.warning("self-upgrade: could not update %s (%s)", self.path, exc)
|
|
335
|
+
self._update_hold()
|
|
336
|
+
return answer
|
|
337
|
+
|
|
338
|
+
def _confirm(self) -> None:
|
|
339
|
+
data = read_request(self.path)
|
|
340
|
+
if not data or data.get("confirmed"):
|
|
341
|
+
return
|
|
342
|
+
if data.get("state") not in PENDING_STATES:
|
|
343
|
+
# A failed or rolled-back target is never confirmed, even when
|
|
344
|
+
# a rollback that could not reinstall left the daemon on it.
|
|
345
|
+
return
|
|
346
|
+
version = _target_version(data)
|
|
347
|
+
if version != self.running or version in skip_list(data):
|
|
348
|
+
return
|
|
349
|
+
data["confirmed"] = True
|
|
350
|
+
data["confirmedAt"] = iso(self._clock())
|
|
351
|
+
data["state"] = "confirmed"
|
|
352
|
+
write_request(self.path, data)
|
|
353
|
+
log.info(
|
|
354
|
+
"self-upgrade: confirmed — this daemon runs %s and Studio answered "
|
|
355
|
+
"its first poll (upgraded from %s)", self.running,
|
|
356
|
+
data.get("previous") or "unknown",
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def _unserved(self) -> None:
|
|
360
|
+
"""Skip and report a target whose last request never moved this
|
|
361
|
+
seat: the file still holds an unconfirmed, pending request that
|
|
362
|
+
THIS version wrote (`previous == running`) for another version,
|
|
363
|
+
or a request of this version's that the boot step refused as
|
|
364
|
+
`not_newer`. Asking again would only repeat the drain, the exit and
|
|
365
|
+
the restart for ever. A file that cannot be written puts the target out of
|
|
366
|
+
reach for this process instead."""
|
|
367
|
+
data = read_request(self.path)
|
|
368
|
+
if not data or data.get("confirmed"):
|
|
369
|
+
return
|
|
370
|
+
state = data.get("state")
|
|
371
|
+
version = _target_version(data)
|
|
372
|
+
if state not in UNSERVED_STATES or not version or version == self.running:
|
|
373
|
+
return
|
|
374
|
+
if data.get("previous") != self.running:
|
|
375
|
+
return
|
|
376
|
+
skip = skip_list(data)
|
|
377
|
+
if version in skip:
|
|
378
|
+
return
|
|
379
|
+
if state == "requested":
|
|
380
|
+
reason = "the boot step never acted on the request (no install was tried)"
|
|
381
|
+
elif state == "not_newer":
|
|
382
|
+
reason = (
|
|
383
|
+
"the boot step refused it: not newer than the installed "
|
|
384
|
+
f"version, but the daemon still runs {self.running}"
|
|
385
|
+
)
|
|
386
|
+
else:
|
|
387
|
+
reason = f"installed at boot, but the daemon still runs {self.running}"
|
|
388
|
+
data.update({
|
|
389
|
+
"state": "unserved", "skip": skip + [version], "lastError": reason,
|
|
390
|
+
"upgradeFailed": {"version": version, "reason": reason},
|
|
391
|
+
"upgradeFailedReported": False,
|
|
392
|
+
})
|
|
393
|
+
try:
|
|
394
|
+
write_request(self.path, data)
|
|
395
|
+
except OSError as exc:
|
|
396
|
+
self._unwritable.add(version)
|
|
397
|
+
log.warning(
|
|
398
|
+
"self-upgrade: the request for %s was never served (%s) and "
|
|
399
|
+
"%s could not be updated (%s) — this process will not try %s "
|
|
400
|
+
"again", version, reason, self.path, exc, version,
|
|
401
|
+
)
|
|
402
|
+
return
|
|
403
|
+
log.warning(
|
|
404
|
+
"self-upgrade: the request for %s → %s was never served: %s — "
|
|
405
|
+
"%s is now on the skip list and reported to Studio; this seat "
|
|
406
|
+
"keeps running %s", self.running, version, reason, version,
|
|
407
|
+
self.running,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
def _clear_skip(self, answer: UpgradeAnswer) -> None:
|
|
411
|
+
version = answer.target_version
|
|
412
|
+
new = version_key(version or "")
|
|
413
|
+
if new is None:
|
|
414
|
+
return
|
|
415
|
+
data = read_request(self.path)
|
|
416
|
+
skip = skip_list(data)
|
|
417
|
+
if not skip or version in skip:
|
|
418
|
+
return
|
|
419
|
+
keys = [k for k in (version_key(v) for v in skip) if k is not None]
|
|
420
|
+
if keys and new <= max(keys):
|
|
421
|
+
return
|
|
422
|
+
assert data is not None
|
|
423
|
+
data["skip"] = []
|
|
424
|
+
write_request(self.path, data)
|
|
425
|
+
log.info(
|
|
426
|
+
"self-upgrade: target %s is newer than every skipped version "
|
|
427
|
+
"(%s) — skip list cleared", version, ", ".join(skip),
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
# -- the hold --
|
|
431
|
+
|
|
432
|
+
def _skipped(self, version: str) -> bool:
|
|
433
|
+
return version in skip_list(read_request(self.path))
|
|
434
|
+
|
|
435
|
+
def wanted(self) -> "str | None":
|
|
436
|
+
"""The version a self-hosted seat should move to now, or None."""
|
|
437
|
+
a = self.answer
|
|
438
|
+
if a is None or a.mode != SELF_HOSTED:
|
|
439
|
+
return None
|
|
440
|
+
if not a.auto_upgrade or a.paused:
|
|
441
|
+
return None
|
|
442
|
+
version = a.target_version
|
|
443
|
+
if not version:
|
|
444
|
+
return None
|
|
445
|
+
# Only a strictly newer target. A pin briefly older than what runs
|
|
446
|
+
# (a merge rolls the seat before the platform pin moves) must never
|
|
447
|
+
# drain and downgrade the seat. Unparseable on either side: never.
|
|
448
|
+
new, cur = version_key(version), version_key(self.running)
|
|
449
|
+
if new is None or cur is None or new <= cur:
|
|
450
|
+
return None
|
|
451
|
+
if version in self._unwritable or self._skipped(version):
|
|
452
|
+
return None
|
|
453
|
+
return version
|
|
454
|
+
|
|
455
|
+
def hold(self) -> "str | None":
|
|
456
|
+
"""Why no new reviewer session may start now (`HOLD_DRAIN`,
|
|
457
|
+
`HOLD_UPGRADE`), or None. Read by every spawn path."""
|
|
458
|
+
return self._hold
|
|
459
|
+
|
|
460
|
+
def hold_detail(self) -> str:
|
|
461
|
+
"""The hold in words, for a deferral's reason."""
|
|
462
|
+
if self._hold == HOLD_DRAIN:
|
|
463
|
+
return "Studio asked this seat to drain for a managed upgrade"
|
|
464
|
+
if self._hold == HOLD_UPGRADE:
|
|
465
|
+
return (
|
|
466
|
+
f"draining to self-upgrade {self.running} → "
|
|
467
|
+
f"{self.answer.target_version if self.answer else '?'}"
|
|
468
|
+
)
|
|
469
|
+
return ""
|
|
470
|
+
|
|
471
|
+
def _update_hold(self) -> None:
|
|
472
|
+
if self.answer is not None and self.answer.drain:
|
|
473
|
+
new = HOLD_DRAIN
|
|
474
|
+
elif self.wanted() is not None:
|
|
475
|
+
new = HOLD_UPGRADE
|
|
476
|
+
else:
|
|
477
|
+
new = None
|
|
478
|
+
if new == self._hold:
|
|
479
|
+
return
|
|
480
|
+
old, self._hold = self._hold, new
|
|
481
|
+
if new is None:
|
|
482
|
+
self._idle_polls = 0
|
|
483
|
+
log.info(
|
|
484
|
+
"self-upgrade: drain lifted (was: %s) — new reviewer sessions may "
|
|
485
|
+
"start again", old,
|
|
486
|
+
)
|
|
487
|
+
else:
|
|
488
|
+
log.info(
|
|
489
|
+
"self-upgrade: draining — %s; no new reviewer session starts, "
|
|
490
|
+
"running ones finish normally", self.hold_detail(),
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
# -- the exit --
|
|
494
|
+
|
|
495
|
+
def after_pass(
|
|
496
|
+
self,
|
|
497
|
+
roster: "Sequence[object] | None",
|
|
498
|
+
queued: "Callable[[], int | None] | None" = None,
|
|
499
|
+
) -> str:
|
|
500
|
+
"""Called once per completed pass, after the vitals push. Returns
|
|
501
|
+
EXIT when the daemon should exit now for the upgrade (the request is
|
|
502
|
+
written), WAIT while it is draining towards one, NONE otherwise.
|
|
503
|
+
|
|
504
|
+
`roster` is the pass's live reviewer sessions (the reap sweep's
|
|
505
|
+
``alissa tmux ls --live`` listing, None when it failed; see
|
|
506
|
+
`live_reviewer_sessions`). `queued` counts
|
|
507
|
+
the undispatched items in the alissa tmux queue (None: unknown). It
|
|
508
|
+
is read only when the roster is idle: an item queued before the
|
|
509
|
+
hold came on is in neither listing yet, and a restart would kill the
|
|
510
|
+
session it dispatches to."""
|
|
511
|
+
# Re-checked here, not only at the hold's start: a skip list the
|
|
512
|
+
# entrypoint just wrote, or a target that moved, must stop the exit.
|
|
513
|
+
self._update_hold()
|
|
514
|
+
version = self.wanted()
|
|
515
|
+
if version is None or self.answer is None:
|
|
516
|
+
self._idle_polls = 0
|
|
517
|
+
return NONE
|
|
518
|
+
live = live_reviewer_sessions(roster)
|
|
519
|
+
if live is None or live:
|
|
520
|
+
if self._idle_polls:
|
|
521
|
+
log.info(
|
|
522
|
+
"self-upgrade: not idle any more (%s) — idle count reset",
|
|
523
|
+
"the session listing failed" if live is None
|
|
524
|
+
else ", ".join(live),
|
|
525
|
+
)
|
|
526
|
+
self._idle_polls = 0
|
|
527
|
+
return WAIT
|
|
528
|
+
waiting = 0 if queued is None else _count(queued)
|
|
529
|
+
if waiting is None or waiting:
|
|
530
|
+
if self._idle_polls:
|
|
531
|
+
log.info(
|
|
532
|
+
"self-upgrade: not idle any more (%s) — idle count reset",
|
|
533
|
+
"the alissa queue listing failed" if waiting is None
|
|
534
|
+
else f"{waiting} undispatched item(s) in the alissa queue",
|
|
535
|
+
)
|
|
536
|
+
self._idle_polls = 0
|
|
537
|
+
return WAIT
|
|
538
|
+
self._idle_polls += 1
|
|
539
|
+
if self._idle_polls < IDLE_POLLS:
|
|
540
|
+
log.info(
|
|
541
|
+
"self-upgrade: idle on poll %d of %d before the exit to %s",
|
|
542
|
+
self._idle_polls, IDLE_POLLS, version,
|
|
543
|
+
)
|
|
544
|
+
return WAIT
|
|
545
|
+
if self.dry_run:
|
|
546
|
+
log.info(
|
|
547
|
+
"[dry-run] self-upgrade: idle — would write %s and exit to "
|
|
548
|
+
"upgrade %s → %s", self.path, self.running, version,
|
|
549
|
+
)
|
|
550
|
+
return WAIT
|
|
551
|
+
try:
|
|
552
|
+
self._request(self.answer)
|
|
553
|
+
except OSError as exc:
|
|
554
|
+
self._unwritable.add(version)
|
|
555
|
+
log.warning(
|
|
556
|
+
"self-upgrade: could not write %s (%s) — NOT upgrading to %s; "
|
|
557
|
+
"the drain is lifted and this process will not try %s again",
|
|
558
|
+
self.path, exc, version, version,
|
|
559
|
+
)
|
|
560
|
+
self._update_hold()
|
|
561
|
+
return NONE
|
|
562
|
+
self.exit_requested = True
|
|
563
|
+
log.info(
|
|
564
|
+
"self-upgrade: idle on %d consecutive polls — wrote %s; exiting "
|
|
565
|
+
"with code 0 so the container restarts and installs %s (from %s)",
|
|
566
|
+
IDLE_POLLS, self.path, version, self.running,
|
|
567
|
+
)
|
|
568
|
+
return EXIT
|
|
569
|
+
|
|
570
|
+
def _request(self, answer: UpgradeAnswer) -> None:
|
|
571
|
+
old = read_request(self.path) or {}
|
|
572
|
+
data: dict = {
|
|
573
|
+
"target": answer.target,
|
|
574
|
+
"previous": self.running,
|
|
575
|
+
"requestedAt": iso(self._clock()),
|
|
576
|
+
"state": "requested",
|
|
577
|
+
"confirmed": False,
|
|
578
|
+
"boots": 0,
|
|
579
|
+
"skip": skip_list(old),
|
|
580
|
+
}
|
|
581
|
+
# A report not yet delivered survives a new request.
|
|
582
|
+
if old.get("upgradeFailed") and not old.get("upgradeFailedReported"):
|
|
583
|
+
data["upgradeFailed"] = old["upgradeFailed"]
|
|
584
|
+
data["upgradeFailedReported"] = False
|
|
585
|
+
write_request(self.path, data)
|
|
586
|
+
|
|
587
|
+
# -- the failure report --
|
|
588
|
+
|
|
589
|
+
def report(self) -> "dict | None":
|
|
590
|
+
"""The ``upgradeFailed`` report the next snapshot owes Studio, or
|
|
591
|
+
None. Only a well-formed `{version, reason}` is offered: the request
|
|
592
|
+
schema is strict."""
|
|
593
|
+
data = read_request(self.path)
|
|
594
|
+
if not data or data.get("upgradeFailedReported"):
|
|
595
|
+
return None
|
|
596
|
+
failed = data.get("upgradeFailed")
|
|
597
|
+
if not isinstance(failed, Mapping):
|
|
598
|
+
return None
|
|
599
|
+
version, reason = failed.get("version"), failed.get("reason")
|
|
600
|
+
if not isinstance(version, str) or not re.fullmatch(r"[A-Za-z0-9._-]{1,128}", version):
|
|
601
|
+
return None
|
|
602
|
+
reason = " ".join(str(reason or "rolled back").split())[:300] or "rolled back"
|
|
603
|
+
return {"version": version, "reason": reason}
|
|
604
|
+
|
|
605
|
+
def reported(self, sent: "Mapping") -> None:
|
|
606
|
+
"""Mark the report delivered, after a POST carrying `sent`
|
|
607
|
+
succeeded."""
|
|
608
|
+
data = read_request(self.path)
|
|
609
|
+
if not data:
|
|
610
|
+
return
|
|
611
|
+
failed = data.get("upgradeFailed")
|
|
612
|
+
if not isinstance(failed, Mapping) or failed.get("version") != sent.get("version"):
|
|
613
|
+
return
|
|
614
|
+
data["upgradeFailedReported"] = True
|
|
615
|
+
try:
|
|
616
|
+
write_request(self.path, data)
|
|
617
|
+
except OSError as exc:
|
|
618
|
+
log.warning("self-upgrade: could not update %s (%s)", self.path, exc)
|
|
619
|
+
return
|
|
620
|
+
log.info(
|
|
621
|
+
"self-upgrade: reported to Studio that %s would not start "
|
|
622
|
+
"(rolled back to %s)", sent.get("version"), self.running,
|
|
623
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.31.29
|
|
@@ -19,6 +19,7 @@ src/main/alissa/tools/github/revloop/parking.py
|
|
|
19
19
|
src/main/alissa/tools/github/revloop/proc.py
|
|
20
20
|
src/main/alissa/tools/github/revloop/prompts.py
|
|
21
21
|
src/main/alissa/tools/github/revloop/prreview.py
|
|
22
|
+
src/main/alissa/tools/github/revloop/self_upgrade.py
|
|
22
23
|
src/main/alissa/tools/github/revloop/state.py
|
|
23
24
|
src/main/alissa/tools/github/revloop/trust.py
|
|
24
25
|
src/main/alissa/tools/github/revloop/version
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.31.27
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|