samagotchi 0.91.0 → 0.93.0

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.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -1
  3. data/README.md +5 -2
  4. data/bin/chi +8 -8
  5. data/docs/architecture.md +20 -16
  6. data/docs/bundle-authoring.md +2 -1
  7. data/docs/cli.md +5 -5
  8. data/docs/configuration.md +3 -1
  9. data/docs/context.md +5 -3
  10. data/docs/guardrails.md +4 -5
  11. data/docs/hooks.md +3 -3
  12. data/docs/internals/background-tasks.md +2 -2
  13. data/docs/internals/bundles.md +4 -4
  14. data/docs/internals/engine.md +27 -8
  15. data/docs/internals/tool-guardrails.md +10 -2
  16. data/docs/memory.md +6 -0
  17. data/docs/plugins.md +30 -13
  18. data/docs/releasing.md +3 -0
  19. data/docs/sessions.md +4 -4
  20. data/docs/testing.md +2 -1
  21. data/lib/samagotchi/bootstrap/probe.rb +2 -2
  22. data/lib/samagotchi/bridge.rb +8 -9
  23. data/lib/samagotchi/bundle_command/check.rb +43 -12
  24. data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
  25. data/lib/samagotchi/bundle_command/test_run.rb +12 -0
  26. data/lib/samagotchi/bundle_command/try/command.rb +164 -0
  27. data/lib/samagotchi/bundle_command/try/render.rb +80 -0
  28. data/lib/samagotchi/bundle_command/try/run.rb +35 -17
  29. data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
  30. data/lib/samagotchi/bundle_command/try/trace.rb +20 -26
  31. data/lib/samagotchi/bundle_command.rb +11 -213
  32. data/lib/samagotchi/bundle_session_settings.rb +82 -45
  33. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  34. data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
  35. data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
  36. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  37. data/lib/samagotchi/child_env.rb +50 -0
  38. data/lib/samagotchi/child_reports.rb +12 -14
  39. data/lib/samagotchi/client_id.rb +18 -0
  40. data/lib/samagotchi/config/restart_watch.rb +112 -0
  41. data/lib/samagotchi/config.rb +18 -59
  42. data/lib/samagotchi/engine.rb +26 -26
  43. data/lib/samagotchi/guardrails/data_readers.rb +34 -30
  44. data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
  45. data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
  46. data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
  47. data/lib/samagotchi/hook_question.rb +21 -0
  48. data/lib/samagotchi/hook_routes.rb +6 -6
  49. data/lib/samagotchi/hooks/registry.rb +7 -5
  50. data/lib/samagotchi/host_registry.rb +1 -1
  51. data/lib/samagotchi/kernel_loop.rb +14 -3
  52. data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
  53. data/lib/samagotchi/llm/chat_loop.rb +3 -1
  54. data/lib/samagotchi/llm/errors.rb +4 -4
  55. data/lib/samagotchi/memory_bundle/builder.rb +3 -2
  56. data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
  57. data/lib/samagotchi/memory_bundle/installer.rb +1 -0
  58. data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
  59. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  60. data/lib/samagotchi/parent_answers_command.rb +6 -6
  61. data/lib/samagotchi/parent_report.rb +12 -0
  62. data/lib/samagotchi/plugin/context.rb +6 -5
  63. data/lib/samagotchi/plugin/http_transport.rb +14 -3
  64. data/lib/samagotchi/plugin/sessions.rb +2 -3
  65. data/lib/samagotchi/question_desk.rb +12 -9
  66. data/lib/samagotchi/reply_wait/poll.rb +267 -0
  67. data/lib/samagotchi/reply_wait.rb +51 -190
  68. data/lib/samagotchi/sandbox_layout.rb +66 -0
  69. data/lib/samagotchi/send_command/parent_gate.rb +83 -0
  70. data/lib/samagotchi/send_command/wait_target.rb +55 -0
  71. data/lib/samagotchi/send_command.rb +34 -103
  72. data/lib/samagotchi/session.rb +10 -9
  73. data/lib/samagotchi/session_inbox.rb +31 -8
  74. data/lib/samagotchi/session_manager.rb +60 -35
  75. data/lib/samagotchi/session_metrics.rb +22 -3
  76. data/lib/samagotchi/sessions_command.rb +4 -4
  77. data/lib/samagotchi/task_watch.rb +64 -21
  78. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  79. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  80. data/lib/samagotchi/testing/bundle.rb +33 -20
  81. data/lib/samagotchi/testing/host.rb +2 -8
  82. data/lib/samagotchi/testing/install.rb +26 -17
  83. data/lib/samagotchi/testing/recorder.rb +6 -9
  84. data/lib/samagotchi/testing/sandbox.rb +45 -37
  85. data/lib/samagotchi/testing/waiting.rb +5 -7
  86. data/lib/samagotchi/tool_response.rb +28 -6
  87. data/lib/samagotchi/tools/delegate.rb +10 -10
  88. data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
  89. data/lib/samagotchi/tools/delegate_relay.rb +31 -16
  90. data/lib/samagotchi/tools/delegate_wait.rb +4 -4
  91. data/lib/samagotchi/tools/task_runtime.rb +2 -2
  92. data/lib/samagotchi/turn_checkpoint.rb +23 -10
  93. data/lib/samagotchi/version.rb +1 -1
  94. data/lib/samagotchi/web/app.rb +46 -63
  95. data/lib/samagotchi/web/public/app.js +130 -554
  96. data/lib/samagotchi/web/public/cards_place.js +4 -1
  97. data/lib/samagotchi/web/public/images.js +6 -0
  98. data/lib/samagotchi/web/public/index.html +1 -1
  99. data/lib/samagotchi/web/public/model_pick.js +10 -0
  100. data/lib/samagotchi/web/public/send_turn.js +173 -0
  101. data/lib/samagotchi/web/public/session_actions.js +203 -0
  102. data/lib/samagotchi/web/public/start_page.js +236 -0
  103. data/lib/samagotchi/web/public/toasts.js +17 -9
  104. data/lib/samagotchi/web/public/unqueue.js +20 -4
  105. data/lib/samagotchi/worker.rb +77 -185
  106. data/lib/samagotchi/worker_idle_exit.rb +20 -5
  107. data/lib/samagotchi/worker_inbound.rb +5 -5
  108. data/lib/samagotchi/worker_task_wakes.rb +160 -0
  109. data/lib/samagotchi/worker_wakes.rb +0 -3
  110. metadata +15 -1
@@ -6,6 +6,7 @@ require "json"
6
6
  require_relative "cli/exit"
7
7
  require_relative "pending_question"
8
8
  require_relative "session"
9
+ require_relative "turn_checkpoint"
9
10
  require_relative "guardrails/parent_policy"
10
11
 
11
12
  module Samagotchi
@@ -339,6 +340,8 @@ module Samagotchi
339
340
  end
340
341
 
341
342
  def no_reply_line(result, session_id = nil, strategy: nil)
343
+ return command_line(result) if result.origin == TurnCheckpoint::COMMAND_ORIGIN
344
+
342
345
  case result.outcome
343
346
  when "failed" then failed_line(result)
344
347
  when "canceled"
@@ -360,6 +363,15 @@ module Samagotchi
360
363
  end
361
364
  end
362
365
 
366
+ # A file-delivered session command's outcome: no turn ran for it.
367
+ def command_line(result)
368
+ case result.outcome
369
+ when "completed" then "the command ran (no turn)"
370
+ when "failed" then "the command failed"
371
+ else "the command was refused (#{result.cancel_reason || "canceled"})"
372
+ end
373
+ end
374
+
363
375
  # A failed turn's line; one whose work stayed says so, so the task isn't
364
376
  # sent again over what it changed.
365
377
  def failed_line(result)
@@ -5,6 +5,7 @@ require_relative "../log"
5
5
  require_relative "../paths"
6
6
  require_relative "../session_metrics/cost"
7
7
  require_relative "../guardrails/context"
8
+ require_relative "../hook_question"
8
9
  require_relative "../idle_client"
9
10
  require_relative "service"
