Skip to content

Repository files navigation

Pinglet - No silent failures

Pinglet

A universal task wrapper for macOS that guarantees no silent failures. Wraps scheduled tasks with unified logging, state tracking, and alerts (Slack + macOS).

What's a Pinglet?

A pinglet is an individual scheduled task managed by Pinglet. Each pinglet wraps a command with reliability features — retries, state tracking, alerting, and missed-run detection. Examples: the UCE link collector is a pinglet, Obsidian git-sync is a pinglet, the daily health check is a pinglet.

Think of it like pods in Kubernetes or containers in Docker — "pinglet" is the unit noun for a scheduled task-agent in this system.


Usage

Run from repo root. All commands output JSON — parse the ok field.

# Setup (after cloning)
bash scripts/install-hooks.sh
# Full CLI reference
uv run python pinglet.py --help
# List all pinglets
uv run python pinglet.py --list --json
# Full details + state + logs for one pinglet
uv run python pinglet.py --task-show <id># Add + enable a pinglet
uv run python pinglet.py --task-add my-task --command /usr/bin/python3 --args script.py --schedule-spec "daily 7:00"
uv run python pinglet.py --task-enable my-task
# Run health check / heartbeat
uv run python pinglet.py --healthcheck
uv run python pinglet.py --heartbeat

Features

  • Pinglet Management CLI: Add, edit, remove, enable, disable pinglets — all via CLI with JSON output
  • Reliability System: Automatic retry with exponential backoff, consecutive failure thresholds, alert cooldown
  • Missed Pinglet Detection: Hourly heartbeat detects pinglets that couldn't run (laptop asleep, etc.)
  • Actionable Notifications: macOS notifications with Run/Ignore buttons, Slack messages
  • Output Formatting: Per-pinglet output parsing for rich notification summaries (JSON/text)
  • Pinglet Queue: Sequential execution with configurable gaps between pinglets
  • Monitoring Self-Protection: Every CLI invocation checks if watchdog agents are alive; surviving agents detect and alert when monitoring is down ("who watches the watchmen")
  • LaunchAgent Health Detection: Real-time launchctl status checks detect disabled (exit 78), failed, and not-loaded agents — not just plist existence
  • Escalation Tiers: Staleness alerts escalate from warning (2x threshold) to urgent (5x) to critical (10x)

Adding a New Pinglet

# Add task with schedule
uv run python pinglet.py --task-add my-task \
--command uv \
--args run python script.py --flag \
--working-dir /path/to/project \
--name "My Task" \
--timeout 300 \
--schedule-spec "daily 7:00"# Enable it (generates LaunchAgent plist + loads it)
uv run python pinglet.py --task-enable my-task
# Verify
uv run python pinglet.py --task-show my-task

Available Config Flags

FlagRequiredDefaultDescription
--commandYes-Executable path
--nameNoTitle-cased IDDisplay name for notifications
--argsNo[]Command arguments
--working-dirNoCommand's directoryWorking directory
--timeoutNo300Timeout in seconds
--envNo[]Env vars to pass through
--output-formatNotextOutput format (text/json)
--summary-templateNonullTemplate for JSON output
--failures-before-alertNo3Consecutive failures before alerting
--schedule-specNonullSchedule (see syntax above)

Editing and Removing Pinglets

# Edit (only specified fields update)
uv run python pinglet.py --task-edit my-task --timeout 600
# Change schedule
uv run python pinglet.py --schedule my-task "every 2h"
uv run python pinglet.py --task-enable my-task # reload with new schedule# Disable (keeps config, removes LaunchAgent)
uv run python pinglet.py --task-disable my-task
# Remove entirely (auto-disables + removes config + state)
uv run python pinglet.py --task-remove my-task
# Preview any change first
uv run python pinglet.py --task-remove my-task --dry-run

Monitoring & Self-Protection

LaunchAgent Status Detection

--list and --healthcheck now query launchctl directly for real agent state instead of just checking if the plist file exists. This catches agents that launchd has disabled (exit code 78) — a common failure mode after reboot or macOS updates.

Statuses: RUNNING, IDLE (loaded, waiting for schedule), DISABLED (exit 78), FAILED, NOT_LOADED, NOT_INSTALLED.

Who Watches the Watchmen

Every CLI invocation checks if the healthcheck and heartbeat monitoring agents are alive. If they're dead, a warning prints to stderr. After any successful task run, a debounced Slack alert fires (24h cooldown) so monitoring failures don't go unnoticed.

Escalation Tiers

Staleness alerts escalate based on how far past the threshold a task is:

MultiplierLevelExample (2h threshold)
2xWarning4h since last run
5xUrgent10h since last run
10xCritical20h since last run

Plist Hardening

Generated plists include KeepAlive > SuccessfulExit: false so launchd restarts agents that exit non-zero instead of permanently disabling them. The heartbeat agent uses KeepAlive: true for maximum resilience.

Adaptive Loop

Pinglet implements a detection-recovery-learning flywheel that reduces human intervention over time:

 +------------------+
