mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-19 10:04:16 +00:00
Update letter: Ouroboros writes one paragraph about a pending official update
A fetching managed-update check (boot, and the Updates panel's Check for updates) now also writes an update letter: one accounted LIGHT-slot call with the ordinary task context (build_llm_messages routed at the light model), fed the first-parent commits between the running base and the official target plus the README Version History rows those commits added, recovered from the commit diffs because the table is capped and untagged releases have no tag to look up. The letter lives in state/update_letter.json under its exact (base, target, channel, ref) key and is never deleted after the update lands; one projection relates it to the live HEAD (pending / applied / superseded / other) for both the Updates payload (`letter`) and the Runtime context, whose new `official_update` fact tells every task and every consciousness cycle whether the body is current with its official source as of the last check. No wake is forced, no new endpoint or WebSocket type, no model substitution when the light slot has no credentials (typed failure instead), the last good letter survives a failed rewrite, and cost stays in the ledger by attempt id. CONSCIOUSNESS.md gains one guideline (6682 -> 6927 bytes): mentioning an update is the mind's judgment, not a duty. Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
parent
83a69e673a
commit
d89bba3d4e
12 changed files with 971 additions and 1 deletions
|
|
@ -65,6 +65,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
|
|||
├── settings_integrity.py ← Strict settings-snapshot integrity pin; `OUROBOROS_SETTINGS_SHA256` enables the trust root
|
||||
├── credential_shapes.py ← Filename shapes that commonly indicate credential material; blocks root read imports
|
||||
├── update_channels.py ← Closed Stable/QA/Development channel mapping and update-network defaults
|
||||
├── update_letter.py ← The update letter: first-parent range material (commits plus the README history rows they added, recovered from commit diffs because the table is capped and untagged releases have no tag), one accounted LIGHT-slot call with the ordinary task context, `state/update_letter.json`, and the one letter projection shared by the Updates panel payload and the Runtime context `official_update` fact; written synchronously inside a fetching check, never deleted after apply
|
||||
├── colab_bootstrap.py ← Google Colab source-mode bootstrap: official update source, stable local `ouroboros` branch, Drive-backed settings/data, personal origin, no-UI server command, native Telegram setup
|
||||
├── cli.py ← Source/headless CLI over gateway tasks, logs, settings, skills, marketplace, local-model, and MCP wrappers
|
||||
├── packaged_cli.py ← Packaged desktop CLI bridge: resolves bundle roots, bootstraps the launcher-managed repo, delegates to cli.py
|
||||
|
|
@ -509,6 +510,7 @@ Extension children, delegated runtimes, services, the local model, and companion
|
|||
│ │ ├── post_task_evolution_counter.json ← per-drive every_n counter
|
||||
│ │ ├── scheduled_tasks.json ← cron (5-field + tz) and one-shot {type:"once", run_at} schedules; consumed one-shot receipts age out past the unified GC retention
|
||||
│ │ ├── claudexor_rotation_provisioning.json ← receipt of the last rotation-reconcile settings POST
|
||||
│ │ ├── update_letter.json ← the last update letter (key = base/target/channel/ref, state, text, `last_good`); kept after apply and projected as pending/applied/superseded/other against the live HEAD (update_letter.py)
|
||||
│ │ ├── projects.json ← Project registry: immutable id/chat identity, working folder, lifecycle/routing fence, revision; tombstones are durable and never age-pruned
|
||||
│ │ ├── project_task_bindings.json ← schema v1 root↔Project bindings with REQUIRED typed origin; one-way enrichment; tombstoning never removes a binding
|
||||
│ │ ├── ui_preferences.json ← owner-local layout preferences + monotonic project_seen_revision ACKs
|
||||
|
|
@ -1008,7 +1010,7 @@ Prompt caching is stable-first: governance and task-stable contracts precede mut
|
|||
|
||||
#### Background consciousness and Evolution
|
||||
|
||||
Background Consciousness is the high-horizon awareness loop. It can groom memory, identity candidates, knowledge, and the improvement backlog, message the owner, and initiate an already configured reviewed Presence binding; it does not acquire the binding's tools or transport authority, and it does not run shell/code work, subagents, reviews, commits, or evolution toggles — awareness proposes work without bypassing task, budget, and review authority.
|
||||
Background Consciousness is the high-horizon awareness loop. It can groom memory, identity candidates, knowledge, and the improvement backlog, message the owner, and initiate an already configured reviewed Presence binding; it does not acquire the binding's tools or transport authority, and it does not run shell/code work, subagents, reviews, commits, or evolution toggles — awareness proposes work without bypassing task, budget, and review authority. Awareness of the body's own version is ambient: the Runtime context of every task and every consciousness cycle carries `official_update` — running version versus the official target as of the last fetching check, plus the update letter (`update_letter.py`); no wake is forced by a check, and whether to mention an update to the owner is the mind's judgment (`prompts/CONSCIOUSNESS.md`).
|
||||
|
||||
Its observation inbox is the append-only `state/consciousness_observations.jsonl` store. Producers write a stable-ID `enqueue` row before the wake notification returns; `_ack_observations` appends ACK rows only after the thought receipt, tool receipts, budget settlement, and other durable writes for that snapshot have succeeded, so any failure leaves the snapshot pending with an actor-readable source reference. The status surface reports only pending count, oldest timestamp, source, and gap count. When the same cycle can call `update_identity`, an observation gap joins the identity-completeness envelope and blocks that destructive write until the actor resolves the named source.
|
||||
|
||||
|
|
@ -1285,6 +1287,7 @@ Providers name the same output-token budget differently: OpenRouter/Anthropic-co
|
|||
| Skill publish PR body generation | 8,192 |
|
||||
| Background consciousness loop | 65,536 |
|
||||
| Project naming LIGHT one-shot (`project_naming.llm_project_name`) | 256 |
|
||||
| Update letter LIGHT one-shot (`update_letter.write_letter`) | 1,024 |
|
||||
| Provider Test (`llm_probe.PROVIDER_TEST_MAX_TOKENS`) | 16 |
|
||||
|
||||
### Default settings
|
||||
|
|
@ -1328,6 +1331,7 @@ A registry of `config.SETTINGS_DEFAULTS` (exact defaults stay canonical in `conf
|
|||
| OUROBOROS_MODEL_SLOT_MAX_WAIT_SEC | 180 | Concurrency-slot wait bound |
|
||||
| OUROBOROS_PROJECT_NAMING_TIMEOUT_SEC | 60 | Project-naming call ceiling |
|
||||
| OUROBOROS_PROJECT_NAMING_ASYNC_TIMEOUT_SEC | 8 | Proactive-namer async bound |
|
||||
| OUROBOROS_UPDATE_LETTER_TIMEOUT_SEC | 120 | Update-letter LIGHT-call transport ceiling (`update_letter.py`) |
|
||||
| OUROBOROS_FALLBACK_COOLDOWN_ENABLED | true | 429-aware per-process model cooldown |
|
||||
| OUROBOROS_FALLBACK_COOLDOWN_SEC | 120 | Cooldown window |
|
||||
| OUROBOROS_FALLBACK_ATTEMPTS_PER_MODEL | 1 | Attempts per model in the fallback walk |
|
||||
|
|
|
|||
|
|
@ -137,6 +137,8 @@ SETTINGS_DEFAULTS = {**UPDATE_SETTINGS_DEFAULTS,
|
|||
# (not project_naming.py magic numbers) per DEVELOPMENT "Timeout & Wait Control".
|
||||
"OUROBOROS_PROJECT_NAMING_TIMEOUT_SEC": 60,
|
||||
"OUROBOROS_PROJECT_NAMING_ASYNC_TIMEOUT_SEC": 8,
|
||||
# Update-letter LIGHT-call provider transport ceiling (update_letter.py); SSOT here.
|
||||
"OUROBOROS_UPDATE_LETTER_TIMEOUT_SEC": 120,
|
||||
# Skill lifecycle lane deadline (wedged-job loud-failure bound).
|
||||
"OUROBOROS_SKILL_LIFECYCLE_TIMEOUT_SEC": 1800,
|
||||
"OUROBOROS_CLAUDEXOR_HARNESS_INSTALL_TIMEOUT_SEC": 300,
|
||||
|
|
@ -370,6 +372,15 @@ def get_light_model() -> str:
|
|||
return str(os.environ.get("OUROBOROS_MODEL_LIGHT", "") or "").strip() or _main_model()
|
||||
|
||||
|
||||
def get_update_letter_timeout_sec() -> float:
|
||||
"""Transport ceiling for the update-letter LIGHT call (default IS the SSOT value)."""
|
||||
default = SETTINGS_DEFAULTS["OUROBOROS_UPDATE_LETTER_TIMEOUT_SEC"]
|
||||
try:
|
||||
return float(os.environ.get("OUROBOROS_UPDATE_LETTER_TIMEOUT_SEC", default))
|
||||
except (TypeError, ValueError):
|
||||
return float(default)
|
||||
|
||||
|
||||
def get_heavy_model() -> str:
|
||||
"""Return the heavy (strong acting/coding) lane slot; empty falls back to
|
||||
OUROBOROS_MODEL. Renamed from the legacy code slot."""
|
||||
|
|
|
|||
|
|
@ -853,6 +853,8 @@ def build_runtime_section(env: Any, task: Dict[str, Any], *, ctx: Any = None, sc
|
|||
}
|
||||
except Exception:
|
||||
log.debug("Failed to inject answer_protocol rule", exc_info=True)
|
||||
from ouroboros.update_letter import official_update_projection # lazy: never raises
|
||||
runtime_data["official_update"] = official_update_projection(git_sha)
|
||||
runtime_ctx = json.dumps(runtime_data, ensure_ascii=False, indent=2)
|
||||
out = "## Runtime context\n\n" + runtime_ctx
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -49,6 +49,21 @@ def _managed_update_payload(*, fetch: bool, include_tags: bool) -> dict[str, Any
|
|||
from supervisor.update_merge import active_update_tx
|
||||
|
||||
status = compute_managed_update_status(fetch=fetch)
|
||||
# The update letter rides the same payload: written only after a FETCHING
|
||||
# check (never on the passive read), projected against the live HEAD/target.
|
||||
letter = None
|
||||
try:
|
||||
from ouroboros import update_letter as _letter
|
||||
|
||||
if fetch:
|
||||
_letter.refresh_after_check(status)
|
||||
letter = _letter.project_letter(
|
||||
_letter.read_record(),
|
||||
head_sha=str(status.get("current_sha") or ""),
|
||||
latest_sha=str(status.get("latest_sha") or ""),
|
||||
)
|
||||
except Exception:
|
||||
log.debug("update letter projection failed", exc_info=True)
|
||||
# Additive minimal public projection of an active managed-update
|
||||
# transaction, so a re-opened panel can say "resolution in progress"
|
||||
# instead of silently reading as ordinary state (a second apply 409s).
|
||||
|
|
@ -82,6 +97,7 @@ def _managed_update_payload(*, fetch: bool, include_tags: bool) -> dict[str, Any
|
|||
"latest_version": latest_version,
|
||||
"official_tags": official_tags,
|
||||
"update_tx": update_tx,
|
||||
"letter": letter,
|
||||
**status,
|
||||
}
|
||||
|
||||
|
|
|
|||
529
ouroboros/update_letter.py
Normal file
529
ouroboros/update_letter.py
Normal file
|
|
@ -0,0 +1,529 @@
|
|||
"""Update letter: Ouroboros's own short note about what a pending official update brings.
|
||||
|
||||
The letter is written by the LIGHT model slot with the ordinary task context (identity,
|
||||
memory, recent dialogue, governance — ``context.build_llm_messages`` with a synthetic task
|
||||
routed at the light model), fed the material of the update range: every first-parent commit
|
||||
between the running base and the official target, plus the README "Version History" rows
|
||||
those commits ADDED. Rows are recovered from the commit diffs rather than read from any
|
||||
README snapshot, because the history table is capped (rows roll off inside one range) and
|
||||
untagged releases have no tag to look up.
|
||||
|
||||
Floor (host code): exact versions/SHAs, the material, one paragraph, the truth rule ("only
|
||||
what the material says"), physical-attempt accounting, no silent model substitution, and a
|
||||
letter that is never deleted after the update lands. Ceiling (the mind): language,
|
||||
register, what matters to this human, and whether to mention it at all.
|
||||
|
||||
Storage is one file, ``data/state/update_letter.json``, with one writer —
|
||||
``refresh_after_check``, which runs synchronously inside a FETCHING update check (boot and
|
||||
the Updates panel's "Check for updates" button) — and one reader shape, ``project_letter``,
|
||||
shared by the Updates panel payload and the agent's Runtime context
|
||||
(``official_update_projection``). The projection compares recorded SHAs with the live HEAD by
|
||||
equality only (no git on the hot context path); a HEAD that descends from the target reads
|
||||
as ``other``, a disclosed residual the panel, which has git, does not share.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import threading
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from ouroboros.utils import atomic_write_json, read_json_dict, truncate_within_limit, utc_now_iso
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
RECORD_REL = pathlib.Path("state") / "update_letter.json"
|
||||
SYSTEM_TASK_ID = "system:update_letter"
|
||||
USAGE_CATEGORY = "update_letter"
|
||||
# Output budget of the LIGHT one-shot (the letter is one short paragraph; docs/ARCHITECTURE.md §7).
|
||||
UPDATE_LETTER_MAX_TOKENS = 1024
|
||||
# Per-commit body bound inside the material (a disclosed cut, never a silent slice).
|
||||
COMMIT_BODY_MAX_CHARS = 1200
|
||||
DEFAULT_MAX_COMMITS = 200
|
||||
|
||||
ERROR_KINDS = (
|
||||
"no_credentials", "budget_exhausted", "context_overflow", "timeout",
|
||||
"provider_unavailable", "empty_response",
|
||||
)
|
||||
|
||||
_ROW_RE = re.compile(r"^\+\|\s*(\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.]+)?)\s*\|")
|
||||
_UNESCAPED_PIPE = re.compile(r"(?<!\\)\|")
|
||||
_REFRESH_LOCK = threading.Lock()
|
||||
|
||||
GitCapture = Callable[[List[str]], Tuple[int, str, str]]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Material: the update range as evidence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _default_git() -> GitCapture:
|
||||
from supervisor.git_ops import git_capture
|
||||
|
||||
return git_capture
|
||||
|
||||
|
||||
def _split_row(line: str) -> Optional[Tuple[str, str, str]]:
|
||||
"""Split one added history row into (version, date, text) on UNESCAPED pipes.
|
||||
|
||||
Rows carry escaped pipes in prose (``incomplete\\|unknown``), so a raw split
|
||||
would shred them. Returns ``None`` when the row does not have exactly three
|
||||
cells — the caller counts that as a disclosed omission, never a silent skip.
|
||||
"""
|
||||
cells = [cell.strip() for cell in _UNESCAPED_PIPE.split(line.lstrip("+").strip())]
|
||||
if cells and cells[0] == "":
|
||||
cells = cells[1:]
|
||||
if cells and cells[-1] == "":
|
||||
cells = cells[:-1]
|
||||
if len(cells) != 3:
|
||||
return None
|
||||
return cells[0], cells[1], cells[2].replace("\\|", "|")
|
||||
|
||||
|
||||
def collect_range_material(
|
||||
base_sha: str,
|
||||
target_sha: str,
|
||||
*,
|
||||
git: Optional[GitCapture] = None,
|
||||
max_commits: int = DEFAULT_MAX_COMMITS,
|
||||
) -> Dict[str, Any]:
|
||||
"""Collect the first-parent commits and the README history rows they added.
|
||||
|
||||
``commits`` are newest-first and capped at ``max_commits`` (``omitted_commits``
|
||||
discloses the rest); ``releases`` are every row added anywhere in the range,
|
||||
newest-first with first-wins per version; malformed rows are counted in
|
||||
``omitted_rows``. An empty range yields empty lists.
|
||||
"""
|
||||
capture = git or _default_git()
|
||||
spec = f"{base_sha}..{target_sha}"
|
||||
material: Dict[str, Any] = {
|
||||
"base_sha": base_sha, "target_sha": target_sha,
|
||||
"commits": [], "omitted_commits": 0, "releases": [], "omitted_rows": 0,
|
||||
"stats": {}, "tags": [],
|
||||
}
|
||||
rc, out, _err = capture([
|
||||
"git", "log", "--first-parent", "--format=%H%x1f%aI%x1f%s%x1f%b%x1e", spec,
|
||||
])
|
||||
all_shas: List[str] = []
|
||||
commits: List[Dict[str, Any]] = []
|
||||
if rc == 0 and out.strip():
|
||||
for chunk in out.split("\x1e"):
|
||||
chunk = chunk.strip("\n")
|
||||
if not chunk.strip():
|
||||
continue
|
||||
parts = chunk.split("\x1f", 3)
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
sha = parts[0].strip()
|
||||
body = parts[3].strip() if len(parts) > 3 else ""
|
||||
all_shas.append(sha)
|
||||
commits.append({
|
||||
"sha": sha, "date": parts[1].strip(), "subject": parts[2].strip(),
|
||||
"body": truncate_within_limit(body, COMMIT_BODY_MAX_CHARS) if body else "",
|
||||
})
|
||||
material["omitted_commits"] = max(0, len(commits) - max_commits)
|
||||
material["commits"] = commits[:max_commits]
|
||||
|
||||
rc, out, _err = capture([
|
||||
"git", "log", "-m", "--first-parent", "-p", "-U0", "--format=%x01%H", spec, "--", "README.md",
|
||||
])
|
||||
seen_versions: set = set()
|
||||
releases: List[Dict[str, Any]] = []
|
||||
if rc == 0 and out:
|
||||
current_sha = ""
|
||||
for line in out.splitlines():
|
||||
if line.startswith("\x01"):
|
||||
current_sha = line[1:].strip()
|
||||
continue
|
||||
if not line.startswith("+|"):
|
||||
continue
|
||||
match = _ROW_RE.match(line)
|
||||
if not match:
|
||||
continue
|
||||
cells = _split_row(line)
|
||||
if cells is None:
|
||||
material["omitted_rows"] += 1
|
||||
continue
|
||||
version, date, text = cells
|
||||
if version in seen_versions:
|
||||
continue
|
||||
seen_versions.add(version)
|
||||
releases.append({"version": version, "date": date, "text": text, "commit": current_sha})
|
||||
material["releases"] = releases
|
||||
|
||||
stats: Dict[str, Any] = {"first_parent_count": len(all_shas)}
|
||||
rc, out, _err = capture(["git", "rev-list", "--left-right", "--count", f"{base_sha}...{target_sha}"])
|
||||
if rc == 0:
|
||||
try:
|
||||
ahead, behind = (int(part) for part in out.split())
|
||||
stats["ahead"], stats["behind"] = ahead, behind
|
||||
except ValueError:
|
||||
pass
|
||||
rc, _out, _err = capture(["git", "merge-base", "--is-ancestor", base_sha, target_sha])
|
||||
stats["base_is_ancestor"] = rc == 0
|
||||
for label, sha in (("base_version", base_sha), ("target_version", target_sha)):
|
||||
rc, out, _err = capture(["git", "show", f"{sha}:VERSION"])
|
||||
stats[label] = out.strip() if rc == 0 else ""
|
||||
material["stats"] = stats
|
||||
|
||||
# for-each-ref has no %xNN escapes; tag names carry no whitespace, so split on it.
|
||||
rc, out, _err = capture([
|
||||
"git", "for-each-ref", "--format=%(refname:short) %(objectname) %(*objectname)",
|
||||
"refs/tags/v*", "refs/ouroboros-managed/tags/v*",
|
||||
])
|
||||
if rc == 0 and out.strip():
|
||||
in_range = set(all_shas)
|
||||
for line in out.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
peeled = parts[2].strip() if len(parts) > 2 else parts[1].strip()
|
||||
if peeled in in_range:
|
||||
material["tags"].append({"tag": parts[0].strip().split("/")[-1], "sha": peeled})
|
||||
return material
|
||||
|
||||
|
||||
def material_text(material: Dict[str, Any]) -> str:
|
||||
"""Render the material as compact evidence text for the prompt (releases first)."""
|
||||
lines: List[str] = []
|
||||
releases = material.get("releases") or []
|
||||
if releases:
|
||||
lines.append("Release notes added in this range (newest first):")
|
||||
for row in releases:
|
||||
lines.append(f"- {row.get('version')} ({row.get('date')}): {row.get('text')}")
|
||||
if material.get("omitted_rows"):
|
||||
lines.append(f"- [{material['omitted_rows']} malformed history row(s) omitted]")
|
||||
commits = material.get("commits") or []
|
||||
if commits:
|
||||
lines.append("")
|
||||
lines.append("First-parent commits in this range (newest first):")
|
||||
for commit in commits:
|
||||
head = f"- {str(commit.get('sha') or '')[:8]} {commit.get('date', '')[:10]} {commit.get('subject', '')}"
|
||||
lines.append(head)
|
||||
body = str(commit.get("body") or "").strip()
|
||||
if body:
|
||||
lines.append(" " + body.replace("\n", "\n "))
|
||||
if material.get("omitted_commits"):
|
||||
lines.append(f"- [{material['omitted_commits']} older commit(s) omitted]")
|
||||
if not lines:
|
||||
lines.append("(no commits in this range)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generation: one LIGHT call with the ordinary context
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _request_text(status: Dict[str, Any], material: Dict[str, Any], target_version: str) -> str:
|
||||
import json
|
||||
|
||||
from ouroboros import get_version
|
||||
|
||||
facts = {
|
||||
"running": {"version": get_version(), "sha": str(status.get("current_sha") or "")},
|
||||
"official_target": {"version": target_version, "sha": str(status.get("latest_sha") or "")},
|
||||
"update_channel": str(status.get("update_channel") or ""),
|
||||
"commits_behind": status.get("behind"),
|
||||
"commits_ahead": status.get("ahead"),
|
||||
"checked_at": str(status.get("checked_at") or ""),
|
||||
}
|
||||
return (
|
||||
"[UPDATE LETTER REQUEST]\n"
|
||||
"An official update of my body is available. Facts (host-attested):\n"
|
||||
+ json.dumps(facts, ensure_ascii=False, indent=2)
|
||||
+ "\n\nMaterial — what changed between the running version and the target:\n"
|
||||
+ material_text(material)
|
||||
+ "\n\nWrite my human ONE short paragraph — no headings, no lists, no more than about "
|
||||
"120 words — about what this update brings, as myself. Use only what the material says; "
|
||||
"if the material does not say something, I do not invent it. This paragraph is shown on "
|
||||
"the Updates page and becomes part of my own context; the commit history stays readable "
|
||||
"for detail. Reply with the paragraph only.\n"
|
||||
"[/UPDATE LETTER REQUEST]"
|
||||
)
|
||||
|
||||
|
||||
def _context_messages(env: Any, memory: Any, task: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""The ordinary task context, projected for the synthetic light-slot task."""
|
||||
from ouroboros.context import build_llm_messages
|
||||
|
||||
messages, _cap = build_llm_messages(env, memory, task)
|
||||
return messages
|
||||
|
||||
|
||||
def _chat(client: Any, *, drive_root: pathlib.Path, **kwargs: Any) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
from ouroboros.llm_observability import chat_observed
|
||||
|
||||
return chat_observed(client, drive_root=drive_root, task_id=SYSTEM_TASK_ID, call_type=USAGE_CATEGORY, **kwargs)
|
||||
|
||||
|
||||
def _classify(exc: Exception) -> Tuple[str, str]:
|
||||
from ouroboros.usage_accounting import BudgetExceeded
|
||||
from ouroboros.utils import sanitize_tool_result_for_log
|
||||
|
||||
safe = sanitize_tool_result_for_log(f"{type(exc).__name__}: {exc}")[:300]
|
||||
if isinstance(exc, BudgetExceeded):
|
||||
return "budget_exhausted", safe
|
||||
if isinstance(exc, TimeoutError) or "timeout" in type(exc).__name__.lower():
|
||||
return "timeout", safe
|
||||
try:
|
||||
from ouroboros.loop_llm_call import classify_llm_exception
|
||||
|
||||
kind = str(classify_llm_exception(exc, safe).kind or "")
|
||||
except Exception:
|
||||
kind = ""
|
||||
if kind == "context_overflow":
|
||||
return "context_overflow", safe
|
||||
return "provider_unavailable", safe
|
||||
|
||||
|
||||
def write_letter(
|
||||
status: Dict[str, Any],
|
||||
material: Dict[str, Any],
|
||||
*,
|
||||
drive_root: Optional[pathlib.Path] = None,
|
||||
repo_dir: Optional[pathlib.Path] = None,
|
||||
llm_client: Any = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""One accounted LIGHT-slot call; returns the letter record (``ready`` or ``failed``)."""
|
||||
from ouroboros import get_version
|
||||
from ouroboros.config import DATA_DIR, REPO_DIR, get_light_model, get_update_letter_timeout_sec
|
||||
|
||||
data_root = pathlib.Path(drive_root or DATA_DIR)
|
||||
repo_root = pathlib.Path(repo_dir or REPO_DIR)
|
||||
key = _key_from_status(status)
|
||||
target_version = str(material.get("stats", {}).get("target_version") or "")
|
||||
record: Dict[str, Any] = {
|
||||
"schema": 1,
|
||||
"key": key,
|
||||
"checked_head_sha": key["base_sha"],
|
||||
"state": "failed",
|
||||
"text": "",
|
||||
"author_version": get_version(),
|
||||
"target_version": target_version,
|
||||
"model": "",
|
||||
"written_at": utc_now_iso(),
|
||||
"attempt_id": "",
|
||||
"error_kind": "",
|
||||
"error_text": "",
|
||||
"last_good": None,
|
||||
}
|
||||
model = get_light_model()
|
||||
use_local = str(os.environ.get("USE_LOCAL_LIGHT", "") or "").lower() in ("true", "1")
|
||||
record["model"] = model
|
||||
if not use_local:
|
||||
from ouroboros.provider_models import model_has_credentials
|
||||
|
||||
if not model_has_credentials(model):
|
||||
record.update(error_kind="no_credentials",
|
||||
error_text=f"no credentials for the light model slot ({model})")
|
||||
return record
|
||||
try:
|
||||
from ouroboros import model_concurrency
|
||||
from ouroboros.agent import Env
|
||||
from ouroboros.llm import LLMClient
|
||||
from ouroboros.memory import Memory
|
||||
from ouroboros.usage_accounting import UsageScope, usage_scope
|
||||
|
||||
env = Env(repo_dir=repo_root, drive_root=data_root)
|
||||
memory = Memory(drive_root=data_root, repo_dir=repo_root)
|
||||
task = {
|
||||
"id": SYSTEM_TASK_ID, "type": "update_letter", "source": USAGE_CATEGORY,
|
||||
"model": model, "use_local_model": use_local, "metadata": {},
|
||||
"text": _request_text(status, material, target_version),
|
||||
}
|
||||
messages = _context_messages(env, memory, task)
|
||||
try:
|
||||
global_limit = float(os.environ.get("TOTAL_BUDGET", "0") or 0)
|
||||
except (TypeError, ValueError):
|
||||
global_limit = 0.0
|
||||
scope = UsageScope(
|
||||
drive_root=data_root, task_id=SYSTEM_TASK_ID, root_task_id=SYSTEM_TASK_ID,
|
||||
category=USAGE_CATEGORY, source=USAGE_CATEGORY,
|
||||
global_limit_usd=global_limit if global_limit > 0 else None,
|
||||
)
|
||||
client = llm_client or LLMClient()
|
||||
with model_concurrency.model_call_slot(model, use_local):
|
||||
with usage_scope(scope):
|
||||
msg, usage = _chat(
|
||||
client, drive_root=data_root, messages=messages, model=model, tools=None,
|
||||
reasoning_effort="low", max_tokens=UPDATE_LETTER_MAX_TOKENS,
|
||||
use_local=use_local, timeout=get_update_letter_timeout_sec(),
|
||||
)
|
||||
attempt_ids = list((usage or {}).get("ledger_attempt_ids") or [])
|
||||
record["attempt_id"] = str(attempt_ids[-1]) if attempt_ids else ""
|
||||
text = str((msg or {}).get("content") or "").strip()
|
||||
if not text:
|
||||
record.update(error_kind="empty_response", error_text="the model returned no text")
|
||||
return record
|
||||
record.update(state="ready", text=text, written_at=utc_now_iso())
|
||||
return record
|
||||
except Exception as exc: # noqa: BLE001 — every failure becomes a typed, secret-free record
|
||||
kind, safe = _classify(exc)
|
||||
log.debug("update letter generation failed (%s)", kind, exc_info=True)
|
||||
record.update(error_kind=kind, error_text=safe)
|
||||
return record
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Storage and the one refresh seam
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def record_path(drive_root: Optional[pathlib.Path] = None) -> pathlib.Path:
|
||||
from ouroboros.config import DATA_DIR
|
||||
|
||||
return pathlib.Path(drive_root or DATA_DIR) / RECORD_REL
|
||||
|
||||
|
||||
def read_record(drive_root: Optional[pathlib.Path] = None) -> Optional[Dict[str, Any]]:
|
||||
record = read_json_dict(record_path(drive_root))
|
||||
return record if record and isinstance(record.get("key"), dict) else None
|
||||
|
||||
|
||||
def _key_from_status(status: Dict[str, Any]) -> Dict[str, str]:
|
||||
return {
|
||||
"base_sha": str(status.get("current_sha") or ""),
|
||||
"target_sha": str(status.get("latest_sha") or ""),
|
||||
"update_channel": str(status.get("update_channel") or ""),
|
||||
"target_ref": str(status.get("target_ref") or ""),
|
||||
}
|
||||
|
||||
|
||||
def refresh_after_check(
|
||||
status: Dict[str, Any],
|
||||
*,
|
||||
drive_root: Optional[pathlib.Path] = None,
|
||||
repo_dir: Optional[pathlib.Path] = None,
|
||||
llm_client: Any = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Write the letter after a successful FETCHING check; never raise, never delete.
|
||||
|
||||
``check_ok`` other than True or no available update leaves the stored record as it
|
||||
is (an applied update keeps its letter — that is the "what changed in this version"
|
||||
text). A second concurrent refresh is a no-op that returns the current record.
|
||||
"""
|
||||
try:
|
||||
current = read_record(drive_root)
|
||||
if status.get("check_ok") is not True or not status.get("available"):
|
||||
return current
|
||||
key = _key_from_status(status)
|
||||
if not key["base_sha"] or not key["target_sha"]:
|
||||
return current
|
||||
if not _REFRESH_LOCK.acquire(blocking=False):
|
||||
return current
|
||||
try:
|
||||
material = collect_range_material(key["base_sha"], key["target_sha"])
|
||||
if not material.get("commits") and not material.get("releases"):
|
||||
return current
|
||||
record = write_letter(status, material, drive_root=drive_root, repo_dir=repo_dir,
|
||||
llm_client=llm_client)
|
||||
if record.get("state") != "ready" and current and current.get("key") == key:
|
||||
previous_good = current if current.get("state") == "ready" else current.get("last_good")
|
||||
record["last_good"] = previous_good or None
|
||||
atomic_write_json(record_path(drive_root), record)
|
||||
return record
|
||||
finally:
|
||||
_REFRESH_LOCK.release()
|
||||
except Exception:
|
||||
log.debug("update letter refresh failed", exc_info=True)
|
||||
return read_record(drive_root)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The one projection shared by the panel payload and the Runtime context
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def project_letter(
|
||||
record: Optional[Dict[str, Any]],
|
||||
*,
|
||||
head_sha: str,
|
||||
latest_sha: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Relate the stored letter to the live HEAD and official target by SHA equality."""
|
||||
if not record:
|
||||
return None
|
||||
key = record.get("key") if isinstance(record.get("key"), dict) else {}
|
||||
base, target = str(key.get("base_sha") or ""), str(key.get("target_sha") or "")
|
||||
head, latest = str(head_sha or ""), str(latest_sha or "")
|
||||
if head and head == target:
|
||||
relation = "applied"
|
||||
elif head and head == base and latest == target:
|
||||
relation = "pending"
|
||||
elif head and head == base:
|
||||
relation = "superseded"
|
||||
else:
|
||||
relation = "other"
|
||||
text = str(record.get("text") or "")
|
||||
last_good = record.get("last_good") if isinstance(record.get("last_good"), dict) else None
|
||||
if record.get("state") != "ready" and not text and last_good:
|
||||
text = str(last_good.get("text") or "")
|
||||
return {
|
||||
"state": "ready" if record.get("state") == "ready" else "failed",
|
||||
"relation": relation,
|
||||
"text": text,
|
||||
"author_version": str(record.get("author_version") or ""),
|
||||
"target_version": str(record.get("target_version") or ""),
|
||||
"written_at": str(record.get("written_at") or ""),
|
||||
"error_kind": str(record.get("error_kind") or ""),
|
||||
"error_text": str(record.get("error_text") or ""),
|
||||
"key": dict(key),
|
||||
"has_last_good": bool(last_good and last_good.get("text")),
|
||||
}
|
||||
|
||||
|
||||
def official_update_projection(
|
||||
head_sha: str,
|
||||
*,
|
||||
drive_root: Optional[pathlib.Path] = None,
|
||||
state: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""The typed fact for the Runtime context: O(1), no git, never raises.
|
||||
|
||||
``status`` is honest about freshness: ``up_to_date`` only when HEAD equals the last
|
||||
checked official target, ``update_available`` only when the letter was written for
|
||||
this exact HEAD, ``moved_since_check`` when HEAD matches neither, ``unchecked`` when
|
||||
no fetching check has completed. ``status_as_of`` names the check the fact comes from.
|
||||
"""
|
||||
try:
|
||||
from ouroboros import get_version
|
||||
|
||||
head = str(head_sha or "")
|
||||
if state is None:
|
||||
from supervisor.state import load_state
|
||||
|
||||
state = load_state() or {}
|
||||
cache = state.get("managed_update_cache") if isinstance(state, dict) else None
|
||||
cache = cache if isinstance(cache, dict) else {}
|
||||
record = read_record(drive_root)
|
||||
latest = str(cache.get("latest_sha") or "")
|
||||
letter = project_letter(record, head_sha=head, latest_sha=latest)
|
||||
checked_head = str((record or {}).get("checked_head_sha") or "")
|
||||
if not cache:
|
||||
status = "unchecked"
|
||||
elif head and head == latest:
|
||||
status = "up_to_date"
|
||||
elif cache.get("available") and checked_head and checked_head == head:
|
||||
status = "update_available"
|
||||
else:
|
||||
status = "moved_since_check"
|
||||
target = None
|
||||
if latest:
|
||||
target = {
|
||||
"version": str((record or {}).get("target_version") or ""),
|
||||
"sha": latest,
|
||||
}
|
||||
return {
|
||||
"status": status,
|
||||
"status_as_of": str(cache.get("checked_at") or ""),
|
||||
"running": {"version": get_version(), "sha": head},
|
||||
"update_channel": str(cache.get("update_channel") or ""),
|
||||
"target": target,
|
||||
"behind": cache.get("behind"),
|
||||
"ahead": cache.get("ahead"),
|
||||
"letter": letter,
|
||||
}
|
||||
except Exception as exc: # noqa: BLE001 — a fact that raises would take the whole context with it
|
||||
log.debug("official_update projection failed", exc_info=True)
|
||||
return {"status": "unknown", "error": type(exc).__name__}
|
||||
|
|
@ -127,6 +127,9 @@ If you have something genuinely useful to say, use `send_user_message`.
|
|||
- Default wakeup: 300 seconds (5 min). Increase if nothing is happening.
|
||||
- Decrease wakeup interval if something urgent or interesting is going on.
|
||||
- Do NOT message my human unless you have something genuinely worth saying.
|
||||
- I know from `official_update` in my Runtime context whether I am current with
|
||||
my official source and what a pending update brings. Mentioning it to my human
|
||||
is my judgment, not a duty; my earlier words and their answer are in Recent chat.
|
||||
- If nothing interesting is happening and maintenance is done, set a longer
|
||||
wakeup (600-1800s).
|
||||
- **Do ONE maintenance item per wakeup, not all of them.**
|
||||
|
|
|
|||
|
|
@ -2648,6 +2648,12 @@ def _boot_managed_update_tasks() -> None:
|
|||
_request_restart_exit()
|
||||
return
|
||||
update_status = compute_managed_update_status(fetch=True)
|
||||
try:
|
||||
from ouroboros.update_letter import refresh_after_check
|
||||
|
||||
refresh_after_check(update_status)
|
||||
except Exception:
|
||||
log.debug("boot update letter refresh failed", exc_info=True)
|
||||
broadcast_ws_sync({
|
||||
"type": "update_status_ready",
|
||||
"available": bool(update_status.get("available")),
|
||||
|
|
|
|||
|
|
@ -2078,3 +2078,16 @@ def test_delegation_fact_failure_never_drops_capability_digest(tmp_path, monkeyp
|
|||
# The surrounding digest survives intact.
|
||||
assert "allow_mutative_subagents" in capabilities
|
||||
assert "write_surfaces" in capabilities
|
||||
|
||||
|
||||
def test_runtime_section_carries_official_update_fact(tmp_path, monkeypatch):
|
||||
env = _make_health_env(tmp_path)
|
||||
monkeypatch.setattr(
|
||||
"ouroboros.update_letter.official_update_projection",
|
||||
lambda head: {"status": "unchecked", "running": {"sha": head}, "letter": None},
|
||||
)
|
||||
section = build_runtime_section(env, {"id": "task-1", "type": "task"})
|
||||
payload = json.loads(section.split("\n\n", 1)[1])
|
||||
|
||||
assert payload["official_update"]["status"] == "unchecked"
|
||||
assert payload["official_update"]["letter"] is None
|
||||
|
|
|
|||
|
|
@ -276,6 +276,8 @@ PROMPT_NON_TOOL_IDENTIFIERS = frozenset({
|
|||
"mermaid", "chart",
|
||||
# backlog item status value in CONSCIOUSNESS.md
|
||||
"done",
|
||||
# runtime-context fact (build_runtime_section -> update_letter.official_update_projection)
|
||||
"official_update",
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -834,3 +834,11 @@ def test_triad_fit_sizes_against_the_local_route(monkeypatch, tmp_path):
|
|||
"a local-only install must size the triad prompt against the local "
|
||||
"route's real window, not the provider inferred from the model text"
|
||||
)
|
||||
|
||||
|
||||
def test_update_letter_max_tokens_pinned():
|
||||
"""The update letter is ONE short paragraph (owner decision 2026-09-03): its LIGHT
|
||||
one-shot budget stays 1024 and matches the ARCHITECTURE 'LLM output token budgets' row."""
|
||||
from ouroboros.update_letter import UPDATE_LETTER_MAX_TOKENS
|
||||
|
||||
assert UPDATE_LETTER_MAX_TOKENS == 1024
|
||||
|
|
|
|||
353
tests/test_update_letter.py
Normal file
353
tests/test_update_letter.py
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
"""The update letter: range material, the accounted LIGHT call, storage, and projection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from ouroboros import update_letter as ul
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _git(repo, *args):
|
||||
return subprocess.run(["git", *args], cwd=repo, capture_output=True, text=True, check=True).stdout.strip()
|
||||
|
||||
|
||||
def _capture_for(repo):
|
||||
def capture(cmd):
|
||||
proc = subprocess.run(cmd, cwd=repo, capture_output=True, text=True)
|
||||
return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
|
||||
|
||||
return capture
|
||||
|
||||
|
||||
_HEADER = "# Demo\n\n## Version History\n\n| Version | Date | Description |\n|---|---|---|\n"
|
||||
|
||||
|
||||
def _write_readme(repo, rows):
|
||||
(repo / "README.md").write_text(_HEADER + "".join(f"| {v} | {d} | {t} |\n" for v, d, t in rows), encoding="utf-8")
|
||||
|
||||
|
||||
def _commit(repo, message):
|
||||
_git(repo, "add", "-A")
|
||||
_git(repo, "commit", "-q", "-m", message)
|
||||
return _git(repo, "rev-parse", "HEAD")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def history_repo(tmp_path):
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "-q", "-b", "main")
|
||||
_git(repo, "config", "user.email", "t@example.com")
|
||||
_git(repo, "config", "user.name", "t")
|
||||
_write_readme(repo, [("1.0.0", "2026-01-01", "first")])
|
||||
(repo / "VERSION").write_text("1.0.0\n")
|
||||
base = _commit(repo, "release: 1.0.0")
|
||||
_write_readme(repo, [("1.1.0", "2026-01-02", "second with an escaped \\| pipe"), ("1.0.0", "2026-01-01", "first")])
|
||||
(repo / "VERSION").write_text("1.1.0\n")
|
||||
c1 = _commit(repo, "release: 1.1.0\n\nBody of the second release.")
|
||||
# Two rows in one commit plus one malformed row (two cells only).
|
||||
(repo / "README.md").write_text(
|
||||
_HEADER
|
||||
+ "| 1.2.0 | 2026-01-03 | third |\n"
|
||||
+ "| 1.1.1 | 2026-01-03 | third-fix |\n"
|
||||
+ "| 1.3.0 | only two cells |\n"
|
||||
+ "| 1.1.0 | 2026-01-02 | second with an escaped \\| pipe |\n"
|
||||
+ "| 1.0.0 | 2026-01-01 | first |\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(repo / "VERSION").write_text("1.2.0\n")
|
||||
c2 = _commit(repo, "release: 1.2.0")
|
||||
# Roll-off: the 1.0.0 row leaves the table (capped), a new row arrives, a tag lands.
|
||||
_write_readme(repo, [
|
||||
("1.4.0", "2026-01-04", "fourth"),
|
||||
("1.2.0", "2026-01-03", "third"),
|
||||
("1.1.1", "2026-01-03", "third-fix"),
|
||||
("1.1.0", "2026-01-02", "second with an escaped \\| pipe"),
|
||||
])
|
||||
(repo / "VERSION").write_text("1.4.0\n")
|
||||
c3 = _commit(repo, "release: 1.4.0")
|
||||
_git(repo, "tag", "-a", "v1.4.0", "-m", "v1.4.0")
|
||||
(repo / "notes.txt").write_text("tail\n")
|
||||
c4 = _commit(repo, "tail work\n\nDetails of the untagged tail.")
|
||||
return {"repo": repo, "base": base, "c1": c1, "c2": c2, "c3": c3, "c4": c4}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# material
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_material_recovers_rows_from_commit_diffs_and_lists_first_parent_commits(history_repo):
|
||||
repo = history_repo["repo"]
|
||||
material = ul.collect_range_material(history_repo["base"], history_repo["c4"], git=_capture_for(repo))
|
||||
|
||||
assert [row["version"] for row in material["releases"]] == ["1.4.0", "1.2.0", "1.1.1", "1.1.0"]
|
||||
assert material["omitted_rows"] == 1 # the two-cell 1.3.0 row is disclosed, not silently dropped
|
||||
by_version = {row["version"]: row for row in material["releases"]}
|
||||
assert by_version["1.1.0"]["text"] == "second with an escaped | pipe"
|
||||
assert by_version["1.4.0"]["commit"] == history_repo["c3"]
|
||||
assert [c["sha"] for c in material["commits"]] == [
|
||||
history_repo["c4"], history_repo["c3"], history_repo["c2"], history_repo["c1"],
|
||||
]
|
||||
assert material["commits"][0]["body"] == "Details of the untagged tail."
|
||||
assert material["omitted_commits"] == 0
|
||||
stats = material["stats"]
|
||||
assert stats["first_parent_count"] == 4
|
||||
assert stats["base_is_ancestor"] is True
|
||||
assert (stats["ahead"], stats["behind"]) == (0, 4)
|
||||
assert stats["base_version"] == "1.0.0" and stats["target_version"] == "1.4.0"
|
||||
assert material["tags"] == [{"tag": "v1.4.0", "sha": history_repo["c3"]}]
|
||||
|
||||
|
||||
def test_material_caps_commits_but_keeps_every_release_row(history_repo):
|
||||
material = ul.collect_range_material(
|
||||
history_repo["base"], history_repo["c4"], git=_capture_for(history_repo["repo"]), max_commits=2,
|
||||
)
|
||||
assert len(material["commits"]) == 2 and material["omitted_commits"] == 2
|
||||
assert len(material["releases"]) == 4
|
||||
rendered = ul.material_text(material)
|
||||
assert "2 older commit(s) omitted" in rendered and "1 malformed history row(s) omitted" in rendered
|
||||
|
||||
|
||||
def test_material_reworded_row_is_first_wins_newest_first(history_repo):
|
||||
repo = history_repo["repo"]
|
||||
_write_readme(repo, [("1.4.0", "2026-01-04", "fourth, reworded"), ("1.2.0", "2026-01-03", "third")])
|
||||
c5 = _commit(repo, "reword 1.4.0 row")
|
||||
material = ul.collect_range_material(history_repo["base"], c5, git=_capture_for(repo))
|
||||
assert material["releases"][0]["text"] == "fourth, reworded"
|
||||
assert [row["version"] for row in material["releases"]] == ["1.4.0", "1.2.0", "1.1.1", "1.1.0"]
|
||||
|
||||
|
||||
def test_material_empty_range_and_non_ancestor_base(history_repo):
|
||||
repo = history_repo["repo"]
|
||||
empty = ul.collect_range_material(history_repo["c4"], history_repo["c4"], git=_capture_for(repo))
|
||||
assert empty["commits"] == [] and empty["releases"] == []
|
||||
reverse = ul.collect_range_material(history_repo["c4"], history_repo["base"], git=_capture_for(repo))
|
||||
assert reverse["commits"] == [] and reverse["stats"]["base_is_ancestor"] is False
|
||||
|
||||
|
||||
def test_split_row_keeps_three_cells_only():
|
||||
assert ul._split_row("+| 1.2.3 | 2026-01-01 | a \\| b |") == ("1.2.3", "2026-01-01", "a | b")
|
||||
assert ul._split_row("+| 1.2.3 | only two |") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _status(**over):
|
||||
base = {
|
||||
"check_ok": True, "available": True, "current_sha": "a" * 40, "latest_sha": "b" * 40,
|
||||
"update_channel": "stable", "target_ref": "managed/main", "behind": 3, "ahead": 0,
|
||||
"checked_at": "2026-09-03T18:00:00+00:00",
|
||||
}
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
|
||||
def _material():
|
||||
return {
|
||||
"commits": [{"sha": "b" * 40, "date": "2026-09-03", "subject": "s", "body": ""}],
|
||||
"releases": [{"version": "6.114.0", "date": "2026-09-01", "text": "row", "commit": "b" * 40}],
|
||||
"omitted_commits": 0, "omitted_rows": 0,
|
||||
"stats": {"target_version": "6.114.0", "base_version": "6.113.5"}, "tags": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def letter_env(tmp_path, monkeypatch):
|
||||
drive = tmp_path / "data"
|
||||
repo = tmp_path / "repo"
|
||||
(drive / "state").mkdir(parents=True)
|
||||
(drive / "memory").mkdir()
|
||||
repo.mkdir()
|
||||
monkeypatch.setenv("OUROBOROS_MODEL_LIGHT", "test/light")
|
||||
monkeypatch.delenv("USE_LOCAL_LIGHT", raising=False)
|
||||
monkeypatch.setattr("ouroboros.provider_models.model_has_credentials", lambda model: True)
|
||||
calls = []
|
||||
|
||||
def fake_context(env, memory, task):
|
||||
calls.append(("context", task))
|
||||
return [{"role": "system", "content": "identity"}, {"role": "user", "content": task["text"]}]
|
||||
|
||||
monkeypatch.setattr(ul, "_context_messages", fake_context)
|
||||
return {"drive": drive, "repo": repo, "calls": calls}
|
||||
|
||||
|
||||
def test_write_letter_ready_record_carries_attempt_and_versions(letter_env, monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake_chat(client, *, drive_root, **kwargs):
|
||||
seen.update(kwargs)
|
||||
return {"content": " One short paragraph. "}, {"ledger_attempt_ids": ["att-1", "att-2"]}
|
||||
|
||||
monkeypatch.setattr(ul, "_chat", fake_chat)
|
||||
record = ul.write_letter(_status(), _material(), drive_root=letter_env["drive"], repo_dir=letter_env["repo"])
|
||||
|
||||
assert record["state"] == "ready" and record["text"] == "One short paragraph."
|
||||
assert record["attempt_id"] == "att-2" and record["model"] == "test/light"
|
||||
assert record["key"]["base_sha"] == "a" * 40 and record["checked_head_sha"] == "a" * 40
|
||||
assert record["target_version"] == "6.114.0" and record["error_kind"] == ""
|
||||
assert seen["model"] == "test/light" and seen["max_tokens"] == ul.UPDATE_LETTER_MAX_TOKENS
|
||||
assert seen["reasoning_effort"] == "low" and seen["tools"] is None
|
||||
task = letter_env["calls"][0][1]
|
||||
assert task["id"] == ul.SYSTEM_TASK_ID and task["model"] == "test/light"
|
||||
assert "[UPDATE LETTER REQUEST]" in task["text"] and "6.114.0" in task["text"]
|
||||
assert "ONE short paragraph" in seen["messages"][-1]["content"]
|
||||
|
||||
|
||||
def test_write_letter_without_light_credentials_fails_typed_and_never_calls(letter_env, monkeypatch):
|
||||
monkeypatch.setattr("ouroboros.provider_models.model_has_credentials", lambda model: False)
|
||||
monkeypatch.setattr(ul, "_chat", lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not call")))
|
||||
record = ul.write_letter(_status(), _material(), drive_root=letter_env["drive"], repo_dir=letter_env["repo"])
|
||||
assert record["state"] == "failed" and record["error_kind"] == "no_credentials"
|
||||
assert "test/light" in record["error_text"] and record["text"] == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exc, kind", [
|
||||
(TimeoutError("slow"), "timeout"),
|
||||
(RuntimeError("boom"), "provider_unavailable"),
|
||||
])
|
||||
def test_write_letter_failures_are_typed(letter_env, monkeypatch, exc, kind):
|
||||
def fake_chat(client, *, drive_root, **kwargs):
|
||||
raise exc
|
||||
|
||||
monkeypatch.setattr(ul, "_chat", fake_chat)
|
||||
record = ul.write_letter(_status(), _material(), drive_root=letter_env["drive"], repo_dir=letter_env["repo"])
|
||||
assert record["state"] == "failed" and record["error_kind"] == kind
|
||||
assert "boom" in record["error_text"] or "slow" in record["error_text"]
|
||||
|
||||
|
||||
def test_write_letter_budget_exhausted_is_typed(letter_env, monkeypatch):
|
||||
from ouroboros.usage_accounting import BudgetExceeded
|
||||
|
||||
def fake_chat(client, *, drive_root, **kwargs):
|
||||
raise BudgetExceeded("global budget exhausted")
|
||||
|
||||
monkeypatch.setattr(ul, "_chat", fake_chat)
|
||||
record = ul.write_letter(_status(), _material(), drive_root=letter_env["drive"], repo_dir=letter_env["repo"])
|
||||
assert record["error_kind"] == "budget_exhausted"
|
||||
|
||||
|
||||
def test_write_letter_empty_response_is_typed(letter_env, monkeypatch):
|
||||
monkeypatch.setattr(ul, "_chat", lambda client, *, drive_root, **kw: ({"content": " "}, {}))
|
||||
record = ul.write_letter(_status(), _material(), drive_root=letter_env["drive"], repo_dir=letter_env["repo"])
|
||||
assert record["state"] == "failed" and record["error_kind"] == "empty_response"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# refresh seam
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_refresh_skips_without_a_successful_available_check(tmp_path, monkeypatch):
|
||||
drive = tmp_path / "data"
|
||||
monkeypatch.setattr(ul, "collect_range_material", lambda *a, **k: (_ for _ in ()).throw(AssertionError("no material")))
|
||||
assert ul.refresh_after_check(_status(check_ok=False), drive_root=drive) is None
|
||||
assert ul.refresh_after_check(_status(check_ok=None), drive_root=drive) is None
|
||||
assert ul.refresh_after_check(_status(available=False), drive_root=drive) is None
|
||||
assert not ul.record_path(drive).exists()
|
||||
|
||||
|
||||
def test_refresh_writes_record_and_keeps_last_good_on_failure(tmp_path, monkeypatch):
|
||||
drive = tmp_path / "data"
|
||||
monkeypatch.setattr(ul, "collect_range_material", lambda base, target, **k: _material())
|
||||
ready = {"schema": 1, "key": ul._key_from_status(_status()), "checked_head_sha": "a" * 40,
|
||||
"state": "ready", "text": "first letter", "author_version": "6.113.5",
|
||||
"target_version": "6.114.0", "model": "m", "written_at": "t1", "attempt_id": "att-1",
|
||||
"error_kind": "", "error_text": "", "last_good": None}
|
||||
monkeypatch.setattr(ul, "write_letter", lambda status, material, **k: dict(ready))
|
||||
record = ul.refresh_after_check(_status(), drive_root=drive)
|
||||
assert record["state"] == "ready" and ul.read_record(drive)["text"] == "first letter"
|
||||
|
||||
failed = dict(ready, state="failed", text="", error_kind="timeout", written_at="t2")
|
||||
monkeypatch.setattr(ul, "write_letter", lambda status, material, **k: dict(failed))
|
||||
record = ul.refresh_after_check(_status(), drive_root=drive)
|
||||
assert record["state"] == "failed" and record["last_good"]["text"] == "first letter"
|
||||
stored = json.loads(ul.record_path(drive).read_text())
|
||||
assert stored["last_good"]["text"] == "first letter"
|
||||
|
||||
# An applied update (available=False) leaves the letter untouched.
|
||||
kept = ul.refresh_after_check(_status(available=False), drive_root=drive)
|
||||
assert kept["last_good"]["text"] == "first letter"
|
||||
|
||||
|
||||
def test_refresh_is_single_flight(tmp_path, monkeypatch):
|
||||
drive = tmp_path / "data"
|
||||
monkeypatch.setattr(ul, "collect_range_material", lambda *a, **k: (_ for _ in ()).throw(AssertionError("busy")))
|
||||
assert ul._REFRESH_LOCK.acquire(blocking=False)
|
||||
try:
|
||||
assert ul.refresh_after_check(_status(), drive_root=drive) is None
|
||||
finally:
|
||||
ul._REFRESH_LOCK.release()
|
||||
|
||||
|
||||
def test_refresh_never_raises(tmp_path, monkeypatch):
|
||||
drive = tmp_path / "data"
|
||||
monkeypatch.setattr(ul, "collect_range_material", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("git down")))
|
||||
assert ul.refresh_after_check(_status(), drive_root=drive) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# projection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _record(**over):
|
||||
record = {"schema": 1, "key": {"base_sha": "a" * 40, "target_sha": "b" * 40, "update_channel": "stable",
|
||||
"target_ref": "managed/main"},
|
||||
"checked_head_sha": "a" * 40, "state": "ready", "text": "letter", "author_version": "6.113.5",
|
||||
"target_version": "6.114.0", "model": "m", "written_at": "t", "attempt_id": "att",
|
||||
"error_kind": "", "error_text": "", "last_good": None}
|
||||
record.update(over)
|
||||
return record
|
||||
|
||||
|
||||
@pytest.mark.parametrize("head, latest, relation", [
|
||||
("a" * 40, "b" * 40, "pending"),
|
||||
("b" * 40, "b" * 40, "applied"),
|
||||
("b" * 40, "c" * 40, "applied"),
|
||||
("a" * 40, "c" * 40, "superseded"),
|
||||
("d" * 40, "b" * 40, "other"),
|
||||
])
|
||||
def test_project_letter_relations(head, latest, relation):
|
||||
view = ul.project_letter(_record(), head_sha=head, latest_sha=latest)
|
||||
assert view["relation"] == relation and view["state"] == "ready" and view["text"] == "letter"
|
||||
|
||||
|
||||
def test_project_letter_failed_with_last_good_shows_previous_text():
|
||||
record = _record(state="failed", text="", error_kind="timeout", error_text="slow",
|
||||
last_good=_record(text="older letter"))
|
||||
view = ul.project_letter(record, head_sha="a" * 40, latest_sha="b" * 40)
|
||||
assert view["state"] == "failed" and view["text"] == "older letter" and view["has_last_good"] is True
|
||||
assert view["error_kind"] == "timeout"
|
||||
assert ul.project_letter(None, head_sha="a" * 40, latest_sha="") is None
|
||||
|
||||
|
||||
def test_official_update_projection_states(tmp_path):
|
||||
drive = tmp_path / "data"
|
||||
(drive / "state").mkdir(parents=True)
|
||||
head = "a" * 40
|
||||
assert ul.official_update_projection(head, drive_root=drive, state={})["status"] == "unchecked"
|
||||
cache = {"managed_update_cache": {"latest_sha": "b" * 40, "available": True, "behind": 3, "ahead": 0,
|
||||
"checked_at": "t0", "update_channel": "stable"}}
|
||||
# A check happened but no letter was written for THIS head: moved/unknown, never invented.
|
||||
assert ul.official_update_projection(head, drive_root=drive, state=cache)["status"] == "moved_since_check"
|
||||
ul.record_path(drive).write_text(json.dumps(_record()))
|
||||
fact = ul.official_update_projection(head, drive_root=drive, state=cache)
|
||||
assert fact["status"] == "update_available" and fact["letter"]["relation"] == "pending"
|
||||
assert fact["target"] == {"version": "6.114.0", "sha": "b" * 40} and fact["status_as_of"] == "t0"
|
||||
assert fact["running"]["sha"] == head and fact["behind"] == 3
|
||||
applied = ul.official_update_projection("b" * 40, drive_root=drive, state=cache)
|
||||
assert applied["status"] == "up_to_date" and applied["letter"]["relation"] == "applied"
|
||||
moved = ul.official_update_projection("e" * 40, drive_root=drive, state=cache)
|
||||
assert moved["status"] == "moved_since_check" and moved["letter"]["relation"] == "other"
|
||||
|
||||
|
||||
def test_official_update_projection_never_raises(tmp_path):
|
||||
fact = ul.official_update_projection("a" * 40, drive_root=tmp_path / "missing", state={"managed_update_cache": "bad"})
|
||||
assert fact["status"] == "unchecked"
|
||||
|
|
@ -250,3 +250,26 @@ def test_passive_read_hides_checked_at_when_cached_tip_is_unresolvable(monkeypat
|
|||
state = git_ops.compute_managed_update_status(fetch=False)
|
||||
assert "checked_at" not in state
|
||||
assert not state.get("available")
|
||||
|
||||
|
||||
def test_passive_payload_projects_letter_without_writing_it(monkeypatch):
|
||||
import ouroboros.update_letter as update_letter
|
||||
|
||||
_wire(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
update_letter, "refresh_after_check",
|
||||
lambda *a, **k: (_ for _ in ()).throw(AssertionError("a passive read must not write the letter")),
|
||||
)
|
||||
monkeypatch.setattr(update_letter, "read_record", lambda drive_root=None: {
|
||||
"key": {"base_sha": CURRENT, "target_sha": LATEST, "update_channel": "stable",
|
||||
"target_ref": "refs/ouroboros-managed/tags/v6.87.5"},
|
||||
"checked_head_sha": CURRENT, "state": "ready", "text": "one paragraph",
|
||||
"author_version": "6.87.4", "target_version": "6.87.5", "written_at": "2026-08-03T00:00:00Z",
|
||||
"attempt_id": "att", "error_kind": "", "error_text": "", "last_good": None,
|
||||
})
|
||||
|
||||
payload = control._managed_update_payload(fetch=False, include_tags=False)
|
||||
|
||||
assert payload["letter"]["relation"] == "pending"
|
||||
assert payload["letter"]["text"] == "one paragraph"
|
||||
assert payload["letter"]["author_version"] == "6.87.4"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue