Skip to content

Commit 5fa6664

Browse files
committed
v1.7.0: cron mode + CLAUDEBOX_* env namespace
- new cron mode (CLAUDEBOX_MODE_CRON=1, CLAUDEBOX_MODE_CRON_FILE=...): yaml-defined jobs with cron schedules and multiline instructions, fires `claude -p` per match, streams stream-json output to ~/.claude/cron/history/<workspace-slug>/<timestamp>-<job>/{activity.jsonl,stderr.log,meta.json} - 6-field cron support (sec min hr dom mon dow) for sub-minute schedules via croniter second_at_beginning=True; main loop sleep capped at 5s for responsiveness - per-job overlap protection: if previous tick still running, next tick is skipped with warning - foreground process so `docker logs` shows every tick; DEBUG=true enables per-tick + per-line debug logs - rename all project env vars to CLAUDEBOX_* prefix (canonical) with CLAUDE_* legacy fallback preserved everywhere: CLAUDEBOX_WORKSPACE, CLAUDEBOX_CONTAINER_NAME, CLAUDEBOX_GIT_NAME/EMAIL, CLAUDEBOX_IMAGE_VARIANT, CLAUDEBOX_MODE_API/_PORT/_TOKEN, CLAUDEBOX_MODE_TELEGRAM, CLAUDEBOX_TELEGRAM_BOT_TOKEN/CONFIG, CLAUDEBOX_MODE_CRON/_FILE, CLAUDEBOX_MINIMAL, CLAUDEBOX_BIN_NAME, CLAUDEBOX_SKIP_PULL/FORCE_PULL - Anthropic-defined vars left untouched (ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, CLAUDE_CODE_DISABLE_1M_CONTEXT, CLAUDE_CONFIG_DIR) - Dockerfile: add croniter to pip install, COPY cron.py, ENV CLAUDEBOX_IMAGE_VARIANT - entrypoint.sh: env var resolution block at top with backwards-compat fallback chain, mode dispatch reads CLAUDEBOX_MODE_* first, cron branch execs python3 cron.py - wrapper.sh: DOCKER_ARGS forwards canonical CLAUDEBOX_* names; supports both CLAUDEBOX_ENV_*/CLAUDE_ENV_* and CLAUDEBOX_MOUNT_*/CLAUDE_MOUNT_* - new tests/test_cron.sh (7 cases incl. end-to-end timing assertion verifying job fires within window) - README updated end-to-end for cron mode docs and CLAUDEBOX_* namespace
1 parent 1d028f1 commit 5fa6664

16 files changed

Lines changed: 1129 additions & 145 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,8 @@ git-update.sh
44
docker-compose.dev.yaml
55
tests/.env
66
tests/.tmp-*/
7+
tests/.fixtures/mounts/*
8+
!tests/.fixtures/mounts/.gitkeep
9+
tests/.fixtures/cron-*/
710
base/
811
desc.txt