| Task Failure |
| (exit != 0) |
+--------+---------+
|
v
+------------------+
| Tier 1: Auto- |<---------+
| Recovery | |
| (bootout+boot) | |
+--------+---------+ |
| |
recovered? |
/ \ |
yes no |
| | |
v v |
+-----------+ +------------------+ |
| Learning | | Tier 2: LLM | |
| Loop | | Self-Diagnosis | |
| (pattern | | (CLI runner) | |
| update) | +--------+--------+ |
+-----------+ | |
fixed? |
/ \ |
yes no |
| | |
v v |
+-----------+ +----------+ |
| Learning | | Tier 3: | |
| Loop | | Human | |
| (pattern | | Alert | |
| update) | | (Slack/ | |
+-----------+ | macOS) | |
+----+-----+ |
| |
+-------+

Recovery Cascade

Three tiers execute in order. Each tier only fires if the previous one failed.

TierActionTrigger
1Auto-recovery (bootout + bootstrap)Every detection of a disabled/failed agent
2LLM self-diagnosis (codex exec, then claude -p, optional OpenRouter diagnosis)After auto-recovery fails once
3Human alert (Slack + macOS)After consecutive_detections >= monitoring_alert_threshold (default: 3)

Learning Loop

The heartbeat tracks failure patterns per task in state/_learning.json and adapts thresholds automatically.

Detected patterns:

PatternMeaningAdaptation
chronic_cycleFails, recovers, fails again repeatedlyHigher alert threshold, suppressed=true
intermittentOccasional failures with long healthy stretchesDefault thresholds maintained
persistentFails and stays failed across multiple checksLower alert threshold for faster escalation

Noise suppression: Tasks classified as chronic_cycle auto-recoverers are marked suppressed=true. They still auto-recover but no longer generate human alerts unless the pattern changes.

Threshold adaptation: The learning loop adjusts each task's effective monitoring_alert_threshold based on its pattern. Chronic cyclers get a higher threshold (fewer alerts); persistent failures get a lower threshold (faster escalation).

Default Values

SettingDefaultDescription
retry_max_attempts3Retries before declaring failure
retry_delays_seconds[10, 60, 300]Exponential backoff delays
consecutive_failures_before_alert3Failures before alerting
alert_cooldown_minutes30Min time between alerts
monitoring_alert_threshold3Detections before human alert
monitoring_alert_cooldown_hours24Cooldown for monitoring alerts
on_failure_timeout180Seconds for on_failure callback
on_failure_max_turns5Max LLM turns
on_failure_max_budget_usd2.00Max spend per callback
self_diagnosis_max_budget_usd1.00Max spend per self-diagnosis

on_failure Callback

When a task fails the alert threshold, Pinglet can invoke a subscription-backed runner to diagnose and fix the root cause before alerting a human. agent_runners.primary, secondary, and tertiary control the order, and Pinglet falls back after three consecutive provider failures.

Without on_failure, failure alerts on Slack include:

LLM Troubleshooter: Not configured — human intervention required

With on_failure, the same alert reports the callback outcome:

LLM Troubleshooter: Invoked ✓ (exit 0 — may be fixed, verify next run)LLM Troubleshooter: Invoked ✗ (exit 1 — human intervention required)

Config (config.yaml):

tasks:
my-task:
command: ./run.pyon_failure:
command: codexargs:
- "-p"
- "Task {task_id} failed (exit {exit_code}). Read {stderr_file}. Check {learning_file}. Fix if possible."timeout: 180max_turns: 5max_budget_usd: 2.00agent_runners:
primary:
provider: codexsubscription: openaimodel: gpt-5.5command: codexsecondary:
provider: claudesubscription: claudemodel: sonnet-4.6fallback_model: sonnet-5command: claudetertiary:
enabled: falseprovider: openroutersubscription: openroutermodel: qwen/qwen3-coderapi_key_env: OPENROUTER_API_KEY

Use explicit paths only if the CLI is not on PATH:

agent_runners:
primary:
provider: codexcommand: /opt/homebrew/bin/codexmodel: gpt-5.5secondary:
provider: claudecommand: /opt/homebrew/bin/claudemodel: sonnet-4.6fallback_model: sonnet-5

CLI flags:

$P --task-add my-task --command ./run.py \
--on-failure-command codex \
--on-failure-prompt "Task {task_id} failed (exit {exit_code}). Read {stderr_file}. Fix if possible." \
--on-failure-timeout 180 \
--on-failure-max-turns 5

Template variables available in on_failure prompts:

VariableDescription
{task_id}Task identifier
{task_name}Display name
{exit_code}Process exit code
{error}Error message (if captured)
{log_file}Path to combined log file
{stderr_file}Path to stderr capture
{stdout_file}Path to stdout capture
{working_dir}Task working directory
{consecutive_failures}Current failure streak count
{state_file}Path to task state JSON
{project_root}Pinglet install directory
{learning_file}Path to state/_learning.json

Exit code contract: The callback should exit 0 if it fixed the problem (Pinglet will retry the task), or 1 if human intervention is needed (Pinglet proceeds to alert).

Sibling: on_diagnose — fires when the heartbeat detects a task is stale or its LaunchAgent is disabled (not when the task itself exited non-zero). Same schema, same template variables. If omitted, heartbeat uses a built-in default prompt. Use this to customize how the LLM recovers from missed-run / disabled-agent states.

