Files
shadow-testandClaude Opus 5 dd2894cc60 Stop Docker disk growth, and fix the Claude Code settings that never worked
Two independent sets of fixes.

## Disk: stop the growth, no UI this round

The dangling-snapshot sweep was already correct and was never the leak. The
leak is that every `docker commit` **stacks** a layer and nothing compacts one:
a file deleted after it has been committed becomes a whiteout, not free bytes.
24 conditions trigger recreation+commit, so changing one settings field costs a
multi-gigabyte layer for the life of the project. One project was measured with
14 stacked commit layers, ~5.1 GB above its base.

* **Scrub the writable layer before every commit** (`docker/container.rs`,
  `SNAPSHOT_SCRUB_PATHS` / `scrub_writable_layer`). The one moment those bytes
  are still free to drop is before the commit that captures them. Measured on
  one container's 4.48 GB pending layer: 3.0 GB of agent scratchpad under
  `/tmp/claude-*`, the terminal drag-drop staging area (256 MiB per file, with
  no `rm` for it anywhere in the repo), a PNG per pasted image, and the apt
  lists/cache/logs that `browser_view/install.rs` and `triple-c-playwright-heal`
  leave behind with no `apt-get clean`. A hardcoded list, never a heuristic:
  `/workspace/{mount_name}` is a host bind mount and nothing here may reach one,
  and the three `/tmp` globs cannot select the read-only `.host-ca`/`.host-aws`
  mounts. Failure is a log line — a scrub must never block a snapshot.

* **Cap container logs** (`capped_log_config`). There was no `LogConfig`
  anywhere, so containers ran on the daemon's unbounded `json-file` default.
  Deliberately *not* wired into `container_needs_recreation`: participating
  would recreate every project once, and a recreation costs a commit, which is
  the thing being fixed. Picked up on the next natural recreation.

* **Make superseded base images sweepable** (`container/Dockerfile`). It carried
  no `LABEL` at all, so `orphan_sweep_filters`' `dangling` + `triple-c.managed`
  pair provably could not match one — ~11.9 GB observed stranded. Stamping
  `triple-c.managed=true` is the whole fix; the sweep needed no change.
  `create_container` writes the new `triple-c.base` key explicitly empty, or
  Docker's label inheritance plus `docker commit` would make every snapshot
  claim to be a base image. `force: false` stays, and now says why.

* **Sweep at startup** (`lib.rs`), not only after recreation: probes first
  (a probe pins an image the unforced sweep then refuses), pins second, sweep
  last. `sweep_orphaned_snapshots_logged` exists because all three callers threw
  the report away — `reclaimed_bytes`, `failed` and `unavailable` included.

* **Reap migration leftovers.** `rollback_migration` retagged and orphaned the
  migrated snapshot with no sweep. Stale `pre-migration-*` pins are now
  age-reaped by scanning the tag pattern rather than trusting the state file —
  `migration_store::load` reports an unparseable record as absent, which
  stranded a 4-12 GB pin nothing could name again; `load` now moves a corrupt
  record aside so `has_record` is trustworthy. A pin whose migration is still
  awaiting confirmation is never reaped at any age. The probe container's
  removal was a plain statement after an await, so a dropped future (an app quit
  mid-migration) leaked a container pinning a multi-gigabyte image; it is a
  `Drop` guard now, with `reap_probe_containers` for the case where the process
  itself dies.

* **Prune scheduler logs.** `remove` deleted a task's JSON but never its log
  directory, and the task runner appended uncapped `claude -p` output.

* **Fix the delete copy.** It said "the container, config volume, and stored
  credentials"; it removes *both* volumes and the snapshot image.

No prune UI, and no unfiltered `prune_images`/`prune_volumes` anywhere — the
daemon is shared with the user's unrelated work.

## Claude Code settings: two invented keys, one inverted default, one sticky bug

Verified against code.claude.com/docs/en/settings-reference.md and env-vars.md.

* `effort` -> **`effortLevel`**, the key Claude Code actually reads; the old one
  was written and silently ignored. `xhigh` added to the dropdown.
* `focusMode` -> **`viewMode: "focus"`**. `focusMode` was invented. The real key
  does exactly what the existing UI hint already described.
