samagotchi 0.92.0 → 0.94.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -1
  3. data/README.md +1 -1
  4. data/bin/chi +4 -4
  5. data/docs/architecture.md +4 -4
  6. data/docs/cli.md +1 -1
  7. data/docs/guardrails.md +4 -5
  8. data/docs/hooks.md +1 -1
  9. data/docs/internals/bundles.md +1 -1
  10. data/docs/internals/engine.md +6 -0
  11. data/docs/memory.md +26 -1
  12. data/docs/plugins.md +37 -0
  13. data/docs/releasing.md +2 -2
  14. data/docs/sessions.md +1 -1
  15. data/docs/testing.md +11 -2
  16. data/lib/samagotchi/bootstrap/probe.rb +2 -2
  17. data/lib/samagotchi/bootstrap_command.rb +1 -0
  18. data/lib/samagotchi/bundle_command.rb +1 -0
  19. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  20. data/lib/samagotchi/bundles/model-notes/manifest.yml +11 -0
  21. data/lib/samagotchi/bundles/model-notes/model_notes_deepseek.md +7 -0
  22. data/lib/samagotchi/bundles/model-notes/notes/README.md +37 -0
  23. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  24. data/lib/samagotchi/child_reports.rb +12 -14
  25. data/lib/samagotchi/client_id.rb +18 -0
  26. data/lib/samagotchi/config/restart_watch.rb +112 -0
  27. data/lib/samagotchi/config.rb +18 -59
  28. data/lib/samagotchi/engine.rb +4 -14
  29. data/lib/samagotchi/guardrails/data_readers.rb +34 -30
  30. data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
  31. data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
  32. data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
  33. data/lib/samagotchi/hook_question.rb +21 -0
  34. data/lib/samagotchi/hook_routes.rb +6 -6
  35. data/lib/samagotchi/hooks/registry.rb +5 -5
  36. data/lib/samagotchi/host_registry.rb +1 -1
  37. data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
  38. data/lib/samagotchi/memory_bundle/profile.rb +22 -4
  39. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -1
  40. data/lib/samagotchi/parent_answers_command.rb +6 -6
  41. data/lib/samagotchi/plugin/context.rb +6 -5
  42. data/lib/samagotchi/plugin/http_transport.rb +14 -3
  43. data/lib/samagotchi/plugin/sessions.rb +2 -3
  44. data/lib/samagotchi/question_desk.rb +12 -9
  45. data/lib/samagotchi/reply_wait/poll.rb +267 -0
  46. data/lib/samagotchi/reply_wait.rb +47 -190
  47. data/lib/samagotchi/send_command/parent_gate.rb +83 -0
  48. data/lib/samagotchi/send_command/wait_target.rb +55 -0
  49. data/lib/samagotchi/send_command.rb +31 -100
  50. data/lib/samagotchi/session.rb +10 -9
  51. data/lib/samagotchi/session_inbox.rb +31 -8
  52. data/lib/samagotchi/session_manager.rb +48 -15
  53. data/lib/samagotchi/session_metrics.rb +22 -3
  54. data/lib/samagotchi/sessions_command.rb +4 -4
  55. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  56. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  57. data/lib/samagotchi/testing/bundle.rb +1 -1
  58. data/lib/samagotchi/testing/host.rb +2 -8
  59. data/lib/samagotchi/testing/recorder.rb +6 -9
  60. data/lib/samagotchi/testing/sandbox.rb +26 -0
  61. data/lib/samagotchi/tools/delegate.rb +10 -10
  62. data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
  63. data/lib/samagotchi/tools/delegate_relay.rb +31 -16
  64. data/lib/samagotchi/tools/delegate_wait.rb +4 -4
  65. data/lib/samagotchi/update_command.rb +1 -1
  66. data/lib/samagotchi/version.rb +1 -1
  67. data/lib/samagotchi/web/app.rb +46 -63
  68. data/lib/samagotchi/web/public/app.js +137 -638
  69. data/lib/samagotchi/web/public/dock_reserve.js +103 -0
  70. data/lib/samagotchi/web/public/index.html +4 -4
  71. data/lib/samagotchi/web/public/send_turn.js +173 -0
  72. data/lib/samagotchi/web/public/session_actions.js +203 -0
  73. data/lib/samagotchi/web/public/start_page.js +236 -0
  74. data/lib/samagotchi/web/public/toast_place.js +1 -1
  75. metadata +14 -1
@@ -31,14 +31,13 @@ module Samagotchi
31
31
  end
32
32
 
33
33
  # The things a hook can do beyond reading its event. Each gets the
34
- # hook's label (event[:hook]) from the registry.
34
+ # hook's label (event[:hook]) from the registry; ask_user gets it inside
35
+ # the HookQuestion.
35
36
  # @return [Hooks::Runtime]
36
37
  def runtime
37
38
  Hooks::Runtime.new(
38
39
  notify: ->(text:, level:, hook:, fallback_for: nil) { notify(text, level, hook, fallback_for: fallback_for) },
39
- ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:, effects: nil|
40
- ask_user(question, options, header, allow_freeform, hook, effects: effects)
41
- },
40
+ ask_user: ->(question) { ask_user(question) },
42
41
  stop_turn: ->(reason:, hook:) { stop_turn(reason, hook) },
