From fa1411628893b9c796d980c99053efd9458e84eb Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sat, 14 Nov 2026 15:47:02 +0800 Subject: [PATCH 1/9] feat(hosts): hand a long run off a dsh-superagent worker (#964) --- changelog.d/964-long-run-handoff.md | 6 +++ scripts/dsh-superagent-run.sh | 81 +++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 changelog.d/964-long-run-handoff.md create mode 100644 scripts/dsh-superagent-run.sh diff --git a/changelog.d/964-long-run-handoff.md b/changelog.d/964-long-run-handoff.md new file mode 100644 index 00000000..7c667d31 --- /dev/null +++ b/changelog.d/964-long-run-handoff.md @@ -0,0 +1,6 @@ +Added +`scripts/dsh-superagent-run.sh` hands a long evolution run off a +dsh-superagent worker: it starts the run in a detached `tmux` session +(`setsid` fallback), writes `waiting.json` with the run id, round and run +directory, and returns at once, so a worker turn no longer holds its slot for +the length of the run (#964). diff --git a/scripts/dsh-superagent-run.sh b/scripts/dsh-superagent-run.sh new file mode 100644 index 00000000..487d701d --- /dev/null +++ b/scripts/dsh-superagent-run.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Start a long evolution run outside the worker's process group, then hand it +# back (#964). The worker reports `waiting` and ends its turn; a follow-up +# Trigger, a person or a scheduled check reads the run with `status`/`show`. +# +# dsh-superagent-run.sh --store DIR -- agentdescent evolve spec.json --detach +# +# tmux is preferred because dsh-superagent already depends on it and a session +# is not in the worker's process group, so the kill that ends the worker's +# background job does not reach the run. `setsid` is the fallback: it survives +# that kill, not a reboot -- the run directory, not the process, is what a +# worker hands on. +# +# Exit 0 once the run is detached and DIR/waiting.json is written. It never +# waits for the run: a handoff that blocks is the failure this replaces. +set -euo pipefail + +WAIT="${DSH_RUN_HANDOFF_WAIT:-10}" # seconds to wait for the run's status.json + +die() { echo "error: $*" >&2; exit 2; } +usage() { awk 'NR > 1 && /^#/ {sub(/^# ?/, ""); print; next} NR > 1 {exit}' "$0"; } + +STORE=""; CMD=() +while [ $# -gt 0 ]; do + case "$1" in + --store) [ $# -ge 2 ] || die "--store needs a directory"; STORE="$2"; shift 2 ;; + --) shift; CMD=("$@"); break ;; + -h|--help) usage; exit 0 ;; + *) die "unknown argument: $1 (try --help)" ;; + esac +done +[ -n "$STORE" ] || die "--store is required" +[ ${#CMD[@]} -gt 0 ] || die "nothing to start: give -- " +mkdir -p "$STORE" +STORE="$(cd "$STORE" && pwd)" +command -v python3 >/dev/null || die "python3 is required to write the handoff report" + +LOG="$STORE/worker.log" +SESSION="ad-$(printf '%s' "$(basename "$STORE")" | tr -c 'A-Za-z0-9_.-' '-')" +QUOTED="$(printf '%q ' "${CMD[@]}")" +if command -v tmux >/dev/null 2>&1; then + DRIVER=tmux + tmux new-session -d -s "$SESSION" -c "$PWD" "$QUOTED >> '$LOG' 2>&1" \ + || die "tmux refused the session $SESSION" +elif command -v setsid >/dev/null 2>&1; then + DRIVER=setsid + setsid sh -c "$QUOTED >> '$LOG' 2>&1" >/dev/null 2>&1 & +else + die "neither tmux nor setsid is available; this host cannot hand off a run" +fi + +# The run owns its status.json (`//status.json`, or the store +# itself when the caller owns the run directory). Absent is reported as such: +# an invented run id is worse than a gap a Trigger can see. +python3 - "$STORE" "$WAIT" "$DRIVER" "$SESSION" "$LOG" "$QUOTED" <<'PY' +import glob, json, os, sys, time +store, wait, driver, session, log, command = sys.argv[1:7] +deadline, found = time.time() + float(wait), None +while time.time() < deadline: + paths = [p for p in [os.path.join(store, "status.json")] + + sorted(glob.glob(os.path.join(store, "*", "status.json"))) + if os.path.isfile(p)] + if paths: + found = max(paths, key=os.path.getmtime) + break + time.sleep(0.2) +run_dir = os.path.dirname(found) if found else store +status = json.load(open(found)) if found else {} +report = {"status": "waiting", "found": bool(found), + "run_id": status.get("run_id", "") if found else "", + "round": status.get("round", 0), "state": status.get("state", "unknown"), + "run_dir": run_dir, "driver": driver, + "session": session if driver == "tmux" else "", "log": log, + "command": command.strip()} +path = os.path.join(store, "waiting.json") +with open(path, "w", encoding="utf-8") as fh: + json.dump(report, fh, indent=2, sort_keys=True) + fh.write("\n") +print(f"waiting {report['run_id'] or '(no run yet)'} round {report['round']} {run_dir}") +print(f"report {path}") +PY From fa943cd6f885f3338cbc00bbd7e33c408a545d02 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sat, 14 Nov 2026 15:47:34 +0800 Subject: [PATCH 2/9] docs(hosts): document the long-run handoff and the runs it cannot save (#964) --- docs/dsh-super-agent.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/dsh-super-agent.md b/docs/dsh-super-agent.md index d1ba8066..5ffb2711 100644 --- a/docs/dsh-super-agent.md +++ b/docs/dsh-super-agent.md @@ -118,6 +118,43 @@ Do not start a nested run from inside a run's turn. A worker that evolves a harness which itself starts runs multiplies cost with no ceiling — keep one run per Ticket, and make the second one a follow-up Ticket. +### Handing a run off instead of holding the slot + +A worker's background shell job is killed at 30 minutes; an evolution run is +often longer. Start it outside the worker's process group and give the slot +back: + +```bash +bash scripts/dsh-superagent-run.sh --store "$RUN_STORE" -- \ + agentdescent evolve spec.json --detach --budget 5 +# waiting 3f9c1a… round 2 /runs/3f9c1a… +# report /runs/3f9c1a…/waiting.json +``` + +It starts the command in a detached `tmux` session (`setsid` when tmux is +absent), waits for the run's own `status.json`, writes `waiting.json` beside it +with `run_id`, `round`, `state`, `run_dir`, `driver`, `session` and `log`, and +exits 0 within seconds. Post the run id, the round and the run directory on the +Ticket and end the turn; nothing holds the slot. + +Read it back from a follow-up Trigger, a person, or a scheduled check — the +wrapper adds no daemon and no scheduler: + +```bash +agentdescent status # one line; safe to poll +agentdescent show # the artifact, once there is one +``` + +What the wrapper changes, and what it cannot: + +| failure | what happens | +|---|---| +| the worker's turn ends, or its background job is killed | the run is in the tmux session, not the worker's process group, so it keeps going | +| no tmux on the host (`setsid` fallback) | survives the group kill, not a reboot; the run directory is still the handoff | +| the host restarts | the run directory reads `failed`; `agentdescent resume ` continues it | +| an orphan outlives the Ticket | `agentdescent status` lists it and `waiting.json` names the session, so `tmux kill-session -t ` ends it | +| the budget runs out | the run stops itself and writes why into its status; the follow-up reads it, nobody is waiting | + ## 7. The check ```bash From 43155caafbc2b2e7edae1d09a4893c2ee0c12357 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sat, 14 Nov 2026 15:48:03 +0800 Subject: [PATCH 3/9] test(hosts): pin the handoff command and the detached smoke (#964) --- tests/test_dsh_superagent_run.py | 105 +++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 tests/test_dsh_superagent_run.py diff --git a/tests/test_dsh_superagent_run.py b/tests/test_dsh_superagent_run.py new file mode 100644 index 00000000..a96501f5 --- /dev/null +++ b/tests/test_dsh_superagent_run.py @@ -0,0 +1,105 @@ +"""The long-run handoff (#964): the page's command, and the run outliving a turn. + +Two checks, and neither needs a model: + +* the documented command shape is real -- the wrapper exists and takes the + flags the page passes it, and `agentdescent evolve` still answers `--detach`, + which is the flag the page tells a worker to use; +* gated behind ``AGENTDESCENT_LONG_RUN_SMOKE=1``, a fake run longer than the + caller's own limit survives the caller's turn: the wrapper returns while the + run is still going, the report names the run id and round, and no tmux + session (hence no orphan) is left once it finishes. +""" +import json +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parent.parent +WRAPPER = REPO / "scripts" / "dsh-superagent-run.sh" +PAGE = REPO / "docs" / "dsh-super-agent.md" + +FAKE = ''' +import json, os, sys, time +store = sys.argv[1] +run_dir = os.path.join(store, "fake-run-1") +os.makedirs(run_dir, exist_ok=True) +path = os.path.join(run_dir, "status.json") + +def write(**kw): + with open(path, "w", encoding="utf-8") as fh: + json.dump(kw, fh) + +write(run_id="fake-run-1", round=2, state="running", pid=os.getpid()) +time.sleep(3) +write(run_id="fake-run-1", round=3, state="finished", pid=os.getpid()) +''' + + +def test_the_page_documents_a_wrapper_that_exists_and_takes_its_flags(): + assert WRAPPER.is_file(), f"no {WRAPPER}" + # Invoked as `bash scripts/dsh-superagent-run.sh`, the way the page and + # `scripts/verify-dsh-superagent.sh` (committed 644) are, so the mode is not + # part of the contract; the command shape below is. + page = PAGE.read_text(encoding="utf-8") + assert "scripts/dsh-superagent-run.sh" in page, "the page does not name the wrapper" + assert "--store" in page and "--detach" in page + helped = subprocess.run(["bash", str(WRAPPER), "--help"], cwd=REPO, + capture_output=True, text=True, timeout=60) + assert helped.returncode == 0, helped.stderr + assert "--store" in helped.stdout, "the wrapper's usage omits --store" + evolve = subprocess.run( + [sys.executable, "-m", "agentdescent.cli", "evolve", "--help"], + cwd=REPO, capture_output=True, text=True, timeout=120) + assert evolve.returncode == 0, evolve.stderr + assert "--detach" in evolve.stdout, "the page tells a worker to pass a flag the CLI dropped" + + +@pytest.mark.skipif(os.environ.get("AGENTDESCENT_LONG_RUN_SMOKE") != "1", + reason="set AGENTDESCENT_LONG_RUN_SMOKE=1 to run the detached smoke") +def test_a_run_outlives_the_turn_that_started_it_and_leaves_no_orphan(tmp_path): + store = tmp_path / "runs" + fake = tmp_path / "fake_run.py" + fake.write_text(FAKE, encoding="utf-8") + + started = time.monotonic() + handed = subprocess.run( + ["bash", str(WRAPPER), "--store", str(store), "--", + sys.executable, str(fake), str(store)], + cwd=REPO, capture_output=True, text=True, timeout=60) + elapsed = time.monotonic() - started + + assert handed.returncode == 0, handed.stderr + assert elapsed < 2.0, f"the handoff blocked for {elapsed:.1f}s, so it held the slot" + report = json.loads((store / "waiting.json").read_text(encoding="utf-8")) + assert report["status"] == "waiting" + assert report["found"] is True + assert report["run_id"] == "fake-run-1" + assert report["round"] == 2 + assert Path(report["run_dir"]).name == "fake-run-1" + + # The caller's turn is over and the run is still going: that is the handoff. + pid = json.loads((Path(report["run_dir"]) / "status.json") + .read_text(encoding="utf-8"))["pid"] + os.kill(pid, 0) + + deadline = time.monotonic() + 30 + status = {} + while time.monotonic() < deadline: + status = json.loads((Path(report["run_dir"]) / "status.json") + .read_text(encoding="utf-8")) + if status["state"] == "finished": + break + time.sleep(0.2) + assert status["state"] == "finished", status + assert status["round"] == 3 + + if report["driver"] == "tmux" and shutil.which("tmux"): + left = subprocess.run(["tmux", "has-session", "-t", report["session"]], + capture_output=True, text=True) + assert left.returncode != 0, f"orphan tmux session {report['session']}" From b5b5f807e3913c1e1be649a78d9df6d9df6bae70 Mon Sep 17 00:00:00 2001 From: HugoXK Date: Sat, 14 Nov 2026 11:11:57 +0000 Subject: [PATCH 4/9] docs(hosts): put --store before the verb and cancel the orphan (#964) The read-back command as written read the default store and exited 2, and the table said a --detach run lives in the wrapper's tmux session. It does not: runstore.launch starts it in its own session, so the session holds only the launcher and the orphan remedy is 'agentdescent --store ... cancel ', which signals the run's process group. --- docs/dsh-super-agent.md | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/dsh-super-agent.md b/docs/dsh-super-agent.md index 5ffb2711..d96f6038 100644 --- a/docs/dsh-super-agent.md +++ b/docs/dsh-super-agent.md @@ -138,21 +138,36 @@ exits 0 within seconds. Post the run id, the round and the run directory on the Ticket and end the turn; nothing holds the slot. Read it back from a follow-up Trigger, a person, or a scheduled check — the -wrapper adds no daemon and no scheduler: +wrapper adds no daemon and no scheduler. `--store` is a **global** flag, so it +goes before the verb: after the verb the CLI exits 2 with `unrecognized +arguments`, and without it the run is looked up in the default store +(`~/.agentdescent/runs`, or `$AGENTDESCENT_HOME/runs`), not in the handoff's. ```bash -agentdescent status # one line; safe to poll -agentdescent show # the artifact, once there is one +agentdescent --store "$RUN_STORE" status "$RUN_ID" --brief # one line; safe to poll +agentdescent --store "$RUN_STORE" show "$RUN_ID" # the artifact, once there is one +``` + +Measured against a handoff of a fake run, a second in — `status` keeps it to +one line, and `show` reports the gap instead of inventing a result: + +```console +$ agentdescent --store "$RUN_STORE" status fake-run-1 --brief +fake-run-1 running round 2 calls=0 skill_dir:./skill +$ agentdescent --store "$RUN_STORE" show fake-run-1 +run_id: fake-run-1 +state: running +note: no result yet ``` What the wrapper changes, and what it cannot: | failure | what happens | |---|---| -| the worker's turn ends, or its background job is killed | the run is in the tmux session, not the worker's process group, so it keeps going | +| the worker's turn ends, or its background job is killed | with the `--detach` above, the run is in its own session — not the worker's process group, and not the wrapper's tmux session either, which holds only the launcher — so it keeps going when either ends | | no tmux on the host (`setsid` fallback) | survives the group kill, not a reboot; the run directory is still the handoff | | the host restarts | the run directory reads `failed`; `agentdescent resume ` continues it | -| an orphan outlives the Ticket | `agentdescent status` lists it and `waiting.json` names the session, so `tmux kill-session -t ` ends it | +| an orphan outlives the Ticket | `agentdescent --store "$RUN_STORE" cancel ` sends SIGTERM to the run's process group; `tmux kill-session -t ` only stops the wrapper's own launcher, because a `--detach` run is not in that session | | the budget runs out | the run stops itself and writes why into its status; the follow-up reads it, nobody is waiting | ## 7. The check From f8db0e3c86441efb06c56b9594c5b8b26eaf267e Mon Sep 17 00:00:00 2001 From: HugoXK Date: Sat, 14 Nov 2026 10:55:35 +0000 Subject: [PATCH 5/9] fix(tests): ask tmux for the exact session the handoff named (#964) `tmux has-session -t ad-runs` is a prefix target, so the check could answer about someone else's session: with an unrelated `ad-runs-zzz` on the server it returned 0, and the smoke would then wait on, or report, a session the wrapper never started. `=` asks for that exact name. Local experiment only; nothing pushed. --- tests/test_dsh_superagent_run.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_dsh_superagent_run.py b/tests/test_dsh_superagent_run.py index a96501f5..5014f300 100644 --- a/tests/test_dsh_superagent_run.py +++ b/tests/test_dsh_superagent_run.py @@ -100,6 +100,8 @@ def test_a_run_outlives_the_turn_that_started_it_and_leaves_no_orphan(tmp_path): assert status["round"] == 3 if report["driver"] == "tmux" and shutil.which("tmux"): - left = subprocess.run(["tmux", "has-session", "-t", report["session"]], + # `=` asks for that exact name: a bare target also prefix-matches a + # longer one, so `-t ad-runs` can answer about an unrelated ad-runs-2. + left = subprocess.run(["tmux", "has-session", "-t", "=" + report["session"]], capture_output=True, text=True) assert left.returncode != 0, f"orphan tmux session {report['session']}" From b05c3f9d97ac37b9e3730c8a2d34cdf42710aa4f Mon Sep 17 00:00:00 2001 From: HugoXK Date: Sat, 14 Nov 2026 10:55:35 +0000 Subject: [PATCH 6/9] test(hosts): poll the handoff session instead of a one-shot check (#964) The run writes status.json state=finished before its own process exits, and tmux destroys the session after the pane's command ends, so has-session can still return 0 for a moment: 3/12 replicas of this check read a live run (or a session mid-teardown) as an orphan, and 1/12 still did with a 1s sleep, so the window is longer than a sleep can cover -- 1.7s of I/O wait in one sample. Poll until the session goes, 15s deadline, and put the session name, the wait and the last reading in the failure message so a real orphan is diagnosable. Local experiment only; nothing pushed. --- tests/test_dsh_superagent_run.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/tests/test_dsh_superagent_run.py b/tests/test_dsh_superagent_run.py index 5014f300..d8474415 100644 --- a/tests/test_dsh_superagent_run.py +++ b/tests/test_dsh_superagent_run.py @@ -100,8 +100,23 @@ def test_a_run_outlives_the_turn_that_started_it_and_leaves_no_orphan(tmp_path): assert status["round"] == 3 if report["driver"] == "tmux" and shutil.which("tmux"): - # `=` asks for that exact name: a bare target also prefix-matches a - # longer one, so `-t ad-runs` can answer about an unrelated ad-runs-2. - left = subprocess.run(["tmux", "has-session", "-t", "=" + report["session"]], - capture_output=True, text=True) - assert left.returncode != 0, f"orphan tmux session {report['session']}" + # The run writes `finished` before its own process exits, and tmux + # destroys the session after the pane's command ends, so the session is + # still listed for a moment -- measured ~1.7s on a loaded host. Poll for + # it to go; only a session that stays is an orphan. `=` asks for that + # exact name: a bare target also prefix-matches a longer one, so + # `-t ad-runs` can answer about an unrelated ad-runs-2. + poll_started = time.monotonic() + deadline = poll_started + 15 + left = 0 + while time.monotonic() < deadline: + left = subprocess.run( + ["tmux", "has-session", "-t", "=" + report["session"]], + capture_output=True, text=True).returncode + if left != 0: + break + time.sleep(0.1) + waited = time.monotonic() - poll_started + assert left != 0, ( + f"orphan tmux session {report['session']!r} still listed after " + f"{waited:.1f}s (last `tmux has-session` rc={left})") From 976dc11298eff493ea596502cde69825878f6bb7 Mon Sep 17 00:00:00 2001 From: HugoXK Date: Sun, 15 Nov 2026 05:10:40 +0000 Subject: [PATCH 7/9] test(hosts): pin the handoff's store, a reused store and the session name (#1084) Three rows, all red at b05c3f9d: the page names the wrapper's store but not the run's, a reused store reports its newest earlier run as the handoff, and two handoffs into stores both called `runs` ask tmux for the same session. --- tests/test_dsh_superagent_run.py | 76 ++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/test_dsh_superagent_run.py b/tests/test_dsh_superagent_run.py index d8474415..f5b884cf 100644 --- a/tests/test_dsh_superagent_run.py +++ b/tests/test_dsh_superagent_run.py @@ -41,6 +41,26 @@ def write(**kw): ''' +def _page_handoff_example() -> str: + """The page's fenced example that starts a run through the wrapper.""" + page = PAGE.read_text(encoding="utf-8") + for block in page.split("```"): + if "scripts/dsh-superagent-run.sh" in block: + return block + raise AssertionError("the page has no example that starts the wrapper") + + +def test_the_page_hands_the_run_the_store_the_wrapper_reports_into(): + # Two stores, one variable. The wrapper's `--store` is where the report + # goes; the run's store is the CLI's own global flag. Naming only the + # wrapper's store made `evolve` write to the default store, so the wrapper + # waited out its deadline and reported found: false, and the page's + # read-back could not find the run (#1084 review). + example = " ".join(_page_handoff_example().split()) + assert 'dsh-superagent-run.sh --store "$RUN_STORE" --' in example, example + assert 'agentdescent --store "$RUN_STORE" evolve' in example, example + + def test_the_page_documents_a_wrapper_that_exists_and_takes_its_flags(): assert WRAPPER.is_file(), f"no {WRAPPER}" # Invoked as `bash scripts/dsh-superagent-run.sh`, the way the page and @@ -120,3 +140,59 @@ def test_a_run_outlives_the_turn_that_started_it_and_leaves_no_orphan(tmp_path): assert left != 0, ( f"orphan tmux session {report['session']!r} still listed after " f"{waited:.1f}s (last `tmux has-session` rc={left})") + + +@pytest.mark.skipif(os.environ.get("AGENTDESCENT_LONG_RUN_SMOKE") != "1", + reason="set AGENTDESCENT_LONG_RUN_SMOKE=1 to run the detached smoke") +def test_a_reused_store_does_not_report_an_earlier_run(tmp_path): + # A store is reused for every Ticket, so it always holds earlier runs. The + # first poll used to take the newest `*/status.json` it could see and report + # that run -- a finished one -- as the handoff (#1084 review). Nothing here + # writes a status.json, so the report must say it found nothing. + store = tmp_path / "runs" + old = store / "old-run" + old.mkdir(parents=True) + (old / "status.json").write_text( + json.dumps({"run_id": "old-run", "round": 7, "state": "finished"}), + encoding="utf-8") + handed = subprocess.run( + ["bash", str(WRAPPER), "--store", str(store), "--", "sleep", "1"], + cwd=REPO, capture_output=True, text=True, timeout=60, + env=dict(os.environ, DSH_RUN_HANDOFF_WAIT="1")) + assert handed.returncode == 0, handed.stderr + report = json.loads((store / "waiting.json").read_text(encoding="utf-8")) + assert report["found"] is False, report + assert report["run_id"] == "", report + assert report["round"] == 0, report + assert os.path.realpath(report["run_dir"]) == os.path.realpath(str(store)), report + + +@pytest.mark.skipif(os.environ.get("AGENTDESCENT_LONG_RUN_SMOKE") != "1", + reason="set AGENTDESCENT_LONG_RUN_SMOKE=1 to run the detached smoke") +def test_two_handoffs_into_stores_called_runs_do_not_take_one_session(tmp_path): + # The session name came from the store's basename, so two handoffs into + # stores both called `runs` asked tmux for the same session and the second + # died with "tmux refused the session" (#1084 review). + if not shutil.which("tmux"): + pytest.skip("no tmux on this host: the setsid fallback has no session name") + fake = tmp_path / "fake_run.py" + fake.write_text(FAKE, encoding="utf-8") + reports = [] + for name in ("a", "b"): + store = tmp_path / name / "runs" + handed = subprocess.run( + ["bash", str(WRAPPER), "--store", str(store), "--", + sys.executable, str(fake), str(store)], + cwd=REPO, capture_output=True, text=True, timeout=60) + assert handed.returncode == 0, handed.stderr + reports.append(json.loads((store / "waiting.json").read_text(encoding="utf-8"))) + assert reports[0]["driver"] == "tmux", reports + assert reports[0]["session"] and reports[1]["session"], reports + assert reports[0]["session"] != reports[1]["session"], reports + + deadline = time.monotonic() + 15 + while time.monotonic() < deadline and any( + subprocess.run(["tmux", "has-session", "-t", "=" + report["session"]], + capture_output=True, text=True).returncode == 0 + for report in reports): + time.sleep(0.1) From d4fa76dcfa88ab7e3f17d5544a589c8745ff8e72 Mon Sep 17 00:00:00 2001 From: HugoXK Date: Sun, 15 Nov 2026 05:14:54 +0000 Subject: [PATCH 8/9] fix(hosts): hand the run its store, not just the report (#1084) Three review findings at b05c3f9d. The page gave --store to the wrapper only, so `evolve` wrote to the default store and the report said found: false; the example and the wrapper's own usage now name the CLI's store too, and the page says why the wrapper cannot do it itself. A reused store lent the report its newest earlier run, so the wrapper snapshots the store before launching and accepts only a status.json that appears or changes after that. The tmux session name came from the store's basename, so two handoffs into stores both called `runs` collided; it now carries a per-launch suffix. --- docs/dsh-super-agent.md | 15 ++++++++++- scripts/dsh-superagent-run.sh | 49 ++++++++++++++++++++++++++++++----- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/docs/dsh-super-agent.md b/docs/dsh-super-agent.md index d96f6038..10620487 100644 --- a/docs/dsh-super-agent.md +++ b/docs/dsh-super-agent.md @@ -126,17 +126,30 @@ back: ```bash bash scripts/dsh-superagent-run.sh --store "$RUN_STORE" -- \ - agentdescent evolve spec.json --detach --budget 5 + agentdescent --store "$RUN_STORE" evolve spec.json --detach --budget 5 # waiting 3f9c1a… round 2 /runs/3f9c1a… # report /runs/3f9c1a…/waiting.json ``` +Two stores, one variable: the wrapper's `--store` is where the report goes, and +the run's store is the CLI's own `--store`, so pass the same value on both. The +wrapper cannot see the child's store — give it only to the wrapper and `evolve` +writes to the default store, the wrapper waits out its deadline and reports +`found: false`, and the read-back below cannot find the run. + It starts the command in a detached `tmux` session (`setsid` when tmux is absent), waits for the run's own `status.json`, writes `waiting.json` beside it with `run_id`, `round`, `state`, `run_dir`, `driver`, `session` and `log`, and exits 0 within seconds. Post the run id, the round and the run directory on the Ticket and end the turn; nothing holds the slot. +A store is reused for every Ticket, so it already holds earlier runs. The +wrapper only reports a `status.json` that appears or changes after it launches, +so a handoff into a used store reports `found: false` until its own run writes +one, instead of naming a finished run from last week. The tmux session carries a +per-launch suffix for the same reason: two handoffs into stores both called +`runs` get two sessions, not one shared, refused name. + Read it back from a follow-up Trigger, a person, or a scheduled check — the wrapper adds no daemon and no scheduler. `--store` is a **global** flag, so it goes before the verb: after the verb the CLI exits 2 with `unrecognized diff --git a/scripts/dsh-superagent-run.sh b/scripts/dsh-superagent-run.sh index 487d701d..85e4312b 100644 --- a/scripts/dsh-superagent-run.sh +++ b/scripts/dsh-superagent-run.sh @@ -3,7 +3,11 @@ # back (#964). The worker reports `waiting` and ends its turn; a follow-up # Trigger, a person or a scheduled check reads the run with `status`/`show`. # -# dsh-superagent-run.sh --store DIR -- agentdescent evolve spec.json --detach +# dsh-superagent-run.sh --store DIR -- agentdescent --store DIR evolve spec.json --detach +# +# The wrapper's --store is where the report goes; the run's store is the CLI's +# own --store. Give both the same value, or the run lands in the default store +# and the report says found: false. # # tmux is preferred because dsh-superagent already depends on it and a session # is not in the worker's process group, so the kill that ends the worker's @@ -36,8 +40,31 @@ STORE="$(cd "$STORE" && pwd)" command -v python3 >/dev/null || die "python3 is required to write the handoff report" LOG="$STORE/worker.log" -SESSION="ad-$(printf '%s' "$(basename "$STORE")" | tr -c 'A-Za-z0-9_.-' '-')" +# The store's basename is not unique -- a worker that hands off two runs keeps +# both in a directory called `runs` -- so the session carries a per-launch +# suffix. Two handoffs then never ask tmux for the same name (#1084 review). +SESSION="ad-$(printf '%s' "$(basename "$STORE")" | tr -c 'A-Za-z0-9_.-' '-')-$$-$RANDOM" QUOTED="$(printf '%q ' "${CMD[@]}")" + +# What the store already holds, taken before the launch: a reused store always +# has earlier runs in it, and reporting the newest of those as this handoff is +# worse than reporting nothing (#1084 review). Only a status.json that this +# launch creates or rewrites may be reported. +SNAP="$STORE/.handoff-$$-$RANDOM.json" +trap 'rm -f "$SNAP"' EXIT +python3 - "$STORE" "$SNAP" <<'PY' +import glob, json, os, sys +store, out = sys.argv[1:3] +snap = {} +paths = [os.path.join(store, "status.json")] + sorted( + glob.glob(os.path.join(store, "*", "status.json"))) +for path in paths: + if os.path.isfile(path): + st = os.stat(path) + snap[path] = [st.st_mtime, st.st_size] +with open(out, "w", encoding="utf-8") as fh: + json.dump(snap, fh) +PY if command -v tmux >/dev/null 2>&1; then DRIVER=tmux tmux new-session -d -s "$SESSION" -c "$PWD" "$QUOTED >> '$LOG' 2>&1" \ @@ -52,16 +79,26 @@ fi # The run owns its status.json (`//status.json`, or the store # itself when the caller owns the run directory). Absent is reported as such: # an invented run id is worse than a gap a Trigger can see. -python3 - "$STORE" "$WAIT" "$DRIVER" "$SESSION" "$LOG" "$QUOTED" <<'PY' +python3 - "$STORE" "$WAIT" "$DRIVER" "$SESSION" "$LOG" "$QUOTED" "$SNAP" <<'PY' import glob, json, os, sys, time -store, wait, driver, session, log, command = sys.argv[1:7] +store, wait, driver, session, log, command, snap_path = sys.argv[1:8] +before = json.load(open(snap_path, encoding="utf-8")) deadline, found = time.time() + float(wait), None while time.time() < deadline: paths = [p for p in [os.path.join(store, "status.json")] + sorted(glob.glob(os.path.join(store, "*", "status.json"))) if os.path.isfile(p)] - if paths: - found = max(paths, key=os.path.getmtime) + fresh = [] + for path in paths: + try: + st = os.stat(path) + except OSError: + continue + seen = before.get(path) + if seen is None or [st.st_mtime, st.st_size] != seen: + fresh.append(path) + if fresh: + found = max(fresh, key=os.path.getmtime) break time.sleep(0.2) run_dir = os.path.dirname(found) if found else store From c69767d7f6e49b0cdd4516bebad87d9caf09694e Mon Sep 17 00:00:00 2001 From: HugoXK Date: Sun, 15 Nov 2026 10:40:59 +0000 Subject: [PATCH 9/9] docs(dsh): pair --budget with --usd-per-call in the handoff example (#1084) The page's example ran `evolve spec.json --detach --budget 5` with no price per call, and `runstore.require_budget_pair` refuses that pair: the CLI exits 2 before creating anything, so the documented command could not start a run. The test that was supposed to catch it asserted a substring, so a command that exits 2 passed. It now parses the fenced block, hands the child's argv to the CLI's own parser, and requires the documented pair to satisfy `require_budget_pair` -- so the page and the CLI cannot drift apart again. Also records the measured expectation that one store carries one live handoff: two launched inside the same wait window both read that store's waiting.json. --- docs/dsh-super-agent.md | 13 +++++-- tests/test_dsh_superagent_run.py | 61 ++++++++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/docs/dsh-super-agent.md b/docs/dsh-super-agent.md index 10620487..2e498df0 100644 --- a/docs/dsh-super-agent.md +++ b/docs/dsh-super-agent.md @@ -126,11 +126,17 @@ back: ```bash bash scripts/dsh-superagent-run.sh --store "$RUN_STORE" -- \ - agentdescent --store "$RUN_STORE" evolve spec.json --detach --budget 5 + agentdescent --store "$RUN_STORE" evolve spec.json --detach \ + --budget 5 --usd-per-call 0.01 # waiting 3f9c1a… round 2 /runs/3f9c1a… # report /runs/3f9c1a…/waiting.json ``` +`--budget` needs `--usd-per-call`: the engine counts calls, and a per-call price +is the only way a dollar figure becomes a stop rule. With one and not the other +the CLI exits 2 before creating anything (`budget_usd needs usd_per_call`), so +the pair is what makes the run stop itself and write why. + Two stores, one variable: the wrapper's `--store` is where the report goes, and the run's store is the CLI's own `--store`, so pass the same value on both. The wrapper cannot see the child's store — give it only to the wrapper and `evolve` @@ -148,7 +154,10 @@ wrapper only reports a `status.json` that appears or changes after it launches, so a handoff into a used store reports `found: false` until its own run writes one, instead of naming a finished run from last week. The tmux session carries a per-launch suffix for the same reason: two handoffs into stores both called -`runs` get two sessions, not one shared, refused name. +`runs` get two sessions, not one shared, refused name. One live handoff per +store: two launched into one store inside the same wait window both read that +store's `waiting.json` and the second can report the first's run, so give each +handoff its own store or wait for the first to finish. Read it back from a follow-up Trigger, a person, or a scheduled check — the wrapper adds no daemon and no scheduler. `--store` is a **global** flag, so it diff --git a/tests/test_dsh_superagent_run.py b/tests/test_dsh_superagent_run.py index f5b884cf..33aebbb7 100644 --- a/tests/test_dsh_superagent_run.py +++ b/tests/test_dsh_superagent_run.py @@ -12,6 +12,7 @@ """ import json import os +import shlex import shutil import subprocess import sys @@ -20,6 +21,8 @@ import pytest +from agentdescent import cli, runstore + REPO = Path(__file__).resolve().parent.parent WRAPPER = REPO / "scripts" / "dsh-superagent-run.sh" PAGE = REPO / "docs" / "dsh-super-agent.md" @@ -50,15 +53,67 @@ def _page_handoff_example() -> str: raise AssertionError("the page has no example that starts the wrapper") +def _handoff_argv(): + """The page's example as the shell would hand it over: (wrapper, child) argv. + + Parsed, not matched as a substring. The block is a command a worker copies, + so its flags have to be the ones the CLI accepts *as a pair* -- a + substring check passes a command that exits 2, which is how `--budget` went + out without its `--usd-per-call` (#1084 review). + """ + block = _page_handoff_example() + # The shell's line continuations, then the `#` lines showing its output -- + # both are page furniture, not arguments. + code = block.replace("\\\n", " ") + code = " ".join(line for line in code.splitlines() + if not line.strip().startswith("#")) + argv = shlex.split(code) + assert argv[0] == "bash", f"the example does not invoke the wrapper with bash: {argv}" + assert "--" in argv, f"the example does not separate the wrapper's flags: {argv}" + cut = argv.index("--") + return argv[1:cut], argv[cut + 1:] + + +def _store_flag(argv) -> str: + """The value after `--store`, which is what must agree on both sides.""" + assert "--store" in argv, f"no --store in {argv}" + return argv[argv.index("--store") + 1] + + def test_the_page_hands_the_run_the_store_the_wrapper_reports_into(): # Two stores, one variable. The wrapper's `--store` is where the report # goes; the run's store is the CLI's own global flag. Naming only the # wrapper's store made `evolve` write to the default store, so the wrapper # waited out its deadline and reported found: false, and the page's # read-back could not find the run (#1084 review). - example = " ".join(_page_handoff_example().split()) - assert 'dsh-superagent-run.sh --store "$RUN_STORE" --' in example, example - assert 'agentdescent --store "$RUN_STORE" evolve' in example, example + wrapper_argv, child_argv = _handoff_argv() + assert Path(wrapper_argv[1]).name == "dsh-superagent-run.sh", wrapper_argv + + # Drop the child's program name ("agentdescent", or `python -m + # agentdescent.cli`); the CLI's own parser then says whether the rest is a + # command it has. + start = next((i for i, token in enumerate(child_argv) + if token in ("agentdescent", "agentdescent.cli")), None) + assert start is not None, f"the example does not run agentdescent: {child_argv}" + cli_argv = child_argv[start + 1:] + try: + args = cli.build_parser().parse_args(cli_argv) + except SystemExit as e: # argparse exits 2 on a flag the CLI does not have + raise AssertionError( + f"the page's handoff command is not one the CLI accepts: {cli_argv} " + f"(exit {e.code})") from e + + assert _store_flag(wrapper_argv) == _store_flag(cli_argv), ( + "the report's store and the run's store must be the same value") + assert getattr(args, "detach", False), "the handoff must start the run detached" + # The section promises a run that stops itself and writes why, which is what + # --budget buys; the CLI refuses that flag without a per-call price. + assert args.budget is not None, ( + "the section documents a capped run, so the example carries --budget") + assert args.usd_per_call, ( + f"the page passes --budget {args.budget} without --usd-per-call, so the " + "documented command exits 2 and starts nothing") + runstore.require_budget_pair(args.budget, args.usd_per_call) def test_the_page_documents_a_wrapper_that_exists_and_takes_its_flags():