launchd-doctor 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Victor Navejar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: launchd-doctor
3
+ Version: 0.1.0
4
+ Summary: Find and fix the macOS LaunchAgent bugs that fail silently, plus a circuit breaker for agents that keep failing.
5
+ Author: Victor Navejar (Navii)
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/naviidaviimusic/launchd-doctor
8
+ Keywords: macos,launchd,launchagent,launchctl,tcc,automation
9
+ Classifier: Operating System :: MacOS
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: System :: Monitoring
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest; extra == "dev"
17
+ Requires-Dist: ruff; extra == "dev"
18
+ Dynamic: license-file
19
+
20
+ # launchd-doctor
21
+
22
+ **Your LaunchAgent is "loaded" but never runs. Or it runs 84 times a day. Or it
23
+ exits 78 and writes nothing at all.** `launchd-doctor` finds the macOS launchd
24
+ bugs that fail silently, explains them in plain English, and fixes them.
25
+
26
+ ```console
27
+ $ launchd-doctor
28
+ ✗ com.me.nightly-report
29
+ StandardOutPath is inside ~/Documents. launchd can't open files there, so the job
30
+ fails with exit 78 and no output at all. Move logs to ~/Library/Logs. [fixable]
31
+ ✗ com.me.backup
32
+ StartCalendarInterval has Hour and Minute in separate dicts: fires 84x/day instead of 1x. [fixable]
33
+ ✗ com.me.ollama
34
+ last run exited 1: the program exited with an error — check its log
35
+ KeepAlive job has started 159 times and isn't running — crash loop?
36
+
37
+ 137 agents scanned, 3 with problems. Run `launchd-doctor fix` to see automatic fixes.
38
+
39
+ $ launchd-doctor fix --apply # backs up each plist, rewrites it, reloads the job
40
+ ```
41
+
42
+ Single file, Python stdlib only, macOS 12+.
43
+
44
+ ## What it catches
45
+
46
+ | Problem | What you see | |
47
+ |---|---|---|
48
+ | **Log path in a privacy-protected folder** (`~/Documents`, `~/Desktop`, `~/Downloads`, iCloud Drive…) | exit **78** (EX_CONFIG), **no output at all**. Jobs loaded before the change keep working until the next reboot, then break | auto-fix → `~/Library/Logs` |
49
+ | **Split `StartCalendarInterval`**: `[{Minute: 55}, {Hour: 5}]` instead of `[{Hour: 5, Minute: 55}]` | runs every hour at :55 **and** every minute from 5:00–5:59: 84 runs/day | auto-fix (merge) |
50
+ | **Apple's Python on a protected folder** (`/usr/bin/python3`, CommandLineTools) | `Operation not permitted`, works in Terminal but never from launchd | explains |
51
+ | Program / script missing or not executable | exit **127** / **126** | explains |
52
+ | `KeepAlive` crash loop | hundreds of restarts, job never stays up | explains |
53
+ | Non-zero last exit | decoded exit code | explains |
54
+ | Huge log files (>50 MB) | disk fills slowly | explains |
55
+ | Empty or unparseable plists | job silently ignored | explains |
56
+
57
+ Read the full story of the first bug: **[Why your LaunchAgent exits 78 with no output](docs/tcc-log-paths.md)**.
58
+
59
+ ## Circuit breaker for your own agents
60
+
61
+ A job that fails on every run wastes CPU and buries the real error in its log.
62
+ `watch` pauses it and retries later:
63
+
64
+ - **3 failed runs in a row** (exit ≠ 0, not killed by a signal) → `launchctl bootout` + macOS notification
65
+ - **Half-open retry** after 24 h, doubling every time it trips again (max 7 days); one good run closes the breaker
66
+ - exit **126/127** → notifies right away, since every run will fail the same way
67
+
68
+ ```bash
69
+ launchd-doctor install-watch --prefix com.myname. # runs `watch` every 30 min
70
+ launchd-doctor breakers # what's paused, and when it retries
71
+ launchd-doctor resume com.myname.job # un-pause now
72
+ ```
73
+
74
+ `watch` only touches agents matching `--prefix`, never Apple's or other vendors' agents.
75
+
76
+ ## Install
77
+
78
+ ```bash
79
+ pip install git+https://github.com/naviidaviimusic/launchd-doctor
80
+ # or just grab the one file:
81
+ curl -O https://raw.githubusercontent.com/naviidaviimusic/launchd-doctor/main/launchd_doctor.py
82
+ python3 launchd_doctor.py
83
+ ```
84
+
85
+ ## Commands
86
+
87
+ ```
88
+ launchd-doctor [scan] [--prefix P] [-v] find problems (exit 1 if any)
89
+ launchd-doctor fix [--apply] [--prefix P] fix what can be fixed (dry run by default)
90
+ launchd-doctor watch --prefix P [--trip N] [--dry-run]
91
+ launchd-doctor install-watch --prefix P
92
+ launchd-doctor breakers
93
+ launchd-doctor resume LABEL
94
+ ```
95
+
96
+ ## Why this exists
97
+
98
+ I run 100+ LaunchAgents for a local AI system on one Mac. Each of these bugs took
99
+ it down at some point. The split-calendar bug sent 97 agents firing 83× a day and
100
+ produced 49,000 duplicate events. The privacy-folder log path broke every agent
101
+ that got reloaded, and none of them logged anything. This tool is what I wish I'd had.
102
+
103
+ ## License
104
+
105
+ MIT © Victor Navejar ([Navii](https://github.com/naviidaviimusic))
@@ -0,0 +1,86 @@
1
+ # launchd-doctor
2
+
3
+ **Your LaunchAgent is "loaded" but never runs. Or it runs 84 times a day. Or it
4
+ exits 78 and writes nothing at all.** `launchd-doctor` finds the macOS launchd
5
+ bugs that fail silently, explains them in plain English, and fixes them.
6
+
7
+ ```console
8
+ $ launchd-doctor
9
+ ✗ com.me.nightly-report
10
+ StandardOutPath is inside ~/Documents. launchd can't open files there, so the job
11
+ fails with exit 78 and no output at all. Move logs to ~/Library/Logs. [fixable]
12
+ ✗ com.me.backup
13
+ StartCalendarInterval has Hour and Minute in separate dicts: fires 84x/day instead of 1x. [fixable]
14
+ ✗ com.me.ollama
15
+ last run exited 1: the program exited with an error — check its log
16
+ KeepAlive job has started 159 times and isn't running — crash loop?
17
+
18
+ 137 agents scanned, 3 with problems. Run `launchd-doctor fix` to see automatic fixes.
19
+
20
+ $ launchd-doctor fix --apply # backs up each plist, rewrites it, reloads the job
21
+ ```
22
+
23
+ Single file, Python stdlib only, macOS 12+.
24
+
25
+ ## What it catches
26
+
27
+ | Problem | What you see | |
28
+ |---|---|---|
29
+ | **Log path in a privacy-protected folder** (`~/Documents`, `~/Desktop`, `~/Downloads`, iCloud Drive…) | exit **78** (EX_CONFIG), **no output at all**. Jobs loaded before the change keep working until the next reboot, then break | auto-fix → `~/Library/Logs` |
30
+ | **Split `StartCalendarInterval`**: `[{Minute: 55}, {Hour: 5}]` instead of `[{Hour: 5, Minute: 55}]` | runs every hour at :55 **and** every minute from 5:00–5:59: 84 runs/day | auto-fix (merge) |
31
+ | **Apple's Python on a protected folder** (`/usr/bin/python3`, CommandLineTools) | `Operation not permitted`, works in Terminal but never from launchd | explains |
32
+ | Program / script missing or not executable | exit **127** / **126** | explains |
33
+ | `KeepAlive` crash loop | hundreds of restarts, job never stays up | explains |
34
+ | Non-zero last exit | decoded exit code | explains |
35
+ | Huge log files (>50 MB) | disk fills slowly | explains |
36
+ | Empty or unparseable plists | job silently ignored | explains |
37
+
38
+ Read the full story of the first bug: **[Why your LaunchAgent exits 78 with no output](docs/tcc-log-paths.md)**.
39
+
40
+ ## Circuit breaker for your own agents
41
+
42
+ A job that fails on every run wastes CPU and buries the real error in its log.
43
+ `watch` pauses it and retries later:
44
+
45
+ - **3 failed runs in a row** (exit ≠ 0, not killed by a signal) → `launchctl bootout` + macOS notification
46
+ - **Half-open retry** after 24 h, doubling every time it trips again (max 7 days); one good run closes the breaker
47
+ - exit **126/127** → notifies right away, since every run will fail the same way
48
+
49
+ ```bash
50
+ launchd-doctor install-watch --prefix com.myname. # runs `watch` every 30 min
51
+ launchd-doctor breakers # what's paused, and when it retries
52
+ launchd-doctor resume com.myname.job # un-pause now
53
+ ```
54
+
55
+ `watch` only touches agents matching `--prefix`, never Apple's or other vendors' agents.
56
+
57
+ ## Install
58
+
59
+ ```bash
60
+ pip install git+https://github.com/naviidaviimusic/launchd-doctor
61
+ # or just grab the one file:
62
+ curl -O https://raw.githubusercontent.com/naviidaviimusic/launchd-doctor/main/launchd_doctor.py
63
+ python3 launchd_doctor.py
64
+ ```
65
+
66
+ ## Commands
67
+
68
+ ```
69
+ launchd-doctor [scan] [--prefix P] [-v] find problems (exit 1 if any)
70
+ launchd-doctor fix [--apply] [--prefix P] fix what can be fixed (dry run by default)
71
+ launchd-doctor watch --prefix P [--trip N] [--dry-run]
72
+ launchd-doctor install-watch --prefix P
73
+ launchd-doctor breakers
74
+ launchd-doctor resume LABEL
75
+ ```
76
+
77
+ ## Why this exists
78
+
79
+ I run 100+ LaunchAgents for a local AI system on one Mac. Each of these bugs took
80
+ it down at some point. The split-calendar bug sent 97 agents firing 83× a day and
81
+ produced 49,000 duplicate events. The privacy-folder log path broke every agent
82
+ that got reloaded, and none of them logged anything. This tool is what I wish I'd had.
83
+
84
+ ## License
85
+
86
+ MIT © Victor Navejar ([Navii](https://github.com/naviidaviimusic))
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: launchd-doctor
3
+ Version: 0.1.0
4
+ Summary: Find and fix the macOS LaunchAgent bugs that fail silently, plus a circuit breaker for agents that keep failing.
5
+ Author: Victor Navejar (Navii)
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/naviidaviimusic/launchd-doctor
8
+ Keywords: macos,launchd,launchagent,launchctl,tcc,automation
9
+ Classifier: Operating System :: MacOS
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: System :: Monitoring
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest; extra == "dev"
17
+ Requires-Dist: ruff; extra == "dev"
18
+ Dynamic: license-file
19
+
20
+ # launchd-doctor
21
+
22
+ **Your LaunchAgent is "loaded" but never runs. Or it runs 84 times a day. Or it
23
+ exits 78 and writes nothing at all.** `launchd-doctor` finds the macOS launchd
24
+ bugs that fail silently, explains them in plain English, and fixes them.
25
+
26
+ ```console
27
+ $ launchd-doctor
28
+ ✗ com.me.nightly-report
29
+ StandardOutPath is inside ~/Documents. launchd can't open files there, so the job
30
+ fails with exit 78 and no output at all. Move logs to ~/Library/Logs. [fixable]
31
+ ✗ com.me.backup
32
+ StartCalendarInterval has Hour and Minute in separate dicts: fires 84x/day instead of 1x. [fixable]
33
+ ✗ com.me.ollama
34
+ last run exited 1: the program exited with an error — check its log
35
+ KeepAlive job has started 159 times and isn't running — crash loop?
36
+
37
+ 137 agents scanned, 3 with problems. Run `launchd-doctor fix` to see automatic fixes.
38
+
39
+ $ launchd-doctor fix --apply # backs up each plist, rewrites it, reloads the job
40
+ ```
41
+
42
+ Single file, Python stdlib only, macOS 12+.
43
+
44
+ ## What it catches
45
+
46
+ | Problem | What you see | |
47
+ |---|---|---|
48
+ | **Log path in a privacy-protected folder** (`~/Documents`, `~/Desktop`, `~/Downloads`, iCloud Drive…) | exit **78** (EX_CONFIG), **no output at all**. Jobs loaded before the change keep working until the next reboot, then break | auto-fix → `~/Library/Logs` |
49
+ | **Split `StartCalendarInterval`**: `[{Minute: 55}, {Hour: 5}]` instead of `[{Hour: 5, Minute: 55}]` | runs every hour at :55 **and** every minute from 5:00–5:59: 84 runs/day | auto-fix (merge) |
50
+ | **Apple's Python on a protected folder** (`/usr/bin/python3`, CommandLineTools) | `Operation not permitted`, works in Terminal but never from launchd | explains |
51
+ | Program / script missing or not executable | exit **127** / **126** | explains |
52
+ | `KeepAlive` crash loop | hundreds of restarts, job never stays up | explains |
53
+ | Non-zero last exit | decoded exit code | explains |
54
+ | Huge log files (>50 MB) | disk fills slowly | explains |
55
+ | Empty or unparseable plists | job silently ignored | explains |
56
+
57
+ Read the full story of the first bug: **[Why your LaunchAgent exits 78 with no output](docs/tcc-log-paths.md)**.
58
+
59
+ ## Circuit breaker for your own agents
60
+
61
+ A job that fails on every run wastes CPU and buries the real error in its log.
62
+ `watch` pauses it and retries later:
63
+
64
+ - **3 failed runs in a row** (exit ≠ 0, not killed by a signal) → `launchctl bootout` + macOS notification
65
+ - **Half-open retry** after 24 h, doubling every time it trips again (max 7 days); one good run closes the breaker
66
+ - exit **126/127** → notifies right away, since every run will fail the same way
67
+
68
+ ```bash
69
+ launchd-doctor install-watch --prefix com.myname. # runs `watch` every 30 min
70
+ launchd-doctor breakers # what's paused, and when it retries
71
+ launchd-doctor resume com.myname.job # un-pause now
72
+ ```
73
+
74
+ `watch` only touches agents matching `--prefix`, never Apple's or other vendors' agents.
75
+
76
+ ## Install
77
+
78
+ ```bash
79
+ pip install git+https://github.com/naviidaviimusic/launchd-doctor
80
+ # or just grab the one file:
81
+ curl -O https://raw.githubusercontent.com/naviidaviimusic/launchd-doctor/main/launchd_doctor.py
82
+ python3 launchd_doctor.py
83
+ ```
84
+
85
+ ## Commands
86
+
87
+ ```
88
+ launchd-doctor [scan] [--prefix P] [-v] find problems (exit 1 if any)
89
+ launchd-doctor fix [--apply] [--prefix P] fix what can be fixed (dry run by default)
90
+ launchd-doctor watch --prefix P [--trip N] [--dry-run]
91
+ launchd-doctor install-watch --prefix P
92
+ launchd-doctor breakers
93
+ launchd-doctor resume LABEL
94
+ ```
95
+
96
+ ## Why this exists
97
+
98
+ I run 100+ LaunchAgents for a local AI system on one Mac. Each of these bugs took
99
+ it down at some point. The split-calendar bug sent 97 agents firing 83× a day and
100
+ produced 49,000 duplicate events. The privacy-folder log path broke every agent
101
+ that got reloaded, and none of them logged anything. This tool is what I wish I'd had.
102
+
103
+ ## License
104
+
105
+ MIT © Victor Navejar ([Navii](https://github.com/naviidaviimusic))
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ launchd_doctor.py
4
+ pyproject.toml
5
+ launchd_doctor.egg-info/PKG-INFO
6
+ launchd_doctor.egg-info/SOURCES.txt
7
+ launchd_doctor.egg-info/dependency_links.txt
8
+ launchd_doctor.egg-info/entry_points.txt
9
+ launchd_doctor.egg-info/requires.txt
10
+ launchd_doctor.egg-info/top_level.txt
11
+ tests/test_doctor.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ launchd-doctor = launchd_doctor:main
@@ -0,0 +1,4 @@
1
+
2
+ [dev]
3
+ pytest
4
+ ruff
@@ -0,0 +1 @@
1
+ launchd_doctor
@@ -0,0 +1,485 @@
1
+ """launchd-doctor — find and fix the macOS LaunchAgent bugs that fail silently.
2
+
3
+ launchd-doctor scan ~/Library/LaunchAgents, explain every problem
4
+ launchd-doctor fix show the fixes it would make (dry run)
5
+ launchd-doctor fix --apply make them (each plist backed up first)
6
+ launchd-doctor watch circuit breaker: pause agents that keep failing
7
+ launchd-doctor install-watch run `watch` every 30 min as its own LaunchAgent
8
+ launchd-doctor breakers show paused agents
9
+ launchd-doctor resume LABEL un-pause an agent now
10
+ """
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ import plistlib
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ import time
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ __version__ = "0.1.0"
25
+
26
+ HOME = Path.home()
27
+ AGENTS_DIR = HOME / "Library" / "LaunchAgents"
28
+ LOG_DIR = HOME / "Library" / "Logs" / "launchd-doctor"
29
+ STATE_FILE = HOME / ".local" / "state" / "launchd-doctor" / "state.json"
30
+ UID = os.getuid()
31
+
32
+ # Folders macOS privacy protection (TCC) guards. launchd itself opens a job's
33
+ # StandardOutPath / StandardErrorPath before the program starts, and launchd has
34
+ # no permission there: the job dies with EX_CONFIG (78) and writes nothing at all.
35
+ PROTECTED = ["Documents", "Desktop", "Downloads", "Library/Mobile Documents", "Pictures", "Movies", "Music"]
36
+ # Interpreters macOS blocks from reading protected folders unless granted Full Disk Access.
37
+ TCC_BLOCKED_INTERPRETERS = ["/Library/Developer/CommandLineTools/usr/bin/python3", "/usr/bin/python3"]
38
+
39
+ EXIT_CODES = {
40
+ "1": "the program exited with an error — check its log",
41
+ "2": "usage error — wrong arguments in ProgramArguments",
42
+ "78": "EX_CONFIG — launchd couldn't set the job up (often a log path it may not open, see above)",
43
+ "126": "found but not executable (permissions, or macOS privacy blocked it)",
44
+ "127": "command not found — the path in ProgramArguments doesn't exist",
45
+ }
46
+ SIGNAL_EXITS = {"-15", "-9", "-2"} # killed by a user or by us: not a failure
47
+
48
+ BREAKER_TRIP = 3 # consecutive failed runs before pausing
49
+ BREAKER_COOL_BASE = 24 * 3600 # first retry after 24 h, doubling per trip …
50
+ BREAKER_COOL_MAX = 7 * 24 * 3600 # … up to a week
51
+
52
+
53
+ @dataclass
54
+ class Finding:
55
+ label: str
56
+ severity: str # error | warn
57
+ code: str
58
+ message: str
59
+ fixable: bool = False
60
+
61
+
62
+ # ── analysis (pure: plist dict in, findings out) ──────────────────────────────
63
+
64
+ def _protected(path: str, home: Path = None):
65
+ home = home or HOME
66
+ p = Path(os.path.expanduser(path))
67
+ for d in PROTECTED:
68
+ root = home / d
69
+ if p == root or root in p.parents:
70
+ return d
71
+ return None
72
+
73
+
74
+ def split_calendar(sci):
75
+ """Return the merged schedule if StartCalendarInterval is the split-dict bug,
76
+ else None. Bug: [{Minute: 55}, {Hour: 5}] means "every hour at :55" PLUS
77
+ "every minute from 5:00 to 5:59" — 84 runs a day instead of one."""
78
+ if not isinstance(sci, list):
79
+ return None
80
+ singles = [d for d in sci if isinstance(d, dict) and len(d) == 1]
81
+ if not any("Hour" in d for d in singles) or not any("Minute" in d for d in singles):
82
+ return None
83
+ merged, pending = [], None
84
+ for d in sci:
85
+ if isinstance(d, dict) and len(d) == 1 and set(d) <= {"Hour", "Minute"}:
86
+ if pending is None:
87
+ pending = dict(d)
88
+ continue
89
+ if set(pending) != set(d):
90
+ pending.update(d)
91
+ merged.append(pending)
92
+ pending = None
93
+ continue
94
+ return None # two Hours (or Minutes) in a row: can't pair safely
95
+ if pending is not None:
96
+ return None
97
+ merged.append(d)
98
+ return merged if pending is None else None
99
+
100
+
101
+ def runs_per_day(sci) -> int:
102
+ """How many times a day a StartCalendarInterval fires (ignores Weekday/Day/Month)."""
103
+ if isinstance(sci, dict):
104
+ sci = [sci]
105
+ total = 0
106
+ for d in sci or []:
107
+ if not isinstance(d, dict):
108
+ continue
109
+ total += (1 if "Hour" in d else 24) * (1 if "Minute" in d else 60)
110
+ return total
111
+
112
+
113
+ def program_args(d: dict) -> list:
114
+ args = list(d.get("ProgramArguments") or [])
115
+ if d.get("Program"):
116
+ args = [d["Program"]] + args[1:]
117
+ return args
118
+
119
+
120
+ def analyze(label: str, d: dict, home: Path = None) -> list:
121
+ if not d:
122
+ return [Finding(label, "warn", "empty-plist", "plist is empty — launchd ignores it")]
123
+ out = []
124
+ for key in ("StandardOutPath", "StandardErrorPath"):
125
+ v = d.get(key)
126
+ where = v and _protected(v, home)
127
+ if where:
128
+ out.append(Finding(label, "error", "tcc-log-path",
129
+ f"{key} is inside ~/{where}. launchd can't open files there, so the "
130
+ f"job fails with exit 78 and no output at all. Move logs to ~/Library/Logs.",
131
+ fixable=True))
132
+
133
+ sci = d.get("StartCalendarInterval")
134
+ merged = split_calendar(sci)
135
+ if merged is not None:
136
+ out.append(Finding(label, "error", "split-calendar",
137
+ f"StartCalendarInterval has Hour and Minute in separate dicts: fires "
138
+ f"{runs_per_day(sci)}x/day instead of {runs_per_day(merged)}x.",
139
+ fixable=True))
140
+ elif sci and runs_per_day(sci) >= 24 * 60 / 2:
141
+ out.append(Finding(label, "warn", "frequent-calendar",
142
+ f"StartCalendarInterval fires {runs_per_day(sci)}x/day — is a dict missing 'Minute'?"))
143
+
144
+ args = program_args(d)
145
+ if not args:
146
+ out.append(Finding(label, "error", "no-program", "no Program or ProgramArguments"))
147
+ else:
148
+ exe = os.path.expanduser(args[0])
149
+ if not os.path.isabs(exe):
150
+ out.append(Finding(label, "warn", "relative-program",
151
+ f"'{args[0]}' isn't an absolute path; launchd's PATH is minimal "
152
+ f"(/usr/bin:/bin:/usr/sbin:/sbin)."))
153
+ elif not os.path.exists(exe):
154
+ out.append(Finding(label, "error", "missing-program", f"{exe} does not exist (exit 127)"))
155
+ elif not os.access(exe, os.X_OK):
156
+ out.append(Finding(label, "error", "not-executable", f"{exe} is not executable (exit 126)"))
157
+ touched = args[1:] + [d.get("WorkingDirectory") or ""]
158
+ if exe in TCC_BLOCKED_INTERPRETERS and any(a and _protected(a, home) for a in touched):
159
+ out.append(Finding(label, "error", "tcc-interpreter",
160
+ f"{exe} runs a script in a protected folder; macOS blocks it without "
161
+ f"Full Disk Access. Use a Homebrew/python.org interpreter or move the script."))
162
+ for a in args[1:]:
163
+ if a.startswith(("/", "~")) and a.endswith((".py", ".sh", ".rb", ".js")) \
164
+ and not os.path.exists(os.path.expanduser(a)):
165
+ out.append(Finding(label, "error", "missing-script", f"{a} does not exist"))
166
+ return out
167
+
168
+
169
+ # ── system ────────────────────────────────────────────────────────────────────
170
+
171
+ def sh(cmd, timeout=30):
172
+ try:
173
+ p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
174
+ return p.returncode, p.stdout + p.stderr
175
+ except (OSError, subprocess.TimeoutExpired) as e:
176
+ return 1, str(e)
177
+
178
+
179
+ def job_info(label):
180
+ """{'runs': int, 'exit': str|None, 'pid': int|None} from launchctl, or None if not loaded."""
181
+ rc, out = sh(["launchctl", "print", f"gui/{UID}/{label}"])
182
+ if rc != 0:
183
+ return None
184
+ runs = re.search(r"^\s+runs = (\d+)", out, re.M)
185
+ code = re.search(r"^\s+last exit code = (-?\d+)", out, re.M)
186
+ pid = re.search(r"^\s+pid = (\d+)", out, re.M)
187
+ return {"runs": int(runs.group(1)) if runs else 0,
188
+ "exit": code.group(1) if code else None,
189
+ "pid": int(pid.group(1)) if pid else None}
190
+
191
+
192
+ def plists(prefix=None):
193
+ for p in sorted(AGENTS_DIR.glob("*.plist")):
194
+ if prefix and not p.stem.startswith(tuple(prefix)):
195
+ continue
196
+ yield p
197
+
198
+
199
+ def load(p: Path):
200
+ try:
201
+ with open(p, "rb") as f:
202
+ return plistlib.load(f)
203
+ except Exception as e: # malformed XML/binary plist
204
+ return e
205
+
206
+
207
+ def runtime_findings(label, d, info):
208
+ out = []
209
+ if info is None:
210
+ return out
211
+ code = info["exit"]
212
+ if code and code != "0" and code not in SIGNAL_EXITS:
213
+ why = EXIT_CODES.get(code, "signal" if code.startswith("-") else "see the job's log")
214
+ out.append(Finding(label, "error", f"exit-{code}", f"last run exited {code}: {why}"))
215
+ if d.get("KeepAlive") and info["runs"] > 50 and not info["pid"]:
216
+ out.append(Finding(label, "warn", "crash-loop",
217
+ f"KeepAlive job has started {info['runs']} times and isn't running — crash loop?"))
218
+ for key in ("StandardOutPath", "StandardErrorPath"):
219
+ v = d.get(key)
220
+ try:
221
+ size = os.path.getsize(os.path.expanduser(v)) if v else 0
222
+ except OSError:
223
+ size = 0
224
+ if size > 50 * 1024 * 1024:
225
+ out.append(Finding(label, "warn", "big-log", f"{v} is {size // 2**20} MB"))
226
+ return out
227
+
228
+
229
+ def notify(title, text):
230
+ script = f'display notification {json.dumps(text)} with title {json.dumps(title)}'
231
+ sh(["osascript", "-e", script], timeout=10)
232
+
233
+
234
+ # ── commands ──────────────────────────────────────────────────────────────────
235
+
236
+ C = {"error": "\033[31m", "warn": "\033[33m", "ok": "\033[32m", "dim": "\033[2m", "": "\033[0m"}
237
+ if not sys.stdout.isatty() or os.environ.get("NO_COLOR"):
238
+ C = dict.fromkeys(C, "")
239
+
240
+
241
+ def cmd_scan(a):
242
+ total, bad = 0, 0
243
+ for p in plists(a.prefix):
244
+ total += 1
245
+ d = load(p)
246
+ if isinstance(d, Exception):
247
+ print(f"{C['error']}✗ {p.stem}{C['']}\n plist won't parse: {d}")
248
+ bad += 1
249
+ continue
250
+ label = d.get("Label", p.stem)
251
+ found = analyze(label, d) + runtime_findings(label, d, job_info(label))
252
+ if found:
253
+ bad += 1
254
+ worst = "error" if any(f.severity == "error" for f in found) else "warn"
255
+ print(f"{C[worst]}{'✗' if worst == 'error' else '!'} {label}{C['']}")
256
+ for f in found:
257
+ tag = f" {C['dim']}[fixable]{C['']}" if f.fixable else ""
258
+ print(f" {f.message}{tag}")
259
+ elif a.verbose:
260
+ print(f"{C['ok']}✓ {label}{C['']}")
261
+ print(f"\n{total} agents scanned, {bad} with problems."
262
+ + (" Run `launchd-doctor fix` to see automatic fixes." if bad else ""))
263
+ return 1 if bad else 0
264
+
265
+
266
+ def fixed_plist(label, d):
267
+ """(new_dict, [descriptions]) for the fixable findings."""
268
+ new, notes = dict(d), []
269
+ for key in ("StandardOutPath", "StandardErrorPath"):
270
+ v = d.get(key)
271
+ if v and _protected(v):
272
+ dest = HOME / "Library" / "Logs" / f"{label}.log"
273
+ if key == "StandardErrorPath" and d.get("StandardOutPath") != v:
274
+ dest = HOME / "Library" / "Logs" / f"{label}.err.log"
275
+ new[key] = str(dest)
276
+ notes.append(f"{key}: {v} → {dest}")
277
+ merged = split_calendar(d.get("StartCalendarInterval"))
278
+ if merged is not None:
279
+ new["StartCalendarInterval"] = merged
280
+ notes.append(f"StartCalendarInterval: {runs_per_day(d['StartCalendarInterval'])}x/day → "
281
+ f"{runs_per_day(merged)}x/day {merged}")
282
+ return new, notes
283
+
284
+
285
+ def cmd_fix(a):
286
+ changes = 0
287
+ for p in plists(a.prefix):
288
+ d = load(p)
289
+ if isinstance(d, Exception):
290
+ continue
291
+ label = d.get("Label", p.stem)
292
+ new, notes = fixed_plist(label, d)
293
+ if not notes:
294
+ continue
295
+ changes += 1
296
+ print(f"{label}")
297
+ for n in notes:
298
+ print(f" {n}")
299
+ if not a.apply:
300
+ continue
301
+ backup = p.with_suffix(f".plist.bak-{time.strftime('%Y%m%d%H%M%S')}")
302
+ shutil.copy2(p, backup)
303
+ (HOME / "Library" / "Logs").mkdir(parents=True, exist_ok=True)
304
+ with open(p, "wb") as f:
305
+ plistlib.dump(new, f)
306
+ if job_info(label) is not None: # reload so launchd uses the fixed definition
307
+ sh(["launchctl", "bootout", f"gui/{UID}/{label}"])
308
+ rc, out = sh(["launchctl", "bootstrap", f"gui/{UID}", str(p)])
309
+ print(f" {'reloaded' if rc == 0 else 'RELOAD FAILED: ' + out.strip()}")
310
+ print(f" {C['dim']}backup: {backup}{C['']}")
311
+ if not changes:
312
+ print("Nothing to fix automatically.")
313
+ elif not a.apply:
314
+ print("\nDry run. Re-run with --apply to make these changes (each plist is backed up first).")
315
+ return 0
316
+
317
+
318
+ def _state():
319
+ try:
320
+ return json.loads(STATE_FILE.read_text())
321
+ except (OSError, ValueError):
322
+ return {}
323
+
324
+
325
+ def _save(st):
326
+ STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
327
+ tmp = STATE_FILE.with_suffix(".tmp")
328
+ tmp.write_text(json.dumps(st, indent=1))
329
+ tmp.replace(STATE_FILE)
330
+
331
+
332
+ def cooldown(trips: int) -> float:
333
+ return min(BREAKER_COOL_BASE * 2 ** max(0, trips - 1), BREAKER_COOL_MAX)
334
+
335
+
336
+ def breaker_step(b: dict, info, now: float, trip_at: int = BREAKER_TRIP):
337
+ """Advance one agent's breaker. Returns an action: None | 'trip' | 'trial' | 'closed' | 'broken'."""
338
+ if b["state"] == "open":
339
+ if now - b["opened_at"] >= cooldown(b["trips"]):
340
+ b.update(state="half-open", streak=trip_at - 1, runs=None)
341
+ return "trial"
342
+ return None
343
+ if info is None:
344
+ return None
345
+ runs, code = info["runs"], info["exit"]
346
+ if b.get("runs") is None or runs < b["runs"]: # first sight, or reset by a reload
347
+ b["runs"] = runs
348
+ return None
349
+ if runs == b["runs"] or code is None:
350
+ return None # no new run since last check
351
+ b["runs"] = runs
352
+ if code == "0":
353
+ was = b["state"]
354
+ b.update(streak=0, state="closed")
355
+ if was == "half-open":
356
+ b["trips"] = 0
357
+ return "closed"
358
+ return None
359
+ if code in SIGNAL_EXITS:
360
+ return None
361
+ b["streak"] = b.get("streak", 0) + 1
362
+ b["last_exit"] = code
363
+ if b["streak"] >= trip_at:
364
+ b.update(state="open", opened_at=now, trips=b.get("trips", 0) + 1)
365
+ return "trip"
366
+ if code in ("126", "127"):
367
+ return "broken" # every run will fail the same way: say so now
368
+ return None
369
+
370
+
371
+ def cmd_watch(a):
372
+ if not a.prefix:
373
+ sys.exit("watch needs --prefix (e.g. --prefix com.myname.) so it only manages your own agents.")
374
+ st = _state()
375
+ br = st.setdefault("breaker", {})
376
+ now = time.time()
377
+ for p in plists(a.prefix):
378
+ label = p.stem
379
+ b = br.setdefault(label, {"state": "closed", "streak": 0, "runs": None, "trips": 0})
380
+ info = None if b["state"] == "open" else job_info(label)
381
+ act = breaker_step(b, info, now, a.trip)
382
+ name = label.rsplit(".", 1)[-1]
383
+ if act == "trip":
384
+ msg = f"{name} failed {b['streak']} runs in a row (exit {b['last_exit']}) — paused, " \
385
+ f"retry in {cooldown(b['trips']) / 3600:.0f}h"
386
+ print(msg)
387
+ if not a.dry_run:
388
+ sh(["launchctl", "bootout", f"gui/{UID}/{label}"])
389
+ notify("launchd-doctor: agent paused", msg)
390
+ elif act == "trial":
391
+ print(f"{name}: cooled down — re-enabled for a trial run")
392
+ if not a.dry_run:
393
+ sh(["launchctl", "bootstrap", f"gui/{UID}", str(p)])
394
+ elif act == "closed":
395
+ print(f"{name}: trial run succeeded — breaker closed")
396
+ elif act == "broken":
397
+ msg = f"{name} exit {b['last_exit']}: {EXIT_CODES[b['last_exit']]}"
398
+ print(msg)
399
+ notify("launchd-doctor: launch config broken", msg)
400
+ if not a.dry_run:
401
+ _save(st)
402
+ return 0
403
+
404
+
405
+ def cmd_breakers(a):
406
+ br = _state().get("breaker", {})
407
+ rows = [(k, v) for k, v in br.items() if v["state"] != "closed" or v.get("streak")]
408
+ if not rows:
409
+ print("All breakers closed.")
410
+ for k, v in rows:
411
+ extra = ""
412
+ if v["state"] == "open":
413
+ left = cooldown(v["trips"]) - (time.time() - v["opened_at"])
414
+ extra = f", trial in {max(0, left) / 3600:.1f}h"
415
+ print(f"{k}: {v['state']} (streak {v.get('streak', 0)}, last exit {v.get('last_exit')}, "
416
+ f"trips {v.get('trips', 0)}{extra})")
417
+ return 0
418
+
419
+
420
+ def cmd_resume(a):
421
+ st = _state()
422
+ b = st.get("breaker", {}).get(a.label)
423
+ p = AGENTS_DIR / f"{a.label}.plist"
424
+ if not p.exists():
425
+ sys.exit(f"no such agent: {p}")
426
+ rc, out = sh(["launchctl", "bootstrap", f"gui/{UID}", str(p)])
427
+ if b:
428
+ b.update(state="closed", streak=0, trips=0, runs=None)
429
+ _save(st)
430
+ print("resumed" if rc == 0 else f"bootstrap: {out.strip()} (may already be loaded)")
431
+ return 0
432
+
433
+
434
+ def cmd_install_watch(a):
435
+ if not a.prefix:
436
+ sys.exit("install-watch needs --prefix (e.g. --prefix com.myname.)")
437
+ label = "io.github.launchd-doctor.watch"
438
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
439
+ args = [sys.executable, os.path.abspath(__file__), "watch"]
440
+ for pre in a.prefix:
441
+ args += ["--prefix", pre]
442
+ d = {"Label": label, "ProgramArguments": args, "StartInterval": 1800, "RunAtLoad": True,
443
+ "ProcessType": "Background", "LowPriorityIO": True,
444
+ "StandardOutPath": str(LOG_DIR / "watch.log"), "StandardErrorPath": str(LOG_DIR / "watch.log")}
445
+ p = AGENTS_DIR / f"{label}.plist"
446
+ with open(p, "wb") as f:
447
+ plistlib.dump(d, f)
448
+ sh(["launchctl", "bootout", f"gui/{UID}/{label}"])
449
+ rc, out = sh(["launchctl", "bootstrap", f"gui/{UID}", str(p)])
450
+ print(f"installed {p} (every 30 min)" if rc == 0 else f"bootstrap failed: {out}")
451
+ return rc
452
+
453
+
454
+ def main(argv=None):
455
+ ap = argparse.ArgumentParser(prog="launchd-doctor", description=__doc__,
456
+ formatter_class=argparse.RawDescriptionHelpFormatter)
457
+ ap.add_argument("--version", action="version", version=__version__)
458
+ sub = ap.add_subparsers(dest="cmd")
459
+
460
+ def common(p):
461
+ p.add_argument("--prefix", action="append", help="only agents whose label starts with this")
462
+ return p
463
+ s = common(sub.add_parser("scan", help="find problems (default)"))
464
+ s.add_argument("-v", "--verbose", action="store_true", help="also list healthy agents")
465
+ common(sub.add_parser("fix", help="fix what can be fixed")).add_argument(
466
+ "--apply", action="store_true", help="actually write changes")
467
+ w = common(sub.add_parser("watch", help="circuit breaker pass"))
468
+ w.add_argument("--trip", type=int, default=BREAKER_TRIP, help="failures in a row before pausing")
469
+ w.add_argument("--dry-run", action="store_true")
470
+ common(sub.add_parser("install-watch", help="run watch every 30 min"))
471
+ sub.add_parser("breakers", help="show breaker state")
472
+ sub.add_parser("resume", help="un-pause an agent").add_argument("label")
473
+
474
+ argv = list(sys.argv[1:] if argv is None else argv)
475
+ if not argv or (argv[0] not in sub.choices and argv[0] not in ("-h", "--help", "--version")):
476
+ argv.insert(0, "scan")
477
+ a = ap.parse_args(argv)
478
+ if sys.platform != "darwin":
479
+ sys.exit("launchd-doctor only runs on macOS.")
480
+ return {"scan": cmd_scan, "fix": cmd_fix, "watch": cmd_watch, "install-watch": cmd_install_watch,
481
+ "breakers": cmd_breakers, "resume": cmd_resume}[a.cmd](a)
482
+
483
+
484
+ if __name__ == "__main__":
485
+ sys.exit(main())
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "launchd-doctor"
7
+ version = "0.1.0"
8
+ description = "Find and fix the macOS LaunchAgent bugs that fail silently, plus a circuit breaker for agents that keep failing."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Victor Navejar (Navii)" }]
14
+ keywords = ["macos", "launchd", "launchagent", "launchctl", "tcc", "automation"]
15
+ classifiers = [
16
+ "Operating System :: MacOS",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: System :: Monitoring",
19
+ ]
20
+ dependencies = []
21
+
22
+ [project.optional-dependencies]
23
+ dev = ["pytest", "ruff"]
24
+
25
+ [project.scripts]
26
+ launchd-doctor = "launchd_doctor:main"
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/naviidaviimusic/launchd-doctor"
30
+
31
+ [tool.setuptools]
32
+ py-modules = ["launchd_doctor"]
33
+
34
+ [tool.pytest.ini_options]
35
+ pythonpath = ["."]
36
+
37
+ [tool.ruff]
38
+ line-length = 115
39
+ target-version = "py39"
40
+
41
+ [tool.ruff.lint]
42
+ select = ["E", "F", "W", "I", "B", "UP"]
43
+ ignore = ["UP006", "UP035", "B023"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,107 @@
1
+ import plistlib
2
+ from pathlib import Path
3
+
4
+ import launchd_doctor as ld
5
+
6
+ HOME = Path("/Users/test")
7
+
8
+
9
+ def codes(findings):
10
+ return {f.code for f in findings}
11
+
12
+
13
+ def test_split_calendar_detected_and_merged():
14
+ bug = [{"Minute": 55}, {"Hour": 5}, {"Minute": 0}, {"Hour": 12}]
15
+ assert ld.runs_per_day(bug) == 24 + 60 + 24 + 60
16
+ merged = ld.split_calendar(bug)
17
+ assert merged == [{"Minute": 55, "Hour": 5}, {"Minute": 0, "Hour": 12}]
18
+ assert ld.runs_per_day(merged) == 2
19
+
20
+
21
+ def test_correct_calendar_untouched():
22
+ assert ld.split_calendar([{"Hour": 5, "Minute": 55}]) is None
23
+ assert ld.split_calendar({"Hour": 5, "Minute": 55}) is None
24
+ assert ld.split_calendar([{"Minute": 0}, {"Minute": 30}]) is None # intentional: twice an hour
25
+
26
+
27
+ def test_ambiguous_split_not_merged():
28
+ assert ld.split_calendar([{"Hour": 5}, {"Hour": 6}, {"Minute": 0}]) is None
29
+
30
+
31
+ def test_tcc_log_path():
32
+ d = {"Label": "x", "ProgramArguments": ["/bin/echo"],
33
+ "StandardOutPath": "/Users/test/Documents/proj/x.log"}
34
+ f = ld.analyze("x", d, HOME)
35
+ assert "tcc-log-path" in codes(f)
36
+ assert all(x.fixable for x in f if x.code == "tcc-log-path")
37
+
38
+
39
+ def test_logs_outside_protected_ok():
40
+ d = {"ProgramArguments": ["/bin/echo"], "StandardOutPath": "/Users/test/Library/Logs/x.log"}
41
+ assert ld.analyze("x", d, HOME) == []
42
+
43
+
44
+ def test_missing_program_and_script():
45
+ d = {"ProgramArguments": ["/nope/python3", "/nope/job.py"]}
46
+ assert {"missing-program", "missing-script"} <= codes(ld.analyze("x", d, HOME))
47
+
48
+
49
+ def test_tcc_interpreter():
50
+ d = {"ProgramArguments": ["/usr/bin/python3", "/Users/test/Documents/job.py"]}
51
+ assert "tcc-interpreter" in codes(ld.analyze("x", d, HOME))
52
+
53
+
54
+ def test_empty_plist():
55
+ assert codes(ld.analyze("x", {}, HOME)) == {"empty-plist"}
56
+
57
+
58
+ def test_fix_dry_run_and_apply(tmp_path, monkeypatch):
59
+ agents = tmp_path / "Library" / "LaunchAgents"
60
+ agents.mkdir(parents=True)
61
+ monkeypatch.setattr(ld, "HOME", tmp_path)
62
+ monkeypatch.setattr(ld, "AGENTS_DIR", agents)
63
+ monkeypatch.setattr(ld, "job_info", lambda label: None)
64
+ p = agents / "com.t.job.plist"
65
+ d = {"Label": "com.t.job", "ProgramArguments": ["/bin/echo"],
66
+ "StandardOutPath": str(tmp_path / "Documents" / "o.log"),
67
+ "StandardErrorPath": str(tmp_path / "Documents" / "o.log"),
68
+ "StartCalendarInterval": [{"Minute": 5}, {"Hour": 3}]}
69
+ p.write_bytes(plistlib.dumps(d))
70
+
71
+ ld.main(["fix"])
72
+ assert plistlib.loads(p.read_bytes()) == d # dry run changes nothing
73
+
74
+ ld.main(["fix", "--apply"])
75
+ new = plistlib.loads(p.read_bytes())
76
+ assert new["StartCalendarInterval"] == [{"Minute": 5, "Hour": 3}]
77
+ assert new["StandardOutPath"] == new["StandardErrorPath"] == str(tmp_path / "Library/Logs/com.t.job.log")
78
+ assert list(agents.glob("*.bak-*")), "backup written"
79
+
80
+
81
+ def test_breaker_trips_after_three_failures_and_recovers():
82
+ b = {"state": "closed", "streak": 0, "runs": None, "trips": 0}
83
+ now = 1_000_000.0
84
+ assert ld.breaker_step(b, {"runs": 10, "exit": "1"}, now) is None # baseline
85
+ assert ld.breaker_step(b, {"runs": 11, "exit": "1"}, now) is None
86
+ assert ld.breaker_step(b, {"runs": 11, "exit": "1"}, now) is None # no new run
87
+ assert ld.breaker_step(b, {"runs": 12, "exit": "1"}, now) is None
88
+ assert ld.breaker_step(b, {"runs": 13, "exit": "1"}, now) == "trip"
89
+ assert b["state"] == "open" and b["trips"] == 1
90
+ assert ld.breaker_step(b, None, now + 3600) is None # still cooling
91
+ assert ld.breaker_step(b, None, now + 24 * 3600) == "trial"
92
+ assert ld.breaker_step(b, {"runs": 0, "exit": None}, now) is None # reloaded: new baseline
93
+ assert ld.breaker_step(b, {"runs": 1, "exit": "0"}, now) == "closed"
94
+ assert b["trips"] == 0
95
+
96
+
97
+ def test_breaker_ignores_signals_and_doubles_cooldown():
98
+ b = {"state": "closed", "streak": 0, "runs": 0, "trips": 0}
99
+ assert ld.breaker_step(b, {"runs": 1, "exit": "-15"}, 0) is None
100
+ assert b["streak"] == 0
101
+ assert ld.cooldown(1) == 24 * 3600 and ld.cooldown(2) == 48 * 3600
102
+ assert ld.cooldown(10) == ld.BREAKER_COOL_MAX
103
+
104
+
105
+ def test_breaker_flags_broken_launch_config():
106
+ b = {"state": "closed", "streak": 0, "runs": 0, "trips": 0}
107
+ assert ld.breaker_step(b, {"runs": 1, "exit": "127"}, 0) == "broken"