autosentry 0.7.1__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.
Files changed (90) hide show
  1. autosentry/__init__.py +3 -0
  2. autosentry/__main__.py +4 -0
  3. autosentry/_tail.py +84 -0
  4. autosentry/analyze.py +174 -0
  5. autosentry/cli/__init__.py +85 -0
  6. autosentry/cli/commands/__init__.py +1 -0
  7. autosentry/cli/commands/analyze.py +154 -0
  8. autosentry/cli/commands/dispatcher.py +266 -0
  9. autosentry/cli/commands/doctor.py +313 -0
  10. autosentry/cli/commands/healer.py +145 -0
  11. autosentry/cli/commands/incidents.py +90 -0
  12. autosentry/cli/commands/init.py +495 -0
  13. autosentry/cli/commands/onboard.py +224 -0
  14. autosentry/cli/commands/run.py +40 -0
  15. autosentry/cli/commands/skills.py +192 -0
  16. autosentry/cli/commands/status.py +55 -0
  17. autosentry/cli/commands/update.py +76 -0
  18. autosentry/cli/commands/watch.py +47 -0
  19. autosentry/cli/commands/web.py +42 -0
  20. autosentry/cli/style.py +163 -0
  21. autosentry/config.py +273 -0
  22. autosentry/detectors/__init__.py +66 -0
  23. autosentry/detectors/base.py +56 -0
  24. autosentry/detectors/exit_code.py +42 -0
  25. autosentry/detectors/pattern.py +37 -0
  26. autosentry/detectors/stall.py +82 -0
  27. autosentry/detectors/traceback.py +165 -0
  28. autosentry/dispatcher/__init__.py +44 -0
  29. autosentry/dispatcher/backends.py +471 -0
  30. autosentry/dispatcher/daemon.py +370 -0
  31. autosentry/git_ops.py +205 -0
  32. autosentry/healers/__init__.py +7 -0
  33. autosentry/healers/base.py +20 -0
  34. autosentry/healers/claude.py +385 -0
  35. autosentry/healers/rules.py +38 -0
  36. autosentry/inbox.py +170 -0
  37. autosentry/incidents/__init__.py +14 -0
  38. autosentry/incidents/exploder.py +306 -0
  39. autosentry/incidents/report.py +85 -0
  40. autosentry/incidents/store.py +159 -0
  41. autosentry/ledger.py +159 -0
  42. autosentry/logger.py +88 -0
  43. autosentry/monitor.py +584 -0
  44. autosentry/notifiers/__init__.py +47 -0
  45. autosentry/notifiers/base.py +20 -0
  46. autosentry/notifiers/discord_outbox.py +16 -0
  47. autosentry/notifiers/file_outbox.py +62 -0
  48. autosentry/notifiers/log.py +19 -0
  49. autosentry/notifiers/slack_outbox.py +15 -0
  50. autosentry/notifiers/webhook.py +37 -0
  51. autosentry/skills.py +339 -0
  52. autosentry/state.py +136 -0
  53. autosentry/supervisors/__init__.py +42 -0
  54. autosentry/supervisors/attach.py +189 -0
  55. autosentry/supervisors/base.py +124 -0
  56. autosentry/supervisors/docker.py +241 -0
  57. autosentry/supervisors/local.py +183 -0
  58. autosentry/supervisors/slurm.py +334 -0
  59. autosentry/templates/autosentry.yaml.tmpl +168 -0
  60. autosentry/templates/program.md.tmpl +91 -0
  61. autosentry/templates/recovery.md.tmpl +48 -0
  62. autosentry/templates/skills/AGENTS.md +276 -0
  63. autosentry/templates/skills/aider.conf.yml +14 -0
  64. autosentry/templates/skills/aider.md +34 -0
  65. autosentry/templates/skills/autosentry.md +147 -0
  66. autosentry/templates/skills/claude.md +31 -0
  67. autosentry/templates/skills/claude_init.md +60 -0
  68. autosentry/templates/skills/codex.md +37 -0
  69. autosentry/templates/skills/codex_init.md +27 -0
  70. autosentry/templates/skills/continue.config.json +13 -0
  71. autosentry/templates/skills/cursor.md +32 -0
  72. autosentry/templates/skills/cursor_init.md +25 -0
  73. autosentry/templates/skills/gemini.toml +45 -0
  74. autosentry/templates/skills/gemini_init.toml +37 -0
  75. autosentry/templates/skills/init.md +70 -0
  76. autosentry/templates/skills/opencode.md +25 -0
  77. autosentry/templates/skills/opencode_init.md +24 -0
  78. autosentry/templates/skills/windsurfrules.md +46 -0
  79. autosentry/templates/skills/zed.md +27 -0
  80. autosentry/templates/skills/zed_init.md +28 -0
  81. autosentry/tui.py +258 -0
  82. autosentry/updater.py +177 -0
  83. autosentry/web/__init__.py +5 -0
  84. autosentry/web/server.py +374 -0
  85. autosentry-0.7.1.dist-info/METADATA +972 -0
  86. autosentry-0.7.1.dist-info/RECORD +90 -0
  87. autosentry-0.7.1.dist-info/WHEEL +4 -0
  88. autosentry-0.7.1.dist-info/entry_points.txt +2 -0
  89. autosentry-0.7.1.dist-info/licenses/LICENSE +201 -0
  90. autosentry-0.7.1.dist-info/licenses/NOTICE +5 -0
