ask-app-server 0.4.20 → 0.4.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +97 -0
- data/README.md +5 -0
- data/lib/ask/app_server/agent_adapter.rb +233 -11
- data/lib/ask/app_server/event_translator.rb +20 -4
- data/lib/ask/app_server/server.rb +25 -14
- data/lib/ask/app_server/session_manager.rb +85 -7
- data/lib/ask/app_server/session_store.rb +5 -3
- data/lib/ask/app_server/version.rb +1 -1
- data/lib/ask-app-server.rb +1 -0
- metadata +17 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a798da52caa5bd806b1b44c6ffc698f3f8c6eda35b46c8106a47ca5858c3dc96
|
|
4
|
+
data.tar.gz: 8dfaa77e96b1b3e181010cfa691b347ef04f1ff77ae4f8975e769f9d269e3a90
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ec63de1fa030729903f69f0cabfb975a7aa8046fa73edf86a6161ff2cf7b2bae4ec07a59a1547c1278150c62196af7c7a2b914c9d12d79ceb1f93145831f1e1d
|
|
7
|
+
data.tar.gz: ee5bf7058adf48edf457e6689155a4f5f23d6b6450373bd7c08faa73deee9e98619f56ecb4417e76a364a43850c17d00c6488f677f488c7eaf70d324fbf6f85c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,102 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Durable session listings survive restart.** When `state_adapter:` is
|
|
8
|
+
configured, the app-server session registry now uses the same persistent
|
|
9
|
+
adapter as the ask-session Host, so SQLite and PostgreSQL sessions remain
|
|
10
|
+
discoverable through `session/list` after restart. Reattaching a durable
|
|
11
|
+
session no longer reports it as a duplicate live session.
|
|
12
|
+
|
|
13
|
+
## [0.4.21] - 2026-09-22
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Durable ask-session integration — `Ask::Session::Host` is the event
|
|
18
|
+
source of truth for replay.** `SessionManager` owns one Host
|
|
19
|
+
(injectable via `SessionManager.new(host:)`, default in-process);
|
|
20
|
+
every session creates its ask-session record at create time, each
|
|
21
|
+
canonical protocol event is appended to it at the `EventTranslator`
|
|
22
|
+
boundary, and `session/events`, subscribe snapshots, and cursor push
|
|
23
|
+
all read back from the Host — replay no longer depends on the
|
|
24
|
+
in-memory translator buffer (events survive the buffer cap). Session
|
|
25
|
+
close closes the durable record (`Host#close`); the wire seq is the
|
|
26
|
+
Host seq, contiguous from 1. Requires the new runtime dependency
|
|
27
|
+
`ask-session >= 0.1.0` (the single session store; no second store
|
|
28
|
+
added). Protocol translation is unchanged and stays in app-server;
|
|
29
|
+
the JSON-RPC surface, approvals, plans, subscriptions, and
|
|
30
|
+
`ask-session-protocol` wire behavior are untouched.
|
|
31
|
+
- **Durable restart resume.** `SessionManager.new(state_adapter:)` and
|
|
32
|
+
`AgentAdapter.new(state_adapter:)` accept any ask-state-providers
|
|
33
|
+
adapter; it is wrapped in `Ask::Session::ProviderStore` and handed to
|
|
34
|
+
the Host, so records, events, and snapshots survive restarts. No
|
|
35
|
+
adapter means the default in-memory Host — nothing changes for
|
|
36
|
+
callers that do not opt in, and no concrete backend is forced.
|
|
37
|
+
- **`session/resume` now falls back to the durable Host.** When the live
|
|
38
|
+
registry does not know the session, the manager rebuilds a fresh
|
|
39
|
+
compatible `Ask::Agent::Session` under the same id (configured from
|
|
40
|
+
the manager's defaults — model/tools/prompt are not serialized), and
|
|
41
|
+
when the Host holds an `agent.snapshot` restores the conversation
|
|
42
|
+
through `Ask::Agent::SessionAdapter.resume`. In-process resume (the
|
|
43
|
+
live adapter) still wins and is unchanged; a terminal (closed)
|
|
44
|
+
durable record refuses resume with the existing `SessionNotFound`
|
|
45
|
+
(-32004) error. The restored SessionAdapter's own event handler is
|
|
46
|
+
detached after restore — the `EventTranslator` remains the single
|
|
47
|
+
protocol writer, so the wire vocabulary never double-writes.
|
|
48
|
+
- **Successful runs append an `agent.snapshot`** (messages +
|
|
49
|
+
turn_count — the payload `SessionAdapter.resume` expects) to the
|
|
50
|
+
Host after a clean turn; failed/aborted turns write none. The
|
|
51
|
+
snapshot is Host-internal and stays off the wire.
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
- **Failure events always wake watchers.** Model stream drops, run
|
|
56
|
+
failures, and disconnects now reliably emit `turn.failed` with turn
|
|
57
|
+
identity: `EventTranslator#turn_failed` always carries a `turnId`
|
|
58
|
+
(the active turn's, or a fresh one when the run died before
|
|
59
|
+
`turn.started`) — previously a run that failed without an announced
|
|
60
|
+
turn raised a protocol validation error inside the run thread, the
|
|
61
|
+
event was swallowed, and every watcher waited forever on a ghost
|
|
62
|
+
turn. The adapter settles `running` before emitting, so observers
|
|
63
|
+
woken by `turn.failed` (the herdr pane reporter, session observers)
|
|
64
|
+
see the run as finished rather than a ghost "working" that no later
|
|
65
|
+
event corrects, and emission itself is guarded so a translation
|
|
66
|
+
error can never take down the run thread after the fact. Aborted
|
|
67
|
+
turns remain client-requested, not failures.
|
|
68
|
+
- **A disconnected watcher cannot starve the others.** `push_pending`
|
|
69
|
+
isolates delivery per connection: a socket that dies mid-write
|
|
70
|
+
(EPIPE/ECONNRESET) no longer aborts the whole pass — the remaining
|
|
71
|
+
subscribed connections still receive their events (including the
|
|
72
|
+
terminal `turn.failed`), and the dead connection's cursor holds
|
|
73
|
+
until its reader reaps it.
|
|
74
|
+
- **Durable replay over `ProviderStore`** no longer drops events:
|
|
75
|
+
rehydrated Host events carry symbolized payload keys, which failed
|
|
76
|
+
protocol payload validation and were silently dropped from
|
|
77
|
+
`session/events` — the adapter now normalizes payload keys to the
|
|
78
|
+
string-keyed wire shape at the boundary.
|
|
79
|
+
|
|
80
|
+
### Boundaries (unchanged this slice)
|
|
81
|
+
|
|
82
|
+
- **`Ask::Agent::SessionAdapter` is not attached.** Its `run` lacks the
|
|
83
|
+
steer/queue/staleness semantics `session/send` depends on, and its
|
|
84
|
+
event vocabulary (`message.added`, `agent.snapshot`, no
|
|
85
|
+
`turn.completed`/approval events, non-wire payloads) would double-write
|
|
86
|
+
and diverge from the canonical contract. The app-server keeps driving
|
|
87
|
+
runs itself and appends protocol events to the Host directly; its
|
|
88
|
+
lifecycle surface maps 1:1 (`SessionAdapter#create`/`#close` ≡
|
|
89
|
+
`Host#create`/`Host#close`).
|
|
90
|
+
- **`Host#send_message` is unused:** the protocol has no `message.added`
|
|
91
|
+
event; user input enters through `session/send`.
|
|
92
|
+
- **`Host#subscribe` is unused:** delivery stays per-connection cursor
|
|
93
|
+
polling over `Host#events` (any number of clients per session).
|
|
94
|
+
- **`session/resume` prefers the live adapter** (in-process, unchanged);
|
|
95
|
+
only when the registry lacks the session does it fall back to the
|
|
96
|
+
durable Host snapshot described above.
|
|
97
|
+
- **`SessionStore`'s state-backed event helpers** were never on the wire
|
|
98
|
+
path and are not the replay source; the Host is.
|
|
99
|
+
|
|
3
100
|
## [0.4.17] - 2026-09-18
|
|
4
101
|
|
|
5
102
|
### Changed
|
data/README.md
CHANGED
|
@@ -111,6 +111,11 @@ Every event is a canonical `{type, seq, payload}` envelope, delivered as a
|
|
|
111
111
|
The full vocabulary, payload shapes, method specs, and versioning live in
|
|
112
112
|
the ask-session-protocol gem (JSON Schema artifact included).
|
|
113
113
|
|
|
114
|
+
The event log itself is event-sourced in [ask-session](https://github.com/ask-rb/ask-session)'s
|
|
115
|
+
`Host`: replay (`session/events`, subscribe snapshots, cursor push) reads
|
|
116
|
+
the durable log rather than an in-memory buffer, and protocol translation
|
|
117
|
+
stays at the app-server boundary.
|
|
118
|
+
|
|
114
119
|
## Clients
|
|
115
120
|
|
|
116
121
|
Any client that speaks the app-server protocol can connect — including
|
|
@@ -10,7 +10,24 @@ module Ask
|
|
|
10
10
|
# owns the approval queue wiring (approval events surface as
|
|
11
11
|
# approval.required / approval.updated) and the interaction controls
|
|
12
12
|
# (approve/reject by id, plan approve/reject) that any client can call.
|
|
13
|
+
#
|
|
14
|
+
# The durable ask-session layer: every session has an
|
|
15
|
+
# Ask::Session::Host record, and each canonical event is appended to
|
|
16
|
+
# it at this boundary (protocol translation stays here — the Host is
|
|
17
|
+
# an event-sourced log, it knows nothing about the wire vocabulary).
|
|
18
|
+
# Replay and cursor delivery read the Host, so the durable log is the
|
|
19
|
+
# source of truth; the translator's in-memory buffer serves live
|
|
20
|
+
# observers only. A successful run also appends an agent.snapshot
|
|
21
|
+
# (the ask-agent restart-resume payload); restart resume rebuilds a
|
|
22
|
+
# fresh session through Ask::Agent::SessionAdapter.resume when the
|
|
23
|
+
# Host holds one ({#resume_from_host}).
|
|
13
24
|
class AgentAdapter
|
|
25
|
+
# Event types the ask-session Host writes itself (Host#create /
|
|
26
|
+
# Host#close). The adapter must not re-append them: the read path
|
|
27
|
+
# maps the Host's lifecycle payloads back to the wire shape, which
|
|
28
|
+
# keeps Host seq and wire seq contiguous from 1.
|
|
29
|
+
HOST_OWNED_EVENT_TYPES = %w[session.created session.ended].freeze
|
|
30
|
+
|
|
14
31
|
# The underlying ask-agent session.
|
|
15
32
|
attr_reader :session
|
|
16
33
|
|
|
@@ -20,6 +37,9 @@ module Ask
|
|
|
20
37
|
# The session ID (same as ask-agent session id).
|
|
21
38
|
attr_reader :session_id
|
|
22
39
|
|
|
40
|
+
# The durable ask-session host this session's records live in.
|
|
41
|
+
attr_reader :host
|
|
42
|
+
|
|
23
43
|
# Whether a turn is currently in progress.
|
|
24
44
|
attr_reader :running
|
|
25
45
|
|
|
@@ -33,10 +53,18 @@ module Ask
|
|
|
33
53
|
# @param approval [Symbol] :off, :require, or :auto
|
|
34
54
|
# @param require_approval [Array<String>, nil] tool names gated behind
|
|
35
55
|
# human approval when approval is :require
|
|
56
|
+
# @param host [Ask::Session::Host, nil] durable session/event host;
|
|
57
|
+
# defaults to a private in-process Host
|
|
58
|
+
# @param state_adapter [Object, nil] ask-state-providers adapter
|
|
59
|
+
# (get/set/delete) used to construct a ProviderStore-backed Host
|
|
60
|
+
# when no +host+ is given; ignored when +host+ is provided
|
|
61
|
+
# @param created_at [Time, nil] session creation time (restart
|
|
62
|
+
# resume passes the durable record's timestamp)
|
|
36
63
|
# @param session_opts [Hash] remaining options passed to
|
|
37
64
|
# Ask::Agent::Session.new (hooks, plan_mode, todos, ...)
|
|
38
65
|
def initialize(model:, tools: nil, system_prompt: nil, agent_dir: nil,
|
|
39
|
-
approval: :off, require_approval: nil,
|
|
66
|
+
approval: :off, require_approval: nil, host: nil,
|
|
67
|
+
state_adapter: nil, created_at: nil, **session_opts)
|
|
40
68
|
@model = model
|
|
41
69
|
@system_prompt = system_prompt
|
|
42
70
|
@tools = resolve_tools(tools)
|
|
@@ -44,6 +72,8 @@ module Ask
|
|
|
44
72
|
@approval = approval
|
|
45
73
|
@require_approval = require_approval
|
|
46
74
|
@agent_dir = agent_dir
|
|
75
|
+
@host = host || build_durable_host(state_adapter)
|
|
76
|
+
@durable_record = false
|
|
47
77
|
# The session's workspace is the tools' home: bash commands
|
|
48
78
|
# without an explicit cd run there (default_workdir), so the
|
|
49
79
|
# agent never drifts into the host's cwd — the recurring
|
|
@@ -57,7 +87,7 @@ module Ask
|
|
|
57
87
|
@running_mutex = Mutex.new
|
|
58
88
|
@run_thread = nil
|
|
59
89
|
@abort_requested = false
|
|
60
|
-
@created_at = Time.now
|
|
90
|
+
@created_at = created_at || Time.now
|
|
61
91
|
@logger = Logger.new($stdout, level: ENV["DEBUG"] ? Logger::DEBUG : Logger::WARN)
|
|
62
92
|
end
|
|
63
93
|
|
|
@@ -68,6 +98,8 @@ module Ask
|
|
|
68
98
|
@translator.on_event = @on_event_block if @on_event_block
|
|
69
99
|
@session = build_session
|
|
70
100
|
@session_id = @session.id
|
|
101
|
+
create_durable_record
|
|
102
|
+
@translator.on_append = method(:persist_event)
|
|
71
103
|
@session.on_event { |event| handle_agent_event(event) }
|
|
72
104
|
@translator.session_created(@session_id)
|
|
73
105
|
@session_id
|
|
@@ -79,10 +111,30 @@ module Ask
|
|
|
79
111
|
@session_id = session.id
|
|
80
112
|
@translator = EventTranslator.new
|
|
81
113
|
@translator.on_event = @on_event_block if @on_event_block
|
|
114
|
+
# Attach to the durable record when the Host already knows this
|
|
115
|
+
# session (full history stays replayable); otherwise this adapter
|
|
116
|
+
# runs translator-buffer-only, as before.
|
|
117
|
+
@durable_record = durable_record?
|
|
118
|
+
@translator.on_append = method(:persist_event) if @durable_record
|
|
82
119
|
@session.on_event { |event| handle_agent_event(event) }
|
|
83
120
|
@session_id
|
|
84
121
|
end
|
|
85
122
|
|
|
123
|
+
# Rebuild this adapter around a durable session the Host already
|
|
124
|
+
# records (restart resume): a fresh compatible ask-agent session
|
|
125
|
+
# under the same id, restored from the latest agent.snapshot when
|
|
126
|
+
# one exists, then attached through the regular {#resume} path.
|
|
127
|
+
#
|
|
128
|
+
# Model/tools/prompt configuration is intentionally not
|
|
129
|
+
# deserialized — the caller configures this adapter with its own
|
|
130
|
+
# defaults before calling.
|
|
131
|
+
def resume_from_host(session_id)
|
|
132
|
+
@session = build_session(id: session_id)
|
|
133
|
+
@session_id = session_id
|
|
134
|
+
restore_from_snapshot
|
|
135
|
+
resume(@session)
|
|
136
|
+
end
|
|
137
|
+
|
|
86
138
|
# Register an observer for every canonical event this session emits
|
|
87
139
|
# (translations plus approval/plan/session-lifecycle emissions).
|
|
88
140
|
# May be called before start_session; the block is applied when the
|
|
@@ -209,10 +261,12 @@ module Ask
|
|
|
209
261
|
|
|
210
262
|
# ── Lifecycle ───────────────────────────────────────────────────────
|
|
211
263
|
|
|
212
|
-
# Close the session: delete its state
|
|
264
|
+
# Close the session: delete its state, emit session.ended, and
|
|
265
|
+
# close the durable ask-session record.
|
|
213
266
|
def close!
|
|
214
267
|
@session&.delete if @session.respond_to?(:delete)
|
|
215
268
|
@translator&.session_ended(@session_id, reason: "closed")
|
|
269
|
+
close_durable_record
|
|
216
270
|
true
|
|
217
271
|
end
|
|
218
272
|
|
|
@@ -237,18 +291,161 @@ module Ask
|
|
|
237
291
|
end
|
|
238
292
|
|
|
239
293
|
# Events after a given sequence number.
|
|
294
|
+
#
|
|
295
|
+
# The durable ask-session log is the source of truth: replay and
|
|
296
|
+
# cursor delivery read Host#events and re-validate each record as a
|
|
297
|
+
# canonical protocol event at this boundary. Falls back to the
|
|
298
|
+
# in-memory translator buffer only when this adapter has no durable
|
|
299
|
+
# record (resume() attached to a session the Host does not know).
|
|
240
300
|
def events_after(after_seq)
|
|
241
|
-
|
|
301
|
+
after = after_seq.to_i
|
|
302
|
+
return pending_events.select { |e| e.seq > after } unless @durable_record
|
|
303
|
+
|
|
304
|
+
@host.events(@session_id, after_seq: after).filter_map { |record| wire_event(record) }
|
|
305
|
+
rescue Ask::Session::NotFoundError
|
|
306
|
+
pending_events.select { |e| e.seq > after }
|
|
242
307
|
end
|
|
243
308
|
|
|
244
309
|
private
|
|
245
310
|
|
|
246
|
-
|
|
311
|
+
# Build the durable Host from an injectable state adapter (any
|
|
312
|
+
# ask-state-providers get/set/delete backend, wrapped in
|
|
313
|
+
# ProviderStore). No adapter means the default in-process Host —
|
|
314
|
+
# durability is opt-in and no concrete backend is forced.
|
|
315
|
+
def build_durable_host(state_adapter)
|
|
316
|
+
return Ask::Session::Host.new unless state_adapter
|
|
317
|
+
|
|
318
|
+
Ask::Session::Host.new(store: Ask::Session::ProviderStore.new(adapter: state_adapter))
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Restore the conversation from the durable snapshot through
|
|
322
|
+
# Ask::Agent::SessionAdapter.resume. That adapter registers its
|
|
323
|
+
# own durable event handler as it attaches, which would append
|
|
324
|
+
# ask-agent-shaped events alongside this adapter's protocol events
|
|
325
|
+
# (double-writing the log with a second, non-wire vocabulary) —
|
|
326
|
+
# restoration is done at that point, so the handler it just added
|
|
327
|
+
# is popped off and the EventTranslator stays the single protocol
|
|
328
|
+
# writer.
|
|
329
|
+
def restore_from_snapshot
|
|
330
|
+
return false unless durable_snapshot?
|
|
331
|
+
|
|
332
|
+
Ask::Agent::SessionAdapter.resume(agent: @session, host: @host, session_id: @session_id)
|
|
333
|
+
handlers = @session.instance_variable_get(:@event_handlers)
|
|
334
|
+
handlers[:all].pop if handlers
|
|
335
|
+
true
|
|
336
|
+
rescue Ask::Agent::SessionAdapter::Error => e
|
|
337
|
+
@logger.debug("Snapshot restore skipped for #{@session_id}: #{e.message}")
|
|
338
|
+
false
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Whether the durable Host holds a restart-resume snapshot for
|
|
342
|
+
# this session.
|
|
343
|
+
def durable_snapshot?
|
|
344
|
+
return false unless @host && @session_id
|
|
345
|
+
|
|
346
|
+
@host.events(@session_id).any? { |e| e.type == Ask::Agent::SessionAdapter::SNAPSHOT_TYPE }
|
|
347
|
+
rescue Ask::Session::NotFoundError
|
|
348
|
+
false
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Create the durable ask-session record. The Host's own
|
|
352
|
+
# session.created event is the session's first durable event; the
|
|
353
|
+
# translator's session.created (identical wire shape, seq 1) is
|
|
354
|
+
# served from it via #wire_event instead of being re-appended.
|
|
355
|
+
def create_durable_record
|
|
356
|
+
@host.create(id: @session_id, metadata: { model: @model })
|
|
357
|
+
@durable_record = true
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Whether the Host already holds a record for this session.
|
|
361
|
+
def durable_record?
|
|
362
|
+
return false unless @host && @session_id
|
|
363
|
+
|
|
364
|
+
@host.session(@session_id)
|
|
365
|
+
true
|
|
366
|
+
rescue Ask::Session::NotFoundError
|
|
367
|
+
false
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Append a canonical protocol event to the durable Host. The Host is
|
|
371
|
+
# a dumb log: protocol vocabulary enters and leaves at this
|
|
372
|
+
# boundary. Host-owned lifecycle events are skipped (see
|
|
373
|
+
# HOST_OWNED_EVENT_TYPES). Failures never break live delivery — a
|
|
374
|
+
# close racing a run just stops receiving durable appends.
|
|
375
|
+
def persist_event(event)
|
|
376
|
+
return unless @durable_record
|
|
377
|
+
return if HOST_OWNED_EVENT_TYPES.include?(event.type)
|
|
378
|
+
|
|
379
|
+
@host.append(@session_id, type: event.type, payload: event.payload)
|
|
380
|
+
rescue StandardError => e
|
|
381
|
+
@logger.debug("Durable append failed for #{event.type}: #{e.message}")
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Close the durable record (Host writes session.ended itself).
|
|
385
|
+
# Already-closed/aborted records are fine to ignore.
|
|
386
|
+
def close_durable_record
|
|
387
|
+
return unless @durable_record
|
|
388
|
+
|
|
389
|
+
@host.close(@session_id, reason: "closed")
|
|
390
|
+
rescue Ask::Session::Error
|
|
391
|
+
nil
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Append the end-of-run snapshot the restart-resume path restores
|
|
395
|
+
# through Ask::Agent::SessionAdapter.resume (same payload shape:
|
|
396
|
+
# messages + turn_count). Written only after a clean run — an
|
|
397
|
+
# aborted or failed turn leaves no half-restorable state. The Host
|
|
398
|
+
# keeps it; wire_event filters it out of protocol replay (it is
|
|
399
|
+
# not a protocol vocabulary type). Failures never break the live
|
|
400
|
+
# turn.
|
|
401
|
+
def persist_snapshot
|
|
402
|
+
return unless @durable_record
|
|
403
|
+
return unless @session.respond_to?(:chat) && @session.respond_to?(:turn_count)
|
|
404
|
+
|
|
405
|
+
@host.append(
|
|
406
|
+
@session_id,
|
|
407
|
+
type: Ask::Agent::SessionAdapter::SNAPSHOT_TYPE,
|
|
408
|
+
payload: {
|
|
409
|
+
messages: @session.chat.messages.map(&:to_h),
|
|
410
|
+
turn_count: @session.turn_count || 0
|
|
411
|
+
}
|
|
412
|
+
)
|
|
413
|
+
rescue StandardError => e
|
|
414
|
+
@logger.debug("Durable snapshot failed for #{@session_id}: #{e.message}")
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Rebuild a durable Host record as a canonical wire event.
|
|
418
|
+
# Returns nil for records outside the protocol vocabulary (e.g.
|
|
419
|
+
# ask-session-internal types), which never reach clients.
|
|
420
|
+
def wire_event(record)
|
|
421
|
+
return nil unless Ask::SessionProtocol::Events.known?(record.type)
|
|
422
|
+
|
|
423
|
+
payload =
|
|
424
|
+
case record.type
|
|
425
|
+
when "session.created"
|
|
426
|
+
{ "sessionId" => record.session_id }
|
|
427
|
+
when "session.ended"
|
|
428
|
+
reason = record.payload[:reason] || record.payload["reason"] || "closed"
|
|
429
|
+
{ "sessionId" => record.session_id, "reason" => reason.to_s }
|
|
430
|
+
else
|
|
431
|
+
# Host Event rehydration (and the durable ProviderStore JSON
|
|
432
|
+
# round-trip) symbolizes payload keys; the wire contract is
|
|
433
|
+
# string-keyed. Normalize here, at the protocol boundary.
|
|
434
|
+
record.payload.transform_keys(&:to_s)
|
|
435
|
+
end
|
|
436
|
+
Ask::SessionProtocol::Events.event(type: record.type, seq: record.seq, payload: payload)
|
|
437
|
+
rescue ArgumentError => e
|
|
438
|
+
@logger.debug("Skipping non-wire durable event #{record.type}: #{e.message}")
|
|
439
|
+
nil
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def build_session(id: nil)
|
|
247
443
|
opts = @session_opts.dup
|
|
248
444
|
hooks = opts.delete(:hooks) || {}
|
|
249
445
|
approval_option = build_approval_option
|
|
250
446
|
opts[:approval] = approval_option if approval_option
|
|
251
447
|
opts[:plan_mode] = false unless opts.key?(:plan_mode)
|
|
448
|
+
opts[:id] = id if id
|
|
252
449
|
|
|
253
450
|
Ask::Agent::Session.new(
|
|
254
451
|
model: @model,
|
|
@@ -291,6 +488,7 @@ module Ask
|
|
|
291
488
|
end
|
|
292
489
|
|
|
293
490
|
@run_thread = Thread.new do
|
|
491
|
+
failure = nil
|
|
294
492
|
begin
|
|
295
493
|
@session.run(content, reset: false)
|
|
296
494
|
# Drain steers queued while the turn was running (mid-execution
|
|
@@ -303,23 +501,36 @@ module Ask
|
|
|
303
501
|
# mid-turn (a dead connection, a provider that closed the
|
|
304
502
|
# stream early). Without this, the client would wait on a
|
|
305
503
|
# ghost run forever. Surface it as a failure so the fix
|
|
306
|
-
# loop can redeliver.
|
|
504
|
+
# loop can redeliver. No snapshot in that case: the turn's
|
|
505
|
+
# conversation state is incomplete.
|
|
307
506
|
if @translator.turn_active?
|
|
308
507
|
@logger.error("Turn ended without a completion event — the model stream dropped mid-turn")
|
|
309
|
-
|
|
508
|
+
failure = "The model stream ended without completing the turn (the connection dropped mid-stream)"
|
|
509
|
+
else
|
|
510
|
+
persist_snapshot
|
|
310
511
|
end
|
|
311
512
|
rescue => e
|
|
312
513
|
# An aborted turn raises Ask::Agent::Aborted — that's not a
|
|
313
|
-
# failure, the client asked for it. Everything else is a
|
|
314
|
-
# run failure the client must see
|
|
315
|
-
#
|
|
514
|
+
# failure, the client asked for it. Everything else is a
|
|
515
|
+
# run failure the client must see — a raised run, or a
|
|
516
|
+
# disconnect from the model (EOF, ECONNRESET, a closed
|
|
517
|
+
# socket) surfaced as an exception: without a turn.failed,
|
|
518
|
+
# a watching board would wait forever on a dead turn. The
|
|
316
519
|
# translator's failure event carries the message.
|
|
317
520
|
unless e.is_a?(Ask::Agent::Aborted) || e.class.name.to_s.include?("Aborted")
|
|
318
521
|
@logger.error("Agent run failed: #{e.class}: #{e.message}")
|
|
319
|
-
|
|
522
|
+
message = e.message.to_s
|
|
523
|
+
message = e.class.name if message.strip.empty?
|
|
524
|
+
failure = message[0, 500]
|
|
320
525
|
end
|
|
321
526
|
ensure
|
|
527
|
+
# Settle the run before the terminal event goes out: a
|
|
528
|
+
# watcher woken by turn.failed (an observer, the pane
|
|
529
|
+
# reporter) must see the run as finished, not a ghost
|
|
530
|
+
# "working" it will never see corrected — no state event
|
|
531
|
+
# fires after this point.
|
|
322
532
|
@running_mutex.synchronize { @running = false }
|
|
533
|
+
emit_failure(failure) if failure
|
|
323
534
|
@logger.debug("Run thread ended: turn_active=#{@translator.turn_active?} last_seq=#{@translator.last_seq}")
|
|
324
535
|
end
|
|
325
536
|
end
|
|
@@ -327,6 +538,17 @@ module Ask
|
|
|
327
538
|
true
|
|
328
539
|
end
|
|
329
540
|
|
|
541
|
+
# Wake the watchers: translate the run's terminal failure into a
|
|
542
|
+
# turn.failed event. Never raises — if emission itself failed, a
|
|
543
|
+
# raised error here would kill the run thread after the fact and
|
|
544
|
+
# leave the turn marked active with no terminal event, hanging
|
|
545
|
+
# every watcher this method exists to wake.
|
|
546
|
+
def emit_failure(message)
|
|
547
|
+
@translator.turn_failed(message)
|
|
548
|
+
rescue StandardError => e
|
|
549
|
+
@logger.error("Failed to emit turn.failed: #{e.class}: #{e.message}")
|
|
550
|
+
end
|
|
551
|
+
|
|
330
552
|
def handle_agent_event(event)
|
|
331
553
|
@translator.translate(event)
|
|
332
554
|
end
|
|
@@ -31,6 +31,13 @@ module Ask
|
|
|
31
31
|
# side effects (e.g. the pane reporter) hook here.
|
|
32
32
|
attr_accessor :on_event
|
|
33
33
|
|
|
34
|
+
# Optional observer called with every emitted canonical Event after
|
|
35
|
+
# buffering — the persistence hook the adapter uses to append the
|
|
36
|
+
# event to the durable ask-session Host. Separate from +on_event+
|
|
37
|
+
# (a single slot) so live observers and durable appends never
|
|
38
|
+
# clobber each other.
|
|
39
|
+
attr_accessor :on_append
|
|
40
|
+
|
|
34
41
|
def initialize
|
|
35
42
|
@events = []
|
|
36
43
|
@seq = 0
|
|
@@ -151,12 +158,20 @@ module Ask
|
|
|
151
158
|
end
|
|
152
159
|
|
|
153
160
|
# Emit a turn.failed event. Called by the adapter when a run dies
|
|
154
|
-
# (an exception, or a stream that ended without
|
|
161
|
+
# (an exception, a disconnect, or a stream that ended without
|
|
162
|
+
# completing).
|
|
163
|
+
#
|
|
164
|
+
# The protocol requires turnId, so the payload always carries
|
|
165
|
+
# identity: the active turn's id when the turn announced itself,
|
|
166
|
+
# a fresh one when the run died before TurnStart (or after the
|
|
167
|
+
# previous turn ended — that id is spent and must not be reused).
|
|
168
|
+
# Emitting without one would raise at the protocol boundary, the
|
|
169
|
+
# event would never reach the buffer, and every watcher of the
|
|
170
|
+
# dead run would wait forever.
|
|
155
171
|
def turn_failed(message)
|
|
172
|
+
@turn_id = SecureRandom.uuid unless @turn_active && @turn_id
|
|
156
173
|
@turn_active = false
|
|
157
|
-
|
|
158
|
-
payload["turnId"] = @turn_id if @turn_id
|
|
159
|
-
emit("turn.failed", payload)
|
|
174
|
+
emit("turn.failed", { "error" => message.to_s, "turnId" => @turn_id })
|
|
160
175
|
end
|
|
161
176
|
|
|
162
177
|
private
|
|
@@ -260,6 +275,7 @@ module Ask
|
|
|
260
275
|
@events << event
|
|
261
276
|
@events.shift if @events.size > MAX_EVENTS
|
|
262
277
|
on_event&.call(event)
|
|
278
|
+
on_append&.call(event)
|
|
263
279
|
[event]
|
|
264
280
|
end
|
|
265
281
|
end
|
|
@@ -193,23 +193,32 @@ module Ask
|
|
|
193
193
|
# several sessions, and the client routes events to the right
|
|
194
194
|
# watcher — without it, a multi-session client would record one
|
|
195
195
|
# session's events into another's run.
|
|
196
|
+
#
|
|
197
|
+
# Delivery is isolated per connection: a watcher whose socket died
|
|
198
|
+
# mid-write (EPIPE/ECONNRESET) must not abort the pass and starve
|
|
199
|
+
# the remaining watchers of their terminal events. The failed
|
|
200
|
+
# connection's cursor does not advance, so undelivered events
|
|
201
|
+
# redeliver on the next pass (clients dedup by seq); its reader
|
|
202
|
+
# thread reaps the dead connection on EOF.
|
|
196
203
|
def push_pending
|
|
197
204
|
connections.each do |connection|
|
|
198
205
|
connection.subscriptions.keys.each do |session_id|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
206
|
+
begin
|
|
207
|
+
adapter = @session_manager.get(session_id)
|
|
208
|
+
next unless adapter
|
|
209
|
+
|
|
210
|
+
events = adapter.events_after(connection.cursor(session_id))
|
|
211
|
+
next if events.empty?
|
|
212
|
+
|
|
213
|
+
events.each do |ev|
|
|
214
|
+
connection.write({ method: "session/event", params: { sessionId: session_id, event: ev.to_h } })
|
|
215
|
+
end
|
|
216
|
+
connection.advance(session_id, events.last.seq)
|
|
217
|
+
rescue => e
|
|
218
|
+
@logger.debug("Push error: #{e.message}") if ENV["DEBUG"]
|
|
207
219
|
end
|
|
208
|
-
connection.advance(session_id, events.last.seq)
|
|
209
220
|
end
|
|
210
221
|
end
|
|
211
|
-
rescue => e
|
|
212
|
-
@logger.debug("Push error: #{e.message}") if ENV["DEBUG"]
|
|
213
222
|
end
|
|
214
223
|
|
|
215
224
|
# ── Response/notification helpers ──────────────────────────────────
|
|
@@ -347,13 +356,15 @@ module Ask
|
|
|
347
356
|
{ sessions: sessions }
|
|
348
357
|
end
|
|
349
358
|
|
|
350
|
-
# Session: resume
|
|
359
|
+
# Session: resume — the live adapter when this process holds it,
|
|
360
|
+
# otherwise a durable restart resume: rebuild a fresh agent
|
|
361
|
+
# session from the Host (restored from its snapshot when one
|
|
362
|
+
# exists) and attach it to the live registry.
|
|
351
363
|
handler("session/resume") do |params, _id|
|
|
352
364
|
session_id = params["sessionId"] || params[:sessionId]
|
|
353
365
|
raise InvalidRequest, "sessionId is required" unless session_id
|
|
354
366
|
|
|
355
|
-
adapter = @session_manager.
|
|
356
|
-
raise Ask::AppServer::SessionNotFound, "Session #{session_id} not found" unless adapter
|
|
367
|
+
adapter = @session_manager.resume_session(session_id)
|
|
357
368
|
|
|
358
369
|
{
|
|
359
370
|
sessionId: session_id,
|
|
@@ -8,6 +8,14 @@ module Ask
|
|
|
8
8
|
# Orchestrates creation, resumption, subscription, messaging, aborting,
|
|
9
9
|
# interaction resolution (approvals, plan), closing, and event polling
|
|
10
10
|
# across AgentAdapter instances stored in SessionStore.
|
|
11
|
+
#
|
|
12
|
+
# Owns the durable ask-session layer: one Ask::Session::Host shared by
|
|
13
|
+
# every session (injectable via +host+, or built from an injectable
|
|
14
|
+
# +state_adapter+ via ProviderStore), which is the event source of
|
|
15
|
+
# truth for replay. SessionStore keeps live adapter registry and
|
|
16
|
+
# subscription state only. Resume prefers the live adapter; when the
|
|
17
|
+
# live registry does not know the session it rebuilds one from the
|
|
18
|
+
# durable Host ({#resume_session}).
|
|
11
19
|
class SessionManager
|
|
12
20
|
# Default tools if none specified.
|
|
13
21
|
DEFAULT_TOOLS = %w[bash read write edit glob grep].freeze
|
|
@@ -19,12 +27,14 @@ module Ask
|
|
|
19
27
|
DEFAULT_REQUIRE_APPROVAL = %w[write edit bash destroy].freeze
|
|
20
28
|
|
|
21
29
|
attr_reader :store
|
|
30
|
+
attr_reader :host
|
|
22
31
|
attr_reader :permission_mode
|
|
23
32
|
attr_reader :blocked_tools
|
|
24
33
|
attr_reader :permission_timeout
|
|
25
34
|
|
|
26
|
-
def initialize(store: nil, permission_mode: :on_request, blocked_tools: nil, permission_timeout: 300)
|
|
27
|
-
@store = store || SessionStore.new
|
|
35
|
+
def initialize(store: nil, host: nil, state_adapter: nil, permission_mode: :on_request, blocked_tools: nil, permission_timeout: 300)
|
|
36
|
+
@store = store || SessionStore.new(state: state_adapter)
|
|
37
|
+
@host = host || build_host(state_adapter)
|
|
28
38
|
@permission_mode = permission_mode
|
|
29
39
|
@blocked_tools = (blocked_tools || DEFAULT_REQUIRE_APPROVAL).map(&:to_s)
|
|
30
40
|
@permission_timeout = permission_timeout
|
|
@@ -60,8 +70,6 @@ module Ask
|
|
|
60
70
|
def create_session(workspace_path: nil, mode: nil, model: nil, tools: nil, system_prompt: nil)
|
|
61
71
|
approval_opts, plan_mode = resolve_approval(mode)
|
|
62
72
|
|
|
63
|
-
# Extract provider prefix from model string (e.g., "opencode_go/deepseek-v4-flash")
|
|
64
|
-
model_id, model_provider = parse_model_string(model || DEFAULT_MODEL)
|
|
65
73
|
# A provider-qualified model must stay resolvable: the agent's
|
|
66
74
|
# model catalog looks up bare ids, so the prefixed model is
|
|
67
75
|
# registered under its provider before the session builds its
|
|
@@ -69,8 +77,7 @@ module Ask
|
|
|
69
77
|
# bare "deepseek-v4-flash" which the catalog may not know — the
|
|
70
78
|
# session falls back to the wrong provider and the run hangs
|
|
71
79
|
# silently on a missing credential.
|
|
72
|
-
|
|
73
|
-
model_for_agent = model_provider ? model_id : (model || DEFAULT_MODEL)
|
|
80
|
+
model_for_agent = resolve_agent_model(model)
|
|
74
81
|
|
|
75
82
|
adapter = AgentAdapter.new(
|
|
76
83
|
model: model_for_agent,
|
|
@@ -79,7 +86,8 @@ module Ask
|
|
|
79
86
|
agent_dir: workspace_path,
|
|
80
87
|
approval: approval_opts[:mode],
|
|
81
88
|
require_approval: approval_opts[:require_approval],
|
|
82
|
-
plan_mode: plan_mode
|
|
89
|
+
plan_mode: plan_mode,
|
|
90
|
+
host: @host
|
|
83
91
|
)
|
|
84
92
|
|
|
85
93
|
session_id = adapter.start_session
|
|
@@ -97,6 +105,49 @@ module Ask
|
|
|
97
105
|
session_id
|
|
98
106
|
end
|
|
99
107
|
|
|
108
|
+
# Resume a session: the live adapter when this process still holds
|
|
109
|
+
# it (in-process resume, unchanged), otherwise rebuild it from the
|
|
110
|
+
# durable ask-session Host — a fresh compatible agent session under
|
|
111
|
+
# the same id, restored from its agent.snapshot through
|
|
112
|
+
# Ask::Agent::SessionAdapter.resume when the Host holds one.
|
|
113
|
+
#
|
|
114
|
+
# Model/tool/prompt configuration is not deserialized across
|
|
115
|
+
# restarts: the rebuilt adapter uses this manager's configured
|
|
116
|
+
# defaults.
|
|
117
|
+
#
|
|
118
|
+
# @return [AgentAdapter]
|
|
119
|
+
# @raise [SessionNotFound] when neither the live registry nor the
|
|
120
|
+
# durable Host knows the session, or its durable record is
|
|
121
|
+
# terminal (closed/aborted)
|
|
122
|
+
def resume_session(session_id)
|
|
123
|
+
adapter = @store.get(session_id)
|
|
124
|
+
return adapter if adapter
|
|
125
|
+
|
|
126
|
+
record = durable_record(session_id)
|
|
127
|
+
raise Ask::AppServer::SessionNotFound, "Session #{session_id} not found" unless record
|
|
128
|
+
unless record.status == :active
|
|
129
|
+
raise Ask::AppServer::SessionNotFound, "Session #{session_id} is not active"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
approval_opts, plan_mode = resolve_approval(nil)
|
|
133
|
+
adapter = AgentAdapter.new(
|
|
134
|
+
model: resolve_agent_model(nil),
|
|
135
|
+
tools: DEFAULT_TOOLS,
|
|
136
|
+
system_prompt: build_default_system_prompt(nil),
|
|
137
|
+
approval: approval_opts[:mode],
|
|
138
|
+
require_approval: approval_opts[:require_approval],
|
|
139
|
+
plan_mode: plan_mode,
|
|
140
|
+
host: @host,
|
|
141
|
+
created_at: record.created_at
|
|
142
|
+
)
|
|
143
|
+
adapter.resume_from_host(session_id)
|
|
144
|
+
@store.add(session_id, adapter)
|
|
145
|
+
|
|
146
|
+
adapter.on_event { |event| notify_session_event(adapter.session_id, event) }
|
|
147
|
+
@logger.info("Resumed session #{session_id} from the durable host")
|
|
148
|
+
adapter
|
|
149
|
+
end
|
|
150
|
+
|
|
100
151
|
# Remove a session.
|
|
101
152
|
def destroy_session(session_id)
|
|
102
153
|
adapter = @store.get(session_id)
|
|
@@ -271,6 +322,33 @@ module Ask
|
|
|
271
322
|
|
|
272
323
|
private
|
|
273
324
|
|
|
325
|
+
# Build the durable Host. With an injectable state adapter (any
|
|
326
|
+
# ask-state-providers get/set/delete backend) the Host is backed
|
|
327
|
+
# by ProviderStore, so records, events, and snapshots survive
|
|
328
|
+
# restarts; without one the default in-memory Host is unchanged.
|
|
329
|
+
def build_host(state_adapter)
|
|
330
|
+
return Ask::Session::Host.new unless state_adapter
|
|
331
|
+
|
|
332
|
+
Ask::Session::Host.new(store: Ask::Session::ProviderStore.new(adapter: state_adapter))
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# The durable ask-session record, or nil when the Host does not
|
|
336
|
+
# know the session.
|
|
337
|
+
def durable_record(session_id)
|
|
338
|
+
@host.session(session_id)
|
|
339
|
+
rescue Ask::Session::NotFoundError
|
|
340
|
+
nil
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Resolve the configured model for a new agent session: parse an
|
|
344
|
+
# optional provider prefix and register it in the catalog so the
|
|
345
|
+
# bare id stays resolvable (see create_session).
|
|
346
|
+
def resolve_agent_model(model)
|
|
347
|
+
model_id, model_provider = parse_model_string(model || DEFAULT_MODEL)
|
|
348
|
+
register_prefixed_model(model_id, model_provider) if model_provider
|
|
349
|
+
model_provider ? model_id : (model || DEFAULT_MODEL)
|
|
350
|
+
end
|
|
351
|
+
|
|
274
352
|
# Resolve the session mode into approval options + plan mode.
|
|
275
353
|
#
|
|
276
354
|
# @return [Array(Hash, Boolean)] [{ mode:, require_approval: }, plan_mode]
|
|
@@ -40,10 +40,12 @@ module Ask
|
|
|
40
40
|
|
|
41
41
|
@mutex.synchronize do
|
|
42
42
|
existing = @state.get("#{SESSION_PREFIX}#{session_id}")
|
|
43
|
-
|
|
43
|
+
if existing && @adapters.key?(session_id)
|
|
44
|
+
raise Ask::AppServer::SessionAlreadyExists, "Session #{session_id} already exists"
|
|
45
|
+
end
|
|
44
46
|
|
|
45
47
|
@state.set("#{SESSION_PREFIX}#{session_id}", metadata)
|
|
46
|
-
@state.list_append(SESSION_LIST_KEY, session_id, max_length: 200)
|
|
48
|
+
@state.list_append(SESSION_LIST_KEY, session_id, max_length: 200) unless existing
|
|
47
49
|
@adapters[session_id] = adapter
|
|
48
50
|
end
|
|
49
51
|
end
|
|
@@ -75,7 +77,7 @@ module Ask
|
|
|
75
77
|
metadata = @state.get("#{SESSION_PREFIX}#{sid}")
|
|
76
78
|
if metadata
|
|
77
79
|
adapter = @adapters[sid]
|
|
78
|
-
metadata.merge(
|
|
80
|
+
metadata.transform_keys(&:to_sym).merge(
|
|
79
81
|
running: adapter&.running || false,
|
|
80
82
|
idle: adapter&.idle? || true
|
|
81
83
|
)
|
data/lib/ask-app-server.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ask-app-server
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.4.
|
|
4
|
+
version: 0.4.22
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: 0.40.
|
|
18
|
+
version: 0.40.22
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: 0.40.
|
|
25
|
+
version: 0.40.22
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: ask-core
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -37,6 +37,20 @@ dependencies:
|
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: 0.12.0
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: ask-session
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: 0.1.0
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: 0.1.0
|
|
40
54
|
- !ruby/object:Gem::Dependency
|
|
41
55
|
name: ask-session-protocol
|
|
42
56
|
requirement: !ruby/object:Gem::Requirement
|