alissa-tools-github-devloop 0.8.36__py3-none-any.whl → 0.8.37__py3-none-any.whl
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/devloop/fleet_vitals.py +71 -2
- alissa/tools/github/devloop/loop.py +275 -8
- alissa/tools/github/devloop/loop_events.py +1 -0
- alissa/tools/github/devloop/parking.py +205 -0
- alissa/tools/github/devloop/state.py +133 -0
- alissa/tools/github/devloop/version +1 -1
- alissa/tools/github/devloop/webui/__main__.py +24 -1
- alissa/tools/github/devloop/webui/server.py +124 -2
- alissa/tools/github/devloop/webui/sources.py +51 -1
- {alissa_tools_github_devloop-0.8.36.dist-info → alissa_tools_github_devloop-0.8.37.dist-info}/METADATA +1 -1
- {alissa_tools_github_devloop-0.8.36.dist-info → alissa_tools_github_devloop-0.8.37.dist-info}/RECORD +16 -15
- {alissa_tools_github_devloop-0.8.36.dist-info → alissa_tools_github_devloop-0.8.37.dist-info}/WHEEL +0 -0
- {alissa_tools_github_devloop-0.8.36.dist-info → alissa_tools_github_devloop-0.8.37.dist-info}/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.8.36.dist-info → alissa_tools_github_devloop-0.8.37.dist-info}/licenses/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.36.dist-info → alissa_tools_github_devloop-0.8.37.dist-info}/licenses/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.36.dist-info → alissa_tools_github_devloop-0.8.37.dist-info}/top_level.txt +0 -0
|
@@ -36,6 +36,15 @@ The roster rule the contract cares most about: `sessions` and `sessionList`
|
|
|
36
36
|
are `null` — not `{live: 0, managed: 0}` — when the listing could not be
|
|
37
37
|
read. A machine whose tmux blinked is not an idle machine, and the Factory
|
|
38
38
|
must be able to tell the two apart.
|
|
39
|
+
|
|
40
|
+
Seat parking (issue #153, studio.alissa.app seat-parking design §2.2/§2.4)
|
|
41
|
+
adds the `idle` block — the seat's own idle verdict (`parking.idle_block`),
|
|
42
|
+
with `drainedAt` while a drain holds — so Studio can re-check a park against
|
|
43
|
+
the seat's OWN snapshot. The console's drain pushes one snapshot before it
|
|
44
|
+
answers (`FleetVitalsPusher.push_now`). Until Studio's schema carries the
|
|
45
|
+
block (the design's lane L3), a strict 400 naming `idle` makes the pusher
|
|
46
|
+
resend without it and leave it out for IDLE_REPROBE_S: the vitals the Factory
|
|
47
|
+
already renders never go dark over a field nobody reads yet.
|
|
39
48
|
"""
|
|
40
49
|
|
|
41
50
|
from __future__ import annotations
|
|
@@ -46,6 +55,7 @@ import time
|
|
|
46
55
|
from datetime import datetime, timezone
|
|
47
56
|
from typing import Callable, Mapping, Sequence
|
|
48
57
|
|
|
58
|
+
from . import parking
|
|
49
59
|
from .alissa_client import AlissaAuthError, AlissaClient, AlissaError
|
|
50
60
|
from .config import Config
|
|
51
61
|
from .version import version as running_version
|
|
@@ -74,6 +84,12 @@ PUSHED = "pushed"
|
|
|
74
84
|
SKIPPED = "skipped"
|
|
75
85
|
FAILED = "failed"
|
|
76
86
|
|
|
87
|
+
# How long a snapshot leaves the `idle` block out after Studio refused it as
|
|
88
|
+
# an unknown key (a schema that predates seat parking), before it offers the
|
|
89
|
+
# block again. An hour: one extra POST an hour against an old Studio, and a
|
|
90
|
+
# newly deployed one starts receiving the block within the hour.
|
|
91
|
+
IDLE_REPROBE_S = 3600.0
|
|
92
|
+
|
|
77
93
|
|
|
78
94
|
def _session_grammar() -> "Callable[[str], object]":
|
|
79
95
|
"""The daemon's own session-name parser, resolved late.
|
|
@@ -189,6 +205,8 @@ def build_snapshot(
|
|
|
189
205
|
now: "float | int | None" = None,
|
|
190
206
|
memory: "Mapping | None" = None,
|
|
191
207
|
parse_session: "Callable[[str], object] | None" = None,
|
|
208
|
+
queued: "int | None" = None,
|
|
209
|
+
drain: "Mapping | None" = None,
|
|
192
210
|
) -> "dict":
|
|
193
211
|
"""The contract body for one pass, from the console's builders.
|
|
194
212
|
|
|
@@ -199,7 +217,12 @@ def build_snapshot(
|
|
|
199
217
|
the `sysinfo.memory_usage` reading, taken by the caller so a test can
|
|
200
218
|
hand in a fixture. Pure assembly over the sources: the only IO is what
|
|
201
219
|
`Sources` does (state.db, `tmux list-panes` + `/proc` per live session,
|
|
202
|
-
the cached rate and PyPI reads).
|
|
220
|
+
the cached rate and PyPI reads).
|
|
221
|
+
|
|
222
|
+
`queued` (the undispatched alissa queue items, None when unread) and
|
|
223
|
+
`drain` (the holding drain row, or None) feed the `idle` block, whose
|
|
224
|
+
owed / timers come from the latest poll snapshot and whose live /
|
|
225
|
+
managed come from `roster` -- the same reading as `sessions`."""
|
|
203
226
|
parse = parse_session or _session_grammar()
|
|
204
227
|
now_ts = heartbeat_at if now is None else now
|
|
205
228
|
|
|
@@ -246,6 +269,12 @@ def build_snapshot(
|
|
|
246
269
|
+ int(latest.get("revert_candidates") or 0)
|
|
247
270
|
)
|
|
248
271
|
|
|
272
|
+
roster_live, roster_managed = parking.roster_counts(roster)
|
|
273
|
+
idle = parking.idle_block(
|
|
274
|
+
latest, live=roster_live, managed=roster_managed, queued=queued,
|
|
275
|
+
drain=drain, now=now_ts,
|
|
276
|
+
)
|
|
277
|
+
|
|
249
278
|
drift = sources.drift()
|
|
250
279
|
return {
|
|
251
280
|
"schemaVersion": SCHEMA_VERSION,
|
|
@@ -263,6 +292,7 @@ def build_snapshot(
|
|
|
263
292
|
"queueDepth": queue_depth,
|
|
264
293
|
"kpis": None,
|
|
265
294
|
"inbox": inbox,
|
|
295
|
+
"idle": idle,
|
|
266
296
|
}
|
|
267
297
|
|
|
268
298
|
|
|
@@ -321,6 +351,9 @@ class FleetVitalsPusher:
|
|
|
321
351
|
self._memory = memory_reader
|
|
322
352
|
self._clock = clock
|
|
323
353
|
self._not_activated_warned = False
|
|
354
|
+
# Until when the `idle` block is left out (Studio refused it as an
|
|
355
|
+
# unknown key); 0 = offer it.
|
|
356
|
+
self._idle_refused_until = 0.0
|
|
324
357
|
self.last: "dict | None" = None
|
|
325
358
|
|
|
326
359
|
def push_pass(self, roster: "Sequence[Mapping] | None") -> str:
|
|
@@ -328,10 +361,26 @@ class FleetVitalsPusher:
|
|
|
328
361
|
FAILED (the caller quotes it in the poll summary). A snapshot that
|
|
329
362
|
cannot be BUILT is a failure too — one WARNING, nothing sent."""
|
|
330
363
|
heartbeat = self._clock()
|
|
364
|
+
return self._push(roster, heartbeat, heartbeat)
|
|
365
|
+
|
|
366
|
+
def push_now(self, roster: "Sequence[Mapping] | None") -> str:
|
|
367
|
+
"""The console's out-of-pass push (the drain handshake, issue #153):
|
|
368
|
+
the same snapshot, its `heartbeatAt` the daemon's LAST completed pass
|
|
369
|
+
(the console completes none of its own -- stamping now would read as
|
|
370
|
+
a heartbeat the loop never gave), `asOf` now. PUSHED or FAILED."""
|
|
371
|
+
snaps = self.sources.snapshots(1)
|
|
372
|
+
now = self._clock()
|
|
373
|
+
heartbeat = snaps[0]["ts"] if snaps else now
|
|
374
|
+
return self._push(roster, heartbeat, now)
|
|
375
|
+
|
|
376
|
+
def _push(
|
|
377
|
+
self, roster: "Sequence[Mapping] | None", heartbeat: float, now: float,
|
|
378
|
+
) -> str:
|
|
331
379
|
try:
|
|
332
380
|
snapshot = fit_to_body_cap(build_snapshot(
|
|
333
381
|
self.sources, roster=roster, heartbeat_at=heartbeat,
|
|
334
|
-
memory=self._memory(),
|
|
382
|
+
now=now, memory=self._memory(),
|
|
383
|
+
queued=self.sources.queued(), drain=self.sources.drain(),
|
|
335
384
|
))
|
|
336
385
|
except Exception as exc:
|
|
337
386
|
log.warning(
|
|
@@ -339,9 +388,17 @@ class FleetVitalsPusher:
|
|
|
339
388
|
"pushed this pass; the next pass builds a fresh one", exc,
|
|
340
389
|
)
|
|
341
390
|
return FAILED
|
|
391
|
+
if now < self._idle_refused_until:
|
|
392
|
+
snapshot.pop("idle", None)
|
|
342
393
|
self.last = snapshot
|
|
343
394
|
return self._post(snapshot)
|
|
344
395
|
|
|
396
|
+
@staticmethod
|
|
397
|
+
def _refuses_idle(exc: AlissaError) -> bool:
|
|
398
|
+
"""A 400 that names the `idle` key: a Studio whose strict schema
|
|
399
|
+
predates seat parking (the design's lane L3 adds the field)."""
|
|
400
|
+
return exc.status == 400 and "idle" in str(exc.detail)
|
|
401
|
+
|
|
345
402
|
def _post(self, snapshot: "dict") -> str:
|
|
346
403
|
try:
|
|
347
404
|
self.client.post_fleet_vitals(snapshot)
|
|
@@ -352,6 +409,18 @@ class FleetVitalsPusher:
|
|
|
352
409
|
self._warn_failed(exc)
|
|
353
410
|
return FAILED
|
|
354
411
|
except AlissaError as exc:
|
|
412
|
+
if "idle" in snapshot and self._refuses_idle(exc):
|
|
413
|
+
self._idle_refused_until = self._clock() + IDLE_REPROBE_S
|
|
414
|
+
log.warning(
|
|
415
|
+
"fleet vitals: Studio refused the seat-parking `idle` "
|
|
416
|
+
"block (status %s: %s) — its schema predates it; "
|
|
417
|
+
"resending without it, and leaving it out for %d min",
|
|
418
|
+
exc.status, exc.detail, int(IDLE_REPROBE_S // 60),
|
|
419
|
+
)
|
|
420
|
+
trimmed = dict(snapshot)
|
|
421
|
+
trimmed.pop("idle")
|
|
422
|
+
self.last = trimmed
|
|
423
|
+
return self._post(trimmed)
|
|
355
424
|
self._warn_failed(exc)
|
|
356
425
|
return FAILED
|
|
357
426
|
sessions = snapshot.get("sessions") or {}
|
|
@@ -463,6 +463,7 @@ from typing import Callable, Sequence
|
|
|
463
463
|
from . import janitor
|
|
464
464
|
from . import fleet_vitals as fleet_vitals_mod
|
|
465
465
|
from . import loop_events as loop_events_mod
|
|
466
|
+
from . import parking as parking_mod
|
|
466
467
|
from . import prompts as prompts_mod
|
|
467
468
|
from .alissa import MANAGED_PREFIX, SHELL_COMMANDS, Alissa
|
|
468
469
|
from .alissa_client import AlissaClient
|
|
@@ -2833,6 +2834,25 @@ class Decision:
|
|
|
2833
2834
|
# and on the capacity valve, whose Action.DEFERRED already says it.
|
|
2834
2835
|
# Observational like `resume`: only the loop-events emitter reads it.
|
|
2835
2836
|
defer_token: str | None = None
|
|
2837
|
+
# The seat-parking verdict (issue #153): whether this candidate is OWED
|
|
2838
|
+
# -- the seat's own work, or a clock the seat owns, is still running on
|
|
2839
|
+
# it -- or settled. None takes the action's default (`decision_owed`);
|
|
2840
|
+
# the sites that know better say so. `timer` is the seat-owned clock
|
|
2841
|
+
# behind an owed verdict, `(kind, until)` with `kind` one of
|
|
2842
|
+
# parking.TIMER_KINDS and `until` a unix second. Observational: only the
|
|
2843
|
+
# poll snapshot (and from it the idle block) reads them.
|
|
2844
|
+
owed: bool | None = None
|
|
2845
|
+
timer: "tuple[str, int] | None" = None
|
|
2846
|
+
|
|
2847
|
+
|
|
2848
|
+
def decision_owed(decision: Decision) -> bool:
|
|
2849
|
+
"""Whether a decision leaves its candidate OWED (seat-parking design
|
|
2850
|
+
§2.1): an explicit verdict on the decision wins; otherwise every action
|
|
2851
|
+
but a skip or a cap is the seat's own work in progress -- a spawn, an
|
|
2852
|
+
attempt in flight, a deferral, an escalation posted this pass."""
|
|
2853
|
+
if decision.owed is not None:
|
|
2854
|
+
return decision.owed
|
|
2855
|
+
return decision.action.value in parking_mod.OWED_ACTIONS
|
|
2836
2856
|
|
|
2837
2857
|
|
|
2838
2858
|
def _oldest_first(found: list) -> list:
|
|
@@ -3370,6 +3390,17 @@ class DevWatcher:
|
|
|
3370
3390
|
self.github = github or GitHub(config.developer_login)
|
|
3371
3391
|
self.alissa = alissa or Alissa()
|
|
3372
3392
|
self.state = state or State(config.state_db)
|
|
3393
|
+
# Seat parking (issue #153): a drain -- and any spawn claim a dead
|
|
3394
|
+
# process left behind -- never outlives the daemon that saw it. A
|
|
3395
|
+
# redeploy (the wake) or a restart boots with the flag down.
|
|
3396
|
+
try:
|
|
3397
|
+
self.state.reset_parking()
|
|
3398
|
+
except sqlite3.Error as exc:
|
|
3399
|
+
log.warning(
|
|
3400
|
+
"parking: could not clear the drain flag at boot (%s) — a "
|
|
3401
|
+
"drain left by the previous process holds until its TTL "
|
|
3402
|
+
"runs out", exc,
|
|
3403
|
+
)
|
|
3373
3404
|
# The COMMENTED-mode verdict tie-breaker (see no_envelope_verdict):
|
|
3374
3405
|
# injectable so the future Alissa-envelope reader is one constructor
|
|
3375
3406
|
# argument, not a rewrite of the verdict logic.
|
|
@@ -3474,6 +3505,39 @@ class DevWatcher:
|
|
|
3474
3505
|
return Liveness.INDETERMINATE
|
|
3475
3506
|
return roster.of(session)
|
|
3476
3507
|
|
|
3508
|
+
def _stale_timer(self, age: float) -> "tuple[str, int]":
|
|
3509
|
+
"""The seat-owned stale window of a spawn row `age` seconds old
|
|
3510
|
+
(issue #153): it ends when the row turns stale, and until then the
|
|
3511
|
+
lane's attempt is the seat's in-flight work."""
|
|
3512
|
+
return (
|
|
3513
|
+
parking_mod.TIMER_STALE_WINDOW,
|
|
3514
|
+
int(time.time() - age) + self.config.stale_minutes * 60,
|
|
3515
|
+
)
|
|
3516
|
+
|
|
3517
|
+
def _stall_timer(
|
|
3518
|
+
self, repo_slug: str, number: int, kind: str, age: float
|
|
3519
|
+
) -> "tuple[str, int] | None":
|
|
3520
|
+
"""The clock of a stall ping (`kind`, STALLED_DEFER_MULTIPLE x the
|
|
3521
|
+
stale window after the spawn) that has not gone out yet, or None once
|
|
3522
|
+
it has. A seat must not park across it: the ping would then fire on
|
|
3523
|
+
the first pass after the wake, the very stall the parking design
|
|
3524
|
+
(§2.9) says no park may manufacture (issue #153)."""
|
|
3525
|
+
if self.state.escalated(repo_slug, number, kind):
|
|
3526
|
+
return None
|
|
3527
|
+
stalled_after = STALLED_DEFER_MULTIPLE * self.config.stale_minutes * 60
|
|
3528
|
+
return (
|
|
3529
|
+
parking_mod.TIMER_STALE_WINDOW, int(time.time() - age) + stalled_after,
|
|
3530
|
+
)
|
|
3531
|
+
|
|
3532
|
+
@staticmethod
|
|
3533
|
+
def _grace_timer(waited: float, grace: float) -> "tuple[str, int]":
|
|
3534
|
+
"""A grace the seat waits out before it acts on its own (the
|
|
3535
|
+
mechanical re-request, the orphan handoff): reported as a stale
|
|
3536
|
+
window, the timer kind for a wait the seat owns (issue #153)."""
|
|
3537
|
+
return (
|
|
3538
|
+
parking_mod.TIMER_STALE_WINDOW, int(time.time() - waited + grace),
|
|
3539
|
+
)
|
|
3540
|
+
|
|
3477
3541
|
def evaluate(
|
|
3478
3542
|
self,
|
|
3479
3543
|
owner: str,
|
|
@@ -3509,6 +3573,7 @@ class DevWatcher:
|
|
|
3509
3573
|
f"attempt {attempts} enqueued {int(age)}s ago",
|
|
3510
3574
|
attempts,
|
|
3511
3575
|
session=session_name(issue, attempts),
|
|
3576
|
+
timer=self._stale_timer(age),
|
|
3512
3577
|
)
|
|
3513
3578
|
|
|
3514
3579
|
# The one dedupe signal that survives an instance change, consulted
|
|
@@ -3648,6 +3713,15 @@ class DevWatcher:
|
|
|
3648
3713
|
issue.full_name, number, stalled_kind(attempts)
|
|
3649
3714
|
):
|
|
3650
3715
|
self._escalate_stalled(issue, attempts, age, linked)
|
|
3716
|
+
# Parking (issue #153): the lane is the seat's until its
|
|
3717
|
+
# stall ping has gone out -- a seat parked across that
|
|
3718
|
+
# clock would post it on the first pass after the wake
|
|
3719
|
+
# -- and, past it, while the session might still be
|
|
3720
|
+
# there. A dead session behind a PR nobody may resume
|
|
3721
|
+
# waits on the review lifecycle: settled.
|
|
3722
|
+
stall_timer = self._stall_timer(
|
|
3723
|
+
issue.full_name, number, stalled_kind(attempts), age
|
|
3724
|
+
)
|
|
3651
3725
|
# Name WHY the deferral held: a dead session behind a
|
|
3652
3726
|
# handed-off PR is a different story from a live one, and
|
|
3653
3727
|
# an operator reading the log must not have to guess.
|
|
@@ -3670,6 +3744,11 @@ class DevWatcher:
|
|
|
3670
3744
|
attempts,
|
|
3671
3745
|
session=session_name(issue, attempts),
|
|
3672
3746
|
defer_token=loop_events_mod.DEFER_IN_FLIGHT,
|
|
3747
|
+
owed=(
|
|
3748
|
+
stall_timer is not None
|
|
3749
|
+
or liveness is not Liveness.DEAD
|
|
3750
|
+
),
|
|
3751
|
+
timer=stall_timer,
|
|
3673
3752
|
)
|
|
3674
3753
|
else:
|
|
3675
3754
|
# No linked open PR: the IMPLEMENT phase, before the session
|
|
@@ -3770,6 +3849,10 @@ class DevWatcher:
|
|
|
3770
3849
|
self._escalate_live_stalled(
|
|
3771
3850
|
issue, attempts, age, live_session, liveness
|
|
3772
3851
|
)
|
|
3852
|
+
live_stall_timer = self._stall_timer(
|
|
3853
|
+
issue.full_name, number, live_stalled_kind(attempts),
|
|
3854
|
+
age,
|
|
3855
|
+
)
|
|
3773
3856
|
return Decision(
|
|
3774
3857
|
Action.IN_FLIGHT,
|
|
3775
3858
|
f"attempt {attempts} is stale ({int(age / 60)} min) "
|
|
@@ -3781,6 +3864,7 @@ class DevWatcher:
|
|
|
3781
3864
|
attempts,
|
|
3782
3865
|
session=live_session,
|
|
3783
3866
|
defer_token=loop_events_mod.DEFER_IN_FLIGHT,
|
|
3867
|
+
timer=live_stall_timer,
|
|
3784
3868
|
)
|
|
3785
3869
|
# DEAD is not yet "died in the implement phase" (issue #144,
|
|
3786
3870
|
# design §8.4): the session is gone and no PR is OPEN, but a
|
|
@@ -3876,6 +3960,84 @@ class DevWatcher:
|
|
|
3876
3960
|
reenqueued: bool = False,
|
|
3877
3961
|
resume_pr: "PullRequest | None" = None,
|
|
3878
3962
|
dead_session: "str | None" = None,
|
|
3963
|
+
) -> Decision:
|
|
3964
|
+
"""The issue edge's spawn, behind the drain gate (`_drain_gated`)."""
|
|
3965
|
+
return self._drain_gated(
|
|
3966
|
+
session_name(issue, attempt), attempt, issue.issue_slug,
|
|
3967
|
+
lambda: self._spawn_claimed(
|
|
3968
|
+
issue, attempt, task_number, budget,
|
|
3969
|
+
reenqueued=reenqueued, resume_pr=resume_pr,
|
|
3970
|
+
dead_session=dead_session,
|
|
3971
|
+
),
|
|
3972
|
+
)
|
|
3973
|
+
|
|
3974
|
+
def _drain_gated(
|
|
3975
|
+
self,
|
|
3976
|
+
name: str,
|
|
3977
|
+
attempt: int,
|
|
3978
|
+
slug: str,
|
|
3979
|
+
spawn: "Callable[[], Decision]",
|
|
3980
|
+
round_: "int | None" = None,
|
|
3981
|
+
) -> Decision:
|
|
3982
|
+
"""Every spawn path's drain gate (seat-parking design §2.4, issue
|
|
3983
|
+
#153): claim the spawn of `name` against the drain flag, run `spawn`
|
|
3984
|
+
under the claim, and drop the claim however it ends.
|
|
3985
|
+
|
|
3986
|
+
The gate sits ABOVE every side effect of the spawn -- the capacity
|
|
3987
|
+
reserve, the hub fetch, the self-assign, the activity line, the
|
|
3988
|
+
`alissa tmux queue add`, the ledger row -- so a drained seat touches
|
|
3989
|
+
nothing on any edge, and the item stays a clean candidate (DEFERRED
|
|
3990
|
+
claims nothing, exactly like the capacity valve). The claim and the
|
|
3991
|
+
flag are read and written in one immediate transaction on each side
|
|
3992
|
+
(`State.claim_spawn` / `State.set_drain`): a drain that lands while
|
|
3993
|
+
this spawn is between its gate and its enqueue sees the claim and
|
|
3994
|
+
refuses itself, so a park can never race a spawn. A ledger that
|
|
3995
|
+
cannot answer stands the spawn down too -- a spawn that cannot prove
|
|
3996
|
+
the seat is not drained does not happen."""
|
|
3997
|
+
try:
|
|
3998
|
+
drain = self.state.claim_spawn(name)
|
|
3999
|
+
except sqlite3.Error as exc:
|
|
4000
|
+
log.warning(
|
|
4001
|
+
"%s: could not claim the spawn of %s against the drain flag "
|
|
4002
|
+
"(%s) — standing it down this pass", slug, name, exc,
|
|
4003
|
+
)
|
|
4004
|
+
return Decision(
|
|
4005
|
+
Action.DEFERRED,
|
|
4006
|
+
f"spawn stood down: the state ledger could not prove the "
|
|
4007
|
+
f"seat is not drained ({exc}); retrying next poll",
|
|
4008
|
+
attempt, round=round_,
|
|
4009
|
+
defer_token=loop_events_mod.DEFER_DRAINED,
|
|
4010
|
+
)
|
|
4011
|
+
if drain is not None:
|
|
4012
|
+
since = parking_mod.iso(drain["drained_at"])
|
|
4013
|
+
until = parking_mod.iso(drain["expires_at"])
|
|
4014
|
+
log.info(
|
|
4015
|
+
"%s: not spawning %s — the seat is drained for parking since "
|
|
4016
|
+
"%s (until %s)", slug, name, since, until,
|
|
4017
|
+
)
|
|
4018
|
+
return Decision(
|
|
4019
|
+
Action.DEFERRED,
|
|
4020
|
+
f"spawns held — the seat is drained for parking since {since} "
|
|
4021
|
+
f"(until {until}: {drain['reason'] or 'no reason given'}); "
|
|
4022
|
+
f"retrying once it is undrained",
|
|
4023
|
+
attempt, round=round_,
|
|
4024
|
+
defer_token=loop_events_mod.DEFER_DRAINED,
|
|
4025
|
+
)
|
|
4026
|
+
try:
|
|
4027
|
+
return spawn()
|
|
4028
|
+
finally:
|
|
4029
|
+
self.state.release_spawn(name)
|
|
4030
|
+
|
|
4031
|
+
def _spawn_claimed(
|
|
4032
|
+
self,
|
|
4033
|
+
issue: Issue,
|
|
4034
|
+
attempt: int,
|
|
4035
|
+
task_number: int | None,
|
|
4036
|
+
budget: "SessionBudget | None" = None,
|
|
4037
|
+
*,
|
|
4038
|
+
reenqueued: bool = False,
|
|
4039
|
+
resume_pr: "PullRequest | None" = None,
|
|
4040
|
+
dead_session: "str | None" = None,
|
|
3879
4041
|
) -> Decision:
|
|
3880
4042
|
name = session_name(issue, attempt)
|
|
3881
4043
|
# The max_sessions gate, FIRST -- before the self-assign that marks
|
|
@@ -3928,7 +4090,7 @@ class DevWatcher:
|
|
|
3928
4090
|
|
|
3929
4091
|
hub, problem = self._ensure_hub(issue.owner, issue.repo)
|
|
3930
4092
|
if problem is not None:
|
|
3931
|
-
return Decision(Action.SKIPPED, problem, attempt)
|
|
4093
|
+
return Decision(Action.SKIPPED, problem, attempt, owed=True)
|
|
3932
4094
|
|
|
3933
4095
|
task_ref = f"TASK-{task_number}" if task_number is not None else None
|
|
3934
4096
|
with_origin = (
|
|
@@ -5181,6 +5343,7 @@ class DevWatcher:
|
|
|
5181
5343
|
pr.full_name, number, round_
|
|
5182
5344
|
) or fix_session_name(pr, round_, attempts),
|
|
5183
5345
|
round=round_,
|
|
5346
|
+
timer=self._stale_timer(age),
|
|
5184
5347
|
)
|
|
5185
5348
|
|
|
5186
5349
|
# A round nothing was spawned for yet -- but the PREVIOUS round's fix
|
|
@@ -5285,6 +5448,10 @@ class DevWatcher:
|
|
|
5285
5448
|
self._escalate_fix_live_stalled(
|
|
5286
5449
|
pr, round_, attempts, age, row_session or ""
|
|
5287
5450
|
)
|
|
5451
|
+
fix_live_timer = self._stall_timer(
|
|
5452
|
+
pr.full_name, number,
|
|
5453
|
+
fix_live_stalled_kind(round_, attempts), age,
|
|
5454
|
+
)
|
|
5288
5455
|
return Decision(
|
|
5289
5456
|
Action.IN_FLIGHT,
|
|
5290
5457
|
f"fix attempt {attempts} for round {round_} is stale "
|
|
@@ -5297,6 +5464,7 @@ class DevWatcher:
|
|
|
5297
5464
|
or fix_session_name(pr, round_, attempts),
|
|
5298
5465
|
round=round_,
|
|
5299
5466
|
defer_token=loop_events_mod.DEFER_IN_FLIGHT,
|
|
5467
|
+
timer=fix_live_timer,
|
|
5300
5468
|
)
|
|
5301
5469
|
if liveness is not Liveness.DEAD:
|
|
5302
5470
|
return Decision(
|
|
@@ -5326,6 +5494,13 @@ class DevWatcher:
|
|
|
5326
5494
|
pr.full_name, number, fix_stalled_kind(round_, attempts)
|
|
5327
5495
|
):
|
|
5328
5496
|
self._escalate_fix_stalled(pr, round_, attempts, age)
|
|
5497
|
+
# Parking (issue #153), the issue edge's rule: owed until
|
|
5498
|
+
# the stall ping is out, and past it while the session
|
|
5499
|
+
# might still be there; a dead one waits on a human.
|
|
5500
|
+
fix_stall_timer = self._stall_timer(
|
|
5501
|
+
pr.full_name, number,
|
|
5502
|
+
fix_stalled_kind(round_, attempts), age,
|
|
5503
|
+
)
|
|
5329
5504
|
return Decision(
|
|
5330
5505
|
Action.IN_FLIGHT,
|
|
5331
5506
|
f"fix attempt {attempts} for round {round_} is stale "
|
|
@@ -5339,6 +5514,11 @@ class DevWatcher:
|
|
|
5339
5514
|
or fix_session_name(pr, round_, attempts),
|
|
5340
5515
|
round=round_,
|
|
5341
5516
|
defer_token=loop_events_mod.DEFER_IN_FLIGHT,
|
|
5517
|
+
owed=(
|
|
5518
|
+
fix_stall_timer is not None
|
|
5519
|
+
or liveness is not Liveness.DEAD
|
|
5520
|
+
),
|
|
5521
|
+
timer=fix_stall_timer,
|
|
5342
5522
|
)
|
|
5343
5523
|
|
|
5344
5524
|
# The per-round budget ran dry: stop and page the operator ON the
|
|
@@ -5552,6 +5732,7 @@ class DevWatcher:
|
|
|
5552
5732
|
f"(session picture: {liveness.value} — {who})",
|
|
5553
5733
|
attempts,
|
|
5554
5734
|
round=round_,
|
|
5735
|
+
owed=True,
|
|
5555
5736
|
)
|
|
5556
5737
|
|
|
5557
5738
|
waited = self._head_public_for(pr, newest)
|
|
@@ -5565,6 +5746,7 @@ class DevWatcher:
|
|
|
5565
5746
|
f"passed; deferring the mechanical re-request",
|
|
5566
5747
|
attempts,
|
|
5567
5748
|
round=round_,
|
|
5749
|
+
owed=True,
|
|
5568
5750
|
)
|
|
5569
5751
|
if waited < grace:
|
|
5570
5752
|
return Decision(
|
|
@@ -5576,6 +5758,8 @@ class DevWatcher:
|
|
|
5576
5758
|
f"racing it",
|
|
5577
5759
|
attempts,
|
|
5578
5760
|
round=round_,
|
|
5761
|
+
owed=True,
|
|
5762
|
+
timer=self._grace_timer(waited, grace),
|
|
5579
5763
|
)
|
|
5580
5764
|
|
|
5581
5765
|
return self._rerequest_review(pr, round_, attempts, newest, kind, waited)
|
|
@@ -5798,6 +5982,7 @@ class DevWatcher:
|
|
|
5798
5982
|
f"({exc}) — transient; not recorded, retrying next poll",
|
|
5799
5983
|
attempts,
|
|
5800
5984
|
round=round_,
|
|
5985
|
+
owed=True,
|
|
5801
5986
|
)
|
|
5802
5987
|
|
|
5803
5988
|
self.state.record_escalation(pr.full_name, pr.number, kind)
|
|
@@ -6045,6 +6230,7 @@ class DevWatcher:
|
|
|
6045
6230
|
f"draft PR is not orphaned — session picture: "
|
|
6046
6231
|
f"{liveness.value} ({who})",
|
|
6047
6232
|
task_ref=task_ref,
|
|
6233
|
+
owed=True,
|
|
6048
6234
|
)
|
|
6049
6235
|
|
|
6050
6236
|
waited = self._draft_public_for(pr)
|
|
@@ -6056,6 +6242,7 @@ class DevWatcher:
|
|
|
6056
6242
|
f"read — cannot prove the {int(grace / 60)} min grace has "
|
|
6057
6243
|
f"passed; deferring the mechanical handoff",
|
|
6058
6244
|
task_ref=task_ref,
|
|
6245
|
+
owed=True,
|
|
6059
6246
|
)
|
|
6060
6247
|
if waited < grace:
|
|
6061
6248
|
return Decision(
|
|
@@ -6065,6 +6252,8 @@ class DevWatcher:
|
|
|
6065
6252
|
f"a session hands its own draft off within minutes; not "
|
|
6066
6253
|
f"racing it",
|
|
6067
6254
|
task_ref=task_ref,
|
|
6255
|
+
owed=True,
|
|
6256
|
+
timer=self._grace_timer(waited, grace),
|
|
6068
6257
|
)
|
|
6069
6258
|
|
|
6070
6259
|
# Last, because it is a configuration problem rather than a property
|
|
@@ -7232,6 +7421,29 @@ class DevWatcher:
|
|
|
7232
7421
|
reenqueued: bool = False,
|
|
7233
7422
|
dead_session: "str | None" = None,
|
|
7234
7423
|
confirmed_dead: "str | None" = None,
|
|
7424
|
+
) -> Decision:
|
|
7425
|
+
"""The fix edge's spawn, behind the drain gate (`_drain_gated`)."""
|
|
7426
|
+
return self._drain_gated(
|
|
7427
|
+
fix_session_name(pr, round_, attempt), attempt, pr.pr_slug,
|
|
7428
|
+
lambda: self._spawn_fix_claimed(
|
|
7429
|
+
pr, round_, attempt, review, budget,
|
|
7430
|
+
reenqueued=reenqueued, dead_session=dead_session,
|
|
7431
|
+
confirmed_dead=confirmed_dead,
|
|
7432
|
+
),
|
|
7433
|
+
round_=round_,
|
|
7434
|
+
)
|
|
7435
|
+
|
|
7436
|
+
def _spawn_fix_claimed(
|
|
7437
|
+
self,
|
|
7438
|
+
pr: PullRequest,
|
|
7439
|
+
round_: int,
|
|
7440
|
+
attempt: int,
|
|
7441
|
+
review: Review,
|
|
7442
|
+
budget: "SessionBudget | None" = None,
|
|
7443
|
+
*,
|
|
7444
|
+
reenqueued: bool = False,
|
|
7445
|
+
dead_session: "str | None" = None,
|
|
7446
|
+
confirmed_dead: "str | None" = None,
|
|
7235
7447
|
) -> Decision:
|
|
7236
7448
|
skipped = self._foreign_head_skip(pr, attempt)
|
|
7237
7449
|
if skipped is not None:
|
|
@@ -7287,7 +7499,7 @@ class DevWatcher:
|
|
|
7287
7499
|
|
|
7288
7500
|
hub, problem = self._ensure_hub(pr.owner, pr.repo)
|
|
7289
7501
|
if problem is not None:
|
|
7290
|
-
return Decision(Action.SKIPPED, problem, attempt)
|
|
7502
|
+
return Decision(Action.SKIPPED, problem, attempt, owed=True)
|
|
7291
7503
|
|
|
7292
7504
|
template = (
|
|
7293
7505
|
FIX_RESUME_DIRECTIVE if dead_session is not None else FIX_DIRECTIVE
|
|
@@ -7441,6 +7653,7 @@ class DevWatcher:
|
|
|
7441
7653
|
f"(the label stays until the session completes)",
|
|
7442
7654
|
attempts,
|
|
7443
7655
|
session=maintain_session_name(pr, attempts),
|
|
7656
|
+
timer=self._stale_timer(age),
|
|
7444
7657
|
)
|
|
7445
7658
|
|
|
7446
7659
|
# The cross-instance claim check, before the epoch re-basing: whether
|
|
@@ -7540,6 +7753,24 @@ class DevWatcher:
|
|
|
7540
7753
|
*,
|
|
7541
7754
|
reenqueued: bool = False,
|
|
7542
7755
|
limited: bool = False,
|
|
7756
|
+
) -> Decision:
|
|
7757
|
+
"""The maintenance edge's spawn, behind the drain gate
|
|
7758
|
+
(`_drain_gated`)."""
|
|
7759
|
+
return self._drain_gated(
|
|
7760
|
+
maintain_session_name(pr, attempt), attempt, pr.pr_slug,
|
|
7761
|
+
lambda: self._spawn_maintain_claimed(
|
|
7762
|
+
pr, attempt, budget, reenqueued=reenqueued, limited=limited,
|
|
7763
|
+
),
|
|
7764
|
+
)
|
|
7765
|
+
|
|
7766
|
+
def _spawn_maintain_claimed(
|
|
7767
|
+
self,
|
|
7768
|
+
pr: PullRequest,
|
|
7769
|
+
attempt: int,
|
|
7770
|
+
budget: "SessionBudget | None" = None,
|
|
7771
|
+
*,
|
|
7772
|
+
reenqueued: bool = False,
|
|
7773
|
+
limited: bool = False,
|
|
7543
7774
|
) -> Decision:
|
|
7544
7775
|
skipped = self._foreign_head_skip(pr, attempt)
|
|
7545
7776
|
if skipped is not None:
|
|
@@ -7569,7 +7800,7 @@ class DevWatcher:
|
|
|
7569
7800
|
|
|
7570
7801
|
hub, problem = self._ensure_hub(pr.owner, pr.repo)
|
|
7571
7802
|
if problem is not None:
|
|
7572
|
-
return Decision(Action.SKIPPED, problem, attempt)
|
|
7803
|
+
return Decision(Action.SKIPPED, problem, attempt, owed=True)
|
|
7573
7804
|
|
|
7574
7805
|
# The resolved reviewers, re-requested after the push (which stales
|
|
7575
7806
|
# any approve). With none resolved the session falls back to the
|
|
@@ -7749,6 +7980,12 @@ class DevWatcher:
|
|
|
7749
7980
|
|
|
7750
7981
|
row = self.state.revert_spawn(pr.full_name, number, labeled_ts)
|
|
7751
7982
|
if row is not None:
|
|
7983
|
+
# Parking (issue #153): a label application the seat already
|
|
7984
|
+
# answered is settled once its spawn row leaves the stale window
|
|
7985
|
+
# -- the revert has no stale retry, and a session still at work
|
|
7986
|
+
# is counted by the roster, not here.
|
|
7987
|
+
revert_age = time.time() - int(row["spawned_at"])
|
|
7988
|
+
fresh = revert_age < self.config.stale_minutes * 60
|
|
7752
7989
|
return Decision(
|
|
7753
7990
|
Action.IN_FLIGHT,
|
|
7754
7991
|
f"revert session {row['session']} already spawned for the "
|
|
@@ -7756,6 +7993,8 @@ class DevWatcher:
|
|
|
7756
7993
|
f"application; unlabel and re-label to ask again)",
|
|
7757
7994
|
int(row["attempt"]),
|
|
7758
7995
|
session=str(row["session"]),
|
|
7996
|
+
owed=fresh,
|
|
7997
|
+
timer=self._stale_timer(revert_age) if fresh else None,
|
|
7759
7998
|
)
|
|
7760
7999
|
|
|
7761
8000
|
# Refusal 1: not the loop's own merge. The lever undoes the LOOP's
|
|
@@ -7927,6 +8166,25 @@ class DevWatcher:
|
|
|
7927
8166
|
labeled_iso: str,
|
|
7928
8167
|
default_branch: str,
|
|
7929
8168
|
budget: "SessionBudget | None" = None,
|
|
8169
|
+
) -> Decision:
|
|
8170
|
+
"""The revert edge's spawn, behind the drain gate (`_drain_gated`)."""
|
|
8171
|
+
return self._drain_gated(
|
|
8172
|
+
revert_session_name(pr, attempt), attempt, pr.pr_slug,
|
|
8173
|
+
lambda: self._spawn_revert_claimed(
|
|
8174
|
+
pr, attempt, labeled_ts, labeler, labeled_iso,
|
|
8175
|
+
default_branch, budget,
|
|
8176
|
+
),
|
|
8177
|
+
)
|
|
8178
|
+
|
|
8179
|
+
def _spawn_revert_claimed(
|
|
8180
|
+
self,
|
|
8181
|
+
pr: PullRequest,
|
|
8182
|
+
attempt: int,
|
|
8183
|
+
labeled_ts: int,
|
|
8184
|
+
labeler: str,
|
|
8185
|
+
labeled_iso: str,
|
|
8186
|
+
default_branch: str,
|
|
8187
|
+
budget: "SessionBudget | None" = None,
|
|
7930
8188
|
) -> Decision:
|
|
7931
8189
|
name = revert_session_name(pr, attempt)
|
|
7932
8190
|
# The max_sessions gate before the ledger row (as in _spawn_maintain):
|
|
@@ -7944,7 +8202,7 @@ class DevWatcher:
|
|
|
7944
8202
|
|
|
7945
8203
|
hub, problem = self._ensure_hub(pr.owner, pr.repo)
|
|
7946
8204
|
if problem is not None:
|
|
7947
|
-
return Decision(Action.SKIPPED, problem, attempt)
|
|
8205
|
+
return Decision(Action.SKIPPED, problem, attempt, owed=True)
|
|
7948
8206
|
|
|
7949
8207
|
task = revert_task_number(pr.body)
|
|
7950
8208
|
task_ref = f"TASK-{task}"
|
|
@@ -11165,6 +11423,11 @@ class DevWatcher:
|
|
|
11165
11423
|
"round": decision.round,
|
|
11166
11424
|
"session": decision.session,
|
|
11167
11425
|
"task_ref": decision.task_ref,
|
|
11426
|
+
"owed": decision_owed(decision),
|
|
11427
|
+
"timer": (
|
|
11428
|
+
{"kind": decision.timer[0], "until": decision.timer[1]}
|
|
11429
|
+
if decision.timer is not None else None
|
|
11430
|
+
),
|
|
11168
11431
|
}
|
|
11169
11432
|
|
|
11170
11433
|
def _write_snapshot(
|
|
@@ -11264,7 +11527,8 @@ class DevWatcher:
|
|
|
11264
11527
|
raise
|
|
11265
11528
|
except CommandError as exc:
|
|
11266
11529
|
log.error("%s: %s", slug, exc)
|
|
11267
|
-
|
|
11530
|
+
# Owed: an evaluation that failed is unknown, never settled.
|
|
11531
|
+
decision = Decision(Action.SKIPPED, str(exc), owed=True)
|
|
11268
11532
|
|
|
11269
11533
|
level = logging.INFO if decision.action != Action.SKIPPED else logging.DEBUG
|
|
11270
11534
|
log.log(level, "%s → %s (%s)", slug, decision.action.value, decision.reason)
|
|
@@ -11306,7 +11570,8 @@ class DevWatcher:
|
|
|
11306
11570
|
raise
|
|
11307
11571
|
except CommandError as exc:
|
|
11308
11572
|
log.error("%s: %s", slug, exc)
|
|
11309
|
-
|
|
11573
|
+
# Owed: an evaluation that failed is unknown, never settled.
|
|
11574
|
+
decision = Decision(Action.SKIPPED, str(exc), owed=True)
|
|
11310
11575
|
|
|
11311
11576
|
level = logging.INFO if decision.action != Action.SKIPPED else logging.DEBUG
|
|
11312
11577
|
log.log(level, "%s → %s (%s)", slug, decision.action.value, decision.reason)
|
|
@@ -11344,7 +11609,8 @@ class DevWatcher:
|
|
|
11344
11609
|
raise
|
|
11345
11610
|
except CommandError as exc:
|
|
11346
11611
|
log.error("%s: %s", slug, exc)
|
|
11347
|
-
|
|
11612
|
+
# Owed: an evaluation that failed is unknown, never settled.
|
|
11613
|
+
decision = Decision(Action.SKIPPED, str(exc), owed=True)
|
|
11348
11614
|
|
|
11349
11615
|
level = logging.INFO if decision.action != Action.SKIPPED else logging.DEBUG
|
|
11350
11616
|
log.log(level, "%s → %s (%s)", slug, decision.action.value, decision.reason)
|
|
@@ -11378,7 +11644,8 @@ class DevWatcher:
|
|
|
11378
11644
|
raise
|
|
11379
11645
|
except CommandError as exc:
|
|
11380
11646
|
log.error("%s: %s", slug, exc)
|
|
11381
|
-
|
|
11647
|
+
# Owed: an evaluation that failed is unknown, never settled.
|
|
11648
|
+
decision = Decision(Action.SKIPPED, str(exc), owed=True)
|
|
11382
11649
|
|
|
11383
11650
|
level = logging.INFO if decision.action != Action.SKIPPED else logging.DEBUG
|
|
11384
11651
|
log.log(level, "%s → %s (%s)", slug, decision.action.value, decision.reason)
|