autosentry/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """autosentry — self-healing sentry for long-running processes."""
2
+
3
+ __version__ = "0.7.1"
autosentry/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from autosentry.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
autosentry/_tail.py ADDED
@@ -0,0 +1,84 @@
1
+ """Shared "tail -F"-style file follower.
2
+
3
+ Used by the SLURM and Attach supervisors. Both follow a single file
4
+ that may not exist yet (SLURM creates it after the job starts; an
5
+ attached process may rotate its log out from under us). The only
6
+ behavioral difference is whether they want to *replay history* on
7
+ first open:
8
+
9
+ - SLURM wants the historical contents (the job may have been running
10
+ before autosentry attached its tail thread).
11
+ - Attach wants to skip history (the watched service may have GB of
12
+ pre-existing log).
13
+
14
+ The ``seek_to_end`` flag controls that choice.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import threading
21
+ import time
22
+ from collections.abc import Callable
23
+ from pathlib import Path
24
+
25
+ from autosentry.logger import log
26
+
27
+
28
+ def follow_file(
29
+ path_provider: Callable[[], Path | None],
30
+ *,
31
+ on_line: Callable[[str], None],
32
+ stop_event: threading.Event,
33
+ ready_event: threading.Event | None = None,
34
+ seek_to_end: bool,
35
+ error_tag: str,
36
+ missing_backoff_cap: float = 5.0,
37
+ ) -> None:
38
+ """Follow a file, calling ``on_line`` for each new line.
39
+
40
+ ``path_provider`` is a callable so callers can defer path resolution
41
+ (SLURM, for instance, only knows the path once the job has a job id).
42
+ Returns when ``stop_event`` is set.
43
+ """
44
+ backoff = 0.5
45
+ last_inode: int | None = None
46
+ pos = 0
47
+ while not stop_event.is_set():
48
+ path = path_provider()
49
+ if path is None or not path.exists():
50
+ time.sleep(min(backoff, missing_backoff_cap))
51
+ backoff = min(backoff * 1.5, missing_backoff_cap)
52
+ continue
53
+ backoff = 0.5
54
+ try:
55
+ with path.open("r", encoding="utf-8", errors="replace") as f:
56
+ inode = os.fstat(f.fileno()).st_ino
57
+ if last_inode != inode:
58
+ last_inode = inode
59
+ if seek_to_end:
60
+ f.seek(0, 2) # SEEK_END
61
+ pos = f.tell()
62
+ else:
63
+ pos = 0
64
+ f.seek(pos)
65
+ if ready_event is not None:
66
+ ready_event.set()
67
+ while not stop_event.is_set():
68
+ line = f.readline()
69
+ if not line:
70
+ pos = f.tell()
71
+ try:
72
+ st = path.stat()
73
+ if st.st_ino != last_inode:
74
+ # File rotated; reopen.
75
+ break
76
+ except FileNotFoundError:
77
+ break
78
+ time.sleep(0.5)
79
+ continue
80
+ on_line(line.rstrip("\n"))
81
+ pos = f.tell()
82
+ except OSError as e:
83
+ log().error(f"{error_tag} log tail error: {e}")
84
+ time.sleep(1)
autosentry/analyze.py ADDED
@@ -0,0 +1,174 @@
1
+ """``autosentry analyze`` — flat-text summary of the attempts ledger.
2
+
3
+ Mirrors the spirit of autoresearch's ``analysis.ipynb`` without dragging
4
+ Jupyter into the dep tree. Reads ``attempts.tsv`` (and optionally
5
+ filters by ``--since``), then prints:
6
+
7
+ - Top failing detectors in the window
8
+ - Per-rule success rate (kept / total terminal attempts)
9
+ - Current regression streaks per detector
10
+ - Overall stats (totals, in-progress)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from collections import Counter, defaultdict
17
+ from collections.abc import Iterable
18
+ from dataclasses import dataclass
19
+ from datetime import datetime, timedelta, timezone
20
+
21
+ from autosentry.ledger import Attempt
22
+
23
+ # ----- time-window parsing -------------------------------------------------
24
+
25
+
26
+ _SINCE_RE = re.compile(r"^\s*(\d+)\s*([smhd])\s*$", re.IGNORECASE)
27
+
28
+
29
+ def parse_since(value: str | None) -> timedelta | None:
30
+ """Parse a window specifier like ``24h``, ``30m``, ``7d``, ``600s``.
31
+
32
+ Returns ``None`` for an empty/missing value (which means "no filter").
33
+ Raises :class:`ValueError` on malformed input.
34
+ """
35
+ if not value:
36
+ return None
37
+ m = _SINCE_RE.match(value)
38
+ if not m:
39
+ msg = f"bad --since value {value!r} (expected like '24h', '30m', '7d', '600s')"
40
+ raise ValueError(msg)
41
+ qty = int(m.group(1))
42
+ unit = m.group(2).lower()
43
+ return {
44
+ "s": timedelta(seconds=qty),
45
+ "m": timedelta(minutes=qty),
46
+ "h": timedelta(hours=qty),
47
+ "d": timedelta(days=qty),
48
+ }[unit]
49
+
50
+
51
+ def filter_window(rows: Iterable[Attempt], window: timedelta | None) -> list[Attempt]:
52
+ if window is None:
53
+ return list(rows)
54
+ cutoff = datetime.now(tz=timezone.utc) - window
55
+ out: list[Attempt] = []
56
+ for r in rows:
57
+ try:
58
+ ts = datetime.fromisoformat(r.timestamp)
59
+ except ValueError:
60
+ # Tolerate bad timestamps — include them rather than drop them.
61
+ out.append(r)
62
+ continue
63
+ if ts.tzinfo is None:
64
+ ts = ts.replace(tzinfo=timezone.utc)
65
+ if ts >= cutoff:
66
+ out.append(r)
67
+ return out
68
+
69
+
70
+ # ----- summary stats -------------------------------------------------------
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class RuleStat:
75
+ source: str
76
+ total: int
77
+ kept: int
78
+ regressed: int
79
+ crashed: int
80
+ discarded: int
81
+ pending: int
82
+
83
+ @property
84
+ def success_rate(self) -> float:
85
+ terminal = self.kept + self.regressed + self.crashed + self.discarded
86
+ if terminal == 0:
87
+ return 0.0
88
+ return self.kept / terminal
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class DetectorStat:
93
+ detector: str
94
+ total: int
95
+ streak_status: str # most recent terminal status
96
+ streak_len: int # consecutive run of same terminal status (most recent first)
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class Summary:
101
+ total: int
102
+ by_status: dict[str, int]
103
+ top_detectors: list[tuple[str, int]]
104
+ rules: list[RuleStat]
105
+ detector_streaks: list[DetectorStat]
106
+
107
+
108
+ def summarize(rows: Iterable[Attempt], *, top_n: int = 10) -> Summary:
109
+ rows = list(rows)
110
+ # Counter[AttemptStatus] → plain dict[str, int]; the str(...) cast keeps
111
+ # pyrefly happy and matches the Summary annotation.
112
+ by_status: dict[str, int] = {str(k): v for k, v in Counter(r.status for r in rows).items()}
113
+ detector_counts = Counter(r.detector for r in rows)
114
+
115
+ # Per-rule stats.
116
+ by_source: dict[str, Counter[str]] = defaultdict(Counter)
117
+ for r in rows:
118
+ by_source[r.source][r.status] += 1
119
+ rules = [
120
+ RuleStat(
121
+ source=source,
122
+ total=sum(statuses.values()),
123
+ kept=statuses.get("kept", 0),
124
+ regressed=statuses.get("regressed", 0),
125
+ crashed=statuses.get("crashed", 0),
126
+ discarded=statuses.get("discarded", 0),
127
+ pending=statuses.get("pending", 0),
128
+ )
129
+ for source, statuses in by_source.items()
130
+ ]
131
+ rules.sort(key=lambda r: r.total, reverse=True)
132
+
133
+ # Detector streaks — walk backward through rows-by-detector, count the
134
+ # longest run of identical terminal status at the tail.
135
+ by_detector: dict[str, list[Attempt]] = defaultdict(list)
136
+ for r in rows:
137
+ by_detector[r.detector].append(r)
138
+ streaks: list[DetectorStat] = []
139
+ for detector, drows in by_detector.items():
140
+ terminal = [r for r in drows if r.status != "pending"]
141
+ if not terminal:
142
+ streaks.append(
143
+ DetectorStat(
144
+ detector=detector,
145
+ total=len(drows),
146
+ streak_status="pending",
147
+ streak_len=0,
148
+ )
149
+ )
150
+ continue
151
+ tail_status = terminal[-1].status
152
+ streak_len = 0
153
+ for r in reversed(terminal):
154
+ if r.status == tail_status:
155
+ streak_len += 1
156
+ else:
157
+ break
158
+ streaks.append(
159
+ DetectorStat(
160
+ detector=detector,
161
+ total=len(drows),
162
+ streak_status=tail_status,
163
+ streak_len=streak_len,
164
+ )
165
+ )
166
+ streaks.sort(key=lambda s: s.total, reverse=True)
167
+
168
+ return Summary(
169
+ total=len(rows),
170
+ by_status=by_status,
171
+ top_detectors=detector_counts.most_common(top_n),
172
+ rules=rules,
173
+ detector_streaks=streaks,
174
+ )
@@ -0,0 +1,85 @@
1
+ """autosentry CLI.
2
+
3
+ The CLI is composed out of small per-command modules under
4
+ ``autosentry.cli.commands``. This file owns the top-level Typer app
5
+ and the eager ``--version`` callback; everything else is registered
6
+ via :func:`_register_commands` so we don't grow a god-file again.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import typer
12
+
13
+ from autosentry import __version__
14
+ from autosentry.cli.style import banner, console
15
+
16
+
17
+ def _print_version(value: bool) -> None:
18
+ if not value:
19
+ return
20
+ console.print(banner(__version__))
21
+ raise typer.Exit()
22
+
23
+
24
+ app = typer.Typer(
25
+ name="autosentry",
26
+ no_args_is_help=True,
27
+ add_completion=False,
28
+ help=(
29
+ "Self-healing sentry for long-running processes.\n\n"
30
+ "autosentry supervises a command, watches its log stream for known "
31
+ "failure modes and anomalies, applies deterministic YAML rules when "
32
+ "it can, and escalates to a Claude Code subagent when it can't. "
33
+ "Each event becomes a folder under .autosentry/incidents/ with "
34
+ "exploded stack frames, config snapshots, and the fix that was "
35
+ "applied. Start with `autosentry init`, then `autosentry doctor`, "
36
+ "then `autosentry run`."
37
+ ),
38
+ rich_markup_mode="rich",
39
+ pretty_exceptions_show_locals=False,
40
+ )
41
+
42
+
43
+ @app.callback()
44
+ def _main(
45
+ version: bool = typer.Option( # noqa: B008 — Typer idiom
46
+ False,
47
+ "--version",
48
+ "-V",
49
+ help="Print version and exit.",
50
+ is_flag=True,
51
+ callback=_print_version,
52
+ is_eager=True,
53
+ ),
54
+ ) -> None:
55
+ """autosentry — self-healing sentry for long-running processes."""
56
+
57
+
58
+ def _register_commands() -> None:
59
+ """Import each command module so it can attach itself to ``app``.
60
+
61
+ Lazy attachment via import keeps the top-level entry fast (no
62
+ network, no file IO) until the user actually runs a subcommand.
63
+ """
64
+ # Import order is for help output ordering only; otherwise no constraint.
65
+ from autosentry.cli.commands import ( # noqa: F401
66
+ analyze,
67
+ dispatcher,
68
+ doctor,
69
+ healer,
70
+ incidents,
71
+ init,
72
+ onboard,
73
+ run,
74
+ skills,
75
+ status,
76
+ update,
77
+ watch,
78
+ web,
79
+ )
80
+
81
+
82
+ _register_commands()
83
+
84
+
85
+ __all__ = ["app"]
@@ -0,0 +1 @@
1
+ """Each module here registers itself onto ``autosentry.cli.app``."""
@@ -0,0 +1,154 @@
1
+ """``autosentry analyze`` — attempts ledger summary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as _json
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich.table import Table
10
+
11
+ from autosentry.cli import app
12
+ from autosentry.cli.style import ACCENT, ERR, INFO, console
13
+ from autosentry.config import load_config
14
+
15
+
16
+ @app.command()
17
+ def analyze(
18
+ config: Path = typer.Option( # noqa: B008
19
+ Path("autosentry.yaml"),
20
+ "--config",
21
+ "-c",
22
+ help="Path to autosentry.yaml. Defaults to ./autosentry.yaml.",
23
+ ),
24
+ since: str = typer.Option(
25
+ "",
26
+ "--since",
27
+ help=(
28
+ "Filter window — e.g. 24h, 30m, 7d, 600s. Empty (the default) = "
29
+ "all-time. Useful before deciding to codify a new rule."
30
+ ),
31
+ ),
32
+ json_out: bool = typer.Option(
33
+ False,
34
+ "--json",
35
+ help="Print as JSON instead of rich tables. Pipe to jq for scripting.",
36
+ ),
37
+ ) -> None:
38
+ """Summarize the attempts ledger (`.autosentry/attempts.tsv`).
39
+
40
+ Three sections: top-failing detectors (sorted by attempt count
41
+ inside the window), per-rule success rate (kept vs regressed vs
42
+ crashed vs pending), and a regression-streak callout when a
43
+ detector has been failing repeatedly.
44
+
45
+ Use this to spot patterns worth codifying as YAML rules: when
46
+ three+ Claude-driven fixes for the same detector all `kept`,
47
+ that's a strong signal the pattern deserves a deterministic
48
+ rule.
49
+ """
50
+ from autosentry.analyze import filter_window, parse_since, summarize
51
+ from autosentry.ledger import AttemptsLedger
52
+
53
+ cfg = load_config(config)
54
+ ledger_path = cfg.resolve(".autosentry/attempts.tsv")
55
+ ledger = AttemptsLedger.load(ledger_path)
56
+ if not ledger.rows:
57
+ console.print(f"[dim]no attempts yet ({ledger_path})[/dim]")
58
+ return
59
+
60
+ try:
61
+ window = parse_since(since)
62
+ except ValueError as e:
63
+ console.print(f"[{ERR}]{e}[/{ERR}]")
64
+ raise typer.Exit(code=2) from e
65
+
66
+ rows = filter_window(ledger.rows, window)
67
+ summary = summarize(rows)
68
+
69
+ if json_out:
70
+ typer.echo(_json.dumps(_summary_to_dict(summary), indent=2))
71
+ return
72
+
73
+ title = f"attempts — {summary.total} total"
74
+ if window is not None:
75
+ title += f" (last {since})"
76
+ console.print(f"\n[bold]{title}[/bold]")
77
+ status_str = " ".join(
78
+ f"[{INFO}]{k}[/{INFO}]={v}" for k, v in sorted(summary.by_status.items())
79
+ )
80
+ console.print(status_str + "\n")
81
+
82
+ if summary.top_detectors:
83
+ t = Table(title="top failing detectors", show_header=True, header_style="bold")
84
+ t.add_column("detector")
85
+ t.add_column("attempts", justify="right")
86
+ for name, count in summary.top_detectors:
87
+ t.add_row(name, str(count))
88
+ console.print(t)
89
+
90
+ if summary.rules:
91
+ t = Table(title="per-rule success", show_header=True, header_style="bold")
92
+ t.add_column("source (rule or claude)")
93
+ t.add_column("total", justify="right")
94
+ t.add_column("kept", justify="right")
95
+ t.add_column("regressed", justify="right")
96
+ t.add_column("crashed", justify="right")
97
+ t.add_column("pending", justify="right")
98
+ t.add_column("success", justify="right")
99
+ for r in summary.rules:
100
+ t.add_row(
101
+ r.source,
102
+ str(r.total),
103
+ str(r.kept),
104
+ str(r.regressed),
105
+ str(r.crashed),
106
+ str(r.pending),
107
+ f"{r.success_rate * 100:.0f}%",
108
+ )
109
+ console.print(t)
110
+
111
+ streaks_with_regression = [
112
+ s for s in summary.detector_streaks if s.streak_status == "regressed" and s.streak_len >= 2
113
+ ]
114
+ if streaks_with_regression:
115
+ t = Table(
116
+ title=f"[{ACCENT}]regression streaks (heads up)[/{ACCENT}]",
117
+ show_header=True,
118
+ header_style="bold",
119
+ )
120
+ t.add_column("detector")
121
+ t.add_column("streak", justify="right")
122
+ for s in streaks_with_regression:
123
+ t.add_row(s.detector, f"{s.streak_len}× regressed")
124
+ console.print(t)
125
+
126
+
127
+ def _summary_to_dict(summary) -> dict: # noqa: ANN001 — Summary is internal
128
+ return {
129
+ "total": summary.total,
130
+ "by_status": summary.by_status,
131
+ "top_detectors": summary.top_detectors,
132
+ "rules": [
133
+ {
134
+ "source": r.source,
135
+ "total": r.total,
136
+ "kept": r.kept,
137
+ "regressed": r.regressed,
138
+ "crashed": r.crashed,
139
+ "discarded": r.discarded,
140
+ "pending": r.pending,
141
+ "success_rate": r.success_rate,
142
+ }
143
+ for r in summary.rules
144
+ ],
145
+ "detector_streaks": [
146
+ {
147
+ "detector": s.detector,
148
+ "total": s.total,
149
+ "streak_status": s.streak_status,
150
+ "streak_len": s.streak_len,
151
+ }
152
+ for s in summary.detector_streaks
153
+ ],
154
+ }