alissa-tools-github-devloop 0.8.42__py3-none-any.whl → 0.8.43__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.
@@ -257,7 +257,8 @@ def build_snapshot(
257
257
  `queued` (the undispatched alissa queue items, None when unread) and
258
258
  `drain` (the holding drain row, or None) feed the `idle` block, whose
259
259
  owed / timers come from the latest poll snapshot and whose live /
260
- managed come from `roster` -- the same reading as `sessions`.
260
+ managed come from `roster` -- the same reading as `sessions`, with a dead
261
+ row backing no held lane left out of `managed` (issue #166).
261
262
 
262
263
  `config` is the seat's `config_block`, or None to leave the key out
263
264
  (the console's push, which has no derived answer to give)."""
@@ -275,13 +276,20 @@ def build_snapshot(
275
276
  if session and session not in repo_by_session:
276
277
  repo_by_session[str(session)] = str(row["repo_slug"])
277
278
 
279
+ # A dead row counts as managed only while it backs a lane the seat still
280
+ # holds (issue #166): Studio's park guard reads `sessions.managed` as well
281
+ # as the idle block's, so both follow the console's rule.
282
+ roster_live, roster_managed, _gone = parking.roster_counts(
283
+ roster, parking.lane_sessions(latest, ledgers=ledgers),
284
+ )
285
+
278
286
  sessions_block: "dict | None" = None
279
287
  session_list: "list[dict] | None" = None
280
288
  if roster is not None:
281
289
  records = sources.sessions(listing=[dict(r) for r in roster])
282
290
  sessions_block = {
283
291
  "live": sum(1 for r in records if r.get("live")),
284
- "managed": len(records),
292
+ "managed": roster_managed,
285
293
  }
286
294
  session_list = [
287
295
  _session_row(r, repo_by_session=repo_by_session, parse_session=parse)
@@ -307,11 +315,13 @@ def build_snapshot(
307
315
  + int(latest.get("revert_candidates") or 0)
308
316
  )
309
317
 
310
- roster_live, roster_managed = parking.roster_counts(roster)
311
318
  idle = parking.idle_block(
312
319
  latest, live=roster_live, managed=roster_managed, queued=queued,
313
320
  drain=drain, now=now_ts,
314
321
  )
322
+ # `gone` is the console's observation; Studio's idle schema is strict and
323
+ # does not carry it.
324
+ idle.pop("gone", None)
315
325
 
316
326
  drift = sources.drift()
317
327
  snapshot = {
@@ -8,8 +8,8 @@ the devloop half of that contract, shared by the daemon (which computes the
8
8
  verdict per pass and pushes it with the fleet vitals) and the console (which
9
9
  serves it on ``GET /api/state`` and runs the drain handshake):
10
10
 
11
- * **The idle block.** ``{asOf, passAt, owed, live, managed, queued, timers,
12
- drainedAt}``. `owed` counts the candidates whose LAST evaluation was an
11
+ * **The idle block.** ``{asOf, passAt, owed, live, managed, queued, gone,
12
+ timers, drainedAt}``. `owed` counts the candidates whose LAST evaluation was an
13
13
  action or a timer the seat owns (a spawn, a fresh spawn row in its stale
14
14
  window, an owed respawn, a deferral, a pending stall ping, a grace the seat
15
15
  is waiting out, an evaluation that failed); a candidate the seat has
@@ -17,7 +17,9 @@ serves it on ``GET /api/state`` and runs the drain handshake):
17
17
  owed. Each pass records the verdict per candidate on its poll snapshot's
18
18
  stage (`owed`, `timer`), so the block is read back from the snapshot and
19
19
  costs no GitHub call. `live` / `managed` / `queued` are read from tmux and
20
- the alissa queue by whoever builds the block.
20
+ the alissa queue by whoever builds the block. `managed` is the live rows
21
+ plus the dead rows whose session backs a lane the seat still holds; a dead
22
+ row backing nothing is reported under `gone` instead (`roster_counts`).
21
23
  * **Unknown is never idle.** No snapshot yet → `owed` is null; a listing that
22
24
  failed → `live` / `managed` / `queued` are null. `idle_refusals` reads every
23
25
  null as a refusal.
@@ -29,7 +31,7 @@ serves it on ``GET /api/state`` and runs the drain handshake):
29
31
  from __future__ import annotations
30
32
 
31
33
  from datetime import datetime, timezone
32
- from typing import Iterable, Mapping, Sequence
34
+ from typing import Collection, Iterable, Mapping, Sequence
33
35
 
34
36
  # The timer kinds of the design's idle block (§2.1). devloop owns the first
35
37
  # three; the last two are revloop's and are listed so the union is stated once.
@@ -111,19 +113,107 @@ def queued_items(queues: "object") -> "int | None":
111
113
  return count
112
114
 
113
115
 
116
+ # The tmux prefix `alissa tmux` puts on a managed session's real name
117
+ # (`alissa.MANAGED_PREFIX`, restated so this module imports nothing of the
118
+ # daemon's). A listing row carries both spellings (`name` bare, `session`
119
+ # prefixed), a snapshot stage the bare one, a queue the prefixed one.
120
+ MANAGED_PREFIX = "ali-"
121
+
122
+ # The spawn ledgers (`Sources.ledgers` keys) with the snapshot edge each one
123
+ # feeds, the column holding its issue / PR number, and the slug separator the
124
+ # poll snapshot uses for it (`owner/repo#7`, `owner/repo#pr16`).
125
+ LEDGER_LANES = (
126
+ ("spawns", "issue", "issue", "#"),
127
+ ("fix_spawns", "fix", "number", "#pr"),
128
+ ("maintain_spawns", "maintain", "number", "#pr"),
129
+ ("revert_spawns", "revert", "number", "#pr"),
130
+ )
131
+
132
+ # A stage holding one of these actions has a worker in flight on it whatever
133
+ # its owed verdict says: its session is never an orphan.
134
+ IN_FLIGHT_ACTIONS = frozenset({"spawned", "in-flight"})
135
+
136
+
137
+ def bare_session(name: "object") -> str:
138
+ """A session name without its `ali-` tmux prefix ("" for a non-name)."""
139
+ text = str(name or "")
140
+ return text[len(MANAGED_PREFIX):] if text.startswith(MANAGED_PREFIX) else text
141
+
142
+
143
+ def lane_sessions(
144
+ snapshot: "Mapping | None",
145
+ *,
146
+ ledgers: "Mapping | None" = None,
147
+ queues: "object" = None,
148
+ ) -> "frozenset[str] | None":
149
+ """The bare names of the sessions that back a lane the seat still holds
150
+ (issue #166): the session a stage that is owed or in flight names; every
151
+ session the spawn ledgers record on such a stage's lane (an earlier
152
+ attempt the lane may still resume); and every session the alissa queue
153
+ holds an undispatched item for. None before the first pass -- with no
154
+ snapshot nothing can be called an orphan, so every row stays managed."""
155
+ if snapshot is None:
156
+ return None
157
+ names: "set[str]" = set()
158
+ held: "set[tuple[str, str]]" = set()
159
+ for stage in snapshot.get("stages") or ():
160
+ if not isinstance(stage, Mapping):
161
+ continue
162
+ if not (stage_owed(stage) or stage.get("stage") in IN_FLIGHT_ACTIONS):
163
+ continue
164
+ held.add((str(stage.get("edge") or ""), str(stage.get("slug") or "")))
165
+ if stage.get("session"):
166
+ names.add(bare_session(stage["session"]))
167
+ for table, edge, column, sep in LEDGER_LANES:
168
+ for row in (ledgers or {}).get(table) or ():
169
+ lane = (edge, f"{row.get('repo_slug')}{sep}{row.get(column)}")
170
+ if lane in held and row.get("session"):
171
+ names.add(bare_session(row["session"]))
172
+ if isinstance(queues, list):
173
+ for queue in queues:
174
+ if not isinstance(queue, Mapping):
175
+ continue
176
+ if any(
177
+ isinstance(item, Mapping) and not item.get("lastDispatchedAt")
178
+ for item in queue.get("items") or ()
179
+ ):
180
+ names.add(bare_session(queue.get("session")))
181
+ names.discard("")
182
+ return frozenset(names)
183
+
184
+
114
185
  def roster_counts(
115
186
  roster: "Sequence[Mapping] | None",
116
- ) -> "tuple[int | None, int | None]":
117
- """`(live, managed)` from an ``alissa tmux ls --json`` listing, the
118
- fleet-vitals `sessions` rule: a row is live unless it says otherwise.
119
- `(None, None)` when the listing failed."""
187
+ lanes: "Collection[str] | None" = None,
188
+ ) -> "tuple[int | None, int | None, list[str] | None]":
189
+ """`(live, managed, gone)` from an ``alissa tmux ls --json`` listing.
190
+
191
+ A row is live unless it says otherwise (the fleet-vitals `sessions`
192
+ rule: its `live` flag, else `status != "gone"`). A row is MANAGED when it
193
+ is live or when its session backs a lane the seat still holds -- `lanes`,
194
+ the bare names `lane_sessions` returns. A dead row matching no lane is an
195
+ orphan: it is not managed, and its name is reported in `gone` instead
196
+ (issue #166 -- one such row kept orcloop's `managed == 0` from ever
197
+ holding). `lanes=None` (no completed pass to read lanes from) keeps every
198
+ row managed: unknown is never idle. `(None, None, None)` when the listing
199
+ failed."""
120
200
  if roster is None:
121
- return None, None
122
- rows = [r for r in roster if isinstance(r, Mapping)]
123
- live = sum(
124
- 1 for r in rows if bool(r.get("live", r.get("status") != "gone"))
125
- )
126
- return live, len(rows)
201
+ return None, None, None
202
+ live = managed = 0
203
+ gone: "list[str]" = []
204
+ for row in roster:
205
+ if not isinstance(row, Mapping):
206
+ continue
207
+ if bool(row.get("live", row.get("status") != "gone")):
208
+ live += 1
209
+ managed += 1
210
+ continue
211
+ name = bare_session(row.get("name") or row.get("session"))
212
+ if lanes is None or name in lanes or bare_session(row.get("session")) in lanes:
213
+ managed += 1
214
+ else:
215
+ gone.append(name)
216
+ return live, managed, sorted(gone)
127
217
 
128
218
 
129
219
  def idle_block(
@@ -134,10 +224,13 @@ def idle_block(
134
224
  queued: "int | None",
135
225
  drain: "Mapping | None",
136
226
  now: "float | int",
227
+ gone: "Sequence[str] | None" = None,
137
228
  ) -> dict:
138
229
  """The seat's idle block from its latest poll snapshot (None before the
139
230
  first pass) and the caller's live readings. `drain` is the holding drain
140
- row (`State.drain`) or None."""
231
+ row (`State.drain`) or None. `gone` is `roster_counts`' orphaned rows,
232
+ reported as `{count, sessions}` (null when the listing was not read) --
233
+ an observation for the operator, never a refusal."""
141
234
  stages: "Iterable[Mapping]" = (snapshot or {}).get("stages") or ()
142
235
  owed: "int | None" = None
143
236
  timers: "list[dict]" = []
@@ -157,6 +250,10 @@ def idle_block(
157
250
  "live": live,
158
251
  "managed": managed,
159
252
  "queued": queued,
253
+ "gone": (
254
+ {"count": len(gone), "sessions": list(gone)}
255
+ if gone is not None else None
256
+ ),
160
257
  "timers": timers,
161
258
  "drainedAt": iso(drain.get("drained_at")) if drain else None,
162
259
  }
@@ -1 +1 @@
1
- 0.8.42
1
+ 0.8.43
@@ -438,17 +438,25 @@ class Sources:
438
438
  a drain holds. A caller that already holds the listing (None when it
439
439
  failed) or the latest snapshot (None before the first pass) hands it
440
440
  in; anything not handed in is read here. A failed listing is null
441
- counts, never zeros."""
441
+ counts, never zeros. A dead row backing no lane the seat holds is
442
+ reported under `gone`, not counted as managed (issue #166)."""
442
443
  if latest is _UNREAD:
443
444
  snaps = self.snapshots(1)
444
445
  latest = snaps[0] if snaps else None
445
446
  if listing is _UNREAD:
446
447
  listing = self._safe_json(["alissa", "tmux", "ls", "--json"])
447
448
  roster = listing if isinstance(listing, list) else None
448
- live, managed = parking.roster_counts(roster)
449
+ snapshot = latest if isinstance(latest, dict) else None
450
+ queues = self._safe_json(["alissa", "tmux", "queue", "ls", "--json"])
451
+ # A dead row counts as managed only while it backs a lane the seat
452
+ # still holds (issue #166).
453
+ lanes = parking.lane_sessions(
454
+ snapshot, ledgers=self.ledgers(), queues=queues,
455
+ )
456
+ live, managed, gone = parking.roster_counts(roster, lanes)
449
457
  return parking.idle_block(
450
- latest if isinstance(latest, dict) else None,
451
- live=live, managed=managed, queued=self.queued(),
458
+ snapshot, live=live, managed=managed,
459
+ queued=parking.queued_items(queues), gone=gone,
452
460
  drain=self.drain(), now=self._wall(),
453
461
  )
454
462
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.42
3
+ Version: 0.8.43
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -194,6 +194,32 @@ resolved from the remote name or URL in the directory the push runs in
194
194
  no marker are untouched. `ALISSA_SHELL_GUARD=off` disables this guard with
195
195
  the rest.
196
196
 
197
+ ## Seat parking: what counts as managed (issue #166)
198
+
199
+ The idle block on `GET /api/state` (`parking.idle_block`, issue #153) is what
200
+ orcloop's D2 invariant reads before it drains and parks the seat: idle only
201
+ when `live == managed == queued == owed == 0` with no timer and a fresh pass.
202
+ `live` and `managed` come from `alissa tmux ls --json`:
203
+
204
+ - a row is **live** unless it says otherwise (its `live` flag, else
205
+ `status != "gone"`);
206
+ - a row is **managed** when it is live, **or** when it is dead but its
207
+ session still backs a lane the seat holds — the session named by a stage
208
+ of the latest poll snapshot that is owed or in flight (`spawned` /
209
+ `in-flight`), any session the spawn ledgers record on such a stage's lane
210
+ (an earlier attempt the lane may still resume), or a session the alissa
211
+ queue holds an undispatched item for;
212
+ - a dead row backing none of these is an **orphan**: it is not managed, and
213
+ is reported in the block's `gone` field as `{count, sessions}` so the
214
+ console and orcloop's logs still show it.
215
+
216
+ Unknown is never idle: a failed listing leaves `live`, `managed` and `gone`
217
+ null, and before the first completed pass (no snapshot to read lanes from)
218
+ every dead row stays managed. The console only reports an orphan — it never
219
+ kills or reaps one. The fleet-vitals push applies the same rule to
220
+ `sessions.managed` and `idle.managed` (Studio's park guard reads both) and
221
+ leaves `gone` out, since Studio's idle schema is strict.
222
+
197
223
  ## License
198
224
 
199
225
  Licensed under the
@@ -4,29 +4,29 @@ alissa/tools/github/devloop/alissa.py,sha256=TJU4eSZc2MvP35HewCwBKvhPAel32vKjtt3
4
4
  alissa/tools/github/devloop/alissa_client.py,sha256=wmpXYWf9ThcLeYnz6gGeDz7gj5BpYjduuVASMc-QOOY,14873
5
5
  alissa/tools/github/devloop/bows.py,sha256=pzDNMRk_S78FX7vMNYMlXHxW6VPHPo1Xd2EzFMgVCUM,17325
6
6
  alissa/tools/github/devloop/config.py,sha256=j6W0Wrz7smFdFdSs3g5S6xadcxfFS5YEhQDL64yAuJk,75109
7
- alissa/tools/github/devloop/fleet_vitals.py,sha256=Hl2xshweh7A9R0N7fyYRg2WzHN4aNefVlt0TiXCv8zk,24979
7
+ alissa/tools/github/devloop/fleet_vitals.py,sha256=5mWnRkJh8u9soSplud3UMxkelT6EqAiz-ivX8UmfIqk,25477
8
8
  alissa/tools/github/devloop/ghclient.py,sha256=tYUHwmoy9CBBt-jSOz9nuAl8lpMvm1LH4A-wHx25bss,71842
9
9
  alissa/tools/github/devloop/hubs.py,sha256=yjnsnSHLG7GNNP8X2hILeAMV6P5n4JGc_IaAxpSP_YQ,18974
10
10
  alissa/tools/github/devloop/janitor.py,sha256=R8REoHBeY2cfW7fYJVhKr-F5z9Mjlc-vmv-z4KtvBZc,27360
11
11
  alissa/tools/github/devloop/loop.py,sha256=uMfuomSdIPC8KkWUGF0OrW3RR9OSC1txKVnXGZ1SeIc,613973
12
12
  alissa/tools/github/devloop/loop_events.py,sha256=tmso0OALqY2yAPW2Fo6ae0sYetQIdIeIYooJ2Sc2cc4,37921
13
- alissa/tools/github/devloop/parking.py,sha256=2Sys1bh_hG8qbq7cO9RxW1F1kAu3dCWHnvwg_iAFVvY,7987
13
+ alissa/tools/github/devloop/parking.py,sha256=vEf-qprN14-I4vu4M53tIFaA3827FiHLpKetBAgN4LQ,12409
14
14
  alissa/tools/github/devloop/proc.py,sha256=nzfsxJSHFWRQcrXmfv588KazFysv8dVQrhMPH1q5eRk,3134
15
15
  alissa/tools/github/devloop/prompts.py,sha256=VzN6ec5eF2wwg7QSCRg52WLcxo-tGbGRgQbc4HSahUc,55169
16
16
  alissa/tools/github/devloop/state.py,sha256=uVwhbkogUTjWXXFq-COKzUeYrpuGbHzi05CjfEzAtPA,88668
17
- alissa/tools/github/devloop/version,sha256=AuempWUg4vcEdSLs24Raav8UvlQqlRrI2s1oyudhK4I,7
17
+ alissa/tools/github/devloop/version,sha256=w6XCzs4ibf7WAa6RiS5RhCLRVAdNmHRvxJaC9NKDINI,7
18
18
  alissa/tools/github/devloop/version.py,sha256=znuuOaitXGrBpbskNy78_rlVpFdtmKmk_3k1ACxZrl0,1418
19
19
  alissa/tools/github/devloop/webui/__init__.py,sha256=vdXYoyGYzD7BrJ5lV6XdH8Bpw_pej8-RK0hjfxop7-8,1513
20
20
  alissa/tools/github/devloop/webui/__main__.py,sha256=wORyW5AkgsniK0TZcrOrvm_P7Q4Mr-fH-4S6qj9XcgA,6137
21
21
  alissa/tools/github/devloop/webui/auth.py,sha256=g9YEDao9nKt-cBEENp7vFqwJ-T8olN6uLiCzVIN8yhA,7890
22
22
  alissa/tools/github/devloop/webui/page.py,sha256=4OD3e01ByvVM4Wo04AZyFuyOYTFEMT2lagktZAvyCKk,30862
23
23
  alissa/tools/github/devloop/webui/server.py,sha256=YuUmcng5qdWqSurcuyxH3FKKkBBfrpDtvtZLidJOGiU,26226
24
- alissa/tools/github/devloop/webui/sources.py,sha256=Ud8357-Ag4qC39V3mlepqnArui9p1fTJ058bAdN29Jc,36730
24
+ alissa/tools/github/devloop/webui/sources.py,sha256=8_f5kQv6DGD0A2Y4goKH-TSIQAab-O5uoO3f2nNRhI0,37209
25
25
  alissa/tools/github/devloop/webui/sysinfo.py,sha256=ptlogH9alFv0vZ-pQfyUaA3w-K7LQS3tQHGv0d8Tdlk,9649
26
- alissa_tools_github_devloop-0.8.42.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
27
- alissa_tools_github_devloop-0.8.42.dist-info/licenses/NOTICE,sha256=FKstNs3uw_tQod4_1XcFLD1FUTJQXf4FC0oXJAOA_HE,260
28
- alissa_tools_github_devloop-0.8.42.dist-info/METADATA,sha256=ZITb9-7j5ZOmFkpcc4wmIz6DRT7_1HygSpTD7sHLp50,10759
29
- alissa_tools_github_devloop-0.8.42.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
30
- alissa_tools_github_devloop-0.8.42.dist-info/entry_points.txt,sha256=8aC7Ajzx4bu9ZRR37a40IDfuStacvZP5KsAxY1dbFKU,145
31
- alissa_tools_github_devloop-0.8.42.dist-info/top_level.txt,sha256=DodjDg-l-TWQnlxG-Vuc3G5sB4O7cWGQwe6XrVx3u-A,7
32
- alissa_tools_github_devloop-0.8.42.dist-info/RECORD,,
26
+ alissa_tools_github_devloop-0.8.43.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
27
+ alissa_tools_github_devloop-0.8.43.dist-info/licenses/NOTICE,sha256=FKstNs3uw_tQod4_1XcFLD1FUTJQXf4FC0oXJAOA_HE,260
28
+ alissa_tools_github_devloop-0.8.43.dist-info/METADATA,sha256=ostOBBFoEkmV42blENKqNX5CrVK2dCdPHLIxfYVrzuY,12222
29
+ alissa_tools_github_devloop-0.8.43.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
30
+ alissa_tools_github_devloop-0.8.43.dist-info/entry_points.txt,sha256=8aC7Ajzx4bu9ZRR37a40IDfuStacvZP5KsAxY1dbFKU,145
31
+ alissa_tools_github_devloop-0.8.43.dist-info/top_level.txt,sha256=DodjDg-l-TWQnlxG-Vuc3G5sB4O7cWGQwe6XrVx3u-A,7
32
+ alissa_tools_github_devloop-0.8.43.dist-info/RECORD,,