crazy-workers 1.1.0__tar.gz → 1.3.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.
Files changed (52) hide show
  1. crazy_workers-1.3.0/PKG-INFO +442 -0
  2. crazy_workers-1.3.0/README.md +407 -0
  3. crazy_workers-1.3.0/crazy_workers/boot/__init__.py +14 -0
  4. crazy_workers-1.3.0/crazy_workers/boot/__main__.py +5 -0
  5. crazy_workers-1.3.0/crazy_workers/boot/base.py +83 -0
  6. crazy_workers-1.3.0/crazy_workers/boot/detect.py +14 -0
  7. crazy_workers-1.3.0/crazy_workers/boot/entry.py +18 -0
  8. crazy_workers-1.3.0/crazy_workers/boot/orchestrator.py +59 -0
  9. crazy_workers-1.3.0/crazy_workers/boot/systemd.py +94 -0
  10. crazy_workers-1.3.0/crazy_workers/boot/windows.py +47 -0
  11. crazy_workers-1.3.0/crazy_workers/cli/commands/__init__.py +7 -0
  12. crazy_workers-1.3.0/crazy_workers/cli/commands/status.py +82 -0
  13. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers/cli/main.py +6 -11
  14. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers/core/manager/__init__.py +41 -10
  15. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers/core/manager/starter.py +14 -1
  16. crazy_workers-1.3.0/crazy_workers/database/storage.py +83 -0
  17. crazy_workers-1.3.0/crazy_workers.egg-info/PKG-INFO +442 -0
  18. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers.egg-info/SOURCES.txt +9 -2
  19. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/pyproject.toml +1 -1
  20. crazy_workers-1.1.0/PKG-INFO +0 -268
  21. crazy_workers-1.1.0/README.md +0 -233
  22. crazy_workers-1.1.0/crazy_workers/cli/commands/__init__.py +0 -8
  23. crazy_workers-1.1.0/crazy_workers/cli/commands/lister.py +0 -57
  24. crazy_workers-1.1.0/crazy_workers/cli/commands/restorer.py +0 -14
  25. crazy_workers-1.1.0/crazy_workers/database/storage.py +0 -56
  26. crazy_workers-1.1.0/crazy_workers.egg-info/PKG-INFO +0 -268
  27. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/LICENSE +0 -0
  28. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers/__init__.py +0 -0
  29. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers/_bootstrap.py +0 -0
  30. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers/cli/__init__.py +0 -0
  31. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers/cli/commands/params.py +0 -0
  32. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers/cli/commands/starter.py +0 -0
  33. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers/cli/commands/stopper.py +0 -0
  34. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers/cli/discovery.py +0 -0
  35. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers/cli/ui.py +0 -0
  36. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers/core/__init__.py +0 -0
  37. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers/core/backend.py +0 -0
  38. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers/core/engine.py +0 -0
  39. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers/core/manager/lister.py +0 -0
  40. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers/core/manager/recoverer.py +0 -0
  41. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers/core/manager/stopper.py +0 -0
  42. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers/core/recovery.py +0 -0
  43. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers/database/__init__.py +0 -0
  44. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers/database/schema.py +0 -0
  45. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers/testing/__init__.py +0 -0
  46. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers/testing/backends.py +0 -0
  47. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers/testing/polling.py +0 -0
  48. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers.egg-info/dependency_links.txt +0 -0
  49. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers.egg-info/entry_points.txt +0 -0
  50. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers.egg-info/requires.txt +0 -0
  51. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/crazy_workers.egg-info/top_level.txt +0 -0
  52. {crazy_workers-1.1.0 → crazy_workers-1.3.0}/setup.cfg +0 -0
