diff --git a/lib/test_proposer_yield.py b/lib/test_proposer_yield.py index 5217145..7975c74 100644 --- a/lib/test_proposer_yield.py +++ b/lib/test_proposer_yield.py @@ -758,3 +758,27 @@ def test_auto_shows_a_changed_check_as_a_diff(tmp_path): assert "health check CHANGED" in passes[1], result.stderr assert "+# weakened" in passes[1] and "-ping -c1" in passes[1] assert [e for e in events if e["event"] == "health_check_changed"] + + +def test_a_yield_whose_completion_marker_already_exists_is_refused(tmp_path): + """Measured live 2026-10-14: an adopted chain's exit_code_file named a .rc an + earlier job had left behind; the wait ended after 0 s while the chain ran.""" + stale = tmp_path / "chain.rc" + stale.write_text("0\n") + document = _yield_json(job={"mode": "launch", "argv": ["/bin/sh", "-c", "sleep 1"]}, + resume_when={"kind": "exit_code_file", "path": str(stale)}) + result, events = _run(tmp_path, _agent(tmp_path, _writes_yield(tmp_path, document)), + max_iter=1) + invalid = [e for e in events if e["event"] == "yield_invalid"] + assert invalid and "already exists" in invalid[0]["reason"], result.stderr + assert not [e for e in events if e["event"] == "yield_job_start"] + + +def test_a_fresh_marker_path_is_still_accepted(tmp_path): + marker = tmp_path / "fresh.rc" + document = _yield_json(job={"mode": "launch", + "argv": ["/bin/sh", "-c", f"sleep 1; echo 0 > {marker}"]}, + resume_when={"kind": "exit_code_file", "path": str(marker)}) + result, events = _run(tmp_path, _agent(tmp_path, _writes_yield(tmp_path, document)), + max_iter=1) + assert [e for e in events if e["event"] == "yield_resume"], result.stderr diff --git a/proposer.sh b/proposer.sh index 39f34b2..7e628e1 100755 --- a/proposer.sh +++ b/proposer.sh @@ -1336,7 +1336,7 @@ HEALTH_ACTIVE=""; HEALTH_PROBLEM="" YIELD_REASON=""; YIELD_MODE=""; YIELD_JOB_CWD=""; YIELD_JOB_LOG="" YIELD_DEADLINE=""; YIELD_PREDICATE=""; YIELD_ON_RESUME=""; YIELD_ADOPT_PID="" -YIELD_NEEDS_HEALTHY="true" +YIELD_NEEDS_HEALTHY="true"; YIELD_MARKER_PATH="" YIELD_JOB_PID=""; YIELD_JOB_RC=""; YIELD_STARTED_AT=""; YIELD_INDEX=0 YIELD_RESUME_BLOCK="" YIELD_ARGV=() @@ -1344,9 +1344,9 @@ YIELD_ARGV=() # The schema owner and the predicate evaluator, in one place. Bash cannot parse # JSON and must not try; this is the same shim shape specstride_spec_* uses. # _yield_py validate -# -> 9 lines (reason, mode, cwd, log, deadline_sec, predicate kind, -# on_resume, adopt pid, requires_healthy_infra), or exit 2 with one line -# saying what is wrong. +# -> 10 lines (reason, mode, cwd, log, deadline_sec, predicate kind, +# on_resume, adopt pid, requires_healthy_infra, explicit marker path for +# exit_code_file/file_exists), or exit 2 with one line saying what is wrong. # _yield_py argv -> one argv entry/line # _yield_py check # -> exit 0 satisfied, 1 not yet, 2 unsatisfiable @@ -1462,7 +1462,8 @@ if not isinstance(needs_healthy, bool): if cmd == "validate": for field in (one_line(reason), mode, cwd, log, str(deadline), kind, - one_line(on_resume), adopt_pid, "true" if needs_healthy else "false"): + one_line(on_resume), adopt_pid, "true" if needs_healthy else "false", + resolve(predicate_path) if kind in ("exit_code_file", "file_exists") else ""): print(field) sys.exit(0) @@ -1561,6 +1562,7 @@ read_yield() { YIELD_DEADLINE="${fields[4]:-}"; YIELD_PREDICATE="${fields[5]:-}" YIELD_ON_RESUME="${fields[6]:-}"; YIELD_ADOPT_PID="${fields[7]:-}" YIELD_NEEDS_HEALTHY="${fields[8]:-true}" + YIELD_MARKER_PATH="${fields[9]:-}" return 0 } @@ -2401,6 +2403,16 @@ for (( i=1; i<=MAX_ITER; i++ )); do artifact "$YIELD_ARTIFACT" rm -f "$YIELD_ARTIFACT" YIELD_INDEX=$(( YIELD_INDEX - 1 )) + elif [[ -n "$YIELD_MARKER_PATH" && -e "$YIELD_MARKER_PATH" ]]; then + # A completion marker that exists BEFORE the wait starts is stale: the wait + # would end at once and resume the agent on a job still running. Measured + # live 2026-10-14: an adopted chain's exit_code_file named a .rc left by an + # earlier job; "yield satisfied after 0s" while the chain took the lab down. + echo "proposer.sh: refusing the yield — its completion marker $YIELD_MARKER_PATH already exists before the job has ended (stale from an earlier job). Remove it before starting the job, name a fresh path, or use resume_when.kind pid." >&2 + specstride_emit yield_invalid iter "$i" reason "completion marker already exists: $YIELD_MARKER_PATH" \ + artifact "$YIELD_ARTIFACT" + rm -f "$YIELD_ARTIFACT" + YIELD_INDEX=$(( YIELD_INDEX - 1 )) elif yield_take_job; then YIELD_STARTED_AT="$(date +%s)" specstride_emit pass_yield iter "$i" reason "$YIELD_REASON" predicate_kind "$YIELD_PREDICATE" \