Skip to content

Commit 027354d

Browse files
Kasper JungeRalphify
authored andcommitted
refactor: flatten nested kill logic in _agent by extracting _try_graceful_group_kill
The _kill_process_group function had 4-5 levels of nesting with nested try/except blocks. Extract the POSIX process-group kill sequence into a dedicated helper that returns success/failure, reducing the main function to a clean "try graceful, fall back to hard kill" pattern. Co-authored-by: Ralphify <noreply@ralphify.co>
1 parent 55facd9 commit 027354d

File tree

4 files changed

+59
-39
lines changed

4 files changed

+59
-39
lines changed

docs/how-it-works.md

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
---
2-
title: How Ralph Loops Work
3-
description: Understand what happens inside each iteration of a ralph loop — command execution, prompt assembly, agent piping, and the self-healing feedback cycle.
4-
keywords: ralph loop, iteration lifecycle, self-healing loop, agent feedback cycle, prompt assembly, autonomous coding architecture
2+
title: How Autonomous AI Coding Loops Work — The Ralph Loop Lifecycle
3+
description: Step-by-step breakdown of what happens inside each ralph loop iteration — command execution, prompt assembly, agent piping, and the self-healing feedback cycle that auto-fixes broken code.
4+
keywords: autonomous coding loop lifecycle, how AI coding agents work, self-healing code loop, AI agent feedback cycle, prompt assembly pipeline, ralph loop architecture, agentic coding workflow
55
---
66

7-
# How it works
7+
# How the ralph loop works
88

9-
This page explains what ralphify does under the hood during each iteration. Understanding the lifecycle helps you write better prompts, debug unexpected behavior, and make informed decisions about commands.
9+
What happens inside each iteration of an autonomous coding loop? This page breaks down the lifecycle — from command execution to prompt assembly to agent piping — so you can write better prompts, debug unexpected behavior, and understand the self-healing feedback cycle.
1010

11-
## The iteration lifecycle
11+
## What happens in each iteration
1212

1313
Every iteration follows the same sequence:
1414

@@ -26,13 +26,13 @@ flowchart TD
2626

2727
Here's what happens at each step.
2828

29-
### 1. Re-read RALPH.md
29+
### 1. Re-read the prompt from disk
3030

3131
The prompt body (everything below the frontmatter) is read from disk **every iteration**. This means you can edit the prompt text — add rules, change the task, adjust constraints — while the loop is running. Changes take effect on the next cycle.
3232

3333
Frontmatter fields (`agent`, `commands`, `args`) are parsed once at startup. To change those, restart the loop.
3434

35-
### 2. Run commands
35+
### 2. Run commands and capture output
3636

3737
Each command defined in the `commands` frontmatter runs in order and captures its output (stdout + stderr). Commands run from the **project root** by default. Commands starting with `./` run relative to the **ralph directory** instead — useful for scripts bundled alongside your `RALPH.md`.
3838

@@ -47,7 +47,7 @@ commands:
4747
timeout: 300 # 5 minutes
4848
```
4949
50-
### 3. Resolve placeholders
50+
### 3. Resolve placeholders with command output
5151
5252
Each `{{ commands.<name> }}` placeholder in the prompt body is replaced with the corresponding command's output. **Only commands referenced by a placeholder appear in the prompt** — if you define a command but don't use `{{ commands.<name> }}` for it, the command still runs every iteration but its output is excluded. This forces you to place data deliberately rather than accidentally dumping everything into the prompt.
5353

@@ -63,15 +63,15 @@ These are useful for iteration-aware prompts — for example, telling the agent
6363

6464
Unmatched placeholders resolve to an empty string — you won't see raw `{{ }}` text in the assembled prompt.
6565

66-
### 4. Assemble the prompt
66+
### 4. Assemble the final prompt
6767

6868
The prompt body (everything below the YAML frontmatter in `RALPH.md`) with all placeholders resolved becomes the fully assembled prompt — a single text string ready for the agent.
6969

7070
HTML comments (`<!-- ... -->`) are stripped during assembly — they never reach the agent. Use them for notes to yourself, like why a rule exists or what to change next. See [Annotate your prompt with HTML comments](writing-prompts.md#annotate-your-prompt-with-html-comments) for examples.
7171

7272
By default, ralphify appends a **co-author trailer instruction** to the end of the prompt, asking the agent to include `Co-authored-by: Ralphify <noreply@ralphify.co>` in its commit messages. This gives visibility into which commits were produced by a ralph loop. To disable it, set `credit: false` in the frontmatter.
7373

74-
### 5. Pipe prompt to agent
74+
### 5. Pipe the prompt to the AI agent
7575

7676
The assembled prompt is piped to the agent command via stdin:
7777

@@ -83,11 +83,11 @@ The agent reads the prompt, does work in the current directory (edits files, run
8383

8484
When the agent command starts with `claude`, ralphify automatically adds `--output-format stream-json --verbose` to enable structured streaming. This lets ralphify track agent activity in real time — you don't need to configure this yourself.
8585

86-
### 6. Repeat
86+
### 6. Loop back with fresh context
8787

8888
The loop starts the next iteration from step 1. The RALPH.md is re-read, commands run again with fresh output, and the agent gets a new prompt reflecting the current state of the codebase.
8989

90-
## What gets re-read each iteration
90+
## What changes between iterations
9191

9292
| What | When read | Why it matters |
9393
|---|---|---|
@@ -96,7 +96,7 @@ The loop starts the next iteration from step 1. The RALPH.md is re-read, command
9696
| Frontmatter (`agent`, `commands`, `args`) | Once at startup | Parsed when the loop starts. Restart to pick up changes. |
9797
| User arguments | Once at startup | Passed via CLI flags, constant for the run |
9898

99-
## How prompt assembly looks in practice
99+
## The self-healing feedback loop in action
100100

101101
Here's a concrete example. Given this `RALPH.md`:
102102

@@ -167,7 +167,7 @@ If tests are failing, fix them first.
167167

168168
The agent sees the test failure and the instruction to fix it first. This is the **self-healing feedback loop**: the agent breaks something, the command captures the failure, and the agent sees it in the next iteration.
169169

170-
## Command execution order
170+
## How commands run in sequence
171171

172172
Commands run in the order they appear in the `commands` list in the RALPH.md frontmatter. All commands run regardless of whether earlier commands fail.
173173

@@ -181,7 +181,7 @@ commands:
181181
run: git log --oneline -10
182182
```
183183

