ouroboros/supervisor/evolution_lifecycle.py
Anton edd8c47254 fix: complete reliable Project admission and recovery
Preserve healthy registry operations beside malformed neighbours, exact
continuations and frozen resources, Project addressing and child wait facts.
Retain one refused evolution transaction and refresh its admitted Git basis.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
2026-09-30 14:59:43 +03:00

1596 lines
72 KiB
Python

"""Evolution campaign state and transaction lifecycle.
Owns the campaign file (``state/evolution_campaign.json``), the campaign/
transaction lifecycle transitions, and the owner-facing cycle reporting.
``supervisor.queue`` imports this module top-level; anything here that needs
queue state (``DRIVE_ROOT``, locks) must import the queue lazily at call time
to avoid a module-load cycle.
"""
from __future__ import annotations
import logging
import os
import pathlib
import uuid
from typing import Any, Dict, Optional
from ouroboros.consciousness_authority import consciousness_origin_metadata
from ouroboros.cost_projection import honest_cost_pair_amount
from ouroboros.evolution_fingerprint import canonical_objective_fingerprint
from ouroboros.outcomes import normalize_outcome_axes
from ouroboros.utils import atomic_write_json, read_json_dict, utc_now_iso
log = logging.getLogger(__name__)
EVOLUTION_CAMPAIGN_FILE = pathlib.Path("state") / "evolution_campaign.json"
EVOLUTION_CAMPAIGN_CAS_TIMEOUT_SEC = 0.05
def current_evolution_boot_generation() -> str:
"""Return the existing custody generation used by boot reconciliation."""
try:
from ouroboros.process_custody import current_custody_session_id
return str(current_custody_session_id() or "")
except Exception:
return ""
def _evolution_campaign_path() -> pathlib.Path:
from supervisor import queue
return pathlib.Path(queue.DRIVE_ROOT) / EVOLUTION_CAMPAIGN_FILE
def _read_evolution_campaign() -> Dict[str, Any]:
data = read_json_dict(_evolution_campaign_path()) or {}
return data if isinstance(data, dict) else {}
# #1307: a received Stop outlives a failed state write. The live latch keeps it in this
# process; the campaign's ``stop_intent`` keeps it durably, beside (never instead of) the
# state flag and whether or not cancellation finished. Only an owner-sourced successful
# ``start_evolution_campaign`` clears them.
_STOP_LATCH = {"stopped": False}
_OWNER_START_SOURCES = frozenset({"owner", "owner_chat"})
def record_evolution_stop_intent(source: str, reason: str = "") -> bool:
"""Latch the Stop, then record it in the EXISTING campaign record (no invented
terminal status). False: the durable intent could not be written (the caller
discloses it). An absent campaign gets only the intent, never a fake status."""
from supervisor import state
_STOP_LATCH["stopped"] = True
lock_fd = state.acquire_file_lock(state.STATE_LOCK_PATH, timeout_sec=1.0)
if lock_fd is None:
return False
try:
campaign = _read_evolution_campaign()
campaign["stop_intent"] = {"at": utc_now_iso(), "source": str(source or "owner"), "reason": str(reason or "")}
return _write_evolution_campaign(campaign, _state_lock_held=True)
except OSError:
return False
finally:
state.release_file_lock(state.STATE_LOCK_PATH, lock_fd)
def evolution_stop_reason(campaign: Optional[Dict[str, Any]] = None) -> str:
"""Why no new evolution may be admitted regardless of state.json: a received
Stop (latch or durable intent) or an unconsumed Panic flag. "" when none."""
from supervisor import queue
from supervisor.state_initialization import confirm_absent
if _STOP_LATCH["stopped"]:
return "stop_latched"
if isinstance((campaign if campaign is not None else _read_evolution_campaign()).get("stop_intent"), dict):
return "stop_intent"
path = pathlib.Path(queue.DRIVE_ROOT) / "state" / "panic_stop.flag"
try:
try:
os.lstat(path)
except FileNotFoundError:
confirm_absent(path)
return ""
return "panic_flag"
except OSError:
return "panic_flag_unknown"
def disable_evolution_projection() -> None:
"""Clear the scheduler projection without changing campaign history."""
from supervisor import state
try:
state.update_state(lambda live: live.update({
"evolution_mode_enabled": False,
"post_task_autostop": False,
}), confirm=("evolution_mode_enabled", "post_task_autostop"))
except state.StateUnavailable:
log.warning("evolution projection disable not persisted: state unavailable", exc_info=True)
def disable_evolution_authority(
action: str, *, campaign_id: str = "", task_id: str = "",
) -> None:
"""Clear an invalid scheduler projection and record the typed refusal."""
from supervisor import queue, state
disable_evolution_projection()
event = {
"ts": utc_now_iso(),
"type": "evolution_authority_missing",
"action": str(action or ""),
"campaign_id": str(campaign_id or ""),
}
if task_id:
event["task_id"] = str(task_id)
state.append_jsonl(pathlib.Path(queue.DRIVE_ROOT) / "logs" / "events.jsonl", event)
def deliver_pending_owner_report(notify: Any = None) -> None:
"""Deliver and clear a worker-staged owner report from the server process."""
try:
campaign = _read_evolution_campaign()
report = campaign.get("pending_owner_report")
if not isinstance(report, dict):
return
(notify or notify_owner_cycle_outcome)(campaign, report)
clear_pending_owner_report(report)
except Exception:
log.debug("failed to deliver pending owner report", exc_info=True)
def _deliver_pending_owner_report() -> None:
"""Queue-compatibility wrapper for the server-process owner report."""
from supervisor import queue as q
q.deliver_pending_owner_report(q.notify_owner_cycle_outcome)
def enqueue_evolution_task_if_needed() -> None:
"""Queue evolution only when idle, enabled, within budget, and not failure-paused."""
from supervisor import queue as q
q._deliver_pending_owner_report()
if q.PENDING or q.RUNNING:
return
from supervisor.state import StateUnavailable, control_is, update_state
st = q.load_state()
# Autonomy needs KNOWN current controls (#1307): an unknown flag is not "enabled".
# The owner chat is only the notice address, so its display value serves.
if not (control_is(st, "evolution_mode_enabled", True) and control_is(st, "evolution_owner_stopped", False)):
return
owner_chat_id = st.get("owner_chat_id")
if not owner_chat_id:
return
campaign = q._read_evolution_campaign()
if evolution_stop_reason(campaign):
return
has_authority = all(str(campaign.get(key) or "").strip() for key in ("id", "source"))
if campaign.get("status") != "active" or not has_authority:
q.disable_evolution_authority("bare_flag_disabled", campaign_id=str(campaign.get("id") or ""))
q.send_with_budget(
int(owner_chat_id),
"🧬 Evolution stayed off: the enable flag had no active campaign authority. Use /evolve start to begin a fresh campaign.",
role="system", system_type="evolution_notice")
return
active_tx = campaign.get("active_transaction") if isinstance(campaign.get("active_transaction"), dict) else {}
if active_tx and (
str(active_tx.get("commit_sha") or "").strip()
or str(active_tx.get("dispatch_status") or "") == "reaping"
):
return
# Defensive net: light mode must never run evolution even if the flag was
# left enabled (e.g. carried across a restart into light mode). Disable and
# pause once; entry points already refuse new starts up front.
block = q.evolution_block_reason()
if block:
q.pause_evolution_campaign("blocked in light runtime mode")
q.disable_evolution_projection()
q.send_with_budget(int(owner_chat_id), block, role="system", system_type="evolution_notice")
return
consecutive_failures = int(st.get("evolution_consecutive_failures") or 0)
if consecutive_failures >= 3:
q.pause_evolution_campaign("paused after consecutive failures")
q.disable_evolution_projection()
q.send_with_budget(
int(owner_chat_id),
f"🧬⚠️ Evolution paused: {consecutive_failures} consecutive failures. "
f"Use /evolve start to resume after investigating the issue.",
role="system", system_type="evolution_notice")
return
# Objective repeats count non-consecutive no-ops by the transaction's canonical
# fingerprint, clearing only on absorption. The failure streak cannot catch
# these loops because every non-failing cycle resets it (events.py).
objective_repeat_counts = campaign.get("objective_repeat_counts") or {}
active_objective_fp = canonical_objective_fingerprint(
str(campaign.get("objective") or ""),
)
objective_repeats = (
int(objective_repeat_counts.get(active_objective_fp, 0))
if active_objective_fp else 0
)
if objective_repeats >= q.OBJECTIVE_REPEAT_CAP:
q.pause_evolution_campaign(
"paused: objective re-proposed without ever absorbing",
)
q.disable_evolution_projection()
q.send_with_budget(
int(owner_chat_id),
f"🧬⚠️ Evolution paused: the current objective ran {objective_repeats} reviewed "
f"cycles WITHOUT ever being absorbed — it keeps getting re-proposed and never lands "
f"(a self-maintenance loop, not progress). A plain resume won't help; use "
f"/evolve start with a DIFFERENT objective.",
role="system", system_type="evolution_notice")
return
try: # on the supervisor loop: ride the snapshot, decide the reserve refusal on the exact read
remaining = q.budget_remaining(st, strict=True, allow_stale=True, refuse_below=q.EVOLUTION_BUDGET_RESERVE)
except Exception:
log.error("Evolution scheduling deferred: cost accounting unavailable", exc_info=True)
q.append_jsonl(q.DRIVE_ROOT / "logs" / "events.jsonl", {
"ts": utc_now_iso(), "type": "evolution_accounting_unavailable",
"action": "dispatch_deferred", "owner_visible": True,
})
return
if remaining < q.EVOLUTION_BUDGET_RESERVE:
q.pause_evolution_campaign("budget reserve reached")
q.disable_evolution_projection()
q.send_with_budget(
int(owner_chat_id),
f"💸 Evolution stopped: ${remaining:.2f} remaining "
f"(reserve ${q.EVOLUTION_BUDGET_RESERVE:.0f} for conversations).",
role="system", system_type="evolution_notice")
return
cycle = int(st.get("evolution_cycle") or 0) + 1
tid = uuid.uuid4().hex[:8]
transaction = q.begin_evolution_transaction(tid, cycle=cycle, campaign=campaign)
if not transaction:
q.disable_evolution_authority("transaction_attach_failed", campaign_id=str(campaign.get("id") or ""), task_id=tid)
q.send_with_budget(
int(owner_chat_id),
"🧬 Evolution stayed off: the campaign changed before its next task could be attached. Start it again when ready.",
role="system", system_type="evolution_notice")
return
tid = transaction["task_id"] # only a positively refused attempt retains its identity
task = {
"id": tid, "type": "evolution",
"chat_id": int(owner_chat_id),
"text": q.build_evolution_task_text(cycle),
"metadata": {"evolution_transaction": transaction, **consciousness_origin_metadata(campaign)},
}
q.attach_task_contract(task)
def prepare_admission(row: Dict[str, Any]) -> bool:
refreshed = q.begin_evolution_transaction(tid, cycle=cycle, campaign=campaign, transaction=transaction)
if refreshed:
row["metadata"]["evolution_transaction"] = refreshed
return bool(refreshed)
admitted = q.enqueue_with_admission_receipt(task, prepare_admission=prepare_admission)
if isinstance(admitted, dict) and admitted.get("_admission_blocked"):
# Preserve positive no-handoff evidence, never infer it from an idle queue.
if admitted.get("_admission_never_admitted") is True:
refusal = {"reason": admitted["_admission_blocked"], "source": campaign["source"]}
update_evolution_transaction(tid, admission_refused=refusal)
reason = str(admitted.get("_admission_blocked") or "admission_fence")
detail = str(admitted.get("_admission_detail") or admitted.get("_worker_pool_disabled_reason") or "")
if not reason.startswith("consciousness_"):
if transaction.get("admission_refused", {}).get("reason") != reason:
log.warning("evolution cycle %s was not admitted (%s); retrying on the next pass", tid, reason)
return
q.pause_evolution_campaign(f"admission_refused:{reason}")
q.disable_evolution_projection()
q.send_with_budget(
int(owner_chat_id),
f"🧬 Evolution paused: its next cycle was not admitted ({reason}{': ' + detail if detail else ''}). "
"/evolve start resumes it.",
role="system", system_type="evolution_notice")
return
def _record_cycle(live: Dict[str, Any]) -> None:
live["evolution_cycle"] = cycle
live["last_evolution_task_at"] = utc_now_iso()
try:
update_state(_record_cycle)
except StateUnavailable:
log.warning("evolution cycle %s admitted; its state counter was not persisted", tid, exc_info=True)
def _write_evolution_campaign(
data: Dict[str, Any], *, expected_campaign_id: Optional[str] = None,
expected_active_transaction: Optional[Dict[str, Any]] = None,
_state_lock_held: bool = False, _lock_timeout_sec: float = 4.0,
) -> bool:
path = _evolution_campaign_path()
from supervisor import state
state.assert_test_data_path(path)
lock_fd = None
if not _state_lock_held:
lock_fd = state.acquire_file_lock(
state.STATE_LOCK_PATH, timeout_sec=float(_lock_timeout_sec),
)
if lock_fd is None:
return False
try:
current = read_json_dict(path)
if current is None and path.is_file(): # present but unreadable is NOT "no campaign"
log.warning("Refusing evolution campaign write: %s exists but is unreadable", path)
return False
current = current or {}
current_id = str(current.get("id") or "")
data_id = str(data.get("id") or "")
expected_id = data_id if expected_campaign_id is None else str(expected_campaign_id or "")
if current_id and current_id != expected_id:
log.warning(
"Refusing stale evolution campaign write: current=%s expected=%s",
current_id, expected_id,
)
return False
if (
current_id == data_id
and current.get("status") not in {"active", "paused"}
and data.get("status") in {"active", "paused"}
):
log.warning("Refusing stale resurrection of terminal evolution campaign %s", data_id)
return False
if expected_active_transaction is not None:
current_tx = current.get("active_transaction", {})
if current_tx != expected_active_transaction:
log.warning("Refusing stale evolution transaction write for campaign %s", data_id)
return False
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(path, data, trailing_newline=True)
return True
finally:
if lock_fd is not None:
state.release_file_lock(state.STATE_LOCK_PATH, lock_fd)
def evolution_block_reason() -> str:
"""Refusal message when evolution may not run in the current runtime mode.
Evolution campaigns are self-modification work, so they require runtime
mode ``advanced`` or ``pro``. In ``light`` (conversation-only) mode they are
hard-blocked before any campaign state, queue entry, or expensive round.
Returns ``""`` when evolution is allowed.
"""
from ouroboros.config import get_runtime_mode
if get_runtime_mode() == "light":
return (
"🧬 Evolution campaigns are self-modification work and require runtime "
"mode 'advanced' or 'pro'. The runtime is in 'light' mode "
"(self-modification is disabled), so no campaign was started. Switch "
"the runtime mode in Settings to evolve."
)
return ""
def start_evolution_campaign(objective: str = "", *, source: str = "owner", origin: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Start or resume the active evolution campaign; ``origin`` = the consciousness origin keys of the starting turn, kept for its cycle tasks."""
from supervisor import state
state.assert_test_data_path(state.STATE_PATH)
lock_fd = state.acquire_file_lock(state.STATE_LOCK_PATH)
if lock_fd is None:
return {}
try:
campaign = _read_evolution_campaign()
owner_start = str(source or "") in _OWNER_START_SOURCES
if not owner_start and evolution_stop_reason(campaign):
return {} # a received Stop yields only to the owner's own start (#1307)
prior_campaign_id = str(campaign.get("id") or "")
now = utc_now_iso()
objective = str(objective or "").strip()
if campaign.get("status") not in {"active", "paused"}:
campaign = {
"schema_version": 1,
"id": uuid.uuid4().hex[:8],
"status": "active",
"objective": objective or "Autonomously improve Ouroboros by acting on the highest-value backlog or process-memory signal.",
"source": str(source or ""), **consciousness_origin_metadata(origin),
"started_at": now,
"updated_at": now,
"cycles_done": 0,
"absorbed_cycles_done": 0,
"objective_repeat_counts": {}, # BUG3: fp -> non-absorbing-cycle count
"dropped_objective_fps": [], # BUG3 Layer B: attempted-and-dropped objective fps
"budget_spent_usd": 0.0,
"last_task_id": "",
"progress_notes": "",
"completed_at": "",
"completion_reason": "",
}
else:
if objective:
campaign["objective"] = objective
if not str(campaign.get("source") or "").strip() and source:
campaign["source"] = str(source)
if (str(source or "") in ("owner", "owner_chat") and campaign.get("status") == "paused"
and consciousness_origin_metadata(campaign)):
# The owner's explicit start ADOPTS a paused consciousness campaign: its cycles are
# the owner's work now, no longer bound by the consciousness allowance (BIBLE P0).
for key in ("initiator", "usage_category", "consciousness_autonomy"):
campaign.pop(key, None)
campaign["adopted_by_owner_at"] = now
campaign.update({k: v for k, v in consciousness_origin_metadata(origin).items() if not campaign.get(k)})
campaign["status"] = "active"
campaign.pop("pause_reason", None)
campaign["updated_at"] = now
generation = current_evolution_boot_generation()
if generation:
campaign["last_boot_reconcile_gen"] = generation
campaign.pop("stop_intent", None) # cleared ONLY by this successful authorized start
written = _write_evolution_campaign(
campaign,
expected_campaign_id=prior_campaign_id,
_state_lock_held=True,
)
if written and owner_start:
_STOP_LATCH["stopped"] = False
return campaign if written else {}
finally:
state.release_file_lock(state.STATE_LOCK_PATH, lock_fd)
def pause_evolution_campaign(reason: str = "") -> Dict[str, Any]:
"""Pause the active evolution campaign without deleting its state.
RESUMABLE: a later ``/evolve start`` resumes the SAME campaign in place
(start_evolution_campaign treats ``paused`` as resumable). Used by the system
breakers (light mode, consecutive failures, objective-repeat cap, budget reserve,
restart-blocked) — NOT by an owner stop. For an owner stop use
``complete_evolution_campaign`` (terminal).
"""
from supervisor import state
state.assert_test_data_path(state.STATE_PATH)
lock_fd = state.acquire_file_lock(state.STATE_LOCK_PATH)
if lock_fd is None:
return {}
try:
campaign = _read_evolution_campaign()
if campaign:
campaign["status"] = "paused"
campaign["updated_at"] = utc_now_iso()
campaign["pause_reason"] = str(reason or "")
if not _write_evolution_campaign(campaign, _state_lock_held=True):
return {}
return campaign
finally:
state.release_file_lock(state.STATE_LOCK_PATH, lock_fd)
def complete_evolution_campaign(
reason: str = "", *, status: str = "stopped", cleanup_worktree: bool = True
) -> Dict[str, Any]:
"""Close rather than pause: a later owner start creates a fresh campaign.
Archive/remove the active transaction and backlog claim so boot cannot absorb
a stopped cycle. The owner's sticky evolution_owner_stopped flag independently
prevents autonomous re-arm. Never raises. Panic passes cleanup_worktree=False:
no git work may delay its hard exit; boot custody owns later recovery.
"""
try:
from supervisor import state
snapshot = _read_evolution_campaign()
snapshot_tx = snapshot.get("active_transaction")
cleanup_updates: Dict[str, Any] = {}
if cleanup_worktree and isinstance(snapshot_tx, dict):
try:
_cleanup_worktree_after_cycle(snapshot_tx, str(snapshot_tx.get("task_id") or ""))
cleanup_updates = {
key: value for key, value in snapshot_tx.items()
if key.startswith("cleanup_") or key == "recovery_hint"
}
except Exception:
pass
state.assert_test_data_path(state.STATE_PATH)
lock_fd = state.acquire_file_lock(
state.STATE_LOCK_PATH, timeout_sec=0.001 if not cleanup_worktree else 4.0,
)
if lock_fd is None:
return {}
try:
campaign = _read_evolution_campaign()
if not campaign:
return campaign or {}
now = utc_now_iso()
tx = campaign.get("active_transaction")
if isinstance(tx, dict):
if (
cleanup_updates
and isinstance(snapshot_tx, dict)
and str(tx.get("transaction_id") or "")
== str(snapshot_tx.get("transaction_id") or "")
):
tx.update(cleanup_updates)
tx = {**tx, "cycle_outcome": tx.get("cycle_outcome") or "owner_stopped"}
append_unique_transaction(campaign, tx)
campaign.pop("active_transaction", None)
campaign.pop("post_task_backlog_id", None)
campaign.pop("pause_reason", None)
campaign["status"] = str(status or "stopped")
campaign["updated_at"] = now
campaign["completed_at"] = now
campaign["completion_reason"] = str(reason or "")
return campaign if _write_evolution_campaign(
campaign, _state_lock_held=True,
) else {}
finally:
state.release_file_lock(state.STATE_LOCK_PATH, lock_fd)
except Exception:
log.debug("complete_evolution_campaign failed", exc_info=True)
return {}
def begin_evolution_transaction(task_id: str, *, cycle: int, campaign: Dict[str, Any],
transaction: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Attach a cycle, or refresh its base under Q immediately before its receipt.
Clear positive-refusal proof BEFORE admission; interruptions keep orphan custody.
"""
previous = campaign.get("active_transaction")
previous = previous if isinstance(previous, dict) else {}
refusal = previous.get("admission_refused")
objective_fp = canonical_objective_fingerprint(str(campaign.get("objective") or ""))
if (transaction is None and isinstance(refusal, dict) and refusal.get("source") == campaign.get("source")
and previous.get("objective_fp") == objective_fp and not previous.get("commit_sha")):
return dict(previous)
expected_transaction = transaction
try:
from supervisor import git_ops
rc_head, head, _ = git_ops.git_capture(["git", "rev-parse", "HEAD"])
rc_branch, branch, _ = git_ops.git_capture(["git", "rev-parse", "--abbrev-ref", "HEAD"])
base_head = head.strip() if rc_head == 0 else ""
base_branch = branch.strip() if rc_branch == 0 else ""
except Exception:
base_head = ""
base_branch = ""
transaction = {
"schema_version": 2,
"transaction_id": uuid.uuid4().hex[:12],
"campaign_id": str((campaign or {}).get("id") or ""),
"task_id": str(task_id or ""),
"cycle": int(cycle or 0),
"objective_fp": objective_fp, # cycle-start truth, not a later campaign objective
"created_at": utc_now_iso(), "updated_at": utc_now_iso(),
"base_head": base_head, "base_branch": base_branch,
"preflight_status": "pending", "advisory_status": "pending", "triad_scope_status": "pending",
"commit_sha": "", "push_status": "pending",
"restart_decision": "", "restart_required": False, "restart_verified": False, "restart_verified_at": "",
"rescue_ref": "", "rescue_path": "", "recovery_hint": "",
}
if expected_transaction is not None:
transaction = {**expected_transaction, **{key: transaction[key] for key in
("base_head", "base_branch", "objective_fp", "cycle")}}
transaction.pop("admission_refused", None)
from supervisor import state
state.assert_test_data_path(state.STATE_PATH)
lock_fd = state.acquire_file_lock(state.STATE_LOCK_PATH)
if lock_fd is None:
return {}
try:
current = _read_evolution_campaign()
live_state = state.read_state().projection()
existing_tx = current.get("active_transaction")
existing_tx = existing_tx if isinstance(existing_tx, dict) else {}
if (
not state.control_is(live_state, "evolution_owner_stopped", False)
or not state.control_is(live_state, "evolution_mode_enabled", True)
or evolution_stop_reason(current)
or current.get("status") != "active"
or any(current.get(key) != campaign.get(key) for key in ("id", "source", "objective"))
or (expected_transaction is not None and existing_tx != expected_transaction)
or bool(str(existing_tx.get("commit_sha") or "").strip())
):
return {}
if expected_transaction is not None and existing_tx == transaction:
return transaction
if existing_tx and expected_transaction is None:
existing_tx.update({
"cycle_outcome": "abandoned",
"abandoned_reason": "dispatch_not_persisted",
"updated_at": utc_now_iso(),
})
append_unique_transaction(current, existing_tx)
current["active_transaction"] = transaction
current["updated_at"] = utc_now_iso()
if not _write_evolution_campaign(current, _state_lock_held=True):
return {}
stored = _read_evolution_campaign()
stored_tx = stored.get("active_transaction")
if stored_tx != transaction:
return {}
return transaction
finally:
state.release_file_lock(state.STATE_LOCK_PATH, lock_fd)
def evolution_commit_receipt_error(
tx: Dict[str, Any], *, campaign_id: str, transaction_id: str,
task_id: str, commit_sha: str,
) -> str:
if str(tx.get("commit_sha") or "") != commit_sha:
return "commit_receipt_mismatch"
receipt = tx.get("commit_receipt")
if not isinstance(receipt, dict) or not bool(receipt.get("ok")):
return "commit_receipt_missing"
expected = {
"campaign_id": campaign_id,
"transaction_id": transaction_id,
"task_id": task_id,
"commit_sha": commit_sha,
}
if any(str(receipt.get(key) or "") != value for key, value in expected.items()):
return "commit_receipt_mismatch"
return ""
def _evolution_claim_error(
campaign: Dict[str, Any], live_state: Dict[str, Any], *,
campaign_id: str, transaction_id: str, task_id: str, commit_sha: str = "",
require_uncommitted: bool = False,
) -> str:
from supervisor.state import control_value
if not campaign_id or not transaction_id or not task_id:
return "claim_identity_missing"
stopped_known, stopped = control_value(live_state, "evolution_owner_stopped")
if (stopped_known and stopped) or evolution_stop_reason(campaign):
return "owner_stopped"
enabled_known, enabled = control_value(live_state, "evolution_mode_enabled")
if not stopped_known or (not enabled_known and not commit_sha):
return "state_controls_unknown" # an unknown control never reads as permission (#1307)
if not enabled and not commit_sha:
return "evolution_disabled"
if campaign.get("status") != "active":
return "campaign_not_active"
if str(campaign.get("id") or "") != campaign_id:
return "campaign_mismatch"
tx = campaign.get("active_transaction")
if not isinstance(tx, dict):
return "transaction_missing"
if str(tx.get("transaction_id") or "") != transaction_id:
return "transaction_mismatch"
if str(tx.get("task_id") or "") != task_id:
return "task_mismatch"
if require_uncommitted and str(tx.get("commit_sha") or "").strip():
return "transaction_already_committed"
if commit_sha:
return evolution_commit_receipt_error(
tx,
campaign_id=campaign_id,
transaction_id=transaction_id,
task_id=task_id,
commit_sha=commit_sha,
)
return ""
def check_evolution_authority(
campaign_id: str, transaction_id: str, task_id: str, *, commit_sha: str = "",
require_uncommitted: bool = False,
) -> Dict[str, Any]:
"""Check the exact campaign claim under the existing state lock."""
from supervisor import state
state.assert_test_data_path(state.STATE_PATH)
lock_fd = state.acquire_file_lock(state.STATE_LOCK_PATH)
if lock_fd is None:
return {"ok": False, "reason": "state_lock_unavailable"}
try:
campaign = _read_evolution_campaign()
live_state = state.read_state().projection()
reason = _evolution_claim_error(
campaign, live_state,
campaign_id=str(campaign_id or ""),
transaction_id=str(transaction_id or ""),
task_id=str(task_id or ""),
commit_sha=str(commit_sha or ""),
require_uncommitted=bool(require_uncommitted),
)
return {
"ok": not reason,
"reason": reason,
"campaign_id": str(campaign_id or ""),
"transaction_id": str(transaction_id or ""),
"task_id": str(task_id or ""),
"commit_sha": str(commit_sha or ""),
}
finally:
state.release_file_lock(state.STATE_LOCK_PATH, lock_fd)
def _build_commit_receipt(
campaign_id: str, transaction_id: str, task_id: str, commit_sha: str,
*, reason: str, recorded_at: str,
) -> Dict[str, Any]:
"""The exact receipt shape ``evolution_commit_receipt_error`` validates.
One constructor so the receipt written by the tool path and the one
re-derived by boot recovery are the same durable fact, differing only in
``reason`` (which records HOW the SHA was attributed).
"""
return {
"ok": True,
"reason": reason,
"campaign_id": str(campaign_id),
"transaction_id": str(transaction_id),
"task_id": str(task_id),
"commit_sha": str(commit_sha),
"recorded_at": recorded_at,
}
def adopt_evolution_commit_intent(
campaign: Dict[str, Any], tx: Dict[str, Any], head_sha: str = "",
) -> str:
"""Finish an interrupted commit receipt from the pre-commit intent.
``git commit`` and its SHA receipt are two writes; a crash between them used
to leave a reviewed commit on HEAD that no boot path could attribute (the
markerless reconcile short-circuits on an empty ``commit_sha``). The intent
written before the commit carries the exact reviewed tree and parents, so
attribution here is structural rather than a guess: the commit at HEAD is
adopted only when its tree AND its full parent list are identical to that
reviewed material. A failed commit, a contained orphan (the branch is rewound
to the parent) or any later HEAD movement fails the match and recovery stays
fail-closed. The caller owns persistence, so the recovered SHA and its receipt
land in the SAME write as the decision that consumed them.
"""
intent = tx.get("commit_intent") if isinstance(tx, dict) else None
if not isinstance(intent, dict):
return ""
tree_sha = str(intent.get("tree_sha") or "")
parents = [str(value) for value in (intent.get("parents") or [])]
if not tree_sha or not parents:
return ""
head = str(head_sha or "").strip() or "HEAD"
try:
from supervisor import git_ops
rc_tree, actual_tree, _ = git_ops.git_capture(["git", "rev-parse", f"{head}^{{tree}}"])
rc_parents, parent_line, _ = git_ops.git_capture(
["git", "rev-list", "--parents", "-n", "1", head]
)
except Exception:
return ""
fields = parent_line.strip().split() if rc_parents == 0 else []
if rc_tree != 0 or not fields or actual_tree.strip() != tree_sha or fields[1:] != parents:
return ""
commit_sha, now = fields[0], utc_now_iso()
tx.update({
"commit_sha": commit_sha,
"commit_receipt": _build_commit_receipt(
str(campaign.get("id") or ""), str(tx.get("transaction_id") or ""),
str(tx.get("task_id") or ""), commit_sha,
reason="recovered_from_commit_intent", recorded_at=now,
),
"commit_receipt_recovered": True,
"restart_required": True,
"restart_verified": False,
"updated_at": now,
})
campaign["active_transaction"] = tx
return commit_sha
def record_evolution_commit(
campaign_id: str, transaction_id: str, task_id: str, commit_sha: str,
*, triad_scope_status: str = "unknown", author_disposition: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""CAS the exact reviewed local commit into its still-authorized transaction."""
from supervisor import state
commit_sha = str(commit_sha or "").strip()
if not commit_sha:
return {"ok": False, "reason": "commit_sha_missing"}
state.assert_test_data_path(state.STATE_PATH)
lock_fd = state.acquire_file_lock(state.STATE_LOCK_PATH)
if lock_fd is None:
return {"ok": False, "reason": "state_lock_unavailable", "commit_sha": commit_sha}
try:
live_state = state.read_state().projection()
from ouroboros.utils import update_json_locked
receipt: Dict[str, Any] = {}
refused = {"reason": "campaign_write_refused"}
def _mutate(campaign: Dict[str, Any]) -> Optional[Dict[str, Any]]:
reason = _evolution_claim_error(
campaign, live_state,
campaign_id=str(campaign_id or ""),
transaction_id=str(transaction_id or ""),
task_id=str(task_id or ""),
)
tx = campaign.get("active_transaction")
prior_sha = str((tx or {}).get("commit_sha") or "") if isinstance(tx, dict) else ""
if not reason and prior_sha and prior_sha != commit_sha:
reason = "commit_receipt_conflict"
if reason:
refused["reason"] = reason
return None
now = utc_now_iso()
receipt.update(_build_commit_receipt(
campaign_id, transaction_id, task_id, commit_sha,
reason="recorded", recorded_at=now,
))
tx.update({
"preflight_status": "passed",
"advisory_status": "fresh_or_bypassed",
"triad_scope_status": str(triad_scope_status or "unknown"),
**({"author_disposition": dict(author_disposition)} if author_disposition else {}),
"commit_sha": commit_sha,
"commit_receipt": dict(receipt),
"restart_required": True,
"restart_verified": False,
"updated_at": now,
})
campaign["active_transaction"] = tx
campaign["updated_at"] = now
return campaign
update_json_locked(
_evolution_campaign_path(), _mutate,
timeout_sec=EVOLUTION_CAMPAIGN_CAS_TIMEOUT_SEC,
strict_existing_dict=True,
)
if not receipt:
return {"ok": False, "reason": refused["reason"], "commit_sha": commit_sha}
return receipt
except Exception as exc:
log.warning("Failed to record exact evolution commit receipt", exc_info=True)
return {"ok": False, "reason": f"campaign_write_failed:{type(exc).__name__}", "commit_sha": commit_sha}
finally:
state.release_file_lock(state.STATE_LOCK_PATH, lock_fd)
def link_evolution_rescue(drive_root: pathlib.Path, rescue_info: Dict[str, Any]) -> Dict[str, Any]:
"""Attach rescue pointers without racing an exact commit receipt."""
from supervisor import state
root = pathlib.Path(drive_root)
path = root / EVOLUTION_CAMPAIGN_FILE
lock_path = root / "locks" / "state.lock"
state.assert_test_data_path(path)
lock_fd = state.acquire_file_lock(lock_path)
if lock_fd is None:
return {}
try:
from ouroboros.utils import update_json_locked
linked: Dict[str, Any] = {}
def _mutate(campaign: Dict[str, Any]) -> Optional[Dict[str, Any]]:
if campaign.get("status") not in {"active", "paused"}:
return None
tx = campaign.get("active_transaction")
if not isinstance(tx, dict):
return None
tx["rescue_ref"] = str(rescue_info.get("rescue_ref") or "")
tx["dirty_snapshot_ref"] = str(rescue_info.get("rescue_ref") or "")
tx["rescue_path"] = str(rescue_info.get("path") or "")
tx["restart_decision"] = "rescue_snapshot_created"
tx["recovery_hint"] = (
f"Recover with {tx['rescue_ref']} or inspect {tx['rescue_path']}"
if tx.get("rescue_ref") or tx.get("rescue_path")
else "Rescue attempted; inspect supervisor logs."
)
tx["updated_at"] = utc_now_iso()
campaign["active_transaction"] = tx
campaign["updated_at"] = utc_now_iso()
linked.update(tx)
return campaign
update_json_locked(
path, _mutate,
timeout_sec=EVOLUTION_CAMPAIGN_CAS_TIMEOUT_SEC,
strict_existing_dict=True,
)
return linked
finally:
state.release_file_lock(lock_path, lock_fd)
def _bump_objective_repeat_count(campaign: Dict[str, Any], tx: Dict[str, Any]) -> None:
"""BUG3: count one non-absorbing cycle against its objective fingerprint.
Cumulative PER-FINGERPRINT (not a consecutive streak), so a blocked objective that is
re-proposed NON-consecutively (interleaved with other no_op work) still accumulates toward
the pause gate. ``setdefault`` tolerates campaigns persisted before this field existed; a
transaction without an ``objective_fp`` (e.g. a tx-less idle cycle) is skipped, never
bucketed under the empty key.
"""
fp = str((tx or {}).get("objective_fp") or "")
if not fp:
return
counts = campaign.setdefault("objective_repeat_counts", {})
counts[fp] = int(counts.get(fp, 0) or 0) + 1
# Layer B: also mark this objective attempted-and-dropped so the chooser (Layer A) can be
# told not to re-propose it. This is a campaign-local signal, NOT a backlog status flip:
# the backlog item stays "open" (the work is genuinely unsolved), we only stop FEEDING it
# back to the evolution objective chooser.
dropped = campaign.setdefault("dropped_objective_fps", [])
if fp not in dropped:
dropped.append(fp)
def _clear_objective_repeat_count(campaign: Dict[str, Any], tx: Dict[str, Any]) -> None:
"""BUG3: a genuine absorb clears ONLY this objective's repeat tally and dropped flag.
Called at every site that sets ``cycle_outcome == "absorbed"`` (task-done here, plus the
two durable boot/restart-verify absorb sites in agent_startup_checks). Keyed on the same
SSOT fingerprint as the bump so success on the looping objective resets exactly its bucket
and un-drops it (it landed, so it is no longer do-not-re-propose).
"""
fp = str((tx or {}).get("objective_fp") or "")
if not fp:
return
counts = campaign.setdefault("objective_repeat_counts", {})
counts.pop(fp, None)
dropped = campaign.setdefault("dropped_objective_fps", [])
if fp in dropped:
dropped.remove(fp)
def update_evolution_transaction(task_id: str, **updates: Any) -> bool:
"""Best-effort update of the active/lightweight evolution transaction."""
from supervisor import state
state.assert_test_data_path(state.STATE_PATH)
lock_fd = state.acquire_file_lock(state.STATE_LOCK_PATH)
if lock_fd is None:
return False
try:
campaign = _read_evolution_campaign()
tx = campaign.get("active_transaction")
if not isinstance(tx, dict) or str(tx.get("task_id") or "") != str(task_id or ""):
return False
updates = {key: value for key, value in updates.items() if value is not None}
if all(tx.get(key) == value for key, value in updates.items()):
return True
tx.update(updates)
tx["updated_at"] = utc_now_iso()
campaign["active_transaction"] = tx
campaign["updated_at"] = utc_now_iso()
return _write_evolution_campaign(campaign, _state_lock_held=True)
finally:
state.release_file_lock(state.STATE_LOCK_PATH, lock_fd)
def _cleanup_worktree_after_cycle(tx: Dict[str, Any], task_id: str) -> None:
"""Restore a no-op/abandoned admitted cycle's base, preserving dirty/ahead work
in recorded stash/local refs. Never reset a positively unadmitted cycle or
another live writer; unknown base, live tests and the cleanup kill-switch skip
cleanup with a reason. Never raises (OUROBOROS_EVOLUTION_CYCLE_CLEANUP=false).
"""
if str(os.environ.get("OUROBOROS_EVOLUTION_CYCLE_CLEANUP", "true") or "true").lower() in {"0", "false", "no", "off"}:
tx["cleanup_status"] = "disabled"
return
if tx.get("admission_refused") and not tx.get("commit_sha"):
tx["cleanup_status"] = "skipped_never_admitted"
return
base_head = str(tx.get("base_head") or "").strip()
if not base_head:
tx["cleanup_status"] = "skipped_no_base"
return
update_lock_fh = None
release_update_lock = None
try:
from supervisor import git_ops, queue
from supervisor.update_merge import acquire_update_lock, release_update_lock
from supervisor.workers import repo_writer_admission_closed
try:
update_lock_fh = acquire_update_lock()
except RuntimeError:
tx["cleanup_status"] = "skipped_managed_update_lock_busy"
return
admission_reason = repo_writer_admission_closed()
if admission_reason:
tx["cleanup_status"] = "skipped_repo_writer_admission_closed"
tx["cleanup_deferred_reason"] = admission_reason
return
# Same protection class as git_ops._guard_live_repo_destructive_git, but
# covering the stash too: a unit test that never re-pointed
# git_ops.REPO_DIR must not stash/reset the LIVE repo's working tree.
if os.environ.get("OUROBOROS_ALLOW_LIVE_REPO_TESTS") != "1":
import sys as _sys
try:
live_repo = git_ops.REPO_DIR.resolve(strict=False) == (
pathlib.Path.home() / "Ouroboros" / "repo"
).resolve(strict=False)
except OSError:
live_repo = False
if live_repo and ("PYTEST_CURRENT_TEST" in os.environ or "pytest" in _sys.modules):
tx["cleanup_status"] = "skipped_live_repo_test_guard"
return
# Lock-free RUNNING snapshot is safe because this runs on the SAME
# single supervisor thread that assigns tasks (dispatch_event ->
# assign_tasks are sequential); only cancel paths mutate RUNNING from
# HTTP threads, which can only shrink the set.
running_others = [tid for tid in list(queue.RUNNING.keys()) if str(tid) != str(task_id)]
if running_others:
# The live worktree is shared: a reset would destroy concurrent
# tasks' work. Leave state for the boot reconcile / next cycle.
tx["cleanup_status"] = "skipped_other_tasks_running"
return
rc_status, status_out, _ = git_ops.git_capture(["git", "status", "--porcelain"])
rc_head, head_out, _ = git_ops.git_capture(["git", "rev-parse", "HEAD"])
if rc_status != 0 or rc_head != 0:
tx["cleanup_status"] = "skipped_git_unavailable"
return
dirty = bool(status_out.strip())
head = head_out.strip()
if not dirty and head == base_head:
tx["cleanup_status"] = "already_clean"
return
if dirty:
stash_label = f"evolution-cycle-cleanup-{tx.get('transaction_id') or task_id}"
rc_stash, _, stash_err = git_ops.git_capture(
["git", "stash", "push", "--include-untracked", "-m", stash_label]
)
if rc_stash != 0:
from ouroboros.utils import truncate_review_artifact
# Refuse to reset over unsaved changes (P1: no silent loss).
tx["cleanup_status"] = "skipped_stash_failed"
tx["recovery_hint"] = (
"worktree dirty and stash failed: "
+ truncate_review_artifact(str(stash_err or "").strip(), 400)
)
return
tx["cleanup_stash"] = stash_label
if head != base_head:
preserved, ref_name = git_ops.preserve_local_ref_branch("HEAD", prefix="evolution-leftover")
if not preserved:
tx["cleanup_status"] = "skipped_preserve_failed"
tx["recovery_hint"] = (
"HEAD ahead of base_head and preserve-branch failed; left as-is"
+ (f" (dirty files already saved in stash {tx['cleanup_stash']})." if tx.get("cleanup_stash") else ".")
)
return
tx["cleanup_preserved_ref"] = ref_name
rc_reset, _, reset_err = git_ops.git_capture(["git", "reset", "--hard", base_head])
if rc_reset != 0:
from ouroboros.utils import truncate_review_artifact
tx["cleanup_status"] = "reset_failed"
tx["recovery_hint"] = (
f"git reset --hard {base_head[:12]} failed: "
+ truncate_review_artifact(str(reset_err or "").strip(), 400)
)
return
tx["cleanup_status"] = "reset_to_base"
else:
tx["cleanup_status"] = "stashed_dirty" # HEAD already at base; only the stash happened
log.info(
"Evolution cycle %s cleanup: worktree restored to base %s (stash=%s, preserved=%s)",
tx.get("transaction_id") or task_id, base_head[:12],
tx.get("cleanup_stash") or "-", tx.get("cleanup_preserved_ref") or "-",
)
except Exception:
tx["cleanup_status"] = "error"
log.debug("Evolution cycle worktree cleanup failed", exc_info=True)
finally:
if update_lock_fh is not None and release_update_lock is not None:
release_update_lock(update_lock_fh)
def _persist_evolution_cleanup(campaign_id: str, tx: Dict[str, Any]) -> bool:
"""Patch cleanup evidence into the exact terminal transaction under the state lock."""
from supervisor import state
lock_fd = state.acquire_file_lock(state.STATE_LOCK_PATH)
if lock_fd is None:
return False
try:
campaign = _read_evolution_campaign()
if str(campaign.get("id") or "") != str(campaign_id or ""):
return False
tx_id = str(tx.get("transaction_id") or "")
cleanup = {
key: tx[key]
for key in (
"cleanup_status", "cleanup_stash", "cleanup_preserved_ref",
"recovery_hint",
)
if key in tx
}
updated = False
for item in list(campaign.get("transaction_history") or []):
if isinstance(item, dict) and str(item.get("transaction_id") or "") == tx_id:
item.update(cleanup)
updated = True
for row in list(campaign.get("history") or []):
item = row.get("transaction") if isinstance(row, dict) else None
if isinstance(item, dict) and str(item.get("transaction_id") or "") == tx_id:
item.update(cleanup)
updated = True
if not updated:
return False
campaign["updated_at"] = utc_now_iso()
return _write_evolution_campaign(
campaign, expected_campaign_id=campaign_id, _state_lock_held=True,
)
finally:
state.release_file_lock(state.STATE_LOCK_PATH, lock_fd)
def _resume_evolution_terminal_effects(
campaign_id: str, task_id: str, tx: Dict[str, Any],
) -> Dict[str, Any]:
"""Finish idempotent effects that follow the durable terminal transition."""
from supervisor import queue
resumed = dict(tx or {})
if resumed.get("cleanup_status") == "pending":
_cleanup_worktree_after_cycle(resumed, str(task_id or ""))
try:
if not _persist_evolution_cleanup(campaign_id, resumed):
log.warning("Failed to persist evolution cleanup evidence for %s", task_id)
except Exception:
log.warning("Failed to persist evolution cleanup evidence for %s", task_id, exc_info=True)
if (
resumed.get("cycle_outcome") == "waiting_for_restart"
and resumed.get("restart_required")
and not resumed.get("restart_verified")
):
try:
request_evolution_restart(queue.DRIVE_ROOT, resumed, log=log)
except Exception:
log.warning("Failed to resume evolution restart request for %s", task_id, exc_info=True)
# Abandon/absorb reports are staged in the same campaign write as the
# transition. Delivery clears only the exact report after a successful send.
deliver_pending_owner_report()
return resumed
def update_evolution_campaign_after_task(
task_id: str,
*,
cost_usd: Optional[float],
cost_accounting_status: str = "available",
outcome_axes: Dict[str, Any],
rounds: int,
transaction: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Record an evolution cycle outcome in the active campaign file."""
from supervisor import state
state.assert_test_data_path(state.STATE_PATH)
lock_fd = state.acquire_file_lock(state.STATE_LOCK_PATH)
if lock_fd is None:
return {
"accepted": True, "persisted": False, "replay": False,
"reason": "state_lock_unavailable", "transaction": {},
}
replay_found = False
replay_tx: Dict[str, Any] = {}
campaign_id = ""
tx: Dict[str, Any] = {}
try:
# The complete read/check/mutate/write is protected by the same state lock
# used by pause, owner stop, boot reconciliation, and transaction updates.
campaign = _read_evolution_campaign()
if campaign.get("status") not in {"active", "paused"}:
return {
"accepted": False, "persisted": False, "replay": False,
"reason": "campaign_not_active", "transaction": {},
}
metadata_tx = transaction if isinstance(transaction, dict) else {}
active_tx = (
campaign.get("active_transaction")
if isinstance(campaign.get("active_transaction"), dict) else {}
)
campaign_id = str(campaign.get("id") or "")
def _matches(candidate: Dict[str, Any]) -> bool:
return bool(
campaign_id
and str(candidate.get("campaign_id") or "") == campaign_id
and str(candidate.get("transaction_id") or "")
and str(candidate.get("task_id") or "") == str(task_id or "")
)
metadata_tx_id = (
str(metadata_tx.get("transaction_id") or "") if _matches(metadata_tx) else ""
)
if metadata_tx_id:
for existing in list(campaign.get("history") or []):
if (
not isinstance(existing, dict)
or str(existing.get("task_id") or "") != str(task_id or "")
):
continue
existing_tx = existing.get("transaction")
if (
isinstance(existing_tx, dict)
and str(existing_tx.get("campaign_id") or "") == campaign_id
and str(existing_tx.get("transaction_id") or "") == metadata_tx_id
):
replay_found = True
replay_tx = dict(existing_tx)
break
if not replay_found and not active_tx and not metadata_tx:
# Preserve idempotency for old history-only records, but never let a
# new metadata-less terminal mutate whichever campaign happens to be active.
for existing in list(campaign.get("history") or []):
if (
isinstance(existing, dict)
and str(existing.get("task_id") or "") == str(task_id or "")
):
replay_found = True
replay_tx = dict(existing.get("transaction") or {})
break
if not replay_found:
return {
"accepted": False, "persisted": False, "replay": False,
"reason": "transaction_missing", "transaction": {},
}
if not replay_found:
if (
not _matches(active_tx)
or not _matches(metadata_tx)
or str(active_tx.get("transaction_id") or "")
!= str(metadata_tx.get("transaction_id") or "")
):
return {
"accepted": False, "persisted": False, "replay": False,
"reason": "transaction_mismatch", "transaction": {},
}
tx = {**metadata_tx, **active_tx}
axes = normalize_outcome_axes({"outcome_axes": outcome_axes or {}})
tx["outcome_axes"] = axes
tx["updated_at"] = utc_now_iso()
history = list(campaign.get("history") or [])
cost_available = cost_accounting_status == "available" and cost_usd is not None
row = {
"task_id": str(task_id or ""),
"ts": utc_now_iso(),
# ABI-3 (fix-round-3): the honest cost name — this row reaches
# /api/state through the evolution snapshot. Stored legacy rows
# (cost_usd) keep resolving deprecated-wins at the readers and
# at the /api/state projection boundary.
"accounted_upper_bound_usd": float(cost_usd) if cost_available else None,
"cost_accounting_status": "available" if cost_available else "unavailable",
"outcome_axes": axes,
"rounds": int(rounds or 0),
"transaction": tx,
}
history.append(row)
campaign["history"] = history[-50:]
has_commit = bool(str(tx.get("commit_sha") or "").strip())
if not has_commit:
# A crash between the reviewed commit and its SHA receipt can reach
# task-done before boot recovery: adopt the commit the intent proves,
# so the cycle is classified as commit-bearing instead of ``no_op``.
has_commit = bool(adopt_evolution_commit_intent(campaign, tx))
restart_verified = bool(tx.get("restart_verified"))
has_rescue = bool(str(tx.get("rescue_ref") or "").strip())
if has_commit and restart_verified:
tx["cycle_outcome"] = "absorbed"
campaign["absorbed_cycles_done"] = int(
campaign.get("absorbed_cycles_done") or 0
) + 1
append_unique_transaction(campaign, tx)
campaign.pop("active_transaction", None)
_clear_objective_repeat_count(campaign, tx)
elif has_rescue:
tx["cycle_outcome"] = "abandoned"
tx["abandoned_reason"] = "rescue_ref_present"
tx["cleanup_status"] = "pending"
append_unique_transaction(campaign, tx)
campaign.pop("active_transaction", None)
campaign.pop("post_task_backlog_id", None)
_bump_objective_repeat_count(campaign, tx)
elif not has_commit:
tx["cycle_outcome"] = "no_op"
tx["restart_required"] = False
tx["recovery_hint"] = ""
tx["cleanup_status"] = "pending"
append_unique_transaction(campaign, tx)
campaign.pop("active_transaction", None)
campaign.pop("post_task_backlog_id", None)
_bump_objective_repeat_count(campaign, tx)
else:
tx["cycle_outcome"] = "waiting_for_restart"
tx["recovery_hint"] = tx.get("recovery_hint") or (
"Task ended without a reviewed commit plus restart verification; active "
"transaction retained until restart verifies, repo state is recovered, or it is superseded."
)
tx["restart_required"] = True
if not tx.get("restart_decision"):
tx["restart_decision"] = "supervisor_auto_requested"
campaign["active_transaction"] = tx
if tx.get("cycle_outcome") in {"absorbed", "abandoned"}:
campaign["pending_owner_report"] = dict(tx)
campaign["last_task_id"] = str(task_id or "")
campaign["cycles_done"] = int(campaign.get("cycles_done") or 0) + 1
execution_status = str((axes.get("execution") or {}).get("status") or "unknown")
objective_status = str((axes.get("objective") or {}).get("status") or "not_evaluated")
cost_note = f"${float(cost_usd):.4f}" if cost_available else "unavailable"
campaign["progress_notes"] = (
f"Last cycle {task_id}: execution={execution_status}, objective={objective_status}, "
f"rounds={int(rounds or 0)}, cost={cost_note}."
)
if cost_available:
campaign["budget_spent_usd"] = round(
float(campaign.get("budget_spent_usd") or 0.0) + float(cost_usd), 6,
)
else:
campaign["cost_accounting_status"] = "unavailable"
campaign["updated_at"] = utc_now_iso()
try:
persisted = _write_evolution_campaign(
campaign,
expected_campaign_id=campaign_id,
_state_lock_held=True,
)
except Exception:
log.warning("Failed to persist evolution terminal for %s", task_id, exc_info=True)
return {
"accepted": True, "persisted": False, "replay": False,
"reason": "campaign_write_failed", "transaction": {},
}
if not persisted:
return {
"accepted": True, "persisted": False, "replay": False,
"reason": "campaign_write_refused", "transaction": {},
}
finally:
state.release_file_lock(state.STATE_LOCK_PATH, lock_fd)
if replay_found:
replay_tx = _resume_evolution_terminal_effects(campaign_id, str(task_id or ""), replay_tx)
return {
"accepted": True, "persisted": True, "replay": True,
"reason": "duplicate_terminal", "transaction": replay_tx,
}
tx = _resume_evolution_terminal_effects(campaign_id, str(task_id or ""), tx)
return {
"accepted": True, "persisted": True, "replay": False,
"reason": "", "transaction": tx,
}
def build_evolution_task_text(cycle: int) -> str:
"""Build the next evolution-campaign task prompt."""
from ouroboros.config import get_evolution_persistent_objective
campaign = _read_evolution_campaign()
if campaign.get("status") != "active":
return f"EVOLUTION #{cycle}"
parts = [
f"EVOLUTION CAMPAIGN {campaign.get('id') or 'active'} — CYCLE #{cycle}",
"",
"## Objective",
str(campaign.get("objective") or "Autonomously improve Ouroboros."),
]
steer = get_evolution_persistent_objective()
if steer:
parts.extend([
"",
"## Owner Standing Steer (optional bias — does NOT override the Objective above)",
steer,
])
progress = str(campaign.get("progress_notes") or "").strip()
if progress:
parts.extend(["", "## Progress So Far", progress])
history = list(campaign.get("history") or [])[-3:]
if history:
parts.extend(["", "## Recent Campaign Cycles"])
for row in history:
axes = normalize_outcome_axes(row)
execution_status = str((axes.get("execution") or {}).get("status") or "unknown")
objective_status = str((axes.get("objective") or {}).get("status") or "not_evaluated")
# ABI-3 read tolerance: a stored legacy row's spelling resolves
# deprecated-wins; new rows carry the honest name only.
_, row_amount = honest_cost_pair_amount(
row, "accounted_upper_bound_usd", "cost_usd",
)
row_cost = (
f"${row_amount:.4f}"
if row.get("cost_accounting_status") != "unavailable"
and row_amount is not None else "unavailable"
)
parts.append(
f"- {row.get('task_id')}: execution={execution_status}, objective={objective_status}; "
f"rounds={row.get('rounds', 0)}; cost={row_cost}"
)
# Fix B (C10.2): surface the durable improvement backlog and recent solve-capability
# as optional CONTEXT, never a directive. Ouroboros decides what (if anything) to act
# on — an evolution cycle is NOT obligated to draw from the backlog or repeat past
# patterns. Injecting them is LLM-first steering, not a hardcoded work order.
try:
from ouroboros.evolution_checkpoints import build_solve_capability_digest
from ouroboros.improvement_backlog import format_backlog_digest
from ouroboros.utils import truncate_review_artifact
from supervisor import queue
_digest_root = pathlib.Path(queue.DRIVE_ROOT)
_backlog_digest = format_backlog_digest(_digest_root, limit=8, max_chars=3000)
if _backlog_digest:
parts.extend([
"",
"## Improvement Backlog (context only — NOT a work order)",
"Standing nominations from past cycles. Weigh them if useful, but you are "
"free to pursue the Objective however you judge best; you need not pick "
"from this list.",
"",
_backlog_digest,
])
_capability_digest = truncate_review_artifact(build_solve_capability_digest(_digest_root), 2000)
if _capability_digest:
parts.extend([
"",
"## Recent Solve-Capability (context only)",
_capability_digest,
])
except Exception:
log.debug("evolution task digest injection failed", exc_info=True)
parts.extend([
"",
"## Execution Contract",
"- Work as a normal Ouroboros self-improvement task.",
"- Use standard tests and the normal advisory + triad + scope review flow before committing code.",
"- Land at most ONE reviewed self-modification commit in this cycle. Fold reviewer fixes into that commit before committing; do not churn follow-up commits.",
"- After a reviewed commit lands, call request_restart once and stop. Restart verification is the absorption boundary for the cycle.",
"- An honest no-op is a legitimate outcome when the objective is unsafe, already solved, too broad, or needs owner input; do not commit just to make a cycle non-empty.",
"- If the best next step is memory/identity/backlog rather than code, update those durable artifacts with provenance, but do not treat that as an absorbed self-evolution cycle.",
"- A true absorbed self-evolution cycle requires one reviewed self-modification commit followed by successful restart verification before the next campaign cycle.",
"- The review enforcement mode (advisory vs blocking) is the owner's setting. Do NOT hardcode review findings to always block (or always pass) regardless of that mode: forcing per-finding blocks under an owner-chosen advisory mode is forbidden self-modification (BIBLE P3), not a hardening. If advisory pass-through of a critical finding feels wrong, surface it to the owner — never patch the enforcement gate to override their choice.",
"- If the objective is complete or needs owner input, say so clearly in the final result.",
])
return "\n".join(parts)
def notify_owner_cycle_outcome(campaign: Dict[str, Any], tx: Dict[str, Any]) -> None:
"""WS-13.5 (e5=ux_absorb_report): owner-facing chat note for a finished
self-evolution cycle. Absorbed -> short what/why; abandoned -> honest
warning; no_op / waiting -> quiet (the lifecycle event already records it).
No web/UI edits; chat only, budget-gated. Lazy imports avoid an import
cycle with supervisor.state / supervisor.message_bus.
"""
outcome = str(tx.get("cycle_outcome") or "")
if outcome not in ("absorbed", "abandoned"):
return # no_op / waiting_for_restart: event-only, stay quiet
from supervisor.state import load_state
from supervisor.message_bus import send_with_budget
owner_chat_id = int(load_state().get("owner_chat_id") or 0)
if not owner_chat_id:
return
objective = str(campaign.get("objective") or "").strip()
obj_short = (objective[:160] + "…") if len(objective) > 160 else objective
if outcome == "absorbed":
commit_sha = str(tx.get("commit_sha") or "").strip()[:12]
msg = (
f"🧬 Evolution cycle absorbed (commit {commit_sha}).\n"
f"Objective: {obj_short or 'autonomous self-improvement'}\n"
"The reviewed self-modification is now live (restart verified). Reply if you want it reverted."
)
else:
reason = str(tx.get("abandoned_reason") or "unspecified")
msg = (
f"⚠️ Evolution cycle abandoned (reason: {reason}).\n"
f"Objective: {obj_short or 'autonomous self-improvement'}\n"
"No change was absorbed; the transaction was rolled back/closed to unblock the next cycle."
)
send_with_budget(owner_chat_id, msg, role="system", system_type="evolution_cycle_outcome")
def clear_pending_owner_report(expected: Dict[str, Any]) -> bool:
"""Clear only the report that was sent, without overwriting newer campaign state."""
from ouroboros.utils import update_json_locked
from supervisor import state
path = _evolution_campaign_path()
state.assert_test_data_path(path)
lock_fd = state.acquire_file_lock(state.STATE_LOCK_PATH)
if lock_fd is None:
return False
cleared = {"ok": False}
def _mutate(campaign: Dict[str, Any]) -> Optional[Dict[str, Any]]:
if campaign.get("pending_owner_report") != expected:
return None
campaign.pop("pending_owner_report", None)
campaign["updated_at"] = utc_now_iso()
cleared["ok"] = True
return campaign
try:
update_json_locked(path, _mutate, strict_existing_dict=True)
return bool(cleared["ok"])
finally:
state.release_file_lock(state.STATE_LOCK_PATH, lock_fd)
def append_unique_transaction(campaign: Dict[str, Any], tx: Dict[str, Any]) -> None:
tx_history = list(campaign.get("transaction_history") or [])
tx_id = str(tx.get("transaction_id") or "")
if tx_id and any(isinstance(item, dict) and str(item.get("transaction_id") or "") == tx_id for item in tx_history):
campaign["transaction_history"] = tx_history[-50:]
return
tx_history.append(dict(tx))
campaign["transaction_history"] = tx_history[-50:]
def write_pending_restart_marker(
drive_root: pathlib.Path, *, expected_sha: str, expected_branch: str, reason: str,
evolution_claim: Optional[Dict[str, Any]] = None,
) -> pathlib.Path:
"""One schema of ``state/pending_restart_verify.json`` for both writers (the
supervisor's evolution restart, the agent's ``restart`` tool); ``verify_restart``
reads it at boot. The claim key exists only for an exact evolution claim — an
empty one would read as a claim mismatch."""
path = pathlib.Path(drive_root) / "state" / "pending_restart_verify.json"
atomic_write_json(path, {
"ts": utc_now_iso(),
"expected_sha": str(expected_sha or "").strip(),
"expected_branch": str(expected_branch or "").strip(),
"reason": str(reason or "").strip(),
**({"evolution_claim": dict(evolution_claim)} if evolution_claim else {}),
}, trailing_newline=True)
return path
def request_evolution_restart(drive_root: pathlib.Path, tx: Dict[str, Any], log: Any = None) -> None:
"""Write the exact restart-verify claim, then ask the server to restart.
``OUROBOROS_EVOLUTION_AUTO_RESTART`` off skips ONLY the restart: the marker is
what lets the next boot — the owner's manual one included — attribute the cycle
by exact claim rather than the weaker markerless reconcile (W4-F3)."""
commit_sha = str(tx.get("commit_sha") or "").strip()
if not commit_sha:
return
claim = {key: str(tx.get(key) or "") for key in ("campaign_id", "transaction_id", "task_id")}
claim["commit_sha"] = commit_sha
authority = check_evolution_authority(**claim)
if not authority.get("ok"):
if log is not None:
log.warning("Automatic evolution restart cancelled: exact authority changed (%s)",
authority.get("reason") or "unknown")
return
try:
existing = read_json_dict(pathlib.Path(drive_root) / "state" / "pending_restart_verify.json") or {}
restart_reason = (
str(existing.get("reason") or "").strip() if existing.get("evolution_claim") == claim else ""
) or "supervisor_auto_evolution_restart"
write_pending_restart_marker(
drive_root, expected_sha=commit_sha, expected_branch=str(tx.get("base_branch") or ""),
reason=restart_reason, evolution_claim=claim,
)
auto_restart = str(os.environ.get("OUROBOROS_EVOLUTION_AUTO_RESTART", "true") or "true").lower()
if auto_restart in {"0", "false", "no", "off"}:
if log is not None:
log.info("Automatic evolution restart is off; the restart-verify marker for %s awaits "
"a manual restart", commit_sha[:12])
return
from supervisor import workers
workers.get_event_q().put({
"type": "restart_request", "reason": restart_reason,
"evolution_restart": True, "ts": utc_now_iso(),
})
except Exception:
if log is not None:
log.debug("Failed to request automatic evolution restart", exc_info=True)