Files
turnstone/tests/test_create_lifecycle_sequencing.py
T
Patrick Buckley 7a06f5e8bc refactor(session): make ModelLane the provider boundary (#979) (#989)
* refactor(session): make ModelLane the provider boundary (#979)

## Summary

This closes the model-lane ownership gap left by #832: `ChatSession` no longer stores raw provider/client handles. `ResolvedModelBinding` now carries the provider, client, model, capabilities, registry generation, and backend-auth configuration as one coherent snapshot.

- Atomically rebind existing sessions after model-registry changes while pinning each in-flight send, fallback, judge, output guard, task agent, title, compaction, perception, and voice operation to its initiating principal and binding.
- Fence UI publication, canonical trajectory folds, durable writes, streams, retries, child scopes, and judge work by generation. Stop can hand off to a successor without accepting late state; cancelled tools retain typed effect receipts, and concurrent approval batches resolve by exact cycle or call.
- Make create, fork, open, close, and delete race-safe with hidden `creating` reservations, incarnation-aware state tails, and an ACL-rechecked transaction that clones checkpoint-bounded history, configuration, project/persona state, and attachment references.
- Extend REST/OpenAPI and Python/TypeScript SDK contracts for create/fork inputs, routed-create metadata, live-workstream probes, targeted approvals, and structured cancellation results.
- Update architecture, storage, authentication, judge, channel, console, API, and SDK documentation, including regenerated architecture diagrams and OpenAPI artifacts.

## Validation

- SQLite suite: 11,188 passed, 9 skipped, 10 deselected
- PostgreSQL suite: 11,195 passed, 2 skipped, 10 deselected
- Live backend: 3 passed
- SSE recovery: 6 passed; browser recovery harness passed all scenarios
- Ruff: clean; 595 files correctly formatted
- mypy: 243 source files clean
- TypeScript: typecheck/build and 35 tests passed
- OpenAPI artifacts fresh; all 14 changed diagrams reproduce byte-for-byte
- `git diff --check` and Git LFS integrity clean

Closes #979.

* fix(deps): update nanoid for GHSA-2v37-7h3g-55p8

Refresh the transitive lock entry admitted by PostCSS so the TypeScript security gate no longer resolves the vulnerable custom-generator implementation.

Validation:
- npm ci
- npm audit --audit-level=moderate: 0 vulnerabilities
- TypeScript typecheck and build
- TypeScript tests: 35 passed

* fix(test): assert canonical model registry URLs

Replace prefix checks with exact canonical base URL assertions so the tests do not model incomplete URL validation.

Validation: tests/test_model_registry.py (185 passed); Ruff check/format; mypy.
2026-08-08 16:13:35 -07:00

620 lines
23 KiB
Python

"""Race regressions for the deferred-create publication boundary."""
from __future__ import annotations
import asyncio
import json
import queue
import threading
import time
from typing import Any
import httpx
import pytest
from starlette.applications import Starlette
from starlette.routing import Route
from tests.test_server_authz import (
_auth,
_FakeSession,
)
from tests.test_server_authz import app_client as app_client
from tests.test_session_manager import FakeAdapter, _make_manager
from turnstone.core.session_routes import (
SessionEndpointConfig,
make_create_handler,
)
class _BlockingCreateEmitter(FakeAdapter):
"""Pause inside ``emit_created`` after commit admission."""
def __init__(self) -> None:
super().__init__()
self.create_emit_entered = threading.Event()
self.release_create_emit = threading.Event()
def emit_created(self, ws: Any) -> None:
self.create_emit_entered.set()
assert self.release_create_emit.wait(timeout=10), "test did not release create emit"
super().emit_created(ws)
@pytest.mark.parametrize("terminal", ["close", "delete"])
def test_terminal_after_commit_admission_observes_created_first(terminal: str) -> None:
"""A close/delete admitted during create fan-out cannot overtake it."""
adapter = _BlockingCreateEmitter()
mgr, _, _ = _make_manager(adapter=adapter)
ws = mgr.create(user_id="u1", name="ordered", defer_emit_created=True)
commit_result: list[bool] = []
terminal_result: list[bool] = []
terminal_started = threading.Event()
terminal_done = threading.Event()
def _commit() -> None:
commit_result.append(mgr.commit_create(ws))
def _retire() -> None:
terminal_started.set()
if terminal == "close":
terminal_result.append(mgr.close(ws.id))
else:
terminal_result.append(mgr.delete(ws.id))
terminal_done.set()
commit_thread = threading.Thread(target=_commit, daemon=True)
terminal_thread = threading.Thread(target=_retire, daemon=True)
commit_thread.start()
assert adapter.create_emit_entered.wait(timeout=5), "commit never entered emit_created"
terminal_thread.start()
assert terminal_started.wait(timeout=5)
try:
assert not terminal_done.wait(timeout=0.1), "terminal transition overtook create emit"
finally:
adapter.release_create_emit.set()
commit_thread.join(timeout=5)
terminal_thread.join(timeout=5)
assert not commit_thread.is_alive()
assert not terminal_thread.is_alive()
assert commit_result == [True]
assert terminal_result == [True]
assert [(event.kind, event.reason) for event in adapter.events] == [
("created", None),
("closed", "closed" if terminal == "close" else "deleted"),
]
def test_pending_idle_deferred_create_is_not_capacity_evicted() -> None:
"""A not-yet-published IDLE reservation remains an in-flight transaction."""
mgr, adapter, _ = _make_manager(max_active=1)
pending = mgr.create(
user_id="u1",
name="pending",
defer_emit_created=True,
)
with pytest.raises(RuntimeError, match="All 1 slots are active"):
mgr.create(user_id="u2", name="challenger")
# Pending reservations are deliberately hidden from public lookup/list
# surfaces. Prove the exact object survived capacity pressure by committing
# it successfully, after which it becomes visible as the sole occupant.
assert mgr.count == 1
assert adapter.events == []
assert mgr.commit_create(pending) is True
assert mgr.get(pending.id) is pending
assert mgr.list_all() == [pending]
assert [event.kind for event in adapter.events] == ["created"]
assert adapter.cleaned_up == []
assert mgr.eviction_count == 0
def _drain_global_events(app_client: Any) -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
global_queue = app_client.app.state.global_queue
while True:
try:
events.append(global_queue.get_nowait())
except queue.Empty:
return events
def _created_audits(storage: Any, ws_id: str) -> list[dict[str, Any]]:
return [
event
for event in storage.list_audit_events(action="workstream.created")
if event["resource_id"] == ws_id
]
async def _wait_for_thread_event(event: threading.Event, timeout: float) -> bool:
"""Poll a thread seam without occupying the loop's default executor."""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
while not event.is_set():
if loop.time() >= deadline:
return False
await asyncio.sleep(0.01)
return True
@pytest.mark.anyio
@pytest.mark.parametrize("anyio_backend", ["asyncio"])
async def test_cancellation_during_session_build_removes_exact_hidden_create(
app_client: Any,
monkeypatch: pytest.MonkeyPatch,
anyio_backend: str,
) -> None:
"""A cancelled request drains the admitted build before exact rollback."""
from turnstone.core.attachment_buffer import get_attachment_buffer
assert anyio_backend == "asyncio"
sync_client, mgr = app_client
storage = sync_client.app.state.auth_storage
assert storage is not None
ws_id = "1" * 32
build_entered = threading.Event()
release_build = threading.Event()
build_finished = threading.Event()
original_build = mgr._adapter.build_session
buffer = get_attachment_buffer()
buffer.clear()
def _blocked_build(ws: Any, **kwargs: Any) -> Any:
build_entered.set()
assert release_build.wait(timeout=10), "test did not release session build"
try:
return original_build(ws, **kwargs)
finally:
build_finished.set()
monkeypatch.setattr(mgr._adapter, "build_session", _blocked_build)
transport = httpx.ASGITransport(app=sync_client.app)
try:
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
request_task = asyncio.create_task(
client.post(
"/v1/api/workstreams/new",
json={"ws_id": ws_id, "name": "cancel-during-build"},
headers=_auth("user-1"),
)
)
assert await _wait_for_thread_event(build_entered, 5), "create never entered build"
# A caller-known id can receive a concurrent staged upload while
# its hidden durable reservation is still being constructed.
buffer.stage(
ws_id=ws_id,
user_id="user-1",
filename="pending.md",
mime_type="text/markdown",
kind="text",
content=b"pending create upload",
)
with mgr._lock:
pending = mgr._workstreams.get(ws_id)
assert pending is not None
assert mgr._pending_creates.get(ws_id) is pending
assert storage.get_workstream(ws_id) is not None
request_task.cancel()
await asyncio.sleep(0.05)
assert not request_task.done()
release_build.set()
with pytest.raises(asyncio.CancelledError):
await request_task
finally:
release_build.set()
assert build_finished.is_set()
with mgr._lock:
assert ws_id not in mgr._workstreams
assert ws_id not in mgr._pending_creates
assert storage.get_workstream(ws_id) is None
assert buffer.list_for(ws_id=ws_id, user_id="user-1") == []
assert _created_audits(storage, ws_id) == []
assert not [event for event in _drain_global_events(sync_client) if event.get("ws_id") == ws_id]
buffer.clear()
@pytest.mark.anyio
@pytest.mark.parametrize("anyio_backend", ["asyncio"])
async def test_second_cancellation_cannot_interrupt_create_rollback(
app_client: Any,
monkeypatch: pytest.MonkeyPatch,
anyio_backend: str,
) -> None:
"""Repeated cancellation is deferred until discard and delete settle."""
from turnstone.core.attachment_buffer import get_attachment_buffer
assert anyio_backend == "asyncio"
sync_client, mgr = app_client
storage = sync_client.app.state.auth_storage
assert storage is not None
ws_id = "2" * 32
build_entered = threading.Event()
release_build = threading.Event()
rollback_entered = threading.Event()
release_rollback = threading.Event()
original_build = mgr._adapter.build_session
original_discard = mgr.discard
buffer = get_attachment_buffer()
buffer.clear()
def _blocked_build(ws: Any, **kwargs: Any) -> Any:
build_entered.set()
assert release_build.wait(timeout=10), "test did not release session build"
return original_build(ws, **kwargs)
def _blocked_discard(*args: Any, **kwargs: Any) -> bool:
rollback_entered.set()
assert release_rollback.wait(timeout=10), "test did not release rollback"
return original_discard(*args, **kwargs)
monkeypatch.setattr(mgr._adapter, "build_session", _blocked_build)
monkeypatch.setattr(mgr, "discard", _blocked_discard)
transport = httpx.ASGITransport(app=sync_client.app)
try:
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
request_task = asyncio.create_task(
client.post(
"/v1/api/workstreams/new",
json={"ws_id": ws_id, "name": "cancel-rollback-twice"},
headers=_auth("user-1"),
)
)
assert await _wait_for_thread_event(build_entered, 5), "create never entered build"
buffer.stage(
ws_id=ws_id,
user_id="user-1",
filename="pending.md",
mime_type="text/markdown",
kind="text",
content=b"survives until rollback",
)
request_task.cancel()
release_build.set()
assert await _wait_for_thread_event(rollback_entered, 5), "rollback never started"
# The first cancellation has already transferred ownership to the
# cleanup bracket. A second one must not strand either half of the
# in-memory/durable rollback transaction.
assert request_task.cancel() is True
await asyncio.sleep(0.05)
assert not request_task.done()
assert storage.get_workstream(ws_id) is not None
assert buffer.list_for(ws_id=ws_id, user_id="user-1")
release_rollback.set()
with pytest.raises(asyncio.CancelledError):
await request_task
finally:
release_build.set()
release_rollback.set()
with mgr._lock:
assert ws_id not in mgr._workstreams
assert ws_id not in mgr._pending_creates
assert storage.get_workstream(ws_id) is None
assert buffer.list_for(ws_id=ws_id, user_id="user-1") == []
assert _created_audits(storage, ws_id) == []
assert not [event for event in _drain_global_events(sync_client) if event.get("ws_id") == ws_id]
buffer.clear()
def test_partial_multipart_failure_drops_staged_refs_before_same_id_successor(
app_client: Any,
) -> None:
"""A partially staged failed request cannot lend uploads to its successor."""
from turnstone.core.attachment_buffer import get_attachment_buffer
client, mgr = app_client
storage = client.app.state.auth_storage
assert storage is not None
ws_id = "3" * 32
buffer = get_attachment_buffer()
buffer.clear()
try:
failed = client.post(
"/v1/api/workstreams/new",
data={"meta": json.dumps({"ws_id": ws_id, "name": "partial"})},
files=[
("file", ("valid.md", b"first file stages", "text/markdown")),
("file", ("invalid.bin", b"\x00\x01\x02", "application/octet-stream")),
],
headers=_auth("user-1"),
)
assert failed.status_code == 400, failed.text
assert storage.get_workstream(ws_id) is None
assert buffer.list_for(ws_id=ws_id, user_id="user-1") == []
with mgr._lock:
assert ws_id not in mgr._workstreams
assert ws_id not in mgr._pending_creates
assert _created_audits(storage, ws_id) == []
assert not [event for event in _drain_global_events(client) if event.get("ws_id") == ws_id]
successor = client.post(
"/v1/api/workstreams/new",
json={"ws_id": ws_id, "name": "successor"},
headers=_auth("user-1"),
)
assert successor.status_code == 200, successor.text
assert successor.json()["attachment_ids"] == []
assert storage.get_workstream(ws_id) is not None
assert buffer.list_for(ws_id=ws_id, user_id="user-1") == []
finally:
buffer.clear()
def test_close_idle_zero_never_retires_pending_create() -> None:
"""The idle sweeper treats a hidden reservation as an in-flight create."""
mgr, adapter, storage = _make_manager()
pending = mgr.create(
user_id="u1",
name="pending-idle",
defer_emit_created=True,
)
pending.last_active = time.monotonic() - 100
assert mgr.close_idle(max_age_seconds=0) == []
assert adapter.cleaned_up == []
assert adapter.events == []
assert storage.rows[pending.id].state == "creating"
with mgr._lock:
assert mgr._workstreams.get(pending.id) is pending
assert mgr._pending_creates.get(pending.id) is pending
assert mgr.commit_create(pending) is True
assert mgr.get(pending.id) is pending
assert [event.kind for event in adapter.events] == ["created"]
def test_delete_endpoint_waits_for_admitted_create_publication(
app_client: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Durable deletion linearizes after a create that already owns admission."""
client, mgr = app_client
storage = client.app.state.auth_storage
assert storage is not None
ws_id = "4" * 32
pending = mgr.create(
ws_id=ws_id,
user_id="user-1",
name="commit-before-delete",
defer_emit_created=True,
)
emit_entered = threading.Event()
release_emit = threading.Event()
delete_admission_entered = threading.Event()
durable_delete_entered = threading.Event()
delete_started = threading.Event()
original_emit = mgr._event_emitter.emit_created
original_delete = storage.delete_workstream_if_fork_reserved
original_delete_persisted = mgr.delete_persisted
commit_results: list[bool] = []
delete_responses: list[Any] = []
def _blocked_emit(ws: Any) -> None:
emit_entered.set()
assert release_emit.wait(timeout=10), "test did not release create publication"
original_emit(ws)
def _tracked_delete(candidate_id: str, reservation_token: str) -> bool:
durable_delete_entered.set()
return original_delete(candidate_id, reservation_token)
def _tracked_delete_persisted(*args: Any, **kwargs: Any) -> bool:
delete_admission_entered.set()
return original_delete_persisted(*args, **kwargs)
def _commit() -> None:
commit_results.append(mgr.commit_create(pending))
def _delete() -> None:
delete_started.set()
delete_responses.append(
client.post(
f"/v1/api/workstreams/{ws_id}/delete",
headers=_auth("user-1"),
)
)
monkeypatch.setattr(mgr._event_emitter, "emit_created", _blocked_emit)
monkeypatch.setattr(storage, "delete_workstream_if_fork_reserved", _tracked_delete)
monkeypatch.setattr(mgr, "delete_persisted", _tracked_delete_persisted)
commit_thread = threading.Thread(target=_commit, daemon=True)
delete_thread = threading.Thread(target=_delete, daemon=True)
commit_thread.start()
assert emit_entered.wait(timeout=5), "commit never entered publication"
delete_thread.start()
assert delete_started.wait(timeout=5)
try:
assert delete_admission_entered.wait(timeout=5), "delete never reached manager admission"
assert delete_thread.is_alive(), "delete overtook admitted create publication"
assert not durable_delete_entered.is_set()
assert storage.get_workstream(ws_id) is not None
finally:
release_emit.set()
commit_thread.join(timeout=10)
delete_thread.join(timeout=10)
assert not commit_thread.is_alive()
assert not delete_thread.is_alive()
assert commit_results == [True]
assert len(delete_responses) == 1
assert delete_responses[0].status_code == 200, delete_responses[0].text
assert durable_delete_entered.is_set()
assert storage.get_workstream(ws_id) is None
assert mgr.get(ws_id) is None
lifecycle = [
event["type"]
for event in _drain_global_events(client)
if event.get("ws_id") == ws_id and event.get("type") in {"ws_created", "ws_closed"}
]
assert lifecycle == ["ws_created", "ws_closed"]
@pytest.mark.parametrize("terminal", ["close", "delete"])
def test_interactive_post_install_has_no_late_publication_after_terminal(
app_client: Any,
monkeypatch: pytest.MonkeyPatch,
terminal: str,
) -> None:
"""The post-commit tail cannot publish or install onto a retired object."""
from turnstone.core.audit import record_audit as original_record_audit
from turnstone.core.storage import get_storage
client, mgr = app_client
storage = get_storage()
assert storage is not None
source_id = "a" * 32
destination_id = "b" * 32
storage.register_workstream(
source_id,
node_id="node-test",
name="source",
user_id="user-1",
)
audit_entered = threading.Event()
release_audit = threading.Event()
watch_registrations: list[str] = []
def _record_audit(*args: Any, **kwargs: Any) -> Any:
action = args[2] if len(args) > 2 else kwargs.get("action")
if action == "workstream.created":
audit_entered.set()
assert release_audit.wait(timeout=10), "test did not release create audit"
return original_record_audit(*args, **kwargs)
def _set_watch_runner(session: _FakeSession, *_args: Any, **_kwargs: Any) -> None:
watch_registrations.append(session.ws_id)
monkeypatch.setattr("turnstone.core.audit.record_audit", _record_audit)
monkeypatch.setattr(_FakeSession, "set_watch_runner", _set_watch_runner)
client.app.state.watch_runner = object()
responses: list[Any] = []
request_errors: list[BaseException] = []
def _create() -> None:
try:
responses.append(
client.post(
"/v1/api/workstreams/new",
json={
"ws_id": destination_id,
"name": "named fork",
"resume_ws": source_id,
},
headers=_auth("user-1"),
)
)
except BaseException as exc: # pragma: no cover - diagnostic capture
request_errors.append(exc)
request_thread = threading.Thread(target=_create, daemon=True)
request_thread.start()
assert audit_entered.wait(timeout=5), "create never reached the post-commit audit"
destination = mgr.get(destination_id)
assert destination is not None
try:
if terminal == "close":
assert mgr.close(destination_id) is True
else:
assert storage.delete_workstream(destination_id) is True
assert mgr.delete(destination_id) is True
events_before_release = _drain_global_events(client)
lifecycle_before_release = [
event["type"]
for event in events_before_release
if event.get("type") in {"ws_created", "ws_rename", "ws_closed"}
]
assert lifecycle_before_release == ["ws_created", "ws_rename", "ws_closed"]
assert watch_registrations == [destination_id]
finally:
release_audit.set()
request_thread.join(timeout=10)
assert not request_thread.is_alive()
assert request_errors == []
assert len(responses) == 1
assert responses[0].status_code == 200, responses[0].text
assert mgr.get(destination_id) is None
events_after_release = _drain_global_events(client)
assert not [
event for event in events_after_release if event.get("type") in {"ws_created", "ws_rename"}
]
assert watch_registrations == [destination_id]
@pytest.mark.anyio
@pytest.mark.parametrize("anyio_backend", ["asyncio"])
async def test_cancellation_after_commit_waits_post_install_and_keeps_one_create(
anyio_backend: str,
) -> None:
"""Cancellation preserves the admitted create and drains its shielded tail."""
assert anyio_backend == "asyncio"
mgr, adapter, _ = _make_manager()
post_install_entered = asyncio.Event()
release_post_install = asyncio.Event()
post_install_completed = False
def _manager_lookup(_request: Any) -> tuple[Any, None]:
return mgr, None
def _build_kwargs(
_request: Any,
body: dict[str, Any],
uid: str,
_skill_data: dict[str, Any] | None,
_skill_id: str,
_skill_version: int,
) -> dict[str, Any]:
return {"user_id": uid or "u1", "name": str(body.get("name") or "")}
async def _post_install(
_request: Any,
_ws: Any,
_body: dict[str, Any],
_uid: str,
_skill_data: dict[str, Any] | None,
_skill_version: int,
_attachment_ids: list[str],
) -> dict[str, Any]:
nonlocal post_install_completed
post_install_entered.set()
await release_post_install.wait()
post_install_completed = True
return {}
cfg = SessionEndpointConfig(
permission_gate=None,
manager_lookup=_manager_lookup,
tenant_check=None,
not_found_label="Workstream not found",
audit_action_prefix="workstream",
create_build_kwargs=_build_kwargs,
create_post_install=_post_install,
)
app = Starlette(routes=[Route("/new", make_create_handler(cfg), methods=["POST"])])
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
request_task = asyncio.create_task(client.post("/new", json={"name": "cancelled"}))
await asyncio.wait_for(post_install_entered.wait(), timeout=5)
request_task.cancel()
await asyncio.sleep(0.05)
assert not request_task.done()
assert post_install_completed is False
release_post_install.set()
with pytest.raises(asyncio.CancelledError):
await request_task
assert post_install_completed is True
assert mgr.count == 1
created = adapter.events_of("created")
assert len(created) == 1
assert created[0].ws_id == mgr.list_all()[0].id
assert adapter.events_of("closed") == []