alissa-tools-github-devloop 0.8.51__tar.gz → 0.8.53__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 (41) hide show
  1. {alissa_tools_github_devloop-0.8.51/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.53}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/hungtests.py +168 -4
  3. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/loop.py +848 -50
  4. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/loop_events.py +4 -2
  5. alissa_tools_github_devloop-0.8.53/src/main/alissa/tools/github/devloop/version +1 -0
  6. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  7. alissa_tools_github_devloop-0.8.51/src/main/alissa/tools/github/devloop/version +0 -1
  8. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/LICENSE +0 -0
  9. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/MANIFEST.in +0 -0
  10. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/NOTICE +0 -0
  11. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/README.md +0 -0
  12. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/requirements.txt +0 -0
  13. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/setup.cfg +0 -0
  14. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/setup.py +0 -0
  15. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  16. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
  17. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  18. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
  19. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  20. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/config.py +0 -0
  21. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/fleet_vitals.py +0 -0
  22. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
  23. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/hubs.py +0 -0
  24. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/integration_check.py +0 -0
  25. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
  26. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/parking.py +0 -0
  27. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  28. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/prompts.py +0 -0
  29. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/state.py +0 -0
  30. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/version.py +0 -0
  31. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  32. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  33. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  34. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  35. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  36. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
  37. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  38. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
  39. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  40. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  41. {alissa_tools_github_devloop-0.8.51 → alissa_tools_github_devloop-0.8.53}/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.51
3
+ Version: 0.8.53
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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
- name = self.tmux_session
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