alissa-tools-github-devloop 0.8.52__py3-none-any.whl → 0.8.54__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/hungtests.py +168 -4
- alissa/tools/github/devloop/loop.py +507 -36
- alissa/tools/github/devloop/loop_events.py +4 -2
- alissa/tools/github/devloop/version +1 -1
- {alissa_tools_github_devloop-0.8.52.dist-info → alissa_tools_github_devloop-0.8.54.dist-info}/METADATA +1 -1
- {alissa_tools_github_devloop-0.8.52.dist-info → alissa_tools_github_devloop-0.8.54.dist-info}/RECORD +11 -11
- {alissa_tools_github_devloop-0.8.52.dist-info → alissa_tools_github_devloop-0.8.54.dist-info}/WHEEL +0 -0
- {alissa_tools_github_devloop-0.8.52.dist-info → alissa_tools_github_devloop-0.8.54.dist-info}/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.8.52.dist-info → alissa_tools_github_devloop-0.8.54.dist-info}/licenses/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.52.dist-info → alissa_tools_github_devloop-0.8.54.dist-info}/licenses/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.52.dist-info → alissa_tools_github_devloop-0.8.54.dist-info}/top_level.txt +0 -0
|
@@ -125,16 +125,23 @@ class TestTree:
|
|
|
125
125
|
@property
|
|
126
126
|
def session(self) -> "str | None":
|
|
127
127
|
"""The owning session's roster name (no `ali-` prefix), or None."""
|
|
128
|
-
|
|
129
|
-
if not name:
|
|
130
|
-
return None
|
|
131
|
-
return name[len(TMUX_PREFIX):] if name.startswith(TMUX_PREFIX) else name
|
|
128
|
+
return roster_name(self.tmux_session)
|
|
132
129
|
|
|
133
130
|
@property
|
|
134
131
|
def command(self) -> str:
|
|
135
132
|
return " ".join(self.root.argv)
|
|
136
133
|
|
|
137
134
|
|
|
135
|
+
def roster_name(tmux_session: "str | None") -> "str | None":
|
|
136
|
+
"""A seat env's `ALISSA_TMUX_SESSION` as the roster's name (no `ali-`
|
|
137
|
+
prefix), or None when the env names no session."""
|
|
138
|
+
if not tmux_session:
|
|
139
|
+
return None
|
|
140
|
+
if tmux_session.startswith(TMUX_PREFIX):
|
|
141
|
+
return tmux_session[len(TMUX_PREFIX):] or None
|
|
142
|
+
return tmux_session
|
|
143
|
+
|
|
144
|
+
|
|
138
145
|
def _clock_ticks() -> int:
|
|
139
146
|
try:
|
|
140
147
|
return int(os.sysconf("SC_CLK_TCK")) or 100
|
|
@@ -424,6 +431,19 @@ def kill_tree(
|
|
|
424
431
|
if current is None or current.start != tree.root.start:
|
|
425
432
|
return 0, 0
|
|
426
433
|
members = [m for m in descendants(fresh, tree.root.pid) if m.alive]
|
|
434
|
+
return kill_procs(members, root=root, grace=grace)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def kill_procs(
|
|
438
|
+
members: "Sequence[Proc]",
|
|
439
|
+
*,
|
|
440
|
+
root: "Path | None" = None,
|
|
441
|
+
grace: float = KILL_GRACE_SECONDS,
|
|
442
|
+
) -> "tuple[int, int]":
|
|
443
|
+
"""TERM every member still alive under its recorded start time, wait up
|
|
444
|
+
to `grace` seconds, then KILL the survivors. Returns (termed, killed).
|
|
445
|
+
A member whose pid now belongs to another process is never signalled."""
|
|
446
|
+
members = [m for m in members if _still_alive(m, root)]
|
|
427
447
|
for member in members:
|
|
428
448
|
send_signal(member.pid, signal.SIGTERM)
|
|
429
449
|
deadline = monotonic() + grace
|
|
@@ -479,3 +499,147 @@ def hung_message(tree: TestTree, minutes: int, reason: str) -> str:
|
|
|
479
499
|
f"touches, each on its own, one file per command. CI on the PR runs "
|
|
480
500
|
f"the full suite."
|
|
481
501
|
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
# -- the dead-session process reaper (issue #186) -----------------------------
|
|
505
|
+
#
|
|
506
|
+
# 2026-10-19: a crashed fix session's tmux pane sat at bash for 17 h while the
|
|
507
|
+
# seat also carried orphan test runs of sessions long finished and ~20 idle
|
|
508
|
+
# Claude Code executor helpers (`node /tmp/.tmpXXXX/local.cjs --ipc-path
|
|
509
|
+
# ….executor.sock`, ~115 MB each). What follows finds what a DEAD session left
|
|
510
|
+
# running; the policy -- which sessions count as dead, and when to sweep --
|
|
511
|
+
# lives in loop.py.
|
|
512
|
+
|
|
513
|
+
# The executor helper's script and the socket argument that identify it.
|
|
514
|
+
EXECUTOR_SCRIPT = "local.cjs"
|
|
515
|
+
EXECUTOR_SOCKET_SUFFIX = ".executor.sock"
|
|
516
|
+
|
|
517
|
+
# Processes never signalled whatever their env says: the tmux server hosts
|
|
518
|
+
# EVERY session on the seat (one `tmux new-session -d` server), so killing it
|
|
519
|
+
# would take the live sessions down with the dead one.
|
|
520
|
+
_NEVER_KILL = frozenset({"tmux", "tmux: server", "tini"})
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
@dataclass(frozen=True)
|
|
524
|
+
class Orphan:
|
|
525
|
+
"""One process a dead session left behind: a member of a process tree
|
|
526
|
+
rooted in a task workspace (`workspace` set), or a parentless executor
|
|
527
|
+
helper (`workspace` None). `session` is the roster name its env names."""
|
|
528
|
+
|
|
529
|
+
proc: Proc
|
|
530
|
+
session: "str | None"
|
|
531
|
+
workspace: "TaskWorkspace | None" = None
|
|
532
|
+
|
|
533
|
+
@property
|
|
534
|
+
def command(self) -> str:
|
|
535
|
+
return " ".join(self.proc.argv)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def is_executor(argv: "Sequence[str]") -> bool:
|
|
539
|
+
"""Is this Claude Code's executor helper: a `local.cjs` script given an
|
|
540
|
+
`….executor.sock` IPC path. Exact tokens, like `is_test_runner`."""
|
|
541
|
+
return (
|
|
542
|
+
any(os.path.basename(arg) == EXECUTOR_SCRIPT for arg in argv)
|
|
543
|
+
and any(arg.endswith(EXECUTOR_SOCKET_SUFFIX) for arg in argv)
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def protected_pids(
|
|
548
|
+
procs: "Mapping[int, Proc]", self_pid: "int | None" = None
|
|
549
|
+
) -> "set[int]":
|
|
550
|
+
"""This process, every ancestor of it, and pid 1: the reaper never
|
|
551
|
+
signals itself or the chain that started it."""
|
|
552
|
+
me = os.getpid() if self_pid is None else self_pid
|
|
553
|
+
protected = {1, me}
|
|
554
|
+
current = procs.get(me)
|
|
555
|
+
while current is not None and current.ppid not in protected:
|
|
556
|
+
protected.add(current.ppid)
|
|
557
|
+
current = procs.get(current.ppid)
|
|
558
|
+
return protected
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _spared(proc: Proc, protected: "set[int]") -> bool:
|
|
562
|
+
if not proc.alive or proc.pid in protected:
|
|
563
|
+
return True
|
|
564
|
+
head = os.path.basename(proc.argv[0]) if proc.argv else ""
|
|
565
|
+
return head in _NEVER_KILL or head.startswith("tmux")
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def find_session_orphans(
|
|
569
|
+
procs: "Mapping[int, Proc]",
|
|
570
|
+
hubs: "Sequence[Path]",
|
|
571
|
+
is_dead: "Callable[[str], bool]",
|
|
572
|
+
root: "Path | None" = None,
|
|
573
|
+
self_pid: "int | None" = None,
|
|
574
|
+
) -> "list[Orphan]":
|
|
575
|
+
"""Every process tree rooted in a task workspace of `hubs` whose owner
|
|
576
|
+
session `is_dead` -- and nothing else.
|
|
577
|
+
|
|
578
|
+
A ROOT is a process whose cwd or a path argument sits inside a task
|
|
579
|
+
workspace (`workspace_of`, the janitor's grammar: `main/`, `.source/` and
|
|
580
|
+
the hub itself never qualify) AND whose env names a session `is_dead`
|
|
581
|
+
answers True for. A process naming no session is never a root: it is not
|
|
582
|
+
a worker's (the seat exports `ALISSA_TMUX_SESSION` to every process a
|
|
583
|
+
session starts), so an operator's shell parked in a worktree is safe.
|
|
584
|
+
Below a root, every descendant goes too -- a test worker that moved its
|
|
585
|
+
cwd to `/tmp`, a child an env-scrubbing helper started -- unless its env
|
|
586
|
+
names a session that is NOT dead. The tmux server, the reaper itself and
|
|
587
|
+
its ancestors are never returned."""
|
|
588
|
+
resolved = []
|
|
589
|
+
for hub in hubs:
|
|
590
|
+
try:
|
|
591
|
+
resolved.append(Path(hub).resolve())
|
|
592
|
+
except OSError:
|
|
593
|
+
continue
|
|
594
|
+
protected = protected_pids(procs, self_pid)
|
|
595
|
+
sessions: "dict[int, str | None]" = {}
|
|
596
|
+
|
|
597
|
+
def session_of(pid: int) -> "str | None":
|
|
598
|
+
if pid not in sessions:
|
|
599
|
+
sessions[pid] = roster_name(session_env(pid, root)[0])
|
|
600
|
+
return sessions[pid]
|
|
601
|
+
|
|
602
|
+
found: "dict[int, Orphan]" = {}
|
|
603
|
+
for proc in sorted(procs.values(), key=lambda p: p.pid):
|
|
604
|
+
if proc.pid in found or _spared(proc, protected):
|
|
605
|
+
continue
|
|
606
|
+
workspace = workspace_of(proc, resolved)
|
|
607
|
+
if workspace is None:
|
|
608
|
+
continue
|
|
609
|
+
session = session_of(proc.pid)
|
|
610
|
+
if session is None or not is_dead(session):
|
|
611
|
+
continue
|
|
612
|
+
for member in descendants(procs, proc.pid):
|
|
613
|
+
if member.pid in found or _spared(member, protected):
|
|
614
|
+
continue
|
|
615
|
+
owner = session_of(member.pid)
|
|
616
|
+
if owner is not None and not is_dead(owner):
|
|
617
|
+
continue
|
|
618
|
+
found[member.pid] = Orphan(
|
|
619
|
+
proc=member, session=owner or session, workspace=workspace
|
|
620
|
+
)
|
|
621
|
+
return sorted(found.values(), key=lambda o: o.proc.pid)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def find_parentless_executors(
|
|
625
|
+
procs: "Mapping[int, Proc]",
|
|
626
|
+
is_alive: "Callable[[str], bool]",
|
|
627
|
+
root: "Path | None" = None,
|
|
628
|
+
self_pid: "int | None" = None,
|
|
629
|
+
) -> "list[Orphan]":
|
|
630
|
+
"""Every executor helper (`is_executor`) whose parent is gone: re-parented
|
|
631
|
+
to init (pid 1) or to a pid the table no longer holds. One whose env names
|
|
632
|
+
a session `is_alive` answers True for is spared -- it may still be that
|
|
633
|
+
session's."""
|
|
634
|
+
protected = protected_pids(procs, self_pid)
|
|
635
|
+
found = []
|
|
636
|
+
for proc in sorted(procs.values(), key=lambda p: p.pid):
|
|
637
|
+
if _spared(proc, protected) or not is_executor(proc.argv):
|
|
638
|
+
continue
|
|
639
|
+
if proc.ppid != 1 and proc.ppid in procs:
|
|
640
|
+
continue
|
|
641
|
+
session = roster_name(session_env(proc.pid, root)[0])
|
|
642
|
+
if session is not None and is_alive(session):
|
|
643
|
+
continue
|
|
644
|
+
found.append(Orphan(proc=proc, session=session))
|
|
645
|
+
return found
|
|
@@ -449,6 +449,7 @@ from __future__ import annotations
|
|
|
449
449
|
|
|
450
450
|
import calendar
|
|
451
451
|
import dataclasses
|
|
452
|
+
import itertools
|
|
452
453
|
import json
|
|
453
454
|
import logging
|
|
454
455
|
import os
|
|
@@ -965,8 +966,10 @@ RESUME_DIRECTIVE = (
|
|
|
965
966
|
# render_discovery_directive for what it says when they do not.
|
|
966
967
|
# The directive is assembled from the pieces below so that the web variant
|
|
967
968
|
# (DISCOVERY_WEB_DIRECTIVE, issue #180) shares every clause it does not
|
|
968
|
-
# change. The concatenation
|
|
969
|
-
#
|
|
969
|
+
# change. The concatenation was byte-for-byte the v0.1 text until issue #188
|
|
970
|
+
# (evidence EV-2) widened the write rule to an `evidence/` folder and added
|
|
971
|
+
# _DISCOVERY_EVIDENCE; the sha256 pins in test_discovery.py were re-pinned for
|
|
972
|
+
# exactly that.
|
|
970
973
|
_DISCOVERY_ROLE = (
|
|
971
974
|
"You are a DISCOVERY worker, not an implementer and not a reviewer: this "
|
|
972
975
|
"round writes NO code. Answer the questions of GitHub issue {issue_url} "
|
|
@@ -1006,8 +1009,38 @@ _DISCOVERY_CODE_READ = (
|
|
|
1006
1009
|
"OUTPUT. "
|
|
1007
1010
|
)
|
|
1008
1011
|
_DISCOVERY_WRITE_DIR = (
|
|
1009
|
-
"WRITE exactly one directory, `{kb_dir}/`, holding `findings.md` and "
|
|
1010
|
-
"
|
|
1012
|
+
"WRITE exactly one directory, `{kb_dir}/`, holding `findings.md` and, "
|
|
1013
|
+
"when a question is better answered with an artifact, a flat `evidence/` "
|
|
1014
|
+
"folder beside it — nothing else; no other file anywhere in the "
|
|
1015
|
+
"repository changes, and this session's push guard refuses a push that "
|
|
1016
|
+
"carries any other path. "
|
|
1017
|
+
)
|
|
1018
|
+
# Evidence EV-2 (issue #188, operator decision 2026-10-20, findings v1.2 in
|
|
1019
|
+
# the plans repository's README): what a finding may carry beside
|
|
1020
|
+
# findings.md, and how it is cited. The types and caps are K1's and K4's,
|
|
1021
|
+
# the citation rule is K9's, and the hygiene sentence is K5's in words: the
|
|
1022
|
+
# evidence is pushed to a repository other people read, so no secret and no
|
|
1023
|
+
# personal or production data may ride in it.
|
|
1024
|
+
_DISCOVERY_EVIDENCE = (
|
|
1025
|
+
"EVIDENCE: when an artifact answers a question better than prose, save "
|
|
1026
|
+
"it under `{kb_dir}/evidence/` — flat (no subfolders), each name at "
|
|
1027
|
+
"most 64 characters of lower-case letters, digits, `.`, `_` and `-`, "
|
|
1028
|
+
"starting with a letter or digit — and cite EACH file from "
|
|
1029
|
+
"`findings.md` as `evidence:<file>`, inline (`the settings page today "
|
|
1030
|
+
"[evidence:settings-1440.png]`) and/or as a `kind: evidence` cite naming "
|
|
1031
|
+
"the `file` in the question's or risk's `cites` (that form needs "
|
|
1032
|
+
"`schema: alissa-findings/v1.2`): every file is cited at least once and "
|
|
1033
|
+
"every cite names a file in the folder (K9). For a question about a "
|
|
1034
|
+
"screen, take screenshots with this seat's headless Chromium "
|
|
1035
|
+
"(`chrome-headless-shell` / Playwright) at 1440 and 390 pixels wide; for "
|
|
1036
|
+
"a flow or an architecture, a Mermaid (`.mmd`) or D2 (`.d2`) diagram; "
|
|
1037
|
+
"for counts and data, a small CSV or JSON extract. Stay inside the "
|
|
1038
|
+
"evidence limits: only `png`, `jpg`/`jpeg`, `webp`, `svg` (inert), "
|
|
1039
|
+
"`csv`, `json`, `mmd`, `d2`, `txt` and `log` files, at most 2 MB per "
|
|
1040
|
+
"file, 10 MB and 20 files per folder. NEVER put secrets, tokens, "
|
|
1041
|
+
"credentials, personal data or production data in evidence — a "
|
|
1042
|
+
"screenshot or an extract included. Large or interactive artifacts "
|
|
1043
|
+
"(prototypes, videos) stay links, never files. "
|
|
1011
1044
|
)
|
|
1012
1045
|
_DISCOVERY_LIMITS = (
|
|
1013
1046
|
"Stay inside "
|
|
@@ -1071,6 +1104,7 @@ DISCOVERY_DIRECTIVE = (
|
|
|
1071
1104
|
"file's git blob SHA at the pinned SHA) or marked `unanswerable` with a "
|
|
1072
1105
|
"reason; then a `## Summary` and one section per question. "
|
|
1073
1106
|
+ _DISCOVERY_LIMITS
|
|
1107
|
+
+ _DISCOVERY_EVIDENCE
|
|
1074
1108
|
+ _DISCOVERY_LINT
|
|
1075
1109
|
+ ". "
|
|
1076
1110
|
+ _DISCOVERY_LINT_GATE
|
|
@@ -1180,6 +1214,8 @@ DISCOVERY_WEB_DIRECTIVE = (
|
|
|
1180
1214
|
"question. "
|
|
1181
1215
|
+ _DISCOVERY_LIMITS
|
|
1182
1216
|
+ "At most 48 URL cites per file. "
|
|
1217
|
+
+ _DISCOVERY_EVIDENCE
|
|
1218
|
+
+ "A large or interactive artifact is cited by its URL, never saved. "
|
|
1183
1219
|
+ _DISCOVERY_LINT
|
|
1184
1220
|
+ ", and the K8 external-cite check (Studio fetches every URL cite and "
|
|
1185
1221
|
"its archive). "
|
|
@@ -1207,7 +1243,10 @@ DISCOVERY_FIX_NOTE = (
|
|
|
1207
1243
|
"alissa-discovery v1 -->`): its review is the one light round, and this "
|
|
1208
1244
|
"is its one fix round — revloop starts no second round, and orcloop "
|
|
1209
1245
|
"merges on the plan-lint run of record at the head you push. Change "
|
|
1210
|
-
"nothing but the PR's one `knowledge-base/…/TASK-<n
|
|
1246
|
+
"nothing but the PR's one `knowledge-base/…/TASK-<n>/` directory — its "
|
|
1247
|
+
"`findings.md` and the flat files of its `evidence/` folder, every one "
|
|
1248
|
+
"still cited as `evidence:<file>` and free of secrets, personal and "
|
|
1249
|
+
"production data. "
|
|
1211
1250
|
"Answer each pursued finding by FIXING it (a corrected answer, a cite "
|
|
1212
1251
|
"that carries the claim, a quote copied verbatim) or by DOWNGRADING the "
|
|
1213
1252
|
"answer: lower its confidence, mark the question `partial` or "
|
|
@@ -1851,6 +1890,14 @@ FIX_STALLED_COMMENT = (
|
|
|
1851
1890
|
"close the PR to park the work."
|
|
1852
1891
|
)
|
|
1853
1892
|
|
|
1893
|
+
# The tail every FIX STALLED ping carries (issue #186): what the session's
|
|
1894
|
+
# pane runs right now -- a bare shell means its claude is gone -- and the
|
|
1895
|
+
# promise that the ping repeats while the stall persists.
|
|
1896
|
+
FIX_STALLED_TAIL = (
|
|
1897
|
+
" Pane foreground command: {pane}. This ping repeats every "
|
|
1898
|
+
"{repeat_hours} h while the stall persists{ordinal}."
|
|
1899
|
+
)
|
|
1900
|
+
|
|
1854
1901
|
# The ALIVE twin of FIX_STALLED_COMMENT (issue #142): the fix edge no longer
|
|
1855
1902
|
# respawns a stale round on the timer alone, so a session that is LISTED but
|
|
1856
1903
|
# has posted no triage reply defers -- floored by this once-per-episode ping
|
|
@@ -2649,6 +2696,26 @@ ESCALATION_FIRST_RUN_DIALOG = "first-run-dialog"
|
|
|
2649
2696
|
WEDGE_CLAUDE_EXITED = "wedged:claude-exited"
|
|
2650
2697
|
ESCALATION_CLAUDE_EXITED = "claude-exited"
|
|
2651
2698
|
|
|
2699
|
+
# The fix edge's claude-exited episode (issue #186): the same wedge read on a
|
|
2700
|
+
# stale FIX round (fix_claude_exited_kind keys it per round and attempt). The
|
|
2701
|
+
# `fix-` prefix keeps it a PR-anchored kind for the loop-events emitter.
|
|
2702
|
+
ESCALATION_FIX_CLAUDE_EXITED = "fix-claude-exited"
|
|
2703
|
+
|
|
2704
|
+
# How many consecutive polls a fix session's pane must read a bare shell
|
|
2705
|
+
# (`SHELL_COMMANDS`) before the fix edge reads the session DEAD (issue #186).
|
|
2706
|
+
# Two, not one: a single read can catch the pane between processes, and the
|
|
2707
|
+
# respawn it licenses lands on the same branch. A poll is one pass's roster
|
|
2708
|
+
# (`SessionLiveness.seq`), so re-reads inside one pass never count twice.
|
|
2709
|
+
SHELL_PANE_DEAD_POLLS = 2
|
|
2710
|
+
|
|
2711
|
+
# The wording of the PR note a dead-pane restart leaves (issue #186).
|
|
2712
|
+
EXITED_RESTART_NOTE = "the previous session exited unexpectedly"
|
|
2713
|
+
|
|
2714
|
+
# How often a FIX STALLED ping repeats while the stall persists (issue #186):
|
|
2715
|
+
# once per episode let a 17 h stall page the operator exactly once. See
|
|
2716
|
+
# stall_repeat_kind.
|
|
2717
|
+
FIX_STALLED_REPEAT_SECONDS = 2 * 60 * 60
|
|
2718
|
+
|
|
2652
2719
|
# The maintenance edge's cap-out kind prefix. The `maintain-` prefix keeps
|
|
2653
2720
|
# rows from ever colliding with the issue edge's `cap` when an issue and a
|
|
2654
2721
|
# PR share a number; the epoch suffix is maintain_cap_kind's job below.
|
|
@@ -3047,6 +3114,24 @@ def claude_exited_kind(attempt: int) -> str:
|
|
|
3047
3114
|
return f"{ESCALATION_CLAUDE_EXITED}:a{attempt}"
|
|
3048
3115
|
|
|
3049
3116
|
|
|
3117
|
+
def fix_claude_exited_kind(round_: int, attempt: int) -> str:
|
|
3118
|
+
"""The ledger kind for ONE fix-round claude-exited episode (issue #186),
|
|
3119
|
+
keyed per round AND attempt for fix_stalled_kind's episode reasoning."""
|
|
3120
|
+
return f"{ESCALATION_FIX_CLAUDE_EXITED}:r{round_}a{attempt}"
|
|
3121
|
+
|
|
3122
|
+
|
|
3123
|
+
def stall_repeat_kind(kind: str, age: float, stalled_after: float) -> str:
|
|
3124
|
+
"""The ledger kind of the FIX STALLED ping due at `age` (issue #186).
|
|
3125
|
+
|
|
3126
|
+
The first ping of an episode keeps the episode's own kind (`kind`), so
|
|
3127
|
+
every existing row, reader and test reads it unchanged; each further
|
|
3128
|
+
FIX_STALLED_REPEAT_SECONDS past the stall floor is its own row,
|
|
3129
|
+
`<kind>@<n>`. One row per window is the dedupe: a ping repeats every
|
|
3130
|
+
2 h while the stall persists, and never twice inside one window."""
|
|
3131
|
+
repeat = int(max(0.0, age - stalled_after) // FIX_STALLED_REPEAT_SECONDS)
|
|
3132
|
+
return kind if repeat == 0 else f"{kind}@{repeat}"
|
|
3133
|
+
|
|
3134
|
+
|
|
3050
3135
|
def deferral_activity_kind(session: str) -> str:
|
|
3051
3136
|
"""The activity-log kind that dedupes ONE capacity-deferral episode's line.
|
|
3052
3137
|
|
|
@@ -3420,6 +3505,14 @@ LIVE_STALLED_STATUS = {
|
|
|
3420
3505
|
}
|
|
3421
3506
|
|
|
3422
3507
|
|
|
3508
|
+
# One number per POLL roster built (issue #186): a pass builds ONE, so its
|
|
3509
|
+
# `seq` is what "consecutive polls" counts in, without the edges having to be
|
|
3510
|
+
# told which pass they run in. One-off listings (`poll=False`: the resume
|
|
3511
|
+
# re-check, the process reaper's fresh listing) take no number, so two passes
|
|
3512
|
+
# in a row always hold adjacent numbers.
|
|
3513
|
+
_ROSTER_SEQ = itertools.count(1)
|
|
3514
|
+
|
|
3515
|
+
|
|
3423
3516
|
class SessionLiveness:
|
|
3424
3517
|
"""The pass's tmux roster -- the oracle behind `session_liveness`.
|
|
3425
3518
|
|
|
@@ -3449,8 +3542,10 @@ class SessionLiveness:
|
|
|
3449
3542
|
self,
|
|
3450
3543
|
sessions: "list[dict] | None" = None,
|
|
3451
3544
|
reaped: "set[str] | None" = None,
|
|
3545
|
+
poll: bool = True,
|
|
3452
3546
|
):
|
|
3453
3547
|
self.listed = sessions is not None
|
|
3548
|
+
self.seq = next(_ROSTER_SEQ) if poll else 0
|
|
3454
3549
|
# The raw rows, kept for the fleet-vitals snapshot (issue #116) so
|
|
3455
3550
|
# the pass's ONE listing is also the roster it reports -- minus the
|
|
3456
3551
|
# names the sweep killed, which are not sessions any more. None when
|
|
@@ -4556,6 +4651,13 @@ class DevWatcher:
|
|
|
4556
4651
|
self._hung_watch: "hungtests.CpuWatch | None" = None
|
|
4557
4652
|
self._hung_tests_at: "float | None" = None
|
|
4558
4653
|
self._hung_reaped: "set[tuple[int, int]]" = set()
|
|
4654
|
+
# The dead-pane read (issue #186): per session, the roster seq of the
|
|
4655
|
+
# last poll its pane read a bare shell and how many polls in a row
|
|
4656
|
+
# did. Any other read drops the entry; a poll that skips the session
|
|
4657
|
+
# breaks the run, and poll_once prunes entries no poll can extend.
|
|
4658
|
+
self._shell_streaks: "dict[str, tuple[int, int]]" = {}
|
|
4659
|
+
# The process reaper's own clock (janitor cadence, issue #186).
|
|
4660
|
+
self._orphans_at: "float | None" = None
|
|
4559
4661
|
|
|
4560
4662
|
# -- per-issue decision ------------------------------------------------
|
|
4561
4663
|
|
|
@@ -5734,7 +5836,7 @@ class DevWatcher:
|
|
|
5734
5836
|
exc,
|
|
5735
5837
|
)
|
|
5736
5838
|
return False
|
|
5737
|
-
if SessionLiveness(sessions).of(session) is Liveness.DEAD:
|
|
5839
|
+
if SessionLiveness(sessions, poll=False).of(session) is Liveness.DEAD:
|
|
5738
5840
|
return True
|
|
5739
5841
|
log.info(
|
|
5740
5842
|
"resume: %s is back in the session listing — standing the resume "
|
|
@@ -5883,10 +5985,10 @@ class DevWatcher:
|
|
|
5883
5985
|
Dry-run classifies and logs but kills nothing and answers False
|
|
5884
5986
|
(the defer holds); so does a kill that fails, retried next poll.
|
|
5885
5987
|
|
|
5886
|
-
|
|
5887
|
-
|
|
5888
|
-
|
|
5889
|
-
|
|
5988
|
+
Once the kill lands, the process trees the session left running in
|
|
5989
|
+
its task workspace are reaped (`reap_session_processes`, issue
|
|
5990
|
+
#186): the seat env every one of them inherited names the session,
|
|
5991
|
+
however they were re-parented."""
|
|
5890
5992
|
command = self.alissa.pane_command(session)
|
|
5891
5993
|
if command not in SHELL_COMMANDS:
|
|
5892
5994
|
return False, None
|
|
@@ -5970,6 +6072,7 @@ class DevWatcher:
|
|
|
5970
6072
|
WEDGE_CLAUDE_EXITED, session, exc,
|
|
5971
6073
|
)
|
|
5972
6074
|
return False, None
|
|
6075
|
+
self.reap_session_processes(session)
|
|
5973
6076
|
if not self.state.activity_logged(issue.full_name, issue.number, kind):
|
|
5974
6077
|
landed = self._append_activity(
|
|
5975
6078
|
issue.owner,
|
|
@@ -5988,6 +6091,152 @@ class DevWatcher:
|
|
|
5988
6091
|
self.state.record_activity(issue.full_name, issue.number, kind)
|
|
5989
6092
|
return True, resume_pr
|
|
5990
6093
|
|
|
6094
|
+
def _shell_pane(
|
|
6095
|
+
self, session: str, roster: SessionLiveness
|
|
6096
|
+
) -> "tuple[str, int]":
|
|
6097
|
+
"""The session's pane foreground command, and for how many polls in
|
|
6098
|
+
a row it has read a bare shell (`SHELL_COMMANDS`) -- 0 for anything
|
|
6099
|
+
else, "" (tmux could not answer) included, which is absence of
|
|
6100
|
+
evidence and resets nothing it did not see (issue #186).
|
|
6101
|
+
|
|
6102
|
+
A poll is one roster (`roster.seq`): two reads inside one pass count
|
|
6103
|
+
once, so "2 polls" always means two passes, never one pass that
|
|
6104
|
+
happened to ask twice. And the polls must be ADJACENT: a pass that
|
|
6105
|
+
never read this pane (round not stale yet, PR not in the search, edge
|
|
6106
|
+
off) breaks the run, so two momentary shell reads hours apart never
|
|
6107
|
+
join into a DEAD read."""
|
|
6108
|
+
command = self.alissa.pane_command(session)
|
|
6109
|
+
seen_at, polls = self._shell_streaks.get(session, (None, 0))
|
|
6110
|
+
if command not in SHELL_COMMANDS:
|
|
6111
|
+
if not command and seen_at in (roster.seq - 1, roster.seq):
|
|
6112
|
+
# Polled but unreadable: carries the run, adds nothing.
|
|
6113
|
+
self._shell_streaks[session] = (roster.seq, polls)
|
|
6114
|
+
else:
|
|
6115
|
+
self._shell_streaks.pop(session, None)
|
|
6116
|
+
return command, 0
|
|
6117
|
+
if seen_at == roster.seq - 1:
|
|
6118
|
+
polls += 1
|
|
6119
|
+
elif seen_at != roster.seq:
|
|
6120
|
+
polls = 1
|
|
6121
|
+
self._shell_streaks[session] = (roster.seq, polls)
|
|
6122
|
+
return command, polls
|
|
6123
|
+
|
|
6124
|
+
def _prune_shell_streaks(self, roster: SessionLiveness) -> None:
|
|
6125
|
+
"""Drop the dead-pane runs this pass's poll can no longer extend --
|
|
6126
|
+
last read before the previous poll -- so sessions that vanished
|
|
6127
|
+
without a kill do not pile up over a long-lived daemon."""
|
|
6128
|
+
for session, (seen_at, _) in list(self._shell_streaks.items()):
|
|
6129
|
+
if seen_at < roster.seq - 1:
|
|
6130
|
+
del self._shell_streaks[session]
|
|
6131
|
+
|
|
6132
|
+
def _fix_claude_exited(
|
|
6133
|
+
self,
|
|
6134
|
+
pr: PullRequest,
|
|
6135
|
+
round_: int,
|
|
6136
|
+
attempts: int,
|
|
6137
|
+
session: str,
|
|
6138
|
+
command: str,
|
|
6139
|
+
polls: int,
|
|
6140
|
+
*,
|
|
6141
|
+
author_activity: bool,
|
|
6142
|
+
) -> bool:
|
|
6143
|
+
"""Issue #186: the fix edge's read of a stale round whose session is
|
|
6144
|
+
LISTED but whose pane is a bare shell -- True once the session is
|
|
6145
|
+
killed and the caller may read it DEAD.
|
|
6146
|
+
|
|
6147
|
+
The 2026-10-19 incident (studio PR #2518): a fix session's claude
|
|
6148
|
+
exited mid-tool-call, the pane fell back to bash, and the round
|
|
6149
|
+
logged "reads alive — a live session owns the round" every poll for
|
|
6150
|
+
17 h. Now SHELL_PANE_DEAD_POLLS consecutive shell reads make the
|
|
6151
|
+
session dead: it is killed (`alissa tmux kill`, the row's own
|
|
6152
|
+
session, never a sweep), the processes it left in its workspace are
|
|
6153
|
+
reaped, and one activity line on the PR says the previous session
|
|
6154
|
+
exited unexpectedly and what the round does next -- which the
|
|
6155
|
+
existing stale-attempt matrix decides once the caller reads DEAD: a
|
|
6156
|
+
respawn without triage replies, a resume behind them (when
|
|
6157
|
+
resume_enabled), the cap-out page when attempt_cap is spent.
|
|
6158
|
+
|
|
6159
|
+
Under the poll threshold, for a usage-limited session (never killed,
|
|
6160
|
+
issue #144), under dry-run and when the kill fails (retried next
|
|
6161
|
+
poll), the answer is False and every defer holds."""
|
|
6162
|
+
if self.state.session_limited(session):
|
|
6163
|
+
return False
|
|
6164
|
+
if polls < SHELL_PANE_DEAD_POLLS:
|
|
6165
|
+
log.info(
|
|
6166
|
+
"%s: fix session %s (%s round %d attempt %d) reads alive but "
|
|
6167
|
+
"its pane is at the shell (%s), poll %d of %d — read dead "
|
|
6168
|
+
"only if it still is next poll",
|
|
6169
|
+
WEDGE_CLAUDE_EXITED, session, pr.pr_slug, round_, attempts,
|
|
6170
|
+
command, polls, SHELL_PANE_DEAD_POLLS,
|
|
6171
|
+
)
|
|
6172
|
+
return False
|
|
6173
|
+
kind = fix_claude_exited_kind(round_, attempts)
|
|
6174
|
+
billed = self.state.fix_attempt_count(
|
|
6175
|
+
pr.full_name, pr.number, round_, billable=True
|
|
6176
|
+
)
|
|
6177
|
+
if author_activity and not self.config.resume_enabled:
|
|
6178
|
+
outcome = (
|
|
6179
|
+
"the round keeps its defer (resume_enabled is off), now "
|
|
6180
|
+
"reading dead"
|
|
6181
|
+
)
|
|
6182
|
+
elif billed >= self.config.attempt_cap:
|
|
6183
|
+
outcome = (
|
|
6184
|
+
f"the round has spent attempt_cap {self.config.attempt_cap}, "
|
|
6185
|
+
f"so the operator is paged instead of a restart"
|
|
6186
|
+
)
|
|
6187
|
+
else:
|
|
6188
|
+
outcome = (
|
|
6189
|
+
f"restarting the round as attempt {attempts + 1}"
|
|
6190
|
+
+ (" (resuming behind its triage replies)"
|
|
6191
|
+
if author_activity else "")
|
|
6192
|
+
)
|
|
6193
|
+
if self.state.escalated(pr.full_name, pr.number, kind):
|
|
6194
|
+
log.info(
|
|
6195
|
+
"%s: fix session %s (%s round %d attempt %d) still has no "
|
|
6196
|
+
"claude in its pane — retrying the kill",
|
|
6197
|
+
WEDGE_CLAUDE_EXITED, session, pr.pr_slug, round_, attempts,
|
|
6198
|
+
)
|
|
6199
|
+
else:
|
|
6200
|
+
log.warning(
|
|
6201
|
+
"%s: fix session %s (%s round %d attempt %d) reads alive but "
|
|
6202
|
+
"its pane has been at the shell (%s) for %d polls — its "
|
|
6203
|
+
"claude exited. Killing it; %s",
|
|
6204
|
+
WEDGE_CLAUDE_EXITED, session, pr.pr_slug, round_, attempts,
|
|
6205
|
+
command, polls, outcome,
|
|
6206
|
+
)
|
|
6207
|
+
self.state.record_escalation(pr.full_name, pr.number, kind)
|
|
6208
|
+
if self.config.dry_run:
|
|
6209
|
+
log.info("[dry-run] would kill %s (claude exited); %s", session, outcome)
|
|
6210
|
+
return False
|
|
6211
|
+
try:
|
|
6212
|
+
self.alissa.kill_session(session)
|
|
6213
|
+
except CommandError as exc:
|
|
6214
|
+
log.warning(
|
|
6215
|
+
"%s: could not kill %s (%s) — kills are best-effort; the "
|
|
6216
|
+
"round keeps its defer and the kill is retried next poll",
|
|
6217
|
+
WEDGE_CLAUDE_EXITED, session, exc,
|
|
6218
|
+
)
|
|
6219
|
+
return False
|
|
6220
|
+
self._shell_streaks.pop(session, None)
|
|
6221
|
+
self.reap_session_processes(session)
|
|
6222
|
+
if not self.state.activity_logged(pr.full_name, pr.number, kind):
|
|
6223
|
+
landed = self._append_activity(
|
|
6224
|
+
pr.owner,
|
|
6225
|
+
pr.repo,
|
|
6226
|
+
pr.number,
|
|
6227
|
+
self._activity_line(
|
|
6228
|
+
session,
|
|
6229
|
+
"fix edge",
|
|
6230
|
+
f"{ACTIVITY_WEDGE_KILLED} — {WEDGE_CLAUDE_EXITED}: "
|
|
6231
|
+
f"{EXITED_RESTART_NOTE} (its pane was at `{command}` "
|
|
6232
|
+
f"for {polls} polls); killed, {outcome}",
|
|
6233
|
+
),
|
|
6234
|
+
pr.pr_slug,
|
|
6235
|
+
)
|
|
6236
|
+
if landed:
|
|
6237
|
+
self.state.record_activity(pr.full_name, pr.number, kind)
|
|
6238
|
+
return True
|
|
6239
|
+
|
|
5991
6240
|
def _wedged_on_first_run_dialog(
|
|
5992
6241
|
self, issue: Issue, attempts: int, session: str
|
|
5993
6242
|
) -> bool:
|
|
@@ -6749,6 +6998,7 @@ class DevWatcher:
|
|
|
6749
6998
|
# says CONFIRMED dead.
|
|
6750
6999
|
resume_dead_session: "str | None" = None
|
|
6751
7000
|
confirmed_dead_session: "str | None" = None
|
|
7001
|
+
exited_session: "str | None" = None
|
|
6752
7002
|
if age is not None:
|
|
6753
7003
|
author_activity = any(
|
|
6754
7004
|
r.author == pr.author and r.submitted_at > newest.submitted_at
|
|
@@ -6758,17 +7008,34 @@ class DevWatcher:
|
|
|
6758
7008
|
pr.full_name, number, round_
|
|
6759
7009
|
)
|
|
6760
7010
|
liveness = self.session_liveness(row_session, roster)
|
|
7011
|
+
# Issue #186: LISTED is not alive. A session whose pane has read a
|
|
7012
|
+
# bare shell for SHELL_PANE_DEAD_POLLS polls lost its claude; once
|
|
7013
|
+
# it is killed it reads DEAD, and the matrix below respawns or
|
|
7014
|
+
# resumes the round exactly as for a session gone from the list.
|
|
7015
|
+
pane = ""
|
|
7016
|
+
if liveness is Liveness.ALIVE and row_session and roster is not None:
|
|
7017
|
+
pane, polls = self._shell_pane(row_session, roster)
|
|
7018
|
+
if polls and self._fix_claude_exited(
|
|
7019
|
+
pr, round_, attempts, row_session, pane, polls,
|
|
7020
|
+
author_activity=author_activity,
|
|
7021
|
+
):
|
|
7022
|
+
liveness = Liveness.DEAD
|
|
7023
|
+
exited_session = row_session
|
|
7024
|
+
stalled_after = (
|
|
7025
|
+
STALLED_DEFER_MULTIPLE * self.config.stale_minutes * 60
|
|
7026
|
+
)
|
|
6761
7027
|
if not author_activity:
|
|
6762
7028
|
if liveness is Liveness.ALIVE:
|
|
6763
|
-
|
|
6764
|
-
|
|
7029
|
+
live_kind = stall_repeat_kind(
|
|
7030
|
+
fix_live_stalled_kind(round_, attempts), age,
|
|
7031
|
+
stalled_after,
|
|
6765
7032
|
)
|
|
6766
7033
|
if age >= stalled_after and not self.state.escalated(
|
|
6767
|
-
pr.full_name, number,
|
|
6768
|
-
fix_live_stalled_kind(round_, attempts),
|
|
7034
|
+
pr.full_name, number, live_kind,
|
|
6769
7035
|
):
|
|
6770
7036
|
self._escalate_fix_live_stalled(
|
|
6771
|
-
pr, round_, attempts, age, row_session or ""
|
|
7037
|
+
pr, round_, attempts, age, row_session or "",
|
|
7038
|
+
kind=live_kind, pane=pane,
|
|
6772
7039
|
)
|
|
6773
7040
|
fix_live_timer = self._stall_timer(
|
|
6774
7041
|
pr.full_name, number,
|
|
@@ -6809,13 +7076,28 @@ class DevWatcher:
|
|
|
6809
7076
|
# resume is an ORDINARY attempt of this round.
|
|
6810
7077
|
resume_dead_session = row_session
|
|
6811
7078
|
else:
|
|
6812
|
-
|
|
6813
|
-
|
|
7079
|
+
# Repeats only while the session may still be there: a
|
|
7080
|
+
# DEAD one (resume off) waits on a human with nothing
|
|
7081
|
+
# left to read, and its seat may park after the first
|
|
7082
|
+
# ping -- a repeat would then be the very page a wake
|
|
7083
|
+
# must never manufacture (parking §2.9).
|
|
7084
|
+
stall_kind = (
|
|
7085
|
+
fix_stalled_kind(round_, attempts)
|
|
7086
|
+
if liveness is Liveness.DEAD
|
|
7087
|
+
else stall_repeat_kind(
|
|
7088
|
+
fix_stalled_kind(round_, attempts), age,
|
|
7089
|
+
stalled_after,
|
|
7090
|
+
)
|
|
6814
7091
|
)
|
|
6815
7092
|
if age >= stalled_after and not self.state.escalated(
|
|
6816
|
-
pr.full_name, number,
|
|
7093
|
+
pr.full_name, number, stall_kind
|
|
6817
7094
|
):
|
|
6818
|
-
|
|
7095
|
+
if not pane and liveness is Liveness.ALIVE and row_session:
|
|
7096
|
+
pane = self.alissa.pane_command(row_session)
|
|
7097
|
+
self._escalate_fix_stalled(
|
|
7098
|
+
pr, round_, attempts, age, kind=stall_kind,
|
|
7099
|
+
pane=pane, session=row_session,
|
|
7100
|
+
)
|
|
6819
7101
|
# Parking (issue #153), the issue edge's rule: owed until
|
|
6820
7102
|
# the stall ping is out, and past it while the session
|
|
6821
7103
|
# might still be there; a dead one waits on a human.
|
|
@@ -6877,14 +7159,25 @@ class DevWatcher:
|
|
|
6877
7159
|
if age is not None and resume_dead_session is not None:
|
|
6878
7160
|
log.warning(
|
|
6879
7161
|
"%s fix attempt %d (round %d) has been in flight %.0f min "
|
|
6880
|
-
"behind its own triage replies and its session %s
|
|
6881
|
-
"from the tmux listing (CONFIRMED dead, not presumed) — "
|
|
7162
|
+
"behind its own triage replies and its session %s %s — "
|
|
6882
7163
|
"re-enqueuing as attempt %d to RESUME the round",
|
|
6883
7164
|
pr.pr_slug,
|
|
6884
7165
|
attempts,
|
|
6885
7166
|
round_,
|
|
6886
7167
|
age / 60,
|
|
6887
7168
|
resume_dead_session,
|
|
7169
|
+
"exited (pane back at the shell; killed)"
|
|
7170
|
+
if exited_session is not None
|
|
7171
|
+
else "is absent from the tmux listing (CONFIRMED dead, not "
|
|
7172
|
+
"presumed)",
|
|
7173
|
+
attempts + 1,
|
|
7174
|
+
)
|
|
7175
|
+
elif age is not None and exited_session is not None:
|
|
7176
|
+
log.warning(
|
|
7177
|
+
"%s fix attempt %d (round %d) has been in flight %.0f min "
|
|
7178
|
+
"with no re-request and its session %s exited (pane back at "
|
|
7179
|
+
"the shell; killed) — restarting the round as attempt %d",
|
|
7180
|
+
pr.pr_slug, attempts, round_, age / 60, exited_session,
|
|
6888
7181
|
attempts + 1,
|
|
6889
7182
|
)
|
|
6890
7183
|
elif age is not None:
|
|
@@ -6912,6 +7205,7 @@ class DevWatcher:
|
|
|
6912
7205
|
reenqueued=age is not None,
|
|
6913
7206
|
dead_session=resume_dead_session,
|
|
6914
7207
|
confirmed_dead=confirmed_dead_session,
|
|
7208
|
+
exited=exited_session is not None,
|
|
6915
7209
|
)
|
|
6916
7210
|
|
|
6917
7211
|
def _reviewer_reviews(
|
|
@@ -8343,8 +8637,29 @@ class DevWatcher:
|
|
|
8343
8637
|
)
|
|
8344
8638
|
self.state.record_escalation(pr.full_name, pr.number, fix_cap_kind(round_))
|
|
8345
8639
|
|
|
8640
|
+
@staticmethod
|
|
8641
|
+
def _fix_stalled_tail(kind: str, pane: str, session: "str | None") -> str:
|
|
8642
|
+
"""FIX_STALLED_TAIL for one ping: `pane` as read, or why there is no
|
|
8643
|
+
reading; and which repeat this is (`kind`'s `@<n>` suffix)."""
|
|
8644
|
+
if pane:
|
|
8645
|
+
shown = f"`{pane}`"
|
|
8646
|
+
if pane in SHELL_COMMANDS:
|
|
8647
|
+
shown += " (a bare shell: the session's claude has exited)"
|
|
8648
|
+
elif session:
|
|
8649
|
+
shown = f"unreadable for `{session}`"
|
|
8650
|
+
else:
|
|
8651
|
+
shown = "unknown (no session recorded for this attempt)"
|
|
8652
|
+
_, sep, repeat = kind.rpartition("@")
|
|
8653
|
+
ordinal = f" (repeat {repeat})" if sep and repeat.isdigit() else ""
|
|
8654
|
+
return FIX_STALLED_TAIL.format(
|
|
8655
|
+
pane=shown, repeat_hours=FIX_STALLED_REPEAT_SECONDS // 3600,
|
|
8656
|
+
ordinal=ordinal,
|
|
8657
|
+
)
|
|
8658
|
+
|
|
8346
8659
|
def _escalate_fix_stalled(
|
|
8347
|
-
self, pr: PullRequest, round_: int, attempts: int, age: float
|
|
8660
|
+
self, pr: PullRequest, round_: int, attempts: int, age: float,
|
|
8661
|
+
*, kind: "str | None" = None, pane: str = "",
|
|
8662
|
+
session: "str | None" = None,
|
|
8348
8663
|
) -> None:
|
|
8349
8664
|
"""Operator ping when the author-activity deferral itself runs long
|
|
8350
8665
|
(_escalate_stalled's shape): the author's own triage replies are the
|
|
@@ -8357,17 +8672,20 @@ class DevWatcher:
|
|
|
8357
8672
|
exactly-once delivery contract as _escalate_stalled -- a transient
|
|
8358
8673
|
failure retries next poll. The decision stays IN_FLIGHT either way
|
|
8359
8674
|
-- this comments, it never respawns."""
|
|
8675
|
+
kind = kind or fix_stalled_kind(round_, attempts)
|
|
8360
8676
|
body = FIX_STALLED_COMMENT.format(
|
|
8361
8677
|
attempts=attempts, round=round_, minutes=int(age / 60)
|
|
8362
|
-
)
|
|
8678
|
+
) + self._fix_stalled_tail(kind, pane, session)
|
|
8363
8679
|
log.warning(
|
|
8364
8680
|
"FIX STALLED %s fix attempt %d (round %d) has been deferred "
|
|
8365
|
-
"%.0f min behind the author's own triage replies —
|
|
8366
|
-
"to operator (
|
|
8681
|
+
"%.0f min behind the author's own triage replies (pane: %s) — "
|
|
8682
|
+
"escalating to operator (repeats every %d h while it persists)",
|
|
8367
8683
|
pr.pr_slug,
|
|
8368
8684
|
attempts,
|
|
8369
8685
|
round_,
|
|
8370
8686
|
age / 60,
|
|
8687
|
+
pane or "unread",
|
|
8688
|
+
FIX_STALLED_REPEAT_SECONDS // 3600,
|
|
8371
8689
|
)
|
|
8372
8690
|
|
|
8373
8691
|
if self.config.dry_run:
|
|
@@ -8384,28 +8702,29 @@ class DevWatcher:
|
|
|
8384
8702
|
exc,
|
|
8385
8703
|
)
|
|
8386
8704
|
return
|
|
8387
|
-
self.state.record_escalation(
|
|
8388
|
-
pr.full_name, pr.number, fix_stalled_kind(round_, attempts)
|
|
8389
|
-
)
|
|
8705
|
+
self.state.record_escalation(pr.full_name, pr.number, kind)
|
|
8390
8706
|
|
|
8391
8707
|
def _escalate_fix_live_stalled(
|
|
8392
8708
|
self, pr: PullRequest, round_: int, attempts: int, age: float,
|
|
8393
|
-
session: str,
|
|
8709
|
+
session: str, *, kind: "str | None" = None, pane: str = "",
|
|
8394
8710
|
) -> None:
|
|
8395
8711
|
"""_escalate_fix_stalled's ALIVE twin (issue #142): the liveness
|
|
8396
8712
|
guard on the no-triage-reply branch defers while the row's session is
|
|
8397
8713
|
listed, and this ping is its floor. Same delivery contract: the row
|
|
8398
8714
|
is recorded only AFTER the comment posts; the decision stays
|
|
8399
8715
|
IN_FLIGHT either way."""
|
|
8716
|
+
kind = kind or fix_live_stalled_kind(round_, attempts)
|
|
8400
8717
|
body = FIX_LIVE_STALLED_COMMENT.format(
|
|
8401
8718
|
attempts=attempts, round=round_, minutes=int(age / 60),
|
|
8402
8719
|
session=session,
|
|
8403
|
-
)
|
|
8720
|
+
) + self._fix_stalled_tail(kind, pane, session)
|
|
8404
8721
|
log.warning(
|
|
8405
8722
|
"FIX STALLED %s fix attempt %d (round %d) has been deferred "
|
|
8406
8723
|
"%.0f min behind its still-listed session %s with no triage "
|
|
8407
|
-
"reply — escalating to operator (
|
|
8724
|
+
"reply (pane: %s) — escalating to operator (repeats every %d h "
|
|
8725
|
+
"while it persists)",
|
|
8408
8726
|
pr.pr_slug, attempts, round_, age / 60, session,
|
|
8727
|
+
pane or "unread", FIX_STALLED_REPEAT_SECONDS // 3600,
|
|
8409
8728
|
)
|
|
8410
8729
|
if self.config.dry_run:
|
|
8411
8730
|
log.info("[dry-run] would comment on %s:\n%s", pr.pr_slug, body)
|
|
@@ -8419,9 +8738,7 @@ class DevWatcher:
|
|
|
8419
8738
|
pr.pr_slug, exc,
|
|
8420
8739
|
)
|
|
8421
8740
|
return
|
|
8422
|
-
self.state.record_escalation(
|
|
8423
|
-
pr.full_name, pr.number, fix_live_stalled_kind(round_, attempts)
|
|
8424
|
-
)
|
|
8741
|
+
self.state.record_escalation(pr.full_name, pr.number, kind)
|
|
8425
8742
|
|
|
8426
8743
|
def _hand_over_round(
|
|
8427
8744
|
self,
|
|
@@ -8743,6 +9060,7 @@ class DevWatcher:
|
|
|
8743
9060
|
reenqueued: bool = False,
|
|
8744
9061
|
dead_session: "str | None" = None,
|
|
8745
9062
|
confirmed_dead: "str | None" = None,
|
|
9063
|
+
exited: bool = False,
|
|
8746
9064
|
) -> Decision:
|
|
8747
9065
|
"""The fix edge's spawn, behind the drain gate (`_drain_gated`)."""
|
|
8748
9066
|
return self._drain_gated(
|
|
@@ -8750,7 +9068,7 @@ class DevWatcher:
|
|
|
8750
9068
|
lambda: self._spawn_fix_claimed(
|
|
8751
9069
|
pr, round_, attempt, review, budget,
|
|
8752
9070
|
reenqueued=reenqueued, dead_session=dead_session,
|
|
8753
|
-
confirmed_dead=confirmed_dead,
|
|
9071
|
+
confirmed_dead=confirmed_dead, exited=exited,
|
|
8754
9072
|
),
|
|
8755
9073
|
round_=round_,
|
|
8756
9074
|
)
|
|
@@ -8766,6 +9084,7 @@ class DevWatcher:
|
|
|
8766
9084
|
reenqueued: bool = False,
|
|
8767
9085
|
dead_session: "str | None" = None,
|
|
8768
9086
|
confirmed_dead: "str | None" = None,
|
|
9087
|
+
exited: bool = False,
|
|
8769
9088
|
) -> Decision:
|
|
8770
9089
|
skipped = self._foreign_head_skip(pr, attempt)
|
|
8771
9090
|
if skipped is not None:
|
|
@@ -8886,7 +9205,14 @@ class DevWatcher:
|
|
|
8886
9205
|
#
|
|
8887
9206
|
# The session name carries the round and attempt; the context marks a
|
|
8888
9207
|
# retry, and names the confirmation when a resume is what unblocked it.
|
|
8889
|
-
if
|
|
9208
|
+
if exited:
|
|
9209
|
+
# Issue #186: the session was listed, but its claude was gone.
|
|
9210
|
+
context = (
|
|
9211
|
+
f"{'resumed' if dead_session is not None else 're-enqueued'} "
|
|
9212
|
+
f"— {EXITED_RESTART_NOTE} (its pane was back at the shell; "
|
|
9213
|
+
f"killed); restarting the round"
|
|
9214
|
+
)
|
|
9215
|
+
elif dead_session is not None:
|
|
8890
9216
|
context = (
|
|
8891
9217
|
"resumed — previous session confirmed dead (absent from the "
|
|
8892
9218
|
"tmux listing); finishing the round on the same branch"
|
|
@@ -12095,6 +12421,146 @@ class DevWatcher:
|
|
|
12095
12421
|
if hungtests.message_session(session_id, text):
|
|
12096
12422
|
log.info("hung tests: told %s its test run was stopped", tree.session)
|
|
12097
12423
|
|
|
12424
|
+
# -- the dead-session process reaper (issue #186) ----------------------
|
|
12425
|
+
|
|
12426
|
+
def _watched_hubs(self) -> "list[Path]":
|
|
12427
|
+
hubs = []
|
|
12428
|
+
for owner, repo in self._watched_repos():
|
|
12429
|
+
hub = self.config.hub_for(owner, repo)
|
|
12430
|
+
if hub.is_dir():
|
|
12431
|
+
hubs.append(hub.resolve())
|
|
12432
|
+
return hubs
|
|
12433
|
+
|
|
12434
|
+
def reap_session_processes(self, session: str) -> int:
|
|
12435
|
+
"""Reap what ONE session the daemon just killed left running in its
|
|
12436
|
+
task workspace: every process tree rooted there whose seat env names
|
|
12437
|
+
`session` (`hungtests.find_session_orphans`). Returns how many
|
|
12438
|
+
processes were signalled. Never raises."""
|
|
12439
|
+
try:
|
|
12440
|
+
hubs = self._watched_hubs()
|
|
12441
|
+
if not hubs:
|
|
12442
|
+
return 0
|
|
12443
|
+
orphans = hungtests.find_session_orphans(
|
|
12444
|
+
hungtests.snapshot(), hubs, lambda name: name == session
|
|
12445
|
+
)
|
|
12446
|
+
return self._reap_orphans(orphans, f"its session {session} was killed")
|
|
12447
|
+
except (CommandError, OSError) as exc:
|
|
12448
|
+
log.error("process reaper: %s could not be reaped (%s)", session, exc)
|
|
12449
|
+
return 0
|
|
12450
|
+
|
|
12451
|
+
def run_process_reaper(self, roster: "SessionLiveness | None") -> int:
|
|
12452
|
+
"""Reap the processes dead sessions left behind (issue #186), on the
|
|
12453
|
+
janitor's cadence (`janitor_interval_minutes`, its own clock).
|
|
12454
|
+
|
|
12455
|
+
2026-10-19: orphan `npm test` runs of sessions whose PRs had merged
|
|
12456
|
+
17-18 h earlier, and ~20 idle executor helpers (`node /tmp/.tmpXXXX/
|
|
12457
|
+
local.cjs --ipc-path ….executor.sock`, ~115 MB each), on a seat at
|
|
12458
|
+
load 16-25. Two sweeps, both needing a SUCCESSFUL listing (no
|
|
12459
|
+
listing is no evidence, and nothing is touched):
|
|
12460
|
+
|
|
12461
|
+
* a process tree rooted in a watched hub's task workspace whose seat
|
|
12462
|
+
env names a session the listing does not carry -- killed, gone, or
|
|
12463
|
+
reaped after its lane's PR merged or closed. A tree of a session
|
|
12464
|
+
still listed is never touched, whatever its lane's state;
|
|
12465
|
+
* an executor helper whose parent is gone, unless its env names a
|
|
12466
|
+
listed session.
|
|
12467
|
+
|
|
12468
|
+
The pass's roster is listed at the START of the pass, and the edges
|
|
12469
|
+
enqueue sessions after it, so on its own it would read a session
|
|
12470
|
+
spawned this pass DEAD and kill its fresh claude. The sweep
|
|
12471
|
+
therefore lists again right AFTER the /proc snapshot: a session that
|
|
12472
|
+
owns a snapshotted process existed before the snapshot, so it is in
|
|
12473
|
+
that listing unless it really ended. A name is DEAD only when BOTH
|
|
12474
|
+
listings lack it; if the fresh listing fails, nothing is touched.
|
|
12475
|
+
The fresh listing is taken only when the pass roster finds something
|
|
12476
|
+
to reap, so a quiet seat keeps its one listing per pass.
|
|
12477
|
+
|
|
12478
|
+
Everything reaped is logged. Nothing in here may take a poll down."""
|
|
12479
|
+
if roster is None or not roster.listed:
|
|
12480
|
+
return 0
|
|
12481
|
+
interval = self.config.janitor_interval_seconds
|
|
12482
|
+
now = time.monotonic()
|
|
12483
|
+
if (
|
|
12484
|
+
self._orphans_at is not None
|
|
12485
|
+
and interval > 0
|
|
12486
|
+
and now - self._orphans_at < interval
|
|
12487
|
+
):
|
|
12488
|
+
return 0
|
|
12489
|
+
self._orphans_at = now
|
|
12490
|
+
try:
|
|
12491
|
+
procs = hungtests.snapshot()
|
|
12492
|
+
hubs = self._watched_hubs()
|
|
12493
|
+
|
|
12494
|
+
def sweep(
|
|
12495
|
+
dead: "Callable[[str], bool]",
|
|
12496
|
+
) -> "tuple[list[hungtests.Orphan], list[hungtests.Orphan]]":
|
|
12497
|
+
trees = (
|
|
12498
|
+
hungtests.find_session_orphans(procs, hubs, dead)
|
|
12499
|
+
if hubs else []
|
|
12500
|
+
)
|
|
12501
|
+
executors = hungtests.find_parentless_executors(
|
|
12502
|
+
procs, lambda name: not dead(name)
|
|
12503
|
+
)
|
|
12504
|
+
return trees, executors
|
|
12505
|
+
|
|
12506
|
+
trees, executors = sweep(
|
|
12507
|
+
lambda name: roster.of(name) is Liveness.DEAD
|
|
12508
|
+
)
|
|
12509
|
+
if not trees and not executors:
|
|
12510
|
+
return 0
|
|
12511
|
+
# Listed AFTER the snapshot, never before (see the docstring).
|
|
12512
|
+
fresh = SessionLiveness(self.alissa.list_sessions(), poll=False)
|
|
12513
|
+
trees, executors = sweep(
|
|
12514
|
+
lambda name: roster.of(name) is Liveness.DEAD
|
|
12515
|
+
and fresh.of(name) is Liveness.DEAD
|
|
12516
|
+
)
|
|
12517
|
+
return self._reap_orphans(
|
|
12518
|
+
trees, "its session is gone"
|
|
12519
|
+
) + self._reap_orphans(executors, "its parent is gone")
|
|
12520
|
+
except (CommandError, OSError) as exc:
|
|
12521
|
+
log.error("process reaper: sweep failed (%s) — skipped this pass", exc)
|
|
12522
|
+
return 0
|
|
12523
|
+
|
|
12524
|
+
def _reap_orphans(
|
|
12525
|
+
self, orphans: "list[hungtests.Orphan]", reason: str
|
|
12526
|
+
) -> int:
|
|
12527
|
+
"""Log, then TERM (KILL after the grace) one batch of orphans;
|
|
12528
|
+
one WARNING per workspace (or for the executors) naming every pid
|
|
12529
|
+
and command. Returns how many processes were signalled."""
|
|
12530
|
+
if not orphans:
|
|
12531
|
+
return 0
|
|
12532
|
+
groups: "dict[str, list[hungtests.Orphan]]" = {}
|
|
12533
|
+
for orphan in orphans:
|
|
12534
|
+
where = (
|
|
12535
|
+
orphan.workspace.slug if orphan.workspace is not None
|
|
12536
|
+
else "parentless executor helpers"
|
|
12537
|
+
)
|
|
12538
|
+
groups.setdefault(where, []).append(orphan)
|
|
12539
|
+
for where, members in groups.items():
|
|
12540
|
+
listing = "; ".join(
|
|
12541
|
+
f"pid {o.proc.pid} `{o.command[:160]}`"
|
|
12542
|
+
+ (f" ({o.session})" if o.session else "")
|
|
12543
|
+
for o in members
|
|
12544
|
+
)
|
|
12545
|
+
if self.config.dry_run:
|
|
12546
|
+
log.info(
|
|
12547
|
+
"[dry-run] process reaper would stop %d process(es) in "
|
|
12548
|
+
"%s — %s: %s", len(members), where, reason, listing,
|
|
12549
|
+
)
|
|
12550
|
+
else:
|
|
12551
|
+
log.warning(
|
|
12552
|
+
"process reaper: stopping %d process(es) in %s — %s: %s",
|
|
12553
|
+
len(members), where, reason, listing,
|
|
12554
|
+
)
|
|
12555
|
+
if self.config.dry_run:
|
|
12556
|
+
return 0
|
|
12557
|
+
termed, killed = hungtests.kill_procs([o.proc for o in orphans])
|
|
12558
|
+
if termed:
|
|
12559
|
+
log.info(
|
|
12560
|
+
"process reaper: TERM to %d process(es), KILL to %d", termed, killed
|
|
12561
|
+
)
|
|
12562
|
+
return termed
|
|
12563
|
+
|
|
12098
12564
|
def _janitor_due(self) -> bool:
|
|
12099
12565
|
"""Has `janitor_interval_minutes` elapsed since the last expensive
|
|
12100
12566
|
half? The first pass of a process always runs (nothing has been swept
|
|
@@ -12785,6 +13251,7 @@ class DevWatcher:
|
|
|
12785
13251
|
included."""
|
|
12786
13252
|
started = time.monotonic()
|
|
12787
13253
|
sweep, budget, roster = self._sweep_and_budget()
|
|
13254
|
+
self._prune_shell_streaks(roster)
|
|
12788
13255
|
|
|
12789
13256
|
# The allowlist is re-derived BETWEEN the sweep and the searches: the
|
|
12790
13257
|
# sweep is deliberately search-independent and must not wait on a feed
|
|
@@ -12833,6 +13300,10 @@ class DevWatcher:
|
|
|
12833
13300
|
# worker's test run that stopped making CPU progress, or outlived its
|
|
12834
13301
|
# lane, is stopped and its live session told once.
|
|
12835
13302
|
self.run_hung_tests(roster)
|
|
13303
|
+
# The process reaper (issue #186), on the same cadence: what dead
|
|
13304
|
+
# sessions left running in their workspaces, and executor helpers
|
|
13305
|
+
# whose parent is gone.
|
|
13306
|
+
self.run_process_reaper(roster)
|
|
12836
13307
|
|
|
12837
13308
|
counts = Counter(decision.action for _, decision in results)
|
|
12838
13309
|
self._write_snapshot(
|
|
@@ -157,8 +157,10 @@ DEFER_DRAINED = "drained" # the seat is drained for parking (issue #153)
|
|
|
157
157
|
# added to the loop later reaches Studio without touching this table.
|
|
158
158
|
_STALLED_RE = re.compile(r"^stalled:a(\d+)$")
|
|
159
159
|
_LIVE_STALLED_RE = re.compile(r"^live-stalled:a(\d+)$")
|
|
160
|
-
|
|
161
|
-
|
|
160
|
+
# The fix stalls repeat every 2 h while they persist (issue #186): repeat n
|
|
161
|
+
# is its own row, `<kind>@<n>`, enriched like the first.
|
|
162
|
+
_FIX_STALLED_RE = re.compile(r"^fix-stalled:r(\d+)a(\d+)(?:@\d+)?$")
|
|
163
|
+
_FIX_LIVE_STALLED_RE = re.compile(r"^fix-live-stalled:r(\d+)a(\d+)(?:@\d+)?$")
|
|
162
164
|
_FIX_CAP_RE = re.compile(r"^fix-cap:r(\d+)$")
|
|
163
165
|
_MAINTAIN_CAP_RE = re.compile(r"^maintain-cap(:e\d+)?$")
|
|
164
166
|
_REVERT_REFUSED_RE = re.compile(r"^revert-refused:e(\d+)$")
|
|
@@ -1 +1 @@
|
|
|
1
|
-
0.8.
|
|
1
|
+
0.8.54
|
{alissa_tools_github_devloop-0.8.52.dist-info → alissa_tools_github_devloop-0.8.54.dist-info}/RECORD
RENAMED
|
@@ -7,16 +7,16 @@ alissa/tools/github/devloop/config.py,sha256=ZgGb7DQR3hOOfCXnRIjmG52m3A2SfQHFX94
|
|
|
7
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
|
-
alissa/tools/github/devloop/hungtests.py,sha256=
|
|
10
|
+
alissa/tools/github/devloop/hungtests.py,sha256=TozIDydUe_Kn1m5EtGeGtYt_BrLqGznrNjikLYhxH-Q,24621
|
|
11
11
|
alissa/tools/github/devloop/integration_check.py,sha256=JkFwz3JqOQBIvzszmiql-IMVGSH3lDers6mJUHh9zMM,17928
|
|
12
12
|
alissa/tools/github/devloop/janitor.py,sha256=R8REoHBeY2cfW7fYJVhKr-F5z9Mjlc-vmv-z4KtvBZc,27360
|
|
13
|
-
alissa/tools/github/devloop/loop.py,sha256=
|
|
14
|
-
alissa/tools/github/devloop/loop_events.py,sha256=
|
|
13
|
+
alissa/tools/github/devloop/loop.py,sha256=Zn_zsOqMq32iQ5XBesTXcDlvqvRFGH_P-31Oi6coXZc,693419
|
|
14
|
+
alissa/tools/github/devloop/loop_events.py,sha256=9b7G2ak69Zm-oc_3IK8PplgOEdBydfER_ZFjKlCJJ5A,38093
|
|
15
15
|
alissa/tools/github/devloop/parking.py,sha256=7h3Gg2XQ0eqlvn0M6K6NeEg9xbqnlUPrZNs9gRofzxc,12410
|
|
16
16
|
alissa/tools/github/devloop/proc.py,sha256=nzfsxJSHFWRQcrXmfv588KazFysv8dVQrhMPH1q5eRk,3134
|
|
17
17
|
alissa/tools/github/devloop/prompts.py,sha256=VzN6ec5eF2wwg7QSCRg52WLcxo-tGbGRgQbc4HSahUc,55169
|
|
18
18
|
alissa/tools/github/devloop/state.py,sha256=uVwhbkogUTjWXXFq-COKzUeYrpuGbHzi05CjfEzAtPA,88668
|
|
19
|
-
alissa/tools/github/devloop/version,sha256=
|
|
19
|
+
alissa/tools/github/devloop/version,sha256=2XT_1tsC9nRGPhB79kgPo0wlEUZtSqk-nyLDdc47fcc,7
|
|
20
20
|
alissa/tools/github/devloop/version.py,sha256=znuuOaitXGrBpbskNy78_rlVpFdtmKmk_3k1ACxZrl0,1418
|
|
21
21
|
alissa/tools/github/devloop/webui/__init__.py,sha256=vdXYoyGYzD7BrJ5lV6XdH8Bpw_pej8-RK0hjfxop7-8,1513
|
|
22
22
|
alissa/tools/github/devloop/webui/__main__.py,sha256=wORyW5AkgsniK0TZcrOrvm_P7Q4Mr-fH-4S6qj9XcgA,6137
|
|
@@ -25,10 +25,10 @@ alissa/tools/github/devloop/webui/page.py,sha256=4OD3e01ByvVM4Wo04AZyFuyOYTFEMT2
|
|
|
25
25
|
alissa/tools/github/devloop/webui/server.py,sha256=3aGYGesjUSrPrgcpfy-oYDefBNKnAj3lGfRzwsRkv_I,27031
|
|
26
26
|
alissa/tools/github/devloop/webui/sources.py,sha256=Uw_2B5QGSmW6f66WfVbQb6yLLYmSv6uuF7HNJ5OSr_I,39314
|
|
27
27
|
alissa/tools/github/devloop/webui/sysinfo.py,sha256=ptlogH9alFv0vZ-pQfyUaA3w-K7LQS3tQHGv0d8Tdlk,9649
|
|
28
|
-
alissa_tools_github_devloop-0.8.
|
|
29
|
-
alissa_tools_github_devloop-0.8.
|
|
30
|
-
alissa_tools_github_devloop-0.8.
|
|
31
|
-
alissa_tools_github_devloop-0.8.
|
|
32
|
-
alissa_tools_github_devloop-0.8.
|
|
33
|
-
alissa_tools_github_devloop-0.8.
|
|
34
|
-
alissa_tools_github_devloop-0.8.
|
|
28
|
+
alissa_tools_github_devloop-0.8.54.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
29
|
+
alissa_tools_github_devloop-0.8.54.dist-info/licenses/NOTICE,sha256=FKstNs3uw_tQod4_1XcFLD1FUTJQXf4FC0oXJAOA_HE,260
|
|
30
|
+
alissa_tools_github_devloop-0.8.54.dist-info/METADATA,sha256=RqFwbyexkIqOjWyMJguzI_luEgkfDJDI5nV04XRRXLA,15973
|
|
31
|
+
alissa_tools_github_devloop-0.8.54.dist-info/WHEEL,sha256=a-udL2m4V_6Yn8b-uKzWb12RjRxwwMo6ohYFM24tpaY,91
|
|
32
|
+
alissa_tools_github_devloop-0.8.54.dist-info/entry_points.txt,sha256=8aC7Ajzx4bu9ZRR37a40IDfuStacvZP5KsAxY1dbFKU,145
|
|
33
|
+
alissa_tools_github_devloop-0.8.54.dist-info/top_level.txt,sha256=DodjDg-l-TWQnlxG-Vuc3G5sB4O7cWGQwe6XrVx3u-A,7
|
|
34
|
+
alissa_tools_github_devloop-0.8.54.dist-info/RECORD,,
|
{alissa_tools_github_devloop-0.8.52.dist-info → alissa_tools_github_devloop-0.8.54.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|