Fail-closed history-commit handoff (#1005)

* 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
This commit is contained in:
Patrick Buckley
2026-08-11 04:18:36 -07:00
committed by GitHub
parent f4fd7e1f67
commit 480a1426b3
134 changed files with 28615 additions and 3124 deletions
+122 -7
View File
@@ -6648,7 +6648,7 @@
"tags": [
"Coordinator"
],
"description": "Truncates the coordinator conversation by N turns via the shared rewind handler and emits ``clear_ui`` so the dashboard re-fetches the truncated history. Gated on ``admin.coordinator``.",
"description": "Claims the coordinator mutation slot, durably truncates N turns, and emits ``clear_ui`` so the dashboard re-fetches the truncated history. Concurrent sends are ordered after the cut; storage failure returns 503 without changing live history. Gated on ``admin.coordinator``.",
"parameters": [
{
"name": "ws_id",
@@ -6730,7 +6730,7 @@
"tags": [
"Coordinator"
],
"description": "Drops the last response and re-sends the last user message via the shared worker dispatch, emitting ``clear_ui``. Gated on ``admin.coordinator``.",
"description": "Uses one shared worker claim to drop the last response and start the replacement generation, emitting ``clear_ui``. Another send cannot enter between those operations. Gated on ``admin.coordinator``.",
"parameters": [
{
"name": "ws_id",
@@ -6802,7 +6802,7 @@
"tags": [
"Coordinator"
],
"description": "Releases the worker thread + UI listeners and marks the row ``state=closed`` in storage. The row remains queryable (audit / history) but cannot be reopened \u2014 a closed coordinator is terminal from the manager's perspective.",
"description": "Releases the worker thread + UI listeners and marks the row ``state=closed`` in storage. The row remains queryable (audit / history) but cannot be reopened \u2014 a closed coordinator is terminal from the manager's perspective. Returns 409 while an accepted live conversation row still requires persistence reconciliation; the coordinator remains loaded and its history journal is retained.",
"parameters": [
{
"name": "ws_id",
@@ -6844,6 +6844,16 @@
}
}
},
"409": {
"description": "Error 409",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Error 500",
"content": {
@@ -6874,7 +6884,7 @@
"tags": [
"Coordinator"
],
"description": "Server-Sent Events stream carrying ``status``, ``message``, ``tool_call``, ``tool_result``, ``approval``, ``error``, and the phase-3 ``child_ws_*`` fan-out events. Pings every 5s. Body is text/event-stream \u2014 the response schema is omitted from the catalog because OpenAPI 3.1 has no first-class SSE type.",
"description": "Server-Sent Events stream carrying ``status``, ``message``, ``tool_call``, ``tool_result``, ``approval``, ``error``, and the phase-3 ``child_ws_*`` fan-out events. After rendering REST history, pass its opaque handoff_token once as ?history_token=; it names the exact accepted conversation-row prefix used for that render. history_resync closes this stream and requires a fresh history read; numeric replay is not a substitute. Native Last-Event-ID reconnects take priority. Pass ?user_turn=1 to receive typed accepted-user events; without it, those rows use the backward-compatible strong-repair projection. Pass ?tool_turn=1 to receive final accepted tool rows as typed tool_result events with accepted=true; without it, accepted tool rows use the same pre-row strong-repair projection. Pings every 5s. Body is text/event-stream \u2014 the response schema is omitted from the catalog because OpenAPI 3.1 has no first-class SSE type.",
"parameters": [
{
"name": "ws_id",
@@ -6883,6 +6893,42 @@
"schema": {
"type": "string"
}
},
{
"name": "last_event_id",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "Numeric per-workstream event cursor for manual reconnects."
},
{
"name": "history_token",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Opaque one-shot token naming the accepted prefix rendered from REST history."
},
{
"name": "user_turn",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "Set to 1 to receive typed user_turn events instead of history-repair frames."
},
{
"name": "tool_turn",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "Set to 1 to receive final accepted tool_result projections."
}
],
"responses": {
@@ -6939,7 +6985,7 @@
"tags": [
"Coordinator"
],
"description": "Returns the tail of the conversation in OpenAI-like message format. Used by the page-load handshake; SSE handles updates after that. Bounded by the ``limit`` query parameter.",
"description": "Returns the tail of the conversation in OpenAI-like message format. Used by the page-load handshake; SSE handles updates after that. Cold coordinators are rehydrated before history is served, so every successful response participates in the REST-to-SSE handoff. Messages are the requested tail of one authoritative total accepted conversation-row prefix: user, assistant, tool, and system rows, including projected compaction checkpoints and cancellation markers. The opaque handoff_token names the exact prefix used for the render and is passed once on initial SSE registration. Admission of a later row changes the token; durable acknowledgement does not. If the durable prefix cannot be loaded, the endpoint returns 503 with `History temporarily unavailable`; that response is not authoritative and supplies no usable handoff token. Bounded by the ``limit`` query parameter.",
"parameters": [
{
"name": "ws_id",
@@ -8374,6 +8420,18 @@
"default": 0,
"title": "Tool Calls",
"type": "integer"
},
"persistence_state": {
"default": "healthy",
"description": "Sanitized durable-history status for a live row. Older nodes and unloaded persisted-only rows default to healthy.",
"enum": [
"healthy",
"pending",
"retrying",
"conflict"
],
"title": "Persistence State",
"type": "string"
}
},
"required": [
@@ -8552,7 +8610,7 @@
}
],
"default": null,
"description": "Live in-flight counters (state, tokens, activity, pending_approval) when the owning node returns them; null on degrade.",
"description": "Live in-flight counters and sanitized durable-history status (state, tokens, activity, pending_approval, persistence_state) when the owning node returns them; null on degrade.",
"title": "Live"
},
"messages": {
@@ -8930,6 +8988,22 @@
"title": "Message",
"type": "string"
},
"client_send_id": {
"anyOf": [
{
"maxLength": 128,
"minLength": 1,
"pattern": "^[A-Za-z0-9_-]+$",
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Opaque browser correlation token echoed in the accepted `user_turn` event and history row. It is not an idempotency key; repeated sends with the same value remain distinct turns.",
"title": "Client Send Id"
},
"attachment_ids": {
"anyOf": [
{
@@ -9369,6 +9443,22 @@
"title": "Message",
"type": "string"
},
"client_send_id": {
"anyOf": [
{
"maxLength": 128,
"minLength": 1,
"pattern": "^[A-Za-z0-9_-]+$",
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Opaque browser correlation token echoed in the accepted `user_turn` event and history row. It is not an idempotency key.",
"title": "Client Send Id"
},
"attachment_ids": {
"anyOf": [
{
@@ -15031,6 +15121,18 @@
"$ref": "#/components/schemas/WorkstreamKind",
"default": "interactive"
},
"persistence_state": {
"default": "healthy",
"description": "Sanitized durable-history status: healthy, pending its first save, retrying automatically, or blocked by a permanent commit conflict.",
"enum": [
"healthy",
"pending",
"retrying",
"conflict"
],
"title": "Persistence State",
"type": "string"
},
"pending_approval": {
"default": false,
"description": "True when at least one approval cycle is live (a gate thread parked awaiting an operator approve/deny). Mirrors the same field on ``DashboardWorkstream`` / cluster live projections so a freshly-loaded chat tab can render the inline approval gate from the detail snapshot before SSE replay arrives.",
@@ -15170,7 +15272,7 @@
"type": "string"
},
"messages": {
"description": "Tail of the workstream's message history, projected to the canonical render shape (``role`` may be ``system`` for operator-context turns; flat tool_calls with verdict / output_assessment; top-level source / attachments / reasoning; derived denied / is_error / pending). Bounded by the ``limit`` query parameter (default 100, max 500).",
"description": "Requested limit-bounded tail of one authoritative total accepted conversation-row prefix, projected to the canonical render shape. Roles include ``user``, ``assistant``, ``tool``, and ``system``; compaction checkpoints project as ``role=system, source=compaction`` and cancellation-generated assistant/tool markers appear when present. The projection also carries flat tool_calls with verdict / output_assessment; top-level source / attachments / reasoning; and derived denied / is_error / pending. Bounded by the ``limit`` query parameter (default 100, max 500).",
"items": {
"additionalProperties": true,
"type": "object"
@@ -15190,6 +15292,19 @@
"default": null,
"description": "SSE resume cursor (a ``Last-Event-ID`` value). Non-null only when the trailing turn is an executing in-flight tool batch that the live ring buffer can replay: ``messages`` then omits that turn and the client opens its initial SSE with this cursor so the existing delta replay fast-forwards the in-flight turn (tool calls, results, prompts) instead of the lossy synthetic snapshot. Null on every other read \u2014 the client connects fresh.",
"title": "Cursor"
},
"handoff_token": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Opaque token naming the exact accepted conversation-row prefix used for this render. Present only while the workstream is loaded. A client that renders this response passes the token once as the initial event stream's ``history_token`` query parameter; the server atomically validates it while registering the listener. Admission of a later row changes the token; durable acknowledgement does not. Clients must not inspect, persist, or reuse it for later reconnects.",
"title": "Handoff Token"
}
},
"required": [
+137 -3
View File
@@ -167,6 +167,7 @@
"tags": [
"Workstreams"
],
"description": "Unloads the live workstream while preserving storage. Returns 409 when any accepted live conversation row still requires persistence reconciliation; the workstream remains loaded and its history journal is retained.",
"parameters": [
{
"name": "ws_id",
@@ -217,6 +218,16 @@
}
}
}
},
"409": {
"description": "Error 409",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
@@ -552,6 +563,7 @@
"tags": [
"Chat"
],
"description": "Claims the workstream mutation slot, durably truncates the requested tail, then emits clear_ui. Concurrent sends are ordered after the cut; a storage failure returns 503 without changing live history.",
"parameters": [
{
"name": "ws_id",
@@ -602,6 +614,16 @@
}
}
}
},
"503": {
"description": "Error 503",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
@@ -613,6 +635,7 @@
"tags": [
"Chat"
],
"description": "Uses one workstream worker claim for the durable truncation and the replacement generation, so another send cannot enter between them. A storage failure returns 503 without changing live history.",
"parameters": [
{
"name": "ws_id",
@@ -653,6 +676,16 @@
}
}
}
},
"503": {
"description": "Error 503",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
@@ -664,7 +697,7 @@
"tags": [
"Streaming"
],
"description": "Opens a Server-Sent Events stream scoped to a single workstream. Returns text/event-stream. See API reference for event types.",
"description": "Opens a Server-Sent Events stream scoped to a single workstream. After rendering REST history, pass its opaque handoff_token once as ?history_token=; it names the exact accepted conversation-row prefix used for that render. A history_resync event closes this stream and requires a fresh history read; numeric event replay is not a substitute. Native Last-Event-ID reconnects take priority. Pass ?user_turn=1 to opt into typed accepted-user events; otherwise those rows become a backward-compatible strong-repair frame. Pass ?tool_turn=1 to receive the final accepted tool row as a typed tool_result with accepted=true; without it, accepted tool rows use the same pre-row strong-repair projection. Returns text/event-stream. See API reference for event types.",
"parameters": [
{
"name": "ws_id",
@@ -673,6 +706,42 @@
"schema": {
"type": "string"
}
},
{
"name": "last_event_id",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "Numeric per-workstream event cursor for manual reconnects."
},
{
"name": "history_token",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Opaque one-shot token naming the accepted prefix rendered from REST history."
},
{
"name": "user_turn",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "Set to 1 to receive typed user_turn events instead of history-repair frames."
},
{
"name": "tool_turn",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "Set to 1 to receive final accepted tool_result projections."
}
],
"responses": {
@@ -972,7 +1041,7 @@
"tags": [
"Workstreams"
],
"description": "Returns the tail of the conversation in OpenAI-like message format. Persisted-but-not-loaded workstreams (closed / evicted) serve history without rehydrating. Lifted from the coord-only surface in the Stage 2 history/detail verb lift \u2014 interactive previously only exposed history through the SSE replay on ``/events``.",
"description": "Returns the tail of the conversation in OpenAI-like message format. Persisted-but-not-loaded workstreams (closed / evicted) are rehydrated before history is served so every successful response participates in the REST-to-SSE handoff. Lifted from the coord-only surface in the Stage 2 history/detail verb lift \u2014 interactive previously only exposed history through the SSE replay on ``/events``. Messages are the requested limit-bounded tail of one authoritative total accepted conversation-row prefix: user, assistant, tool, and system rows, including projected compaction checkpoints and cancellation markers. The opaque handoff_token names the exact prefix used for the render and is passed once on initial SSE registration. Admission of a later row changes the token; durable acknowledgement does not. If the durable prefix cannot be loaded, the endpoint returns 503 with `History temporarily unavailable`; that response is not authoritative and supplies no usable handoff token.",
"parameters": [
{
"name": "ws_id",
@@ -2324,6 +2393,22 @@
"title": "Message",
"type": "string"
},
"client_send_id": {
"anyOf": [
{
"maxLength": 128,
"minLength": 1,
"pattern": "^[A-Za-z0-9_-]+$",
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Opaque browser correlation token echoed in the accepted `user_turn` event and history row. It is not an idempotency key; repeated sends with the same value remain distinct turns.",
"title": "Client Send Id"
},
"attachment_ids": {
"anyOf": [
{
@@ -2860,6 +2945,18 @@
],
"default": null,
"title": "Project Id"
},
"persistence_state": {
"default": "healthy",
"description": "Sanitized durable-history status for the loaded workstream: healthy, pending its first save, retrying automatically, or blocked by a permanent commit conflict. Older servers and unloaded rows default to healthy.",
"enum": [
"healthy",
"pending",
"retrying",
"conflict"
],
"title": "Persistence State",
"type": "string"
}
},
"required": [
@@ -2893,6 +2990,18 @@
"$ref": "#/components/schemas/WorkstreamKind",
"default": "interactive"
},
"persistence_state": {
"default": "healthy",
"description": "Sanitized durable-history status: healthy, pending its first save, retrying automatically, or blocked by a permanent commit conflict.",
"enum": [
"healthy",
"pending",
"retrying",
"conflict"
],
"title": "Persistence State",
"type": "string"
},
"pending_approval": {
"default": false,
"description": "True when at least one approval cycle is live (a gate thread parked awaiting an operator approve/deny). Mirrors the same field on ``DashboardWorkstream`` / cluster live projections so a freshly-loaded chat tab can render the inline approval gate from the detail snapshot before SSE replay arrives.",
@@ -3032,7 +3141,7 @@
"type": "string"
},
"messages": {
"description": "Tail of the workstream's message history, projected to the canonical render shape (``role`` may be ``system`` for operator-context turns; flat tool_calls with verdict / output_assessment; top-level source / attachments / reasoning; derived denied / is_error / pending). Bounded by the ``limit`` query parameter (default 100, max 500).",
"description": "Requested limit-bounded tail of one authoritative total accepted conversation-row prefix, projected to the canonical render shape. Roles include ``user``, ``assistant``, ``tool``, and ``system``; compaction checkpoints project as ``role=system, source=compaction`` and cancellation-generated assistant/tool markers appear when present. The projection also carries flat tool_calls with verdict / output_assessment; top-level source / attachments / reasoning; and derived denied / is_error / pending. Bounded by the ``limit`` query parameter (default 100, max 500).",
"items": {
"additionalProperties": true,
"type": "object"
@@ -3052,6 +3161,19 @@
"default": null,
"description": "SSE resume cursor (a ``Last-Event-ID`` value). Non-null only when the trailing turn is an executing in-flight tool batch that the live ring buffer can replay: ``messages`` then omits that turn and the client opens its initial SSE with this cursor so the existing delta replay fast-forwards the in-flight turn (tool calls, results, prompts) instead of the lossy synthetic snapshot. Null on every other read \u2014 the client connects fresh.",
"title": "Cursor"
},
"handoff_token": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Opaque token naming the exact accepted conversation-row prefix used for this render. Present only while the workstream is loaded. A client that renders this response passes the token once as the initial event stream's ``history_token`` query parameter; the server atomically validates it while registering the listener. Admission of a later row changes the token; durable acknowledgement does not. Clients must not inspect, persist, or reuse it for later reconnects.",
"title": "Handoff Token"
}
},
"required": [
@@ -3209,6 +3331,18 @@
"default": null,
"title": "Project Id"
},
"persistence_state": {
"default": "healthy",
"description": "Sanitized durable-history status for this live row. Contains no storage error, commit key, retry count, or conversation content.",
"enum": [
"healthy",
"pending",
"retrying",
"conflict"
],
"title": "Persistence State",
"type": "string"
},
"pending_approval_details": {
"description": "Inline approval payload for the coordinator children-tree UI: EVERY live approval cycle, oldest first \u2014 parallel task agents gate concurrently, so a workstream can hold several prompts at once. Each entry carries the cycle's items + per-call_id LLM verdict cache so a coord can render approve/deny buttons + judge pill without a separate per-child round-trip; resolve each with its ``cycle_id``. Empty when no approval is pending. Also surfaced (verbatim) on ``GET /v1/api/cluster/ws/live`` via the ``_CLUSTER_WS_LIVE_KEYS`` projection. Replaces 1.6's ``pending_approval_detail`` single-object field (breaking, 1.7).",
"items": {
+60 -1
View File
@@ -1,4 +1,8 @@
import type { ClusterOverviewResponse, ClusterSnapshotNode } from "./types.js";
import type {
ClusterOverviewResponse,
ClusterSnapshotNode,
ConversationPersistenceState,
} from "./types.js";
// ---------------------------------------------------------------------------
// Server SSE events
@@ -35,6 +39,37 @@ export interface HistoryEvent {
messages: Array<Record<string, unknown>>;
}
/**
* The REST history rendered by the caller no longer names the live accepted
* row prefix. Stop this stream, refetch and render history, then open a new
* stream with its cursor and one-shot token. The SDK does not do this
* automatically.
*/
export interface HistoryResyncEvent {
type: "history_resync";
/** Present on registration-time handoff mismatches; implied by a scoped stream. */
ws_id?: string;
reason: string;
}
/** One accepted user row, projected live to every workstream consumer. */
export interface UserTurnEvent {
type: "user_turn";
ws_id?: string;
content: string;
attachments?: Array<{
attachment_id: string;
kind: string;
filename: string;
mime_type: string;
}>;
sender?: string;
source?: string;
/** Optimistic-browser correlation only; not delivery idempotency. */
client_send_ids: string[];
_event_id?: number;
}
export interface ThinkingStartEvent {
type: "thinking_start";
}
@@ -112,6 +147,12 @@ export interface ToolResultEvent {
name: string;
output: string;
is_error?: boolean;
preview?: Record<string, unknown>;
/** True only for the final guarded row accepted into conversation history. */
accepted?: boolean;
effect_status?: string;
/** Monotonic accepted-row identity; present for projection-capable clients. */
_event_id?: number;
}
export interface ToolOutputChunkEvent {
@@ -210,6 +251,8 @@ export interface WsStateEvent {
context_ratio: number;
activity: string;
activity_state: string;
/** Defaults to `healthy` when omitted by an older node. */
persistence_state?: ConversationPersistenceState;
/** Full assistant response text — populated on idle transitions only. */
content?: string;
}
@@ -237,6 +280,8 @@ export interface WsClosedEvent {
export type ServerEvent =
| ConnectedEvent
| HistoryEvent
| HistoryResyncEvent
| UserTurnEvent
| ThinkingStartEvent
| ThinkingStopEvent
| ContentEvent
@@ -284,6 +329,8 @@ export interface ClusterStateEvent {
context_ratio: number;
activity: string;
activity_state: string;
/** Defaults to `healthy` when omitted by an older node. */
persistence_state?: ConversationPersistenceState;
}
export interface ClusterWsCreatedEvent {
@@ -291,6 +338,8 @@ export interface ClusterWsCreatedEvent {
ws_id: string;
node_id: string;
name: string;
/** Defaults to `healthy` when omitted by an older node. */
persistence_state?: ConversationPersistenceState;
}
export interface ClusterWsClosedEvent {
@@ -374,3 +423,13 @@ export function isApprovalResolvedEvent(
export function isCancelledEvent(e: ServerEvent): e is CancelledEvent {
return e.type === "cancelled";
}
export function isHistoryResyncEvent(
e: ServerEvent,
): e is HistoryResyncEvent {
return e.type === "history_resync";
}
export function isUserTurnEvent(e: ServerEvent): e is UserTurnEvent {
return e.type === "user_turn";
}
+7
View File
@@ -30,6 +30,8 @@ export type {
ClusterEvent,
ConnectedEvent,
HistoryEvent,
HistoryResyncEvent,
UserTurnEvent,
ThinkingStartEvent,
ThinkingStopEvent,
ContentEvent,
@@ -71,10 +73,13 @@ export {
isApproveRequestEvent,
isApprovalResolvedEvent,
isCancelledEvent,
isHistoryResyncEvent,
isUserTurnEvent,
} from "./events.js";
// Request/response types
export type {
ConversationPersistenceState,
SendRequest,
SendResponse,
ApproveRequest,
@@ -87,6 +92,8 @@ export type {
CloseWorkstreamRequest,
WorkstreamInfo,
ListWorkstreamsResponse,
WorkstreamHistoryResponse,
StreamEventsOptions,
DashboardWorkstream,
DashboardAggregate,
DashboardResponse,
+42 -3
View File
@@ -25,8 +25,10 @@ import type {
SendResponse,
SkillSummary,
StatusResponse,
StreamEventsOptions,
TurnResult,
UploadAttachmentResponse,
WorkstreamHistoryResponse,
} from "./types.js";
function generateWsId(): string {
@@ -113,12 +115,15 @@ export class TurnstoneServer extends BaseClient {
async send(
message: string,
wsId: string,
opts?: { attachmentIds?: string[] },
opts?: { attachmentIds?: string[]; clientSendId?: string },
): Promise<SendResponse> {
const body: Record<string, unknown> = { message };
if (opts?.attachmentIds !== undefined) {
body.attachment_ids = opts.attachmentIds;
}
if (opts?.clientSendId !== undefined) {
body.client_send_id = opts.clientSendId;
}
return this.request(
"POST",
`/v1/api/workstreams/${encodeURIComponent(wsId)}/send`,
@@ -231,11 +236,45 @@ export class TurnstoneServer extends BaseClient {
);
}
// -- History ---------------------------------------------------------------
/**
* Return the requested tail of the authoritative total accepted row prefix.
* A 503 is non-authoritative and must not replace an existing transcript.
*/
async getHistory(
wsId: string,
opts?: { limit?: number },
): Promise<WorkstreamHistoryResponse> {
return this.request(
"GET",
`/v1/api/workstreams/${encodeURIComponent(wsId)}/history`,
{ params: { limit: opts?.limit ?? 100 } },
);
}
// -- Streaming ------------------------------------------------------------
async *streamEvents(wsId: string): AsyncIterableIterator<ServerEvent> {
/**
* Open one caller-managed event stream. Pass history hints only after fully
* rendering the corresponding `getHistory()` response. On `history_resync`,
* stop this iterator, refetch and render history, then open a new stream with
* the new hints. No automatic reconnect or transcript repair is performed.
*/
async *streamEvents(
wsId: string,
opts?: StreamEventsOptions,
): AsyncIterableIterator<ServerEvent> {
const params: Record<string, string | number> = { user_turn: 1 };
if (opts?.lastEventId !== undefined) {
params.last_event_id = opts.lastEventId;
}
if (opts?.historyToken) {
params.history_token = opts.historyToken;
}
yield* this.streamSSE<ServerEvent>(
`/v1/api/workstreams/${encodeURIComponent(wsId)}/events`,
params,
);
}
@@ -277,7 +316,7 @@ export class TurnstoneServer extends BaseClient {
// Start consuming the per-workstream SSE stream first
const events = this.streamSSE<ServerEvent>(
`/v1/api/workstreams/${encodeURIComponent(wsId)}/events`,
undefined,
{ user_turn: 1 },
controller.signal,
);
+40 -3
View File
@@ -2,6 +2,13 @@
// Shared types
// ---------------------------------------------------------------------------
/** Sanitized operator-visible state of accepted conversation persistence. */
export type ConversationPersistenceState =
| "healthy"
| "pending"
| "retrying"
| "conflict";
export interface ErrorResponse {
error: string;
}
@@ -56,6 +63,11 @@ export interface SendRequest {
* workstream are auto-consumed; an empty list disables auto-consume.
*/
attachment_ids?: string[];
/**
* Opaque optimistic-send correlation echoed by user_turn/history.
* Reusing it does not collapse or deduplicate accepted turns.
*/
client_send_id?: string;
}
export interface SendResponse {
@@ -225,6 +237,8 @@ export interface WorkstreamInfo {
parent_ws_id: string | null;
user_id: string;
project_id: string | null;
/** Defaults to `healthy` when omitted by an older node. */
persistence_state?: ConversationPersistenceState;
}
export interface ListWorkstreamsResponse {
@@ -240,14 +254,33 @@ export interface WorkstreamDetailResponse {
state: string;
user_id: string;
kind: string;
/** Defaults to `healthy` when omitted by an older node. */
persistence_state?: ConversationPersistenceState;
}
export interface WorkstreamHistoryResponse {
ws_id: string;
// Tail of the workstream's reconstructed message history
// (provider-fidelity OpenAI-like shape). Bounded by the ?limit=
// query param (default 100, max 500).
/**
* Requested limit-bounded tail of the authoritative total accepted
* conversation-row prefix.
* Roles include user, assistant, tool, and system; projected compaction and
* cancellation markers participate in the same prefix.
*/
messages: Record<string, unknown>[];
/** Initial event-ring cursor returned by the history projection, if needed. */
cursor: number | null;
/**
* Opaque one-shot token naming the exact live prefix used for this render.
* Null for a workstream that is not currently loaded.
*/
handoff_token: string | null;
}
export interface StreamEventsOptions {
/** Initial event-ring cursor, normally copied from `getHistory()`. */
lastEventId?: number;
/** One-shot live-prefix token, copied only from the history just rendered. */
historyToken?: string;
}
export interface DashboardWorkstream {
@@ -264,6 +297,8 @@ export interface DashboardWorkstream {
node?: string;
model?: string;
model_alias?: string;
/** Defaults to `healthy` when omitted by an older node. */
persistence_state?: ConversationPersistenceState;
}
export interface DashboardAggregate {
@@ -530,6 +565,8 @@ export interface ClusterWorkstreamInfo {
activity?: string;
activity_state?: string;
tool_calls?: number;
/** Defaults to `healthy` when omitted by an older node. */
persistence_state?: ConversationPersistenceState;
}
export interface ClusterWorkstreamsResponse {
+45
View File
@@ -8,6 +8,8 @@ import {
isApproveRequestEvent,
isApprovalResolvedEvent,
isReasoningEvent,
isHistoryResyncEvent,
isUserTurnEvent,
} from "../src/events.js";
import type { ServerEvent } from "../src/events.js";
@@ -44,6 +46,26 @@ describe("event type guards", () => {
expect(isToolResultEvent(e)).toBe(true);
});
it("carries accepted tool projection metadata", () => {
const e: ServerEvent = {
type: "tool_result",
call_id: "c-final",
name: "open_preview",
output: "guarded\nscalar",
is_error: true,
preview: { kind: "html", attachment_id: "preview-1" },
accepted: true,
effect_status: "unknown",
_event_id: 42,
};
expect(isToolResultEvent(e)).toBe(true);
if (!isToolResultEvent(e)) throw new Error("tool result type guard failed");
expect(e.accepted).toBe(true);
expect(e.preview).toEqual({ kind: "html", attachment_id: "preview-1" });
expect(e.effect_status).toBe("unknown");
expect(e._event_id).toBe(42);
});
it("isWsStateEvent", () => {
const e: ServerEvent = {
type: "ws_state",
@@ -53,6 +75,7 @@ describe("event type guards", () => {
context_ratio: 0,
activity: "",
activity_state: "",
persistence_state: "retrying",
};
expect(isWsStateEvent(e)).toBe(true);
});
@@ -70,4 +93,26 @@ describe("event type guards", () => {
};
expect(isApprovalResolvedEvent(e)).toBe(true);
});
it("isHistoryResyncEvent", () => {
const e: ServerEvent = {
type: "history_resync",
ws_id: "ws1",
reason: "handoff_mismatch",
};
expect(isHistoryResyncEvent(e)).toBe(true);
expect(isContentEvent(e)).toBe(false);
});
it("isUserTurnEvent", () => {
const e: ServerEvent = {
type: "user_turn",
content: "hello",
sender: "user-1",
client_send_ids: ["browser-send"],
_event_id: 17,
};
expect(isUserTurnEvent(e)).toBe(true);
expect(isContentEvent(e)).toBe(false);
});
});
+66
View File
@@ -88,6 +88,21 @@ describe("TurnstoneServer", () => {
expect(JSON.parse(init.body)).toEqual({ message: "Hello" });
});
it("send threads the optional browser correlation token", async () => {
const fetchFn = mockFetch({ status: "ok" });
const client = new TurnstoneServer({
baseUrl: "http://test",
fetch: fetchFn,
});
await client.send("Hello", "ws1", { clientSendId: "browser-send_1" });
const [, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
expect(JSON.parse(init.body)).toEqual({
message: "Hello",
client_send_id: "browser-send_1",
});
});
it("approve selects a cycle without duplicating ws_id in the body", async () => {
const fetchFn = mockFetch({ status: "ok", cycle_id: "cycle-1" });
const client = new TurnstoneServer({
@@ -127,6 +142,57 @@ describe("TurnstoneServer", () => {
expect(response.dropped).toEqual({ tool_calls: ["call-1"] });
});
it("getHistory returns the cursor and one-shot handoff token", async () => {
const fetchFn = mockFetch({
ws_id: "ws1",
messages: [{ role: "system", source: "compaction", content: "summary" }],
cursor: 0,
handoff_token: "epoch.7",
});
const client = new TurnstoneServer({
baseUrl: "http://test",
fetch: fetchFn,
});
const history = await client.getHistory("ws1", { limit: 42 });
expect(history.cursor).toBe(0);
expect(history.handoff_token).toBe("epoch.7");
const [url] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("http://test/v1/api/workstreams/ws1/history?limit=42");
});
it("streamEvents forwards caller-managed initial history hints", async () => {
const fetchFn = vi
.fn()
.mockResolvedValue(
new Response(
'data: {"type":"history_resync","ws_id":"ws1","reason":"handoff_mismatch"}\n\n',
{ status: 200, headers: { "content-type": "text/event-stream" } },
),
);
const client = new TurnstoneServer({
baseUrl: "http://test",
fetch: fetchFn,
});
const events = [];
for await (const event of client.streamEvents("ws1", {
lastEventId: 0,
historyToken: "epoch.7",
})) {
events.push(event);
}
expect(events).toEqual([
{ type: "history_resync", ws_id: "ws1", reason: "handoff_mismatch" },
]);
const [url] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe(
"http://test/v1/api/workstreams/ws1/events?user_turn=1&last_event_id=0&history_token=epoch.7",
);
});
it("injects auth header when token provided", async () => {
const fetchFn = mockFetch({ workstreams: [] });
const client = new TurnstoneServer({