Files
Triple-C/container/triple-c-task-runner
T
jknappandClaude Opus 5.5 0d117e97fe
Build App / compute-version (push) Successful in 3s
Build Container / build-container (push) Successful in 1m58s
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 3m4s
Build App / build-windows (push) Successful in 5m48s
Build App / build-linux (push) Successful in 5m10s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 2m27s
Add Auto permission mode (#63)
Adds Claude Code's auto permission mode as a fifth option between Accept Edits and Bypass, across terminals, resumed sessions, the tab badge, the scheduler task runner and docs. Warns that Auto falls back to prompting when unavailable for the model/backend.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 17:02:06 +00:00

254 lines
12 KiB
Bash

#!/bin/bash
# triple-c-task-runner — Executes a scheduled task via Claude Code agent
# Called by cron with a task ID argument. Handles locking, logging,
# notifications, one-time task cleanup, and log pruning.
set -uo pipefail
SCHEDULER_DIR="${HOME}/.claude/scheduler"
TASKS_DIR="${SCHEDULER_DIR}/tasks"
LOGS_DIR="${SCHEDULER_DIR}/logs"
NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications"
RUNNING_DIR="${SCHEDULER_DIR}/running"
ENV_FILE="${SCHEDULER_DIR}/.env"
TASK_ID="${1:-}"
if [ -z "$TASK_ID" ]; then
echo "Usage: triple-c-task-runner <task-id>" >&2
exit 1
fi
TASK_FILE="${TASKS_DIR}/${TASK_ID}.json"
LOCK_FILE="${SCHEDULER_DIR}/.lock-${TASK_ID}"
if [ ! -f "$TASK_FILE" ]; then
echo "Task file not found: $TASK_FILE" >&2
exit 1
fi
# ── Acquire lock (prevent overlapping runs of the same task) ─────────────────
exec 200>"$LOCK_FILE"
if ! flock -n 200; then
echo "Task $TASK_ID is already running, skipping." >&2
exit 0
fi
# ── Source saved environment ─────────────────────────────────────────────────
# The env file is a snapshot taken by the entrypoint, which runs as root. A
# snapshot written before the entrypoint stopped capturing HOME still carries
# HOME=/root, and `set -a` would apply it to `claude` below — which then finds no
# credential under /root/.claude and exits with "Not logged in". The env file
# lives on the home volume, so those stale copies outlive an image update until
# the container is restarted; keep our own HOME regardless of what it says.
if [ -f "$ENV_FILE" ]; then
REAL_HOME="${HOME:-/home/claude}"
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
HOME="$REAL_HOME"
fi
# ── Read task definition ────────────────────────────────────────────────────
PROMPT=$(jq -r '.prompt' "$TASK_FILE")
WORKING_DIR=$(jq -r '.working_dir // "/workspace"' "$TASK_FILE")
TASK_NAME=$(jq -r '.name' "$TASK_FILE")
TASK_TYPE=$(jq -r '.type' "$TASK_FILE")
# ── Resolve permission mode ─────────────────────────────────────────────────
# TRIPLE_C_PERMISSION_MODE is injected into the container by Triple-C from the
# project's permission setting. Keep this mapping in sync with
# PermissionMode::cli_args() in app/src-tauri/src/models/project.rs.
# NOTE: headless `claude -p` runs cannot answer a permission prompt, so any
# mode other than "bypass" means the task may stop early when Claude Code asks
# for permission. In "auto", classifier-blocked actions are denied instead of
# prompted, but if auto mode is unavailable for the session's model/backend,
# Claude Code falls back to prompting and the same stall applies.
# Unset or unrecognized values pass no flag (Claude's default).
PERMISSION_ARGS=()
case "${TRIPLE_C_PERMISSION_MODE:-}" in
plan) PERMISSION_ARGS=(--permission-mode plan) ;;
acceptEdits) PERMISSION_ARGS=(--permission-mode acceptEdits) ;;
auto) PERMISSION_ARGS=(--permission-mode auto) ;;
bypass) PERMISSION_ARGS=(--dangerously-skip-permissions) ;;
*) PERMISSION_ARGS=() ;;
esac
# ── Prepare log directory ───────────────────────────────────────────────────
TASK_LOG_DIR="${LOGS_DIR}/${TASK_ID}"
mkdir -p "$TASK_LOG_DIR"
TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
LOG_FILE="${TASK_LOG_DIR}/${TIMESTAMP}.log"
# ── Publish run state ───────────────────────────────────────────────────────
# A scheduled run is detached — cron has no terminal, and the app fires it as a
# detached exec — so without this there is no way to tell a task that is still
# thinking from one that died, and a long run reads as a stall. `list`, `status`
# and the app's Automation tab all read this file.
#
# flock above is what actually prevents overlapping runs; this is purely an
# observability record, which is why readers verify the pid rather than trust
# the file. The EXIT trap covers the crash paths (OOM, container stop, SIGTERM)
# that would otherwise leave a task looking like it had been running for days.
mkdir -p "$RUNNING_DIR"
RUN_STATE="${RUNNING_DIR}/${TASK_ID}.json"
trap 'rm -f "$RUN_STATE"' EXIT
jq -n \
--arg pid "$$" \
--arg started "$(date +%s)" \
--arg log "$LOG_FILE" \
--arg name "$TASK_NAME" \
'{pid: ($pid | tonumber), started_epoch: ($started | tonumber), log: $log, name: $name}' \
> "$RUN_STATE"
# ── Execute Claude agent ────────────────────────────────────────────────────
{
echo "=== Task: $TASK_NAME ($TASK_ID) ==="
echo "=== Started: $(date) ==="
echo "=== Working dir: $WORKING_DIR ==="
echo "=== Prompt: $PROMPT ==="
echo "=== Permission mode: ${TRIPLE_C_PERMISSION_MODE:-default} ==="
echo ""
} > "$LOG_FILE"
EXIT_CODE=0
if [ -d "$WORKING_DIR" ]; then
cd "$WORKING_DIR"
# ${arr[@]+"${arr[@]}"} keeps an empty array safe under `set -u`
claude -p "$PROMPT" ${PERMISSION_ARGS[@]+"${PERMISSION_ARGS[@]}"} >> "$LOG_FILE" 2>&1 || EXIT_CODE=$?
else
echo "Error: working directory '$WORKING_DIR' does not exist" >> "$LOG_FILE"
EXIT_CODE=1
fi
{
echo ""
echo "=== Finished: $(date) ==="
echo "=== Exit code: $EXIT_CODE ==="
} >> "$LOG_FILE"
# ── Cap the size of this run's log ──────────────────────────────────────────
# `claude -p` output is unbounded — a task told to walk a large tree can emit
# hundreds of megabytes in one run — and the pruning below counts *files*, not
# bytes, so twenty logs of any size are twenty logs. One chatty task can
# therefore fill the home volume, which is also where ~/.claude and the OAuth
# credential live.
#
# The tail is the half worth keeping: `claude -p` writes its answer at the end,
# and the footer just appended carries the exit code that `status` and the app
# both grep for. So an oversize log is rewritten as a marker line plus its last
# MAX_LOG_BYTES rather than being deleted or capped from the front. This runs
# before the notification below so the summary is taken from the capped file.
#
# Best effort throughout: the run's real result is already recorded, so a
# failure here must not change the exit status. Note that `run` may be tailing
# this file — it has already streamed everything up to here, and nothing is
# appended after this point, so replacing the inode is invisible to it.
MAX_LOG_BYTES=$(( 5 * 1024 * 1024 ))
LOG_BYTES=$(wc -c < "$LOG_FILE" 2>/dev/null || echo 0)
if [ "${LOG_BYTES:-0}" -gt "$MAX_LOG_BYTES" ]; then
TRUNC_FILE="${LOG_FILE}.trunc"
if {
echo "=== Log truncated: $(( LOG_BYTES - MAX_LOG_BYTES )) bytes dropped from the start (cap ${MAX_LOG_BYTES} bytes) ==="
tail -c "$MAX_LOG_BYTES" "$LOG_FILE"
} > "$TRUNC_FILE" 2>/dev/null; then
mv -f "$TRUNC_FILE" "$LOG_FILE" 2>/dev/null || rm -f "$TRUNC_FILE"
else
rm -f "$TRUNC_FILE"
fi
fi
# ── Write notification ──────────────────────────────────────────────────────
mkdir -p "$NOTIFICATIONS_DIR"
NOTIFY_FILE="${NOTIFICATIONS_DIR}/${TASK_ID}_${TIMESTAMP}.notify"
if [ $EXIT_CODE -eq 0 ]; then
STATUS="SUCCESS"
else
STATUS="FAILED (exit code $EXIT_CODE)"
fi
# Extract a summary (last 10 meaningful lines before the footer)
SUMMARY=$(grep -v "^===" "$LOG_FILE" | grep -v "^$" | tail -n 10)
cat > "$NOTIFY_FILE" <<NOTIFY
Task: $TASK_NAME ($TASK_ID)
Status: $STATUS
Time: $(date)
Type: $TASK_TYPE
Summary:
$SUMMARY
NOTIFY
# ── One-time task cleanup ───────────────────────────────────────────────────
if [ "$TASK_TYPE" = "once" ]; then
rm -f "$TASK_FILE"
# Rebuild crontab to remove the completed one-time task
/usr/local/bin/triple-c-scheduler list > /dev/null 2>&1 || true
# Direct crontab rebuild (in case scheduler list doesn't trigger it)
TMP_CRON=$(mktemp)
echo "# Triple-C scheduled tasks — managed by triple-c-scheduler" > "$TMP_CRON"
echo "# Do not edit manually; changes will be overwritten." >> "$TMP_CRON"
echo "" >> "$TMP_CRON"
for tf in "$TASKS_DIR"/*.json; do
[ -f "$tf" ] || continue
local_enabled=$(jq -r '.enabled' "$tf")
[ "$local_enabled" = "true" ] || continue
local_schedule=$(jq -r '.schedule' "$tf")
local_id=$(jq -r '.id' "$tf")
echo "$local_schedule /usr/local/bin/triple-c-task-runner $local_id" >> "$TMP_CRON"
done
crontab "$TMP_CRON" 2>/dev/null || true
rm -f "$TMP_CRON"
fi
# ── Prune old logs (keep 20 per task) ───────────────────────────────────────
LOG_COUNT=$(find "$TASK_LOG_DIR" -name "*.log" -type f 2>/dev/null | wc -l)
if [ "$LOG_COUNT" -gt 20 ]; then
find "$TASK_LOG_DIR" -name "*.log" -type f | sort | head -n $((LOG_COUNT - 20)) | xargs rm -f
fi
# ── Reap log dirs of tasks that no longer exist ─────────────────────────────
# `triple-c-scheduler remove` deletes a task's log dir with the task, but a
# one-time task deletes its own task file above, so `remove` can never be run
# for it — nothing knows the id any more — and its directory would sit on the
# home volume forever. This is the sweep for that case.
#
# Deliberately delayed rather than done in the cleanup above: the run that just
# finished has only just written the sole record of itself, `run` and the app's
# Automation tab may still be tailing it, and `logs --id` keeps working for a
# task whose file is gone. So a dir is reaped only once nothing in it has been
# touched for LOG_RETENTION_DAYS, and never while a run is publishing state for
# that id. The sweep rides on task runs, so a container whose only task was
# one-time keeps that one directory until something else runs.
#
# Same paranoia as reap_task_logs() in triple-c-scheduler: the id comes from a
# directory name and is re-validated before it is used to build an `rm -rf`
# path, so no empty or path-bearing name can reach beyond $LOGS_DIR.
LOG_RETENTION_DAYS=7
for ORPHAN_DIR in "$LOGS_DIR"/*/; do
[ -d "$ORPHAN_DIR" ] || continue
ORPHAN_ID=$(basename "$ORPHAN_DIR")
[[ "$ORPHAN_ID" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || continue
[ -f "${TASKS_DIR}/${ORPHAN_ID}.json" ] && continue
[ -f "${RUNNING_DIR}/${ORPHAN_ID}.json" ] && continue
# Anything modified inside the window keeps the whole directory.
[ -n "$(find "$ORPHAN_DIR" -mmin "-$(( LOG_RETENTION_DAYS * 1440 ))" -print -quit 2>/dev/null)" ] && continue
rm -rf -- "${LOGS_DIR:?}/${ORPHAN_ID}"
done
# ── Prune old notifications (keep 50 total) ─────────────────────────────────
NOTIFY_COUNT=$(find "$NOTIFICATIONS_DIR" -name "*.notify" -type f 2>/dev/null | wc -l)
if [ "$NOTIFY_COUNT" -gt 50 ]; then
find "$NOTIFICATIONS_DIR" -name "*.notify" -type f | sort | head -n $((NOTIFY_COUNT - 50)) | xargs rm -f
fi
# Release lock
flock -u 200
rm -f "$LOCK_FILE"
exit $EXIT_CODE