Conversion review fixes: bounded synthetic receipts, fresh origin before adopt, docs made true

Annotation compaction kept every `agent-steer:<token>` receipt forever (each steer
mints a new token), so a long-lived install would keep chat_annotations.jsonl
permanently above the compaction threshold and rewrite it on every append; only the
newest 64 synthetic rows survive now (routing_wait polls for at most 15 seconds).
The UI conversion refreshes an absent origin at the top of the claim, before the
adopt decision, instead of after the project was already created; the implicit
check compares the request id through the server's own sanitizer; the naming
telemetry row is written only when a project is really created; the lane mark's
snapshot persist is best-effort so a persist failure cannot poison the rollback;
the origin lookup reads the registry only when a binding names the message; the
legacy-ambiguity disclosure is written once per (store, origin, choice) per
process. The browser proof clicks the sibling unconditionally and replays the
client's stale request, asserting the adopt. Docs: a timeout retry is bound only by
a later implicit act; the one-Project claim states its conditions; the promote
paragraph matches the transaction the code now holds.
This commit is contained in:
Ouroboros 2026-09-14 21:06:15 +03:00
parent 382882db11
commit cecfe16b66
9 changed files with 218 additions and 70 deletions

View file

@ -763,7 +763,7 @@ Card cost is sticky task-scope evidence. Only frames carrying task accounting st
A Cancel action appears only on unfinished, unconverted root cards carrying the host-attested `cancelable=true`; both pooled tasks and addressable native direct turns use the existing custody owner, while card shape alone grants no control. The stop control is the shared dropdown above, sent with `cascade:true` (root plus live descendant subtree): the hard path answers only after teardown or a typed refusal; the graceful "Wrap up" path records a durable finalize intent and answers immediately with a typed 202 pending acknowledgement. Natural completion wins a race, a missing live task reconciles from the durable record, and a process that cannot be proven dead remains a visible refusal rather than being painted Cancelled.
A Main root card may be turned into a Project: conversion creates or reuses the Project, names it owner-facing, binds the task and its canonical origin message, moves live work onto the Project lane, and replaces the Main action with a calm Project pointer; tasks already bound to a Project get no second conversion button. The one-click conversion (the request names no project, or the default id the client derives for that task) ADOPTS the Project the task's owner message already has instead of minting a second one: the existing row comes back with `adopted: true`, the coined name is discarded, and a card whose button is still on screen after a sibling bound it answers with its own Project rather than an error. A fresh conversion binds that origin's live sibling roots and direct turns to the new Project (disclosed in `events.jsonl` as `project_origin_siblings_bound`, skipping a sibling whose own binding names another room), so a promoted root and the turn that promoted it can never become two Projects for one message. A request that names a DIFFERENT project id is an explicit choice and keeps the 409 refusal. The pointer is the one shared project chip (`ui_helpers.js::renderProjectChip`): cards inside a Project panel and nested subagent cards never receive it, and clicking opens the panel or does nothing when already open — a pointer never closes what it points at. Naming reuses an already coined model title or falls back through the server naming path; the UI invents no second name authority. Project-SCOPED is not project-BOUND: a headless/CLI run carries a `project_id` for lease and memory without a durable binding — addressed to the Project thread at admission, it never mints a Main card and needs no conversion button.
A Main root card may be turned into a Project: conversion creates or reuses the Project, names it owner-facing, binds the task and its canonical origin message, moves live work onto the Project lane, and replaces the Main action with a calm Project pointer; tasks already bound to a Project get no second conversion button. The one-click conversion (the request names no project, or the default id the client derives for that task) ADOPTS the Project the task's owner message already has instead of minting a second one: the existing row comes back with `adopted: true`, the coined name is discarded, and a card whose button is still on screen after a sibling bound it answers with its own Project rather than an error. A fresh conversion binds that origin's live sibling roots and direct turns to the new Project (disclosed in `events.jsonl` as `project_origin_siblings_bound`, skipping a sibling whose own binding names another room), so — for work born from one owner message, while the binding store is readable and that Project is still active — a promoted root and the turn that promoted it cannot become two Projects for one message, and a second click mints nothing. A request that names a DIFFERENT project id is an explicit choice and keeps the 409 refusal. The pointer is the one shared project chip (`ui_helpers.js::renderProjectChip`): cards inside a Project panel and nested subagent cards never receive it, and clicking opens the panel or does nothing when already open — a pointer never closes what it points at. Naming reuses an already coined model title or falls back through the server naming path; the UI invents no second name authority. Project-SCOPED is not project-BOUND: a headless/CLI run carries a `project_id` for lease and memory without a durable binding — addressed to the Project thread at admission, it never mints a Main card and needs no conversion button.
A Project chat has one compact navigation pointer in its existing status bar (`project_work_pointer.js`). `projectWorkTarget` selects the latest connected non-child card by timestamp and physical source position that is unfinished, or the latest represented root when all are finished; it creates no card or execution state. The pointer updates through Chat's existing stable-viewport mutation seam. Clicking scrolls only that chat's messages container to the selected card and records the existing reading intent; it does not select the recipient of the next message. The label is `Working ·` / `Latest task ·` plus the card's coined name or title, whitespace-normalized and capped by `projectWorkLabel` and ellipsized to one line by CSS — never the card's full status headline. The pointer keeps the bar's original 180 px flex basis, so a default desktop panel renders one row while the status pill is short (Online, Working, Thinking, Sending, Queued); a longer pill, a narrower panel or a phone wraps the bar to a second row, never a third. Unless `historyWindow.complete` is explicitly true, the adjacent note says `Loaded messages only`; an empty represented set hides the button and the note, never a claim that the Project has no work. The binding's disposer removes its one listener and button/note with the chat instance. No separate task pane, history fetch, poller or persisted pointer state is introduced.

