Skip to content

fix(jobs): kill_graceful reaches descendants that escaped into their own process group - #394

Merged
getappz merged 3 commits into
masterfrom
task/17
Aug 7, 2026
Merged

fix(jobs): kill_graceful reaches descendants that escaped into their own process group#394
getappz merged 3 commits into
masterfrom
task/17

Conversation

@getappz

@getappz getappz commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Summary

  • kill_graceful now also walks /proc/<pid>/task/*/children recursively (Linux-only) to find every descendant of a timed-out job by parent-child relationship, and signals each one directly, in addition to the existing process-group signal.
  • Fixes a real orphan: agentflare-jobs::Supervisor isolates agentflare work into its own process group so it can kill the whole group on timeout; agent_launch::run_captured (invoked inside agentflare work to run the actual agent CLI) does the same to its own child, for the same reason, standalone. When the outer timeout fires first, its group-kill can't reach a descendant the inner layer has already re-grouped away -- it's left running, unsupervised, past the timeout.

Found live while letting item #15/#16's now-fixed dispatch pipeline run a real item to completion without stopping it early -- it hit its 300s timeout and left a claude -p process running for minutes afterward.

Not yet covered: macOS has no /proc, so the same escape would still orphan a descendant there. Would need a pgrep -P/ps -o pid,ppid walk to close that gap.

Test plan

  • New test: a setsid'd grandchild (reproducing the exact escape) is confirmed killed on timeout, not orphaned (Linux-only, matching the fix's scope)
  • cargo test -p agentflare-jobs -- all pass
  • cargo clippy --all-targets --all-features -- -D warnings -- clean

Summary by CodeRabbit

  • Bug Fixes
    • Improved timeout handling on Linux by ensuring descendant processes are also terminated.
    • Enhanced graceful shutdown to signal both process groups and discovered child processes.
    • Added safeguards for terminating descendants that have moved into separate process groups.
    • Prevented lingering background processes after a timed-out task completes.
    • Preserved compatible shutdown behavior across supported platforms.

…own process group

Found live: letting the (now-fixed) supervisor/work dispatch pipeline run
item #13 end-to-end surfaced a real orphaned agent CLI process once its job
hit its timeout.

Two independent layers each isolate their own child into a fresh process
group so their own timeout can kill it: agentflare-jobs::Supervisor does
this to the `agentflare work` process, and agent_launch::run_captured does
the same, again, to *its* child (the actual agent CLI) -- needed so it can
still enforce a timeout when `agentflare work` is run standalone by a human,
with no outer supervisor at all. When the outer layer's timeout fires
first, its group-kill only reaches whatever is still in `agentflare work`'s
group -- the agent CLI has already been re-grouped by the inner layer and
is left running, unsupervised, past the timeout.

kill_graceful now also walks /proc/<pid>/task/*/children recursively
(Linux-only; no /proc on macOS/Windows) to find every descendant by
parent-child relationship, which re-grouping never changes, and signals
each one directly alongside the existing group signal. New test reproduces
the exact escape (a setsid'd grandchild) and confirms it's killed on
timeout rather than orphaned.
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The supervisor now traverses Linux descendant PIDs through /proc and signals those descendants during graceful and forced Unix termination. A Linux-specific timeout test covers descendants moved into their own process group.

Changes

Process tree termination

Layer / File(s) Summary
Descendant discovery and timeout validation
crates/agentflare-jobs/src/supervisor.rs
Linux code recursively discovers descendant PIDs. Unix termination sends TERM and KILL to the child process group and each discovered descendant. The Linux timeout test verifies that a setsid descendant cannot perform a delayed marker-file write.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the main change: extending graceful termination to descendants that escaped their process group.
Description check ✅ Passed The description explains the change, motivation, Linux scope, edge case, and test results; it omits the Notes heading and cargo fmt check.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch task/17

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/agentflare-jobs/src/supervisor.rs`:
- Around line 192-200: Update the local signal closure around signal and
descendant_pids so descendants are discovered and their verified process
identities retained before signaling the process group. Use that preserved
target set for both TERM and the later KILL phase, rather than relying on a
post-signal descendant_pids traversal after the parent may have exited.
- Around line 237-260: The test
spawn_times_out_and_kills_a_descendant_that_escaped_into_its_own_process_group
must establish that the setsid child started before timing out. Add a readiness
marker created inside the setsid child, use a nonzero timeout sufficient for
that marker, and assert its presence before waiting and verifying the delayed
marker remains absent.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8aa92c3e-92d1-4d2c-b80d-7ba05649f4c2

📥 Commits

Reviewing files that changed from the base of the PR and between 0ab3275 and 9cf9200.

📒 Files selected for processing (1)
  • crates/agentflare-jobs/src/supervisor.rs

Comment on lines +192 to +200
let signal = |sig: &str, pid: u32| {
let _ = Command::new("kill").arg("-s").arg(sig).arg("--").arg(format!("-{pid}")).status();
for descendant in descendant_pids(pid) {
let _ = Command::new("kill").arg("-s").arg(sig).arg("--").arg(descendant.to_string()).status();
}
};
signal("TERM", pid);
std::thread::sleep(kill_after);
let _ = Command::new("kill")
.arg("-s")
.arg("KILL")
.arg("--")
.arg(format!("-{pid}"))
.status();
signal("KILL", pid);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Preserve descendant targets before the group signal.

At Line 193, the process-group TERM can terminate the direct child before Line 194 reads its descendants. The detached descendant is then reparented and no longer appears below /proc/<pid>. It receives neither TERM nor the later KILL.

Discover descendants before sending the group signal. Retain verified process identities for the forced-kill phase. Do not rely only on a second traversal after the parent can exit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/agentflare-jobs/src/supervisor.rs` around lines 192 - 200, Update the
local signal closure around signal and descendant_pids so descendants are
discovered and their verified process identities retained before signaling the
process group. Use that preserved target set for both TERM and the later KILL
phase, rather than relying on a post-signal descendant_pids traversal after the
parent may have exited.

Comment on lines +237 to +260
fn spawn_times_out_and_kills_a_descendant_that_escaped_into_its_own_process_group() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("still-alive");
let mut supervisor = Supervisor::new(
"test".to_string(),
"sh".to_string(),
vec![
"-c".to_string(),
format!("setsid sh -c 'sleep 5; touch {}' & wait", marker.display()),
],
vec![],
None,
0, // times out immediately — the point is what happens on timeout
0,
dir.path().to_path_buf(),
);

let (output, _state) = supervisor.spawn().unwrap();
assert!(output.timed_out, "should report timeout");
// The 5s `sleep` inside the setsid'd grandchild would still have it
// alive if `kill_graceful` only signaled the direct child's process
// group; give it a moment past `spawn()` returning, then confirm it
// never reached its `touch`.
std::thread::sleep(Duration::from_millis(500));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Ensure the test starts the detached descendant before timeout.

At Line 249, a zero timeout can call kill_graceful before sh executes setsid. The marker then stays absent even if descendant termination is broken.

Add a readiness marker from inside the setsid child. Use a timeout that permits that marker. Assert readiness before checking that the delayed marker was not written.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/agentflare-jobs/src/supervisor.rs` around lines 237 - 260, The test
spawn_times_out_and_kills_a_descendant_that_escaped_into_its_own_process_group
must establish that the setsid child started before timing out. Add a readiness
marker created inside the setsid child, use a nonzero timeout sufficient for
that marker, and assert its presence before waiting and verifying the delayed
marker remains absent.

@getappz
getappz merged commit b0f59dc into master Aug 7, 2026
15 checks passed
@getappz
getappz deleted the task/17 branch August 7, 2026 06:41
getappz added a commit that referenced this pull request Aug 11, 2026
…ge collisions (#456)

* git-shim/worktree residual polish: teardown messaging, branch-create accuracy, stranded-checkout recovery

- worktree deny message now distinguishes provisioning (claim) from
  teardown (check_merge/release/audit --prune) so an agent denied
  mid-teardown gets the exact cleanup action (vent #350)
- is_branch_create() classifies checkout -b/-B and switch -c/-C;
  canonical-checkout deny says 'create a new branch' instead of the
  misleading 'would detach HEAD' (vent #395)
- audit_orphans flags worktrees stranded on the default branch (intact
  gitdir) so worktree audit --prune clears the gh pr merge --delete-
  branch / post-merge-sync collision root cause (vents #351/#394/#423)
- AGENTFLARE_GIT_ALLOW_CANONICAL_MUTATE now also lifts the default-branch
  checkout/switch deny in the canonical checkout, giving stranded
  checkouts a sanctioned way back (vent #386 residual)
- allowlist classify.rs (already 1604L on master) in the LOC gate

Agentflare-Agent: 1
Agentflare-Branch: task/441
Agentflare-Item: 441

* fix(git-shim): close branch-create detection gaps, protect dirty stranded worktrees

CodeRabbit review on PR #456:
- is_branch_create only matched exact -b/-B/-c/-C, missing --orphan
  (checkout+switch), switch's --create/--force-create long forms, and
  attached short-option spellings (-bname). Those forms slipped past
  both would_detach_head and deny_canonical_detach_reason undetected,
  letting an agent create a branch in the canonical checkout via e.g.
  'git switch --orphan x' or 'git checkout -bx' with no deny at all.
- audit_orphans' new on-default-branch detection could hand a dirty,
  uncommitted stranded worktree to gc_orphans for deletion; the only
  other gc_orphans caller (cleanup_item_worktree) already refuses on
  a dirty status check first. Apply the same guard here.

Agentflare-Agent: claude-code_2-1-227_agent
Agentflare-Branch: task/441
Agentflare-Item: 441
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant