alissa-tools-github-devloop 0.8.52__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 +464 -31
- 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.53.dist-info}/METADATA +1 -1
- {alissa_tools_github_devloop-0.8.52.dist-info → alissa_tools_github_devloop-0.8.53.dist-info}/RECORD +11 -11
- {alissa_tools_github_devloop-0.8.52.dist-info → alissa_tools_github_devloop-0.8.53.dist-info}/WHEEL +0 -0
- {alissa_tools_github_devloop-0.8.52.dist-info → alissa_tools_github_devloop-0.8.53.dist-info}/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.8.52.dist-info → alissa_tools_github_devloop-0.8.53.dist-info}/licenses/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.52.dist-info → alissa_tools_github_devloop-0.8.53.dist-info}/licenses/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.52.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
|
|
@@ -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
|
|
@@ -1851,6 +1852,14 @@ FIX_STALLED_COMMENT = (
|
|
|
1851
1852
|
"close the PR to park the work."
|
|
1852
1853
|
)
|
|
1853
1854
|
|
|
1855
|
+
# The tail every FIX STALLED ping carries (issue #186): what the session's
|
|
1856
|
+
# pane runs right now -- a bare shell means its claude is gone -- and the
|
|
1857
|
+
# promise that the ping repeats while the stall persists.
|
|
1858
|
+
FIX_STALLED_TAIL = (
|
|
1859
|
+
" Pane foreground command: {pane}. This ping repeats every "
|
|
1860
|
+
"{repeat_hours} h while the stall persists{ordinal}."
|
|
1861
|
+
)
|
|
1862
|
+
|
|
1854
1863
|
# The ALIVE twin of FIX_STALLED_COMMENT (issue #142): the fix edge no longer
|
|
1855
1864
|
# respawns a stale round on the timer alone, so a session that is LISTED but
|
|
1856
1865
|
# has posted no triage reply defers -- floored by this once-per-episode ping
|
|
@@ -2649,6 +2658,26 @@ ESCALATION_FIRST_RUN_DIALOG = "first-run-dialog"
|
|
|
2649
2658
|
WEDGE_CLAUDE_EXITED = "wedged:claude-exited"
|
|
2650
2659
|
ESCALATION_CLAUDE_EXITED = "claude-exited"
|
|
2651
2660
|
|
|
2661
|
+
# The fix edge's claude-exited episode (issue #186): the same wedge read on a
|
|
2662
|
+
# stale FIX round (fix_claude_exited_kind keys it per round and attempt). The
|
|
2663
|
+
# `fix-` prefix keeps it a PR-anchored kind for the loop-events emitter.
|
|
2664
|
+
ESCALATION_FIX_CLAUDE_EXITED = "fix-claude-exited"
|
|
2665
|
+
|
|
2666
|
+
# How many consecutive polls a fix session's pane must read a bare shell
|
|
2667
|
+
# (`SHELL_COMMANDS`) before the fix edge reads the session DEAD (issue #186).
|
|
2668
|
+
# Two, not one: a single read can catch the pane between processes, and the
|
|
2669
|
+
# respawn it licenses lands on the same branch. A poll is one pass's roster
|
|
2670
|
+
# (`SessionLiveness.seq`), so re-reads inside one pass never count twice.
|
|
2671
|
+
SHELL_PANE_DEAD_POLLS = 2
|
|
2672
|
+
|
|
2673
|
+
# The wording of the PR note a dead-pane restart leaves (issue #186).
|
|
2674
|
+
EXITED_RESTART_NOTE = "the previous session exited unexpectedly"
|
|
2675
|
+
|
|
2676
|
+
# How often a FIX STALLED ping repeats while the stall persists (issue #186):
|
|
2677
|
+
# once per episode let a 17 h stall page the operator exactly once. See
|
|
2678
|
+
# stall_repeat_kind.
|
|
2679
|
+
FIX_STALLED_REPEAT_SECONDS = 2 * 60 * 60
|
|
2680
|
+
|
|
2652
2681
|
# The maintenance edge's cap-out kind prefix. The `maintain-` prefix keeps
|
|
2653
2682
|
# rows from ever colliding with the issue edge's `cap` when an issue and a
|
|
2654
2683
|
# PR share a number; the epoch suffix is maintain_cap_kind's job below.
|
|
@@ -3047,6 +3076,24 @@ def claude_exited_kind(attempt: int) -> str:
|
|
|
3047
3076
|
return f"{ESCALATION_CLAUDE_EXITED}:a{attempt}"
|
|
3048
3077
|
|
|
3049
3078
|
|
|
3079
|
+
def fix_claude_exited_kind(round_: int, attempt: int) -> str:
|
|
3080
|
+
"""The ledger kind for ONE fix-round claude-exited episode (issue #186),
|
|
3081
|
+
keyed per round AND attempt for fix_stalled_kind's episode reasoning."""
|
|
3082
|
+
return f"{ESCALATION_FIX_CLAUDE_EXITED}:r{round_}a{attempt}"
|
|
3083
|
+
|
|
3084
|
+
|
|
3085
|
+
def stall_repeat_kind(kind: str, age: float, stalled_after: float) -> str:
|
|
3086
|
+
"""The ledger kind of the FIX STALLED ping due at `age` (issue #186).
|
|
3087
|
+
|
|
3088
|
+
The first ping of an episode keeps the episode's own kind (`kind`), so
|
|
3089
|
+
every existing row, reader and test reads it unchanged; each further
|
|
3090
|
+
FIX_STALLED_REPEAT_SECONDS past the stall floor is its own row,
|
|
3091
|
+
`<kind>@<n>`. One row per window is the dedupe: a ping repeats every
|
|
3092
|
+
2 h while the stall persists, and never twice inside one window."""
|
|
3093
|
+
repeat = int(max(0.0, age - stalled_after) // FIX_STALLED_REPEAT_SECONDS)
|
|
3094
|
+
return kind if repeat == 0 else f"{kind}@{repeat}"
|
|
3095
|
+
|
|
3096
|
+
|
|
3050
3097
|
def deferral_activity_kind(session: str) -> str:
|
|
3051
3098
|
"""The activity-log kind that dedupes ONE capacity-deferral episode's line.
|
|
3052
3099
|
|
|
@@ -3420,6 +3467,14 @@ LIVE_STALLED_STATUS = {
|
|
|
3420
3467
|
}
|
|
3421
3468
|
|
|
3422
3469
|
|
|
3470
|
+
# One number per POLL roster built (issue #186): a pass builds ONE, so its
|
|
3471
|
+
# `seq` is what "consecutive polls" counts in, without the edges having to be
|
|
3472
|
+
# told which pass they run in. One-off listings (`poll=False`: the resume
|
|
3473
|
+
# re-check, the process reaper's fresh listing) take no number, so two passes
|
|
3474
|
+
# in a row always hold adjacent numbers.
|
|
3475
|
+
_ROSTER_SEQ = itertools.count(1)
|
|
3476
|
+
|
|
3477
|
+
|
|
3423
3478
|
class SessionLiveness:
|
|
3424
3479
|
"""The pass's tmux roster -- the oracle behind `session_liveness`.
|
|
3425
3480
|
|
|
@@ -3449,8 +3504,10 @@ class SessionLiveness:
|
|
|
3449
3504
|
self,
|
|
3450
3505
|
sessions: "list[dict] | None" = None,
|
|
3451
3506
|
reaped: "set[str] | None" = None,
|
|
3507
|
+
poll: bool = True,
|
|
3452
3508
|
):
|
|
3453
3509
|
self.listed = sessions is not None
|
|
3510
|
+
self.seq = next(_ROSTER_SEQ) if poll else 0
|
|
3454
3511
|
# The raw rows, kept for the fleet-vitals snapshot (issue #116) so
|
|
3455
3512
|
# the pass's ONE listing is also the roster it reports -- minus the
|
|
3456
3513
|
# names the sweep killed, which are not sessions any more. None when
|
|
@@ -4556,6 +4613,13 @@ class DevWatcher:
|
|
|
4556
4613
|
self._hung_watch: "hungtests.CpuWatch | None" = None
|
|
4557
4614
|
self._hung_tests_at: "float | None" = None
|
|
4558
4615
|
self._hung_reaped: "set[tuple[int, int]]" = set()
|
|
4616
|
+
# The dead-pane read (issue #186): per session, the roster seq of the
|
|
4617
|
+
# last poll its pane read a bare shell and how many polls in a row
|
|
4618
|
+
# did. Any other read drops the entry; a poll that skips the session
|
|
4619
|
+
# breaks the run, and poll_once prunes entries no poll can extend.
|
|
4620
|
+
self._shell_streaks: "dict[str, tuple[int, int]]" = {}
|
|
4621
|
+
# The process reaper's own clock (janitor cadence, issue #186).
|
|
4622
|
+
self._orphans_at: "float | None" = None
|
|
4559
4623
|
|
|
4560
4624
|
# -- per-issue decision ------------------------------------------------
|
|
4561
4625
|
|
|
@@ -5734,7 +5798,7 @@ class DevWatcher:
|
|
|
5734
5798
|
exc,
|
|
5735
5799
|
)
|
|
5736
5800
|
return False
|
|
5737
|
-
if SessionLiveness(sessions).of(session) is Liveness.DEAD:
|
|
5801
|
+
if SessionLiveness(sessions, poll=False).of(session) is Liveness.DEAD:
|
|
5738
5802
|
return True
|
|
5739
5803
|
log.info(
|
|
5740
5804
|
"resume: %s is back in the session listing — standing the resume "
|
|
@@ -5883,10 +5947,10 @@ class DevWatcher:
|
|
|
5883
5947
|
Dry-run classifies and logs but kills nothing and answers False
|
|
5884
5948
|
(the defer holds); so does a kill that fails, retried next poll.
|
|
5885
5949
|
|
|
5886
|
-
|
|
5887
|
-
|
|
5888
|
-
|
|
5889
|
-
|
|
5950
|
+
Once the kill lands, the process trees the session left running in
|
|
5951
|
+
its task workspace are reaped (`reap_session_processes`, issue
|
|
5952
|
+
#186): the seat env every one of them inherited names the session,
|
|
5953
|
+
however they were re-parented."""
|
|
5890
5954
|
command = self.alissa.pane_command(session)
|
|
5891
5955
|
if command not in SHELL_COMMANDS:
|
|
5892
5956
|
return False, None
|
|
@@ -5970,6 +6034,7 @@ class DevWatcher:
|
|
|
5970
6034
|
WEDGE_CLAUDE_EXITED, session, exc,
|
|
5971
6035
|
)
|
|
5972
6036
|
return False, None
|
|
6037
|
+
self.reap_session_processes(session)
|
|
5973
6038
|
if not self.state.activity_logged(issue.full_name, issue.number, kind):
|
|
5974
6039
|
landed = self._append_activity(
|
|
5975
6040
|
issue.owner,
|
|
@@ -5988,6 +6053,152 @@ class DevWatcher:
|
|
|
5988
6053
|
self.state.record_activity(issue.full_name, issue.number, kind)
|
|
5989
6054
|
return True, resume_pr
|
|
5990
6055
|
|
|
6056
|
+
def _shell_pane(
|
|
6057
|
+
self, session: str, roster: SessionLiveness
|
|
6058
|
+
) -> "tuple[str, int]":
|
|
6059
|
+
"""The session's pane foreground command, and for how many polls in
|
|
6060
|
+
a row it has read a bare shell (`SHELL_COMMANDS`) -- 0 for anything
|
|
6061
|
+
else, "" (tmux could not answer) included, which is absence of
|
|
6062
|
+
evidence and resets nothing it did not see (issue #186).
|
|
6063
|
+
|
|
6064
|
+
A poll is one roster (`roster.seq`): two reads inside one pass count
|
|
6065
|
+
once, so "2 polls" always means two passes, never one pass that
|
|
6066
|
+
happened to ask twice. And the polls must be ADJACENT: a pass that
|
|
6067
|
+
never read this pane (round not stale yet, PR not in the search, edge
|
|
6068
|
+
off) breaks the run, so two momentary shell reads hours apart never
|
|
6069
|
+
join into a DEAD read."""
|
|
6070
|
+
command = self.alissa.pane_command(session)
|
|
6071
|
+
seen_at, polls = self._shell_streaks.get(session, (None, 0))
|
|
6072
|
+
if command not in SHELL_COMMANDS:
|
|
6073
|
+
if not command and seen_at in (roster.seq - 1, roster.seq):
|
|
6074
|
+
# Polled but unreadable: carries the run, adds nothing.
|
|
6075
|
+
self._shell_streaks[session] = (roster.seq, polls)
|
|
6076
|
+
else:
|
|
6077
|
+
self._shell_streaks.pop(session, None)
|
|
6078
|
+
return command, 0
|
|
6079
|
+
if seen_at == roster.seq - 1:
|
|
6080
|
+
polls += 1
|
|
6081
|
+
elif seen_at != roster.seq:
|
|
6082
|
+
polls = 1
|
|
6083
|
+
self._shell_streaks[session] = (roster.seq, polls)
|
|
6084
|
+
return command, polls
|
|
6085
|
+
|
|
6086
|
+
def _prune_shell_streaks(self, roster: SessionLiveness) -> None:
|
|
6087
|
+
"""Drop the dead-pane runs this pass's poll can no longer extend --
|
|
6088
|
+
last read before the previous poll -- so sessions that vanished
|
|
6089
|
+
without a kill do not pile up over a long-lived daemon."""
|
|
6090
|
+
for session, (seen_at, _) in list(self._shell_streaks.items()):
|
|
6091
|
+
if seen_at < roster.seq - 1:
|
|
6092
|
+
del self._shell_streaks[session]
|
|
6093
|
+
|
|
6094
|
+
def _fix_claude_exited(
|
|
6095
|
+
self,
|
|
6096
|
+
pr: PullRequest,
|
|
6097
|
+
round_: int,
|
|
6098
|
+
attempts: int,
|
|
6099
|
+
session: str,
|
|
6100
|
+
command: str,
|
|
6101
|
+
polls: int,
|
|
6102
|
+
*,
|
|
6103
|
+
author_activity: bool,
|
|
6104
|
+
) -> bool:
|
|
6105
|
+
"""Issue #186: the fix edge's read of a stale round whose session is
|
|
6106
|
+
LISTED but whose pane is a bare shell -- True once the session is
|
|
6107
|
+
killed and the caller may read it DEAD.
|
|
6108
|
+
|
|
6109
|
+
The 2026-10-19 incident (studio PR #2518): a fix session's claude
|
|
6110
|
+
exited mid-tool-call, the pane fell back to bash, and the round
|
|
6111
|
+
logged "reads alive — a live session owns the round" every poll for
|
|
6112
|
+
17 h. Now SHELL_PANE_DEAD_POLLS consecutive shell reads make the
|
|
6113
|
+
session dead: it is killed (`alissa tmux kill`, the row's own
|
|
6114
|
+
session, never a sweep), the processes it left in its workspace are
|
|
6115
|
+
reaped, and one activity line on the PR says the previous session
|
|
6116
|
+
exited unexpectedly and what the round does next -- which the
|
|
6117
|
+
existing stale-attempt matrix decides once the caller reads DEAD: a
|
|
6118
|
+
respawn without triage replies, a resume behind them (when
|
|
6119
|
+
resume_enabled), the cap-out page when attempt_cap is spent.
|
|
6120
|
+
|
|
6121
|
+
Under the poll threshold, for a usage-limited session (never killed,
|
|
6122
|
+
issue #144), under dry-run and when the kill fails (retried next
|
|
6123
|
+
poll), the answer is False and every defer holds."""
|
|
6124
|
+
if self.state.session_limited(session):
|
|
6125
|
+
return False
|
|
6126
|
+
if polls < SHELL_PANE_DEAD_POLLS:
|
|
6127
|
+
log.info(
|
|
6128
|
+
"%s: fix session %s (%s round %d attempt %d) reads alive but "
|
|
6129
|
+
"its pane is at the shell (%s), poll %d of %d — read dead "
|
|
6130
|
+
"only if it still is next poll",
|
|
6131
|
+
WEDGE_CLAUDE_EXITED, session, pr.pr_slug, round_, attempts,
|
|
6132
|
+
command, polls, SHELL_PANE_DEAD_POLLS,
|
|
6133
|
+
)
|
|
6134
|
+
return False
|
|
6135
|
+
kind = fix_claude_exited_kind(round_, attempts)
|
|
6136
|
+
billed = self.state.fix_attempt_count(
|
|
6137
|
+
pr.full_name, pr.number, round_, billable=True
|
|
6138
|
+
)
|
|
6139
|
+
if author_activity and not self.config.resume_enabled:
|
|
6140
|
+
outcome = (
|
|
6141
|
+
"the round keeps its defer (resume_enabled is off), now "
|
|
6142
|
+
"reading dead"
|
|
6143
|
+
)
|
|
6144
|
+
elif billed >= self.config.attempt_cap:
|
|
6145
|
+
outcome = (
|
|
6146
|
+
f"the round has spent attempt_cap {self.config.attempt_cap}, "
|
|
6147
|
+
f"so the operator is paged instead of a restart"
|
|
6148
|
+
)
|
|
6149
|
+
else:
|
|
6150
|
+
outcome = (
|
|
6151
|
+
f"restarting the round as attempt {attempts + 1}"
|
|
6152
|
+
+ (" (resuming behind its triage replies)"
|
|
6153
|
+
if author_activity else "")
|
|
6154
|
+
)
|
|
6155
|
+
if self.state.escalated(pr.full_name, pr.number, kind):
|
|
6156
|
+
log.info(
|
|
6157
|
+
"%s: fix session %s (%s round %d attempt %d) still has no "
|
|
6158
|
+
"claude in its pane — retrying the kill",
|
|
6159
|
+
WEDGE_CLAUDE_EXITED, session, pr.pr_slug, round_, attempts,
|
|
6160
|
+
)
|
|
6161
|
+
else:
|
|
6162
|
+
log.warning(
|
|
6163
|
+
"%s: fix session %s (%s round %d attempt %d) reads alive but "
|
|
6164
|
+
"its pane has been at the shell (%s) for %d polls — its "
|
|
6165
|
+
"claude exited. Killing it; %s",
|
|
6166
|
+
WEDGE_CLAUDE_EXITED, session, pr.pr_slug, round_, attempts,
|
|
6167
|
+
command, polls, outcome,
|
|
6168
|
+
)
|
|
6169
|
+
self.state.record_escalation(pr.full_name, pr.number, kind)
|
|
6170
|
+
if self.config.dry_run:
|
|
6171
|
+
log.info("[dry-run] would kill %s (claude exited); %s", session, outcome)
|
|
6172
|
+
return False
|
|
6173
|
+
try:
|
|
6174
|
+
self.alissa.kill_session(session)
|
|
6175
|
+
except CommandError as exc:
|
|
6176
|
+
log.warning(
|
|
6177
|
+
"%s: could not kill %s (%s) — kills are best-effort; the "
|
|
6178
|
+
"round keeps its defer and the kill is retried next poll",
|
|
6179
|
+
WEDGE_CLAUDE_EXITED, session, exc,
|
|
6180
|
+
)
|
|
6181
|
+
return False
|
|
6182
|
+
self._shell_streaks.pop(session, None)
|
|
6183
|
+
self.reap_session_processes(session)
|
|
6184
|
+
if not self.state.activity_logged(pr.full_name, pr.number, kind):
|
|
6185
|
+
landed = self._append_activity(
|
|
6186
|
+
pr.owner,
|
|
6187
|
+
pr.repo,
|
|
6188
|
+
pr.number,
|
|
6189
|
+
self._activity_line(
|
|
6190
|
+
session,
|
|
6191
|
+
"fix edge",
|
|
6192
|
+
f"{ACTIVITY_WEDGE_KILLED} — {WEDGE_CLAUDE_EXITED}: "
|
|
6193
|
+
f"{EXITED_RESTART_NOTE} (its pane was at `{command}` "
|
|
6194
|
+
f"for {polls} polls); killed, {outcome}",
|
|
6195
|
+
),
|
|
6196
|
+
pr.pr_slug,
|
|
6197
|
+
)
|
|
6198
|
+
if landed:
|
|
6199
|
+
self.state.record_activity(pr.full_name, pr.number, kind)
|
|
6200
|
+
return True
|
|
6201
|
+
|
|
5991
6202
|
def _wedged_on_first_run_dialog(
|
|
5992
6203
|
self, issue: Issue, attempts: int, session: str
|
|
5993
6204
|
) -> bool:
|
|
@@ -6749,6 +6960,7 @@ class DevWatcher:
|
|
|
6749
6960
|
# says CONFIRMED dead.
|
|
6750
6961
|
resume_dead_session: "str | None" = None
|
|
6751
6962
|
confirmed_dead_session: "str | None" = None
|
|
6963
|
+
exited_session: "str | None" = None
|
|
6752
6964
|
if age is not None:
|
|
6753
6965
|
author_activity = any(
|
|
6754
6966
|
r.author == pr.author and r.submitted_at > newest.submitted_at
|
|
@@ -6758,17 +6970,34 @@ class DevWatcher:
|
|
|
6758
6970
|
pr.full_name, number, round_
|
|
6759
6971
|
)
|
|
6760
6972
|
liveness = self.session_liveness(row_session, roster)
|
|
6973
|
+
# Issue #186: LISTED is not alive. A session whose pane has read a
|
|
6974
|
+
# bare shell for SHELL_PANE_DEAD_POLLS polls lost its claude; once
|
|
6975
|
+
# it is killed it reads DEAD, and the matrix below respawns or
|
|
6976
|
+
# resumes the round exactly as for a session gone from the list.
|
|
6977
|
+
pane = ""
|
|
6978
|
+
if liveness is Liveness.ALIVE and row_session and roster is not None:
|
|
6979
|
+
pane, polls = self._shell_pane(row_session, roster)
|
|
6980
|
+
if polls and self._fix_claude_exited(
|
|
6981
|
+
pr, round_, attempts, row_session, pane, polls,
|
|
6982
|
+
author_activity=author_activity,
|
|
6983
|
+
):
|
|
6984
|
+
liveness = Liveness.DEAD
|
|
6985
|
+
exited_session = row_session
|
|
6986
|
+
stalled_after = (
|
|
6987
|
+
STALLED_DEFER_MULTIPLE * self.config.stale_minutes * 60
|
|
6988
|
+
)
|
|
6761
6989
|
if not author_activity:
|
|
6762
6990
|
if liveness is Liveness.ALIVE:
|
|
6763
|
-
|
|
6764
|
-
|
|
6991
|
+
live_kind = stall_repeat_kind(
|
|
6992
|
+
fix_live_stalled_kind(round_, attempts), age,
|
|
6993
|
+
stalled_after,
|
|
6765
6994
|
)
|
|
6766
6995
|
if age >= stalled_after and not self.state.escalated(
|
|
6767
|
-
pr.full_name, number,
|
|
6768
|
-
fix_live_stalled_kind(round_, attempts),
|
|
6996
|
+
pr.full_name, number, live_kind,
|
|
6769
6997
|
):
|
|
6770
6998
|
self._escalate_fix_live_stalled(
|
|
6771
|
-
pr, round_, attempts, age, row_session or ""
|
|
6999
|
+
pr, round_, attempts, age, row_session or "",
|
|
7000
|
+
kind=live_kind, pane=pane,
|
|
6772
7001
|
)
|
|
6773
7002
|
fix_live_timer = self._stall_timer(
|
|
6774
7003
|
pr.full_name, number,
|
|
@@ -6809,13 +7038,28 @@ class DevWatcher:
|
|
|
6809
7038
|
# resume is an ORDINARY attempt of this round.
|
|
6810
7039
|
resume_dead_session = row_session
|
|
6811
7040
|
else:
|
|
6812
|
-
|
|
6813
|
-
|
|
7041
|
+
# Repeats only while the session may still be there: a
|
|
7042
|
+
# DEAD one (resume off) waits on a human with nothing
|
|
7043
|
+
# left to read, and its seat may park after the first
|
|
7044
|
+
# ping -- a repeat would then be the very page a wake
|
|
7045
|
+
# must never manufacture (parking §2.9).
|
|
7046
|
+
stall_kind = (
|
|
7047
|
+
fix_stalled_kind(round_, attempts)
|
|
7048
|
+
if liveness is Liveness.DEAD
|
|
7049
|
+
else stall_repeat_kind(
|
|
7050
|
+
fix_stalled_kind(round_, attempts), age,
|
|
7051
|
+
stalled_after,
|
|
7052
|
+
)
|
|
6814
7053
|
)
|
|
6815
7054
|
if age >= stalled_after and not self.state.escalated(
|
|
6816
|
-
pr.full_name, number,
|
|
7055
|
+
pr.full_name, number, stall_kind
|
|
6817
7056
|
):
|
|
6818
|
-
|
|
7057
|
+
if not pane and liveness is Liveness.ALIVE and row_session:
|
|
7058
|
+
pane = self.alissa.pane_command(row_session)
|
|
7059
|
+
self._escalate_fix_stalled(
|
|
7060
|
+
pr, round_, attempts, age, kind=stall_kind,
|
|
7061
|
+
pane=pane, session=row_session,
|
|
7062
|
+
)
|
|
6819
7063
|
# Parking (issue #153), the issue edge's rule: owed until
|
|
6820
7064
|
# the stall ping is out, and past it while the session
|
|
6821
7065
|
# might still be there; a dead one waits on a human.
|
|
@@ -6877,14 +7121,25 @@ class DevWatcher:
|
|
|
6877
7121
|
if age is not None and resume_dead_session is not None:
|
|
6878
7122
|
log.warning(
|
|
6879
7123
|
"%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) — "
|
|
7124
|
+
"behind its own triage replies and its session %s %s — "
|
|
6882
7125
|
"re-enqueuing as attempt %d to RESUME the round",
|
|
6883
7126
|
pr.pr_slug,
|
|
6884
7127
|
attempts,
|
|
6885
7128
|
round_,
|
|
6886
7129
|
age / 60,
|
|
6887
7130
|
resume_dead_session,
|
|
7131
|
+
"exited (pane back at the shell; killed)"
|
|
7132
|
+
if exited_session is not None
|
|
7133
|
+
else "is absent from the tmux listing (CONFIRMED dead, not "
|
|
7134
|
+
"presumed)",
|
|
7135
|
+
attempts + 1,
|
|
7136
|
+
)
|
|
7137
|
+
elif age is not None and exited_session is not None:
|
|
7138
|
+
log.warning(
|
|
7139
|
+
"%s fix attempt %d (round %d) has been in flight %.0f min "
|
|
7140
|
+
"with no re-request and its session %s exited (pane back at "
|
|
7141
|
+
"the shell; killed) — restarting the round as attempt %d",
|
|
7142
|
+
pr.pr_slug, attempts, round_, age / 60, exited_session,
|
|
6888
7143
|
attempts + 1,
|
|
6889
7144
|
)
|
|
6890
7145
|
elif age is not None:
|
|
@@ -6912,6 +7167,7 @@ class DevWatcher:
|
|
|
6912
7167
|
reenqueued=age is not None,
|
|
6913
7168
|
dead_session=resume_dead_session,
|
|
6914
7169
|
confirmed_dead=confirmed_dead_session,
|
|
7170
|
+
exited=exited_session is not None,
|
|
6915
7171
|
)
|
|
6916
7172
|
|
|
6917
7173
|
def _reviewer_reviews(
|
|
@@ -8343,8 +8599,29 @@ class DevWatcher:
|
|
|
8343
8599
|
)
|
|
8344
8600
|
self.state.record_escalation(pr.full_name, pr.number, fix_cap_kind(round_))
|
|
8345
8601
|
|
|
8602
|
+
@staticmethod
|
|
8603
|
+
def _fix_stalled_tail(kind: str, pane: str, session: "str | None") -> str:
|
|
8604
|
+
"""FIX_STALLED_TAIL for one ping: `pane` as read, or why there is no
|
|
8605
|
+
reading; and which repeat this is (`kind`'s `@<n>` suffix)."""
|
|
8606
|
+
if pane:
|
|
8607
|
+
shown = f"`{pane}`"
|
|
8608
|
+
if pane in SHELL_COMMANDS:
|
|
8609
|
+
shown += " (a bare shell: the session's claude has exited)"
|
|
8610
|
+
elif session:
|
|
8611
|
+
shown = f"unreadable for `{session}`"
|
|
8612
|
+
else:
|
|
8613
|
+
shown = "unknown (no session recorded for this attempt)"
|
|
8614
|
+
_, sep, repeat = kind.rpartition("@")
|
|
8615
|
+
ordinal = f" (repeat {repeat})" if sep and repeat.isdigit() else ""
|
|
8616
|
+
return FIX_STALLED_TAIL.format(
|
|
8617
|
+
pane=shown, repeat_hours=FIX_STALLED_REPEAT_SECONDS // 3600,
|
|
8618
|
+
ordinal=ordinal,
|
|
8619
|
+
)
|
|
8620
|
+
|
|
8346
8621
|
def _escalate_fix_stalled(
|
|
8347
|
-
self, pr: PullRequest, round_: int, attempts: int, age: float
|
|
8622
|
+
self, pr: PullRequest, round_: int, attempts: int, age: float,
|
|
8623
|
+
*, kind: "str | None" = None, pane: str = "",
|
|
8624
|
+
session: "str | None" = None,
|
|
8348
8625
|
) -> None:
|
|
8349
8626
|
"""Operator ping when the author-activity deferral itself runs long
|
|
8350
8627
|
(_escalate_stalled's shape): the author's own triage replies are the
|
|
@@ -8357,17 +8634,20 @@ class DevWatcher:
|
|
|
8357
8634
|
exactly-once delivery contract as _escalate_stalled -- a transient
|
|
8358
8635
|
failure retries next poll. The decision stays IN_FLIGHT either way
|
|
8359
8636
|
-- this comments, it never respawns."""
|
|
8637
|
+
kind = kind or fix_stalled_kind(round_, attempts)
|
|
8360
8638
|
body = FIX_STALLED_COMMENT.format(
|
|
8361
8639
|
attempts=attempts, round=round_, minutes=int(age / 60)
|
|
8362
|
-
)
|
|
8640
|
+
) + self._fix_stalled_tail(kind, pane, session)
|
|
8363
8641
|
log.warning(
|
|
8364
8642
|
"FIX STALLED %s fix attempt %d (round %d) has been deferred "
|
|
8365
|
-
"%.0f min behind the author's own triage replies —
|
|
8366
|
-
"to operator (
|
|
8643
|
+
"%.0f min behind the author's own triage replies (pane: %s) — "
|
|
8644
|
+
"escalating to operator (repeats every %d h while it persists)",
|
|
8367
8645
|
pr.pr_slug,
|
|
8368
8646
|
attempts,
|
|
8369
8647
|
round_,
|
|
8370
8648
|
age / 60,
|
|
8649
|
+
pane or "unread",
|
|
8650
|
+
FIX_STALLED_REPEAT_SECONDS // 3600,
|
|
8371
8651
|
)
|
|
8372
8652
|
|
|
8373
8653
|
if self.config.dry_run:
|
|
@@ -8384,28 +8664,29 @@ class DevWatcher:
|
|
|
8384
8664
|
exc,
|
|
8385
8665
|
)
|
|
8386
8666
|
return
|
|
8387
|
-
self.state.record_escalation(
|
|
8388
|
-
pr.full_name, pr.number, fix_stalled_kind(round_, attempts)
|
|
8389
|
-
)
|
|
8667
|
+
self.state.record_escalation(pr.full_name, pr.number, kind)
|
|
8390
8668
|
|
|
8391
8669
|
def _escalate_fix_live_stalled(
|
|
8392
8670
|
self, pr: PullRequest, round_: int, attempts: int, age: float,
|
|
8393
|
-
session: str,
|
|
8671
|
+
session: str, *, kind: "str | None" = None, pane: str = "",
|
|
8394
8672
|
) -> None:
|
|
8395
8673
|
"""_escalate_fix_stalled's ALIVE twin (issue #142): the liveness
|
|
8396
8674
|
guard on the no-triage-reply branch defers while the row's session is
|
|
8397
8675
|
listed, and this ping is its floor. Same delivery contract: the row
|
|
8398
8676
|
is recorded only AFTER the comment posts; the decision stays
|
|
8399
8677
|
IN_FLIGHT either way."""
|
|
8678
|
+
kind = kind or fix_live_stalled_kind(round_, attempts)
|
|
8400
8679
|
body = FIX_LIVE_STALLED_COMMENT.format(
|
|
8401
8680
|
attempts=attempts, round=round_, minutes=int(age / 60),
|
|
8402
8681
|
session=session,
|
|
8403
|
-
)
|
|
8682
|
+
) + self._fix_stalled_tail(kind, pane, session)
|
|
8404
8683
|
log.warning(
|
|
8405
8684
|
"FIX STALLED %s fix attempt %d (round %d) has been deferred "
|
|
8406
8685
|
"%.0f min behind its still-listed session %s with no triage "
|
|
8407
|
-
"reply — escalating to operator (
|
|
8686
|
+
"reply (pane: %s) — escalating to operator (repeats every %d h "
|
|
8687
|
+
"while it persists)",
|
|
8408
8688
|
pr.pr_slug, attempts, round_, age / 60, session,
|
|
8689
|
+
pane or "unread", FIX_STALLED_REPEAT_SECONDS // 3600,
|
|
8409
8690
|
)
|
|
8410
8691
|
if self.config.dry_run:
|
|
8411
8692
|
log.info("[dry-run] would comment on %s:\n%s", pr.pr_slug, body)
|
|
@@ -8419,9 +8700,7 @@ class DevWatcher:
|
|
|
8419
8700
|
pr.pr_slug, exc,
|
|
8420
8701
|
)
|
|
8421
8702
|
return
|
|
8422
|
-
self.state.record_escalation(
|
|
8423
|
-
pr.full_name, pr.number, fix_live_stalled_kind(round_, attempts)
|
|
8424
|
-
)
|
|
8703
|
+
self.state.record_escalation(pr.full_name, pr.number, kind)
|
|
8425
8704
|
|
|
8426
8705
|
def _hand_over_round(
|
|
8427
8706
|
self,
|
|
@@ -8743,6 +9022,7 @@ class DevWatcher:
|
|
|
8743
9022
|
reenqueued: bool = False,
|
|
8744
9023
|
dead_session: "str | None" = None,
|
|
8745
9024
|
confirmed_dead: "str | None" = None,
|
|
9025
|
+
exited: bool = False,
|
|
8746
9026
|
) -> Decision:
|
|
8747
9027
|
"""The fix edge's spawn, behind the drain gate (`_drain_gated`)."""
|
|
8748
9028
|
return self._drain_gated(
|
|
@@ -8750,7 +9030,7 @@ class DevWatcher:
|
|
|
8750
9030
|
lambda: self._spawn_fix_claimed(
|
|
8751
9031
|
pr, round_, attempt, review, budget,
|
|
8752
9032
|
reenqueued=reenqueued, dead_session=dead_session,
|
|
8753
|
-
confirmed_dead=confirmed_dead,
|
|
9033
|
+
confirmed_dead=confirmed_dead, exited=exited,
|
|
8754
9034
|
),
|
|
8755
9035
|
round_=round_,
|
|
8756
9036
|
)
|
|
@@ -8766,6 +9046,7 @@ class DevWatcher:
|
|
|
8766
9046
|
reenqueued: bool = False,
|
|
8767
9047
|
dead_session: "str | None" = None,
|
|
8768
9048
|
confirmed_dead: "str | None" = None,
|
|
9049
|
+
exited: bool = False,
|
|
8769
9050
|
) -> Decision:
|
|
8770
9051
|
skipped = self._foreign_head_skip(pr, attempt)
|
|
8771
9052
|
if skipped is not None:
|
|
@@ -8886,7 +9167,14 @@ class DevWatcher:
|
|
|
8886
9167
|
#
|
|
8887
9168
|
# The session name carries the round and attempt; the context marks a
|
|
8888
9169
|
# retry, and names the confirmation when a resume is what unblocked it.
|
|
8889
|
-
if
|
|
9170
|
+
if exited:
|
|
9171
|
+
# Issue #186: the session was listed, but its claude was gone.
|
|
9172
|
+
context = (
|
|
9173
|
+
f"{'resumed' if dead_session is not None else 're-enqueued'} "
|
|
9174
|
+
f"— {EXITED_RESTART_NOTE} (its pane was back at the shell; "
|
|
9175
|
+
f"killed); restarting the round"
|
|
9176
|
+
)
|
|
9177
|
+
elif dead_session is not None:
|
|
8890
9178
|
context = (
|
|
8891
9179
|
"resumed — previous session confirmed dead (absent from the "
|
|
8892
9180
|
"tmux listing); finishing the round on the same branch"
|
|
@@ -12095,6 +12383,146 @@ class DevWatcher:
|
|
|
12095
12383
|
if hungtests.message_session(session_id, text):
|
|
12096
12384
|
log.info("hung tests: told %s its test run was stopped", tree.session)
|
|
12097
12385
|
|
|
12386
|
+
# -- the dead-session process reaper (issue #186) ----------------------
|
|
12387
|
+
|
|
12388
|
+
def _watched_hubs(self) -> "list[Path]":
|
|
12389
|
+
hubs = []
|
|
12390
|
+
for owner, repo in self._watched_repos():
|
|
12391
|
+
hub = self.config.hub_for(owner, repo)
|
|
12392
|
+
if hub.is_dir():
|
|
12393
|
+
hubs.append(hub.resolve())
|
|
12394
|
+
return hubs
|
|
12395
|
+
|
|
12396
|
+
def reap_session_processes(self, session: str) -> int:
|
|
12397
|
+
"""Reap what ONE session the daemon just killed left running in its
|
|
12398
|
+
task workspace: every process tree rooted there whose seat env names
|
|
12399
|
+
`session` (`hungtests.find_session_orphans`). Returns how many
|
|
12400
|
+
processes were signalled. Never raises."""
|
|
12401
|
+
try:
|
|
12402
|
+
hubs = self._watched_hubs()
|
|
12403
|
+
if not hubs:
|
|
12404
|
+
return 0
|
|
12405
|
+
orphans = hungtests.find_session_orphans(
|
|
12406
|
+
hungtests.snapshot(), hubs, lambda name: name == session
|
|
12407
|
+
)
|
|
12408
|
+
return self._reap_orphans(orphans, f"its session {session} was killed")
|
|
12409
|
+
except (CommandError, OSError) as exc:
|
|
12410
|
+
log.error("process reaper: %s could not be reaped (%s)", session, exc)
|
|
12411
|
+
return 0
|
|
12412
|
+
|
|
12413
|
+
def run_process_reaper(self, roster: "SessionLiveness | None") -> int:
|
|
12414
|
+
"""Reap the processes dead sessions left behind (issue #186), on the
|
|
12415
|
+
janitor's cadence (`janitor_interval_minutes`, its own clock).
|
|
12416
|
+
|
|
12417
|
+
2026-10-19: orphan `npm test` runs of sessions whose PRs had merged
|
|
12418
|
+
17-18 h earlier, and ~20 idle executor helpers (`node /tmp/.tmpXXXX/
|
|
12419
|
+
local.cjs --ipc-path ….executor.sock`, ~115 MB each), on a seat at
|
|
12420
|
+
load 16-25. Two sweeps, both needing a SUCCESSFUL listing (no
|
|
12421
|
+
listing is no evidence, and nothing is touched):
|
|
12422
|
+
|
|
12423
|
+
* a process tree rooted in a watched hub's task workspace whose seat
|
|
12424
|
+
env names a session the listing does not carry -- killed, gone, or
|
|
12425
|
+
reaped after its lane's PR merged or closed. A tree of a session
|
|
12426
|
+
still listed is never touched, whatever its lane's state;
|
|
12427
|
+
* an executor helper whose parent is gone, unless its env names a
|
|
12428
|
+
listed session.
|
|
12429
|
+
|
|
12430
|
+
The pass's roster is listed at the START of the pass, and the edges
|
|
12431
|
+
enqueue sessions after it, so on its own it would read a session
|
|
12432
|
+
spawned this pass DEAD and kill its fresh claude. The sweep
|
|
12433
|
+
therefore lists again right AFTER the /proc snapshot: a session that
|
|
12434
|
+
owns a snapshotted process existed before the snapshot, so it is in
|
|
12435
|
+
that listing unless it really ended. A name is DEAD only when BOTH
|
|
12436
|
+
listings lack it; if the fresh listing fails, nothing is touched.
|
|
12437
|
+
The fresh listing is taken only when the pass roster finds something
|
|
12438
|
+
to reap, so a quiet seat keeps its one listing per pass.
|
|
12439
|
+
|
|
12440
|
+
Everything reaped is logged. Nothing in here may take a poll down."""
|
|
12441
|
+
if roster is None or not roster.listed:
|
|
12442
|
+
return 0
|
|
12443
|
+
interval = self.config.janitor_interval_seconds
|
|
12444
|
+
now = time.monotonic()
|
|
12445
|
+
if (
|
|
12446
|
+
self._orphans_at is not None
|
|
12447
|
+
and interval > 0
|
|
12448
|
+
and now - self._orphans_at < interval
|
|
12449
|
+
):
|
|
12450
|
+
return 0
|
|
12451
|
+
self._orphans_at = now
|
|
12452
|
+
try:
|
|
12453
|
+
procs = hungtests.snapshot()
|
|
12454
|
+
hubs = self._watched_hubs()
|
|
12455
|
+
|
|
12456
|
+
def sweep(
|
|
12457
|
+
dead: "Callable[[str], bool]",
|
|
12458
|
+
) -> "tuple[list[hungtests.Orphan], list[hungtests.Orphan]]":
|
|
12459
|
+
trees = (
|
|
12460
|
+
hungtests.find_session_orphans(procs, hubs, dead)
|
|
12461
|
+
if hubs else []
|
|
12462
|
+
)
|
|
12463
|
+
executors = hungtests.find_parentless_executors(
|
|
12464
|
+
procs, lambda name: not dead(name)
|
|
12465
|
+
)
|
|
12466
|
+
return trees, executors
|
|
12467
|
+
|
|
12468
|
+
trees, executors = sweep(
|
|
12469
|
+
lambda name: roster.of(name) is Liveness.DEAD
|
|
12470
|
+
)
|
|
12471
|
+
if not trees and not executors:
|
|
12472
|
+
return 0
|
|
12473
|
+
# Listed AFTER the snapshot, never before (see the docstring).
|
|
12474
|
+
fresh = SessionLiveness(self.alissa.list_sessions(), poll=False)
|
|
12475
|
+
trees, executors = sweep(
|
|
12476
|
+
lambda name: roster.of(name) is Liveness.DEAD
|
|
12477
|
+
and fresh.of(name) is Liveness.DEAD
|
|
12478
|
+
)
|
|
12479
|
+
return self._reap_orphans(
|
|
12480
|
+
trees, "its session is gone"
|
|
12481
|
+
) + self._reap_orphans(executors, "its parent is gone")
|
|
12482
|
+
except (CommandError, OSError) as exc:
|
|
12483
|
+
log.error("process reaper: sweep failed (%s) — skipped this pass", exc)
|
|
12484
|
+
return 0
|
|
12485
|
+
|
|
12486
|
+
def _reap_orphans(
|
|
12487
|
+
self, orphans: "list[hungtests.Orphan]", reason: str
|
|
12488
|
+
) -> int:
|
|
12489
|
+
"""Log, then TERM (KILL after the grace) one batch of orphans;
|
|
12490
|
+
one WARNING per workspace (or for the executors) naming every pid
|
|
12491
|
+
and command. Returns how many processes were signalled."""
|
|
12492
|
+
if not orphans:
|
|
12493
|
+
return 0
|
|
12494
|
+
groups: "dict[str, list[hungtests.Orphan]]" = {}
|
|
12495
|
+
for orphan in orphans:
|
|
12496
|
+
where = (
|
|
12497
|
+
orphan.workspace.slug if orphan.workspace is not None
|
|
12498
|
+
else "parentless executor helpers"
|
|
12499
|
+
)
|
|
12500
|
+
groups.setdefault(where, []).append(orphan)
|
|
12501
|
+
for where, members in groups.items():
|
|
12502
|
+
listing = "; ".join(
|
|
12503
|
+
f"pid {o.proc.pid} `{o.command[:160]}`"
|
|
12504
|
+
+ (f" ({o.session})" if o.session else "")
|
|
12505
|
+
for o in members
|
|
12506
|
+
)
|
|
12507
|
+
if self.config.dry_run:
|
|
12508
|
+
log.info(
|
|
12509
|
+
"[dry-run] process reaper would stop %d process(es) in "
|
|
12510
|
+
"%s — %s: %s", len(members), where, reason, listing,
|
|
12511
|
+
)
|
|
12512
|
+
else:
|
|
12513
|
+
log.warning(
|
|
12514
|
+
"process reaper: stopping %d process(es) in %s — %s: %s",
|
|
12515
|
+
len(members), where, reason, listing,
|
|
12516
|
+
)
|
|
12517
|
+
if self.config.dry_run:
|
|
12518
|
+
return 0
|
|
12519
|
+
termed, killed = hungtests.kill_procs([o.proc for o in orphans])
|
|
12520
|
+
if termed:
|
|
12521
|
+
log.info(
|
|
12522
|
+
"process reaper: TERM to %d process(es), KILL to %d", termed, killed
|
|
12523
|
+
)
|
|
12524
|
+
return termed
|
|
12525
|
+
|
|
12098
12526
|
def _janitor_due(self) -> bool:
|
|
12099
12527
|
"""Has `janitor_interval_minutes` elapsed since the last expensive
|
|
12100
12528
|
half? The first pass of a process always runs (nothing has been swept
|
|
@@ -12785,6 +13213,7 @@ class DevWatcher:
|
|
|
12785
13213
|
included."""
|
|
12786
13214
|
started = time.monotonic()
|
|
12787
13215
|
sweep, budget, roster = self._sweep_and_budget()
|
|
13216
|
+
self._prune_shell_streaks(roster)
|
|
12788
13217
|
|
|
12789
13218
|
# The allowlist is re-derived BETWEEN the sweep and the searches: the
|
|
12790
13219
|
# sweep is deliberately search-independent and must not wait on a feed
|
|
@@ -12833,6 +13262,10 @@ class DevWatcher:
|
|
|
12833
13262
|
# worker's test run that stopped making CPU progress, or outlived its
|
|
12834
13263
|
# lane, is stopped and its live session told once.
|
|
12835
13264
|
self.run_hung_tests(roster)
|
|
13265
|
+
# The process reaper (issue #186), on the same cadence: what dead
|
|
13266
|
+
# sessions left running in their workspaces, and executor helpers
|
|
13267
|
+
# whose parent is gone.
|
|
13268
|
+
self.run_process_reaper(roster)
|
|
12836
13269
|
|
|
12837
13270
|
counts = Counter(decision.action for _, decision in results)
|
|
12838
13271
|
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.53
|
{alissa_tools_github_devloop-0.8.52.dist-info → alissa_tools_github_devloop-0.8.53.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=hhB92wgot5H2KTCg3gARWos1mDK-05GV8brr9_CyYH4,690963
|
|
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=vNneLCSZvtUhO_wU9JFzULL8TpYn9ulHY4YWNTYykZU,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.53.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
29
|
+
alissa_tools_github_devloop-0.8.53.dist-info/licenses/NOTICE,sha256=FKstNs3uw_tQod4_1XcFLD1FUTJQXf4FC0oXJAOA_HE,260
|
|
30
|
+
alissa_tools_github_devloop-0.8.53.dist-info/METADATA,sha256=VhWa2WDOnuqVugjFYmLjM-8FMYCtjViai7ECLPsW9qE,15973
|
|
31
|
+
alissa_tools_github_devloop-0.8.53.dist-info/WHEEL,sha256=a-udL2m4V_6Yn8b-uKzWb12RjRxwwMo6ohYFM24tpaY,91
|
|
32
|
+
alissa_tools_github_devloop-0.8.53.dist-info/entry_points.txt,sha256=8aC7Ajzx4bu9ZRR37a40IDfuStacvZP5KsAxY1dbFKU,145
|
|
33
|
+
alissa_tools_github_devloop-0.8.53.dist-info/top_level.txt,sha256=DodjDg-l-TWQnlxG-Vuc3G5sB4O7cWGQwe6XrVx3u-A,7
|
|
34
|
+
alissa_tools_github_devloop-0.8.53.dist-info/RECORD,,
|
{alissa_tools_github_devloop-0.8.52.dist-info → alissa_tools_github_devloop-0.8.53.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|