alissa-tools-github-devloop 0.8.35__tar.gz → 0.8.37__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {alissa_tools_github_devloop-0.8.35/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.37}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa/tools/github/devloop/fleet_vitals.py +71 -2
  3. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa/tools/github/devloop/loop.py +275 -8
  4. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa/tools/github/devloop/loop_events.py +1 -0
  5. alissa_tools_github_devloop-0.8.37/src/main/alissa/tools/github/devloop/parking.py +205 -0
  6. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa/tools/github/devloop/state.py +133 -0
  7. alissa_tools_github_devloop-0.8.37/src/main/alissa/tools/github/devloop/version +1 -0
  8. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa/tools/github/devloop/webui/__main__.py +24 -1
  9. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa/tools/github/devloop/webui/server.py +124 -2
  10. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa/tools/github/devloop/webui/sources.py +51 -1
  11. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  12. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +1 -0
  13. alissa_tools_github_devloop-0.8.35/src/main/alissa/tools/github/devloop/version +0 -1
  14. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/LICENSE +0 -0
  15. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/MANIFEST.in +0 -0
  16. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/NOTICE +0 -0
  17. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/README.md +0 -0
  18. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/requirements.txt +0 -0
  19. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/setup.cfg +0 -0
  20. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/setup.py +0 -0
  21. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  22. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
  23. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  24. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
  25. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  26. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa/tools/github/devloop/config.py +0 -0
  27. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
  28. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa/tools/github/devloop/hubs.py +0 -0
  29. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
  30. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  31. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa/tools/github/devloop/prompts.py +0 -0
  32. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa/tools/github/devloop/version.py +0 -0
  33. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  34. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  35. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  36. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  37. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  38. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  39. {alissa_tools_github_devloop-0.8.35 → alissa_tools_github_devloop-0.8.37}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.35
3
+ Version: 0.8.37
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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
- decision = Decision(Action.SKIPPED, str(exc))
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
- decision = Decision(Action.SKIPPED, str(exc))
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
- decision = Decision(Action.SKIPPED, str(exc))
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
- decision = Decision(Action.SKIPPED, str(exc))
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)