CHANGELOG [Unreleased] / Removed (BREAKING — 1.5.0) block calling out the legacy URL family removal with the swap table. Doc passes on api-reference.md (per-endpoint sections rewritten with path parameters and slimmer body shapes), architecture.md (handler-list diagram and console-proxy URL example), console.md (URL-rewriting JS shim docstring + SSE proxy example), and the two PlantUML diagrams (11-console-data-flow, 16-channel-architecture). Also picks up two test-side stragglers from step 5 that referenced the legacy adapters in a docstring + a stale /v1/api/events SSE test: turn into path-keyed equivalents. OpenAPI JSON dump regenerated to reflect the catalog edits from step 3. After this commit: - 4557 tests passing under -m "not live" - ruff + mypy clean on turnstone/ tests/ sdk/ - grep for "/v1/api/send", "/v1/api/approve", "/v1/api/cancel", "/v1/api/workstreams/close" returns zero hits across turnstone/ sdk/ docs/ tests/ (excluding CHANGELOG.md, which intentionally documents the old shape). - grep for make_legacy_body_keyed_adapter, make_legacy_query_keyed_adapter, _make_method_dispatch, close_legacy returns zero hits.
66 KiB
Changelog
All notable changes to turnstone are documented here.
The format is based on Keep a Changelog,
and this project adheres to PEP 440 for
version numbers (X.Y.Z, with X.Y.ZaN / bN / rcN for pre-releases).
Three release tracks are maintained:
stable/1.0— patch-only (v1.0.x)stable/1.3— patch-only (v1.3.x)stable/1.4— patch-only (v1.4.x)main— experimental (v1.5.0aN)
[Unreleased]
Removed (BREAKING — 1.5.0)
-
Legacy body-keyed and query-keyed URL family for the workstream interaction verbs. Pre-1.5 interactive shipped both a path-keyed and a body-keyed surface for the same five verbs; this release drops the body-keyed and query-keyed mounts (and the
make_legacy_body_keyed_adapter/make_legacy_query_keyed_adaptershims that backed them). External SDK consumers on stable 1.0/1.3/1.4 must move to the path-keyed shape:Removed (1.0/1.3/1.4) Use instead GET /v1/api/events?ws_id=XGET /v1/api/workstreams/{ws_id}/eventsPOST /v1/api/send(bodyws_id)POST /v1/api/workstreams/{ws_id}/sendDELETE /v1/api/send(bodyws_id)DELETE /v1/api/workstreams/{ws_id}/sendPOST /v1/api/approve(bodyws_id)POST /v1/api/workstreams/{ws_id}/approvePOST /v1/api/cancel(bodyws_id)POST /v1/api/workstreams/{ws_id}/cancelPOST /v1/api/workstreams/close(body)POST /v1/api/workstreams/{ws_id}/closeCalls to the old URLs return 404 on 1.5.0+. Bodies on the new URLs no longer carry
ws_id(the path provides it); theSendRequest/ApproveRequest/CancelRequestPydantic schemas drop the field, andCloseWorkstreamRequestis removed outright (its only field wasws_id)./v1/api/planand/v1/api/commandare unaffected and remain body-keyed in this release. The bundled web UI, channel adapters, Python SDK, TypeScript SDK, and console routing-proxy SDK ship the new URLs automatically; pinning to ≥ 1.5.0 is enough.The console routing proxy's
/v1/api/route/...family is updated alongside:/v1/api/route/workstreams/{ws_id}/<verb>replaces the pre-1.5/v1/api/route/{send,approve,cancel,workstreams/close}mounts.DELETEis now passed through (client.request(method, ...)instead ofclient.post(...)) so the new dequeue route works through the proxy. Audit attribution forDELETEon/sendis logged asroute.workstream.dequeuerather thanroute.workstream.send.Auth scope wiring (
WRITE_PATHS/APPROVE_PATHSliterals plus the path-keyed verb match inrequired_scope) updated to grantwritefor path-keyedsend/cancel/close,approvefor path-keyedapprove, andwriteforDELETEon path-keyed/send. The/node/*proxy branch mirrors all four.
Changed
-
Dashboard row shape:
id→ws_id. TheGET /v1/api/dashboardrow dict now keys the workstream identifier asws_id(matching the rest of the v1 workstream surface — active list, saved list, history, detail). The Stage 2 list-verb lift converged/v1/api/workstreamsand/v1/api/workstreams/savedonws_idbut left dashboard alone to keep that PR's diff focused; this lands the same rename on the remaining endpoint so the v1 row shape is consistent across the family. PydanticDashboardWorkstreamand the TypeScript SDKDashboardWorkstreaminterface both rename the field accordingly. The bundled web UI is the only consumer that readsdashboard.workstreams[].idand is updated atomically; no external SDK on a stable line reads the field, so the swap is bounded by normal static-asset reload. Console_fetch_live_block(cluster-inspect's projection over a remote node's dashboard payload) is updated to match. -
Coordinator gains rich
ws_statepayload + live activity broadcast ([§ Post-P3 reckoning item #2 follow-up]). Pre-lift coord's cluster broadcast was state-only — the dashboard's coord rows showed the state column flipping but thetokens,context_ratio,activity, and per-turncontentfields were all hardcoded to zero / empty. The lift turnson_status/on_content_token/on_thinking_start/on_thinking_stop/on_stream_end/on_tool_resultinto shared bodies on :class:SessionUIBaseso coord populates the same per-ws metric fields interactive does (the fields were already declared on the base; only the writes were WebUI-specific).coord_adapter.emit_statenow reads the UI's snapshot under_ws_lockvia the new :meth:SessionUIBase.snapshot_and_consume_state_payloadhelper and passes the rich kwargs through tocollector.emit_console_ws_state; the cluster dashboard's coord rows now render with the same tokens / activity / content / context_ratio fields interactive rows do.Three observable behaviour changes (all CHANGELOG-callout-worthy):
- Coord persists
usage_eventstorage rows. Pre-lift only WebUI did. The liftedon_statusbody unifies usage tracking so governance dashboards / token-spend queries see coordinator consumption alongside interactive. Operators queryingusage_eventbyws_idwill see coord rows for the first time. - Coord broadcasts live activity transitions. New
ClusterCollector.update_console_ws_activity(ws_id, *, activity, activity_state)method (namedupdate_*rather thanemit_*to flag the no-fan-out asymmetry vs. the rest of theemit_console_ws_*family — it updates the in-memory pseudo-node row but intentionally does NOT fan out a separate SSE event). The cluster dashboard's per-ws polling reads the in-memory pseudo-node row, so activity ticks land on the next snapshot fetch (matches WebUI's behaviour where activity events are observational; not fanned out through the cluster SSE stream). - Cluster
cluster_stateevents for coord rows now carry non-zerotokens/contentfields. Frontend rendering that conditionally hid these on coord rows can drop the branch.
Architecture changes:
_MAX_TURN_CONTENT_CHARSmoved fromturnstone.servertoturnstone.core.session_ui_baseso coord enforces the same per-turn content cap interactive does.- WebUI keeps
on_status/on_tool_result/on_erroroverrides that layer Prometheus_metrics.record_*calls (node-only) on top of the shared body viasuper()— the Prometheus surface stays node-scoped (the console isn't a node and has no /metrics endpoint). ConsoleCoordinatorUIadds a_broadcast_activityoverride that fans out via the cluster collector instead of the global SSE queue (which is node-only on interactive).coord_endpoint_configwires a new_coord_spawn_metricshook (mirrors interactive's) so the per-spawn_ws_messagesincrement +_ws_turn_tool_callsreset happen on coord too.
Test additions: 23 new tests in
tests/test_coord_rich_ws_state_payload.pypin the per-ws metric writes (status, content accumulation, activity tracking, tool-result counters, stream-end activity clear), the snapshot helper's IDLE/ERROR drain semantics + single-lock-acquisition guarantee, the adapter's rich-payload pass-through + defensive None-UI handling, the activity broadcast (collector wire + failure swallow + no-op-when- collector-unset + dedup against last-emitted state), the spawn_metrics hook, and a concurrent-writes-during-snapshot stress case (cycles through running / idle / error so the drain branches actually run against a concurrent writer). Plus WebUI-override regression tests confirming_metrics.record_*still fires on top of the lifted bodies. Existingtests/test_webui_content.pyupdated to import_MAX_TURN_CONTENT_CHARSfrom its new home inturnstone.core.session_ui_base;tests/test_coordinator_adapter.pyupdated to expect the rich-payload kwargs (tokens=0defaults) onemit_console_ws_state.Two deferred follow-ups (out-of-scope for this lift, flagged for tracking):
- Synchronous
record_usage_eventINSERT on coord worker thread. The liftedon_statusbody persists usage rows on every provider response — same shape WebUI uses, but coord workers can fire multi-step plan/task agent loops where each response blocks the worker for a write transaction. Parity with WebUI is the explicit goal here; if coord throughput becomes a concern, batch usage_event writes onto a background flusher thread (one batch INSERT per N events / per K ms) on both kinds. - Coord assistant turn content now flows on the cluster SSE
stream (
/v1/api/cluster/events). Pre-lift the broadcast wascontent=""; post-lift it carries the joined assistant output. The cluster SSE stream has no per-user filter today — extends an existing cross-tenant exposure (interactivecluster_stateevents already carry content) to a previously-empty channel (coord rows). Proper fix needs the SSE endpoint gated onadmin.cluster.inspect(matching/v1/api/cluster/ws/{ws_id}/detail) or per-listener user_id filtering. Tracked as a separate security-tightening project; not gating this lift since it inherits an existing exposure rather than introducing a new mechanism.
- Coord persists
-
history/detailverb bodies lifted across both kinds ([Stage 2 Verb Lift —history/detail]). The coordGET /v1/api/workstreams/{ws_id}/historyandGET /v1/api/workstreams/{ws_id}handlers now share two factory bodies viamake_history_handler(cfg)andmake_detail_handler(cfg). The lift adds both endpoints to the interactive surface as a feature gain (pre-lift only coord exposed them; interactive consumers had to subscribe to/eventsSSE just to read history rows or display fields). No newSessionEndpointConfigfields — the factories reusepermission_gate,manager_lookup,not_found_label,audit_action_prefix, and (for history's storage-fallback kind check)list_kind— all already wired by both production lifespans.Three observable behaviour changes (all documented per kind):
- Interactive gains
GET /v1/api/workstreams/{ws_id}. Pre-lift interactive had no detail endpoint — SDK consumers had to read display fields from the SSE replay on/eventsor scrape the active list. The lifted body lazy-rehydrates a closed/evicted workstream viamgr.open()so the response shape is stable across loaded / persisted-only states. Same{ws_id, name, state, user_id, kind}shape coord exposed pre-lift, now available on both surfaces. - Interactive gains
GET /v1/api/workstreams/{ws_id}/history. Same?limit=query param contract as coord (default 100, max 500, malformed values fall back to 100, out-of-range clamps to [1, 500]). Persisted-but-not-loaded interactives serve history without rehydrating — the lifted body falls back to a storage-row- kind check (via
cfg.list_kind) whenmgr.getreturnsNone, mirroring coord's pre-lift_resolve_coordinator_or_404ladder.
- kind check (via
- Storage / manager-lock work moved off the event loop on coord.
The lifted
historybody always runsstorage.get_workstream(storage-fallback path) andstorage.load_messagesthroughasyncio.to_thread; pre-lift coord ran them inline on the event loop. Long-tail message reads on a saturated console no longer stall every other async handler for the duration of the SQL.
Pydantic schemas:
CoordinatorDetailResponseandCoordinatorHistoryResponseremoved; both folded intoWorkstreamDetailResponse/WorkstreamHistoryResponseon the sharedserver_schemas.py(mirrors the list lift's pattern forWorkstreamInfo). Both server and console OpenAPI specs reference the unified schemas;server_spec.pygainsEndpointSpecentries for the new interactive endpoints. TS SDK gainsWorkstreamDetailResponse/WorkstreamHistoryResponseinterfaces insdk/typescript/src/types.ts;openapi-{server,console}.jsonregenerated.GET /v1/api/workstreams/{ws_id}/historyis the only verb whose lifted body keeps a kind-aware storage fallback (viacfg.list_kind);detaildefers cross-kind isolation tomgr.open()itself. - Interactive gains
-
list/savedverb bodies lifted across both kinds ([Stage 2 Verb Lift —list/saved]). The interactiveGET /v1/api/workstreams+GET /v1/api/workstreams/savedand coordGET /v1/api/workstreams+GET /v1/api/workstreams/savedhandlers now share two factory bodies viamake_list_handler(cfg)andmake_saved_handler(cfg). Four newSessionEndpointConfigfields capture the per-kind divergence:list_resolve_titles: ListResolveTitles | None— interactive wires :func:turnstone.core.memory.get_workstream_display_names(new bulk helper added on the storage layer +memory.py) so the active-list endpoint resolves every user-set alias in ONESELECT ... WHERE ws_id IN (...)instead of the pre-lift per-row N+1. Coord wiresNone(no alias surface today).list_kind: WorkstreamKind | None— required storage-side kind classifier passed tolist_workstreams_with_history. Interactive wiresWorkstreamKind.INTERACTIVE; coord wiresWorkstreamKind.COORDINATOR. Distinct fromaudit_action_prefix(audit-action namespacing) so adding a third kind doesn't have to overload the audit prefix as a classifier; missing value surfaces as 500 with a clear log line rather than silently filtering for the wrong kind.saved_state_filter: str | None— coord wires"closed"so only explicitly-closed coordinators surface in the saved-card grid. Interactive wiresNone(the storage layer already excludesstate='deleted'tombstones).saved_loaded_lookup: SavedLoadedLookup | None— coord-only defence-in-depth filter that excludes ws_ids currently in the in-memory pool (a row can bestate='closed'for a few seconds while the close-emit sequence races the in-memory pop). Interactive wiresNone.
Five observable behaviour changes (all documented per kind):
- Active-list top-level key converges on
"workstreams". Pre-lift coord returned{"coordinators": [...]}; the lifted body returns{"workstreams": [...]}for response-shape parity with interactive. Coord is a 1.5.0aN-only surface — never shipped stable — so SDK / frontend consumers swap once and there's no compat shim or fallback (the convergence MUST land before v1.5.0 stable perproject_unification_before_stable.md). - Saved-list top-level key converges on
"workstreams". Same shape change as the active list, applied toGET /v1/api/workstreams/savedon coord. Coord-only surface; no compat shim. - Active-list row key renames
"id"→"ws_id"on interactive. Pre-lift interactive used the bareidfield while every other shared verb on this surface (cancel, open, events, create, saved-list) usesws_id. Convergence eliminates the internal inconsistency. Frontend consumers readingws.idfrom the active-list response swap tows.ws_id. Interactive HAS shipped stable across 1.0 / 1.3 / 1.4, but the active-list endpoint is consumed by the bundled JS only — there's no external SDK on those stable lines reading the field. Browser-cache staleness is bounded by normal static-asset reload on next page load. - Active-list row gains always-include fields.
user_idwas coord-only;kind+parent_ws_idwere interactive-only. Both kinds now populate all three.parent_ws_iddefaults toNonefor coord (coordinators have no parent). - Storage / manager-lock work moved off the event loop on
interactive. The lifted
savedbody always usesasyncio.to_threadforlist_workstreams_with_history; pre-lift interactive ran it inline (correlated COUNT subquery can stall every other async handler on a cluster with thousands of saved rows). Coord already usedto_thread(perf-2 from the saved-coordinators review); convergence lifts interactive up. The active-list body also movesmgr.list_all+ per-row title resolution off the event loop on both kinds.
Pydantic schemas:
WorkstreamInfo.idrenamed →ws_id,WorkstreamInfo.user_idfield added.CoordinatorInfoandCoordinatorListResponseremoved (folded into the unifiedWorkstreamInfo/ListWorkstreamsResponse);console_specactive-list endpoint now points atListWorkstreamsResponse. OpenAPI spec snapshots regenerated.GET /v1/api/dashboardis not in the lift's scope and still returns rows keyed onid. A separate cleanup PR will converge the dashboard row shape with the rest of the v1 surface. -
SessionManager.creategains a deferred-emit option; liftedcreateHTTP handler eliminates the phantom create→close pair on coord rollback.SessionManager.createnow acceptsdefer_emit_created: bool = False(default preserves the legacy "advertise immediately" contract for direct callers); two new methods complete the deferred-create bracket:SessionManager.commit_create(ws)fires the deferredemit_createdevent after the caller's post-create work confirms the workstream should be advertised.SessionManager.discard(ws_id)releases the in-memory slot- cleans up the UI WITHOUT firing
emit_closed— the workstream's existence was never advertised, so there's nothing to advertise on rollback. Storage-row deletion stays a separate concern (caller invokesdelete_workstreamfor a complete rollback), mirroringmgr.create's split between slot reservation andregister_workstream. Logs awarning(session_mgr.discard.after_emit_created) when invoked on a workstream that's already been advertised (non-deferred create or post-commit_create); the slot is still released so capacity isn't stranded, but the warning surfaces the caller-bug case whereclosewould have been the right call.
- cleans up the UI WITHOUT firing
The lifted
make_create_handlernow uses this bracket: passdefer_emit_created=True, validate uploaded attachments, thenmgr.commit_create(ws)on success /mgr.discard(ws.id)on failure. Pre-fix, coord'smgr.createfiredemit_createdsynchronously — a rollback then calledmgr.closewhich firedemit_closed, surfacing a quick create→close pair on the cluster events stream that the collector's diff-reconcile had to handle. Post-fix, a rejected upload produces zero events. Interactive'semit_createdis a documented no-op stub so the deferral is observably a no-op there; thews_createdbroadcast on the global SSE queue continues to fire from the kind's post_install callback after attachment validation passes (unchanged).Direct callers of
mgr.create(test fixtures, the CLI REPL, channel adapters) keep the defaultdefer_emit_created=Falseand see no behaviour change. -
Coordinator HTTP surface unified under
/v1/api/workstreams/([Stage 2 Priority 0]). The experimental/v1/api/coordinator/*URL tree from 1.5.0aN is removed; coord verbs now mount at the same shape as interactive workstreams via a shared route registrar (turnstone.core.session_routes). Path mapping:Was (1.5.0aN) Now POST /v1/api/coordinator/newPOST /v1/api/workstreams/newGET /v1/api/coordinatorGET /v1/api/workstreamsGET /v1/api/coordinator/savedGET /v1/api/workstreams/savedGET /v1/api/coordinator/{ws_id}GET /v1/api/workstreams/{ws_id}POST /v1/api/coordinator/{ws_id}/{verb}POST /v1/api/workstreams/{ws_id}/{verb}Permission scopes, request / response bodies, and SSE event shapes are unchanged. Callers on the experimental 1.5.0aN coord SDK must swap their URL prefix; the legacy paths are gone with no compat shim. Stable releases (1.0 / 1.3 / 1.4) never exposed
/v1/api/coordinator/, so this change is a no-op for anyone upgrading from a stable line.Two handler bodies (
approve,close) lifted into the shared registrar with kind branching behindSessionEndpointConfig— both kinds share one implementation per verb. Two related behavior changes on the interactive close path:mgr.close()race-loss returns 404 (was 500 on coord; "popped between .get() and .close()" is a not-found semantic, not a server error).- Audit-write failures (
record_auditraising on the storage write) are now caught and logged atwarninglevel; the close still returns 200. Previously the interactive path let the exception propagate as HTTP 500. Coord previously already swallowed; convergence is intentional — operators monitor thews.close.audit_failedlog line in both kinds the same way.
Other shared verbs (
send,cancel,open,events,create,list,saved,history,detail) keep their per-kind handlers — body convergence for those requires SessionManager- side refactors (e.g. Priority 1's worker-dispatch unification forsend) or coordinated frontend changes (response-shape unification forlist/saved) that fall outside Priority 0 scope. -
TypeScript SDK bumped to 0.4.0 to flag the URL change for any 1.5.0aN-era consumer of the experimental coord client. The
openapi-{server,console}.jsonreference specs ship with the unified path tree. -
Worker dispatch unified across interactive + coordinator ([Stage 2 Priority 1]). The atomic check-and-(spawn-or-queue) decision for
ChatSession.sendnow lives inturnstone.core.session_worker.sendand is shared by both paths. Interactive/v1/api/send, the coordinator adapter, the watch-result dispatch, the rewind/retry path, and the initial-message-on-create path all gate onWorkstream._worker_running(set/cleared atomically underws._lock) instead ofThread.is_alive()— closes a race where two senders could spawn parallel workers on the same ChatSession.The
/sendHTTP body itself stays per-kind in this PR. Verb-shape convergence (one shared factory body with capability flags for attachments / queue priorities / metric increments) is tracked as P1.5 and MUST land before 1.5.0 stable — letting the fork ship into the stable line bakes the duplication in for the lifetime of the 1.5 track. -
/sendbody lift + coordinator attachments + queue surface parity ([Stage 2 Priority 1.5]). The/sendHTTP handler is now ONE factory body (make_send_handler(cfg)) wired with capability flags on both kinds; the four attachment endpoints (upload/list/get_content/delete) are also unified viamake_attachment_handlers(cfg). Coord workstreams light up:POST/GET /v1/api/workstreams/{ws_id}/attachments,GET .../attachments/{aid}/content,DELETE .../attachments/{aid}— same shape, same caps, same reservation flow as interactive.POST /v1/api/workstreams/{ws_id}/sendacceptsattachment_ids(or auto-consumes pending) and returnsattached_ids/dropped_attachment_idsfor surfacing partial reservations. Live-worker reuse path also returnspriority/msg_id(parity with the interactivestatus: queuedshape).
Backend parity is end-to-end: storage layer was already kind-agnostic; the route registrar's
AttachmentHandlersslot has been there since Stage 2 P0; the multi-node attachment routing-proxy on the console (route_attachment_proxy) was already shipping. P1.5 is the wiring + verb-shape lift that lets these primitives surface on the coord side.Coord dashboard rendering surfaces an attachment-count badge on past messages with attachments; full chip rendering with click-to-view is deferred (the coord dashboard is diagnostic-leaning and chip parity isn't on the critical path for the unification thesis). Python SDK adds
coordinator_send/coordinator_upload_attachment/coordinator_list_attachments/coordinator_get_attachment_content/coordinator_delete_attachmentonAsyncTurnstoneConsole+TurnstoneConsole. TS SDK regenerated; bumped to 0.5.0.Three lifted helpers (
sniff_image_mime,classify_text_attachment,upload_lock) moved fromturnstone/server.pytoturnstone/core/attachments.pyso both processes use the canonical implementation. The interactive surface keeps the same behaviour; the helpers are simply imported from their new home.coordinator_sendno longer returns429on a full worker queue — the unified body returns200 {"status": "queue_full"}for parity with interactive. Existing callers checking for429should switch to the status-code shape.Coord
GenerationCancellednow emitsstate=idle+stream_end(parity with interactive); pre-P1.5 a cancel-killed coord worker would have terminated silently with no state event. Cluster fanout / alerting keyed onstate=errorfor cancelled coord workers should switch to monitoringstream_end/state=idletogether. -
SessionKindAdapterProtocol split into construction + emission ([Stage 2 Priority 3]). The adapter Protocol now covers only what every kind must implement (kind/build_ui/build_session/cleanup_ui); the four lifecycle emit methods (emit_created/emit_state/emit_rehydrated/emit_closed) move to a separateSessionEventEmitterProtocol wired through a new optionalevent_emitter: SessionEventEmitter | Nonekwarg onSessionManager. Both production adapters (interactive onserver.py, coordinator onconsole/server.py) implement both Protocols and are passed as bothadapterandevent_emitterat lifespan-construction time, so production behavior is unchanged. The interactive adapter's threeemit_created/emit_state/emit_rehydratedmethods remain documented no-op stubs (those events fire from out-of-band paths — the create handler enqueuesws_createdafter attachment validation,WebUI._broadcast_stateemitsws_state);emit_closedstays load-bearing as the sole transport path forws_closedonto the global SSE queue. -
cancelverb body lifted across both kinds ([Stage 2 Verb Lift —cancel]). The interactive/v1/api/canceland coord/v1/api/workstreams/{ws_id}/cancelhandlers now share one body viamake_cancel_handler(cfg, *, audit_emit=None); per-kind divergence captured by a newcancel_forensics: CancelForensics | Nonefield onSessionEndpointConfig(interactive wires_capture_cancel_forensics; coord wiresNone). Three observable behaviour changes for coord callers:- Coord cancel now accepts a
forceflag. Same shape as interactive: posting{"force": true}abandons the worker thread and emitsstream_endso a stuck coord generation can be recovered without waiting for the daemon thread to exit. Pre-lift coord ignoredforce. - Coord cancel response always includes
"dropped". Pre-lift coord returned bare{"status": "ok"}; the lifted body returns{"status": "ok", "dropped": {}}(always-include parity with interactive). SDK consumers don't need to branch on kind to readdropped. - Coord cancel returns 400 when the workstream's session is
None. Pre-lift coord calledcoord_mgr.cancelwhich silently no-op'd on a placeholder/build-failed workstream; the lifted body 400s with{"error": "No session"}for parity with interactive's pre-existing branch.
Two observable changes for interactive (asymmetric — coord pre-lift already had this behaviour):
resolve_plannow runs on every cancel (previously gated onwas_running).resolve_planhas an internal_pending_plan_review is Noneguard, so the call is no-op when no plan review is pending. Lift gives interactive coord's pre-lift recovery path: a stuck plan-pending state from a crashed worker can be cleared viacancelinstead of requiring a workstream close + rehydrate.resolve_approvalruns on every cancel only whenui._pending_approval is not None(the lifted body gates the call).resolve_approvalis not idempotent — it always broadcastsapproval_resolvedand overwrites_approval_result— so the gate prevents a stale resolution event from leaking on idle cancels while preserving the recovery path when an approval really is pending.
Coord
coordinator.cancelaudit detail now includesforceso operator-driven recovery is distinguishable from a routine cancel in the audit log.Three /review fixes folded into the same commit:
- No more stale
approval_resolvedSSE event on idle cancel. The lifted body'sresolve_approvalcall is now gated onui._pending_approval is not None. Pre-fix, the unconditional call would broadcast a phantomapproval_resolvedto every SSE listener even when no prompt was pending — listener UIs that key on the event would dismiss prompts they didn't have. - Force-cancel now clears
_worker_runningalongsideworker_thread. Previously the force path left the half- state(_worker_running=True, worker_thread=None), which routed any follow-upsendthrough the queue-enqueue path onto the abandoned worker (where the cancel flag short-circuits the queue-drain seam, leaving the message orphaned until the next spawn). Restores the(worker_thread, _worker_running)invariantsession_worker.senddocuments. coordinator_stop_cascadenow treats child cancel400 + "No session"asskipped(was previouslyfailed). Lifted coord cancel returns 400 on placeholder / build-failed children — matching the pre-lift outcome where those children were silently no-op'd, so the cascade response'sfailedbucket no longer fires spurious operator alerts.
- Coord cancel now accepts a
-
openverb body lifted across both kinds ([Stage 2 Verb Lift —open]). The interactivePOST /v1/api/workstreams/{ws_id}/openand coordPOST /v1/api/workstreams/{ws_id}/openhandlers now share one body viamake_open_handler(cfg, *, audit_emit=None). Per-kind divergence captured by two newSessionEndpointConfigfields:open_resolve_alias: AliasResolver | None— interactive wires :func:turnstone.core.memory.resolve_workstreamso callers can pass user-friendly aliases ("my-debug-ws") in the path param. Coord wiresNone(hex ids only).open_post_load: OpenPostLoad | None— interactive wires the UI-replay (clear_ui+ history) + handler-sidews_createdenqueue onto the global SSE queue. Coord wiresNoneand relies on the cluster collector fan-out fromCoordinatorAdapter.emit_rehydrated.
Load-bearing fix (§ Post-P3 reckoning item #3): interactive
open_workstreampreviously calledmgr.create(ws_id=resolved_id)+ws.session.resume(...)to rehydrate, bypassingmgr.open()entirely. After the lift both kinds route throughmgr.open()— which makesInteractiveAdapter.emit_rehydratedreachable on interactive (it had been dead-by-routing) and gives the manager a single rehydrate code path to maintain.emit_rehydratedstays a documented no-op stub on the interactive adapter (the handler-sidews_createdenqueue fromopen_post_loadis the load-bearing emission).Two observable behaviour changes for interactive callers:
- Cross-kind open returns 404 (was 400). Pre-lift had a
pre-mgr storage probe that returned
400with"Workstream is not an interactive kind"for coord rows; the lift consolidates onmgr.open()'s singleNone- return contract for missing / wrong-kind / tombstoned rows. Security boundary unchanged. - Already-loaded response uses
ws.namedirectly (wasget_workstream_display_name(resolved_id) or resolved_id). A workstream renamed viaset_workstream_aliasafter being loaded into memory will surface the storage-row name in the open response'snamefield instead of the latest alias. The dashboard listing endpoint still resolves aliases on its own pass, so the user-visible workstream name in the tab strip isn't affected.
Coord behaviour unchanged.
Two /review fixes folded into the same commit:
- Resume failures now return 5xx instead of broken-200.
SessionManager.open()previously caught andlog.debug- swallowed exceptions fromChatSession.resume(which assignsself.messagesbefore the config-restore block, so a partial-failure resume — corruptedworkstream_configrow, model-registry mismatch on a saved alias, malformedtemperature/max_tokens— would leave the session with history but with default config). Pre-lift, the interactive open handler calledws.session.resume(...)directly and let exceptions propagate as 500. The lift accidentally inherited the swallow because it routed throughmgr.open(). Restored pre-lift behaviour:mgr.open()now re-raises resume exceptions after rolling back the slot (cleanup_ui+_remove_locked), so the lifted handler returns 500 with a correlation id and the storage row stays available for a retry instead of silently 200'ing with broken state. except Exceptionin the lifted body documents intent. The bare exception catch aroundmgr.open(ws_id)is intentional — the kind's session factory has no documented exception spec, and resume can propagate fromChatSession.resume. A one-line rationale comment in the handler body keeps a future contributor from narrowing it incorrectly.
-
eventsverb body lifted across both kinds ([Stage 2 Verb Lift —events]). The interactiveGET /v1/api/events?ws_id=...and coordGET /v1/api/workstreams/{ws_id}/eventsSSE handlers now share one body viamake_events_handler(cfg). Per-kind divergence captured by a newevents_replay: EventsReplay | Nonecfg field — a Protocol- typed callback yielding the kind-specific initial replay payload that the lifted body iterates and sends asdata:lines before starting the live event loop. Interactive's_interactive_events_replayyields the pre-lift sequence (connected+status+history+pending_approval- cached intent verdicts +
pending_plan_review); coord's_coord_events_replayyields justpending_approval+pending_plan_review(matches pre-lift coord behaviour).
The legacy interactive query-keyed URL is preserved via a new
make_legacy_query_keyed_adapterhelper (sister tomake_legacy_body_keyed_adapterfrom earlier lifts) — it readsws_idfrom the query string and splices intorequest.path_paramsbefore delegating to the lifted body.GET /v1/api/events?ws_id=...continues to work for any 1.x SDK consumer.Two convergence wins:
- Coord gains SSE connect/disconnect metrics. Pre-lift
coord didn't record per-stream metrics; the lifted body
always calls
metrics.record_sse_connect()/...disconnect(), giving the cluster dashboard the same per-stream observability interactive's had since 1.0. - Both kinds now check
request.is_disconnected()AND thews_closedevent to terminate. Pre-lift interactive relied solely onws_closed(which never fires if the client just goes away without closing the workstream); pre-lift coord relied solely onis_disconnected. The lifted body uses both — whichever fires first wins.
One observable shape change for coord callers: the lifted body returns 409
"session has no UI"whenws.uiis missing (placeholder / build-failed UI), matching pre-lift coord. Pre-lift interactive returned 404 in this case; the lift converges on 409 across kinds because the workstream EXISTS (404 would imply it doesn't).Item #2 from § Post-P3 reckoning split out of this lift during scoping (rich
ws_statepayload parity for coord — lifting coord'sConsoleCoordinatorUIto broadcasttokens + context_ratio + activity + contentlikeWebUI._broadcast_statedoes). The body lift touchessession_routes.py+server.py+console/server.py; the rich-payload work touchescoordinator_ui.py+collector.py+session_ui_base.py(different files, different reviewer concern). Tracked as standalone follow-upfeat/coord-rich-ws-state-payload.Two /review fixes folded into the same commit:
- Restored interactive's dedicated SSE thread pool. The
initial draft of
make_events_handlerusedasyncio.to_thread(default executor, capped atmin(32, cpu_count + 4)) for the per-connectionclient_queue.getblocking wait. Pre-lift interactive used a dedicated 200-threadsse_executor(created in the lifespan withthread_name_prefix="sse") precisely to avoid this — under high concurrent SSE counts the default pool starves and SSE polling contends with every otherasyncio.to_threadcaller in the process (storage, router, audit). Restored isolation via a newsse_executor_lookup: SseExecutorLookup | Nonecfg field; interactive returnsrequest.app.state.sse_executor, coord wiresNoneand falls through to the default executor. - Restored 5s queue.get poll (was shortened to 1s in the
initial draft). The 5x wakeup-rate bump compounded the thread-
pool starvation; the
request.is_disconnected()probe between polls already covers cancel-detection latency the timeout would otherwise gate. - Replay phase streams events directly from the generator
instead of pre-building into a list. The initial draft
materialised the entire kind-specific replay payload
(
connected+status+history+ pending prompts) into a list before constructing theEventSourceResponse, delaying time-to-first-byte until the heaviest replay event (_build_historyfor long-running interactive workstreams) finished serialising AND letting the per-UI listener queue accumulate over its 500-slot cap on a chatty mid-generation workstream. The lifted body now iteratescfg.events_replayinside the async generator so each event ships as soon as the callback yields it; the existing observational-failure swallow semantics are preserved by wrapping the iteration in the same try/except.
- cached intent verdicts +
-
createverb body lifted across both kinds ([Stage 2 Verb Lift —create]). The interactivePOST /v1/api/workstreams/newand coordPOST /v1/api/workstreams/newhandlers now share one body viamake_create_handler(cfg, *, audit_emit=None). Per-kind divergence captured by five newSessionEndpointConfigfields:create_supports_attachments: bool— multipart body parsing- attachment validation+save+rollback. Both kinds wire
True.
- attachment validation+save+rollback. Both kinds wire
create_supports_user_id_override: bool— trusted-source bodyuser_idoverride (interactiveTruefor console- proxied creates; coordFalse).create_validate_request: CreateRequestValidator | None— per-kind pre-create gates (interactive: ws_id format, kind, parent ownership, attachments+resume_ws combo; coord: 401-on- empty-uid).create_build_kwargs: CreateKwargsBuilder | None— per-kind kwargs dict formgr.create.create_post_install: CreatePostInstall | None— per-kind tail end (interactive: WebUI auto_approve + watch_runner +ws_createdglobal broadcast + atomic resume + skill session config + notify_targets + routing override + initial-message worker thread; coord:coord_adapter.sendfor the optional initial_message).
The pure helper
_validate_and_save_uploaded_fileslifted fromturnstone.servertoturnstone.core.attachmentsasvalidate_and_save_uploaded_filesso both processes can call the same kind-agnostic implementation.§ Post-P3 reckoning item #1 done — coord gains create-time attachments. Pre-lift
coordinator_createaccepted JSON only and ignored uploads; the lifted body parsesmultipart/form-dataon coord and saves attachments through the kind-agnostic storage layer.CoordinatorAdapter.sendgained optionalattachments+send_idkwargs so when a create request carries bothinitial_messageand uploads, the attachments are reserved onto the dispatched first turn — the worker'sChatSession.send(..., send_id=...)consumes them on dequeue exactly the way interactive's create-with-attachments worker thread does. Thesend_idreservation token soft-locks the rows, and the adapter's failure path unreserves so a worker crash returns them to pending. The pure helper_reserve_and_resolve_attachmentslifted fromserver.pytoturnstone.core.attachmentsasreserve_and_resolve_attachmentsso both kinds call one kind-agnostic implementation.Note on broadcast timing: coord's
mgr.createfiresemit_created(cluster collector fan-out) BEFORE the lifted body runs attachment validation. If validation fails on coord and the rollback (mgr.close→emit_closed) fires, the cluster events stream sees a phantom create→close pair. Cluster consumers handle this gracefully (same shape as any quick-create-close); decouplingemit_createdfrommgr.createwould be a bigger refactor that doesn't belong in the verb lift. Interactive's broadcast (gq.put_nowait("ws_created")) is held until after attachment validation by the post-install callback, so interactive never sees the phantom pair.Five observable behaviour changes on the create response:
- Both kinds converge on 200 OK. Pre-lift interactive
returned 200 (default JSONResponse status); pre-lift coord
returned 201. Picked 200 over 201 for response-shape parity
with every other shared verb at the cost of REST-strict
correctness — a one-time release note rather than ongoing
client churn (the rest of the v1 SDK already uses
response.okperfeedback_test_frontend_locally.md). SDK consumers that branched onstatus == 201for coord must switch toresponse.ok. - Always-include response shape. Pre-lift interactive
returned
{ws_id, name, resumed, message_count, attachment_ids}(5 fields); pre-lift coord returned{ws_id, name}(2). The lifted body always returns the full shape, withresumed=False/message_count=0/attachment_ids=[]on kinds whose post-install doesn't populate them. Coord callers will see the parity fields appear with default values. - Both kinds converge on the manager-at-capacity 429
semantic. Pre-lift interactive translated
mgr.create'sRuntimeErrorto 400; coord already translated to 429. The documented contract onSessionManager.createis "raises RuntimeError when the manager is at capacity" — 429 (rate- limit / try-later) is the correct shape. - Both kinds converge on the factory-misconfig 503 semantic.
Pre-lift interactive let
ValueErrorpropagate as 500 with a stack trace; coord already translated to 503 with the factory's remediation text. Operators get the actionable message instead of the trace. - Both kinds get a correlation_id'd 500 on unexpected
mgr.createfailure. Pre-lift interactive let unexpected exceptions propagate as 500 with a stack trace (potential information leak via frame names / file paths); coord already returned a correlation_id'd 500 with the message redacted. The lifted body adopts coord's safer pattern on both kinds.
Two coord-specific parity gains:
- Coord rejects disabled skills. Pre-lift
coordinator_createsilently allowed disabled skills to flow through tomgr.create— the row would create with a skill the operator had marked inert, surprising both the operator and the next user. The lifted body returns 400 "Skill not found or disabled" matching interactive's behaviour. - Coord audit-emit failures no longer 500. Pre-lift
coordinator_createalready swallowed; pre-lift interactive let the failure propagate as 500. The lifted body wrapsaudit_emitin try/except +warninglog, returning the successful 200 to the caller. Mirrors the close / cancel / open / events lift contracts.
No legacy adapter is needed for create — both kinds already mounted
POST {prefix}/newpre-lift; the lifted handler slots in at the same path on each kind.Three /review fixes folded into the same commit:
- Pre-lift's 400 on malformed
notify_targetspreserved. The initial draft surfacednotify_targetsvalidation errors from inside the interactivepost_installcallback, which the factory had no return-the-400 channel for — the only signal was toraise, which the factory's generic exception handler turned into a redacted 500. Worse, by the timepost_installran the workstream was fully built (audit row written,ws_createdbroadcast emitted), so a malformed-input request surfaced as "create failed" with the workstream actually live. Fixed by moving thenotify_targetsvalidation into :func:_interactive_create_validate_request(the pre-create gate), which returns the 400 beforemgr.createruns and keeps storage clean. New regression test:test_create_lift_400s_on_malformed_notify_targets. - Skill-lookup storage failure now correlation_id'd. The
initial draft swallowed
get_skill_by_nameexceptions intoskill_data = Noneand returned a 400 "Skill not found or disabled" — masking storage outages as user-input misses and making operator triage of skill-related reports impossible. The lifted body now lets the storage exception propagate to the same correlation_id'd 500 path thatmgr.createfailures use; the skill-lookup + version count +mgr.createall live inside onetry / exceptso storage outages anywhere in the create-prelude get the redacted-message-with-correlation-id treatment instead of a stack-traced 500 leak. - Whitespace-only
skillfield treated as empty. The initial draft tookbody.get("skill") or ""literally — a payload with"skill": " "would have hitget_skill_by_name(" ")and 400'd as "Skill not found". Pre-lift coord stripped via(body.get("skill") or "").strip() or None; the lifted body now strips for both kinds (interactive never received whitespace-only skills from the web UI but the convergence is the safer default). - Canonical skill name persisted to
mgr.create. The initial draft's_interactive_create_build_kwargs/_coord_create_build_kwargspassed the rawbody["skill"]through, so a whitespace-padded request would have persisted" my-skill "even though the lookup was done on the stripped name. The build_kwargs callbacks now threadskill_data["name"](the resolved row's canonical name) so the persistedWorkstream.skillmatches the row that was actually applied — keeps later session-sideskilllookups working regardless of how dirty the inbound payload was.
Security
-
Coord attachment endpoints are now kind-strict ([Stage 2 P1.5]). The coord
attachment_owner_resolverresolves through the in-memorycoord_mgronly — it does NOT fall back to storage. Without this, anadmin.coordinator-scoped caller could pass an interactive workstream ws_id to the new coord attachment endpoints; the genericget_workstream_ownerstorage call (kind-agnostic) would resolve cleanly and grant cross-kind read / write access to interactive attachments. The kind-strict resolver returns 404 for any ws_id not currently held by the coord manager, closing the cross-kind path. Persisted-but-not-loaded coordinators must beopened before their attachment endpoints respond. Caught by /review pre-merge; no exploit observed. -
Workstream state writes are now buffered through
StateWriter.SessionManager.set_stateno longer holdsws._lockacross a synchronous PostgresUPDATEfor non-terminal transitions; instead aStateWriter(constructed at app startup, started / shutdown by the lifespan) coalesces transient transitions per ws_id and flushes every ~1s. Observable behavior change: transient state (thinking/running/idle/attention) shows up in storage up to ~1s late; SSE consumers see it immediately via the adapter'semit_state. TerminalERRORtransitions andclose()write synchronously and remain durable on return. The bug-3 invariant — a closed row can't be resurrected by a buffered transient — is preserved byclose()callingstate_writer.discard(ws_id)(drops pending + waits for any in-flight flush) before its syncstate='closed'write.
[1.4.0]
User-visible additions: a full attachment system (images + text documents, including pre-creation uploads), a unified dashboard composer, a Slack channel adapter, per-call plan/task model selection with an admin UI, and provider capability passthrough.
This release introduces two forward-only schema migrations
(037_workstream_attachments, 038_workstream_attachments_reserved_at)
that the server applies automatically on first startup against an
existing 1.3.x database. Both are additive; no data loss. See
Database migrations below for details.
Added
- Workstream attachments — images (png/jpeg/gif/webp, 4 MiB cap) and
text documents (any
text/*MIME, allowlisted application MIMEs, or known text extensions; 512 KiB cap; UTF-8 enforced). Magic-byte image sniffing on upload; per-(ws, user) pending cap of 10. Three-state lifecycle (pending → reserved → consumed) with reservation tokens threaded through/v1/api/sendso queued multimodal turns can't lose files to overlapping sends. Provider-side translation: Anthropic emits native document blocks; OpenAI Chat Completions inlines them as escaped<document>text blocks; Responses API emitsinput_textwith the same wrapper. (#356) - Attachments at workstream-creation time —
POST /v1/api/workstreams/newacceptsmultipart/form-data(onemetaJSON field plus 0..Nfileparts). Files are validated and reserved onto the first turn before the dispatch worker fires; failure rolls back the fresh workstream so no orphan rows leak. Web UI (new-workstream modal + dashboard composer), Python SDK, and TypeScript SDK all gained attachment support. Cluster routing (/v1/api/route/workstreams/{ws_id}/attachments) extended to forward multipart bodies + preserve upstream headers (CSP, Content-Disposition). SDKs auto-generatews_idclient-side so cluster-routed callers can bind the body to the owning node before it lands. (#362) - Slack channel adapter (Socket Mode) — mirrors the Discord adapter:
per-user channel sessions via configurable slash command, DM routing
without slash command, SSE event consumption, tool approval buttons
with per-user owner enforcement, plan-review approve / request-changes
modal, notification reply routing back into the workstream, and
session recovery after restart via persisted recoverable route keys
(the bot re-subscribes to existing Slack-routed workstreams when it
comes back). Install with
pip install 'turnstone[slack]'. (#355) - Console admin UX support for Slack — channel-link modal offers
Slack alongside Discord; skill notify-on-complete forms expose a
per-row channel-type dropdown (and no longer hardcode
discord); per-platform.scope-discord/.scope-slackbadge classes with theme-aware tokens (--discord/--slack) so light theme passes WCAG AA. (#365) - Per-call plan/task model selection —
plan_modelandtask_modelare now distinct from the conversation model and from each other, with configurable reasoning effort per agent. Three layers:- Backend split (
#54dd557) —ModelRegistrygainsplan_model,task_model,plan_effort,task_effort; per-kind overrides win over the legacyagent_model, which still works as the single-knob fallback.resolve_agent_alias(kind)andresolve_agent_effort(kind)centralise resolution. Loader validates effort against{none, minimal, low, medium, high, xhigh, max}with warn+drop on typos. - Runtime configurability (
#360) —ConfigStoreadmin tab in the console UI lets operators switch alias and reasoning effort per agent without restarting.INHERIT_EMPTY_LABEL_KEYSshows(inherit)for empty effort selections — distinct from the literalnonechoice which actually disables reasoning. Routing overrides apply on/v1/api/_internal/config-reload(admin saves), andmodel-reloadshort-circuits when nothing changed so no in-flight clients churn. - Per-call override (
#361) — the calling LLM can passmodel="<alias>"toplan_agentortask_agentto override the operator-configured per-kind model for that one invocation. Tool descriptions list the live registered aliases (refreshed when the operator hits "sync to nodes"), so the LLM always sees current options. Bad aliases return a corrective error dict listing the available choices. No whitelist — cost control is intentionally ceded to the model. Plan-retry path reuses the alias so coaching reflects real model behaviour. (#360, #361)
- Backend split (
- Provider capability passthrough — resolved per-model capabilities
(vision, reasoning, native web search, thinking_mode, token_param,
etc.) flow through to provider clients via a new
capabilitiesparameter oncreate_streaming/create_completion, so feature gating no longer relies on string matching and admin-UI / config.toml overrides actually reach the provider. Defensive shallow-copy in_finalize_extra_bodyso callers reusing the same dict across models are safe; deep-merge ofchat_template_kwargsso operators can extend instead of silently overwriting. (#352) - Server compatibility layer for local model servers — vLLM and
llama.cpp profiles suggest the right thinking mode and per-server
workarounds (
skip_special_tokensfor vLLM,reasoning_formatfor llama.cpp) during model detection. Admin UI gains structured fields for server type, thinking mode, and extra body params, hidden for non-local providers (openai/anthropic/google). Newthinking_paramtext field surfaces the alias name (defaultenable_thinking; Granite/DeepSeek usethinking). Verified end-to-end against real vLLM (Gemma 4 31B) and llama.cpp (Gemma 4 E4B) servers. (#352) - Claude Opus 4.7 support —
claude-opus-4-7capability entry (1M ctx, 128K output, adaptive thinking,supports_temperature=False,thinking_display=summarized). NewModelCapabilities.thinking_displayfield — Opus 4.7 omits thinking by default but always sends summarized blocks back through the provider boundary. Addsxhigheffort level to the global mapping and to Opus 4.7'seffort_levels; admin-console skill-template dropdowns gainedxhighandmaxoptions. Reasoning effort label capitalization aligned across all console dropdowns. (#357 — also in 1.3.1) - Dashboard composer refactor — unified single-flow create from the
per-node dashboard. Multi-line textarea + collapsible Options panel
(model / judge / skill) + paperclip + drag-drop / paste-image + chip
strip. Submit-button label dynamically toggles between
Create(empty) andSend(text or attachments staged); Enter and click both go through the samedashboardSubmit(). Replaces the inconsistent prior split where Enter created+sent raw and the button opened a separate modal. Options panel state persists inlocalStorage; active non-default selections render as an inline summary chip beside the Options button; drag-over shows an explicit "Drop to attach" overlay. The tab-bar+new-workstream modal also gained a paperclip- chip strip + first-message field so the same flow is reachable from both entry points. (#362, #366)
- Workstream attachments — orphan reservation sweep — periodic
background sweep clears
reserved_for_msg_idon rows whosereserved_atexceeds a 1-hour threshold, self-healing reservations leaked by process crashes between reserve and consume. Backed by a partial index on(reserved_at) WHERE reserved_at IS NOT NULLso the scan stays cheap as the consumed-history grows. Threshold tracks reservation age, not upload age, so a long-pending fresh send can't be racially unreserved. (#363) SendResponseextended —attached_ids,dropped_attachment_ids,priority,msg_idfields exposed in Pydantic + TypeScript SDKs so attachment-aware clients can detect partial reservations and dequeue queued messages. (#365)
Changed
plan_modelandtask_modelnow split from the conversation model and from each other — operators who rely on a single model for all three should set bothplan_modelandtask_modelexplicitly in their config; otherwise both default to the conversation model so behaviour is unchanged. (#54dd557)- Channel notify-on-complete
channel_typeis no longer hardcoded in the admin UI — operators creating notify targets through the skill admin form previously gotchannel_type: "discord"regardless of what they wanted. Existing skill JSON values are unaffected; only newly created targets through the form differ. (#365) - Slack adapter approval previews — capped at 600 chars per item
with a 2700-char total budget so multi-tool approval batches never
exceed Slack's 3000-char
section.textlimit. Truncated batches show a…and N more (preview truncated)suffix. (#365) - PostgreSQL deployment image swapped from
bitnami/pgbouncertoedoburu/pgbouncerto track upstream releases and reduce image size. Environment variables remapped to the edoburu naming, ports updated to match documented expectations, and the Kubernetes Helm Chart link in the deployment docs now points at the same container. Review your helm values if you depend onbitnami-specific environment variable conventions. (#353)
Fixed
plan_resolvedSSE broadcast — when one client resolved a plan approval, other clients viewing the same workstream now have the approval card dismissed in sync. (#87a9af1)- Slack notification reply routing — one notification reply
previously pinned every later assistant response for that workstream
to the notification thread until the bot restarted. Reply-route
override now clears on
StreamEndEvent. (#365) - Slack plan-review mrkdwn fence — plan content containing triple
backticks (very common — plans often quote code) no longer breaks the
surrounding fence and lets later content render as live markup. The
shared
_sanitize_slack_previewhelper splices a zero-width space inside any`` sequence while keeping single backticks readable. (#365) - Slack-routed workstreams now load the chat-specific system prompt
via
client_type="chat", matching Discord. (#365) /v1/api/workstreams/newno longer emits a phantomws_created/ws_closedSSE pair when attachment validation rejects a multipart create. Validation runs before the broadcast so failed creates are silent on dashboards. (#362)- Multipart Content-Type boundary preservation in console routing
proxy —
boundary=parameter is case-sensitive and was being lowercased before forwarding to the upstream node, breaking parsing for clients that used mixed-case boundaries (most browsers). (#362) - Local-theme contrast for new badge colors —
.scope-discordand.scope-slackfirst shipped with raw hex that failed WCAG AA on light theme (1.8:1 / 2.4:1). Theme-aware--discord/--slacktokens with proper light variants now pass. (#365) - Cross-user attachment fetch hardening —
get_attachment_contentnow scopes the row byuser_idin addition tows_id, so an unowned workstream can't be a vector for cross-user blob fetches via attachment-id guessing. (#356) - Attachment-list DoS guard —
/v1/api/sendrejectsattachment_idslists longer than the per-(ws, user) pending cap with a 400, preventing hostile clients from blowing up the storageIN (...)clause. (#356) - Bounded LRU for upload locks — the per-(ws, user) attachment upload-lock map now evicts the oldest unlocked entries past a soft cap, so the in-process map can't grow unbounded on long-running nodes. (#356)
- 3.12 CI deadlock on attachment uploads — the upload-lock was
initially an
asyncio.Lock, but Starlette'sTestClientruns each request on a fresh anyio task / event loop, so the cached lock's_waitersbound to the first loop and a later request would block on a Future from a closed loop (silent deadlock). Switched tothreading.Lock— loop-agnostic, and the critical section is one COUNT + one INSERT. Same root cause is reproducible against any Starlette TestClient harness on Python ≥ 3.10; 3.12 surfaces it more often. Production users on a single event loop weren't affected, but the test environment was. (#356)
Security
- Slack approval per-user authentication — only the session owner can click Approve/Deny on a Slack tool-approval card. Without this, any channel member with view access could approve dangerous tool calls initiated by someone else. (#355)
- Attachment ownership masking — cross-user/cross-workstream attachment ID lookups return 404 (not 403) so non-owners can't enumerate workstream existence by response code. (#356)
- Bumped Debian base image; remaining unfixable
jqCVEs are documented and exception-listed. (#aaea4d3)
Database migrations
037_workstream_attachments— newworkstream_attachmentstable with the lifecycle columns described above. Indexes for ws_id, pending lookups, message linkage, and reservation scoping.038_workstream_attachments_reserved_at— addsreserved_atcolumn for the orphan-sweep staleness signal, plus a partial index onreserved_at IS NOT NULLso the periodic scan is cheap.
Both migrations are additive and idempotent, and the server applies
them automatically on first startup against an existing 1.3.x database.
No manual alembic upgrade step is required — though running it
manually beforehand (e.g. as part of a phased deploy) remains safe.
SDK
Python + TypeScript clients gained:
AttachmentUploadtypeupload_attachment(ws_id, filename, data, mime_type=None)list_attachments(ws_id)get_attachment_content(ws_id, attachment_id) → bytes / Blobdelete_attachment(ws_id, attachment_id)send(message, ws_id, attachment_ids=...)(extended)create_workstream(..., attachments=[...])— multipart variant with client-sidews_idgeneration for cluster-routed callers- Console SDK:
route_create_workstream(attachments=...),route_upload_attachment,route_list_attachments,route_get_attachment_content,route_delete_attachment - Refusal of
attachments + target_nodecombination at the SDK boundary (the multipart routing layer doesn't honortarget_node, so silently picking the wrong node is now an explicit error) PlanResolvedEventSSE event with type guard, dispatched when one client (e.g. mobile) resolves a plan so other connected clients can dismiss their plan-approval modal in sync. Available in both the Python and TypeScript SDKs. (#87a9af1)
Operational
- CI vendor-asset auto-download covers
hls.js— thevendor-js.ymlworkflow previously only iterated katex/hljs/mermaid, so Renovate bumps forhls.jsfailed the wheel-completeness check and required manual file downloads. Detection loop now includeshls, so future Renovate bumps are merge-ready without intervention. (#354)
Contributors
Thanks to the people who made this release happen — especially the external contributors who picked up substantial pieces of work:
- @daoxley — designed and shipped the Slack channel adapter (Socket Mode bot, per-user sessions, approvals, plan-review, notification routing). Major new feature surface in #355.
- @pizzaandcheese — replaced the deprecated bitnami pgbouncer image with the edoburu image, remapped environment variables, ports, and helm chart references. Operationally important for anyone running our reference Postgres deployment (#353).
- Renovate kept dependencies and the JS vendor tree current via several automated bumps.
If you're interested in contributing, channel-attachment ingest from Discord + Slack is the headline 1.4.1 feature and a solid place to start — see the open issues on GitHub or open one to scope a piece.
[1.3.1]
Added
- Backport: Claude Opus 4.7 support (provider capabilities, tokenizer, adaptive thinking). (#357)