10
11
  require_relative "side_question"
@@ -28,8 +29,8 @@ module Samagotchi
28
29
 
29
30
  # The Engine's side of a Context: callables, so the Engine itself is
30
31
  # never handed out. +messages+ returns the conversation, +notify+ takes
31
- # (text, level, label, fallback_for:), +ask_user+ takes (question:, options:, header:,
32
- # allow_freeform:, hook:, and effects: when the plugin gave them) like the hook runtime's, +card+ takes
32
+ # (text, level, label, fallback_for:), +ask_user+ takes a HookQuestion
33
+ # (the plugin's label in it) like the hook runtime's, +card+ takes
33
34
  # Engine#show_card's keywords and returns the id, +ask_model+ takes
34
35
  # (chat messages, timeout:, max_tokens:, cancel_controller:, model:: a
35
36
  # configured model ref, nil for the session's) and returns the answer
@@ -363,13 +364,13 @@ module Samagotchi
363
364
  # @return [Hash, nil] {selected:, freeform:, selected_indices:}, or nil
364
365
  # (no one to ask, cancelled, bad options or effects)
365
366
  def ask_user(question:, options:, header: nil, allow_freeform: false, effects: nil)
366
- tags = effects.nil? ? {} : { effects: effects }
367
367
  if (helper = event_helper(:ask_user))
368
+ tags = effects.nil? ? {} : { effects: effects }
368
369
  return helper.call(question: question, options: options, header: header, allow_freeform: allow_freeform, **tags)
369
370
  end
370
371
 
371
- @host.ask_user.call(question: question, options: options, header: header, allow_freeform: allow_freeform,
372
- hook: @label, **tags)
372
+ @host.ask_user.call(HookQuestion.new(question: question, options: options, header: header,
373
+ allow_freeform: allow_freeform, hook: @label, effects: effects))
373
374
  end
374
375
 
375
376
  # Whether the running turn was cancelled (a long tool should stop).
@@ -12,8 +12,10 @@ module Samagotchi
12
12
  class Http
13
13
  # Plugin::Http's wire: one LLM::HTTP request per call (chi's TLS,
14
14
  # proxies, User-Agent, cancel by closing the socket, and one log line
15
- # without query, headers or body), with chi's retries off. Every error
16
- # but a cancel becomes an HttpError, so a plugin rescues one type.
15
+ # without query, headers or body), with chi's retries off. Every
16
+ # network, TLS, protocol or provider error but a cancel becomes an
17
+ # HttpError, so a plugin rescues one type; anything else is a bug in
18
+ # chi, logged with its backtrace and raised as it is.
17
19
  #
18
20
  # Loaded with the first real request (it pulls in net/http and the
19
21
  # LLM layer). Stateless.
@@ -29,6 +31,12 @@ module Samagotchi
29
31
  Net::ProtocolError => "protocol error", Zlib::Error => "bad compressed body"
30
32
  }.freeze
31
33
 
34
+ # The errors a request's wire may raise: each becomes an HttpError.
35
+ WIRE_ERRORS = [
36
+ *REASONS.keys, *LLM::HTTP::NETWORK_ERRORS, SystemCallError, IOError, OpenSSL::OpenSSLError,
37
+ LLM::ProviderError
38
+ ].uniq.freeze
39
+
32
40
  def initialize(bundle:)
33
41
  @bundle = bundle.to_s
34
42
  end
@@ -47,8 +55,11 @@ module Samagotchi
47
55
  raise HttpCancelled, "#{method} #{where(uri)}: cancelled"
48
56
  rescue HttpError
49
57
  raise
50
- rescue StandardError => e
58
+ rescue *WIRE_ERRORS => e
51
59
  raise HttpError, "#{method} #{where(uri)}: #{reason(e)}"
60
+ rescue StandardError => e
61
+ Log.exception(:plugins, "http_transport_failed", e, bundle: @bundle)
62
+ raise
52
63
  end
53
64
 
54
65
  private
@@ -80,11 +80,10 @@ module Samagotchi
80
80
  sid = resolve(id, state_dir)
81
81
  delivered = SessionManager.deliver_turn(sid, prompt: text.to_s, client_id: ClientId::PLUGIN, state_dir: state_dir,
82
82
  delivery: wire)
83
- case delivered[:status]
83
+ case delivered.status
84
84
  when :accepted then sid
85
85
  when :refused
86
- ack = delivered[:ack] || {}
87
- raise Error, "session #{sid[0, 8]} refused the message (#{ack["error"]}#{": #{ack["detail"]}" if ack["detail"]})"
86
+ raise Error, "session #{sid[0, 8]} refused the message (#{delivered.error}#{": #{delivered.detail}" if delivered.detail})"
88
87
  when :timeout then raise Error, "session #{sid[0, 8]}'s worker did not answer in time; the message was not sent"
89
88
  else raise Error, "the message to session #{sid[0, 8]} could not be written"
90
89
  end
@@ -6,6 +6,7 @@ require "securerandom"
6
6
  require "time"
7
7
 
8
8
  require_relative "client_id"
9
+ require_relative "hook_question"
9
10
  require_relative "log"
10
11
  require_relative "pending_question"
11
12
  require_relative "question_answer"
@@ -404,30 +405,32 @@ module Samagotchi
404
405
  # web), single-select, kind "hook". A --non-interactive run has no one
405
406
  # to ask: nil at once. Anything but an answer (a sync handler's text, no
406
407
  # answer, cancelled) is nil too.
407
- # @param effects [Hash, nil] label => effect for a parent agent's answer
408
- # (Guardrails::ParentPolicy); a label left out is "answer". A label
408
+ # @param asked [HookQuestion] its effects (label => effect for a parent
409
+ # agent's answer, Guardrails::ParentPolicy; a label left out is
410
+ # "answer") become the pending question's per-option tags. A label
409
411
  # the options don't have, or an effect it doesn't know, makes the ask
410
412
  # invalid (nil, warned), like bad options.
411
413
  # @return [Hash, nil] {selected:, freeform:, selected_indices:} (a plain
412
414
  # Hash: the bundle boundary)
413
- def ask_for_hook(question, options, header, allow_freeform, hook, effects: nil)
415
+ def ask_for_hook(asked)
414
416
  return nil if @interface_lookup.call == :non_interactive
415
417
 
416
- opts = Tools::AskUserQuestion.normalize_options(options)
418
+ hook = asked.hook.to_s
419
+ opts = Tools::AskUserQuestion.normalize_options(asked.options)
417
420
  unless opts
418
- Log.warn(:hooks, "ask_user_invalid", echo: "[samagotchi:hooks] #{hook} asked with invalid options (2-8 strings)", hook: hook.to_s)
421
+ Log.warn(:hooks, "ask_user_invalid", echo: "[samagotchi:hooks] #{hook} asked with invalid options (2-8 strings)", hook: hook)
419
422
  return nil
420
423
  end
421
- tags = Guardrails::ParentPolicy.option_tags(opts, effects)
424
+ tags = Guardrails::ParentPolicy.option_tags(opts, asked.effects)
422
425
  if tags == false
423
426
  Log.warn(:hooks, "ask_user_invalid",
424
427
  echo: "[samagotchi:hooks] #{hook} asked with invalid effects (option label => " \
425
- "#{Guardrails::ParentPolicy::EFFECTS.join("|")})", hook: hook.to_s)
428
+ "#{Guardrails::ParentPolicy::EFFECTS.join("|")})", hook: hook)
426
429
  return nil
427
430
  end
428
431
 
429
- fields = { question: question.to_s, options: opts, header: header, multi_select: false,
430
- allow_freeform: !!allow_freeform, effects: tags, kind: "hook", hook: hook.to_s }.compact
432
+ fields = { question: asked.question.to_s, options: opts, header: asked.header, multi_select: false,
433
+ allow_freeform: !!asked.allow_freeform, effects: tags, kind: "hook", hook: hook }.compact
431
434
  answer = open_question(fields)
432
435
  return nil unless answer.is_a?(QuestionAnswer)
433
436
 
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../session"
4
+ require_relative "../session_inbox"
5
+ require_relative "../turn_checkpoint"
6
+
7
+ module Samagotchi
8
+ module ReplyWait
9
+ # One ReplyWait.call: what the wait learned so far (the turn's input
10
+ # claimed, its turn seen running, any turn seen running, the worker
11
+ # gone since) and one look at the session per poll. Each check returns
12
+ # a Result that ends the wait, or nil to go on, in the order #look runs
13
+ # them. ReplyWait.call's parameters say what each means.
14
+ class Poll
15
+ def initialize(id, state_dir:, cursor:, timeout:, poll_interval:, cancelled:, baseline:, owner_grace:, interject:,
16
+ turn:, queued:, ended_before:)
17
+ @id = id
18
+ @state_dir = state_dir
19
+ @cursor = cursor
20
+ @poll_interval = poll_interval
21
+ @cancelled = cancelled
22
+ @baseline = baseline
23
+ @owner_grace = owner_grace
24
+ @interject = interject
25
+ @ended_before = ended_before
26
+ @deadline = timeout && (ReplyWait.monotonic + timeout.to_f)
27
+ @turn = turn.to_s.empty? ? nil : turn.to_s
28
+ @session_dir = Session.session_dir(id, state_dir: state_dir) if @turn
29
+ # Whether the turn's input file has been taken (latched): looked at
30
+ # before each load, so a running session seen after it is that turn
31
+ # or a later one, never the turn that ran when the message was sent.
32
+ @claimed = false
33
+ # Its input runs a turn of its own (a queue, images: +queued+, or
34
+ # seen waiting as one): never merged into another turn.
35
+ @own_turn_input = queued
36
+ # The turn was seen running (its mark's id), and last_turn's
37
+ # ended_at then: a later end that isn't its is a turn after it.
38
+ @seen_own = false
39
+ @own_base = nil
40
+ @seen_running = false
41
+ @gone_since = nil
42
+ end
43
+
44
+ # @return [Result]
45
+ def run
46
+ loop do
47
+ result = look
48
+ return result if result
49
+
50
+ if @interject
51
+ started = ReplyWait.monotonic
52
+ @interject.call
53
+ @deadline += ReplyWait.monotonic - started if @deadline
54
+ next if @cancelled.call
55
+ end
56
+ sleep(@poll_interval)
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ # One poll: the input file, then the session as loaded now.
63
+ # @return [Result, nil]
64
+ def look
65
+ look_at_input
66
+ session = Session.load(@id, state_dir: @state_dir)
67
+ if (result = removed(session))
68
+ return result
69
+ end
70
+
71
+ note_own_running(session)
72
+ follow_merge(session)
73
+ reply(session) || settled_turn(session) || status_end(session) || question(session) || canceled ||
74
+ ended_before_reply(session) || ended_before_turn(session) || turn_end(session) || owner_gone(session) ||
75
+ timed_out
76
+ end
77
+
78
+ def look_at_input
79
+ return unless @turn && !@claimed
80
+
81
+ waiting = SessionInbox.waiting_input(@session_dir, @turn)
82
+ @own_turn_input ||= waiting == :turn_end
83
+ @claimed = waiting.nil?
84
+ end
85
+
86
+ # Taken back before it ran (POST turn/dequeue): no turn will come. A
87
+ # tombstone with the claimed file still there is a removal not done
88
+ # yet (or one a turn's claim beat): looked at again next poll. One
89
+ # whose turn ran anyway (its reply, or last_turn its own) is a stray:
90
+ # a worker killed between the tombstone and the claim left it, or a
91
+ # short turn won the claim and ended before the removal took it back.
92
+ def removed(session)
93
+ return nil unless @turn && @claimed && !@seen_own && SessionInbox.removed?(@session_dir, @turn)
94
+ return nil if SessionInbox.claimed_input?(@session_dir, @turn) || turn_ran?(session)
95
+
96
+ Result.new(status: :no_reply, outcome: "removed", session: session)
97
+ end
98
+
99
+ def turn_ran?(session)
100
+ session.last_turn&.enqueued_id == @turn || !SessionInbox.reply_for(@session_dir, @turn).nil?
101
+ end
102
+
103
+ def note_own_running(session)
104
+ return unless @turn && !@seen_own && own_running?(session)
105
+
106
+ @seen_own = true
107
+ @own_base = session.last_turn&.ended_at
108
+ end
109
+
110
+ # Taken but never run as a turn of its own: a running turn's drain
111
+ # merged it as a steer, so that turn's reply (or question) is its
112
+ # answer. Waited for as the next reply past the cursor, as without
113
+ # turn:, never as a turn that will not come.
114
+ def follow_merge(session)
115
+ return unless @turn && @claimed && !@seen_own && !@own_turn_input &&
116
+ ReplyWait.merged_elsewhere?(session, @turn, @baseline, @session_dir)
117
+
118
+ @turn = nil
119
+ @seen_running = running?(session)
120
+ end
121
+
122
+ def reply(session)
123
+ reply = if @turn
124
+ ReplyWait.reply_for_turn(@id, state_dir: @state_dir, turn: @turn)
125
+ else
126
+ ReplyWait.reply_past(@id, state_dir: @state_dir, cursor: @cursor)
127
+ end
128
+ return nil unless reply
129
+
130
+ reply.session = ReplyWait.settled(session, @id, state_dir: @state_dir)
131
+ reply
132
+ end
133
+
134
+ # Its worker died in it and the next one settled it, or an input
135
+ # command finished without a turn. After the reply check: a worker
136
+ # killed between writing the reply and its idle save left the reply.
137
+ def settled_turn(session)
138
+ settled = @turn && TurnCheckpoint.settled_turn(@session_dir, @turn)
139
+ return nil unless settled
140
+
141
+ Result.new(status: :no_reply, outcome: settled.outcome, session: session, origin: settled.origin,
142
+ **settled.stop_facts)
143
+ end
144
+
145
+ def status_end(session)
146
+ case session.status
147
+ when Session::STATUS_ERROR then Result.new(status: :error, text: session.last_prompt.to_s.strip, session: session)
148
+ when Session::STATUS_STOPPED then Result.new(status: :stopped, session: session)
149
+ end
150
+ end
151
+
152
+ # Only a question a live worker holds waits for an answer (the lists'
153
+ # rule): one a dead worker left waits for no one, and a chi REPL's is
154
+ # answered there.
155
+ def question(session)
156
+ pending = session.pending_question
157
+ return nil unless pending && !@baseline&.question?(pending.id) &&
158
+ (@turn.nil? || ReplyWait.own_question?(session, @turn, @claimed)) &&
159
+ session.waiting_question(live: SessionManager.worker_live?(@id, state_dir: @state_dir))
160
+
161
+ Result.new(status: :waiting_for_answer, question: pending, session: session)
162
+ end
163
+
164
+ def canceled = @cancelled.call ? Result.new(status: :canceled) : nil
165
+
166
+ # The turn that ended before this wait (ended_before: chi send --wait
167
+ # with no message, on a session that went idle in the gap between
168
+ # waits): its last_turn still stands, the session is idle, so its end
169
+ # is the answer, not a wait that times out "running" again. Its reply
170
+ # file sorts at the cursor (the newest), not past it: it is the
171
+ # turn's when it is at least as new as the turn's end (the worker
172
+ # writes it after recording the ended_at), else the turn left no
173
+ # reply and an earlier turn's file is not its.
174
+ def ended_before_reply(session)
175
+ return nil unless @ended_before && @turn.nil? && session.status == Session::STATUS_IDLE && baseline_end?(session)
176
+
177
+ file = ReplyWait.newest_reply(@id, state_dir: @state_dir)
178
+ path = File.join(ReplyWait.reply_dir(@id, state_dir: @state_dir), file) if file
179
+ if file && ReplyWait.reply_is_turns?(path, session.last_turn.ended_at)
180
+ reply = Result.new(status: :done, text: File.read(path), file: file)
181
+ reply.session = ReplyWait.settled(session, @id, state_dir: @state_dir)
182
+ return reply
183
+ end
184
+
185
+ ReplyWait.no_reply(session, @baseline, fresh: true)
186
+ end
187
+
188
+ # The same for a turn: wait whose turn is that last one (not running
189
+ # again). The turn's own reply lookup (#reply) already found its file,
190
+ # if one exists; without it the newest file is no fallback: it could
191
+ # be another turn's.
192
+ def ended_before_turn(session)
193
+ return nil unless @ended_before && @turn && session.last_turn&.enqueued_id == @turn && !own_running?(session) &&
194
+ baseline_end?(session)
195
+
196
+ ReplyWait.no_reply(session, @baseline, fresh: true)
197
+ end
198
+
199
+ def turn_end(session)
200
+ return own_turn_end(session) if @turn
201
+
202
+ if running?(session)
203
+ @seen_running = true
204
+ return nil
205
+ end
206
+ # It ran and is idle again with no new reply: canceled, failed or
207
+ # empty. Before it was ever seen running, idle means a
208
+ # file-delivered message its worker has not picked up yet. With a
209
+ # baseline only a turn that ended since counts: a session left
210
+ # running by a worker that died goes idle when the next worker
211
+ # settles that turn (TurnCheckpoint.settle), before the sent
212
+ # message's turn runs.
213
+ return nil unless (@seen_running && @baseline.nil?) || ReplyWait.turn_ended_since?(session, @baseline)
214
+
215
+ # The worker writes the reply before its idle save, so the read
216
+ # above has it; one more look costs nothing should that change.
217
+ ReplyWait.reply_past(@id, state_dir: @state_dir, cursor: @cursor)&.tap { |r| r.session = session } ||
218
+ ReplyWait.no_reply(session, @baseline)
219
+ end
220
+
221
+ # Only the turn's own end: the session runs another turn when the
222
+ # wait starts, and that one's end is not the answer. A last_turn with
223
+ # its id is its end, even with a next queued turn running by now (the
224
+ # look between the two missed the idle). Only an end after the
225
+ # baseline: a continue offer answered from idle leaves the exhausted
226
+ # turn's last_turn, with the same id, until the continue turn (or a
227
+ # Stop) records its own.
228
+ def own_turn_end(session)
229
+ if session.last_turn&.enqueued_id == @turn && ReplyWait.ended_since_baseline?(session, @baseline)
230
+ return ReplyWait.reply_for_turn(@id, state_dir: @state_dir, turn: @turn)&.tap { |r| r.session = session } ||
231
+ ReplyWait.no_reply(session, @baseline, fresh: true)
232
+ end
233
+ # Seen running, and now another turn runs or a later turn ended
234
+ # while last_turn (looked at first) isn't its: lost (its worker
235
+ # died), or it ended with no reply and a next turn ran and ended
236
+ # between two looks. Waiting on would sit through those turns.
237
+ return Result.new(status: :no_reply, session: session) if @seen_own && ReplyWait.past_own?(session, @turn, @own_base)
238
+
239
+ nil
240
+ end
241
+
242
+ def owner_gone(session)
243
+ return nil unless @owner_grace
244
+
245
+ if SessionManager.session_owner(@id, state_dir: @state_dir)
246
+ @gone_since = nil
247
+ elsif ReplyWait.monotonic - (@gone_since ||= ReplyWait.monotonic) > @owner_grace
248
+ return Result.new(status: :worker_gone, session: session)
249
+ end
250
+ nil
251
+ end
252
+
253
+ def timed_out = @deadline && ReplyWait.monotonic > @deadline ? Result.new(status: :timeout) : nil
254
+
255
+ # Whether the session's last turn is the one that had ended at the
256
+ # baseline (its ended_at set and unchanged).
257
+ def baseline_end?(session)
258
+ ended = session.last_turn&.ended_at
259
+ (!ended.to_s.empty? && @baseline&.ended_at?(ended)) || false
260
+ end
261
+
262
+ def running?(session) = session.status == Session::STATUS_RUNNING
263
+
264
+ def own_running?(session) = running?(session) && session.interrupted_turn&.enqueued_id == @turn
265
+ end
266
+ end
267
+ end