4 Commits
Author SHA1 Message Date
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
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 7d00390e1f Add scheduled task creation, and stop a bad cron unscheduling everything
Build App / compute-version (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 9m35s
Build App / build-linux (pull_request) Successful in 5m35s
Build App / build-windows (pull_request) Failing after 2m26s
Build App / build-macos (pull_request) Successful in 2m49s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Completes the Automation tab: it could list, toggle, run, log and remove
tasks but not create them, so task creation still meant dropping to the
CLI. Adds add_scheduled_task and update_scheduled_task, plus a task
editor with cron presets and a plain-English reading of the expression.

Every field is free user text, so all of it goes to the scheduler as a
bare argv vector through bollard — no shell, no quoting. Validation is
shape-only rather than metacharacter scrubbing: length caps, no control
characters in single-line fields, no leading-dash name, absolute
working_dir. Verified by round-tripping a prompt containing
`; rm -rf /`, `$(id)`, backticks and newlines: it landed byte-for-byte
in the task JSON with nothing executed.

The scheduler CLI has no `edit`, so update is add-then-remove with the
add first — a rejected edit leaves the original intact. The new id is
surfaced in the editor rather than hidden.

Root-cause fix, and the more serious half of this commit:
triple-c-scheduler never validated --schedule, and rebuild_crontab
regenerates the entire crontab and pipes it to `crontab`, which rejects
the whole file if any line is malformed — with the error thrown away by
`2>/dev/null || true`. A single bad schedule therefore silently
unscheduled every other task in the container while reporting success.
Reproduced directly. It matters because the global CLAUDE.md tells
Claude to drive this CLI, so Claude could trigger it unprompted.

`add` now validates the expression and exits non-zero, and
rebuild_crontab reports a rejected crontab instead of swallowing it,
keeping the offending file for inspection. Verified against the real CLI
in this container: a bad schedule is refused without disturbing an
existing task's crontab entry, and `0 9 * * 1-5`, `*/30 * * * *`,
`0,30 8-17 * * *` and `0 0 1 1 *` are all still accepted. The Rust layer
validates independently, agreeing with vixie cron on 23 probed
expressions including `1/2` and `*/0` being invalid.

121 frontend tests, 44 Rust tests, both builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:20:52 -07:00
shadow-testandClaude Opus 4.6 2e81b52205 Add container-native scheduled task system with timezone support
Build App / build-linux (push) Successful in 2m39s
Build App / build-windows (push) Successful in 3m43s
Build Container / build-container (push) Successful in 16s
Introduces a cron-based scheduler that lets Claude set up recurring and
one-time tasks inside containers. Tasks run as separate Claude Code agents
and persist across container recreation via the named volume.

New files:
- container/triple-c-scheduler: CLI for add/remove/enable/disable/list/logs/run/notifications
- container/triple-c-task-runner: cron wrapper with flock, logging, notifications, auto-cleanup

Key changes:
- Dockerfile: add cron package and COPY both scripts
- entrypoint.sh: timezone setup, cron daemon, crontab restore, env saving
- container.rs: init=true for zombie reaping, TZ env, scheduler instructions, timezone recreation check
- image.rs: embed scheduler scripts in build context
- app_settings.rs + types.ts: timezone field
- settings_commands.rs: detect_host_timezone via iana-time-zone crate
- SettingsPanel.tsx: timezone input with auto-detection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 15:57:22 +00:00