Dockerfile

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
1919
RUN apt-get update && apt-get install -y \
2020
python3 python3-pip python3-venv \
2121
&& rm -rf /var/lib/apt/lists/* \
22-
&& pip3 install --no-cache-dir --break-system-packages --ignore-installed fastapi uvicorn python-telegram-bot pyyaml mcp
22+
&& pip3 install --no-cache-dir --break-system-packages --ignore-installed fastapi uvicorn python-telegram-bot pyyaml mcp croniter
2323

2424
# docker (needed for docker-in-docker)
2525
RUN curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg && \
@@ -66,18 +66,19 @@ WORKDIR /workspace
6666
COPY entrypoint.sh /home/claude/entrypoint.sh
6767
COPY api_server.py /home/claude/api_server.py
6868
COPY telegram_bot.py /home/claude/telegram_bot.py
69+
COPY cron.py /home/claude/cron.py
6970
COPY jsonpipe.py /home/claude/jsonpipe.py
7071
RUN chmod +x /home/claude/entrypoint.sh
7172

7273
ENTRYPOINT ["/home/claude/entrypoint.sh"]
7374

7475
# ── minimal ────────────────────────────────────────────────────────────────────
7576
FROM base AS minimal
76-
ENV CLAUDE_IMAGE_VARIANT=minimal
77+
ENV CLAUDEBOX_IMAGE_VARIANT=minimal
7778

7879
# ── full ───────────────────────────────────────────────────────────────────────
7980
FROM base AS full
80-
ENV CLAUDE_IMAGE_VARIANT=full
81+
ENV CLAUDEBOX_IMAGE_VARIANT=full
8182

8283
# build tools
8384
RUN apt-get update && apt-get install -y \

README.md

Lines changed: 194 additions & 95 deletions
Large diffs are not rendered by default.

api_server.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,12 @@ def _shutdown(sig, _frame):
109109
with open(SYSTEM_HINT_FILE) as _f:
110110
SYSTEM_HINT = _f.read().strip()
111111

112-
API_TOKEN = os.environ.get("CLAUDE_MODE_API_TOKEN", "")
112+
API_TOKEN = os.environ.get("CLAUDEBOX_MODE_API_TOKEN") or os.environ.get("CLAUDE_MODE_API_TOKEN", "")
113+
_port_raw = os.environ.get("CLAUDEBOX_MODE_API_PORT") or os.environ.get("CLAUDE_MODE_API_PORT", "8080")
113114
try:
114-
PORT = int(os.environ.get("CLAUDE_MODE_API_PORT", "8080"))
115+
PORT = int(_port_raw)
115116
except ValueError:
116-
log.error("CLAUDE_MODE_API_PORT must be a number, got: %s", os.environ.get("CLAUDE_MODE_API_PORT"))
117+
log.error("CLAUDEBOX_MODE_API_PORT must be a number, got: %s", _port_raw)
117118
raise SystemExit(1)
118119

119120
ALWAYS_SKILLS_DIR = "/home/claude/.claude/.always-skills"

cron.py

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
#!/usr/bin/env python3
2+
"""Cron mode for claudebox.
3+
4+
Reads a yaml of jobs (cron schedule + multiline instruction) and fires
5+
`claude -p ...` per match. Streams output to ~/.claude/cron/history/<workspace-slug>/<timestamp>-<job>/.
6+
7+
Activated when CLAUDEBOX_MODE_CRON=1. Yaml path from CLAUDEBOX_MODE_CRON_FILE.
8+
Workspace from CLAUDEBOX_WORKSPACE (legacy CLAUDE_WORKSPACE still accepted).
9+
"""
10+
from __future__ import annotations
11+
12+
import json
13+
import logging
14+
import os
15+
import re
16+
import shlex
17+
import signal
18+
import subprocess
19+
import sys
20+
import threading
21+
from datetime import datetime, timezone
22+
from pathlib import Path
23+
from typing import Any
24+
25+
import yaml
26+
from croniter import croniter
27+
28+
DEBUG = os.environ.get("DEBUG", "").lower() == "true"
29+
logging.basicConfig(
30+
level=logging.DEBUG if DEBUG else logging.INFO,
31+
format="%(asctime)s [%(levelname)s] %(message)s",
32+
datefmt="%Y-%m-%d %H:%M:%S",
33+
)
34+
log = logging.getLogger("claudebox-cron")
35+
36+
CRON_FILE = os.environ.get("CLAUDEBOX_MODE_CRON_FILE") or os.environ.get("CLAUDE_MODE_CRON_FILE", "")
37+
WORKSPACE = os.environ.get("CLAUDEBOX_WORKSPACE") or os.environ.get("CLAUDE_WORKSPACE") or "/workspace"
38+
HOME = os.environ.get("HOME", "/home/claude")
39+
HISTORY_ROOT = Path(HOME) / ".claude" / "cron" / "history"
40+
41+
_running_jobs: dict[str, threading.Thread] = {}
42+
_running_lock = threading.Lock()
43+
_shutdown = threading.Event()
44+
45+
46+
def slugify(path: str) -> str:
47+
s = re.sub(r"[^A-Za-z0-9]+", "_", path).strip("_")
48+
return s or "workspace"
49+
50+
51+
def load_jobs(path: str) -> list[dict[str, Any]]:
52+
log.debug("loading cron file: %s", path)
53+
with open(path) as f:
54+
data = yaml.safe_load(f) or {}
55+
if not isinstance(data, dict) or "jobs" not in data:
56+
raise ValueError("cron file must be a mapping with a 'jobs' key")
57+
jobs = data["jobs"]
58+
if not isinstance(jobs, list) or not jobs:
59+
raise ValueError("'jobs' must be a non-empty list")
60+
61+
seen: set[str] = set()
62+
valid: list[dict[str, Any]] = []
63+
for i, j in enumerate(jobs):
64+
if not isinstance(j, dict):
65+
raise ValueError(f"job #{i} is not a mapping")
66+
name = j.get("name")
67+
schedule = j.get("schedule")
68+
instruction = j.get("instruction")
69+
if not name or not isinstance(name, str):
70+
raise ValueError(f"job #{i}: 'name' is required and must be a string")
71+
if not re.match(r"^[A-Za-z0-9_\-]+$", name):
72+
raise ValueError(f"job '{name}': name must match [A-Za-z0-9_-]+")
73+
if name in seen:
74+
raise ValueError(f"duplicate job name: {name}")
75+
seen.add(name)
76+
if not schedule or not isinstance(schedule, str):
77+
raise ValueError(f"job '{name}': 'schedule' is required and must be a string")
78+
if not croniter.is_valid(schedule, second_at_beginning=True):
79+
raise ValueError(f"job '{name}': invalid cron schedule: {schedule}")
80+
if not instruction or not isinstance(instruction, str) or not instruction.strip():
81+
raise ValueError(f"job '{name}': 'instruction' is required and must be non-empty")
82+
model = j.get("model")
83+
if model is not None and not isinstance(model, str):
84+
raise ValueError(f"job '{name}': 'model' must be a string")
85+
valid.append({
86+
"name": name,
87+
"schedule": schedule,
88+
"instruction": instruction,
89+
"model": model,
90+
})
91+
log.debug("loaded job: name=%s schedule=%s model=%s", name, schedule, model)
92+
return valid
93+
94+
95+
def _run_job(job: dict[str, Any], fired_at: datetime, workspace_slug: str) -> None:
96+
name = job["name"]
97+
ts = fired_at.strftime("%Y%m%d-%H%M%S")
98+
job_dir = HISTORY_ROOT / workspace_slug / f"{ts}-{name}"
99+
try:
100+
job_dir.mkdir(parents=True, exist_ok=True)
101+
except OSError as e:
102+
log.error("[%s] failed to create history dir %s: %s", name, job_dir, e)
103+
return
104+
105+
activity_path = job_dir / "activity.jsonl"
106+
stderr_path = job_dir / "stderr.log"
107+
meta_path = job_dir / "meta.json"
108+
109+
cmd = ["claude", "--dangerously-skip-permissions", "-p", job["instruction"],
110+
"--output-format", "stream-json", "--verbose"]
111+
if job.get("model"):
112+
cmd += ["--model", job["model"]]
113+
114+
started_at = datetime.now(timezone.utc).isoformat()
115+
meta: dict[str, Any] = {
116+
"name": name,
117+
"schedule": job["schedule"],
118+
"model": job.get("model"),
119+
"instruction": job["instruction"],
120+
"workspace": WORKSPACE,
121+
"started_at": started_at,
122+
"finished_at": None,
123+
"exit_code": None,
124+
"error": None,
125+
}
126+
meta_path.write_text(json.dumps(meta, indent=2))
127+
128+
log.info("[%s] firing job (history: %s)", name, job_dir)
129+
log.debug("[%s] cmd: %s", name, shlex.join(cmd))
130+
131+
rc = -1
132+
err: str | None = None
133+
try:
134+
with open(activity_path, "wb") as out_f, open(stderr_path, "wb") as err_f:
135+
proc = subprocess.Popen(
136+
cmd,
137+
cwd=WORKSPACE,
138+
stdout=subprocess.PIPE,
139+
stderr=err_f,
140+
env=os.environ.copy(),
141+
)
142+
assert proc.stdout is not None
143+
for line in proc.stdout:
144+
out_f.write(line)
145+
out_f.flush()
146+
if DEBUG:
147+
try:
148+
log.debug("[%s] activity: %s", name, line.decode("utf-8", errors="replace").rstrip())
149+
except Exception:
150+
pass
151+
rc = proc.wait()
152+
except FileNotFoundError as e:
153+
err = f"claude binary not found: {e}"
154+
log.error("[%s] %s", name, err)
155+
except Exception as e:
156+
err = f"{type(e).__name__}: {e}"
157+
log.exception("[%s] job crashed: %s", name, err)
158+
159+
finished_at = datetime.now(timezone.utc).isoformat()
160+
meta["finished_at"] = finished_at
161+
meta["exit_code"] = rc
162+
meta["error"] = err
163+
meta_path.write_text(json.dumps(meta, indent=2))
164+
165+
if rc == 0:
166+
log.info("[%s] finished ok (rc=0)", name)
167+
else:
168+
log.warning("[%s] finished with rc=%s err=%s", name, rc, err)
169+
170+
171+
def _spawn_job(job: dict[str, Any], fired_at: datetime, workspace_slug: str) -> None:
172+
name = job["name"]
173+
with _running_lock:
174+
existing = _running_jobs.get(name)
175+
if existing and existing.is_alive():
176+
log.warning("[%s] previous run still in progress — skipping this tick", name)
177+
return
178+
179+
def target() -> None:
180+
try:
181+
_run_job(job, fired_at, workspace_slug)
182+
finally:
183+
with _running_lock:
184+
if _running_jobs.get(name) is threading.current_thread():
185+
del _running_jobs[name]
186+
187+
t = threading.Thread(target=target, name=f"job-{name}", daemon=True)
188+
_running_jobs[name] = t
189+
t.start()
190+
191+
192+
def _handle_signal(signum: int, _frame: Any) -> None:
193+
log.info("received signal %d, shutting down", signum)
194+
_shutdown.set()
195+
196+
197+
def main() -> int:
198+
if not CRON_FILE:
199+
log.error("CLAUDEBOX_MODE_CRON_FILE not set")
200+
return 1
201+
if not os.path.isfile(CRON_FILE):
202+
log.error("cron file not found: %s", CRON_FILE)
203+
return 1
204+
205+
try:
206+
jobs = load_jobs(CRON_FILE)
207+
except (ValueError, yaml.YAMLError) as e:
208+
log.error("invalid cron file: %s", e)
209+
return 1
210+
211+
workspace_slug = slugify(WORKSPACE)
212+
log.info("loaded %d job(s) from %s", len(jobs), CRON_FILE)
213+
log.info("workspace: %s (slug: %s)", WORKSPACE, workspace_slug)
214+
log.info("history root: %s", HISTORY_ROOT / workspace_slug)
215+
for j in jobs:
216+
log.info(" - %s [%s]%s", j["name"], j["schedule"],
217+
f" model={j['model']}" if j.get("model") else "")
218+
219+
HISTORY_ROOT.mkdir(parents=True, exist_ok=True)
220+
221+
signal.signal(signal.SIGTERM, _handle_signal)
222+
signal.signal(signal.SIGINT, _handle_signal)
223+
224+
# initialize each job's "next fire" time from now
225+
now = datetime.now()
226+
iters = {j["name"]: croniter(j["schedule"], now, second_at_beginning=True) for j in jobs}
227+
next_at: dict[str, datetime] = {n: it.get_next(datetime) for n, it in iters.items()}
228+
for n, t in next_at.items():
229+
log.debug("[%s] next fire: %s", n, t.isoformat())
230+
231+
while not _shutdown.is_set():
232+
now = datetime.now()
233+
for j in jobs:
234+
n = j["name"]
235+
if next_at[n] <= now:
236+
fired_at = next_at[n]
237+
_spawn_job(j, fired_at, workspace_slug)
238+
next_at[n] = iters[n].get_next(datetime)
239+
log.debug("[%s] next fire: %s", n, next_at[n].isoformat())
240+
# sleep until the next fire, capped so we react quickly to short schedules
241+
soonest = min(next_at.values())
242+
delta = max(0.5, min(5.0, (soonest - datetime.now()).total_seconds()))
243+
_shutdown.wait(timeout=delta)
244+
245+
log.info("waiting for in-flight jobs to finish...")
246+
with _running_lock:
247+
threads = list(_running_jobs.values())
248+
for t in threads:
249+
t.join(timeout=30)
250+
log.info("bye")
251+
return 0
252+
253+
254+
if __name__ == "__main__":
255+
sys.exit(main())

cron.yml.example

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Claudebox cron configuration
2+
# Copy somewhere accessible (e.g. ~/.claude/cron.yaml) and point CLAUDEBOX_MODE_CRON_FILE at it.
3+
#
4+
# Run with:
5+
# CLAUDEBOX_MODE_CRON=1 CLAUDEBOX_MODE_CRON_FILE=/home/claude/.claude/cron.yaml claudebox
6+
# or via docker-compose (see README).
7+
#
8+
# Each job fires `claude -p "<instruction>"` on schedule. Output streams to
9+
# ~/.claude/cron/history/<workspace-slug>/<YYYYMMDD-HHMMSS>-<job-name>/activity.jsonl
10+
# alongside stderr.log and meta.json.
11+
#
12+
# Workspace comes from CLAUDE_WORKSPACE — set it on the container (or via the wrapper
13+
# which sets it to $PWD). All jobs share the same workspace.
14+
#
15+
# Cron syntax:
16+
# 5-field "min hr dom mon dow" — standard cron, minute resolution
17+
# 6-field "sec min hr dom mon dow" — sub-minute resolution (e.g. */30 fires every 30s)
18+
19+
jobs:
20+
- name: every_thirty_seconds
21+
schedule: "*/30 * * * * *" # 6-field — every 30 seconds
22+
model: haiku # optional; defaults to claude's default
23+
instruction: |
24+
Write the current UTC timestamp to ./status.txt.
25+
26+
- name: every_five_minutes
27+
schedule: "*/5 * * * *" # 5-field — every 5 minutes
28+
model: haiku
29+
instruction: |
30+
Check the current time and write a one-line status file to ./status.txt
31+
with the current UTC timestamp.
32+
33+
- name: hourly_repo_check
34+
schedule: "0 * * * *"
35+
instruction: |
36+
Look at the git log for the last hour. Summarize commits.
37+
If you have an MCP server configured for notifications (e.g. Telegram),
38+
use it to send the summary to the configured chat.
39+
40+
- name: nightly_cleanup
41+
schedule: "0 3 * * *"
42+
model: sonnet
43+
instruction: |
44+
Look for stale temporary files in ./tmp older than 7 days.
45+
Delete them. Report what you removed.

0 commit comments

Comments
 (0)