AgentStack
SKILL unreviewed MIT Self-run

Cron Script Fastpath

skill-humanerd-drew-opencode-drewgent-cron-script-fastpath · by humanerd-drew

Add a script field to jobs.json + scheduler.py branch to bypass LLM for simple shell cron jobs (cost + reliability). Reusable pattern for any 'Run: python3 xxx.py' style cron.

No reviews yet
0 installs
7 views
0.0% view→install

Install

$ agentstack add skill-humanerd-drew-opencode-drewgent-cron-script-fastpath

Open-source listing — not yet scanned by AgentStack. Follow the source repository for install instructions.

Security review

⚠ Flagged

1 finding(s); flagged for manual review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures
  • high Dangerous shell/eval execution.

What it can access

  • Network access Used
  • Filesystem access No
  • Shell / process execution Used
  • Environment & secrets Used
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

Are you the author of Cron Script Fastpath? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

Cron Script Fast-Path — LLM Bypass for Simple Shell Jobs

{{AGENT_NAME}} cron jobs run their prompt through AIAgent (LLM) by default. For jobs whose prompt is just a shell command ("Run: python3 xxx.py"), this is wasteful — LLM parses the prompt, runs the script, and forwards stdout to delivery. Each run costs a full LLM round-trip.

This skill documents a surgical patch that adds a script: field fast-path: when a job has script: "path", scheduler runs the script directly via subprocess.run() and skips the LLM entirely.


1. When to Use

Apply when a cron job's prompt is structurally a single shell command:

  • "Run: python3 ~/.{{AGENTNAMELOWER}}/scripts/foo.py"
  • "Run: bash ~/.{{AGENTNAMELOWER}}/scripts/bar.sh"
  • Any job where the prompt's only value-add over subprocess.run is

"Report: ..." or "If silent, [SILENT]" — both of which the script already produces in stdout.

Two independent motivations:

| Motivation | Why | When it bites hardest | |---|---|---| | Cost | LLM round-trip per job tick — adds up on hourly/daily cadence with a 1M-context model. | High-frequency jobs (every N minutes). | | Reliability / timeout avoidance | LLM-based cron jobs inherit the main model. If the provider is slow to start streaming or stalls mid-response, the cron scheduler's 600s idle timeout fires — job fails with TimeoutError: ... idle for 603s (limit 600s) — last activity: waiting for provider response (streaming). The script itself would finish in tuple[bool, str, str, Optional[str]]: """Execute a script-based cron job via direct subprocess (no LLM).

