Compare commits

...
Author SHA1 Message Date
jknapp 7265f55f27 Merge pull request 'Fix scheduled tasks failing to authenticate, and show when one is running' (#24) from fix/scheduler-home-clobber into main
Build Container / build-container (push) Successful in 1m52s
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m36s
Build App / build-windows (push) Successful in 5m40s
Build App / build-linux (push) Successful in 7m30s
Build App / create-tag (push) Successful in 11s
Build App / sync-to-github (push) Successful in 17s
Reviewed-on: #24
2026-08-12 13:55:54 +00:00
jknapp 88f2e73474 Merge branch 'main' into fix/scheduler-home-clobber
Build App (Preview) / compute-version (pull_request) Successful in 5s
Build Container / build-container (pull_request) Successful in 34s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-linux (pull_request) Canceled after 0s
Build App (Preview) / prune-previews (pull_request) Canceled after 0s
Build App (Preview) / build-macos (pull_request) Canceled after 21s
Build App (Preview) / build-windows (pull_request) Canceled after 22s
2026-08-12 13:55:37 +00:00
shadow-testandClaude Opus 5 fa4940dd7d Say when a scheduled task is running
Build App (Preview) / compute-version (pull_request) Successful in 7s
Build Container / build-container (pull_request) Successful in 2m53s
Build App (Preview) / create-release (pull_request) Successful in 5s
Build App (Preview) / build-macos (pull_request) Successful in 2m37s
Build App (Preview) / build-windows (pull_request) Successful in 6m2s
Build App (Preview) / build-linux (pull_request) Successful in 6m53s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
A run is detached — cron has no terminal, and the app fires it as a detached
exec — so triggering one and watching the log was indistinguishable from
triggering one that died. Worse, `claude -p` writes its answer in a single
burst at the end, so a healthy run shows nothing but its log header for as
long as it is thinking. The honest reading of the old UI was "it stalled".

triple-c-task-runner now publishes a state file per run (pid, start time, log
path) and removes it from an EXIT trap. flock remains what actually prevents
overlapping runs; this is purely observability, so every reader verifies the
pid rather than trusting the file — a container stopped mid-run cannot fire a
trap, and a task stuck on "running" forever would be a worse lie than no
indicator at all. Stale files are cleared on read.

On top of that:

- `list` grows a status column: "running 4m12s" or "idle".
- `status [--id] [--watch]` answers "is it still going?" directly, with
  elapsed time and the tail of the log when there is any output yet.
- `run` streams the log instead of blocking silently, and refuses to start a
  task that is already running.
- The Automation tab marks a running task, disables its Run now button, and
  polls while anything is in flight — including the second or two between
  firing a run and the runner registering it, which is the exact window that
  used to read as dead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 06:33:49 -07:00
shadow-testandClaude Opus 5 9027fa9ad4 Stop the scheduler handing Claude root's HOME
Build Container / build-container (pull_request) Successful in 1m11s
Every scheduled task failed with "Not logged in · Please run /login" while
the container's OAuth credential sat there, valid, the whole time.

The entrypoint snapshots the environment into ~/.claude/scheduler/.env so
cron jobs get more than cron's minimal env. It runs as root, and HOME was
in the capture list, so the file recorded HOME=/root. The task runner then
sources that file with `set -a`, overwriting the HOME cron gave the job.
`claude -p` looks for its credential under $HOME, finds no /root/.claude,
and exits 1. Logging still worked — SCHEDULER_DIR is expanded before the
sourcing — which is why this presents as a well-formed log of a task that
never authenticated.

Drop HOME from the captured set and write it explicitly instead; cron does
still need one. Then restore HOME across the source in the task runner too:
.env lives on the home volume, so every project created before this ships
keeps a stale copy of it until its container restarts, and the runner is
what has to survive that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 06:13:59 -07:00
jknapp be37723c38 Merge pull request 'Sweep the snapshot commits recreation leaves behind' (#23) from sweep-orphaned-snapshots into main
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m38s
Build App / build-windows (push) Successful in 5m44s
Build App / build-linux (push) Successful in 6m18s
Build App / create-tag (push) Successful in 12s
Build App / sync-to-github (push) Successful in 12s
Reviewed-on: #23
2026-08-12 02:06:24 +00:00
shadow-testandClaude Opus 5 5f990dd28b Sweep the snapshot commits recreation leaves behind
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m40s
Build App (Preview) / build-linux (pull_request) Successful in 5m37s
Build App (Preview) / build-windows (pull_request) Successful in 6m16s
Build App (Preview) / prune-previews (pull_request) Successful in 5s
Every recreation commits the container to triple-c-snapshot-{id}:latest
and moves that tag; the image it pointed at keeps its layers and loses
its name. Nothing deleted those, so they accumulate — measured on one
real host, 7 orphans holding 7.4 GB, three of them from a single day's
work.

`sweep_orphaned_snapshots` removes them, under two conditions that are
the whole safety argument. Untagged: every image the app depends on
carries a tag, so a project's live `:latest` and a migration's
`pre-migration-*` rollback pin cannot match the filter at all. And
labelled `triple-c.managed=true`, which `docker commit` copies from the
container onto the image — the user's own dangling images are not ours
to delete. Removal is unforced on top of that, so Docker refuses while
any container is still built from the image, including the stopped
containers of projects that are not running; those are counted and left
for the next sweep.

It runs after a recreation, which is when the orphan it just made
becomes removable, and after a migration is accepted, which is the
moment dropping the pin turns the pre-migration snapshot into an orphan.
Both detached: this is housekeeping, and a full disk beats a project
that will not start. Each sweep clears every orphan it finds, so
recreations that predate it are cleaned up too.

The label string is now a constant rather than four literals, and a test
pins both filter conditions in place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 19:01:20 -07:00
jknapp 4df59da2d8 Merge pull request #22: Give the env var its value box back, and stop labelling the secret
Build App / compute-version (push) Successful in 18s
Build App / build-macos (push) Successful in 2m47s
Build App / build-windows (push) Successful in 5m35s
Build App / build-linux (push) Successful in 6m47s
Build App / create-tag (push) Successful in 26s
Build App / sync-to-github (push) Successful in 13s
2026-08-11 22:34:30 +00:00
jknapp a72406f0d8 Merge pull request #21: Bring the README back in step with the code, and give it a spine 2026-08-11 22:34:15 +00:00
shadow-testandClaude Opus 5 9b2f4fe79f Give the env var its value box back, and stop labelling the secret
Build App (Preview) / compute-version (pull_request) Successful in 7s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 2m56s
Build App (Preview) / build-windows (pull_request) Successful in 5m33s
Build App (Preview) / build-linux (pull_request) Successful in 6m47s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
Two separate faults, both reachable from one screenshot of the Global
Environment Variables editor.

The value input was collapsed to a sliver, so a variable looked like it
had lost its value. `inputClass` carries `w-full`, and the `w-2/5` on the
key input did not beat it — class-attribute order is not what resolves
that conflict, stylesheet order is. The key therefore asked for the whole
row, and the value input, whose `flex-1` gives it a basis of 0 and only
the leftover space, got almost nothing. Widths now live on wrapper divs,
where nothing competes with them.

The fingerprint that detects custom-env changes was a plaintext
`KEY=VALUE` join, and it is written as the `triple-c.custom-env-fingerprint`
label. Labels are readable by anything on the host via `docker inspect`,
`docker commit` copies them onto the project's snapshot image, and the
recreation check logs both sides on a mismatch — so an API token set as a
custom variable was published to all three. It is hashed now, exactly as
`triple-c.git-token-hash` already was. Empty stays empty, so "nothing
configured" still reads as an empty label.

Changing the fingerprint format means every project's label mismatches
once: expect a single container recreation per project on next start.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 15:27:19 -07:00
15 changed files with 664 additions and 42 deletions
+11 -1
View File
@@ -1139,13 +1139,23 @@ triple-c-scheduler list # List all tasks
triple-c-scheduler enable --id abc123 # Enable a task
triple-c-scheduler disable --id abc123 # Disable a task
triple-c-scheduler remove --id abc123 # Delete a task
triple-c-scheduler run --id abc123 # Trigger a task immediately
triple-c-scheduler run --id abc123 # Trigger a task now, streaming its log
triple-c-scheduler status # What is running right now, and for how long
triple-c-scheduler status --id abc123 -w # Watch one task until its run finishes
triple-c-scheduler logs --id abc123 # View logs for a task
triple-c-scheduler logs --tail 20 # View last 20 log entries (all tasks)
triple-c-scheduler notifications # View completion notifications
triple-c-scheduler notifications --clear # Clear notifications
```
`list` carries a status column, and the Automation tab marks a task **Running** with
its elapsed time, so a triggered run is visible rather than silent.
Note that a log which has stopped growing is not evidence of a stall: `claude -p`
writes its answer in one go when it finishes, so a healthy run shows nothing but its
header for as long as it is thinking. `status` is what distinguishes a slow run from
a dead one — it reports the run only while the runner's process is genuinely alive.
### Cron Schedule Format
Standard 5-field cron: `minute hour day-of-month month day-of-week`
+8
View File
@@ -133,6 +133,14 @@ forces that).
4. **Stop**: Container halted (its filesystem layer and both named volumes persist)
5. **Restart**: Existing container restarted; if any `triple-c.*` label no longer matches the project's settings, the container is committed to a snapshot image, removed, and recreated from that snapshot — so installed packages survive
6. **Migrate**: The project is moved onto a newer base image without losing its volumes — see below
Each recreation moves the `triple-c-snapshot-{projectId}:latest` tag, leaving the image it pointed
at before untagged but still on disk — multiple gigabytes per recreation. `sweep_orphaned_snapshots`
clears those after a recreation and after a migration is accepted. It only ever removes images that
are **both** untagged *and* labelled `triple-c.managed=true`, so a live snapshot tag and a
migration's `pre-migration-*` rollback pin are structurally out of reach, and removal is unforced so
Docker itself refuses while any container — including a stopped project's — is still built from the
image.
7. **Reset**: Container, snapshot image **and both named volumes** all removed, then recreated from the clean base image. `remove_project_volumes` deletes `triple-c-home-{projectId}` and `triple-c-claude-config-{projectId}`, so `~/.claude`, `~/.claude.json`, the OAuth login, installed skills, session transcripts and the scheduler's tasks are all lost.
### Base-Image Migration
+23 -2
View File
@@ -164,6 +164,11 @@ pub struct ScheduledTask {
/// Only known for enabled one-shot tasks (their `at` time). Recurring cron
/// expressions are not evaluated here.
pub next_run: Option<String>,
/// Whether a run is in flight right now, from the runner's state file in
/// `~/.claude/scheduler/running/<id>.json` with its pid verified live.
pub running: bool,
/// When the in-flight run started, ISO 8601 (UTC). `None` unless `running`.
pub running_since: Option<String>,
}
/// A completion notice written by `triple-c-task-runner` after a task ran.
@@ -614,13 +619,25 @@ const SCHEDULER_LIST_SCRIPT: &str = r#"exec 2>/dev/null
set -u
TASKS="$HOME/.claude/scheduler/tasks"
LOGS="$HOME/.claude/scheduler/logs"
RUNNING="$HOME/.claude/scheduler/running"
[ -d "$TASKS" ] || { echo '[]'; exit 0; }
for f in "$TASKS"/*.json; do
[ -f "$f" ] || continue
id=$(jq -r '.id // ""' "$f") || continue
[ -n "$id" ] || id=$(basename "$f" .json)
last=$(find "$LOGS/$id" -name '*.log' -type f -printf '%T@\n' | sort -rn | head -1)
jq -c --arg fallback_id "$id" --arg lr "${last%%.*}" '{
# Live-run state. The pid is checked, not trusted: a container stopped
# mid-run cannot fire the runner's cleanup trap, and a task stuck on
# "running" forever is a worse lie than showing nothing.
started=""
state="$RUNNING/$id.json"
if [ -f "$state" ]; then
pid=$(jq -r '.pid // empty' "$state")
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
started=$(jq -r '.started_epoch // empty' "$state")
fi
fi
jq -c --arg fallback_id "$id" --arg lr "${last%%.*}" --arg started "$started" '{
id: (if (.id // "") == "" then $fallback_id else .id end),
name: (.name // ""),
prompt: (.prompt // ""),
@@ -630,7 +647,8 @@ for f in "$TASKS"/*.json; do
enabled: (.enabled == true),
working_dir: (.working_dir // "/workspace"),
created_at: (.created_at // null),
last_run_epoch: (if $lr == "" then null else ($lr | tonumber) end)
last_run_epoch: (if $lr == "" then null else ($lr | tonumber) end),
running_since_epoch: (if $started == "" then null else ($started | tonumber) end)
}' "$f"
done | jq -s 'sort_by(.name, .id)'
"#;
@@ -673,6 +691,7 @@ struct RawScheduledTask {
working_dir: String,
created_at: Option<String>,
last_run_epoch: Option<i64>,
running_since_epoch: Option<i64>,
}
#[derive(Debug, Deserialize)]
@@ -723,6 +742,8 @@ pub async fn list_scheduled_tasks(
created_at: t.created_at,
last_run: t.last_run_epoch.map(epoch_to_iso),
next_run,
running: t.running_since_epoch.is_some(),
running_since: t.running_since_epoch.map(epoch_to_iso),
}
})
.collect())
@@ -833,6 +833,16 @@ pub async fn confirm_migration(
migration_store::clear_staging(&project_id)?;
migration_store::clear(&project_id)?;
log::info!("Migration confirmed for project {}", project_id);
// Dropping the pin above is what turns the pre-migration image into an
// orphan: it was the only tag holding a multi-gigabyte pre-migration
// snapshot. Accepting the update is therefore the moment to sweep, and
// waiting for the project's next recreation would leave it lying around
// indefinitely.
tauri::async_runtime::spawn(async {
crate::docker::sweep_orphaned_snapshots().await;
});
Ok(())
}
@@ -450,6 +450,18 @@ pub async fn start_project_container(
).await?;
emit_progress(&app_handle, &project_id, "Starting container...");
docker::start_container(&new_id).await?;
// The commit above moved `:latest` and orphaned the image it
// used to point at; the container holding that image open was
// removed a few lines up, so now is when Docker will actually
// let it go. Detached because this is housekeeping and the
// project is already running — and it sweeps every orphan, not
// just this one, so recreations that happened before the sweep
// existed are cleaned up too.
tauri::async_runtime::spawn(async {
docker::sweep_orphaned_snapshots().await;
});
new_id
} else {
emit_progress(&app_handle, &project_id, "Starting container...");
+190 -8
View File
@@ -18,11 +18,12 @@ This container supports scheduled tasks via `triple-c-scheduler`. You can set up
### Commands
- `triple-c-scheduler add --name "NAME" --schedule "CRON" --prompt "TASK"` Add a recurring task
- `triple-c-scheduler add --name "NAME" --at "YYYY-MM-DD HH:MM" --prompt "TASK"` Add a one-time task
- `triple-c-scheduler list` List all scheduled tasks
- `triple-c-scheduler list` List all scheduled tasks, with a running/idle status column
- `triple-c-scheduler remove --id ID` Remove a task
- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` Toggle tasks
- `triple-c-scheduler status [--id ID] [--watch]` Show what is running right now, and for how long
- `triple-c-scheduler logs [--id ID] [--tail N]` View execution logs
- `triple-c-scheduler run --id ID` Manually trigger a task immediately
- `triple-c-scheduler run --id ID` Manually trigger a task immediately (streams its log)
- `triple-c-scheduler notifications [--clear]` View or clear completion notifications
### Cron format
@@ -36,7 +37,7 @@ Use `--at "YYYY-MM-DD HH:MM"` instead of `--schedule`. The task automatically re
Use `--working-dir /workspace/project` to set where the task runs (default: /workspace).
### Checking results
After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.
While a task is running, `triple-c-scheduler status` reports it with elapsed time a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.
### Timezone
Scheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used."#;
@@ -211,6 +212,12 @@ pub const SECRET_ENV_KEYS: &[&str] = &[
];
/// Env var name prefixes Triple-C manages itself; users cannot set these by hand.
/// The label every container Triple-C creates carries — and, because
/// `docker commit` copies a container's labels onto the image, every snapshot it
/// commits. [`sweep_orphaned_snapshots`] treats it as the mark of provenance,
/// which is what keeps the sweep away from the user's own images.
const LABEL_MANAGED: &str = "triple-c.managed";
const RESERVED_ENV_PREFIXES: &[&str] = &["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
/// Exact env var names Triple-C manages itself. Not covered by
@@ -318,8 +325,19 @@ fn is_reserved_env_key(key: &str) -> bool {
|| RESERVED_ENV_EXACT.iter().any(|e| upper == *e)
}
/// Compute a fingerprint string for the custom environment variables.
/// Sorted alphabetically so order changes do not cause spurious recreation.
/// Compute a fingerprint for the custom environment variables.
///
/// Sorted alphabetically so order changes do not cause spurious recreation, and
/// **hashed**, because this value is written as the
/// `triple-c.custom-env-fingerprint` label. Labels are readable by anything on
/// the host through `docker inspect`, `docker commit` copies them onto the
/// project's snapshot image, and `container_needs_recreation` logs both sides on
/// a mismatch — so a plaintext `KEY=VALUE` join published every custom
/// variable's *value*, API tokens included, to all three places. Same treatment
/// as `triple-c.git-token-hash`.
///
/// Empty stays empty rather than becoming the hash of the empty string: an empty
/// label is how every other `triple-c.*` key says "nothing configured".
fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
let mut parts: Vec<String> = Vec::new();
for env_var in custom_env_vars {
@@ -330,7 +348,10 @@ fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
parts.push(format!("{}={}", key, env_var.value));
}
parts.sort();
parts.join(",")
if parts.is_empty() {
return String::new();
}
sha256_hex(&parts.join(","))
}
/// The shared Claude Code OAuth token to inject for this project, paired with
@@ -1341,7 +1362,7 @@ pub async fn create_container(
}
let mut labels = HashMap::new();
labels.insert("triple-c.managed".to_string(), "true".to_string());
labels.insert(LABEL_MANAGED.to_string(), "true".to_string());
labels.insert("triple-c.project-id".to_string(), project.id.clone());
labels.insert("triple-c.project-name".to_string(), project.name.clone());
labels.insert("triple-c.backend".to_string(), format!("{:?}", project.backend));
@@ -1689,6 +1710,128 @@ fn env_holds_a_secret(env: &[String]) -> bool {
})
}
/// Outcome of [`sweep_orphaned_snapshots`].
#[derive(Debug, Default, Clone, serde::Serialize)]
pub struct SnapshotSweepReport {
/// Image ids that were removed.
pub removed: Vec<String>,
/// Bytes the removed images accounted for, as Docker reported them. A
/// shared-layer estimate, not a disk-usage measurement.
pub reclaimed_bytes: i64,
/// Orphans Docker refused to delete because a container is still built
/// from them. Normal, not a failure — the next sweep gets them.
pub in_use: usize,
/// Orphans that could not be removed for any other reason, with the error.
pub failed: Vec<(String, String)>,
/// Set when the engine could not be reached or listed at all.
pub unavailable: Option<String>,
}
/// The filter every sweep runs under. Extracted so a test can hold the two
/// conditions in place: **dangling** and **labelled as ours**. Losing either
/// one turns a snapshot sweep into a prune of the user's whole image store.
fn orphan_sweep_filters() -> HashMap<String, Vec<String>> {
HashMap::from([
("dangling".to_string(), vec!["true".to_string()]),
(
"label".to_string(),
vec![format!("{}=true", LABEL_MANAGED)],
),
])
}
/// Remove the untagged snapshot commits left behind by recreation.
///
/// Every recreation commits the container to `triple-c-snapshot-{id}:latest`
/// and moves that tag; the image the tag pointed at before keeps its layers and
/// loses its name. Nothing else deletes those, so a project that has been
/// recreated a dozen times leaves a dozen multi-gigabyte orphans behind.
///
/// Two conditions, and the safety of this whole function rests on them:
///
/// * **Dangling** — untagged. Every image the app relies on carries a tag:
/// `triple-c-snapshot-{id}:latest` is what a project is rebuilt from, and a
/// migration's `pre-migration-*` pin is the only copy of a rollback target.
/// Neither can ever match this filter, so neither can be swept.
/// * **`triple-c.managed=true`** — only images Triple-C itself committed.
/// `docker commit` copies the container's labels onto the image, which is what
/// makes the label a reliable mark of provenance. The user's own dangling
/// images are none of our business.
///
/// Removal is not forced, so Docker refuses (409) while any container is still
/// built from the image — including the stopped containers of projects that are
/// not running. That refusal is the third safety net and it is the daemon's,
/// not ours; those orphans are simply counted and left for a later sweep.
///
/// Never fails the caller: this is housekeeping, and a full disk is a better
/// outcome than a project that will not start.
pub async fn sweep_orphaned_snapshots() -> SnapshotSweepReport {
use bollard::image::ListImagesOptions;
let mut report = SnapshotSweepReport::default();
let docker = match get_docker() {
Ok(d) => d,
Err(e) => {
report.unavailable = Some(e);
return report;
}
};
let images = match docker
.list_images(Some(ListImagesOptions {
all: false,
filters: orphan_sweep_filters(),
..Default::default()
}))
.await
{
Ok(images) => images,
Err(e) => {
report.unavailable = Some(format!("Could not list orphaned snapshots: {}", e));
return report;
}
};
for summary in images {
match docker
.remove_image(
&summary.id,
Some(RemoveImageOptions {
force: false,
noprune: false,
}),
None,
)
.await
{
Ok(_) => {
report.reclaimed_bytes += summary.size;
report.removed.push(summary.id);
}
Err(bollard::errors::Error::DockerResponseServerError {
status_code: 409, ..
}) => {
report.in_use += 1;
}
Err(e) => {
report.failed.push((summary.id, e.to_string()));
}
}
}
if !report.removed.is_empty() || report.in_use > 0 {
log::info!(
"Snapshot sweep: removed {} orphan(s) ({:.2} GB), {} still in use by a container",
report.removed.len(),
report.reclaimed_bytes as f64 / 1_073_741_824.0,
report.in_use
);
}
report
}
/// Outcome of [`scrub_secrets_from_snapshots`], so callers can tell the user
/// what actually happened rather than guessing.
#[derive(Debug, Default, Clone, serde::Serialize)]
@@ -2352,7 +2495,7 @@ pub async fn list_sibling_containers() -> Result<Vec<ContainerSummary>, String>
.into_iter()
.filter(|c| {
if let Some(labels) = &c.labels {
!labels.contains_key("triple-c.managed")
!labels.contains_key(LABEL_MANAGED)
} else {
true
}
@@ -2473,6 +2616,45 @@ mod tests {
assert_eq!(fp, "");
}
#[test]
fn the_orphan_sweep_only_ever_looks_at_our_own_untagged_images() {
// Both conditions are load-bearing. Without `dangling` the sweep would
// match `triple-c-snapshot-{id}:latest` — what every project is rebuilt
// from — and a migration's `pre-migration-*` pin, which is the only copy
// of a rollback target. Without the label it would match every dangling
// image on the user's machine.
let filters = orphan_sweep_filters();
assert_eq!(filters.get("dangling"), Some(&vec!["true".to_string()]));
assert_eq!(
filters.get("label"),
Some(&vec!["triple-c.managed=true".to_string()])
);
assert_eq!(filters.len(), 2, "an extra filter widens or narrows the sweep");
}
#[test]
fn the_custom_env_fingerprint_never_carries_the_value() {
// It goes into `triple-c.custom-env-fingerprint`, which `docker inspect`
// hands to anything on the host, `docker commit` copies onto the
// project's snapshot image, and the recreation check logs on a mismatch.
let secret = "33da01c1b320644920c20d6b5e0a1c6b3c3451c2";
let fp = compute_env_fingerprint(&[EnvVar {
key: "TEA_TOKEN".to_string(),
value: secret.to_string(),
}]);
assert!(!fp.contains(secret), "fingerprint leaked the value: {}", fp);
assert!(!fp.contains("TEA_TOKEN"), "fingerprint leaked the key: {}", fp);
assert_eq!(fp.len(), 64, "expected a sha256 hex digest, got {:?}", fp);
// It still has to move when the value does, or a rotated token would
// never reach the container.
let rotated = compute_env_fingerprint(&[EnvVar {
key: "TEA_TOKEN".to_string(),
value: "rotated".to_string(),
}]);
assert_ne!(fp, rotated);
}
#[test]
fn the_deprecated_small_fast_model_var_is_never_emitted() {
let rendered: Vec<String> = aliases(Some("m"), Some("h"))
+28 -18
View File
@@ -43,26 +43,36 @@ export default function EnvVarsEditor({
</p>
)}
{/* The row's widths live on wrapper divs, not on the inputs. `inputClass`
carries `w-full`, and a width utility on the input itself does not beat
it class-attribute order is not what resolves the conflict, stylesheet
order is. Sizing the key input directly left it asking for the whole row
and collapsed the value input, whose `flex-1` basis of 0 gave it only the
leftover space, to an unusable sliver. */}
{vars.map((ev, i) => (
<div key={i} className="flex gap-2 items-center">
<input
value={ev.key}
onChange={(e) => updateVar(i, "key", e.target.value)}
onBlur={() => onSave(vars)}
placeholder="KEY"
aria-label={`Environment variable ${i + 1} name`}
disabled={disabled}
className={`w-2/5 ${monoInputClass}`}
/>
<input
value={ev.value}
onChange={(e) => updateVar(i, "value", e.target.value)}
onBlur={() => onSave(vars)}
placeholder="value"
aria-label={`Environment variable ${i + 1} value`}
disabled={disabled}
className={`flex-1 ${monoInputClass}`}
/>
<div className="w-2/5 shrink-0">
<input
value={ev.key}
onChange={(e) => updateVar(i, "key", e.target.value)}
onBlur={() => onSave(vars)}
placeholder="KEY"
aria-label={`Environment variable ${i + 1} name`}
disabled={disabled}
className={monoInputClass}
/>
</div>
<div className="flex-1 min-w-0">
<input
value={ev.value}
onChange={(e) => updateVar(i, "value", e.target.value)}
onBlur={() => onSave(vars)}
placeholder="value"
aria-label={`Environment variable ${i + 1} value`}
disabled={disabled}
className={monoInputClass}
/>
</div>
<Button
variant="danger"
disabled={disabled}
@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import AutomationTab from "./AutomationTab";
import type { Project, ScheduledTask } from "../../../lib/types";
const listScheduledTasks = vi.fn(async () => tasks);
const getSchedulerNotifications = vi.fn(async () => []);
const runScheduledTaskNow = vi.fn(async () => "started");
const pushToast = vi.fn();
vi.mock("../../../lib/tauri-commands", () => ({
listScheduledTasks: () => listScheduledTasks(),
getSchedulerNotifications: () => getSchedulerNotifications(),
runScheduledTaskNow: (p: string, t: string) => runScheduledTaskNow(p, t),
clearSchedulerNotifications: vi.fn(async () => {}),
getScheduledTaskLog: vi.fn(async () => ""),
removeScheduledTask: vi.fn(async () => {}),
setScheduledTaskEnabled: vi.fn(async () => {}),
}));
vi.mock("../../../store/appState", () => ({
useAppState: (selector: (s: unknown) => unknown) => selector({ pushToast }),
}));
const project = { id: "p1", name: "api", status: "running" } as unknown as Project;
const baseTask: ScheduledTask = {
id: "a1b2c3d4",
name: "nightly",
prompt: "Run the suite",
schedule: "0 3 * * *",
task_type: "recurring",
at: null,
enabled: true,
working_dir: "/workspace",
created_at: null,
last_run: null,
next_run: null,
running: false,
running_since: null,
};
let tasks: ScheduledTask[] = [];
async function renderTab() {
render(<AutomationTab project={project} />);
await act(async () => {
await Promise.resolve();
});
}
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
tasks = [baseTask];
listScheduledTasks.mockClear();
runScheduledTaskNow.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
describe("AutomationTab run state", () => {
it("offers Run now for an idle task and says nothing about running", async () => {
await renderTab();
expect(screen.getByRole("button", { name: "Run now" })).toBeEnabled();
expect(screen.queryByText(/Running/)).toBeNull();
});
it("shows a running task as running, with elapsed time, and blocks a second trigger", async () => {
const startedSecondsAgo = new Date(Date.now() - 90_000).toISOString();
tasks = [{ ...baseTask, running: true, running_since: startedSecondsAgo }];
await renderTab();
// The whole point: a detached run is visible rather than silent.
expect(screen.getByText(/Running for 1m/)).toBeTruthy();
expect(screen.getByRole("button", { name: "Running…" })).toBeDisabled();
});
it("keeps polling after a trigger, so a run that has not registered yet still appears", async () => {
await renderTab();
const callsAfterLoad = listScheduledTasks.mock.calls.length;
// The runner needs a moment to write its state file; until then the task
// still reads as idle, which is exactly the window that used to look dead.
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Run now" }));
await Promise.resolve();
});
expect(runScheduledTaskNow).toHaveBeenCalledWith("p1", "a1b2c3d4");
tasks = [{ ...baseTask, running: true, running_since: new Date().toISOString() }];
await act(async () => {
vi.advanceTimersByTime(2000);
await Promise.resolve();
});
expect(listScheduledTasks.mock.calls.length).toBeGreaterThan(callsAfterLoad);
expect(screen.getByRole("button", { name: "Running…" })).toBeDisabled();
});
it("stops polling once nothing is running", async () => {
await renderTab();
// No trigger, nothing running: the interval must not be armed at all.
const before = listScheduledTasks.mock.calls.length;
await act(async () => {
vi.advanceTimersByTime(30_000);
await Promise.resolve();
});
expect(listScheduledTasks.mock.calls.length).toBe(before);
});
});
@@ -15,7 +15,7 @@ import Toggle from "../../ui/Toggle";
import Modal from "../../ui/Modal";
import StatusIndicator from "../../ui/StatusIndicator";
import TaskEditorModal from "./TaskEditorModal";
import { formatAge } from "./format";
import { formatAge, formatRunningFor } from "./format";
interface Props {
project: Project;
@@ -59,6 +59,22 @@ export default function AutomationTab({ project }: Props) {
useEffect(load, [load]);
// A task in flight is the one state this view cannot sit still for: runs are
// detached, so without polling "Run now" looks like it did nothing until the
// user reaches for Refresh. Polling stops as soon as nothing is running.
//
// `justTriggered` covers the gap between firing a run and the runner writing
// its state file — a second or two in which the task still reads as idle, and
// where giving up on polling would reproduce the exact silence this fixes.
const anyTaskRunning = tasks.some((t) => t.running);
const [justTriggered, setJustTriggered] = useState(0);
useEffect(() => {
if (!running) return;
if (!anyTaskRunning && Date.now() - justTriggered > 20_000) return;
const timer = setInterval(load, anyTaskRunning ? 5000 : 1500);
return () => clearInterval(timer);
}, [running, anyTaskRunning, justTriggered, load]);
const withTask = async (taskId: string, label: string, fn: () => Promise<unknown>) => {
setBusyTaskId(taskId);
try {
@@ -185,6 +201,12 @@ export default function AutomationTab({ project }: Props) {
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded-[var(--radius-control)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">
{task.task_type}
</span>
{task.running && (
<StatusIndicator
tone="busy"
label={`Running ${formatRunningFor(task.running_since) ?? ""}`.trim()}
/>
)}
</div>
<div className="text-xs text-[var(--text-secondary)] font-mono truncate">
{task.at ?? task.schedule}
@@ -202,14 +224,15 @@ export default function AutomationTab({ project }: Props) {
}
/>
<Button
disabled={busyTaskId === task.id}
disabled={busyTaskId === task.id || task.running}
onClick={() =>
withTask(task.id, "Run now", () =>
runScheduledTaskNow(project.id, task.id),
)
withTask(task.id, "Run now", async () => {
await runScheduledTaskNow(project.id, task.id);
setJustTriggered(Date.now());
})
}
>
Run now
{task.running ? "Running…" : "Run now"}
</Button>
<Button disabled={busyTaskId === task.id} onClick={() => setEditing(task)}>
Edit
@@ -60,6 +60,8 @@ const existingTask: ScheduledTask = {
created_at: null,
last_run: null,
next_run: null,
running: false,
running_since: null,
};
async function renderEditor(task: ScheduledTask | null = null, project = baseProject) {
@@ -26,6 +26,21 @@ export function formatElapsed(ms: number): string {
return `${days}d ago`;
}
/** "for 42s" / "for 4m" / "for 1h 12m" elapsed phrasing for a run in flight.
* Seconds are kept below a minute because the first thing anyone wants from a
* freshly triggered run is evidence that it started at all. */
export function formatRunningFor(iso: string | null | undefined): string | null {
if (!iso) return null;
const started = Date.parse(iso);
if (Number.isNaN(started)) return null;
const seconds = Math.max(0, Math.floor((Date.now() - started) / 1000));
if (seconds < 60) return `for ${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `for ${minutes}m`;
const hours = Math.floor(minutes / 60);
return `for ${hours}h ${minutes % 60}m`;
}
/** Uptime phrasing for a known start timestamp. */
export function formatUptime(startedAtMs: number | undefined): string | null {
if (startedAtMs === undefined) return null;
+4
View File
@@ -388,6 +388,10 @@ export interface ScheduledTask {
last_run: string | null;
/** Known only for enabled one-shot tasks; cron is not evaluated. */
next_run: string | null;
/** A run is in flight right now (the runner's pid was verified live). */
running: boolean;
/** When that run started. Null unless `running`. */
running_since: string | null;
}
/** Mirrors Rust `ScheduleKind` which of the scheduler's two `add` flags to
+10 -1
View File
@@ -434,17 +434,26 @@ chown -R claude:claude "$SCHEDULER_DIR"
cron
# Save environment variables for cron jobs (cron runs with a minimal env)
#
# HOME is deliberately NOT captured here. This entrypoint runs as root, so the
# snapshot would record HOME=/root — and the task runner sources this file with
# `set -a`, which would overwrite the HOME cron gives the job. Claude Code then
# looks for its OAuth credential at /root/.claude/.credentials.json instead of
# /home/claude/.claude/.credentials.json and every scheduled task dies with
# "Not logged in · Please run /login". Cron still needs a HOME, so it is written
# explicitly below with the value the `claude` user actually has.
ENV_FILE="$SCHEDULER_DIR/.env"
: > "$ENV_FILE"
env | while IFS='=' read -r key value; do
case "$key" in
ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|TRIPLE_C_PERMISSION_MODE|PATH|HOME|LANG|TZ|COLORTERM|BROWSER|NODE_EXTRA_CA_CERTS|REQUESTS_CA_BUNDLE|SSL_CERT_FILE)
ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|TRIPLE_C_PERMISSION_MODE|PATH|LANG|TZ|COLORTERM|BROWSER|NODE_EXTRA_CA_CERTS|REQUESTS_CA_BUNDLE|SSL_CERT_FILE)
# Escape single quotes in value and write as KEY='VALUE'
escaped_value=$(printf '%s' "$value" | sed "s/'/'\\\\''/g")
printf "%s='%s'\n" "$key" "$escaped_value" >> "$ENV_FILE"
;;
esac
done
printf "HOME='/home/claude'\n" >> "$ENV_FILE"
chown claude:claude "$ENV_FILE"
chmod 600 "$ENV_FILE"
+180 -6
View File
@@ -8,17 +8,59 @@ 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"
mkdir -p "$TASKS_DIR" "$LOGS_DIR" "$NOTIFICATIONS_DIR" "$RUNNING_DIR"
}
generate_id() {
head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n'
}
# 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
@@ -85,8 +127,9 @@ Commands:
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
run Manually trigger a task now (streams its log)
notifications Show or clear completion notifications
Add options:
@@ -99,6 +142,10 @@ Add options:
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)
@@ -313,8 +360,8 @@ cmd_disable() {
cmd_list() {
local found=false
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "ID" "NAME" "TYPE" "ENABLED" "SCHEDULE" "PROMPT"
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "──────────" "────────────────────" "──────────" "─────────" "────────────────────" "──────────────────────────────"
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
@@ -333,12 +380,21 @@ cmd_list() {
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 %s\n" "$id" "$name" "$type" "$enabled" "$display_schedule" "$prompt"
printf "%-10s %-20s %-10s %-9s %-20s %-12s %s\n" \
"$id" "$name" "$type" "$enabled" "$display_schedule" "$status" "$prompt"
done
if [ "$found" = "false" ]; then
@@ -346,6 +402,78 @@ cmd_list() {
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
@@ -413,8 +541,53 @@ cmd_run() {
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)..."
/usr/local/bin/triple-c-task-runner "$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() {
@@ -464,6 +637,7 @@ case "$command" in
enable) cmd_enable "$@" ;;
disable) cmd_disable "$@" ;;
list) cmd_list ;;
status) cmd_status "$@" ;;
logs) cmd_logs "$@" ;;
run) cmd_run "$@" ;;
notifications) cmd_notifications "$@" ;;
+30
View File
@@ -9,6 +9,7 @@ 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:-}"
@@ -34,11 +35,19 @@ if ! flock -n 200; then
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 ────────────────────────────────────────────────────
@@ -69,6 +78,27 @@ 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) ==="