alissa-tools-github-devloop 0.8.52__tar.gz → 0.8.54__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.52/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.54}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/hungtests.py +168 -4
  3. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/loop.py +507 -36
  4. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/loop_events.py +4 -2
  5. alissa_tools_github_devloop-0.8.54/src/main/alissa/tools/github/devloop/version +1 -0
  6. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  7. alissa_tools_github_devloop-0.8.52/src/main/alissa/tools/github/devloop/version +0 -1
  8. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/LICENSE +0 -0
  9. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/MANIFEST.in +0 -0
  10. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/NOTICE +0 -0
  11. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/README.md +0 -0
  12. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/requirements.txt +0 -0
  13. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/setup.cfg +0 -0
  14. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/setup.py +0 -0
  15. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  16. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
  17. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  18. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
  19. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  20. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/config.py +0 -0
  21. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/fleet_vitals.py +0 -0
  22. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
  23. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/hubs.py +0 -0
  24. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/integration_check.py +0 -0
  25. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
  26. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/parking.py +0 -0
  27. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  28. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/prompts.py +0 -0
  29. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/state.py +0 -0
  30. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/version.py +0 -0
  31. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  32. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  33. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  34. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  35. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  36. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
  37. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  38. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
  39. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  40. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  41. {alissa_tools_github_devloop-0.8.52 → alissa_tools_github_devloop-0.8.54}/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.52
3
+ Version: 0.8.54
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
@@ -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 is byte-for-byte the v0.1 text, and the sha256 pin
969
- # in test_discovery.py proves it.
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
- "nothing else; no other file anywhere in the repository changes. "
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>/findings.md`. "
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
- Not done here: the process groups the session's claude started
5887
- that outlived it (a test run re-parented to init). Nothing on the
5888
- daemon's side ties such a group to a session once its parent is
5889
- gone, so they are left to the operator (see the README)."""
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
- stalled_after = (
6764
- STALLED_DEFER_MULTIPLE * self.config.stale_minutes * 60
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
- stalled_after = (
6813
- STALLED_DEFER_MULTIPLE * self.config.stale_minutes * 60
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, fix_stalled_kind(round_, attempts)
7093
+ pr.full_name, number, stall_kind
6817
7094
  ):
6818
- self._escalate_fix_stalled(pr, round_, attempts, age)
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 is absent "
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 — escalating "
8366
- "to operator (once per episode)",
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 (once per episode)",
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 dead_session is not None:
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
- _FIX_STALLED_RE = re.compile(r"^fix-stalled:r(\d+)a(\d+)$")
161
- _FIX_LIVE_STALLED_RE = re.compile(r"^fix-live-stalled:r(\d+)a(\d+)$")
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.52
3
+ Version: 0.8.54
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera