feat(bash): run daemon shell tasks in systemd scopes so they survive module restarts
Linked issue
Approved issue: #1109
What changes
On Linux, when AFT runs as a daemon module, each non-PTY shell task runs in its own systemd user scope, aft.slice/aft-task-<task_id>.scope. The scope is not in the module cgroup. When the daemon restarts or reloads the module, it stops the module cgroup, but the task scope continues. After the restart, AFT finds the task again and reports its actual exit code and output.
This follows the design in the issue and the decisions in the issue comments:
- Daemon mode only. Standalone mode, PTY tasks, and the native sandbox launcher do not change.
- On by default when a systemd user manager is available. Set
bash.linux_scope: falseto disable it. - A command without its own timeout gets a constant ceiling through
RuntimeMaxSec. The default is 24 hours. Set it with the user-only keybash.linux_scope_max_runtime_secs(1 to 4294967295). A command with a timeout gets that timeout plus 60 seconds. - No new result field and no new notice.
- All scope operations are in one module,
bash_background/task_scope.rs. Thus a cgroup that the daemon owns can replace it in a subsequent change.
Other behavior:
- Launch.
systemd-runstarts from/usr/binor/binonly. On systemd 254 and subsequent versions, the argv has--expand-environment=no(#1134). The argv also has--descriptionwith the task id and a hash of the task store, and--before the payload. The journal records the description, not the command text. If systemd refuses the scope before the payload starts, AFT starts the task without a scope one time. It does not start a payload two times. - Memory limit.
MemoryMaxandMemorySwapMax=0go on the task scope. On systemd 253 and subsequent versions, the scope also getsOOMPolicy=kill. On older systemd, AFT setsmemory.oom.groupitself. If AFT cannot write that file, it stops the task before the payload starts. - Kill. After the usual process-group SIGTERM, grace period, and SIGKILL, AFT writes
cgroup.killon the task scope. This also stops processes that left the process group. The kill uses the cgroup file directly. The user manager is not necessary for it. - Replay. After a restart, AFT uses a live task again only if its start time and its
/proc/<pid>/cgroupmembership agree. If the process of a task stopped and the task has no exit marker, the task staysFateUnknown. AFT does not read a systemd unit result as an exit code. If the user manager is not available, a live task staysRunning, with or without a memory limit. - Cleanup. At startup, AFT stops a leftover task scope only when two conditions are correct. This storage holds a terminal record for that task, and the scope description names the same task store. AFT does not stop a unit that has no record. AFT examines a small number of records at a time, and it ignores records larger than 1 MiB.
- Hook for the drain-reason work.
end_all_task_scopes(&self, storage_dir: &Path) -> Result<usize, String>stops all task scopes that this storage owns. At this time, no code calls it. The drain-reason change in the issue can call it fordisableandshutdown. The function waits on file and D-Bus operations. Thus do not call it from the frame loop or while the code holds the scheduler lock.
Known limit, also in the docs: a command can move its own processes to a different cgroup in the user's tree, for example with systemd-run --user. Then the memory limit, the scope kill, and the time ceiling do not apply to those processes. Before this change, a task in the module cgroup could go out of it with the same procedure. To prevent this, AFT must stop cgroup writes from the task (for example with Landlock or seccomp). That is not in this change. I can open a follow-up issue if you want it.
How it was checked
On Linux with systemd 262 and cgroup v2 (cargo test with isolated storage):
cargo test -p agent-file-tools --lib task_scope_: 55 passed, 0 failed, 0 skipped. The tests that need a user manager ran. They did not skip.task_scope_survives_module_cgroup_killruns a fake module in its own transient scope, starts a task, and writescgroup.killon the module cgroup. The task continues, replays asRunning, and completes with its actual exit code and all its output, delivered one time. With scopes off, the same test fails attask scope missing before module kill.- Mutation checks: 19 mutations of the production code (placement, daemon gate, argv, memory limit, kill, replay identity, cleanup ownership, unreachable manager, nested placement). Each one made a named test fail.
- Full
cargo test -p agent-file-tools --locked: 11426 passed, 15 failed. The same 15 tests also fail on the base commit 70f62ccc6 on this host (memory-limit kernel path, secret-proxy audits, configure home detection, and others). The branch adds no failures. bun run test:unit: 4729 passed, 8 failed. The same 8 also fail on the base commit.cargo check --workspace --all-targets --locked,cargo fmt --check, typecheck, lint,git diff --check: pass.cargo clippy -D warningshas no finding on a changed line.- After a rebase onto current main: no conflicts.
cargo check --all-targets,cargo fmt --check, and the task-scope and memory-limit tests give the same result (80 passed, and the 5 memory-limit kernel tests that also fail on the base commit).
Not checked here: macOS and Windows builds. This host has no toolchain for other targets. The Linux-only code is behind cfg(target_os = "linux") with small stubs for other systems. The Windows -D warnings CI task is the check for that.
![View with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3-3x.png)
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Need help on this PR? Tag @codesmith-bot with what you need.
Summary by cubic
On Linux, daemon-mode bash tasks now run in per-task systemd user scopes (aft.slice/aft-task-<id>.scope) outside the module cgroup, so they survive AFT module restarts and replay with their real exit code and output.
Behavior
- Enabled by default when a systemd user manager is available; set
bash.linux_scope: falseto opt out. Standalone mode, PTY tasks, and the native sandbox launcher are unchanged, and both scope settings are user-only (projects cannot override them). - New user-only
bash.linux_scope_max_runtime_secs(1–4294967295, default 86400) caps commands without their own timeout; commands with a timeout get that timeout plus 60 seconds. Kill messages distinguish the fixed scope ceiling, which waits cannot extend, from the renewable default limit, which worker waits extend only up to that ceiling. - Memory limits and the post-SIGKILL
cgroup.killapply to the scope, so processes that left the process group are still stopped; monitor recovery now fails closed. - After a restart, a task is replayed only when its recorded start time and cgroup membership agree; otherwise it stays
FateUnknown(orRunningwhen the user manager is unreachable). - Startup cleanup stops leftover scopes only when terminal storage and scope identity match, retries unconfirmed stops, and caches launcher probes with retry backoff;
end_all_task_scopesis provided for future drain-reason work but not yet called. - A command can still move its own processes to another cgroup (for example
systemd-run --user), escaping the memory limit, kill, and time ceiling — the same as before this change.