Recipe Run Failure Cleanup¶
Status: Shipped — process teardown, the fail-closed recursion-depth guard,
and caller git-state restoration all live in
crates/amplihack-cli/src/commands/recipe/run/execute.rs.
· Issue: rysweet/amplihack-rs#964
· Crate: amplihack-cli · Module: commands::recipe::run
Overview¶
amplihack recipe run launches a recipe-runner-rs child that, for
orchestration recipes (smart-orchestrator, dev-orchestrator), recursively
spawns further agent and recipe subprocesses. When a terminal recipe step
fails, the top-level command must exit non-zero and leave the machine in a
clean, usable state.
Before this change a terminal failure could:
- leak hundreds of orphaned descendants (
recipe-runner-rs,amplihack copilot,node copilot) that kept recursively spawning and were reparented to PID 1, and - leave the originating checkout unusable — the caller's repository was left
with
core.bare=true, sogit statusfailed withthis operation must be run in a work tree.
The failure-cleanup path now guarantees, on any terminal recipe failure:
- Descendant teardown — every process in the runner's session/process group is terminated (landed behavior; see Process teardown).
- Recursion-depth enforcement — a fail-closed guard blocks re-entry into the orchestrator beyond the configured depth before any subprocess is spawned (see Depth guard).
- Caller git-state restoration — the originating checkout's pre-run
core.baresetting and worktree registration are restored, while durable child commits and worktrees are preserved (see Caller git-state restoration). - Explicit reporting — any cleanup failure is surfaced via structured
tracing(never a strayprint!/println!).
All behavior is additive and non-breaking: the happy-path orchestration contract, the PRD, and all public APIs are unchanged. There is no "Bridge" naming anywhere in the feature.
Depth guard¶
The depth guard is a fail-closed recursion limiter. It runs before every
subprocess spawn path in execute.rs, so a failing or misbehaving recipe can
never re-enter the orchestrator and fork-bomb the host.
It reuses the existing session-tree depth convention rather than inventing a
new one: the same AMPLIHACK_SESSION_DEPTH / AMPLIHACK_MAX_DEPTH env vars and
the shared DEFAULT_MAX_DEPTH (3) and MAX_DEPTH_CEILING (32) constants
from commands::session_tree::state (see session_tree::tree_context). The
guard must not fork a second source of truth with a divergent ceiling.
Environment variables¶
| Variable | Meaning | Default | Hard cap |
|---|---|---|---|
AMPLIHACK_SESSION_DEPTH |
Current nesting depth of this session | 0 |
— |
AMPLIHACK_MAX_DEPTH |
Maximum allowed nesting depth | 3 (DEFAULT_MAX_DEPTH) |
32 (MAX_DEPTH_CEILING) |
On each spawn the guard reads AMPLIHACK_SESSION_DEPTH and AMPLIHACK_MAX_DEPTH
and bails before spawning when depth >= max.
Fail-closed parsing rules¶
- Absent
AMPLIHACK_SESSION_DEPTH→ treated as0(top-level session). - Malformed / non-numeric / overflowing
AMPLIHACK_SESSION_DEPTH→ treated as at the maximum (guard bails). It is never silently coerced to0, which would bypass the guard.Note: this is intentionally stricter than the existing
session_tree::tree_context, which parses malformed depth as0(fail-open). The recipe-run guard deliberately fails closed; implementers must not "align" it back to the fail-open behavior. - Absent / malformed
AMPLIHACK_MAX_DEPTH→ falls back to the defaultDEFAULT_MAX_DEPTH(3). - Forged large
AMPLIHACK_MAX_DEPTH(e.g.999999) → clamped to the shared ceilingMAX_DEPTH_CEILING(32, via.min(MAX_DEPTH_CEILING)). This prevents a forged environment from disabling the limit and turning a recursive recipe into a fork bomb / DoS.
Behavior on limit¶
When the limit is reached the guard returns a structured error and no subprocess is spawned:
The error is emitted through tracing with numeric fields only:
Security: the guard logs depths and counts only — never environment-variable values, which may carry session tokens or secrets.
API¶
/// Fail-closed recursion guard for `amplihack recipe run`. Runs at the top of
/// `execute_recipe_via_rust`, before binary lookup or any spawn.
///
/// Reads `AMPLIHACK_SESSION_DEPTH` (current depth, default `0`) and
/// `AMPLIHACK_MAX_DEPTH` (limit, default `DEFAULT_MAX_DEPTH = 3`, clamped to the
/// shared `MAX_DEPTH_CEILING = 32`) and returns `Err(..)` when `depth >= max`,
/// BEFORE any subprocess is spawned. A malformed / non-UTF-8
/// `AMPLIHACK_SESSION_DEPTH` is treated as at-max (fail-closed); a malformed
/// `AMPLIHACK_MAX_DEPTH` falls back to `3`.
fn enforce_recursion_depth_guard() -> anyhow::Result<()>;
Caller git-state restoration¶
Orchestration recipes create child worktrees for their nested tasks. On a
terminal failure the caller's own checkout could be left with core.bare=true
and its worktree registration dropped, making git status fail even though the
source files were still on disk.
The restoration flow:
- Snapshot before spawn — capture the caller checkout's
core.barevalue and worktree registration state into aGitStateSnapshot. - Restore after terminal-failure teardown — once descendants are reaped, best-effort restore the snapshotted state on the caller checkout only.
Guarantees¶
- Runs only on terminal failure, after descendant teardown — never on the happy path.
- Best-effort: a restore failure is logged via
tracing::warn!and never aborts or masks the original failure. - Scoped to the single
core.barekey and the caller checkout. It never touches child.gitregistrations, so durable child commits and worktrees are preserved. - A
Nonesnapshot (nothing captured) is never restored asfalse— absence is distinguished from a captured value. - Paths are canonicalized and
.gitownership is confirmed before writing; symlink / relative-path escapes are rejected. The operation inherits caller privileges only (no elevation).
API¶
/// Pre-run snapshot of the caller checkout's `core.bare` state, captured before
/// spawning the recipe runner.
struct CallerGitState { /* dir + was_git_checkout + core.bare */ }
impl CallerGitState {
/// Capture the caller checkout's `core.bare` before spawning. A no-op
/// (`was_git_checkout = false`) when `dir` is not a git work tree.
fn snapshot(dir: &Path) -> Self;
/// Best-effort restore of the caller checkout to the snapshotted `core.bare`,
/// run only after a terminal failure. Never bails: logs `tracing::error!` on
/// failure and preserves durable child worktrees (config-only).
fn restore_on_failure(&self);
}
Process teardown¶
Descendant teardown is deterministic and scoped strictly to the runner's own process group.
- The runner is spawned as a session leader (
setsidinspawn_with_streaming_stderr), so its PID doubles as the process-group id (pgid) shared by every descendant. - Teardown signals target
-pgidonly vialibc::kill(-pgid, sig)— it never usespkill/killall/ name-based matching, and never touches parent or unrelated processes. - Contract: SIGTERM → grace window → SIGKILL.
terminate_recipe_runnerhandles the timeout path.reap_recipe_runner_groupsweeps orphaned descendants left behind on any non-timeout early-exit (success or failure).- A missing group (
ESRCH) is expected and silent; any other signal failure is logged viatracing::warn!.
Grace window configuration¶
| Variable | Meaning | Default |
|---|---|---|
AMPLIHACK_TEARDOWN_GRACE_SECS |
Whole-second SIGTERM→SIGKILL grace window. 0 escalates to SIGKILL immediately. |
RECIPE_RUNNER_DEFAULT_TEARDOWN_GRACE (5 seconds) |
End-to-end failure sequence¶
amplihack recipe run smart-orchestrator ...
│
├─ enforce_recursion_depth_guard() # fail-closed: bail before spawn if depth >= max
├─ CallerGitState::snapshot(repo) # capture caller core.bare before spawn
├─ spawn recipe-runner-rs (setsid) # session leader; pgid == pid
│ └─ … nested agents / recipes …
│
├─ TERMINAL FAILURE
│ ├─ terminate_recipe_runner / reap_recipe_runner_group # SIGTERM → grace → SIGKILL on -pgid
│ └─ caller_git.restore_on_failure() # best-effort; preserves child worktrees
│
└─ exit non-zero, structured tracing report
Constraints¶
- Additive / non-breaking; the PRD and happy-path orchestration contract are preserved.
- Structured
tracing+ OpenTelemetry only — noprint!/println!/eprintln!in the new code. - No "Bridge" naming.
- Shared OODA-core files (
execute.rs) are modified under the serializedooda-coresequence group to avoid colliding with other core edits.
Testing¶
Regression coverage lives in
crates/amplihack-cli/src/commands/recipe/run/tests_teardown.rs (uses an RAII
env-guard and a stub spawn harness — no real sockets, no real recursion):
| Test area | Asserts |
|---|---|
| Depth-exceeded bail | enforce_recursion_depth_guard bails and no spawn occurs. |
Malformed AMPLIHACK_SESSION_DEPTH |
Fail-closed: treated as at-max, guard bails. |
Forged AMPLIHACK_MAX_DEPTH |
Clamped to shared MAX_DEPTH_CEILING = 32. |
| Git snapshot / restore idempotency | Caller core.bare restored so git status works again on failure. |
| Child-worktree preservation | Durable child worktrees are never removed by restoration. |
| Descendant teardown (verify-only) | Signals target -pgid only; no name-based kill. |