@@ -0,0 +1,442 @@
1
+ Metadata-Version: 2.4
2
+ Name: crazy-workers
3
+ Version: 1.3.0
4
+ Summary: A Python library for managing background worker processes with persistent state, automatic recovery, and a CLI.
5
+ Author: GioVanni Colasanto
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Vanni-broUser/crazy-workers
8
+ Project-URL: Bug Tracker, https://github.com/Vanni-broUser/crazy-workers/issues
9
+ Project-URL: Source, https://github.com/Vanni-broUser/crazy-workers
10
+ Keywords: workers,background,processes,process-manager,task-runner,cli,psutil
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Topic :: System :: Systems Administration
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: sqlalchemy>=2.0.0
27
+ Requires-Dist: psutil>=5.9.0
28
+ Requires-Dist: rich>=13.0.0
29
+ Provides-Extra: dev
30
+ Requires-Dist: ruff; extra == "dev"
31
+ Requires-Dist: pytest>=7.0; extra == "dev"
32
+ Requires-Dist: coverage; extra == "dev"
33
+ Requires-Dist: flask; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # Crazy Workers
37
+
38
+ A Python library for managing background worker processes with persistent state, automatic crash recovery, and a built-in CLI.
39
+
40
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/)
41
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
42
+
43
+ ## Features
44
+
45
+ - **Persistent State** — SQLite database tracks worker status, PIDs, and parameters across restarts.
46
+ - **Backend Integration** — Co-locate crazy_workers' tables in your project's database (pass a SQLAlchemy engine or URL), inject a shared `DATABASE_URL` into every worker, and recover workers automatically when the backend boots. See [Backend integration](#backend-integration).
47
+ - **Process Management** — Start, stop, and monitor background Python scripts as independent OS processes.
48
+ - **Automatic Recovery** — Detects crashed workers and restarts them on application boot.
49
+ - **Automatic Boot-Restore** — On Linux and Windows, starting a worker transparently installs a per-user OS hook (a systemd user unit / a logon Scheduled Task) that runs recovery after a machine reboot — no host application required. Opt out with `CRAZY_WORKERS_NO_BOOT`. See [Automatic boot-restore](#automatic-boot-restore).
50
+ - **Child Process Control** — On stop, terminates unmanaged subprocesses while preserving independently-managed nested workers.
51
+ - **CLI Interface** — Manage workers from the terminal with interactive prompts and auto-discovery (see [CLI.md](https://github.com/Vanni-broUser/crazy-workers/blob/main/CLI.md)).
52
+ - **Security** — Worker types and keys are restricted to a safe identifier charset (`A-Z a-z 0-9 _ -`), with a defence-in-depth check that the resolved script path stays inside the workers directory. This blocks path traversal on both Unix and Windows (including drive-relative names like `c:evil`).
53
+ - **Observability** — Per-worker file logging; all service files (DB, lock, logs) live in a `.service/` folder inside your workers directory.
54
+ - **Zombie Protection** — Distinguishes active processes from zombies using `psutil`.
55
+ - **PID-Reuse Safe** — Each worker is tagged with an identity token on its command line; recovery and stop confirm a PID still belongs to the worker before acting, so a recycled PID is never mistaken for (or worse, killed as) a live worker. Works on both Unix and Windows.
56
+ - **Gunicorn-safe** — File-based lock prevents concurrent recovery runs across multiple workers.
57
+ - **Testable** — Drive your orchestration with a `FakeBackend` (no real processes) and import polling helpers for the few genuine end-to-end tests. See [Testing your app](#testing-your-app).
58
+
59
+ ## Installation
60
+
61
+ ```bash
62
+ pip install crazy-workers
63
+ ```
64
+
65
+ Or from source:
66
+
67
+ ```bash
68
+ git clone https://github.com/Vanni-broUser/crazy-workers
69
+ cd crazy-workers
70
+ pip install .
71
+ ```
72
+
73
+ ## Quick Start
74
+
75
+ ### 1. Create a worker script
76
+
77
+ ```python
78
+ # workers/my_worker.py
79
+ import json, sys, time
80
+
81
+ params = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {}
82
+ duration = params.get('duration', 60)
83
+
84
+ for _ in range(duration):
85
+ time.sleep(1)
86
+ ```
87
+
88
+ ### 2. Manage it from Python
89
+
90
+ ```python
91
+ from crazy_workers import WorkerManager
92
+
93
+ manager = WorkerManager('workers')
94
+
95
+ # Start
96
+ success, result = manager.start_worker(
97
+ 'my_worker',
98
+ worker_key='job_1',
99
+ parameters={'duration': 30},
100
+ )
101
+ print(result['pid']) # OS process ID
102
+ print(result['status']) # 'RUNNING'
103
+
104
+ # List
105
+ for w in manager.list_workers():
106
+ print(w['worker_key'], w['status'])
107
+
108
+ # Stop
109
+ manager.stop_worker('job_1')
110
+
111
+ # Recover crashed workers (call on app startup)
112
+ restarted = manager.recover_workers()
113
+
114
+ manager.dispose() # releases DB connection; does NOT kill workers
115
+ ```
116
+
117
+ ### 3. Or from the CLI
118
+
119
+ ```bash
120
+ crazy-workers status
121
+ crazy-workers start my_worker --key job_1 --params '{"duration": 30}'
122
+ crazy-workers stop job_1
123
+ ```
124
+
125
+ See [CLI.md](https://github.com/Vanni-broUser/crazy-workers/blob/main/CLI.md) for full CLI documentation.
126
+
127
+ ## API Reference
128
+
129
+ ### `WorkerManager(workers_dir, create_dir=True, ...)`
130
+
131
+ | Parameter | Type | Default | Description |
132
+ |-----------|------|---------|-------------|
133
+ | `workers_dir` | `str` | `'workers'` | Directory containing worker `.py` scripts |
134
+ | `create_dir` | `bool` | `True` | Create `workers_dir` and `.service/` if they don't exist |
135
+ | `backend` | `ProcessBackend` | `None` | Process backend; the default spawns real subprocesses (tests inject a fake) |
136
+ | `auto_boot` | `bool` | `True` | Install the per-user OS boot-restore hook on first start — see [Automatic boot-restore](#automatic-boot-restore) |
137
+ | `boot_provider` | `BootProvider` | `None` | Override the boot-restore mechanism (mainly a test seam) |
138
+ | `db_url` | `str` | `None` | SQLAlchemy URL for worker state; defaults to SQLite under `.service/` |
139
+ | `engine` | `Engine` | `None` | Reuse an existing SQLAlchemy engine so the tables live in your database; **not** disposed by crazy_workers |
140
+ | `worker_env` | `dict` | `None` | Environment variables injected into **every** spawned worker (e.g. `DATABASE_URL`) |
141
+ | `auto_recover` | `bool` | `True` | Recover dead-but-`RUNNING` workers when the manager is constructed |
142
+ | `create_tables` | `bool` | `True` | Create crazy_workers' own tables on init; set `False` when the host owns the schema via its migrations |
143
+
144
+ See [Backend integration](#backend-integration) for `db_url` / `engine` / `worker_env` / `auto_recover` / `create_tables`.
145
+
146
+ ### `start_worker(worker_type, worker_key=None, parameters=None, env=None)`
147
+
148
+ | Parameter | Type | Default | Description |
149
+ |-----------|------|---------|-------------|
150
+ | `worker_type` | `str` | — | Filename (without `.py`) of the worker script |
151
+ | `worker_key` | `str` | `worker_type` | Unique identifier; allows multiple instances of the same type |
152
+ | `parameters` | `dict` | `{}` | JSON-serializable dict passed as `sys.argv[1]` to the worker |
153
+ | `env` | `dict` | `None` | Extra environment variables injected into the worker process |
154
+
155
+ Returns `(bool, dict | str)` — `(True, worker_dict)` on success, `(False, error_message)` on failure.
156
+
157
+ > **Note on `RUNNING`:** success means the worker was *spawned* and survived a brief startup grace period that catches immediate launch failures (bad import, missing module). It does **not** guarantee the worker will run to completion — a worker that fails later is still reported `RUNNING` until the next `list_workers()` / `recover_workers()` reconciles its state.
158
+
159
+ ### `stop_worker(worker_key)`
160
+
161
+ Gracefully terminates the worker (SIGTERM → SIGKILL after timeout). Returns `(bool, str)`.
162
+
163
+ ### `list_workers()`
164
+
165
+ Returns a list of worker dicts including RUNNING, STOPPED, CRASHED, and NEVER_STARTED (filesystem-discovered) workers.
166
+
167
+ ### `recover_workers()`
168
+
169
+ Restarts any worker whose DB status is RUNNING but whose process is dead. Uses a file lock to prevent concurrent recovery. Returns a list of restarted keys.
170
+
171
+ > You rarely call this directly: it runs automatically when the manager is constructed (`auto_recover=True`) and after a machine reboot via the boot hook. It remains available as an explicit, idempotent trigger.
172
+
173
+ ### `dispose()`
174
+
175
+ Closes the database connection and clears internal process references. Does **not** kill background workers — they continue running independently.
176
+
177
+ ## Worker Script Contract
178
+
179
+ A worker receives its parameters as a JSON string in `sys.argv[1]`:
180
+
181
+ ```python
182
+ import json, sys
183
+
184
+ params = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {}
185
+ # ... do work ...
186
+ ```
187
+
188
+ A worker is a separate process, so it cannot be handed a live object (e.g. a DB
189
+ connection). Pass **configuration** instead: the manager's `worker_env` (and any
190
+ per-call `env`) is injected as environment variables, so a worker reads, say,
191
+ `os.environ['DATABASE_URL']` and opens its own connection. See
192
+ [`example_app/workers/db_writer.py`](https://github.com/Vanni-broUser/crazy-workers/blob/main/example_app/workers/db_writer.py).
193
+
194
+ ## Testing your app
195
+
196
+ Code that uses `WorkerManager` has two kinds of logic worth testing:
197
+ **orchestration** (which workers start, pairing, rollback, recovery) and the
198
+ **work itself** (does the worker actually do its job). `crazy_workers.testing`
199
+ makes both fast and non-flaky.
200
+
201
+ ### Orchestration — without launching a single process
202
+
203
+ `WorkerManager.for_testing()` wires a **FakeBackend**: spawning and termination
204
+ are faked, but the state machine (SQLite, recovery, validation) stays **real**
205
+ and runs in-process. The backend is exposed as `manager.test` for assertions.
206
+
207
+ ```python
208
+ from crazy_workers import WorkerManager
209
+
210
+ def test_starts_recorder_and_renamer():
211
+ manager = WorkerManager.for_testing('workers') # FakeBackend, no processes
212
+
213
+ manager.start_worker('recorder', worker_key='cam1', parameters={'device': 'cam1'})
214
+ manager.start_worker('renamer', worker_key='renamer_cam1', parameters={'output_dir': '/data/cam1'})
215
+
216
+ assert manager.test.started_types == ['recorder', 'renamer']
217
+ assert manager.test.is_running('cam1')
218
+ assert manager.test.parameters_for('renamer_cam1') == {'output_dir': '/data/cam1'}
219
+ manager.dispose()
220
+
221
+
222
+ def test_recovery_restarts_a_crash():
223
+ manager = WorkerManager.for_testing('workers')
224
+ manager.start_worker('recorder', worker_key='cam1')
225
+
226
+ manager.test.crash('cam1') # simulate an unexpected death
227
+ manager.recover_workers() # the real recovery path runs in-process
228
+
229
+ assert manager.test.start_count('cam1') == 2
230
+ assert manager.test.is_running('cam1')
231
+ manager.dispose()
232
+ ```
233
+
234
+ `workers_dir` must still contain the `<type>.py` files (`start_worker` checks
235
+ the script exists), but the fake backend never executes them — empty files are
236
+ enough.
237
+
238
+ `manager.test` exposes:
239
+
240
+ | Member | Returns |
241
+ |---|---|
242
+ | `started_types` / `started_keys` | every spawn, in order (a restart appears twice) |
243
+ | `running_keys` | keys whose latest process is still "alive" |
244
+ | `is_running(key)` | bool |
245
+ | `start_count(key)` | how many times the key was (re)started |
246
+ | `parameters_for(key)` | parameters of the most recent spawn |
247
+ | `crash(key)` | simulate an unexpected death (without a stop) |
248
+
249
+ ### The real thing — polling helpers, not `sleep`
250
+
251
+ The few tests that *must* launch real processes should wait on conditions,
252
+ never fixed sleeps. `crazy_workers.testing` exposes the helpers used by the
253
+ library's own suite:
254
+
255
+ ```python
256
+ from crazy_workers.testing import wait_for_worker_status, wait_for_log, wait_for_pid_dead
257
+
258
+ manager = WorkerManager('workers')
259
+ ok, res = manager.start_worker('recorder', worker_key='cam1')
260
+
261
+ wait_for_worker_status(manager, 'cam1', 'RUNNING')
262
+ wait_for_log('workers/.service/logs/cam1.log', 'recording started')
263
+ # ... assert the worker actually did its job ...
264
+ manager.stop_worker('cam1')
265
+ wait_for_pid_dead(res['pid'])
266
+ ```
267
+
268
+ Available: `wait_for`, `wait_for_file`, `wait_for_log`, `wait_for_worker_status`,
269
+ `wait_for_worker_in_db`, `wait_for_worker_pid`, `wait_for_pid_dead`. Each raises
270
+ `AssertionError` with a useful message on timeout.
271
+
272
+ > **What the fake covers — and what it doesn't.** `for_testing`/FakeBackend
273
+ > tests *orchestration*, not "the worker actually records / sends / converts".
274
+ > Keep a small number of real end-to-end tests for that, made stable with the
275
+ > polling helpers above, and move everything else — the bulk — into the fast,
276
+ > deterministic fake world.
277
+
278
+ ## Project Structure
279
+
280
+ ```
281
+ crazy_workers/ # Library package
282
+ core/ # WorkerManager, process engine, recovery lock
283
+ boot/ # Automatic per-user boot-restore (systemd user unit / scheduled task)
284
+ cli/ # CLI entry point, commands, discovery
285
+ database/ # SQLAlchemy schema and pluggable storage (SQLite, or a shared engine/URL)
286
+ testing/ # FakeBackend + polling helpers for consumer test suites
287
+ example_app/ # Flask demo application
288
+ app.py
289
+ workers/ # Example worker scripts
290
+ tests/
291
+ core/ # Unit tests for core modules
292
+ cli/ # Unit tests for CLI modules
293
+ database/ # Unit tests for storage layer
294
+ testing/ # Tests for the FakeBackend and polling helpers
295
+ integration/ # Full-stack integration tests (real processes)
296
+ app/ # Tests for the example Flask app
297
+ ```
298
+
299
+ ## Flask Integration
300
+
301
+ > ⚠️ **Security:** `start_worker()` runs the worker script named by the caller. Exposing it over HTTP makes it a **privileged operation** — anyone who can reach the route can launch any script in your workers directory. Put such routes behind authentication, and prefer validating `worker_type` against a known allow-list of expected workers. The example below is a minimal demo with **no auth**.
302
+
303
+ ```python
304
+ from crazy_workers import WorkerManager
305
+
306
+ def create_app(db_engine, db_url):
307
+ app = Flask(__name__)
308
+
309
+ manager = WorkerManager(
310
+ 'workers',
311
+ engine=db_engine, # crazy_workers' tables live in YOUR database
312
+ worker_env={'DATABASE_URL': db_url}, # injected into every worker
313
+ # auto_recover=True (default): when the app boots, workers left RUNNING
314
+ # with a dead PID are restored automatically — no explicit call needed.
315
+ )
316
+
317
+ @app.route('/workers/start', methods=['POST'])
318
+ def start():
319
+ data = request.json
320
+ success, result = manager.start_worker(
321
+ data['worker_type'],
322
+ worker_key=data.get('worker_key'),
323
+ parameters=data.get('parameters', {}),
324
+ )
325
+ return (jsonify(result), 200) if success else (jsonify({'error': result}), 400)
326
+
327
+ return app
328
+ ```
329
+
330
+ See [`example_app/app.py`](https://github.com/Vanni-broUser/crazy-workers/blob/main/example_app/app.py) for a complete example.
331
+
332
+ ## Backend integration
333
+
334
+ When crazy_workers runs inside a backend, three options let it cooperate with
335
+ the project instead of living off to the side:
336
+
337
+ - **Co-locate its tables in your database.** Pass an existing SQLAlchemy
338
+ `engine` (or a `db_url`) to `WorkerManager`. crazy_workers creates its own
339
+ `workers` table inside your database and inherits its persistence and backups
340
+ — so state survives even if the process/container is recreated. A shared
341
+ engine is never disposed by crazy_workers; its owner manages it.
342
+ - **Let your migrations own the schema.** If your project tracks its schema with
343
+ a migration tool (Alembic, etc.), pass `create_tables=False` so crazy_workers
344
+ issues no DDL: the `workers` table becomes a normal migration in your history,
345
+ with a single source of truth and no create-on-import side effect. You own the
346
+ ordering — the table must exist before the manager queries it, so build the
347
+ manager *after* your migrations run (and keep `auto_recover=False` until then,
348
+ since recovery reads that table). See the `workers` schema in
349
+ [`crazy_workers/database/schema.py`](https://github.com/Vanni-broUser/crazy-workers/blob/main/crazy_workers/database/schema.py)
350
+ for the columns your migration must create.
351
+ - **Give workers the connection they need.** A worker is a separate process, so
352
+ it can't receive a live DB connection — pass the *configuration* instead.
353
+ `worker_env={'DATABASE_URL': ...}` is injected into every spawned worker
354
+ (overridable per call via `start_worker(..., env=...)`); the worker opens its
355
+ own connection from it.
356
+ - **Recovery on construction.** `auto_recover=True` (default) restores
357
+ dead-but-`RUNNING` workers when the manager is built, so a restarting backend
358
+ brings its workers back with no explicit call. The CLI and boot entrypoint
359
+ set it to `False` (management/one-shot, not supervision).
360
+
361
+ ## Gunicorn / Multi-Process Servers
362
+
363
+ When using a pre-fork server like Gunicorn:
364
+
365
+ - **Recovery is atomic** — a file lock (`.service/workers.db.recovery.lock`) ensures `recover_workers()` runs once even when multiple workers boot simultaneously.
366
+ - **Workers outlive their parent** — if a Gunicorn worker is recycled, background processes keep running. The next recovery cycle re-attaches or restarts them.
367
+
368
+ ## Automatic boot-restore
369
+
370
+ Starting a worker transparently installs a **per-user OS hook** for its workers
371
+ directory, so workers come back after a reboot without any host application
372
+ running. The hook calls the internal entrypoint `python -m crazy_workers.boot
373
+ --workers-dir <dir>`, which runs `recover_workers()`. The install is
374
+ best-effort and happens at most once per directory (a marker lives in
375
+ `.service/boot.json`); a failure never blocks the worker from starting and is
376
+ reported by `crazy-workers status`.
377
+
378
+ | Platform | Mechanism | When it runs |
379
+ |----------|-----------|--------------|
380
+ | Linux | systemd **user** unit (`~/.config/systemd/user`) | at user login — or at true boot if **lingering** is enabled |
381
+ | Windows | logon Scheduled Task (`schtasks /SC ONLOGON`) | at user logon |
382
+
383
+ **Unattended boot — the honest caveat.** The recovery itself is durable across
384
+ consecutive reboots (a worker left `RUNNING` with a dead PID is restarted, and
385
+ this is PID-reuse safe). But running it *without any login* depends on the OS:
386
+
387
+ - **Linux:** enable lingering once, as root — `loginctl enable-linger <user>` —
388
+ so the user's systemd starts at boot (the same model as the Docker daemon
389
+ restarting containers). Without it, restore runs at the next login.
390
+ - **Windows:** a user task fires at logon; true pre-logon start needs autologon
391
+ or an administrator-installed task.
392
+
393
+ `crazy-workers status` always reports whether the hook runs **at boot** or
394
+ **at login**, so this is never silently wrong.
395
+
396
+ **Opting out.** Set `CRAZY_WORKERS_NO_BOOT` (any non-empty value) to disable the
397
+ automatic install entirely — useful in containers, CI, or when you manage boot
398
+ yourself. You can also pass `WorkerManager(..., auto_boot=False)`.
399
+
400
+ ## Logs
401
+
402
+ Each worker's stdout/stderr is appended to `.service/logs/<worker_key>.log`. These files are written directly by the worker process, so the library does **not** rotate them — they grow until you act. For long-lived deployments, rotate them externally (e.g. `logrotate` with `copytruncate`) or have your worker script configure its own `logging.handlers.RotatingFileHandler` instead of writing to stdout/stderr.
403
+
404
+ ## Development
405
+
406
+ ### Setup
407
+
408
+ ```bash
409
+ git clone https://github.com/Vanni-broUser/crazy-workers
410
+ cd crazy-workers
411
+ pip install -e .[dev]
412
+ ```
413
+
414
+ ### Commands
415
+
416
+ ```bash
417
+ # Lint and format
418
+ ruff check . --fix && ruff format .
419
+
420
+ # Run tests
421
+ pytest
422
+
423
+ # Run tests with coverage
424
+ coverage run -m pytest && coverage report
425
+ ```
426
+
427
+ ### Standards
428
+
429
+ See [AI.md](https://github.com/Vanni-broUser/crazy-workers/blob/main/AI.md) for the full coding and testing standards used in this project.
430
+
431
+ ## Support the project ❤️
432
+
433
+ crazy-workers is free and open-source (MIT). If it saves you time or powers your work,
434
+ consider supporting its development:
435
+
436
+ - **GitHub Sponsors** — recurring or one-time, 0% platform fee: https://github.com/sponsors/Vanni-broUser
437
+ - **Stripe** — one-time card donation via secure checkout: _Stripe Payment Link (coming soon)_
438
+ - **⭐ Star the repo** — free, and it really helps visibility.
439
+
440
+ > The Stripe link is configured via a [Payment Link](https://stripe.com/docs/payment-links).
441
+ > Replace the placeholder above (and in [`.github/FUNDING.yml`](.github/FUNDING.yml)) with your real
442
+ > `https://buy.stripe.com/...` URL once created.