* **Session recap was inverted.** Claude Code's recap is on by default, so
  `CLAUDE_CODE_ENABLE_AWAY_SUMMARY=1`-when-enabled was a no-op and the control
  could never turn the recap *off*. The field is renamed to
  `session_recap_disabled` rather than reused: reusing the name with the
  opposite meaning would have read every stored `enable_session_recap: false` —
  which is every project that never touched the control — as "the user turned
  this off".
* **The stickiness, which is the important one.** Keys were emitted only when
  non-default, and the entrypoint *merges* into a settings.json on a persisted
  volume, so switching a setting off omitted its key, the merge preserved the
  stale on-value, and the setting stayed on until a destructive Reset. The fix
  already existed in the same file — the sandbox block is emitted
  unconditionally for exactly this reason — and is now applied to all five keys.
  A key whose neutral state is *unset* (`tui`, `effortLevel`, `viewMode`,
  `awaySummaryEnabled`) is emitted as JSON `null` and the entrypoint deletes it,
  because a stand-in value is not neutral: `tui: "default"` pins the classic
  renderer where unset lets Claude Code choose, and `viewMode: "default"`
  overrides the user's own sticky `/focus` choice.
* The same stickiness existed, unnoticed, in the **env vars**: `docker commit`
  bakes container env into the snapshot image, so a `=1` written once rode it
  forever. All four are now emitted on every create, extracted into
  `claude_code_env_vars` and unit tested. Two use an empty value for "off"
  rather than `0`, because they outrank a setting the user can change from
  inside their own container and Triple-C's default must not overrule a
  `/config` choice it never asked about.
* TUI mode is now a genuine three-way choice (automatic / classic / fullscreen),
  which the always-emitted key makes both necessary and possible.

`merge_claude_code_settings` is untouched by choice: a project-level OFF still
cannot override a globally-ON setting.

Tests: 364 frontend (+5), 308 Rust (+23), covering the scrub path list and
script, log rotation, pin reaping, and that toggling a setting off actually
clears a previously-set ON value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 08:35:10 -07:00

673 lines
21 KiB
Bash

