Skip to content

fix(scheduler): alert on routine failure + run routines with sys.executable#124

Open
mt-alarcon wants to merge 1 commit into
evolution-foundation:mainfrom
mt-alarcon:fix/scheduler-alert-on-routine-failure
Open

fix(scheduler): alert on routine failure + run routines with sys.executable#124
mt-alarcon wants to merge 1 commit into
evolution-foundation:mainfrom
mt-alarcon:fix/scheduler-alert-on-routine-failure

Conversation

@mt-alarcon

@mt-alarcon mt-alarcon commented Jul 21, 2026

Copy link
Copy Markdown

Problem

Two defects in scheduler.py that compound into silent, long-lived outages of scheduled work. We hit both at once and lost a daily routine for 25 days without noticing.

1. Routine failures were invisible

run_adw reported failures only through print() to buffered stdout. The scheduler's output is normally redirected to a log file, so nothing reached disk until the buffer filled. A routine failing every single day was indistinguishable from a routine that never ran — and the log file sat empty either way.

2. uv run python silently pruned dependencies

Routines were executed via uv run, which re-syncs the venv against uv.lock before every run and removes any package not declared in pyproject.toml.

The consequence is nastier than it first looks: a routine whose dependency was installed ad-hoc would delete that dependency while starting up, and then fail because of it. Every run. Reinstalling by hand only survived until the next tick, which makes the problem look intermittent or haunted rather than deterministic.

Fix

Failures are now recorded and announced. They append to ADWs/logs/routine-failures.jsonl with timestamp, routine name, and the tail of the child's stderr — enough to diagnose without reproducing — and send a Telegram notification through the existing ADWs/runner.send_telegram.

Ordering is deliberate: the disk record is written first and always; the notification is best-effort and wrapped so a missing token or a dead bot can never take the scheduler down. All three failure paths are covered — missing script, non-zero exit, timeout. The status print()s also gain flush=True.

Routines run under sys.executable, the same interpreter as the scheduler. No sync, no pruning. Declaring dependencies in pyproject.toml is still the correct practice; this just stops the scheduler from actively punishing the ones that aren't.

Verification

Each failure path was executed against this branch — not merely compiled:

00:01 ✗ PR-check
{"ts": "2026-07-21T00:01:18", "routine": "PR-check", "detail": "exit 9\nfalha upstream"}

Telegram delivery confirmed separately with credentials loaded (the ⚠ Telegram not configured line above is the guard working as intended in a bare shell).

Notes for reviewers

  • The JSONL lands in ADWs/logs/, already gitignored.
  • _alert_failure imports send_telegram lazily and inside try — no new hard dependency, and installs without Telegram configured are unaffected beyond one warning line.
  • shlex.quote on PYTHON because sys.executable can contain spaces and the command is built for shell=True.
  • Behaviour change worth flagging: uv run also had the side effect of installing missing declared deps before each run. Environments that leaned on that will now need uv sync at deploy time — which start-services.sh style bootstraps already do.

🤖 Generated with Claude Code

Summary by Sourcery

Improve scheduler reliability by recording routine failures and running routines with the same Python interpreter as the scheduler.

Bug Fixes:

  • Ensure routine failures are persisted to a JSONL log and surfaced via Telegram notifications instead of remaining silent.
  • Avoid unintended pruning or resync of dependencies during routine execution by no longer invoking routines via uv run.

Enhancements:

  • Run scheduled routines using sys.executable to align with the scheduler’s runtime environment.
  • Add flushed status output and structured failure details (including exit codes and stderr tail) for scheduled routines.

…utable

Two defects that compound into silent, long-lived outages of scheduled work.

**1. Routine failures were invisible.** `run_adw` reported them only via
`print()` to buffered stdout. Since the scheduler's output is normally
redirected to a log file, nothing reached disk until the buffer filled — so a
routine failing every day looked exactly like a routine that never ran. We lost
a daily routine for 25 days to this before noticing.

Failures now append to `ADWs/logs/routine-failures.jsonl` (timestamp, routine
name, and the tail of the child's stderr, so the cause is diagnosable without
reproducing) and send a Telegram notification via the existing
`ADWs/runner.send_telegram`. The disk record is written first and always; the
notification is best-effort and wrapped so it can never take the scheduler
down. All three failure paths are covered: missing script, non-zero exit, and
timeout. The status `print()`s also gain `flush=True` so the log stops lying.

