alissa-tools-github-devloop 0.8.51__py3-none-any.whl → 0.8.53__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 +848 -50
- alissa/tools/github/devloop/loop_events.py +4 -2
- alissa/tools/github/devloop/version +1 -1
- {alissa_tools_github_devloop-0.8.51.dist-info → alissa_tools_github_devloop-0.8.53.dist-info}/METADATA +1 -1
- {alissa_tools_github_devloop-0.8.51.dist-info → alissa_tools_github_devloop-0.8.53.dist-info}/RECORD +11 -11
- {alissa_tools_github_devloop-0.8.51.dist-info → alissa_tools_github_devloop-0.8.53.dist-info}/WHEEL +0 -0
- {alissa_tools_github_devloop-0.8.51.dist-info → alissa_tools_github_devloop-0.8.53.dist-info}/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.8.51.dist-info → alissa_tools_github_devloop-0.8.53.dist-info}/licenses/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.51.dist-info → alissa_tools_github_devloop-0.8.53.dist-info}/licenses/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.51.dist-info → alissa_tools_github_devloop-0.8.53.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
|