184-
## Stop conditions
184+
## When does the loop stop
185185

186186
The loop continues until one of these happens:
187187

docs/llms-full.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2721,7 +2721,7 @@ commands:
27212721
run: scripts/run-tests.sh
27222722
```
27232723

2724-
Commands without a `./` prefix run from the project root, so `scripts/run-tests.sh` resolves to `/scripts/run-tests.sh`. If you want to bundle the script next to your `RALPH.md`, use the `./` prefix instead — see [Working directory](how-it-works.md#2-run-commands) for details.
2724+
Commands without a `./` prefix run from the project root, so `scripts/run-tests.sh` resolves to `/scripts/run-tests.sh`. If you want to bundle the script next to your `RALPH.md`, use the `./` prefix instead — see [Working directory](how-it-works.md#2-run-commands-and-capture-output) for details.
27252725

27262726
### Command always failing
27272727

docs/troubleshooting.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ commands:
315315
run: scripts/run-tests.sh
316316
```
317317

318-
Commands without a `./` prefix run from the project root, so `scripts/run-tests.sh` resolves to `<project-root>/scripts/run-tests.sh`. If you want to bundle the script next to your `RALPH.md`, use the `./` prefix instead — see [Working directory](how-it-works.md#2-run-commands) for details.
318+
Commands without a `./` prefix run from the project root, so `scripts/run-tests.sh` resolves to `<project-root>/scripts/run-tests.sh`. If you want to bundle the script next to your `RALPH.md`, use the `./` prefix instead — see [Working directory](how-it-works.md#2-run-commands-and-capture-output) for details.
319319

320320
### Command always failing
321321

src/ralphify/_agent.py

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -57,32 +57,52 @@
5757
)
5858

5959

60+
def _try_graceful_group_kill(proc: subprocess.Popen[Any]) -> bool:
61+
"""Attempt to kill the process via its POSIX process group.
62+
63+
Sends SIGTERM, waits briefly, then escalates to SIGKILL if needed.
64+
Only acts when the process is a session leader (pgid == pid) to avoid
65+
accidentally killing the caller's group in tests.
66+
67+
Returns ``True`` if the group kill succeeded, ``False`` if the caller
68+
should fall back to ``proc.kill()``.
69+
"""
70+
try:
71+
pgid = os.getpgid(proc.pid)
72+
except (OSError, ProcessLookupError):
73+
return False
74+
75+
if pgid != proc.pid:
76+
return False
77+
78+
try:
79+
os.killpg(pgid, signal.SIGTERM)
80+
except (OSError, ProcessLookupError):
81+
return False
82+
83+
try:
84+
proc.wait(timeout=_SIGTERM_GRACE_PERIOD)
85+
return True
86+
except subprocess.TimeoutExpired:
87+
pass
88+
89+
try:
90+
os.killpg(pgid, signal.SIGKILL)
91+
return True
92+
except (OSError, ProcessLookupError):
93+
return False
94+
95+
6096
def _kill_process_group(proc: subprocess.Popen[Any]) -> None:
6197
"""Kill the agent process and its entire process group.
6298
63-
On POSIX, sends SIGTERM then SIGKILL to the process group — but only when
64-
the process is actually a session leader (its pgid equals its pid). This
65-
guard prevents accidentally killing the *caller's* process group when the
66-
child was not started with ``start_new_session=True`` (e.g. in tests).
67-
Falls back to ``proc.kill()`` on Windows or if the group kill fails.
99+
On POSIX, attempts a graceful group kill (SIGTERM → SIGKILL) via
100+
:func:`_try_graceful_group_kill`. Falls back to ``proc.kill()``
101+
on Windows, when the process already exited, or if the group kill fails.
68102
"""
69103
if not IS_WINDOWS and proc.poll() is None:
70-
try:
71-
pgid = os.getpgid(proc.pid)
72-
except (OSError, ProcessLookupError):
73-
pgid = None
74-
75-
if pgid == proc.pid:
76-
try:
77-
os.killpg(pgid, signal.SIGTERM)
78-
try:
79-
proc.wait(timeout=_SIGTERM_GRACE_PERIOD)
80-
return
81-
except subprocess.TimeoutExpired:
82-
os.killpg(pgid, signal.SIGKILL)
83-
return
84-
except (OSError, ProcessLookupError):
85-
pass
104+
if _try_graceful_group_kill(proc):
105+
return
86106
proc.kill()
87107

88108

0 commit comments

Comments
 (0)