#!/bin/bash
# triple-c-scheduler — CLI for managing scheduled tasks in Triple-C containers
# Tasks are stored as JSON files and crontab is rebuilt from them as the source of truth.
set -euo 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"
# ── Helpers ──────────────────────────────────────────────────────────────────
ensure_dirs() {
mkdir -p "$TASKS_DIR" "$LOGS_DIR" "$NOTIFICATIONS_DIR" "$RUNNING_DIR"
}
generate_id() {
head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n'
}
# Delete a task's log directory, called wherever a task stops existing.
#
# The task file is the only index of a task, so a log directory that outlives
# it is unreachable — `logs --id` needs an id nothing can hand you any more —
# and it sits on the home volume for the life of the project. The moment of
# removal is the last point at which we still know what to delete.
#
# The `rm -rf` deserves paranoia, so the id is re-validated here rather than
# trusted from the caller: the pattern rejects an empty id (which would expand
# to $LOGS_DIR itself), anything containing `/` or `.` (which could climb out
# of $LOGS_DIR), and a leading `-`. It matches validate_task_id() in
# app/src-tauri/src/commands/inspect_commands.rs. Always one literal path,
# never a glob.
reap_task_logs() {
local id="${1:-}"
[[ "$id" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || return 0
local dir="${LOGS_DIR:?}/${id}"
[ -d "$dir" ] || return 0
rm -rf -- "$dir"
}
# Live run state for a task: prints "pid<TAB>started_epoch<TAB>log" and returns
# 0 when the task is genuinely running, returns 1 otherwise.
#
# triple-c-task-runner writes the file and removes it from an EXIT trap, but a
# trap cannot fire for SIGKILL or a container stop mid-run. So the pid is
# checked rather than believed, and a state file whose process is gone is
# cleared here — otherwise one hard stop leaves a task reading as "running"
# forever, which is worse than no indicator at all.
run_state() {
local id="$1"
local state_file="${RUNNING_DIR}/${id}.json"
[ -f "$state_file" ] || return 1
local pid
pid=$(jq -r '.pid // empty' "$state_file" 2>/dev/null)
if [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; then
rm -f "$state_file"
return 1
fi
printf '%s\t%s\t%s\n' \
"$pid" \
"$(jq -r '.started_epoch // 0' "$state_file")" \
"$(jq -r '.log // ""' "$state_file")"
}
# Compact elapsed time since an epoch, e.g. "8s", "4m12s", "1h07m".
elapsed_since() {
local start="$1" now delta
now=$(date +%s)
delta=$(( now - start ))
[ "$delta" -lt 0 ] && delta=0
if [ "$delta" -ge 3600 ]; then
printf '%dh%02dm' $(( delta / 3600 )) $(( (delta % 3600) / 60 ))
elif [ "$delta" -ge 60 ]; then
printf '%dm%02ds' $(( delta / 60 )) $(( delta % 60 ))
else
printf '%ds' "$delta"
fi
}
# Reject a malformed cron expression at the point of entry.
#
# Without this an invalid schedule is written to a task file, and the next
# rebuild hands crontab a file it refuses wholesale — taking every other task
# down with it. Deliberately shape-only: five fields, each built from `*`,
# numbers, ranges, lists and steps. Names (JAN, MON) and `@daily` are not
# accepted here.
validate_cron() {
local expr="$1"
# Exactly five whitespace-separated fields.
read -ra _cron_fields <<< "$expr"
[ "${#_cron_fields[@]}" -eq 5 ] || return 1
local field
for field in "${_cron_fields[@]}"; do
# A field is one or more comma-separated terms; a term is `*`, a number,
# or a range, each optionally followed by a `/step`. Vixie only allows a
# step after `*` or a range, which this mirrors.
[[ "$field" =~ ^(\*(/[0-9]+)?|[0-9]+(-[0-9]+(/[0-9]+)?)?)(,(\*(/[0-9]+)?|[0-9]+(-[0-9]+(/[0-9]+)?)?))*$ ]] || return 1
done
return 0
}
rebuild_crontab() {
local tmp
tmp=$(mktemp)
# Header
echo "# Triple-C scheduled tasks — managed by triple-c-scheduler" > "$tmp"
echo "# Do not edit manually; changes will be overwritten." >> "$tmp"
echo "" >> "$tmp"
for task_file in "$TASKS_DIR"/*.json; do
[ -f "$task_file" ] || continue
local enabled schedule id
enabled=$(jq -r '.enabled' "$task_file")
[ "$enabled" = "true" ] || continue
schedule=$(jq -r '.schedule' "$task_file")
id=$(jq -r '.id' "$task_file")
echo "$schedule /usr/local/bin/triple-c-task-runner $id" >> "$tmp"
done
# `crontab` validates the WHOLE file and rejects all of it if any single
# line is malformed. Swallowing that error silently unschedules every task
# in the container, so report it loudly and leave the previous crontab
# (which `crontab` keeps on rejection) in place.
local crontab_err
if ! crontab_err=$(crontab "$tmp" 2>&1); then
echo "ERROR: crontab rejected the generated schedule; NO tasks are scheduled." >&2
echo " ${crontab_err}" >&2
echo " Offending file kept at ${tmp} for inspection." >&2
echo " Fix or remove the task with the bad schedule, then re-run any" >&2
echo " scheduler command to rebuild." >&2
return 1
fi
rm -f "$tmp"
}
usage() {
cat <<'EOF'
Usage: triple-c-scheduler <command> [options]
Commands:
add Add a new scheduled task
remove Remove a task
enable Enable a disabled task
disable Disable a task
list List all tasks
status Show which tasks are running right now
logs Show execution logs
run Manually trigger a task now (streams its log)
notifications Show or clear completion notifications
Add options:
--name NAME Task name (required)
--prompt "TASK" Task prompt for Claude (required)
--schedule "CRON" Cron schedule expression (for recurring tasks)
--at "DATETIME" Target datetime as "YYYY-MM-DD HH:MM" (for one-time tasks)
--working-dir DIR Working directory (default: /workspace)
Remove/Enable/Disable/Run options:
--id ID Task ID (required)
Status options:
--id ID Show one task, including its last result when idle
--watch, -w Refresh every 5s until the run finishes
Logs options:
--id ID Show logs for a specific task (optional)
--tail N Show last N lines (default: 50)
Notifications options:
--clear Clear all notifications
Examples:
triple-c-scheduler add --name "run-tests" --schedule "*/30 * * * *" --prompt "Run the test suite and report results"
triple-c-scheduler add --name "friday-commit" --at "2026-03-06 16:00" --prompt "Commit all changes with a descriptive message"
triple-c-scheduler list
triple-c-scheduler logs --id a1b2c3d4 --tail 20
triple-c-scheduler run --id a1b2c3d4
EOF
}
# ── Commands ─────────────────────────────────────────────────────────────────
cmd_add() {
local name="" prompt="" schedule="" at="" working_dir="/workspace"
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--prompt) prompt="$2"; shift 2 ;;
--schedule) schedule="$2"; shift 2 ;;
--at) at="$2"; shift 2 ;;
--working-dir) working_dir="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done
if [ -z "$name" ]; then
echo "Error: --name is required" >&2
return 1
fi
if [ -z "$prompt" ]; then
echo "Error: --prompt is required" >&2
return 1
fi
if [ -z "$schedule" ] && [ -z "$at" ]; then
echo "Error: either --schedule or --at is required" >&2
return 1
fi
if [ -n "$schedule" ] && [ -n "$at" ]; then
echo "Error: use either --schedule or --at, not both" >&2
return 1
fi
local id task_type cron_expr
id=$(generate_id)
if [ -n "$at" ]; then
task_type="once"
# Parse "YYYY-MM-DD HH:MM" into cron expression
local year month day hour minute
if ! [[ "$at" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})\ ([0-9]{2}):([0-9]{2})$ ]]; then
echo "Error: --at must be in format 'YYYY-MM-DD HH:MM'" >&2
return 1
fi
year="${BASH_REMATCH[1]}"
month="${BASH_REMATCH[2]}"
day="${BASH_REMATCH[3]}"
hour="${BASH_REMATCH[4]}"
minute="${BASH_REMATCH[5]}"
# Remove leading zeros for cron
month=$((10#$month))
day=$((10#$day))
hour=$((10#$hour))
minute=$((10#$minute))
cron_expr="$minute $hour $day $month *"
else
task_type="recurring"
cron_expr="$schedule"
if ! validate_cron "$cron_expr"; then
echo "Error: invalid cron expression: '$cron_expr'" >&2
echo " Expected five fields: minute hour day-of-month month day-of-week" >&2
echo " e.g. '*/30 * * * *' (every 30 min), '0 9 * * 1-5' (9am weekdays)" >&2
exit 1
fi
fi
local created_at
created_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
local task_json
task_json=$(jq -n \
--arg id "$id" \
--arg name "$name" \
--arg prompt "$prompt" \
--arg schedule "$cron_expr" \
--arg type "$task_type" \
--arg at "$at" \
--arg created_at "$created_at" \
--argjson enabled true \
--arg working_dir "$working_dir" \
'{
id: $id,
name: $name,
prompt: $prompt,
schedule: $schedule,
type: $type,
at: $at,
created_at: $created_at,
enabled: $enabled,
working_dir: $working_dir
}')
echo "$task_json" > "$TASKS_DIR/${id}.json"
rebuild_crontab
echo "Task created:"
echo " ID: $id"
echo " Name: $name"
echo " Type: $task_type"
if [ "$task_type" = "once" ]; then
echo " At: $at"
fi
echo " Schedule: $cron_expr"
echo " Prompt: $prompt"
}
cmd_remove() {
local id=""
while [[ $# -gt 0 ]]; do
case "$1" in
--id) id="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done
if [ -z "$id" ]; then
echo "Error: --id is required" >&2
return 1
fi
local task_file="$TASKS_DIR/${id}.json"
if [ ! -f "$task_file" ]; then
echo "Error: task '$id' not found" >&2
return 1
fi
local name
name=$(jq -r '.name' "$task_file")
rm -f "$task_file"
reap_task_logs "$id"
rebuild_crontab
echo "Removed task '$name' ($id)"
}
cmd_enable() {
local id=""
while [[ $# -gt 0 ]]; do
case "$1" in
--id) id="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done
if [ -z "$id" ]; then
echo "Error: --id is required" >&2
return 1
fi
local task_file="$TASKS_DIR/${id}.json"
if [ ! -f "$task_file" ]; then
echo "Error: task '$id' not found" >&2
return 1
fi
local tmp
tmp=$(mktemp)
jq '.enabled = true' "$task_file" > "$tmp" && mv "$tmp" "$task_file"
rebuild_crontab
local name
name=$(jq -r '.name' "$task_file")
echo "Enabled task '$name' ($id)"
}
cmd_disable() {
local id=""
while [[ $# -gt 0 ]]; do
case "$1" in
--id) id="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done
if [ -z "$id" ]; then
echo "Error: --id is required" >&2
return 1
fi
local task_file="$TASKS_DIR/${id}.json"
if [ ! -f "$task_file" ]; then
echo "Error: task '$id' not found" >&2
return 1
fi
local tmp
tmp=$(mktemp)
jq '.enabled = false' "$task_file" > "$tmp" && mv "$tmp" "$task_file"
rebuild_crontab
local name
name=$(jq -r '.name' "$task_file")
echo "Disabled task '$name' ($id)"
}
cmd_list() {
local found=false
printf "%-10s %-20s %-10s %-9s %-20s %-12s %s\n" "ID" "NAME" "TYPE" "ENABLED" "SCHEDULE" "STATUS" "PROMPT"
printf "%-10s %-20s %-10s %-9s %-20s %-12s %s\n" "──────────" "────────────────────" "──────────" "─────────" "────────────────────" "────────────" "──────────────────────────────"
for task_file in "$TASKS_DIR"/*.json; do
[ -f "$task_file" ] || continue
found=true
local id name type enabled schedule at prompt
id=$(jq -r '.id' "$task_file")
name=$(jq -r '.name' "$task_file")
type=$(jq -r '.type' "$task_file")
enabled=$(jq -r '.enabled' "$task_file")
schedule=$(jq -r '.schedule' "$task_file")
at=$(jq -r '.at // ""' "$task_file")
prompt=$(jq -r '.prompt' "$task_file")
local display_schedule="$schedule"
if [ "$type" = "once" ] && [ -n "$at" ]; then
display_schedule="at $at"
fi
local status state started
if state=$(run_state "$id"); then
started=$(printf '%s' "$state" | cut -f2)
status="running $(elapsed_since "$started")"
else
status="idle"
fi
# Truncate long fields for display
[ ${#name} -gt 20 ] && name="${name:0:17}..."
[ ${#display_schedule} -gt 20 ] && display_schedule="${display_schedule:0:17}..."
[ ${#prompt} -gt 30 ] && prompt="${prompt:0:27}..."
printf "%-10s %-20s %-10s %-9s %-20s %-12s %s\n" \
"$id" "$name" "$type" "$enabled" "$display_schedule" "$status" "$prompt"
done
if [ "$found" = "false" ]; then
echo "No scheduled tasks."
fi
}
# Is anything running, and how far along is it?
#
# This is the command for the question "did my `run` do anything, or has it
# stalled?" — `logs` alone cannot answer it, because a log that stops growing
# looks identical whether Claude is thinking or the run is dead.
cmd_status() {
local id="" watch=false
while [[ $# -gt 0 ]]; do
case "$1" in
--id) id="$2"; shift 2 ;;
--watch|-w) watch=true; shift ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done
while true; do
local any=false
for task_file in "$TASKS_DIR"/*.json; do
[ -f "$task_file" ] || continue
local tid
tid=$(jq -r '.id' "$task_file")
[ -z "$id" ] || [ "$tid" = "$id" ] || continue
local name state
name=$(jq -r '.name' "$task_file")
if state=$(run_state "$tid"); then
any=true
local pid started log
pid=$(printf '%s' "$state" | cut -f1)
started=$(printf '%s' "$state" | cut -f2)
log=$(printf '%s' "$state" | cut -f3)
echo "● RUNNING $name ($tid)"
echo " elapsed: $(elapsed_since "$started") pid: $pid"
echo " log: $log"
# `claude -p` writes its answer in one go at the end, so a log
# with only its header is the normal state of a healthy run —
# print the tail only when there is something to show, rather
# than an empty "last output:" that reads like a stall.
# `|| true` throughout: under `set -e` a grep matching nothing
# would otherwise abort the whole command.
local tail_out=""
if [ -f "$log" ]; then
tail_out=$({ grep -v '^===' "$log" || true; } \
| { grep -v '^$' || true; } | tail -n 3)
fi
if [ -n "$tail_out" ]; then
echo " last output:"
printf '%s\n' "$tail_out" | sed 's/^/ /'
fi
elif [ -n "$id" ]; then
echo "○ idle $name ($tid)"
local latest
latest=$(ls -t "$LOGS_DIR/$tid"/*.log 2>/dev/null | head -1) || true
if [ -n "$latest" ]; then
echo " last run: $(basename "$latest" .log) $(grep -o 'Exit code: [0-9]*' "$latest" | tail -1)"
fi
fi
done
if [ "$any" = "false" ] && [ -z "$id" ]; then
echo "Nothing running."
fi
[ "$watch" = "true" ] || break
# Stop watching once the thing being watched has finished.
[ "$any" = "true" ] || break
sleep 5
echo ""
done
}
cmd_logs() {
local id="" tail_n=50
while [[ $# -gt 0 ]]; do
case "$1" in
--id) id="$2"; shift 2 ;;
--tail) tail_n="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done
if [ -n "$id" ]; then
local log_dir="$LOGS_DIR/$id"
if [ ! -d "$log_dir" ]; then
echo "No logs found for task '$id'"
return 0
fi
# Show the most recent log file
local latest
latest=$(ls -t "$log_dir"/*.log 2>/dev/null | head -1)
if [ -z "$latest" ]; then
echo "No logs found for task '$id'"
return 0
fi
echo "=== Latest log for task $id: $(basename "$latest") ==="
tail -n "$tail_n" "$latest"
else
# Show recent logs across all tasks
local all_logs
all_logs=$(find "$LOGS_DIR" -name "*.log" -type f 2>/dev/null | sort -r | head -n 10)
if [ -z "$all_logs" ]; then
echo "No logs found."
return 0
fi
for log_file in $all_logs; do
local task_id
task_id=$(basename "$(dirname "$log_file")")
echo "=== Task $task_id: $(basename "$log_file") ==="
tail -n 5 "$log_file"
echo ""
done
fi
}
cmd_run() {
local id=""
while [[ $# -gt 0 ]]; do
case "$1" in
--id) id="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done
if [ -z "$id" ]; then
echo "Error: --id is required" >&2
return 1
fi
local task_file="$TASKS_DIR/${id}.json"
if [ ! -f "$task_file" ]; then
echo "Error: task '$id' not found" >&2
return 1
fi
local name
name=$(jq -r '.name' "$task_file")
if run_state "$id" >/dev/null; then
echo "Task '$name' ($id) is already running — see: triple-c-scheduler status --id $id"
return 0
fi
echo "Manually triggering task '$name' ($id)..."
# Run in the background and stream its log. A task can easily think for
# minutes, and the previous behaviour — block with no output until it is
# over — is indistinguishable from a hang.
/usr/local/bin/triple-c-task-runner "$id" &
local runner_pid=$!
local state="" waited=0
while [ "$waited" -lt 20 ]; do
if state=$(run_state "$id"); then
break
fi
kill -0 "$runner_pid" 2>/dev/null || break
sleep 0.5
waited=$(( waited + 1 ))
done
local log=""
[ -n "$state" ] && log=$(printf '%s' "$state" | cut -f3)
if [ -n "$log" ]; then
echo " log: $log"
echo " elsewhere: triple-c-scheduler status --id $id --watch"
echo ""
# --pid stops the follow when the runner exits, so this returns on its own.
tail -n +1 -f --pid="$runner_pid" "$log" 2>/dev/null
fi
local rc=0
wait "$runner_pid" || rc=$?
# A run short enough that its state file was never observed still deserves
# its output shown rather than swallowed.
if [ -z "$log" ]; then
local latest
latest=$(ls -t "$LOGS_DIR/$id"/*.log 2>/dev/null | head -1) || true
[ -n "$latest" ] && tail -n 20 "$latest"
fi
return $rc
}
cmd_notifications() {
local clear=false
while [[ $# -gt 0 ]]; do
case "$1" in
--clear) clear=true; shift ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done
if [ "$clear" = "true" ]; then
rm -f "$NOTIFICATIONS_DIR"/*.notify
echo "Notifications cleared."
return 0
fi
local found=false
for notify_file in $(ls -t "$NOTIFICATIONS_DIR"/*.notify 2>/dev/null); do
[ -f "$notify_file" ] || continue
found=true
cat "$notify_file"
echo "---"
done
if [ "$found" = "false" ]; then
echo "No notifications."
fi
}
# ── Main ─────────────────────────────────────────────────────────────────────
ensure_dirs
if [ $# -eq 0 ]; then
usage
exit 1
fi
command="$1"
shift
case "$command" in
add) cmd_add "$@" ;;
remove) cmd_remove "$@" ;;
enable) cmd_enable "$@" ;;
disable) cmd_disable "$@" ;;
list) cmd_list ;;
status) cmd_status "$@" ;;
logs) cmd_logs "$@" ;;
run) cmd_run "$@" ;;
notifications) cmd_notifications "$@" ;;
help|--help|-h) usage ;;
*)
echo "Unknown command: $command" >&2
usage
exit 1
;;
esac