Gotcha: Claude Code hooks can block callback tools; keep hook paths absolute and reusable across projects.

--status API

The --status command returns a JSON overview of all tasks, the adaptive loop state, and recent activity:

uv run python pinglet.py --status

Example output (abbreviated):

{
"ok": true,
"summary": {
"total_tasks": 4,
"healthy": 3,
"failing": 1,
"disabled": 0
},
"adaptive_loop": {
"learning_file": "state/_learning.json",
"patterns": {
"uce": {"pattern": "intermittent", "suppressed": false},
"git-sync": {"pattern": "chronic_cycle", "suppressed": true}
},
"auto_recoveries_24h": 2,
"human_alerts_24h": 0
},
"tasks": [
{
"id": "uce",
"status": "IDLE",
"last_run": "2026-03-17T07:00:12Z",
"last_exit_code": 0,
"consecutive_failures": 0
}
]
}

Missed Pinglet Detection

Pinglet detects when scheduled pinglets haven't run (e.g., laptop was asleep) and notifies with actionable options.

Install Heartbeat

uv run python pinglet.py --install-heartbeat
uv run python pinglet.py --uninstall-heartbeat

How It Works

  1. Heartbeat runs hourly via LaunchAgent
  2. Checks each pinglet's last_run against healthcheck.expected_intervals
  3. Checks launchctl for disabled/failed agents
  4. For missed pinglets, sends macOS notification with Run/Ignore buttons
  5. For disabled agents, sends urgent Slack alert with fix commands
  6. Also sends Slack message with escalation level (warning/urgent/critical)
  7. 30-second wake delay allows system to stabilize after wake

Manual Commands

uv run python pinglet.py --heartbeat # Run check now
uv run python pinglet.py --run-now uce # Run a missed task
uv run python pinglet.py --ignore uce # Ignore until next run

Output Formatting

Pinglets can configure custom output formatters for rich notification summaries.

Text Format (Default)

Shows last 5 lines of stdout, truncated to 200 characters.

JSON Format

Parses stdout as JSON and applies template string substitution.

tasks:
obsidian-tab-archiver:
output:
format: jsonsummary_template: "Archived {tabs_archived} tabs, kept {tabs_kept}"

With stdout {"tabs_archived": 12, "tabs_kept": 8}, the notification shows: Archived 12 tabs, kept 8

Configuration Reference

Notifications

notifications:
on_success: falseon_failure: trueslack_enabled: truemacos_enabled: truesuccess_silent: truemanual_complete_silent: true

Reliability System

Reduces alert fatigue by:

  1. Automatic retry with exponential backoff (10s -> 60s -> 300s)
  2. Consecutive failure threshold - only alert after N failures
  3. Alert cooldown - don't spam if task keeps failing
  4. Recovery notifications - notify when task recovers
reliability:
retry:
max_attempts: 3delays_seconds: [10, 60, 300]jitter: 0.25alert:
consecutive_failures: 3cooldown_minutes: 30notify_on_recovery: true

Choosing consecutive_failures Threshold

ThresholdUse Case
1Critical/infrequent tasks
2-3Important tasks with occasional transient failures
3-5Frequent tasks where transient failures are common
5+Very frequent tasks with known flakiness

Heartbeat

heartbeat:
enabled: trueinterval_minutes: 60wake_delay_seconds: 30healthcheck:
expected_intervals:
uce: 14git-sync: 2

Project Structure

pinglet/
├── pinglet.py # Main entry point + CLI + watchdog self-check
├── config.yaml # Task registry and configuration
├── lib/
│ ├── task_manager.py # Pinglet CRUD, schedule parsing, plist generation, launchd status
│ ├── alerts.py # Slack + macOS notifications + critical monitoring alerts
│ ├── reliability.py # Retry, threshold, cooldown logic
│ ├── state.py # Pinglet state tracking (JSON)
│ ├── logging.py # Structured logging
│ ├── heartbeat.py # Missed pinglet detection + disabled agent detection + escalation
│ ├── ignored.py # Ignored pinglets management
│ ├── queue.py # Pinglet queue for sequential execution
│ └── output_formatter.py # Output formatting (JSON/text)
├── tests/ # Test suite (227 tests)
├── state/ # Per-task state files (*.json)
│ ├── _learning.json # Adaptive loop pattern history
│ └── _monitoring_down_state.json # Monitoring agent down-state tracking
├── logs/ # Log files
└── launchagents/ # Generated LaunchAgent plists

Troubleshooting

# Show full task debug info (config + state + launchd status + logs)
uv run python pinglet.py --task-show <task-id># View recent logs for a task
uv run python pinglet.py --task-logs <task-id> 100
# Test a task manually
uv run python pinglet.py --task <task-id># Check LaunchAgent status (shows disabled agents with exit 78)
uv run python pinglet.py --list
# Raw launchctl status
launchctl list | grep pinglet
# Re-enable a disabled agent
uv run python pinglet.py --task-enable <task-id># Run tests
uv run pytest

About

Universal task wrapper that guarantees no silent failures

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages