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.
@@ -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
@@ -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
- 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)."""
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
- stalled_after = (
6764
- STALLED_DEFER_MULTIPLE * self.config.stale_minutes * 60
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
- stalled_after = (
6813
- STALLED_DEFER_MULTIPLE * self.config.stale_minutes * 60
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, fix_stalled_kind(round_, attempts)
7055
+ pr.full_name, number, stall_kind
6817
7056
  ):
6818
- self._escalate_fix_stalled(pr, round_, attempts, age)
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 is absent "
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 — escalating "
8366
- "to operator (once per episode)",
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 (once per episode)",
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 dead_session is not None:
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
- _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 +1 @@
1
- 0.8.52
1
+ 0.8.53
@@ -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.53
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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=LKBLg0fmswEhGz6GcPWoOx1bDJf0NCBd0UGJLl0K6Yo,18185
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=JuT3cWOjOZAz0mA-gPdfl-YnepkmWSf0jVX6-N2SmWU,670260
14
- alissa/tools/github/devloop/loop_events.py,sha256=pKtBl1HK8gYPB2gKiI29xUkx-JuW2A48KwRlbdcf_lo,37942
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=zTdwnMsnc43u41Sz30AsmePfwZ75js_gUSxIgwqVa64,7
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.52.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
29
- alissa_tools_github_devloop-0.8.52.dist-info/licenses/NOTICE,sha256=FKstNs3uw_tQod4_1XcFLD1FUTJQXf4FC0oXJAOA_HE,260
30
- alissa_tools_github_devloop-0.8.52.dist-info/METADATA,sha256=ubhTFTwht-kKRAONJl7o-c8qhMlZfuLcHIojF0OtlT4,15973
31
- alissa_tools_github_devloop-0.8.52.dist-info/WHEEL,sha256=a-udL2m4V_6Yn8b-uKzWb12RjRxwwMo6ohYFM24tpaY,91
32
- alissa_tools_github_devloop-0.8.52.dist-info/entry_points.txt,sha256=8aC7Ajzx4bu9ZRR37a40IDfuStacvZP5KsAxY1dbFKU,145
33
- alissa_tools_github_devloop-0.8.52.dist-info/top_level.txt,sha256=DodjDg-l-TWQnlxG-Vuc3G5sB4O7cWGQwe6XrVx3u-A,7
34
- alissa_tools_github_devloop-0.8.52.dist-info/RECORD,,
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,,