fix(escalate): review round 1 — say what really ends the shared wait, name the repair on every bound refusal

The description claimed the shared wait "ends on the first owner reply". Any
incoming message ends it (a descendant's message, a hurry, a system notice), so
it now says "the first incoming message"; 935 -> 932 bytes against the base,
and the catalog pin is rolled with that number.

The above-ceiling refusal now carries the same "omit it for an unbounded wait"
repair as the other invalid required bounds. The child's receipt discloses an
ignored bound in brackets instead of gluing a second period onto an assumption
that already ends with one. Tests pin the child path, the ceiling repair and
the consumer of the wait fields (the parked wait carries no bound).

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
Ouroboros 2026-09-18 22:46:19 +03:00 • committed by Ouroboros
parent 42cb27ec19
commit 4bc05ffb44
6 changed files with 19 additions and 11 deletions

View file

@ -1462,7 +1462,7 @@ def get_tools() -> List[ToolEntry]:
"is irreversible or costly to redo, or the choice is the owner's to make "
"(spending, publishing, deleting); your judgment decides. The task then waits after the "
"current tool batch without model calls; waiting questions in one batch share one wait, "
"which ends on the first owner reply."
"which ends on the first incoming message."
),
"parameters": {"type": "object", "properties": {
"question": {"type": "string", "description": "The decision being escalated (markdown renders in chat)"},

View file

@ -391,7 +391,7 @@ def _validate_wait_bound(max_wait_minutes: Any, *, wait_for_answer: bool) -> Opt
raise QuizValidationError(
"QUIZ_WAIT_BOUND_INVALID",
f"max_wait_minutes must be at most {ceiling_minutes} "
"(the task's absolute wall-clock ceiling).",
"(the task's absolute wall-clock ceiling); omit it for an unbounded wait.",
)
return int(max_wait_minutes)
@ -551,7 +551,8 @@ def _escalate(
if not written:
return f"⚠️ ESCALATE_UNWRITTEN: the escalation to parent {parent_task_id} was not persisted."
return (f"OK: escalated to parent task {parent_task_id}; continuing under "
f"assumption: {payload['assumption']}{'.' if ignored_bound else ''}{ignored_bound}")
f"assumption: {payload['assumption']}"
+ (f" ({ignored_bound.strip().rstrip('.')})" if ignored_bound else ""))
# Root task: the owner gets a typed quiz card.
from ouroboros.owner_quiz import record_asked

View file

@ -115,11 +115,10 @@ def test_core_catalog_schema_bytes_and_handler_owners_are_stable():
# bound joins its parameters. Rolled again for the owner's "autonomy first" decision:
# the escalate description states when waiting is worth it (an irreversible or costly
# next step, or a choice that is the owner's to make) and that waiting questions of one
# batch share one wait; 935 -> 927 bytes. Diffing the whole catalog base to head shows
# exactly those edits and nothing else.
# Workflow scope: describe lazy artifact writes and the shared brace-mask selector.
# batch share one wait, ended by the first incoming message; 935 -> 932 bytes. Diffing the
# whole catalog base to head shows exactly those edits and nothing else.
assert hashlib.sha256(schema_bytes).hexdigest() == (
"aa36894863d4b5df69520c86026bb29e62743fd36c8caff6faa5eb8627b5764d"
"c4804f21764713b866e114eb8a7fee56501f84f1584dd502085e80c9362f8a2b"
)
assert {
entry.name: (entry.handler.__module__, entry.handler.__name__)

View file

@ -272,6 +272,9 @@ def test_a_bounded_wait_does_not_lend_its_bound_to_the_next_question_of_the_batc
second = _escalate(ctx, question="Second?", options=["a", "b"], wait_for_answer=True)
assert "the task waits after this tool batch" in second and "up to" not in second
assert ctx._owner_wait_max_minutes == 0 and ctx._owner_wait_deadline_at == ""
from ouroboros.owner_wait import _wait_bound_fields
assert _wait_bound_fields(ctx) == {}, "the parked wait carries no bound either"
assert ctx._owner_wait_requested == list(ctx.event_queue.queue)[-1]["quiz_id"]

View file

@ -410,6 +410,10 @@ def test_escalate_subagent_writes_parent_mailbox_frame(tmp_path, monkeypatch):
assert "forward_to_worker(task_id=child-9" in text
# No owner card, no projection for the child hop.
assert not [e for e in ctx.pending_events if e.get("type") == "send_quiz"]
# A habit-filled bound is disclosed on the child's receipt too, never sent up the chain.
out = _escalate(ctx, question="Again?", options=["a", "b"], assumption="keep going.", max_wait_minutes=1)
assert out.endswith("assumption: keep going. (max_wait_minutes ignored: it applies only to wait_for_answer=true)")
assert "max_wait_minutes" not in drain_owner_entries(tmp_path, "root-1", set())[-1]["text"]
assert quiz_states(tmp_path, "child-9") == {}

View file

@ -66,10 +66,11 @@ class TestValidateQuizPayload:
for named_default in (0, 1, 5, 60, -5, True, "30"):
assert "max_wait_minutes" not in validate_quiz_payload(
"q", ["a", "b"], "", "assume", max_wait_minutes=named_default)
# A required wait keeps the refusal, and the refusal names the repair.
with pytest.raises(QuizValidationError) as err:
validate_quiz_payload("q", ["a", "b"], "", "", wait_for_answer=True, max_wait_minutes=0)
assert "omit it for an unbounded wait" in str(err.value)
# A required wait keeps the refusal, and every such refusal names the repair.
for bad in (0, 361):
with pytest.raises(QuizValidationError) as err:
validate_quiz_payload("q", ["a", "b"], "", "", wait_for_answer=True, max_wait_minutes=bad)
assert "omit it for an unbounded wait" in str(err.value)
def test_empty_or_oversized_question_refused(self):
with pytest.raises(QuizValidationError):