**2. `uv run python` silently pruned dependencies.** Routines were executed via
`uv run`, which re-syncs the venv against `uv.lock` before every run and removes
any package not declared in `pyproject.toml`. A routine whose dependency was
installed ad-hoc therefore deleted that dependency while starting up, then
failed — on every single run, unrecoverably, since reinstalling by hand only
survived until the next tick.

Routines now run under `sys.executable`, the same interpreter as the scheduler,
which has no such side effect. Declaring dependencies in `pyproject.toml`
remains the correct practice; this only stops the scheduler from actively
punishing the ones that aren't.

Verified by executing each failure path against this branch and confirming both
the JSONL record and the Telegram delivery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR changes the scheduler to run routines using the same Python interpreter as the scheduler and adds durable alerting for routine failures via a JSONL log and optional Telegram notifications, ensuring failures are visible and do not silently break scheduled work.

Sequence diagram for routine execution and failure alerting in scheduler

sequenceDiagram
    participant Scheduler
    participant run_adw
    participant Subprocess as subprocess.run
    participant Alert as _alert_failure
    participant File as FAILURE_LOG
    participant Telegram as send_telegram

    Scheduler->>run_adw: run_adw(name, script, args)
    run_adw->>run_adw: script_path.exists()
    alt script not found
        run_adw->>Scheduler: print(..., flush=True)
        run_adw->>Alert: _alert_failure(name, "script not found: script")
    else script exists
        run_adw->>Subprocess: subprocess.run([PYTHON, script_path, args], timeout=900)
        alt returncode == 0
            run_adw->>Scheduler: print("✓ name", flush=True)
        else returncode != 0
            run_adw->>Scheduler: print("✗ name", flush=True)
            run_adw->>Alert: _alert_failure(name, "exit code + stderr tail")
        end
    end

    alt subprocess.TimeoutExpired
        run_adw->>Scheduler: print("timeout (15min)", flush=True)
        run_adw->>Alert: _alert_failure(name, "timed out after 15min")
    else other Exception
        run_adw->>Scheduler: print("error: e", flush=True)
        run_adw->>Alert: _alert_failure(name, "scheduler exception: e")
    end

    Alert->>File: open(FAILURE_LOG, "a")
    Alert->>File: write(json.dumps({ts, routine, detail}))
    Alert->>Scheduler: print("WARN: could not write", flush=True)

    Alert->>Telegram: send_telegram("Routine failed — name\ndetail\n(ts)")
    Alert->>Scheduler: print("WARN: Telegram alert not sent", flush=True)
Loading

File-Level Changes

Change Details Files
Run scheduled routines with the same Python interpreter as the scheduler instead of via uv run.
  • Replace dynamic selection of uv run python/python3 with sys.executable as the routine interpreter.
  • Shell-quote the Python interpreter path to handle spaces because commands are executed with shell=True.
  • Add a FAILURE_LOG path constant pointing at ADWs/logs/routine-failures.jsonl.
scheduler.py
Add persistent logging and best-effort Telegram alerts for routine failures.
  • Introduce _alert_failure helper to append structured JSONL entries for routine failures and send Telegram notifications via runner.send_telegram.
  • Ensure failure logging directory is created lazily and handle disk-write errors with WARN messages instead of crashing the scheduler.
  • Import send_telegram lazily from ADWs/runner inside a guarded try block so missing Telegram configuration or import errors only emit WARN messages.
scheduler.py
Make all routine failure modes visible and immediately flushed to logs.
  • On missing routine script, log the error with flush=True and call _alert_failure with a descriptive message.
  • After routine subprocess completion, log status with flush=True; on non-zero exit, capture a 400-character tail from stderr/stdout and pass it to _alert_failure along with the exit code.
  • On TimeoutExpired and other exceptions from subprocess.run, log the error with flush=True and call _alert_failure with appropriate detail strings (timeout or scheduler exception).
