From e4f07e2932ba33485a766ce0c1f87b327f900edd Mon Sep 17 00:00:00 2001 From: Ancelin SERRE Date: Tue, 27 Oct 2026 13:46:33 +0100 Subject: [PATCH] feat: let follow.sh exit when a person is expected --exit-on-human ends the loop with exit code 4 when a lane publishes or fails, so any harness learns of it from the command ending, with no background-output support needed. Outcomes announced by a previous run are recorded beside the lane logs so a restart does not stop on them. Co-Authored-By: Claude Sonnet 5.5 --- .agents/skills/delivery-orchestrator/SKILL.md | 4 +- .../delivery-orchestrator/scripts/follow.sh | 16 ++++- CHANGELOG.md | 4 ++ README.md | 4 +- tests/unit/test_follow_script.py | 66 +++++++++++++++++++ 5 files changed, 91 insertions(+), 3 deletions(-) diff --git a/.agents/skills/delivery-orchestrator/SKILL.md b/.agents/skills/delivery-orchestrator/SKILL.md index 6c8ce14..fe4e354 100644 --- a/.agents/skills/delivery-orchestrator/SKILL.md +++ b/.agents/skills/delivery-orchestrator/SKILL.md @@ -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 diff --git a/.agents/skills/delivery-orchestrator/scripts/follow.sh b/.agents/skills/delivery-orchestrator/scripts/follow.sh index b8c2826..240767b 100644 --- a/.agents/skills/delivery-orchestrator/scripts/follow.sh +++ b/.agents/skills/delivery-orchestrator/scripts/follow.sh @@ -27,6 +27,10 @@ Options: --summary-every Seconds between WAITING summaries (default: 300). --max-lanes 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 Stop after n polls (default: no limit). --log-dir Lane logs (default: .lanes/logs). --status-script status.sh from delivery-status (default: beside this skill). @@ -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 } @@ -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" @@ -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 ;; @@ -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 @@ -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 @@ -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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d9dd53..a637ea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 83edc00..71d5f97 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/tests/unit/test_follow_script.py b/tests/unit/test_follow_script.py index 45f0ff8..ef3ef14 100644 --- a/tests/unit/test_follow_script.py +++ b/tests/unit/test_follow_script.py @@ -15,6 +15,7 @@ COMPLETE = 0 STUCK = 3 +HUMAN = 4 def repo_with(tmp_path: Path, tasks: dict[str, str]) -> Path: @@ -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