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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -1
- data/README.md +1 -1
- data/bin/chi +4 -4
- data/docs/architecture.md +4 -4
- data/docs/cli.md +1 -1
- data/docs/guardrails.md +4 -5
- data/docs/hooks.md +1 -1
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +6 -0
- data/docs/memory.md +26 -1
- data/docs/plugins.md +37 -0
- data/docs/releasing.md +2 -2
- data/docs/sessions.md +1 -1
- data/docs/testing.md +11 -2
- data/lib/samagotchi/bootstrap/probe.rb +2 -2
- data/lib/samagotchi/bootstrap_command.rb +1 -0
- data/lib/samagotchi/bundle_command.rb +1 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/model-notes/manifest.yml +11 -0
- data/lib/samagotchi/bundles/model-notes/model_notes_deepseek.md +7 -0
- data/lib/samagotchi/bundles/model-notes/notes/README.md +37 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +12 -14
- data/lib/samagotchi/client_id.rb +18 -0
- data/lib/samagotchi/config/restart_watch.rb +112 -0
- data/lib/samagotchi/config.rb +18 -59
- data/lib/samagotchi/engine.rb +4 -14
- data/lib/samagotchi/guardrails/data_readers.rb +34 -30
- data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
- data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
- data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
- data/lib/samagotchi/hook_question.rb +21 -0
- data/lib/samagotchi/hook_routes.rb +6 -6
- data/lib/samagotchi/hooks/registry.rb +5 -5
- data/lib/samagotchi/host_registry.rb +1 -1
- data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
- data/lib/samagotchi/memory_bundle/profile.rb +22 -4
- data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -1
- data/lib/samagotchi/parent_answers_command.rb +6 -6
- data/lib/samagotchi/plugin/context.rb +6 -5
- data/lib/samagotchi/plugin/http_transport.rb +14 -3
- data/lib/samagotchi/plugin/sessions.rb +2 -3
- data/lib/samagotchi/question_desk.rb +12 -9
- data/lib/samagotchi/reply_wait/poll.rb +267 -0
- data/lib/samagotchi/reply_wait.rb +47 -190
- data/lib/samagotchi/send_command/parent_gate.rb +83 -0
- data/lib/samagotchi/send_command/wait_target.rb +55 -0
- data/lib/samagotchi/send_command.rb +31 -100
- data/lib/samagotchi/session.rb +10 -9
- data/lib/samagotchi/session_inbox.rb +31 -8
- data/lib/samagotchi/session_manager.rb +48 -15
- data/lib/samagotchi/session_metrics.rb +22 -3
- data/lib/samagotchi/sessions_command.rb +4 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
- data/lib/samagotchi/testing/bundle.rb +1 -1
- data/lib/samagotchi/testing/host.rb +2 -8
- data/lib/samagotchi/testing/recorder.rb +6 -9
- data/lib/samagotchi/testing/sandbox.rb +26 -0
- data/lib/samagotchi/tools/delegate.rb +10 -10
- data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
- data/lib/samagotchi/tools/delegate_relay.rb +31 -16
- data/lib/samagotchi/tools/delegate_wait.rb +4 -4
- data/lib/samagotchi/update_command.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +46 -63
- data/lib/samagotchi/web/public/app.js +137 -638
- data/lib/samagotchi/web/public/dock_reserve.js +103 -0
- data/lib/samagotchi/web/public/index.html +4 -4
- data/lib/samagotchi/web/public/send_turn.js +173 -0
- data/lib/samagotchi/web/public/session_actions.js +203 -0
- data/lib/samagotchi/web/public/start_page.js +236 -0
- data/lib/samagotchi/web/public/toast_place.js +1 -1
- 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:
|
|
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
|
|
92
|
-
@question_desk.call.ask_for_hook(question
|
|
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 (
|
|
117
|
-
#
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
32
|
-
#
|
|
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,
|
|
372
|
-
|
|
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
|
|
16
|
-
#
|
|
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
|
|
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
|
|
83
|
+
case delivered.status
|
|
84
84
|
when :accepted then sid
|
|
85
85
|
when :refused
|
|
86
|
-
|
|
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
|
|
408
|
-
#
|
|
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(
|
|
415
|
+
def ask_for_hook(asked)
|
|
414
416
|
return nil if @interface_lookup.call == :non_interactive
|
|
415
417
|
|
|
416
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|