mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
7a06f5e8bc
* 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.
167 lines
4.4 KiB
Plaintext
167 lines
4.4 KiB
Plaintext
@startuml
|
|
!theme plain
|
|
title Turnstone — Watch Tool Architecture
|
|
|
|
skinparam participant {
|
|
BackgroundColor<<server>> #FFE0B2
|
|
BackgroundColor<<storage>> #B3E5FC
|
|
BackgroundColor<<session>> #C8E6C9
|
|
BackgroundColor<<ui>> #E8EAF6
|
|
}
|
|
|
|
participant "ChatSession\n(session.py)" as Session <<session>>
|
|
participant "WatchRunner\n(watch.py)" as Runner <<server>>
|
|
participant "StorageBackend\n(SQLite / PostgreSQL)" as Storage <<storage>>
|
|
participant "WebUI / SSE\n(server.py)" as UI <<ui>>
|
|
|
|
== Create Phase ==
|
|
|
|
Session -> Session : _prepare_watch(action="create")
|
|
note right
|
|
Validates:
|
|
- command via is_command_blocked()
|
|
- poll_every → parse_duration()
|
|
- stop_on → validate_condition()
|
|
- max watches limit (5)
|
|
- duplicate name check
|
|
needs_approval = True
|
|
end note
|
|
|
|
Session -> Storage : create_watch(watch_id, ws_id,\nnode_id, command, interval,\nstop_on, max_polls, next_poll)
|
|
|
|
Session --> UI : tool_result:\n"Watch 'pr-review' created"
|
|
|
|
== Poll Phase (WatchRunner daemon, every 15s) ==
|
|
|
|
Runner -> Storage : list_due_watches(now)
|
|
Storage --> Runner : due_watches[]
|
|
note right
|
|
Filters:
|
|
active=1 AND
|
|
next_poll <= now AND
|
|
node_id matches
|
|
end note
|
|
|
|
loop for each due watch
|
|
|
|
Runner -> Runner : is_command_blocked()?
|
|
alt blocked
|
|
Runner -> Storage : update_watch(active=False)
|
|
else safe
|
|
|
|
Runner -> Runner : subprocess.run(command)
|
|
note right
|
|
timeout = tool_timeout
|
|
start_new_session = True
|
|
output truncated at 64KB
|
|
end note
|
|
|
|
Runner -> Runner : evaluate_condition(\nstop_on, output,\nexit_code, prev_output)
|
|
note right
|
|
**Variables:**
|
|
output, data, exit_code,
|
|
prev_output, changed
|
|
|
|
**Safe builtins only:**
|
|
len, str, int, sorted, ...
|
|
No import/open/exec/eval
|
|
|
|
**stop_on=None:**
|
|
fires on change (skip 1st poll)
|
|
end note
|
|
|
|
alt condition fired OR max_polls reached
|
|
Runner -> Storage : update_watch(\npoll_count++,\nlast_output, active=False)
|
|
Runner -> Runner : format_watch_message()
|
|
Runner -> Runner : _dispatch_result(ws_id, msg)
|
|
else not fired
|
|
Runner -> Storage : update_watch(\npoll_count++,\nlast_output, next_poll)
|
|
end
|
|
|
|
end
|
|
end
|
|
|
|
== Dispatch Phase ==
|
|
|
|
note over Runner, Session
|
|
**Three dispatch paths:**
|
|
end note
|
|
|
|
alt Path A: workstream active + idle
|
|
Runner -> Session : dispatch_fn(message)\n→ _watch_pending.put()
|
|
Session -> Session : _dispatch_pending_watch()\n→ self.send(message)
|
|
Session -> UI : SSE: thinking, content,\ntool calls...
|
|
note right
|
|
Watch result appears as
|
|
synthetic user message.
|
|
Model sees it and responds.
|
|
Depth guard: max 5 chains.
|
|
end note
|
|
|
|
else Path B: workstream active + busy
|
|
Runner -> Session : dispatch_fn(message)\n→ _watch_pending.put()
|
|
note right
|
|
Queued. Dispatched when
|
|
current send() reaches IDLE.
|
|
end note
|
|
|
|
else Path C: workstream evicted
|
|
Runner -> Runner : restore_fn(ws_id)
|
|
note right
|
|
1. mgr.create() — may evict
|
|
another idle workstream
|
|
2. session.resume(ws_id)
|
|
3. set_watch_runner()
|
|
4. register new dispatch_fn
|
|
end note
|
|
Runner -> Session : restored dispatch_fn(message)
|
|
end
|
|
|
|
== Cancel / List ==
|
|
|
|
Session -> Storage : list_watches_for_ws(ws_id)
|
|
note right : action="list" (auto-approve)
|
|
|
|
Session -> Storage : update_watch(active=False)
|
|
note right : action="cancel" (auto-approve)
|
|
|
|
== Server Lifecycle ==
|
|
|
|
note over Runner, Storage
|
|
**Startup:**
|
|
1. WatchRunner created in main() with storage + node_id
|
|
2. restore_fn closure captures SessionManager
|
|
3. Initial workstream: session.set_watch_runner(runner)
|
|
4. _lifespan(): runner.start() — daemon thread begins
|
|
|
|
**New workstream:**
|
|
session.set_watch_runner(runner) in create_workstream()
|
|
→ registers dispatch_fn for ws_id
|
|
|
|
**Eviction / close:**
|
|
session.close() → runner.remove_dispatch_fn(ws_id)
|
|
Watches remain active in DB — WatchRunner uses restore_fn
|
|
|
|
**Restart recovery:**
|
|
Overdue watches fire ONE immediate poll
|
|
next_poll updated to now + interval
|
|
Normal cadence resumes
|
|
|
|
**Shutdown:**
|
|
_lifespan(): runner.stop() — joins thread
|
|
end note
|
|
|
|
== REST API ==
|
|
|
|
note over UI, Storage
|
|
**GET /v1/api/watches[?ws_id=X]**
|
|
List active watches (for node or workstream)
|
|
|
|
**POST /v1/api/watches/{watch_id}/cancel**
|
|
Cancel a watch (sets active=False)
|
|
|
|
Both require write scope
|
|
end note
|
|
|
|
@enduml
|