For jobs whose prompt is just a shell command (cron-output-cleanup, kanban-maintenance, etc.), this path bypasses the AIAgent round-trip entirely and runs the script directly. Returns the same tuple shape as runjob() so tick() delivery logic stays uniform. """ jobid = job["id"] jobname = job.get("name", jobid) expandedscript = os.path.expanduser(scriptpath) logger.info( "Script-based job '%s' (ID: %s, script: %s)", jobname, jobid, expanded_script, )

if not os.path.isfile(expandedscript): errormsg = f"Script not found: {expandedscript}" logger.error(errormsg) return False, "", "", error_msg

try: env = {**os.environ, "DREWHOME": str({{AGENTNAMELOWER}}home)} result = subprocess.run( [sys.executable, expandedscript], captureoutput=True, text=True, timeout=300, env=env, cwd=str({{AGENTNAMELOWER}}home), ) output = result.stdout.strip() erroroutput = result.stderr.strip()

if result.returncode != 0: errormsg = f"Script exit {result.returncode}: {erroroutput[:500]}" logger.error("Script job '%s' failed: %s", jobname, errormsg) return False, output, "", error_msg

logger.info( "Script job '%s' completed (exit=0, output=%d chars)", jobname, len(output), ) return True, output, output, None except subprocess.TimeoutExpired: return False, "", "", f"Script timeout (300s): {expandedscript}" except Exception as e: return False, "", "", f"{type(e).__name__}: {e}"


### File 2: `cron/jobs.json` — flip `script: null` to path

For each target job, change:
```json
"script": null,

to:

"script": "~/.{{AGENT_NAME_LOWER}}/scripts/.py",

Keep the existing prompt field untouched — it's now unused but stays as a human-readable breadcrumb of what the job does.

File 3: (optional) the script itself

If the cron job's previous behavior was "LLM runs shell + reports output", make sure the underlying script already produces the required output format. For kanban-maintenance specifically, a new scripts/kanban_maintenance.py was written from the kanban-maintenance-guide.md recipe (cleanup + HTML refresh).


3. Tick() Reuse — Why It Just Works

tick() in scheduler.py (line 800+) iterates due_jobs, calls run_job(), and runs the result through a uniform pipeline:

success, output, final_response, error = run_job(job)

is_silent = (
    success
    and bool(final_response)
    and SILENT_MARKER in final_response.strip().upper()
)

if not is_silent:
    output_file = save_job_output(job["id"], output)

deliver_content = final_response if success else f"⚠️ Cron job ... failed: ..."
if deliver_content and not (success and is_silent):
    _deliver_result(job, deliver_content, ...)

Because _run_script_subprocess returns the same (success, output, final_response, error) tuple, all of the above logic — [SILENT] detection, output file save, delivery to Discord or local — just works without any change in tick().

The script's stdout becomes final_response directly. If stdout contains [SILENT], delivery is skipped (line 833-835). If script exits non-zero, error is set and a "⚠️ failed" delivery goes out (line 831).


4. Dry-Run Verification (no LLM, no cron tick)

After patching, verify with a direct call to run_job() from execute_code:

import sys, os, json
sys.path.insert(0, '~/.{{AGENT_NAME_LOWER}}/source/{{AGENT_NAME_LOWER}}-agent')
os.chdir('~/.{{AGENT_NAME_LOWER}}/source/{{AGENT_NAME_LOWER}}-agent')

from cron.scheduler import run_job

with open('~/.{{AGENT_NAME_LOWER}}/cron/jobs.json') as f:
    jobs = json.load(f)['jobs']

# Pick one of the patched jobs
job = next(j for j in jobs if j.get('script'))
print(f"script: {job['script']!r}")

success, output, final, error = run_job(job)
print(f"success={success}")
print(f"final[:300]={final[:300]!r}")
print(f"error={error!r}")

success=True + a non-empty final + error=None = LLM was bypassed, script ran, delivery path will fire normally. No tokens consumed.


5. Cost Impact (per enabled job)

| Metric | Before | After | |---|---|---| | LLM calls per run | 1 (full AIAgent round-trip) | 0 | | LLM model | main (e.g. deepseek-v4-flash) | n/a | | Subprocess cost | small (one shell + script) | same | | Delivery | identical | identical |

For jobs running on a cadence (daily, weekly), the saving accumulates. A 1M-context M3 call is expensive — replacing it with subprocess.run of a 50-line Python script is essentially free.


6. Verification Checklist (pre-commit / pre-deploy)

  • [ ] python3 -m py_compile on cron/scheduler.py and the new script
  • [ ] python3 -c "import json; json.load(open('cron/jobs.json'))" valid
  • [ ] Dry-run via execute_code shows success=True, error=None
  • [ ] Other cron jobs (5 unaffected: SEO, Trend, 3× kanban-dispatcher,

site-spec-audit) still hit the LLM path — verify by inspecting that their script: field is still null in jobs.json

  • [ ] On next live tick of the patched job, check cron-runner.log

for "Script-based job ... completed" entry (no "Running job" + no AIAgent mention)


7. Common Pitfalls

  1. Don't change tick(). The whole point of the same-tuple

contract is no caller changes. If you find yourself editing tick(), the helper's return shape is wrong.

  1. os.path.expanduser for ~ in script path — jobs.json

stores ~ but subprocess.run won't expand it.

  1. Don't move env injection into the helper. The finally in

run_job() already cleans DREW_SESSION_PLATFORM/CHAT_ID/etc. for both branches because the helper is called inside try:.

  1. Timeout 300s is hardcoded. If a script needs longer, increase

it (but prefer making the script faster — cron has its own DREW_CRON_TIMEOUT for LLM path).

  1. The script is invoked with sys.executable, not the user's

python. If the script depends on the {{AGENTNAMELOWER}} venv (most do), ensure shebang or interpreter path is correct, OR rely on the env VIRTUAL_ENV being set in _{{AGENT_NAME_LOWER}}_home invocation.

  1. "Done ✅" claims in review docs can be stale. KANBAN-REVIEW-

20260520.md had "Worker mode: KANBANWORKERMODE implemented ✅" but the actual code path runs AIAgent. Always grep the actual code path before trusting review docs.

  1. LLM-based cron jobs without model override inherit the main model.

If jobs.json shows model: null, provider: null, the job uses whatever config.yaml sets as model.default. The current routing is opencode-go/deepseek-v4-flash (fast, $10/mo subscription). But even a fast model can lag. The safer pattern is the trigger→kanban delegation: make the cron job a lightweight agent that calls kanban_create and finishes, letting a kanban worker do the heavy LLM work with its own timeout budget. See references/trend-harvester-kanban-delegation.md for a worked example.

If you must keep the LLM in the cron itself, add an explicit fast-model override at job creation or update time: `` cronjob(action='create'|'update', job_id='...', model={provider: 'opencode-go', model: 'deepseek-v4-flash'}) ``

  1. Verify model existence on the provider before debugging timeout. When an LLM cron job fails with "waiting for provider response (streaming)", the first step is NOT to tune timeouts — check whether the model actually exists on the provider endpoint. Query /v1/models on the provider's base URL:
curl -s "${BASE_URL}/models" -H "Authorization: Bearer ${API_KEY}" \
  | python3 -c "import json,sys; [print(m['id']) for m in json.load(sys.stdin).get('data',[])]"

For Opencode Go specifically (base_url: https://opencode.ai/zen/go/v1):

curl -s "https://opencode.ai/zen/go/v1/models" -H "Authorization: Bearer $OPENCODE_GO_API_KEY"

Known supported models (verified 2026-06-13): deepseek-v4-flash, deepseek-v4-pro, qwen3.5-plus, qwen3.6-plus, qwen3.7-plus, qwen3.7-max, glm-5, glm-5.1, kimi-k2.5, kimi-k2.6, kimi-k2.7-code, hy3-preview, mimo-v2-omni, mimo-v2-pro, mimo-v2.5, mimo-v2.5-pro

If the model is absent from /v1/models, the job will hang indefinitely waiting for a 404-wrapped-as-streaming-empty — fix the model name or provider mapping, don't increase timeouts.

  1. Cron idle timeout (600s) is tighter than you think.

The cron scheduler enforces a 600s (10 min) idle limit per job tick. An LLM-based job that "runs a script then summarizes" can hit this if:

  • The script itself takes >5 min (data scraping, network collection)
  • The LLM is slow to start streaming (>5 min queue on OpenRouter)
  • The LLM stalls mid-stream (network blip, provider issue)
  • Combined: script runs for 4 min, then the LLM summary call takes

another 5+ min to start = timeout.

A script-only (no_agent: true) job avoids this entirely — the subprocess timeout is separately configurable (default 300s) and doesn't share the 600s idle pool.

  1. PATH is NOT inherited from user shell in no-agent cron jobs.

When a no_agent: true script runs CLI commands internally, the subprocess does NOT inherit the user's interactive shell PATH. Common installation paths are missing:

  • ~/.local/bin/ — local user binaries
  • /opt/homebrew/bin/ — Homebrew packages
  • /usr/local/bin/ — system local binaries

Symptom: The script runs but CLI commands return empty output. KV data shows cron.active: [] even though the script's --dry-run showed 16+ active jobs.

Fix — add _EXTRA_PATH to subprocess.run() env:

```python import os, subprocess

HOME = os.path.expanduser("~") EXTRAPATH = os.pathsep.join([ os.path.join(HOME, ".local", "bin"), os.path.join(HOME, ".hermes", "hermes-agent", ".venv", "bin"), "/opt/homebrew/bin", "/usr/local/bin", ]) EXTRAENV = {"PATH": EXTRAPATH + os.pathsep + os.environ.get("PATH", "")}

def runcmd(cmd, timeout=15): env = {EXTRAENV, os.environ} r = subprocess.run( cmd, captureoutput=True, text=True, timeout=timeout, shell=True, env=env ) return r.stdout.strip(), r.stderr.strip(), r.returncode ```

Detection: ``bash env -i PATH="/usr/bin:/bin" python3 -c " import subprocess, json r = subprocess.run(['python3', '-c', 'import json; d=json.load(open(\"$HOME/.{{AGENT_NAME_LOWER}}/cron/jobs.json\")); print(len([j for j in d.get(\"jobs\",[]) if j.get(\"enabled\")]))'], capture_output=True, text=True) print('stdout:', repr(r.stdout[:100])) print('stderr:', repr(r.stderr[:200])) " ``

  1. Interpreter selection rule (CRITICAL). _run_job_script() in

cron/scheduler.py picks the interpreter by file EXTENSION:

  • .sh / .bash → run with /bin/bash
  • everything else (.py, .js, .rb, no extension) → run with

sys.executable (the Python interpreter that launched the scheduler)

This means a no_agent: true cron job with script: "foo.js" will be executed by Python, not Node.js — producing a SyntaxError.

To run Node.js (or any non-Python) scripts:

  • ALWAYS use a .sh wrapper that calls the actual interpreter:

``bash #!/bin/bash # wrapper.sh — cron scheduler runs .sh with bash HULY_KEY="$(grep '^HULY_KEY=' "$HOME/.{{AGENT_NAME_LOWER}}/.env" | head -1 | cut -d= -f2-)" export HULY_KEY exec 2>/dev/null exec node --no-warnings actual_script.js ``

  • Set script: "wrapper.sh" in the cron job config.
  • _run_job_script() is at cron/scheduler.py:957 (verified 2026-06-14).

This caught us with huly_check.js — the cron scheduler ran it with Python, which failed on the em‑dash character as a syntax error.


8. Related

  • cron/scheduler.py:run_job — branch insertion point (line ~493)
  • cron/scheduler.py:tick — caller, no changes needed
  • ~/.{{AGENT_NAME_LOWER}}/P4-cortex/growth/kanban-maintenance-guide.md — recipe

used to write scripts/kanban_maintenance.py

  • ~/.{{AGENT_NAME_LOWER}}/P6-prefrontal/incidents/cron-jobs-stalled-20260601

related to cron-runner lifecycle, separate concern

  • skills/software-development/yaml-config-patch-{{AGENT_NAME_LOWER}} — sister

skill for ~/.{{AGENT_NAME_LOWER}}/config.yaml + P5-ego/config/config.yaml dual-patch pattern

  • references/launchd-service-watchdog.md — launchd watchdog pattern

for service health monitoring + auto-recovery (independent of the process being monitored)

  • references/trend-harvester-kanban-delegation.md — worked example:

Trend Harvester pipeline redesign. Real 5-stage cron → kanban delegation migration: collection (noagent), evaluate-trigger (fast LLM), evaluation (kanban worker), usage-watch (noagent), retire-trigger (fast LLM → worker). Taste Review was also migrated to the same trigger→kanban pattern on 2026-06-14.

  • references/opencode-go-models.md — verified model catalog for

Opencode Go provider. Query command + 19 confirmed models.


Created 2026-06-03 after Round 2 (H1) of background LLM cost optimization: 2 jobs migrated, dry-run LLM=0 confirmed, full-QA pending next live tick.

Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet — be the first.

Versions

  • v0.1.0 Imported from the upstream source.