From e9e739c11e126c38c39a22e6830a8f4fb49d431e Mon Sep 17 00:00:00 2001 From: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:54:53 +0300 Subject: [PATCH] P2-S6: Name the host's own typed failure and the leaf's model at a nanny terminal A nanny that died on its own Codex lane was reported by the reviewer role it played (73c216b8, "c-final-fable-review"), so the owner asked why Fable was broken while the Fable run was alive and then cancelled by I2. Two facts were missing, and they are separate facts: the HOST's last typed error, and the LEAF's identity. Both land where the reader already looks, on projections that already exist, with no new host_route sub-dict (disposition R7). model_execution_projection gains exactly one typed key, last_llm_error_kind, beside used_model and provider; it reads the same usage['_last_llm_error_kind'] the loop already keeps and a successful send already clears, so it names the failure of the host's LAST model call rather than a sticky stale one. Each terminal_runs row grows from {run_id, state} to carry model, profile_id and selected_subagent_id, all already on the replayed RunCustody, so the sharing costs zero extra reads; terminal_custody_notice names the leaf model when the row carried one. The precondition is covered, not assumed: half of the leaf exists only once delegate_terminal_reconciliation has been persisted, and on the live row it was null. With no persisted audit the notice renders nothing and the host half stands alone; an older custody row without a model simply says less. Deliberately not in outcome_axes: outcomes.py is at its line ceiling and the axis statuses are not this reader's path. terminal_runs is in _ENVELOPE_DISCLOSURE_FIELDS, so stored envelopes compare unequal once and are rewritten by the audit-only refresh. gateway/contracts.py is frozen and needs no edit: model_execution is already NotRequired[Dict[str, Any]]. Tests: the pinned terminal_runs shapes carry the leaf fields and the notice names the leaf model with no live join; a dead host lane carries the typed error kind on model_execution while an absent reconciliation renders only the host half; the solve half is still preserved through an empty call, which now visibly moves the error kind alone. --- ouroboros/delegate_terminal.py | 14 ++++++++-- ouroboros/task_finalization.py | 4 +++ tests/test_delegate_sweep_refresh.py | 3 ++- tests/test_task_model_execution.py | 37 ++++++++++++++++++++++++++- tests/test_terminal_custody_notice.py | 14 +++++++--- 5 files changed, 65 insertions(+), 7 deletions(-) diff --git a/ouroboros/delegate_terminal.py b/ouroboros/delegate_terminal.py index a4e39291e..8a0aff858 100644 --- a/ouroboros/delegate_terminal.py +++ b/ouroboros/delegate_terminal.py @@ -128,8 +128,13 @@ def _audit_task_custody(drive_root: Any, mine: str, result: Dict[str, Any], *, terminal_runs = [] try: if not audit_failure: + # The LEAF's own identity, already on the replayed row (zero extra + # reads): without it a nanny terminal names only the role the host + # played and the reader asks why the leaf's model was broken (I9). terminal_runs = sorted(( - {"run_id": str(row.run_id), "state": str(row.terminal_state)} + {"run_id": str(row.run_id), "state": str(row.terminal_state), + "model": str(row.model), "profile_id": str(row.profile_id), + "selected_subagent_id": str(row.selected_subagent_id)} for row in state.values() if row.task_id == mine and row.settled and row.terminal_state in custody.TERMINAL_STATES ), key=lambda row: row["run_id"]) @@ -182,7 +187,12 @@ def terminal_custody_notice(result: Mapping[str, Any]) -> str: return "" lines = [] if non_success: - shown = "; ".join(f"{row.get('run_id')}: {row.get('state')}" for row in non_success[:10]) + # The leaf's model when the replayed row carried one, never a live join + # and never a guess: an older row without it simply says less. + shown = "; ".join( + f"{row.get('run_id')}: {row.get('state')}" + + (f" on {row.get('model')}" if row.get("model") else "") + for row in non_success[:10]) omitted = len(non_success) - 10 lines.append("Confirmed delegated terminal receipts: " + shown + (f" (+{omitted} more in task details)" if omitted > 0 else "") + ".") diff --git a/ouroboros/task_finalization.py b/ouroboros/task_finalization.py index a97cbe0d8..9017ec8e6 100644 --- a/ouroboros/task_finalization.py +++ b/ouroboros/task_finalization.py @@ -527,6 +527,10 @@ def model_execution_projection(usage: Dict[str, Any]) -> Dict[str, Any] | None: "used_local": call.get("use_local"), "provider": call.get("provider"), "llm_call_id": call.get("llm_call_id"), + # The host's OWN last typed failure, beside the model it was running. + # A nanny that died on its own lane used to be reported by the reviewer + # role it played, so its death read as the delegated leaf's fault (I9). + "last_llm_error_kind": usage.get("_last_llm_error_kind") or None, "source": "usable_solve_response" if call else "not_observed", } diff --git a/tests/test_delegate_sweep_refresh.py b/tests/test_delegate_sweep_refresh.py index 93e3984e2..1acc4b77a 100644 --- a/tests/test_delegate_sweep_refresh.py +++ b/tests/test_delegate_sweep_refresh.py @@ -133,7 +133,8 @@ def test_boot_backfill_fixes_row_settled_in_a_previous_generation(tmp_path): assert custody == { "unreconciled": [], "trigger": "boot_backfill", "audit_status": "ok", "open_run_ids": [], "pending_invocation_ids": [], "undisposed_patch_run_ids": [], - "terminal_runs": [{"run_id": "run-1", "state": "succeeded"}], + "terminal_runs": [{"run_id": "run-1", "state": "succeeded", "model": "", + "profile_id": "", "selected_subagent_id": ""}], } diff --git a/tests/test_task_model_execution.py b/tests/test_task_model_execution.py index 37d6cfc42..1e43eba30 100644 --- a/tests/test_task_model_execution.py +++ b/tests/test_task_model_execution.py @@ -40,17 +40,27 @@ def test_real_dispatch_preserves_last_solve_through_empty_and_forced_calls(tmp_p "used_model": "fallback", "reported_model": "Provider display alias", "used_local": False, "provider": "openrouter", "llm_call_id": ctx.accumulated_usage["llm_call_refs"][-1]["llm_call_id"], + "last_llm_error_kind": None, "source": "usable_solve_response", } ctx.round_idx += 1 failed = dispatch(ctx, "empty", message={"role": "assistant", "content": "", "tool_calls": []}) assert failed[0] is None - assert model_execution_projection(ctx.accumulated_usage) == observed + after_empty = model_execution_projection(ctx.accumulated_usage) + # The solve half is preserved. The host's OWN last typed failure is a + # separate fact on the same projection and does move (I9): a nanny that + # died on its own lane must be readable without guessing at the leaf. + assert {k: v for k, v in after_empty.items() if k != "last_llm_error_kind"} == { + k: v for k, v in observed.items() if k != "last_llm_error_kind"} + assert after_empty["last_llm_error_kind"] == "provider_incomplete_response" + assert observed["last_llm_error_kind"] is None assert not ctx.accumulated_usage["llm_call_refs"][-1].get("usable_solve_response") # Forced/post-task calls use the same call recorder but not ordinary dispatch. call_llm_with_retry(Model({"role": "assistant", "content": "wrap up"}, {}), ctx.messages, "forced", [], "high", 1, ctx.drive_logs, ctx.task_id, 3, None, ctx.accumulated_usage, attempt_cap=1) + # ...and a successful send clears the stale typed error, so the projection + # returns to the solve half alone. assert model_execution_projection(ctx.accumulated_usage) == observed refs = collect_trace_refs(ctx.accumulated_usage, {})["llm_call_refs"] assert len(refs) == 3 @@ -194,3 +204,28 @@ def test_owner_wait_source_retains_initial_route_and_marked_calls(tmp_path, monk resume_native_loop(registry, saved, [], {}, restored, set()) assert model_execution_projection(restored) == expected assert ctx.active_model == "fallback" and ctx.owner_wait_resume is None + + +def test_dead_host_lane_renders_its_own_half_without_inventing_the_leaf(): + """I9: the host's typed failure and the leaf's identity are SEPARATE facts. + + A nanny that died on its own Codex lane used to be reported by the reviewer + role it played, so the owner asked why the leaf's model was broken while + that leaf was alive. The host half now rides model_execution beside the + model it was running; when the delegated reconciliation was never persisted + (it was null on the live row) the custody notice stays silent rather than + guessing at the leaf. + """ + from ouroboros.task_finalization import terminal_host_notice_text + + usage = { + "initial_model_request": {"model": "primary", "use_local": False}, + "llm_call_refs": [{"model": "host-lane-model", "llm_call_id": "call-1", + "provider": "claudexor", "usable_solve_response": True}], + "_last_llm_error_kind": "provider_outcome_unknown", + } + projected = model_execution_projection(usage) + assert projected["used_model"] == "host-lane-model" + assert projected["provider"] == "claudexor" + assert projected["last_llm_error_kind"] == "provider_outcome_unknown" + assert terminal_host_notice_text({"model_execution": projected}) == "" diff --git a/tests/test_terminal_custody_notice.py b/tests/test_terminal_custody_notice.py index cfaddc6e9..e516ea6cf 100644 --- a/tests/test_terminal_custody_notice.py +++ b/tests/test_terminal_custody_notice.py @@ -17,6 +17,8 @@ def _cancelled_with_patch(tmp_path): continuation_narrative={"text": text}) assert custody.emit(tmp_path, custody.STARTED, { "run_id": "run-one", "task_id": "root", "route": "fixture", + "model": "fixture-model", "profile_id": "fixture-profile", + "selected_subagent_id": "fixture-actor", "snapshot_id": "snapshot-one", "shape": {}, }) assert custody.emit(tmp_path, custody.SETTLED, { @@ -32,7 +34,10 @@ def test_cleanup_receipt_is_carried_before_final_delivery_without_rewriting_answ audit = stored["delegate_terminal_reconciliation"] assert audit["open_run_ids"] == audit["pending_invocation_ids"] == [] assert audit["undisposed_patch_run_ids"] == ["run-one"] - assert audit["terminal_runs"] == [{"run_id": "run-one", "state": "cancelled"}] + assert audit["terminal_runs"] == [{ + "run_id": "run-one", "state": "cancelled", "model": "fixture-model", + "profile_id": "fixture-profile", "selected_subagent_id": "fixture-actor", + }] before = copy.deepcopy(stored) usage = {"terminal_origin": "model_final", "terminal_host_notice": "Budget stop retained."} event = prepare_terminal_send_event( @@ -42,7 +47,9 @@ def test_cleanup_receipt_is_carried_before_final_delivery_without_rewriting_answ ) assert event["text"] == text assert event["terminal_host_notice"].startswith("Budget stop retained.") - assert "run-one: cancelled" in event["terminal_host_notice"] + # The leaf's own model rides the replayed row, so the nanny's terminal is + # not read as a verdict about the role the host played (I9). + assert "run-one: cancelled on fixture-model" in event["terminal_host_notice"] assert "Pending patch decisions: run-one" in event["terminal_host_notice"] assert load_task_result(tmp_path, "root") == before @@ -91,7 +98,8 @@ def test_custody_notice_is_bounded_and_does_not_duplicate_on_public_projection() "delegate_terminal_reconciliation": { "audit_status": "ok", "open_run_ids": [], "pending_invocation_ids": [], "undisposed_patch_run_ids": [f"run-{i}" for i in range(12)], - "terminal_runs": [{"run_id": f"run-{i}", "state": "cancelled"} for i in range(12)], + "terminal_runs": [{"run_id": f"run-{i}", "state": "cancelled", + "model": "leaf-model"} for i in range(12)], }} public = public_task_result(row) assert "+2 more in task details" in public["terminal_host_notice"]