mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-24 21:04:48 -06:00
e60c19befd5e31376bb606cd380c3564ac4e27df
13 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e60c19befd |
fix(watch): harden nudge/wake delivery across eviction, cancel, and identity rebinds
Wake path: - Denial metacog nudge moves to the tool channel so it drains with the denied tool batch instead of the next user-message seam. - wake_workstream_if_pending: shared wake gate for watch fires on already-idle workstreams (no IDLE transition for the watcher to observe), wired as wake_fn at every set_watch_runner site via the shared _watch_fire_wake_fn helper (closes over the Workstream OBJECT — after eviction+restore an id-keyed manager lookup would miss). - session_worker exit backstop re-runs the wake gate the moment worker ownership clears: IDLE fans out on the worker thread, so transition-time wakes always landed on the reuse path and no-op'd (the coordinator idle_children strand). - deliver_wake_nudge_from_queue contains GenerationCancelled — it is the wake worker's run() closure and only Exception is caught downstream. Watch delivery: - Terminal fires that cannot reach their workstream are HELD and redelivered on min(interval, 60s) without re-running the command, bounded by MAX_DELIVERY_ATTEMPTS per cycle and the watch's own max_polls across cycles; the poll charge commits durably at hold time so restarts stay budget-bounded. - Restore admission control: per-ws dedup + MAX_CONCURRENT_RESTORES cap, presence-only re-check under the lock, detection-only stall alerts (reclaiming a wedged admission would trade capped degradation for total poll-pool collapse). - Permanent-vs-transient restore taxonomy: corrupt persona stamp and genuinely-missing history (confirmed by a raising storage probe — the resume loader swallows read blips into []) deactivate the watch immediately; everything else holds and retries. - Cancel-race defense: delivery paths re-check is_watch_active before stashing/dispatching, cancel paths write the row BEFORE forget_terminal_dispatched, the HTTP cancel endpoint clears runner state, and a per-tick sweep bounds the residual stash-after-clear interleaving to one check_interval. - Abandon/exhaustion commits are write-then-clear so storage that can read but not write retries the row write instead of re-running the command every cycle; the fresh-fire unrestorable path stashes before its deactivation write for the same reason. Registry follows identity: - The dispatch registry is keyed by _ws_id at registration time; every rebind now moves it: non-fork resume() and /new go through _follow_watch_registration (new key live before the old is removed, never stealing a registration another live session holds), removals are owner-checked so tearing down a watch-restore shell or a resumed-away session cannot unregister a live pane, the restore shell yields to a registration that appears mid-restore, CLI --resume registers after the successful resume, and both the open path and the detail-GET lazy rehydrate wire the registration. Teardown gating and backpressure honesty: - cleanup_session_ui marks ws._closed FIRST under ws._lock — every teardown path (close, close_idle, evict, delete, discard) funnels through it — and session_worker.send re-checks under the same lock, so a wake can never spawn a worker on a torn-down workstream. - Create responses carry initial_message_status when the initial message could not be delivered (queue_full / refused_closed) instead of reading as success; staged attachments survive for the retry; /send surfaces a closed workstream as 404 rather than queue_full. Docs/spec: OpenAPI artifacts regenerated; api-reference documents the new create-response field; TS SDK type extended. Tests: ~30 new pins (cancel races, budget durability across restarts, owner-checked registry moves, teardown gating, stall alerts, backpressure surfaces, wait_until final re-check); wide subsystem sweep green (2353 passed). |
||
|
|
a318265946 |
fix(fence): bracket trust-fence markers instead of angle-bracket XML
Swap the trust-fence marker shape from <tag_nonce>...</tag_nonce> to [start tag_nonce]...[end tag_nonce] for both the operator fold (system-reminder) and the output-guard judge (tool_output). Angle-bracket markup pushed some local models out of distribution and toward emitting their own turn-structure tokens: chat templates built around rigid <...>-style structural tokens derail once a few folded reminders accumulate. The start/end keywords carry no slash (no </ or [/ closing-tag shape) and read as ordinary text. Single-source the shape in fence.py (_OPEN_KW/_CLOSE_KW + detection_pattern) so wrap, neutralize, the forgery/leak detector, and both trust declarations track one definition. The nonce still rides both boundaries (unforgeable close); the leak-vs-forgery split and the forge-in / break-out defang are preserved. The fold is wire-only, so there is no migration; the legacy persisted-envelope readers keep the old shape. Add regression tests pinning each trust declaration to fence.wrap's emission so a future keyword change fails loudly instead of silently desyncing the anchors. |
||
|
|
f3c96e6493 |
feat(skills): make skill hints first-class system turns; drop escape_wrapper_tags
_skill_hint spliced its guidance into the tool result as a bare <system-reminder>
block — but the operator declaration now tells the model to treat bare markers
as untrusted, silently demoting the hint. Make the hint first-class instead:
- _skill_hint returns the tool result verbatim and queues the guidance via
_queue_tool_advisory("skill_hint", ...); _collect_advisories drains it into a
{role:system, _source:"skill_hint"} turn after the clean result — folded in
the trusted nonce fence for non-native models, inline for native. (Queuing
no-ops mid-wake, like the other tool-channel advisories.)
- skill_hint added to SYSTEM_TURN_SOURCES (an advisory-producer source).
- escape_wrapper_tags removed outright: it was the last consumer, and its job
(defang a marker next to the bare block) is now covered at fold time by
_neutralize_host. The result message rides through verbatim. This also
collapses the two-escaping-mechanism confusion the review flagged.
Tests assert the clean result + the queued/drained hint, plus wake suppression.
|
||
|
|
98d4be8ffe |
fix(watch): deliver terminal fires instead of dropping them silently
WatchRunner._poll_watch committed active=False to the row BEFORE calling _dispatch_result for a terminal fire, and the dispatch closure registered by ChatSession.set_watch_runner enqueued each reminder with a valid_until=is_watch_active predicate that re-read the row at drain time. Since the runner already flipped active to 0, the predicate returned False for every dispatched fire and NudgeQueue.drain silently dropped the entry — the model never saw a watch result. Then a subsequent action=cancel call hit list_watches_for_ws (filters active==1), the now-inactive row was invisible, and the cancel returned 'Watch "X" not found.' regardless of whether the watch had actually run. Reorder _poll_watch to dispatch before the row write, drop the valid_until predicate from the watch closure (its only effect was the bug above), and add a _terminal_dispatched guard on the runner so a transient storage failure between dispatch and row-write doesn't re-fire the reminder on the next tick. Add WatchRunner.forget_terminal_dispatched and call it from the cancel path so an out-of-band deactivate (next_poll='') doesn't leak the watch_id from the runner's pending-retry set indefinitely. Cancel-by-name now routes through a new find_watch_by_name storage method that ignores the active filter and prefers active rows over newer-inactive same-name siblings. The session.py cancel branch distinguishes 'already completed (auto-cancelled)' from 'not found' so the model can tell apart 'this watch ran and finished' from 'no such watch.' Consolidate the two byte-identical _escape_like / _escape_ilike helpers in the storage backends into a single turnstone.core.storage._utils.escape_like and apply it to the new find_watch_by_name LIKE pattern so a model-supplied watch name containing % or _ can't redirect a cancel to a sibling watch. NudgeQueue.drain previously dropped predicate-failed entries without logging anything, which is what hid this bug for so long. Drain now emits nudge_queue.predicate_dropped: info for reason=predicate_false (the normal lifecycle case — idle_children when every active child finished between enqueue and drain), warning with exc_info for reason=predicate_raised (a misbehaving predicate). Tests: new test_poll_watch_terminal_fire_survives_drain (parametrized stop_on_fired + max_polls_reached) drives the real WatchRunner._poll_watch against a real tmp_db row and confirmed to fail against pristine main. test_poll_watch_retry_deactivate_after_update_watch_failure exercises the _terminal_dispatched retry-deactivate branch end to end. test_cancel_clears_pending_terminal_dispatched_entry covers the cancel- path leak case. test_find_by_name_prefers_active_over_newer_inactive catches the ordering regression. test_find_by_name_treats_percent_as_literal + test_find_by_name_treats_underscore_as_literal pin the LIKE escape. |
||
|
|
7e35050b68 |
fix(metacog): cleanup batch — share watch-key constant, sanitize metadata, drop tombstones
Closes round-1 review findings q-2 (minor), q-5 (minor), q-6 (nit), q-7
(nit), sec-1 (nit), perf-4 (nit).
* **q-5:** Export ``_WATCH_REMINDER_OPTIONAL_KEYS`` from
``turnstone/core/watch.py`` and import in the dispatch closure
(session.py) and the replay filter (server.py:_build_history). The
three-place duplication of the literal tuple
``("watch_name", "command", "poll_count", "max_polls", "is_final")``
is gone; future field adds touch one constant.
* **sec-1:** Run ``sanitize_payload`` over string-typed metadata fields
(``watch_name`` / ``command``) before they enter the queue. Today's
consumers all use ``textContent``, but the asymmetry — sanitised
``text`` alongside unsanitised metadata — would survive forever in
DB rows and resurface if a future consumer used a non-textContent
sink (aria-label, copy-to-clipboard, markdown render).
* **q-7:** Drop the per-iteration ``isinstance(reminder, dict)`` from
the dispatch closure's metadata comprehension. By the time the
block runs, ``text = reminder.get("text", "") if isinstance(...)``
+ the ``if not sanitized: return`` guard above already established
``reminder`` is a non-empty dict.
* **q-2:** Strip tombstone-style references — "post-#482", "post-#484",
"Step 7 of the watch-card UX plan", "Post-Step-7 dispatch surface",
and the brittle line-anchor "session.py:2685-2686" — across
``session.py``, ``test_session.py``, ``test_watch.py``,
``test_watch_dispatch.py``, ``test_watch_integration.py``. Comment
intent preserved; historical anchors gone.
* **q-6:** Drop the ``del source`` line in ``cli.py``'s
``on_user_reminder``; the parallel ``on_tool_reminder`` ignores
``tool_call_id`` without ``del`` and the comment alone is enough.
* **perf-4:** Document the SQLite ``render_as_batch=True`` recreate
cost in migration 050's docstring — first deployment after upgrade
copies the conversations table twice (one per ``add_column``).
PostgreSQL is unaffected.
5734 non-live tests pass; ruff + mypy clean.
|
||
|
|
13db19905a |
feat(metacog): structured watch reminders carry watch metadata onto NudgeQueue
WatchRunner._dispatch_result now takes a structured reminder dict
produced by build_watch_reminder() — text matches format_watch_message
verbatim (so compaction / channel adapters / wire splice keep their
behaviour), and watch_name / command / poll_count / max_polls /
is_final ride alongside as queue-entry metadata.
The dispatch closure registered in ChatSession.set_watch_runner pulls
the optional fields out of the dict and passes them to enqueue via
the new metadata kwarg. Drain seams already merge metadata into the
rendered reminder dict (Commit 2), so the SSE event for a watch fire
now carries the structured fields without further plumbing.
* turnstone/core/watch.py — new build_watch_reminder() helper, _poll_watch
switches from format_watch_message + dispatch(str) to build_watch_reminder
+ dispatch(dict). set_dispatch_fn / get_dispatch_fn / restore_fn
signatures widen from Callable[[str, str], None] to
Callable[[dict[str, Any], str], None].
* turnstone/core/session.py — dispatch closure builds the metadata dict
via {k: reminder[k] for k in ("watch_name", "command", ...) if k in reminder}
and passes it to nudge_queue.enqueue.
* tests/test_watch.py — new TestBuildWatchReminder class pinning the
builder shape; existing dispatch_fn_registry / restore_fn tests
updated to dict shape.
* tests/test_watch_dispatch.py — every dispatch(...) call updated to
pass a structured reminder dict via _reminder() helper; new
TestMetadataPropagation class pins the metadata-on-enqueue contract.
* tests/test_watch_integration.py — _dispatch_result calls updated to
dict shape.
Plan reference: docs/design/watch-card-ux.md §4 Step 7 + Step 8 watch-test
subset (Commit 3).
|
||
|
|
751ed9c85f |
test(metacog): drop redundant valid_until test + tighten concurrency bound + cover is_watch_active
Closes round-2 review findings q-1, q-2, q-6. * **q-1:** ``test_valid_until_drops_when_watch_missing`` collapsed to the same code path as ``test_valid_until_drops_when_watch_inactive`` after the apply-pass switched the predicate from ``get_watch[active]`` to ``is_watch_active`` (both stubbed via ``patch_session_storage(active=False)``). The "missing" case has no distinguishable branch at the dispatch layer, so dropping it removes a tautological duplicate. The missing-row mapping moves to the storage layer (q-2 below) where it IS distinguishable. * **q-2:** ``is_watch_active`` was a new public storage primitive with zero direct backend coverage — only via-session-via-stub coverage. New ``TestIsWatchActive`` in ``tests/test_watch_storage.py`` covers active row → True, inactive row → False, missing row → False. Pinned at the storage boundary so future backend changes fail loudly there instead of in the dispatch tests. * **q-6:** Concurrency test had ``n_threads = 2`` alongside two literal Thread objects and a tautological ``assert len(threads) == n_threads``. Threads are now built from a labels tuple, so ``len(threads)`` drives the slack bound; the redundant assertion is gone. |
||
|
|
20c4dfaca6 |
fix(metacog): tighten concurrency bound + lift storage-patch helper
Closes review findings bug-4 and q-6. bug-4 — the watch dispatch concurrency test bounded depth at ``_WATCH_QUEUE_SOFT_CAP + 2 * per_thread`` (= 250) which is tautologically true: two threads × 100 fires can append at most 200 entries above the cap, so the bound asserted nothing more than what ``depth <= 2 * per_thread`` already says. Tighten to ``_WATCH_QUEUE_SOFT_CAP + N_THREADS`` (= 52): the count-then-drop window admits at most one slip per concurrent thread. q-6 — 7 near-duplicate ``monkeypatch.setattr(session_mod, "get_storage", lambda: _StubStorage())`` sites across ``test_watch_dispatch.py`` + ``test_watch_integration.py`` (4 different stub shapes, mostly trivial variations on the active flag). Lift a ``patch_session_storage`` helper into the existing ``tests/_helpers.py`` with kwargs for the common cases (``active``, ``raise_on_is_active``), returns the call list so call-shape assertions still work. Tests collapse from ~10-line inline-class blocks to one-line helper calls. |
||
|
|
3b495eba15 |
fix(metacog): is_watch_active storage primitive for hot-path valid_until
Closes review finding perf-1. The watch dispatch closure's ``valid_until`` predicate fires once per watch entry at every drain seam — on the chat-loop hot path. It only needs the ``active`` flag, but ``storage.get_watch`` runs a full-row ``SELECT *`` and marshals the result into a dict. At the typical drain depth (cap-50 + a busy chat loop) that's ~50 throwaway dict allocations per drain pass for one boolean. Adds ``StorageProtocol.is_watch_active(watch_id) -> bool`` plus SQLite + Postgres implementations doing a single-column ``SELECT active FROM watches WHERE watch_id = ?`` (returns False on missing row). ``_still_active`` in ``ChatSession.set_watch_runner`` now calls that instead of indexing into the full row. Test stubs that mocked ``get_watch`` for the predicate are converted to mock ``is_watch_active`` directly. Bulk variant deferred — single-row fix is sufficient at typical drain depths. |
||
|
|
7ca00b564c |
test(metacog): NudgeQueue-based dispatch tests for watch closure
Replaces the deleted tests/test_watch_dispatch.py with a focused
14-test suite exercising the closure that ChatSession.set_watch_runner
now constructs (per the previous commit's switchover). Each test
pins one assertion:
- enqueue shape: ("watch_triggered", text, "any") on the per-session
NudgeQueue; not on user / tool channels
- producer-side sanitisation strips control / bidi / zero-width chars
and angle-bracket tag breakers; preserves TAB/LF/CR so multi-line
shell output keeps its layout (R8); empty-after-strip → no enqueue
- soft-cap drop-oldest at _WATCH_QUEUE_SOFT_CAP with a queue_full
WARNING log; non-watch entries on the same queue are not collateral
damage
- valid_until predicate drops on inactive / missing / storage-raises;
delivers when active (counter-test)
- concurrent enqueues across two threads stay bounded under the
3-acquisition count-then-drop window
Implements watch-switchover plan section 5.1 / step 9. No production
changes — pure test rewrite.
|
||
|
|
94ed79d488 |
feat(metacog): switchover — watches enqueue onto NudgeQueue not _watch_pending
Replaces the bespoke _make_watch_dispatch / _watch_pending /
_dispatch_pending_watch / _MAX_WATCH_CHAIN machinery with a single
NudgeQueue.enqueue("watch_triggered", ...) call inside
ChatSession.set_watch_runner. Watch results now drain at the same
<system-reminder> envelope seams as every other metacog nudge
(USER_DRAIN, TOOL_DRAIN, IdleNudgeWatcher IDLE wake) — no separate
worker-spawn, no recursive watch chain, no per-session queue.Queue.
The dispatch closure built inside set_watch_runner carries:
- producer-side sanitize_payload over the whole formatted message
before enqueue, so steering-vector / control-char shell output
can't tamper with the envelope at interpolation time
- a soft cap of 50 entries on per-session "watch_triggered" depth
via the new NudgeQueue.drop_oldest_by_type, replacing the prior
_watch_pending maxsize=20 + _MAX_WATCH_CHAIN=5 bounds; drop policy
is drop-oldest (latest output most useful), logged at WARNING
- a valid_until predicate that re-checks
storage.get_watch(watch_id)["active"] at drain time so a cancelled
watch's last splat doesn't ride out a future wake
Behavioural delta documented in the plan section 3.4: N back-to-back
watch fires now drain into ONE assistant turn responding to all N
(via the envelope splice) instead of N separate send turns. This is
intentional — fewer model invocations for noisy watches, and uniform
with the rest of the metacog pull-model surface introduced by #482.
Implements watch-switchover plan steps 5-8. Server-side simplifications
let the previously-load-bearing _make_watch_dispatch (47 lines), its
session_worker.send import, and the chat-loop _dispatch_pending_watch
seam at the no-tools IDLE branch all disappear. The obsolete
tests/test_watch_dispatch.py and the wake-tag test in test_session.py
(both pinning contracts that no longer exist) are removed; the
NudgeQueue-based replacement plus an integration test land in the
following commit.
|
||
|
|
4e791cfb15 |
refactor(server): swap interactive workers to session_worker.send
Five spawn sites in turnstone/server.py now share the worker dispatch: * ``send_message`` (``POST /v1/api/send``) — the main path. Now uses ``session_worker.send`` with separate ``_enqueue`` / ``_run`` closures; the queue-vs-spawn outcome is conveyed via a captured ``queue_outcome`` dict so the existing response shapes (``status: queued`` vs ``status: ok``) survive. * ``_make_watch_dispatch`` — watch results dispatch. * ``run_retry`` (post-rewind) and ``_run_initial`` (initial-message on workstream creation) — set ``_worker_running`` directly under ws._lock instead of going through session_worker (their structural shape doesn't fit a queue-vs-spawn decision) but stay consistent with the shared gate so they can't race with /send into parallel workers. * ``cancel_generation``'s ``was_running`` snapshot now reads ``_worker_running`` for parity with the dispatcher. Pre-dispatch cancel-await also gates on ``_worker_running`` for consistency. The ``busy_error`` / ``status: busy`` legacy branch (reached only when worker is alive but ws.session is None) is gone — the new path checks ws.session up front and returns the same 500 shape. Test fixtures in test_server_attachments_endpoints.py and test_watch_dispatch.py updated to set ws._worker_running explicitly (MagicMock auto-truthifies the field, which would otherwise mis-route all idle paths into queue mode). |
||
|
|
3f432b8a42 |
fix: watch dispatch error handler missing stream_end and state cleanup (#208)
* fix: watch dispatch error handler missing stream_end and state cleanup The watch dispatch run() closure was missing GenerationCancelled handling, stream_end emission, on_state_change calls, and the worker_thread identity guard that the send_message path has. This left the web UI in a stale state when watch-dispatched sends failed. * fix: address review feedback - on_stream_end, put_nowait, ws._lock, tests * fix: ruff lint (unused pytest import) * fix: send_message() use on_stream_end() instead of raw _enqueue |