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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8da88a56836df0e35058cf843c44e1ea6c487879ae3a2e60d972bdc1dd2df688
4
- data.tar.gz: 19952aeaece5d94114c2f761a39dd89e2a362eda6e35321f402b0425a5747fc3
3
+ metadata.gz: a798da52caa5bd806b1b44c6ffc698f3f8c6eda35b46c8106a47ca5858c3dc96
4
+ data.tar.gz: 8dfaa77e96b1b3e181010cfa691b347ef04f1ff77ae4f8975e769f9d269e3a90
5
5
  SHA512:
6
- metadata.gz: 5a319040bb32da4ca8ea567b84539f6544ea3ea99b7bb4281ad4b1314375d9106904f2d8d44f248c47a8d3a2aa3f44bfb4401ad970f59b96d63cff1672b6a352
7
- data.tar.gz: 5a497e8d9f889312763a57b7ce313677dd0548013ed8bb2a6e58c03ca76c8dfd16c0bd24550ed4cf6610999564f6b7dccbae6acc34089f62c86031c59f3c4669
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, **session_opts)
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 and emit session.ended.
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
- pending_events.select { |e| e.seq > after_seq }
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
- def build_session
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
- @translator.turn_failed("The model stream ended without completing the turn (the connection dropped mid-stream)")
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 real
314
- # run failure the client must see: without a turn.failed, a
315
- # watching board would wait forever on a dead turn. The
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
- @translator.turn_failed(e.message.to_s[0, 500])
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 completing).
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
- payload = { "error" => message.to_s }
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
- adapter = @session_manager.get(session_id)
200
- next unless adapter
201
-
202
- events = adapter.events_after(connection.cursor(session_id))
203
- next if events.empty?
204
-
205
- events.each do |ev|
206
- connection.write({ method: "session/event", params: { sessionId: session_id, event: ev.to_h } })
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.get(session_id)
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
- register_prefixed_model(model_id, model_provider) if model_provider
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
- raise Ask::AppServer::SessionAlreadyExists, "Session #{session_id} already exists" if existing
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
  )
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module AppServer
5
- VERSION = "0.4.20"
5
+ VERSION = "0.4.22"
6
6
  end
7
7
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ask/agent"
4
+ require "ask-session"
4
5
  require "ask-session-protocol"
5
6
  require "ask-tools-shell"
6
7
 
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.20
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.19
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.19
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