scheduler.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • Consider avoiding repeated sys.path.insert and dynamic imports inside _alert_failure; importing send_telegram once at module level (behind a try/except) or caching it after first use would reduce path churn and make resolution more predictable.
  • The subprocess.run call still relies on shell=True with a manually constructed command where only sys.executable is quoted; using shell=False with an argument list would simplify handling of spaces in paths/args and avoid subtle quoting/injection edge cases.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Consider avoiding repeated `sys.path.insert` and dynamic imports inside `_alert_failure`; importing `send_telegram` once at module level (behind a try/except) or caching it after first use would reduce path churn and make resolution more predictable.
- The `subprocess.run` call still relies on `shell=True` with a manually constructed command where only `sys.executable` is quoted; using `shell=False` with an argument list would simplify handling of spaces in paths/args and avoid subtle quoting/injection edge cases.

## Individual Comments

### Comment 1
<location path="scheduler.py" line_range="92-101" />
<code_context>
+    Telegram notification is best-effort and can never take the scheduler down.
+    """
+    ts = datetime.now().isoformat(timespec="seconds")
+    try:
+        FAILURE_LOG.parent.mkdir(parents=True, exist_ok=True)
+        with open(FAILURE_LOG, "a") as f:
+            f.write(json.dumps({"ts": ts, "routine": name, "detail": detail},
+                               ensure_ascii=False) + "\n")
+    except Exception as e:
+        print(f"  WARN: could not write {FAILURE_LOG}: {e}", flush=True)
+
+    try:
+        sys.path.insert(0, str(WORKSPACE / "ADWs"))
+        from runner import send_telegram
+        send_telegram(f"\U0001F6A8 Routine failed — {name}\n{detail}\n({ts})")
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Repeatedly inserting into `sys.path` inside `_alert_failure` can cause subtle global side effects.

Since `_alert_failure` may be invoked frequently, each call inserts a new entry at the front of `sys.path`, changing import resolution order and potentially growing `sys.path` indefinitely, which is risky in a long-lived or multi-threaded scheduler. Consider moving this `sys.path` modification to module import time, or guarding it so the path is only inserted if it’s not already present.

Suggested implementation:

```python
    try:
        adws_path = str(WORKSPACE / "ADWs")
        if adws_path not in sys.path:
            sys.path.insert(0, adws_path)
        from runner import send_telegram
        send_telegram(f"\U0001F6A8 Routine failed — {name}\n{detail}\n({ts})")
    except Exception as e:
        print(f"  WARN: Telegram alert not sent: {e}", flush=True)

```

If `_alert_failure` is called from hot paths and Telegram sending is expected to be available for the lifetime of the scheduler, you may also want to:
1. Move the `adws_path` insertion and `send_telegram` import to module import time (top-level of `scheduler.py`) to avoid repeated imports and path checks.
2. Wrap that top-level import in a `try/except` and fall back to a no-op `send_telegram` to keep `_alert_failure` logic simple.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread scheduler.py
Comment on lines +92 to +101
try:
FAILURE_LOG.parent.mkdir(parents=True, exist_ok=True)
with open(FAILURE_LOG, "a") as f:
f.write(json.dumps({"ts": ts, "routine": name, "detail": detail},
ensure_ascii=False) + "\n")
except Exception as e:
print(f" WARN: could not write {FAILURE_LOG}: {e}", flush=True)

try:
sys.path.insert(0, str(WORKSPACE / "ADWs"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Repeatedly inserting into sys.path inside _alert_failure can cause subtle global side effects.

Since _alert_failure may be invoked frequently, each call inserts a new entry at the front of sys.path, changing import resolution order and potentially growing sys.path indefinitely, which is risky in a long-lived or multi-threaded scheduler. Consider moving this sys.path modification to module import time, or guarding it so the path is only inserted if it’s not already present.

Suggested implementation:

    try:
        adws_path = str(WORKSPACE / "ADWs")
        if adws_path not in sys.path:
            sys.path.insert(0, adws_path)
        from runner import send_telegram
        send_telegram(f"\U0001F6A8 Routine failed — {name}\n{detail}\n({ts})")
    except Exception as e:
        print(f"  WARN: Telegram alert not sent: {e}", flush=True)

If _alert_failure is called from hot paths and Telegram sending is expected to be available for the lifetime of the scheduler, you may also want to:

  1. Move the adws_path insertion and send_telegram import to module import time (top-level of scheduler.py) to avoid repeated imports and path checks.
  2. Wrap that top-level import in a try/except and fall back to a no-op send_telegram to keep _alert_failure logic simple.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant