mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
480a1426b3
* fix(session): fail-closed history-commit handoff (#981) The deleted-workstream discovery is now a terminal, ws_id-keyed latch: keyed conversation commits refuse admission once the durable parent is gone (convergence finalizers and force-abandon are exempt), history handoff refuses to mint a proof token so /history fails closed with a 503 instead of silently wiping the pane, and the SSE stream carries a workstream_gone resync reason. Discarded commits leave a forensic log of commit keys and roles, never content. Conversation rows gain a commit_key (migration 071): keyed saves are idempotent under retry, validated against the full commit identity, and refused when they would cross a workstream deletion. The prune orphan category now requires a NULL alias plus a two-hour updated grace, with cutoffs computed at discovery time and carried into both dialects' rechecks. The mid-turn interjection queue is owner-partitioned with no per-site mode flags: pops take the acting principal's and unowned rows, other participants' rows are structurally retained, and enforcement lives at queue admission plus the shared before_spawn gates. The retraction ledger is bounded by open pop windows: pops open a window atomically with the queue delete, restores close their ids atomically with the ledger consume, every other exit closes through one helper, and misses for unheld ids record nothing. The workstream-gone latch refuses unattended wakes at all three gates (watcher spawn, claim, delivery pre-pop), and the retry dispatcher regained its pre-envelope cancel/error convergence net. Persistence-state reporting derives through the session bound to each UI instead of a registry lookup by id that failed open to healthy during tombstone retention. The dashboard roster no longer re-inserts ghost entries from trailing activity events, the history tool-outcome scan tolerates interleaved non-turn rows, and the shared handoff-deadline handle owns its own retirement. Single-sourced across call sites: keyed-commit row values, attachment save wrappers, tail-truncation and conflict-resolution bodies for both storage dialects; worker-slot lifecycle field sets; the direct-commit admission frame; queued-row layout accessors; the string-aware comment stripper shared by every JS harness suite. Refs #981 #964 * fix(session): sweep handoff fixes to their sibling surfaces The interactive replay loop treated a system row as a tool-batch boundary, so every tool result after an interleaved row vanished from that pane while the coordinator rendered the same history correctly. Only a conversational turn ends the batch window now, matching the shared outcome index. Accepted user turns clear the composer's attachment chips on the same viewer policy that settles optimistic bubbles rather than on having matched a local bubble, so a workstream created with an upload no longer keeps a chip for an attachment the create dispatch already consumed. The coordinator's raced-Stop arm emits the stream-end hook it inherits alongside the idle state, leaving no unfinalized bubble or unflushed tool output. Ending a session surfaces a failure toast when the request never lands or answers with a non-JSON body. The per-second persistence reconcile now probes each session without blocking: a workstream whose generation and handoff locks are held is skipped until the next pass instead of contending the locks every commit needs. The one-shot repair that gates workstream creation at capacity keeps a definite probe — it has no next pass, and the sessions likeliest to be contended are the ones whose unresolved journals emptied its candidate list. Single-sourced: the attachment lane builds its conversation row through the shared commit-identity builder; the ordinary worker exit releases its slot through the lifecycle owner; both operator surfaces snapshot their counters through one non-consuming helper; the replay preamble loses its per-kind wrappers and its config hook; the browser harness suites share one brace walker; and each in-flight history attempt is one record carrying both its abort controller and its deadline. Refs #981 #964
179 lines
7.2 KiB
Python
179 lines
7.2 KiB
Python
"""Tests for the per-node pending-upload buffer (turnstone.core.attachment_buffer)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
|
|
from turnstone.core.attachment_buffer import (
|
|
AttachmentBuffer,
|
|
StagedAttachment,
|
|
get_attachment_buffer,
|
|
)
|
|
|
|
|
|
def _stage(
|
|
buf: AttachmentBuffer,
|
|
*,
|
|
content: bytes = b"hi",
|
|
ws: str = "ws1",
|
|
user: str = "u1",
|
|
filename: str = "f.txt",
|
|
mime: str = "text/plain",
|
|
kind: str = "text",
|
|
) -> StagedAttachment:
|
|
return buf.stage(
|
|
ws_id=ws, user_id=user, filename=filename, mime_type=mime, kind=kind, content=content
|
|
)
|
|
|
|
|
|
def test_stage_returns_content_hash_id_and_size() -> None:
|
|
buf = AttachmentBuffer()
|
|
entry = _stage(buf, content=b"hello")
|
|
assert entry.attachment_id == hashlib.sha256(b"hello").hexdigest()
|
|
assert entry.size_bytes == 5
|
|
|
|
|
|
def test_stage_is_idempotent_for_identical_bytes() -> None:
|
|
buf = AttachmentBuffer()
|
|
a = _stage(buf, content=b"same")
|
|
b = _stage(buf, content=b"same")
|
|
assert a.attachment_id == b.attachment_id
|
|
assert len(buf.list_for(ws_id="ws1", user_id="u1")) == 1 # deduped by content hash
|
|
|
|
|
|
def test_get_enforces_scope() -> None:
|
|
buf = AttachmentBuffer()
|
|
entry = _stage(buf, ws="ws1", user="u1")
|
|
assert buf.get(entry.attachment_id, ws_id="ws1", user_id="u1") is not None
|
|
assert buf.get(entry.attachment_id, ws_id="ws2", user_id="u1") is None # wrong ws
|
|
assert buf.get(entry.attachment_id, ws_id="ws1", user_id="u2") is None # wrong user
|
|
|
|
|
|
def test_list_for_scopes_by_ws_and_user() -> None:
|
|
buf = AttachmentBuffer()
|
|
_stage(buf, content=b"a", ws="ws1", user="u1")
|
|
_stage(buf, content=b"b", ws="ws1", user="u1")
|
|
_stage(buf, content=b"c", ws="ws2", user="u1")
|
|
assert len(buf.list_for(ws_id="ws1", user_id="u1")) == 2
|
|
assert len(buf.list_for(ws_id="ws2", user_id="u1")) == 1
|
|
|
|
|
|
def test_discard_is_scope_checked() -> None:
|
|
buf = AttachmentBuffer()
|
|
entry = _stage(buf)
|
|
wrong_scope = buf.discard(entry.attachment_id, ws_id="ws2", user_id="u1")
|
|
assert wrong_scope is False
|
|
right_scope = buf.discard(entry.attachment_id, ws_id="ws1", user_id="u1")
|
|
assert right_scope is True
|
|
assert buf.get(entry.attachment_id, ws_id="ws1", user_id="u1") is None
|
|
|
|
|
|
def test_consume_all_consumes_present_subset_and_reports_missing() -> None:
|
|
"""Survivors are consumed exactly once even when a sibling is missing.
|
|
|
|
Deliberate pin update: the old all-or-nothing contract left every
|
|
surviving reference staged when one handle expired, letting the same
|
|
uploads be attached again after the turn that owned their bytes already
|
|
committed (the double-spend the atomic transfer exists to prevent).
|
|
"""
|
|
|
|
buf = AttachmentBuffer()
|
|
first = _stage(buf, content=b"first")
|
|
second = _stage(buf, content=b"second")
|
|
_stage(buf, content=b"first", ws="other", user="u1")
|
|
missing = hashlib.sha256(b"missing").hexdigest()
|
|
|
|
consumed = buf.consume_all(
|
|
[first.attachment_id, missing, second.attachment_id],
|
|
ws_id="ws1",
|
|
user_id="u1",
|
|
)
|
|
assert consumed == {first.attachment_id, second.attachment_id}
|
|
assert buf.get(first.attachment_id, ws_id="ws1", user_id="u1") is None
|
|
assert buf.get(second.attachment_id, ws_id="ws1", user_id="u1") is None
|
|
# Scope isolation: another workstream's staging of the same bytes survives.
|
|
assert buf.get(first.attachment_id, ws_id="other", user_id="u1") is not None
|
|
|
|
# A second consume finds nothing — the ownership reference is one-shot,
|
|
# and duplicate handles in one call consume it only once.
|
|
assert (
|
|
buf.consume_all(
|
|
[first.attachment_id, first.attachment_id, second.attachment_id],
|
|
ws_id="ws1",
|
|
user_id="u1",
|
|
)
|
|
== frozenset()
|
|
)
|
|
assert buf.consume_all([], ws_id="ws1", user_id="u1") == frozenset()
|
|
|
|
|
|
def test_ttl_eviction_on_access() -> None:
|
|
clock = [0.0]
|
|
buf = AttachmentBuffer(ttl_seconds=10.0, clock=lambda: clock[0])
|
|
_stage(buf, content=b"x")
|
|
clock[0] = 11.0 # past the TTL
|
|
assert buf.list_for(ws_id="ws1", user_id="u1") == []
|
|
|
|
|
|
def test_size_cap_evicts_oldest_first() -> None:
|
|
clock = [0.0]
|
|
buf = AttachmentBuffer(max_total_bytes=10, clock=lambda: clock[0])
|
|
clock[0] = 1.0
|
|
a = _stage(buf, content=b"aaaaa") # 5 bytes
|
|
clock[0] = 2.0
|
|
b = _stage(buf, content=b"bbbbb") # +5 → 10, at the ceiling
|
|
clock[0] = 3.0
|
|
c = _stage(buf, content=b"ccccc") # +5 → 15 > 10 → evict oldest (a)
|
|
ids = {e.attachment_id for e in buf.list_for(ws_id="ws1", user_id="u1")}
|
|
assert a.attachment_id not in ids
|
|
assert {b.attachment_id, c.attachment_id} <= ids
|
|
|
|
|
|
def test_cross_scope_identical_bytes_resolve_independently() -> None:
|
|
"""Identical bytes staged from two scopes dedupe to one blob but keep
|
|
independent references — so neither scope's send drops the other's upload
|
|
(the bug: a hash-only key let the second stage overwrite + rescope the
|
|
first, and resolve has no committed-store fallback)."""
|
|
buf = AttachmentBuffer()
|
|
a = _stage(buf, content=b"shared", ws="wsA", user="u1", filename="a.txt")
|
|
b = _stage(buf, content=b"shared", ws="wsB", user="u1", filename="b.txt")
|
|
assert a.attachment_id == b.attachment_id # same content hash → one blob
|
|
# Both scopes resolve their own staged upload — neither was overwritten —
|
|
# and each keeps its own per-scope metadata (filename).
|
|
ra = buf.get(a.attachment_id, ws_id="wsA", user_id="u1")
|
|
rb = buf.get(b.attachment_id, ws_id="wsB", user_id="u1")
|
|
assert ra is not None and ra.content == b"shared" and ra.filename == "a.txt"
|
|
assert rb is not None and rb.content == b"shared" and rb.filename == "b.txt"
|
|
|
|
|
|
def test_discard_one_scope_keeps_other_and_evicts_on_last() -> None:
|
|
"""Discarding one scope's reference (a committing send draining its own
|
|
upload) leaves another scope's pending upload of the same bytes intact; the
|
|
shared blob is evicted only when the last reference goes."""
|
|
buf = AttachmentBuffer()
|
|
h = _stage(buf, content=b"dup", ws="wsA", user="u1").attachment_id
|
|
_stage(buf, content=b"dup", ws="wsB", user="u1")
|
|
discarded_a = buf.discard(h, ws_id="wsA", user_id="u1")
|
|
assert discarded_a is True
|
|
assert buf.get(h, ws_id="wsA", user_id="u1") is None # wsA's ref gone
|
|
assert buf.get(h, ws_id="wsB", user_id="u1") is not None # wsB's survives
|
|
discarded_b = buf.discard(h, ws_id="wsB", user_id="u1")
|
|
assert discarded_b is True
|
|
assert buf.get(h, ws_id="wsB", user_id="u1") is None # last ref → blob evicted
|
|
|
|
|
|
def test_size_cap_counts_deduped_bytes_once() -> None:
|
|
"""The size ceiling bounds bytes actually resident: identical bytes staged
|
|
from many scopes count once (not once-per-scope as re-keying would), so
|
|
dedup-heavy staging isn't falsely evicted."""
|
|
buf = AttachmentBuffer(max_total_bytes=8) # fits exactly one 8-byte blob
|
|
for ws in ("wsA", "wsB", "wsC"):
|
|
_stage(buf, content=b"eightyte", ws=ws, user="u1") # 8 bytes, same blob
|
|
handle = hashlib.sha256(b"eightyte").hexdigest()
|
|
for ws in ("wsA", "wsB", "wsC"):
|
|
assert buf.get(handle, ws_id=ws, user_id="u1") is not None
|
|
|
|
|
|
def test_singleton_getter_is_stable() -> None:
|
|
assert get_attachment_buffer() is get_attachment_buffer()
|