43
42
  steer: ->(text:, hook:) { @steer.call(text, source(hook)) },
44
43
  stop_generation: ->(reason:, hook:) { stop_generation(reason, hook) }
@@ -87,9 +86,10 @@ module Samagotchi
87
86
  # web), single-select, kind "hook". A --non-interactive run has no one
88
87
  # to ask: nil at once. Anything but an answer (a sync handler's text, no
89
88
  # answer, cancelled) is nil too.
89
+ # @param question [HookQuestion]
90
90
  # @return [Hash, nil] {selected:, freeform:, selected_indices:}
91
- def ask_user(question, options, header, allow_freeform, hook, effects: nil)
92
- @question_desk.call.ask_for_hook(question, options, header, allow_freeform, hook, effects: effects)
91
+ def ask_user(question)
92
+ @question_desk.call.ask_for_hook(question)
93
93
  end
94
94
 
95
95
  # Cancel the running turn (reason :hook), after a notice that says why.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "monitor"
4
+ require_relative "../hook_question"
4
5
  require_relative "../log"
5
6
 
6
7
  module Samagotchi
@@ -113,8 +114,8 @@ module Samagotchi
113
114
  # callables, each given the hook's label. +notify+ takes
114
115
  # (text:, level:, hook:, and fallback_for: when the hook gave one: what
115
116
  # the line stands in for, HookRoutes#notify) and shows one line to the
116
- # user; +ask_user+ takes (question:, options:, header:, allow_freeform:, hook:, and
117
- # effects: when the hook gave them) and returns the answer hash or nil; +stop_turn+ takes (reason:, hook:) and
117
+ # user; +ask_user+ takes a HookQuestion (the hook's label in it) and
118
+ # returns the answer hash or nil; +stop_turn+ takes (reason:, hook:) and
118
119
  # cancels the running turn (true when it did); +steer+ takes (text:,
119
120
  # hook:) and puts the text into the running turn (Engine#steer, true
120
121
  # when queued); +stop_generation+ takes (reason:, hook:) and cuts the
@@ -392,9 +393,8 @@ module Samagotchi
392
393
  nil
393
394
  }
394
395
  event[:ask_user] ||= lambda { |question:, options:, header: nil, allow_freeform: false, effects: nil|
395
- @runtime&.ask_user&.call(question: question, options: options, header: header,
396
- allow_freeform: allow_freeform, hook: event[:hook],
397
- **(effects.nil? ? {} : { effects: effects }))
396
+ @runtime&.ask_user&.call(HookQuestion.new(question: question, options: options, header: header,
397
+ allow_freeform: allow_freeform, hook: event[:hook], effects: effects))
398
398
  }