View file

@ -291,10 +291,12 @@ exercises that seam. For fuzzy entities use the LLM-first pattern
The same captured reference is also the IDENTITY OF THE WORK, not just its
provenance: a new task id minted from the same owner message (a promoted root,
a retry, a mid-run scope call) must INHERIT that origin's project binding
a mid-run scope call) must INHERIT that origin's project binding
(`projects_registry.project_id_for_origin`, keyed by value on chat id +
client message id), never re-derive project membership from its own id — one
convertible unit per message, not one per task id.
convertible unit per message, not one per task id. A timeout retry COPIES the
origin ref onto its new id but is deliberately NOT bound at clone time; it joins
that origin's project when a later implicit act adopts it.
One named exception inside role (b): a verification RECEIPT with no earlier
ingress point is reconciled by ONE TYPED IDENTITY KEY, matching on the key's

View file

@ -267,24 +267,19 @@ def _system_task_display_name(drive_root: object, task_id: str) -> str:
def _emit_naming_reason(drive_root: object, task_id: str, name: str, reason: str) -> None:
"""Durable structured telemetry for HOW a project was named (which fallback
path fired) so a future "New project" regression is visible in events.jsonl
instead of silent (north star: transparency). Best-effort; never raises."""
"""Durable structured telemetry for HOW a project was named (which fallback path
fired) so a future "New project" regression is visible in events.jsonl instead of
silent (north star: transparency). Written only where a project is really CREATED.
Best-effort; never raises."""
try:
import pathlib
from ouroboros.utils import append_jsonl, utc_now_iso
append_jsonl(
pathlib.Path(str(drive_root)) / "logs" / "events.jsonl",
{
"ts": utc_now_iso(),
"type": "project_named",
"task_id": str(task_id),
"name": str(name),
"reason": str(reason),
},
)
append_jsonl(pathlib.Path(str(drive_root)) / "logs" / "events.jsonl", {
"ts": utc_now_iso(), "type": "project_named",
"task_id": str(task_id), "name": str(name), "reason": str(reason),
})
except Exception:
log.debug("_emit_naming_reason failed", exc_info=True)
@ -293,8 +288,10 @@ def _mark_task_lane(task_id: str, pid: str) -> str:
"""Point the live queue/lease copy of ``task_id`` at ``pid`` under the queue lock
and persist the snapshot; returns the value the lane held BEFORE the call, so a
refused durable bind can put it back. SSOT for every post-hoc convert path here
(fresh bind, origin adopt, sibling claim) and the twin of the in-task
``ensure_project_scope`` mark, so they cannot drift apart again.
(fresh bind, origin adopt, sibling claim). It shares the lane MECHANICS — queue
lock plus snapshot — with the in-task ``ensure_project_scope`` mark so those
cannot drift apart, but NOT the authority policy: that call passes
``authority="binding"`` only when the origin adopt named the project.
The lease + assignment read ``task["project_id"]`` from the supervisor's in-memory
RUNNING map and PENDING list, NOT the durable bindings — so this mark, not
@ -304,9 +301,12 @@ def _mark_task_lane(task_id: str, pid: str) -> str:
pass already sees the lane; the bind's relative timing is irrelevant because
assignment never reads it). ``authority="binding"`` because the caller OWNS the
binding it is about to write, so the in-memory copy follows the truth even when the
row carries a derived id from a bare-workspace promote. The snapshot write keeps a
still-PENDING converted task scoped across a restart. No-op when the task is
neither running nor pending — the durable bind alone is then correct."""
row carries a derived id from a bare-workspace promote. The snapshot keeps a
still-PENDING converted task scoped across a restart, and is BEST-EFFORT: raising
after the mark landed cost the caller its ``previous`` value, so a later bind
refusal "restored" an empty lane it never set, clearing a project the task really
had. The main loop persists every tick anyway. No-op when the task is neither
running nor pending — the durable bind alone is then correct."""
from ouroboros.project_lease import mark_task_project, task_lane_project_id
from supervisor.queue import _queue_lock, persist_queue_snapshot
from supervisor.workers import PENDING, RUNNING
@ -315,7 +315,10 @@ def _mark_task_lane(task_id: str, pid: str) -> str:
previous = task_lane_project_id(RUNNING, PENDING, task_id)
marked = mark_task_project(RUNNING, PENDING, task_id, pid, authority="binding")
if marked:
persist_queue_snapshot(reason="project_from_task")
try:
persist_queue_snapshot(reason="project_from_task")
except Exception:
log.debug("_mark_task_lane: snapshot persist failed for %s", task_id, exc_info=True)
return previous
@ -762,12 +765,12 @@ async def api_project_from_task(request: Request) -> JSONResponse:
)
drive_root = request_drive_root(request)
# An IMPLICIT conversion is the one-click owner act: the browser sends the
# default id for this task (chat_activity.js::projectIdFromTask) or none at
# all, so the request names no particular room and the work's own project
# answers it. An EXPLICIT different id is an API caller naming a room.
implicit = str(body.get("id") or body.get("project_id") or "").strip().lower() in (
"", f"task-{task_id}".lower()[:64],
)
# default id for this task (chat_activity.js::projectIdFromTask) or none at all
# — and ``raw_id`` already defaults to that same ``task-<task_id>`` — so the
# request names no particular room and the work's own project answers it. Both
# halves go through the SERVER's own sanitizer, so the client's slug rules and
# this check cannot drift. An EXPLICIT different id is a caller naming a room.
implicit = sanitize_project_id(raw_id) == sanitize_project_id(f"task-{task_id}")
disclosed: list = []
def _conflicting_binding(*, disclose: bool) -> Any:
@ -777,9 +780,9 @@ async def api_project_from_task(request: Request) -> JSONResponse:
hands it a lane - all of which used to happen before the immutable bind
refused a task that already belonged somewhere else, and a single read taken
before the naming await cannot see a task that bound itself during it. The
refusal NAMES that project, so no surface has to invent an explanation
(there is no reload affordance in the desktop shell, the Telegram mini app
or the mobile layout). An UNREADABLE store is disclosed once and read as "no
refusal NAMES that project, so no surface has to invent an explanation (there
is no reload affordance in the desktop shell, the Telegram mini app or the
mobile layout). An UNREADABLE store is disclosed once and read as "no
binding", exactly as the hot path reads it, so the conversion proceeds as it
would for an unbound task."""
try:
@ -808,8 +811,7 @@ async def api_project_from_task(request: Request) -> JSONResponse:
def _bind_and_answer(project: dict, *, adopted: bool) -> Any:
"""Mark the lane, write the durable bind, answer. Called with the claim
lock held."""
nonlocal origin, origin_ref
lock held, after ``_claim`` has refreshed the origin."""
pid = str(project["id"])
try:
previous_lane = _mark_task_lane(task_id, pid)
@ -817,12 +819,6 @@ async def api_project_from_task(request: Request) -> JSONResponse:
previous_lane = ""
log.debug("api_project_from_task: in-memory project_id update failed for %s",
task_id, exc_info=True)
if "absent" in origin:
# The record may have persisted during the naming await; re-read before
# the bind so a conversion clicked a second after the message still
# keeps the start message (and can still claim that message's siblings).
origin = _owner_task_origin(drive_root, task_id)
origin_ref = origin.get("ref")
try:
binding = bind_task_to_project(
drive_root, task_id, pid, project.get("chat_id"), origin=origin,
@ -856,7 +852,7 @@ async def api_project_from_task(request: Request) -> JSONResponse:
payload["adopted"] = True
return JSONResponse(payload)
def _claim(project_name: str = "") -> Any:
def _claim(project_name: str = "", naming_reason: str = "") -> Any:
"""The whole claim under the ONE process-local claim lock: re-read the
authority, ADOPT the project this work already has, or — once a name is
settled — CREATE the requested one and bind to it. ``None`` when there is
@ -875,7 +871,14 @@ async def api_project_from_task(request: Request) -> JSONResponse:
An EXPLICIT different project id is an API caller naming a specific room,
never the one-click owner act: it is not adopted away, and the task-keyed
409 still answers it (P13)."""
nonlocal origin, origin_ref
with origin_claim_lock():
if "absent" in origin:
# The ingress record may persist only after this click started (and
# again during the naming await): a stale absence answers "this work
# has no project" and mints the one the sibling already owns.
origin = _owner_task_origin(drive_root, task_id)
origin_ref = origin.get("ref")
try:
bound = str(project_id_for_task(drive_root, task_id, strict=True) or "")
adopted = str(project_id_for_origin(drive_root, origin_ref, strict=True) or "")
@ -899,6 +902,9 @@ async def api_project_from_task(request: Request) -> JSONResponse:
refusal = _conflicting_binding(disclose=False)
if refusal is not None:
return refusal
# Only a really CREATED project gets a naming row: an adopt discards the
# coined name, and a row for a name nobody saw reads as a regression.
_emit_naming_reason(drive_root, task_id, project_name, naming_reason)
return _bind_and_answer(
create_project(
drive_root, sanitize_project_id(raw_id), name=project_name,
@ -914,13 +920,6 @@ async def api_project_from_task(request: Request) -> JSONResponse:
refusal = _conflicting_binding(disclose=True)
if refusal is not None:
return refusal
# Auto-name from the task's own title/objective when the caller sends none
# (the one-click convert path), so no human input and no extra LLM call
# are needed (owner P1). An explicit name still wins. Order: explicit name ->
# server-derived (title/objective/queue) -> the frontend's objective_hint
# (the owner's original request, for a still in-progress DIRECT chat task
# with no server-side source yet) -> a neutral "New project". Never the bare
# task id — the owner explicitly does not want names surfacing as "task-…".
supplied_name = str(body.get("name") or "").strip()
if len(supplied_name) > PROJECT_NAME_MAX:
return JSONResponse(
@ -935,15 +934,17 @@ async def api_project_from_task(request: Request) -> JSONResponse:
if len(hint) > _MAX_DERIVED_NAME:
hint = hint[: _MAX_DERIVED_NAME - 1].rstrip() + "…"
owner_text = _owner_request_text(drive_root, task_id, full_hint)
# LLM-first project name (Cluster B): the owner wants a name the model coined,
# not the heuristic "task-…". Order: explicit caller name -> explicit task title
# -> a title the proactive card namer already coined (both reused with ZERO extra call) -> an inline bounded
# light-model call -> the heuristic (title/objective/queue) -> the frontend hint
# -> a neutral "New project". The async namer folds the heuristic/hint candidates
# into its own fail-soft fallback, so a missing key / timeout never blocks convert.
# LLM-first project name (Cluster B), with no human input and no extra LLM call
# on the one-click path (owner P1): explicit caller name -> explicit task title
# -> a title the proactive card namer already coined (both reused with ZERO extra
# call) -> an inline bounded light-model call -> the heuristic (title/objective/
# queue) -> the frontend's objective_hint (the owner's original request, for a
# still in-progress DIRECT chat task with no server-side source yet) -> a neutral
# "New project". Never the bare task id — the owner does not want names surfacing
# as "task-…". The async namer folds the heuristic/hint candidates into its own
# fail-soft fallback, so a missing key / timeout never blocks convert.
if supplied_name:
project_name = supplied_name
_emit_naming_reason(drive_root, task_id, project_name, "supplied")
project_name, reason = supplied_name, "supplied"
else:
from ouroboros.project_naming import llm_project_name_async
@ -978,12 +979,11 @@ async def api_project_from_task(request: Request) -> JSONResponse:
else:
reason = "hint_or_fallback"
project_name = _cap_name(project_name)
_emit_naming_reason(drive_root, task_id, project_name, reason)
# The naming step ran OUTSIDE the claim lock because it can await a model
# call for seconds. Claim again: if a sibling card of this same owner
# message won the race meanwhile, the coined name is simply discarded and
# the task joins the project that work already has.
return _claim(project_name or "New project")
return _claim(project_name or "New project", reason)
except Exception as exc:
return json_exception(exc)

View file

@ -29,12 +29,18 @@ _ANNOTATIONS_NAME = "chat_annotations.jsonl"
# on one through ``routing_wait``, and silence there reports a landed delivery
# as unconfirmed), but it must annotate no owner message: the id is the act's
# routing token, so nothing in any chat joins to it. Chat-row membership is
# therefore the wrong retention test for these rows — compaction keeps the
# latest row per synthetic id (per-id dedupe bounds them like any other) and
# applies the chat-retention rule only to ids that address a real message. The
# colon shape cannot collide with a client id: the browser mints
# ``msg-<epoch_ms>-<n>`` and non-web ingress ``host-<uuid5>``.
# therefore the wrong retention test for these rows — compaction applies the
# chat-retention rule only to ids that address a real message, and bounds these
# by the newest-N cap below. Per-id dedupe cannot bound them: every steer mints
# a fresh token, so each act has an id of its own. The colon shape cannot
# collide with a client id: the browser mints ``msg-<epoch_ms>-<n>`` and non-web
# ingress ``host-<uuid5>``.
AGENT_RECEIPT_ID_PREFIX = "agent-steer:"
# How many synthetic receipts survive a compaction. ``routing_wait`` polls for at
# most 15 seconds, so every live waiter's row is far inside this window; without a
# cap a long-lived install would keep the file permanently above the threshold and
# rewrite the whole of it on every append.
_RETAINED_AGENT_RECEIPTS = 64
_COMPACT_AT_BYTES = 800_000
_RETAINED_ARCHIVES = 3
log = logging.getLogger(__name__)
@ -336,9 +342,14 @@ def _compact_annotations_locked(drive_root: Any, path: pathlib.Path) -> None:
for row in iter_jsonl_objects(chat_path)
if row.get("client_message_id")
}
latest = _latest_annotations(path)
kept_receipts = set(sorted(
(message_id for message_id in latest if message_id.startswith(AGENT_RECEIPT_ID_PREFIX)),
key=lambda message_id: str(latest[message_id].get("ts") or ""),
)[-_RETAINED_AGENT_RECEIPTS:])
rows = [
row for message_id, row in _latest_annotations(path).items()
if message_id in retained_ids or message_id.startswith(AGENT_RECEIPT_ID_PREFIX)
row for message_id, row in latest.items()
if message_id in retained_ids or message_id in kept_receipts
]
rows.sort(key=lambda row: str(row.get("ts") or ""))
tmp = path.with_name(f".{path.name}.tmp.{uuid.uuid4().hex}")

View file

@ -481,6 +481,8 @@ def project_id_for_origin(drive_root: Any, origin_ref: Any, *, strict: bool = Fa
and str(row.get("project_id") or "").strip()
and origin_key(row.get("source_ref")) == key
)
if not candidates:
return "" # the common case: no binding names this message, so no registry read
active = {str(project.get("id") or "") for project in list_projects(drive_root)}
candidates = [row for row in candidates if row[2] in active]
if not candidates:
@ -495,9 +497,23 @@ def project_id_for_origin(drive_root: Any, origin_ref: Any, *, strict: bool = Fa
return chosen
_DISCLOSED_AMBIGUOUS_ORIGINS: set = set()
def _emit_origin_ambiguous(drive_root: Any, key: tuple, candidates: list, chosen: str) -> None:
"""Durable disclosure (P1) that one owner message names several projects, and
which one the adopt chose. Best-effort; never raises."""
which one the adopt chose. Best-effort; never raises.
ONCE per (store, origin, choice) per process: legacy state is read on every
promote tool call, every promote admission and every convert click, and a fact
that has not changed is not news — a row per lookup would bury the disclosure in
its own repetitions. A restart discloses once more, and a CHANGED choice (the
bound task went live, or a candidate's project was deleted) is a new fact and is
always written."""
disclosure = (str(drive_root), key, chosen)
if disclosure in _DISCLOSED_AMBIGUOUS_ORIGINS:
return
_DISCLOSED_AMBIGUOUS_ORIGINS.add(disclosure)
try:
from ouroboros.utils import append_jsonl

View file

@ -440,6 +440,13 @@ def test_project_id_for_origin_resolves_a_legacy_double_binding_and_discloses_it
assert {row["project_id"] for row in rows[-1]["candidates"]} == {"first-room", "second-room"}
assert rows[-1]["origin"] == {"chat_id": 1, "client_message_id": "msg-1"}
# Legacy state is re-read on every promote, admission and convert click, and a
# fact that has not changed is not news: the same choice discloses once.
assert project_id_for_origin(tmp_path, ref, strict=True) == "second-room"
assert len([json.loads(line) for line in
(tmp_path / "logs" / "events.jsonl").read_text(encoding="utf-8").splitlines()
if line.strip()]) == len(rows)
# The earlier binding's task is still running: the work is there, so it wins.
import supervisor.workers as workers

View file

@ -355,6 +355,9 @@ def test_ui_conversion_adopts_a_binding_that_lands_during_the_naming_await(tmp_p
assert [row["project_id"] for row in broadcasts] == ["token-observatory"]
assert workers.RUNNING["trace"]["task"]["project_id"] == "token-observatory"
assert (project_binding_for_task(tmp_path, "trace") or {}).get("project_id") == "token-observatory"
# The coined name was discarded with the create branch, so nothing recorded a
# naming decision for a project that was never made.
assert _events(tmp_path, "project_named") == []
def test_ui_conversion_restores_the_lane_when_the_durable_bind_is_refused(tmp_path, monkeypatch):
@ -527,6 +530,9 @@ def test_converting_the_turn_claims_the_root_it_promoted(tmp_path, monkeypatch,
[row] = _events(tmp_path, "project_origin_siblings_bound")
assert row["task_id"] == "t-turn" and row["project_id"] == pid
assert row["bound"] == ["t-root"] and row["skipped"] == []
# A project that really WAS created still records how it was named.
[named] = _events(tmp_path, "project_named")
assert named["task_id"] == "t-turn" and named["reason"] == "explicit_task_title"
# The root's card may still be showing its stale button (a mid-air /api/state
# refresh, the Telegram mini app, a phone). Clicking it ADOPTS the project the
@ -674,6 +680,49 @@ def test_a_sibling_that_binds_during_the_naming_await_is_adopted_not_duplicated(
assert [p["id"] for p in list_projects(tmp_path)] == ["sibling-room"]
def test_a_conversion_whose_origin_lands_late_still_adopts_the_message_project(
tmp_path, monkeypatch, _direct_turns,
):
"""The ingress record can persist only AFTER the click starts - the window the
convert path already documents for the naming await. Resolving the origin once,
before the authority reads, then answering "this work has no project" is exactly
how the second Project got minted while a sibling of the SAME message was already
bound to the first. The re-read runs inside the claim, ahead of both reads."""
import ouroboros.gateway.projects as gateway
from ouroboros.projects_registry import (
bind_task_to_project,
create_project,
list_projects,
project_binding_for_task,
)
(tmp_path / "logs").mkdir()
ref = _origin_ref()
_seed_origin_task(tmp_path, "t-turn", ref)
room = create_project(tmp_path, "the-work", name="The Work")
bind_task_to_project(tmp_path, "t-root", "the-work", room["chat_id"],
origin={"ref": ref, "text": _OWNER_TEXT})
_live_queue(monkeypatch, tmp_path, {}, [])
real_origin, calls = gateway._owner_task_origin, []
def _lands_late(drive_root, task_id):
calls.append(task_id)
if len(calls) == 1:
return {"absent": "post_hoc_unresolved"}
return real_origin(drive_root, task_id)
monkeypatch.setattr(gateway, "_owner_task_origin", _lands_late)
resp = _convert(tmp_path, "t-turn")
body = json.loads(resp.body.decode("utf-8"))
assert resp.status_code == 200 and body["adopted"] is True
assert body["project"]["id"] == "the-work"
assert [p["id"] for p in list_projects(tmp_path)] == ["the-work"]
assert (project_binding_for_task(tmp_path, "t-turn") or {}).get("project_id") == "the-work"
assert _events(tmp_path, "project_named") == []
def test_two_sibling_cards_converted_at_once_still_yield_one_project(
tmp_path, monkeypatch, _direct_turns,
):

View file

@ -343,6 +343,54 @@ def test_chat_annotation_compaction_keeps_receipts_that_address_no_message(tmp_p
assert latest_chat_annotations(tmp_path)[receipt_id]["status"] == "needs_manual_target"
def test_synthetic_receipt_retention_is_bounded_by_the_newest_cap(tmp_path):
"""Every steer mints a fresh token, so per-id dedupe bounds nothing: without a
cap the synthetic rows accumulate forever, the file stays permanently above the
compaction threshold and every append rewrites the whole of it. The newest cap
survives, which is all any live `routing_wait` (15 s of polling) can need."""
from ouroboros.project_dialogue import (
AGENT_RECEIPT_ID_PREFIX, _COMPACT_AT_BYTES, _RETAINED_AGENT_RECEIPTS,
append_chat_annotation, latest_chat_annotations,
)
logs = tmp_path / "logs"
logs.mkdir()
(logs / "chat.jsonl").write_text("", encoding="utf-8")
annotations = logs / "chat_annotations.jsonl"
seeded = 70
with annotations.open("w", encoding="utf-8") as stream:
for index in range(seeded):
stream.write(json.dumps({
"ts": f"2026-09-14T12:{index // 60:02d}:{index % 60:02d}Z",
"type": "chat_annotation",
"client_message_id": f"{AGENT_RECEIPT_ID_PREFIX}token{index:04d}",
"action": "steer_task", "target": "t-target", "status": "delivered",
"routing_token": f"token{index:04d}", "detail": "x" * 200,
}) + "\n")
assert annotations.stat().st_size < _COMPACT_AT_BYTES # no compaction yet
assert len(latest_chat_annotations(tmp_path)) == seeded
# Cross the threshold with one oversized stale row, then append the newest receipt.
with annotations.open("a", encoding="utf-8") as stream:
stream.write(json.dumps({
"ts": "2026-07-13T00:00:00Z", "type": "chat_annotation",
"client_message_id": "expired", "action": "routed",
"target": "x" * _COMPACT_AT_BYTES, "status": "delivered",
}) + "\n")
newest = f"{AGENT_RECEIPT_ID_PREFIX}tokennewest"
assert append_chat_annotation(
tmp_path, newest, action="steer_task", target="t-target",
status="delivered", routing_token="tokennewest",
)
latest = latest_chat_annotations(tmp_path)
assert annotations.stat().st_size < _COMPACT_AT_BYTES # it really did compact
synthetic = [mid for mid in latest if mid.startswith(AGENT_RECEIPT_ID_PREFIX)]
assert len(synthetic) <= _RETAINED_AGENT_RECEIPTS
assert newest in synthetic # the row its own append had to keep
assert "expired" not in latest # chat retention still drops addressed rows
def test_project_sidebar_and_menu_static_contracts():
from pathlib import Path

View file

@ -101,12 +101,12 @@ def test_ui_two_cards_one_origin_convert_into_one_project(direct_server_with_dat
assert len(projects) == 1, projects
first_name = c1.inner_text().strip().splitlines()[0]
# The sibling card: convert it too (the button may still be rendered until
# the next state refresh — the SERVER must adopt, never mint).
# The sibling card: its button is still there (this task is completed, so
# the sibling claim never bound it), and converting it must ADOPT.
btn = c2.locator("[data-turn-into-project]")
if btn.count():
# JS click: a toast from the first conversion can sit over the button.
btn.first.evaluate("b => b.click()")
assert btn.count() == 1
# JS click: a toast from the first conversion can sit over the button.
btn.first.evaluate("b => b.click()")
c2.locator(".chat-live-project-card-btn").first.wait_for(
state="visible", timeout=20_000,
)
@ -116,6 +116,21 @@ def test_ui_two_cards_one_origin_convert_into_one_project(direct_server_with_dat
second_name = c2.inner_text().strip().splitlines()[0]
assert first_name == second_name, (first_name, second_name)
# A card that is already bound can STILL have its button on screen (a phone
# that missed the refresh, the Telegram mini app, a second tab). Replay the
# exact request the client sends — api_client.js::projectFromTask POSTs
# /api/projects/from-task with the default id chat_activity.js derives — and
# the server adopts that Project instead of minting one or answering 4xx.
replay = page.request.post(f"{url}/api/projects/from-task", data={
"task_id": "t2promoted", "id": "task-t2promoted",
"name": "", "objective_hint": ORIGIN_TEXT,
})
assert replay.status == 200, replay.text()
replayed = replay.json()
assert replayed["adopted"] is True, replayed
assert replayed["project"]["id"] == projects[0]["id"]
assert len(page.request.get(f"{url}/api/projects").json()["projects"]) == 1
bindings = json.loads((data_dir / "state" / "project_task_bindings.json").read_text())["bindings"]
assert bindings["t1direct"]["project_id"] == bindings["t2promoted"]["project_id"]