Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .agents/skills/delivery-orchestrator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,9 @@ and `COMPLETE`, `MILESTONE` or `STUCK` when it exits. `MILESTONE` means the
current milestone needs a person: tell the user, and take up its review. Run it in the background where the
harness reports a background command's output as it arrives, and tell the user
about the lines that need them: a pull request to review, a failed lane, a stuck
set. Merging stays with the user; the loop never merges.
set. Where it does not, add `--exit-on-human`: the command ends (exit 4) when a
lane publishes or fails, as it already does for `MILESTONE`, `STUCK` and
`COMPLETE`, so its end is the notification. Tell the user, then run it again. Merging stays with the user; the loop never merges.

The full lifecycle, the limits and what to do when a lane does not finish are in
[lanes](references/lanes.md). The review budget and the two lenses worth running
Expand Down
16 changes: 15 additions & 1 deletion .agents/skills/delivery-orchestrator/scripts/follow.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ Options:
--summary-every <s> Seconds between WAITING summaries (default: 300).
--max-lanes <n> Lanes running at once on this machine (default: 3).
--no-launch Report only; never start a lane.
--exit-on-human Exit as soon as a lane ends (PUBLISHED or LANE-FAILED),
so the end of the command is the notification. Run it
again after telling the user; outcomes already
announced this way do not stop the next run.
--max-polls <n> Stop after n polls (default: no limit).
--log-dir <dir> Lane logs (default: .lanes/logs).
--status-script <path> status.sh from delivery-status (default: beside this skill).
Expand All @@ -53,6 +57,7 @@ Exit codes:
was reached
1 usage error, or task state unreadable five polls in a row
3 stuck: the remaining tasks can never become ready
4 --exit-on-human: a lane ended and a person is expected
USAGE
}

Expand All @@ -61,6 +66,7 @@ interval=30
summary_every=300
max_lanes=3
launch=1
exit_on_human=0
max_polls=0
log_dir=".lanes/logs"
status_script="$here/../../delivery-status/scripts/status.sh"
Expand All @@ -72,6 +78,7 @@ while [ $# -gt 0 ]; do
--summary-every) [ $# -ge 2 ] || die "--summary-every requires a value"; summary_every="$2"; shift 2 ;;
--max-lanes) [ $# -ge 2 ] || die "--max-lanes requires a value"; max_lanes="$2"; shift 2 ;;
--no-launch) launch=0; shift ;;
--exit-on-human) exit_on_human=1; shift ;;
--max-polls) [ $# -ge 2 ] || die "--max-polls requires a value"; max_polls="$2"; shift 2 ;;
--log-dir) [ $# -ge 2 ] || die "--log-dir requires a value"; log_dir="$2"; shift 2 ;;
--status-script) [ $# -ge 2 ] || die "--status-script requires a value"; status_script="$2"; shift 2 ;;
Expand Down Expand Up @@ -146,7 +153,7 @@ while :; do
[ "$failures" -lt 5 ] || exit 1
else
failures=0
rm -rf "$store/now" "$store/ready" "$store/in_flight" "$store/blocked" "$store/review" "$store/running"
rm -rf "$store/now" "$store/ready" "$store/in_flight" "$store/blocked" "$store/review" "$store/running" "$store/human"
mkdir "$store/now"
while IFS="$tab" read -r kind id st _; do
[ "$kind" = TASK ] || continue
Expand Down Expand Up @@ -192,6 +199,9 @@ EOF_END
else
event LANE-FAILED "$id" "$end"
fi
# The log outlives this run, so what a previous run already announced is
# kept beside it; otherwise every restart would stop on the same lane.
[ "$exit_on_human" -eq 0 ] || grep -Fxq -- "$end" "$log_dir/announced" 2>/dev/null || add human "$end"
done
for id in $(items in_flight); do
if [ "$(get now "$id")" = in-review ] || has published "$id"; then add review "$id"; fi
Expand Down Expand Up @@ -259,6 +269,10 @@ EOF_LAUNCHED
event STUCK "$(words blocked)"
exit 3
fi
if [ "$(count human)" -gt 0 ]; then
items human >>"$log_dir/announced"
exit 4
fi

if [ $(($(date +%s) - last_summary)) -ge "$summary_every" ]; then
last_summary=$(date +%s)
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ tagging it.

## [Unreleased]

- `follow.sh --exit-on-human` ends the loop with exit code 4 when a lane
publishes or fails, so any harness learns a person is expected from the
command ending, with no background-output support needed.

## [0.1.0] - 2026-10-23

First public release.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -607,7 +607,9 @@ Options after `--` go to the launcher. Your part is reviewing and merging the
pull requests it reports; it never merges, and it exits once every task is
merged, or when the remaining tasks can never become ready. With milestones, it
stops at the end of each one with a `MILESTONE` line, and never starts the
next. The orchestrator
next. With `--exit-on-human` it also exits (code 4) when a lane publishes or
fails, for a harness that cannot report a background command's output as it
arrives: the command ending is the signal, and the agent runs it again. The orchestrator
starts it for you after the first launch.

### 4. Watch a running lane
Expand Down
66 changes: 66 additions & 0 deletions tests/unit/test_follow_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

COMPLETE = 0
STUCK = 3
HUMAN = 4


def repo_with(tmp_path: Path, tasks: dict[str, str]) -> Path:
Expand Down Expand Up @@ -295,3 +296,68 @@ def test_unreadable_state_is_retried_then_given_up(self, tmp_path: Path) -> None
# Then it reports five errors and gives up
assert result.returncode == 1
assert [e[0] for e in events(result.stdout)] == ["ERROR"] * 5


class TestExitOnHuman:
"""With --exit-on-human the end of the command is the notification."""

def test_a_published_lane_ends_the_command(self, tmp_path: Path) -> None:
# Given a lane that published its pull request, and a ready task
repo = repo_with(tmp_path, {"a-001": "", "a-002": ""})
logs = repo / ".lanes" / "logs"
logs.mkdir(parents=True)
(logs / "a-001.log").write_text("PUBLISHED\ta-001\thttps://example.test/pr/4\n", encoding="utf-8")
status = scripted_status(tmp_path, [{"a-001": "in-progress", "a-002": "ready"}])

# When the loop follows with --exit-on-human
result = follow(repo, status, recording_launcher(tmp_path), "--exit-on-human")

# Then it reports the pull request, still launches the ready task, and exits distinctly
assert result.returncode == HUMAN, result.stdout + result.stderr
assert ["PUBLISHED", "a-001", "https://example.test/pr/4"] in events(result.stdout)
assert launched(tmp_path) == ["a-002"]

def test_a_failed_lane_ends_the_command(self, tmp_path: Path) -> None:
# Given a lane that ended without a pull request
repo = repo_with(tmp_path, {"a-001": ""})
logs = repo / ".lanes" / "logs"
logs.mkdir(parents=True)
(logs / "a-001.log").write_text("KEPT\ta-001\tthe worktree has changes\n", encoding="utf-8")
status = scripted_status(tmp_path, [{"a-001": "in-progress"}])

# When the loop follows with --exit-on-human
result = follow(repo, status, recording_launcher(tmp_path), "--exit-on-human")

# Then it exits distinctly
assert result.returncode == HUMAN
assert events(result.stdout)[0][:2] == ["LANE-FAILED", "a-001"]

def test_a_restart_does_not_stop_on_what_was_announced(self, tmp_path: Path) -> None:
# Given a published lane whose pull request is still a draft
repo = repo_with(tmp_path, {"a-001": ""})
logs = repo / ".lanes" / "logs"
logs.mkdir(parents=True)
(logs / "a-001.log").write_text("PUBLISHED\ta-001\thttps://example.test/pr/4\n", encoding="utf-8")
status = scripted_status(tmp_path, [{"a-001": "in-progress"}])
first = follow(repo, status, recording_launcher(tmp_path), "--exit-on-human")

# When the loop is run again
second = follow(repo, status, recording_launcher(tmp_path), "--exit-on-human", "--max-polls", "2")

# Then only the first run stopped
assert first.returncode == HUMAN
assert second.returncode == COMPLETE

def test_without_the_option_a_published_lane_does_not_stop_the_loop(self, tmp_path: Path) -> None:
# Given a published lane
repo = repo_with(tmp_path, {"a-001": ""})
logs = repo / ".lanes" / "logs"
logs.mkdir(parents=True)
(logs / "a-001.log").write_text("PUBLISHED\ta-001\thttps://example.test/pr/4\n", encoding="utf-8")
status = scripted_status(tmp_path, [{"a-001": "in-progress"}])

# When the loop polls twice
result = follow(repo, status, recording_launcher(tmp_path), "--max-polls", "2")

# Then it ends by its poll limit
assert result.returncode == COMPLETE
Loading