399
399
  event[:stop_turn] ||= lambda { |reason|
400
400
  next false if TURN_OVER_EVENTS.include?(event[:type])
@@ -68,7 +68,7 @@ module Samagotchi
68
68
  def api_base_url
69
69
  return openai_base_url unless api&.to_sym == :anthropic
70
70
 
71
- (url || root_url).chomp("/").delete_suffix("/v1")
71
+ LLM::AnthropicMessages.api_root(url || root_url)
72
72
  end
73
73
 
74
74
  # The header the host's key goes in: auth: when set, else the api's
@@ -87,6 +87,11 @@ module Samagotchi
87
87
 
88
88
  attr_reader :base_url, :host_name, :api_key_env, :models_ttl, :first_token_timeout, :dialect
89
89
 
90
+ # The Messages API root a base URL names: without a trailing / and a
91
+ # trailing /v1 (as ANTHROPIC_BASE_URL), so requests never go to
92
+ # /v1/v1. HostEntry#api_base_url and the bootstrap probe both use it.
93
+ def self.api_root(url) = url.to_s.chomp("/").delete_suffix("/v1")
94
+
90
95
  # @param entry [HostRegistry::HostEntry]
91
96
  def self.for(entry, **)
92
97
  new(base_url: entry.api_base_url, host_name: entry.name, api_key_env: entry.api_key_env, auth: entry.auth,
@@ -25,9 +25,14 @@ module Samagotchi
25
25
  # shipped meta expands: includes: in any other bundle is ignored.
26
26
  module Profile
27
27
  # installed: members installed now; already: members found installed
28
- # (recorded, not reinstalled); skipped and failed: name => why.
29
- InstallResult = Struct.new(:name, :version, :installed, :already, :skipped, :failed, keyword_init: true) do
28
+ # (recorded, not reinstalled); skipped and failed: name => why;
29
+ # kept_own: member installed now => its memory files the user already
30
+ # had, left as they are and not owned (Installer#skip_existing).
31
+ InstallResult = Struct.new(:name, :version, :installed, :already, :skipped, :failed, :kept_own, keyword_init: true) do
30
32
  def failed? = !failed.empty?
33
+
34
+ # One line per kept file (Profile.kept_own_line).
35
+ def kept_own_lines = (kept_own || {}).flat_map { |member, files| files.map { |file| Profile.kept_own_line(member, file) } }
31
36
  end
32
37
  # removed: members uninstalled; blocked: name => why; gone: the meta
33
38
  # itself was removed (only once every member is); trash: member =>
@@ -69,6 +74,15 @@ module Samagotchi
69
74
 
70
75
  def installed_names = Provenance.each_installed.map { |name, _| name }
71
76
 
77
+ # What a profile's install says of a member's memory file the user
78
+ # already had: chi bundle install says "Skipped … (already exists)",
79
+ # which a profile's member install would leave unsaid (docs/memory.md,
80
+ # "Shipped notes").
81
+ def kept_own_line(member, file)
82
+ "#{member}: kept your own #{file} (not the bundle's; to take the bundle's, remove yours with " \
83
+ "memory_write remove: true, then chi bundle install #{member})"
84
+ end
85
+
72
86
  # The one rule.
73
87
  def to_install(manifest, bundle = Provenance.new(name: manifest.name).record, installed: installed_names)
74
88
  manifest.includes - recorded(bundle, manifest) - installed
@@ -84,7 +98,7 @@ module Samagotchi
84
98
  was = recorded(bundle, manifest)
85
99
  installed = installed_names
86
100
  result = InstallResult.new(name: manifest.name, version: manifest.version, installed: [],
87
- already: (manifest.includes - was) & installed, skipped: {}, failed: {})
101
+ already: (manifest.includes - was) & installed, skipped: {}, failed: {}, kept_own: {})
88
102
 
89
103
  to_install(manifest, bundle, installed: installed).each do |member|
90
104
  install_member(member, shipped_dir, chi_version, dry_run, force, result)
@@ -109,8 +123,12 @@ module Samagotchi
109
123
  return result.skipped[member] = failure if failure
110
124
  return result.installed << member if dry_run
111
125
 
112
- Installer.new(source: member_dir, name: member, scope: "system", force: force, strict: true, shipped_dir: shipped_dir).run
126
+ installer = Installer.new(source: member_dir, name: member, scope: "system", force: force, strict: true,
127
+ shipped_dir: shipped_dir)
128
+ installer.run
113
129
  result.installed << member
130
+ kept = installer.results.select { |_, entry| entry[:status] == "skipped" && entry[:reason] == "already exists" }.keys
131
+ result.kept_own[member] = kept.sort unless kept.empty?
114
132
  rescue StandardError => e
115
133
  result.failed[member] = e.message.lines.first.to_s.strip
116
134
  end
@@ -37,7 +37,10 @@ module Samagotchi
37
37
  # :failed. note says why for skipped/failed. kept and replaced are
38
38
  # file names; under plan they are what apply would keep or replace.
39
39
  # adds is nil but for a profile: the members it installs.
40
- Row = Struct.new(:name, :from, :to, :status, :note, :kept, :replaced, :source_dir, :scope, :adds, keyword_init: true)
40
+ # kept_own is nil but for a profile that installed a member: its
41
+ # Profile.kept_own_line lines.
42
+ Row = Struct.new(:name, :from, :to, :status, :note, :kept, :replaced, :source_dir, :scope, :adds, :kept_own,
43
+ keyword_init: true)
41
44
 
42
45
  module_function
43
46
 
@@ -84,6 +87,7 @@ module Samagotchi
84
87
  result = Profile.install(row.source_dir, shipped_dir: File.dirname(row.source_dir))
85
88
  row.dup.tap do |r|
86
89
  r.adds = result.installed
90
+ r.kept_own = result.kept_own_lines
87
91
  r.status = result.failed? ? :failed : :updated
88
92
  r.note = result.failed.map { |member, why| "#{member}: #{why}" }.join("; ") if result.failed?
89
93
  end
@@ -25,10 +25,11 @@ module Samagotchi
25
25
  return [:show] if words.empty?
26
26
  return [:usage] if words.size > 1
27
27
 
28
- word = words.first.downcase
29
- return [:set, nil] if word == Guardrails::ParentPolicy::UNSET
30
-
31
- Guardrails::ParentPolicy::ORDER.include?(word) ? [:set, word] : [:usage]
28
+ case (level = Guardrails::ParentPolicy.parse_level(words.first))
29
+ when :unset then [:set, nil]
30
+ when :invalid then [:usage]
31
+ else [:set, level]
32
+ end
32
33
  end
33
34
 
34
35
  # Whether the command line +line+ loosens a session whose own value is
@@ -72,8 +73,7 @@ module Samagotchi
72
73
  effective = Guardrails::ParentPolicy.for_session(session)
73
74
  own = session.parent_answers || "none"
74
75
  # A hand-edited value that isn't a level: fail-closed, said here.
75
- own = "#{own} (unreadable value, read as hard)" if session.parent_answers &&
76
- !Guardrails::ParentPolicy::ORDER.include?(session.parent_answers)
76
+ own = "#{own} (unreadable value, read as hard)" if Guardrails::ParentPolicy.unknown_level?(session.parent_answers)
77
77
  "parent answers: #{effective.name} (this session: #{own}; config.yml #{config.label})"
78
78
  end
79
79
  end
@@ -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