samagotchi 0.103.0 → 0.105.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -1
  3. data/bin/chi +39 -2
  4. data/docs/architecture.md +36 -24
  5. data/docs/cli.md +97 -21
  6. data/docs/configuration.md +1 -1
  7. data/docs/guardrails.md +15 -1
  8. data/docs/internals/anthropic.md +2 -1
  9. data/docs/internals/context-telemetry.md +6 -2
  10. data/docs/internals/engine.md +22 -1
  11. data/docs/internals/participants.md +167 -0
  12. data/docs/internals/prompt-caching.md +8 -0
  13. data/docs/internals/remote-sessions.md +153 -27
  14. data/docs/internals/working-set.md +4 -3
  15. data/docs/plugins.md +7 -1
  16. data/docs/sessions.md +4 -2
  17. data/docs/sub-agent.md +8 -0
  18. data/docs/testing.md +89 -4
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -1
  20. data/lib/samagotchi/bridge.rb +17 -10
  21. data/lib/samagotchi/bridge_client/event_stream.rb +9 -4
  22. data/lib/samagotchi/bridge_client.rb +4 -1
  23. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  24. data/lib/samagotchi/chi_invocation.rb +13 -16
  25. data/lib/samagotchi/config.rb +1 -1
  26. data/lib/samagotchi/context_status.rb +15 -2
  27. data/lib/samagotchi/engine.rb +52 -8
  28. data/lib/samagotchi/guardrail_wiring.rb +7 -1
  29. data/lib/samagotchi/idle_recap.rb +1 -1
  30. data/lib/samagotchi/idle_scheduler.rb +34 -9
  31. data/lib/samagotchi/kernel_loop.rb +8 -1
  32. data/lib/samagotchi/last_turn.rb +5 -2
  33. data/lib/samagotchi/launch_mode.rb +4 -0
  34. data/lib/samagotchi/llm/chat_loop.rb +9 -1
  35. data/lib/samagotchi/owner_lock.rb +65 -5
  36. data/lib/samagotchi/participant_name.rb +21 -0
  37. data/lib/samagotchi/participants.rb +122 -0
  38. data/lib/samagotchi/queued_command.rb +9 -5
  39. data/lib/samagotchi/remote_attach_command.rb +119 -0
  40. data/lib/samagotchi/run_options.rb +14 -2
  41. data/lib/samagotchi/send_command.rb +1 -1
  42. data/lib/samagotchi/session.rb +8 -4
  43. data/lib/samagotchi/session_access/contract.rb +42 -4
  44. data/lib/samagotchi/session_access/dispatch.rb +29 -10
  45. data/lib/samagotchi/session_access/local.rb +52 -1
  46. data/lib/samagotchi/session_access/remote.rb +112 -16
  47. data/lib/samagotchi/session_access/web_bridge_client.rb +60 -3
  48. data/lib/samagotchi/session_access.rb +85 -3
  49. data/lib/samagotchi/session_continue.rb +1 -1
  50. data/lib/samagotchi/session_inbox.rb +18 -9
  51. data/lib/samagotchi/session_manager.rb +21 -5
  52. data/lib/samagotchi/session_metrics.rb +7 -1
  53. data/lib/samagotchi/session_participants.rb +59 -0
  54. data/lib/samagotchi/sessions_command.rb +14 -11
  55. data/lib/samagotchi/share_command.rb +174 -0
  56. data/lib/samagotchi/steer.rb +83 -10
  57. data/lib/samagotchi/system_prompt.rb +21 -2
  58. data/lib/samagotchi/terminal_ui/attach_launcher.rb +17 -4
  59. data/lib/samagotchi/terminal_ui/attach_link.rb +10 -0
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +117 -29
  61. data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
  62. data/lib/samagotchi/terminal_ui/local_link.rb +5 -0
  63. data/lib/samagotchi/terminal_ui/reline_seam.rb +21 -1
  64. data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
  65. data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
  66. data/lib/samagotchi/terminal_ui/version_lines.rb +23 -0
  67. data/lib/samagotchi/terminal_ui.rb +4 -1
  68. data/lib/samagotchi/testing/waiting.rb +92 -9
  69. data/lib/samagotchi/tools/task_runtime.rb +17 -3
  70. data/lib/samagotchi/turn_checkpoint.rb +5 -2
  71. data/lib/samagotchi/version.rb +1 -1
  72. data/lib/samagotchi/web/app.rb +187 -74
  73. data/lib/samagotchi/web/grants.rb +303 -0
  74. data/lib/samagotchi/web/participant_access.rb +286 -0
  75. data/lib/samagotchi/web/principal.rb +29 -0
  76. data/lib/samagotchi/web/public/index.html +6 -5
  77. data/lib/samagotchi/web/public/working_set.js +6 -4
  78. data/lib/samagotchi/web/public/working_set_view.js +1 -1
  79. data/lib/samagotchi/web/server.rb +3 -1
  80. data/lib/samagotchi/web/session_access_routes.rb +120 -4
  81. data/lib/samagotchi/worker.rb +44 -11
  82. data/lib/samagotchi/worker_inbound.rb +5 -2
  83. metadata +12 -1
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require_relative "../session_access"
5
+ require_relative "../session_access/remote"
6
+ require_relative "../session_access/http_transport"
7
+ require_relative "../owner_lock"
8
+ require_relative "../launch_mode"
9
+ require_relative "attach_launcher"
10
+ require_relative "remote_link"
11
+
12
+ module Samagotchi
13
+ class TerminalUI
14
+ # AttachLauncher.run's connector for a session on another machine: POST
15
+ # …/wake through that machine's chi web (the live worker, else one
16
+ # started there) and the RemoteLink the attached loop runs on. Its
17
+ # failures are AttachLauncher::Error, in the words chi web sent (the
18
+ # exceptions' own: one condition, one message).
19
+ class RemoteConnector
20
+ # @param remote [SessionAccess::Remote]
21
+ # @param session_id [String] resolved there already
22
+ # @param hostname [String] this machine's, for #client_id
23
+ # @param err [IO] where the wait for a leaving worker is said
24
+ def initialize(remote, session_id:, hostname: Socket.gethostname, err: $stderr)
25
+ @remote = remote
26
+ @session_id = session_id
27
+ @hostname = hostname
28
+ @err = err
29
+ end
30
+
31
+ # A worker there still leaving (its recap on leave) is said here
32
+ # (LaunchMode::RECAP_WAIT, as a local attach says it) and waited for
33
+ # by the wake, whose read then allows for that wait too.
34
+ # @return [RemoteLink]
35
+ # @raise [AttachLauncher::Error]
36
+ def connect
37
+ woke = @remote.attach_wake(@session_id, wait: AttachLauncher::BRIDGE_WAIT, read_timeout: wake_read_timeout)
38
+ RemoteLink.new(remote: @remote, session_id: @session_id, woke: woke)
39
+ rescue SessionManager::OwnedByTUI, SessionAccess::Refused, SessionAccess::RemoteFailed, ArgumentError => e
40
+ raise AttachLauncher::Error, e.message
41
+ end
42
+
43
+ # Seconds past the transport's read timeout a wake may take beyond its
44
+ # leave and Bridge waits there.
45
+ WAKE_READ_SLACK = 15.0
46
+
47
+ # nil (the transport's) unless the worker there is leaving: then its
48
+ # leave's budget left, OwnerLock::LEAVE_MARGIN, the Bridge wait and
49
+ # some slack, at least the transport's.
50
+ def wake_read_timeout
51
+ state = @remote.attach_worker(@session_id)
52
+ return nil unless state.leaving
53
+
54
+ @err.puts(LaunchMode::RECAP_WAIT)
55
+ budget = state.leave_seconds.to_f + OwnerLock::LEAVE_MARGIN + AttachLauncher::BRIDGE_WAIT + WAKE_READ_SLACK
56
+ [budget, SessionAccess::HTTPTransport::READ_TIMEOUT].max
57
+ end
58
+
59
+ # tui:PID@HOST (P4-D6): an interactive, human client for the UIs
60
+ # there (ClientId), with the machine it types on (its short name).
61
+ def self.client_id(hostname = Socket.gethostname) = "tui:#{Process.pid}@#{hostname.to_s.split(".").first}"
62
+
63
+ def client_id = RemoteConnector.client_id(@hostname)
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "fileutils"
5
+ require_relative "attach_link"
6
+ require_relative "../session_access"
7
+ require_relative "../session_access/remote"
8
+ require_relative "../session_access/web_bridge_client"
9
+ require_relative "../children_status"
10
+ require_relative "../image_store"
11
+ require_relative "../delivery"
12
+ require_relative "../session_inbox"
13
+ require_relative "../log"
14
+
15
+ module Samagotchi
16
+ class TerminalUI
17
+ # The AttachLink of a session on another machine (chi --remote NAME
18
+ # --attach ID), reached through that machine's chi web: one
19
+ # SessionAccess::WebBridgeClient for the whole attach (a new worker
20
+ # changes its worker_key, never the object), the worker's features from
21
+ # the wake and GET …/worker, and that machine's default model, newest
22
+ # chi and parent strategy. What stays this machine's: the prompt
23
+ # history, the run flags' baselines, the @path files (ingested here,
24
+ # uploaded there).
25
+ class RemoteLink
26
+ include AttachLink
27
+
28
+ # The registry ids (SessionCommands' local: true, :attached) the
29
+ # attached loop runs remotely as it does here: its own leaving, the
30
+ # deliveries, the dequeue route, and the exit, archive, stats and
31
+ # recap through chi web's routes.
32
+ HANDLED_COMMANDS = %i[detach cut queue unqueue exit archive stats recap].freeze
33
+
34
+ # About a minute of reconnects (P4-D5); a Bridge here keeps
35
+ # EventStream::REDISCOVER_DELAYS.
36
+ RECONNECT_DELAYS = [0.25, 0.5, 1.0, 2.0, 2.0, 3.0, 5.0, 5.0, 5.0, 5.0, 10.0, 10.0, 10.0].freeze
37
+ # How long chi web must answer "no live worker" (not stopped) before
38
+ # the reconnect wakes one (P4-D9): a restart's new worker comes
39
+ # sooner, so it isn't raced.
40
+ WAKE_AFTER = 3.0
41
+ # The wake's wait for a woken worker's Bridge (AttachLauncher's).
42
+ WAKE_WAIT = 10.0
43
+
44
+ attr_reader :client
45
+
46
+ # @param remote [SessionAccess::Remote]
47
+ # @param session_id [String]
48
+ # @param woke [SessionAccess::WakeReply] the launch's wake
49
+ # @param clock [#call] monotonic seconds
50
+ def initialize(remote:, session_id:, woke:, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
51
+ @remote = remote
52
+ @woke = woke
53
+ @features = Array(woke.worker&.fetch("features", nil))
54
+ @client = remote.attach_client(session_id, worker: woke.worker || {})
55
+ @clock = clock
56
+ @no_worker_since = nil
57
+ @woken_once = false
58
+ @failure = nil
59
+ @default_model = woke.default_model
60
+ @last_records = nil
61
+ end
62
+
63
+ # GET …/worker, with the reason when there is none: [client, nil]
64
+ # (its key updated in place: EventStream sees a move when it
65
+ # changed), [nil, reason] to retry, [:gone, reason] to stop. A worker
66
+ # chi web says isn't live and isn't stopped is woken once per
67
+ # outage, after WAKE_AFTER (P4-D9).
68
+ def live_worker
69
+ state = @remote.attach_worker(session_id)
70
+ return [:gone, "stopped"] if state.stopped
71
+ return follow(state.worker) if state.live
72
+
73
+ wake_once || [nil, "worker_gone"]
74
+ rescue SessionAccess::AuthRefused, SessionAccess::RemoteTooOld => e
75
+ @failure = e.message
76
+ [:gone, e.is_a?(SessionAccess::AuthRefused) ? "auth_failed" : "too_old"]
77
+ rescue SessionAccess::RemoteFailed => e
78
+ @failure = e.message
79
+ [nil, "unreachable"]
80
+ rescue ArgumentError
81
+ [:gone, "unknown_session"]
82
+ rescue StandardError => e
83
+ Log.debug(:attached, "rediscover_failed", error: e.class.name, message: e.message)
84
+ @failure = e.message
85
+ [nil, "unreachable"]
86
+ end
87
+
88
+ # Still the same client (its key moved already: the stream's epoch
89
+ # watch, or #live_worker); the new worker's features read again.
90
+ def switched(event)
91
+ epoch = event[:epoch] || event["epoch"]
92
+ @client.worker_key = epoch if epoch
93
+ refresh_features
94
+ @client
95
+ end
96
+
97
+ def queues? = @features.include?(Delivery::QUEUE_FEATURE)
98
+
99
+ def unqueues? = @features.include?(SessionInbox::UNQUEUE_FEATURE)
100
+
101
+ # Each @path image ingested here (converted and downscaled as chi send
102
+ # does), then uploaded into the session there.
103
+ def ingest_images(paths)
104
+ refs = paths.map { |path| ImageStore.ingest(image_dir, path: path) }
105
+ @remote.upload_images(session_id, refs, from: image_dir)
106
+ rescue SessionAccess::Refused => e
107
+ raise ImageStore::Error, e.message
108
+ rescue SessionAccess::RemoteFailed => e
109
+ raise SessionAccess::WebBridgeClient::Unreachable, e.message
110
+ end
111
+
112
+ # A turn's saved records there (GET …/turns/:turn_id/records): [] and
113
+ # nil when they can't be read, as for an older session. A join asks
114
+ # for the tool records, then the turn record, of the same turn: one
115
+ # read gives both, so the second takes what the first read.
116
+ def saved_tool_records(turn_id)
117
+ @last_records = [turn_id, turn_records(turn_id)]
118
+ Array(@last_records[1]&.fetch("tool_records", nil))
119
+ end
120
+
121
+ def saved_turn_record(turn_id)
122
+ read = @last_records&.first == turn_id ? @last_records[1] : turn_records(turn_id)
123
+ @last_records = nil
124
+ record = read&.fetch("turn_record", nil)
125
+ record.is_a?(Hash) ? record : nil
126
+ end
127
+
128
+ # The delegates' counts there (GET …/children/counts); none when they
129
+ # can't be read (the status row keeps no children segment).
130
+ def children_counts
131
+ json = @client.children_counts
132
+ ChildrenStatus::Counts.new(running: json["running"].to_i, waiting: json["waiting"].to_i,
133
+ unreported: json["unreported"].to_i)
134
+ rescue StandardError => e
135
+ Log.debug(:attached, "children_counts_failed", error: e.class.name, message: e.message)
136
+ ChildrenStatus::Counts.none
137
+ end
138
+
139
+ # GET …/stats (nil when the worker didn't give them).
140
+ # @raise [SessionAccess::WebBridgeClient::Unreachable] chi web couldn't
141
+ # be asked, or has no worker for it: the loop says why
142
+ def stats_metrics = @client.stats
143
+
144
+ # /exit --delete, once the worker let go: DELETE …?force=true there
145
+ # (SessionManager.delete_session's refusals, raised from chi web's
146
+ # fields).
147
+ def delete_session
148
+ @remote.delete(session_id, force: true)
149
+ rescue SessionAccess::RemoteFailed => e
150
+ raise SessionAccess::WebBridgeClient::Unreachable, e.message
151
+ end
152
+
153
+ # /archive: POST …/archive there ({discarded: […]}, as
154
+ # SessionManager.archive_session's).
155
+ def archive_session
156
+ @remote.archive(session_id)
157
+ rescue SessionAccess::RemoteFailed => e
158
+ raise SessionAccess::WebBridgeClient::Unreachable, e.message
159
+ end
160
+
161
+ # That machine's newest chi (the wake's).
162
+ def installed_version = @woke.installed
163
+
164
+ # That machine's config's default model: the wake's, then the newest
165
+ # worker read's (a restart may come with another config).
166
+ def default_model_name = @default_model
167
+
168
+ # Read again now (a parent's report at the end of a run), the wake's
169
+ # when chi web can't be asked.
170
+ def parent_strategy
171
+ @remote.attach_worker(session_id).strategy
172
+ rescue StandardError
173
+ @woke.strategy
174
+ end
175
+
176
+ # / commands and this machine's @path files (images are ingested here);
177
+ # no memory names: they would be this machine's (P4-D11).
178
+ def completions = Completions.new(paths: true, memories: false)
179
+
180
+ def reconnect_delays = RECONNECT_DELAYS
181
+
182
+ def via = @remote.invocation
183
+
184
+ # Why chi web couldn't be asked, the last time it couldn't (its
185
+ # message: the remote's label and the cause), for the closing line.
186
+ attr_reader :failure
187
+
188
+ # The images' scratch dir goes with the attach.
189
+ def close
190
+ FileUtils.rm_rf(@image_dir) if @image_dir
191
+ @image_dir = nil
192
+ end
193
+
194
+ private
195
+
196
+ def session_id = @client.session_id
197
+
198
+ def image_dir = @image_dir ||= Dir.mktmpdir("chi-attach-images")
199
+
200
+ def follow(worker)
201
+ @no_worker_since = nil
202
+ @woken_once = false
203
+ @features = Array(worker&.fetch("features", nil))
204
+ key = worker && (worker["epoch"] || worker["started_at"])
205
+ @client.worker_key = key if key
206
+ [@client, nil]
207
+ end
208
+
209
+ # nil while chi web hasn't said "no worker" for WAKE_AFTER yet, or
210
+ # once this outage's wake was made (or failed).
211
+ def wake_once
212
+ now = @clock.call
213
+ @no_worker_since ||= now
214
+ return nil if @woken_once || now - @no_worker_since < WAKE_AFTER
215
+
216
+ @woken_once = true
217
+ woke = @remote.attach_wake(session_id, wait: WAKE_WAIT)
218
+ follow(woke.worker)
219
+ rescue SessionAccess::Refused, SessionManager::OwnedByTUI => e
220
+ @failure = e.message
221
+ nil
222
+ end
223
+
224
+ # nil without a turn id (an older session's prompt) or when chi web
225
+ # can't give them (a join reads them within JOIN_READ_TIMEOUT).
226
+ def turn_records(turn_id)
227
+ return nil if turn_id.to_s.empty?
228
+
229
+ json = @client.turn_records(turn_id)
230
+ json.is_a?(Hash) ? json : nil
231
+ rescue StandardError => e
232
+ Log.debug(:attached, "turn_records_failed", error: e.class.name, message: e.message)
233
+ nil
234
+ end
235
+
236
+ def refresh_features
237
+ state = @remote.attach_worker(session_id)
238
+ @features = Array(state.worker&.fetch("features", nil)) if state.live
239
+ @default_model = state.default_model if state.default_model
240
+ rescue StandardError => e
241
+ Log.debug(:attached, "worker_features_failed", error: e.class.name, message: e.message)
242
+ end
243
+ end
244
+ end
245
+ end
@@ -20,6 +20,7 @@ module Samagotchi
20
20
  # @return [String, nil] one "chi> ..." line, or nil when all is current
21
21
  def at_attach(worker:, installed:, session_id:, terminal: VERSION, via: ChiInvocation::HERE)
22
22
  return nil if worker.to_s.empty?
23
+ return across_machines(worker: worker, installed: installed, session_id: session_id, terminal: terminal, via: via) if via.remote?
23
24
 
24
25
  newest = [installed, terminal].select { |v| Gem::Version.correct?(v.to_s) && !v.to_s.empty? }
25
26
  .max_by { |v| Gem::Version.new(v) }
@@ -41,6 +42,28 @@ module Samagotchi
41
42
  end
42
43
  end
43
44
 
45
+ # A remote attach (P4-D10): the worker and +installed+ are that
46
+ # machine's, the terminal this one's, so each is advised where it
47
+ # runs: the worker older than that machine's newest moves with chi
48
+ # --remote NAME sessions restart; this terminal older than the newer
49
+ # of the two updates here.
50
+ # @return [String, nil]
51
+ def across_machines(worker:, installed:, session_id:, terminal:, via:)
52
+ there = [installed, worker].select { |v| Gem::Version.correct?(v.to_s) && !v.to_s.empty? }
53
+ .max_by { |v| Gem::Version.new(v) }
54
+ worker_old = InstalledVersions.newer?(installed, worker)
55
+ terminal_old = InstalledVersions.newer?(there, terminal)
56
+ return nil unless worker_old || terminal_old
57
+
58
+ lines = []
59
+ if worker_old
60
+ lines << "the session's worker runs chi #{worker}; #{via.remote} has #{installed} installed: " \
61
+ "#{via.command("sessions restart #{session_id.to_s[0, 8]}")}"
62
+ end
63
+ lines << "#{worker_old ? "This" : "this"} terminal runs chi #{terminal} (#{via.remote} has #{there}): chi update here" if terminal_old
64
+ "chi> #{lines.join(". ")}"
65
+ end
66
+
44
67
  # After the stream moved to a new worker (a restart).
45
68
  def restarted(worker)
46
69
  worker.to_s.empty? ? "chi> the session's worker restarted" : "chi> the session's worker restarted on chi #{worker}"
@@ -867,7 +867,10 @@ module Samagotchi
867
867
  route = ComposerRoute.call(input, running: false, registry: command_registry)
868
868
  return @surface.commit(STEER_CHOICE_USAGE) if route.kind == :usage
869
869
 
870
- input = route.text if route.choice
870
+ if route.choice
871
+ input = route.text
872
+ @surface.commit(STEER_CHOICE_IDLE)
873
+ end
871
874
 
872
875
  # /model, /models, !rollback, !cmd, /continue (shared with workers);
873
876
  # an anytime command's cards print as it shows them (btw's
@@ -5,12 +5,71 @@ require_relative "../process_group"
5
5
  module Samagotchi
6
6
  module Testing
7
7
  # Waiting with a deadline, for a test that waits on a thread or a
8
- # process (Helpers has them).
8
+ # process (Helpers has them). Every deadline is the given +timeout+ ×
9
+ # Waiting.time_scale (SPEC_TIME_SCALE, CI sets 2): a slow runner gets
10
+ # more headroom, a fast one never waits longer than its condition takes.
9
11
  module Waiting
12
+ # A wait whose deadline passed: its message names what it waited for
13
+ # (Waiting.timed_out_message).
14
+ class TimedOut < StandardError; end
15
+
16
+ # The most of a +seen:+ value's inspect a message shows.
17
+ SEEN_LIMIT = 300
18
+
19
+ class << self
20
+ # SPEC_TIME_SCALE as a Float, at least 1 (unset or empty: 1). A value
21
+ # that isn't a number gives 1 and one warning. Read per call.
22
+ def time_scale
23
+ raw = ENV.fetch("SPEC_TIME_SCALE", "").strip
24
+ return 1.0 if raw.empty?
25
+
26
+ [Float(raw), 1.0].max
27
+ rescue ArgumentError
28
+ unless @warned_scale
29
+ @warned_scale = true
30
+ warn("SPEC_TIME_SCALE=#{raw.inspect} is not a number: waiting with scale 1")
31
+ end
32
+ 1.0
33
+ end
34
+
35
+ # +timeout+ s scaled: the deadline a helper waits for.
36
+ def scaled(timeout, scale = time_scale) = timeout * scale
37
+
38
+ # How long a wait took to give up: "5 s", or with a scale
39
+ # "10.0 s (5 s × SPEC_TIME_SCALE 2)".
40
+ def waited(timeout, scale = time_scale)
41
+ return "#{timeout} s" if scale == 1
42
+
43
+ "#{scaled(timeout, scale).round(3).to_f} s (#{timeout} s × SPEC_TIME_SCALE #{number(scale)})"
44
+ end
45
+
46
+ # The one failure text of wait_for, join! and pop!:
47
+ # "timed out after 5 s waiting for <what>", then "; last saw: <value>"
48
+ # when +seen+ (a callable) is given, its value's inspect cut to
49
+ # SEEN_LIMIT chars.
50
+ def timed_out_message(what, timeout, scale: time_scale, seen: nil)
51
+ message = "timed out after #{waited(timeout, scale)} waiting for #{what}"
52
+ seen ? "#{message}; last saw: #{seen_text(seen)}" : message
53
+ end
54
+
55
+ private
56
+
57
+ def number(value) = value == value.to_i ? value.to_i.to_s : value.to_s
58
+
59
+ def seen_text(seen)
60
+ text = seen.call.inspect
61
+ text.length > SEEN_LIMIT ? "#{text[0, SEEN_LIMIT]}…" : text
62
+ rescue StandardError => e
63
+ "(raised #{e.class}: #{e.message})"
64
+ end
65
+ end
66
+
10
67
  # The block's value once truthy, polled every +interval+ s; after
11
- # +timeout+ s its last (falsy) value.
68
+ # +timeout+ s (scaled) its last (falsy) value. For a wait whose
69
+ # timeout is fine (a bounded best-effort wait); a wait that must hold
70
+ # is #wait_for.
12
71
  def wait_until(timeout: 5, interval: 0.01)
13
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
72
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + Waiting.scaled(timeout)
14
73
  until (value = yield)
15
74
  return yield if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
16
75
 
@@ -19,15 +78,39 @@ module Samagotchi
19
78
  value
20
79
  end
21
80
 
22
- # #wait_until, raising "timed out after N s waiting for <what>" when
23
- # the time runs out: for a wait no check follows.
24
- def wait_until!(what, timeout: 5, interval: 0.01, &)
25
- wait_until(timeout: timeout, interval: interval, &) || raise("timed out after #{timeout} s waiting for #{what}")
81
+ # Polls the block every +interval+ s until it returns truthy and
82
+ # returns that value. After +timeout+ s (scaled) it raises
83
+ # Waiting::TimedOut naming +what+ ("timed out after 5 s waiting for
84
+ # <what>"); +seen+ (a callable) adds what it last saw, since a falsy
85
+ # block value tells nothing.
86
+ def wait_for(what, timeout: 5, interval: 0.01, seen: nil, &)
87
+ wait_until(timeout: timeout, interval: interval, &) ||
88
+ raise(TimedOut, Waiting.timed_out_message(what, timeout, seen: seen))
89
+ end
90
+
91
+ # Thread#join with a deadline (scaled): a thread that never ends fails
92
+ # the test with "<what> to finish" named, not after a suite-wide guard.
93
+ # Raises what the thread raised, as #join does.
94
+ # @return [Thread]
95
+ def join!(thread, what, timeout: 5)
96
+ thread.join(Waiting.scaled(timeout)) || raise(TimedOut, Waiting.timed_out_message("#{what} to finish", timeout))
97
+ end
98
+
99
+ # Queue#pop with a deadline (scaled), for a queue that never carries
100
+ # nil: nothing pushed in time fails with +what+ named. A gate a stubbed
101
+ # lib thread waits on while the test works takes a longer +timeout+
102
+ # (30): it only has to end a hang, never cut a slow test short.
103
+ # @return the value popped.
104
+ def pop!(queue, what, timeout: 5)
105
+ value = queue.pop(timeout: Waiting.scaled(timeout))
106
+ raise(TimedOut, Waiting.timed_out_message(what, timeout)) if value.nil?
107
+
108
+ value
26
109
  end
27
110
 
28
111
  # Whether no process +pid+ is left (a killed grandchild is reaped a
29
- # moment later: wait_until { process_gone?(pid) }). One that isn't
30
- # ours to signal is still there (ProcessGroup.alive?).
112
+ # moment later: wait_for("pid gone") { process_gone?(pid) }). One that
113
+ # isn't ours to signal is still there (ProcessGroup.alive?).
31
114
  def process_gone?(pid) = !ProcessGroup.alive?(pid)
32
115
  end
33
116
  end
@@ -99,9 +99,10 @@ module Samagotchi
99
99
  # status. Only while this chi runs. A record a refresh finished in
100
100
  # between (failed, no exit code) is corrected; a stopped task (its
101
101
  # stop flag on disk) is left as task_stop records it.
102
- # The thread is named EXIT_STATUS_THREAD (a spec joins them before it
103
- # removes the tasks' folder) here, before it returns: named from
104
- # inside, a thread not yet scheduled was missed by that join.
102
+ # The thread is named EXIT_STATUS_THREAD (a spec joins them with
103
+ # #quiesce! before it removes the tasks' folder) here, before it
104
+ # returns: named from inside, a thread not yet scheduled was missed by
105
+ # that join.
105
106
  # @return [Thread]
106
107
  def record_exit_status(waiter, path, task_id:)
107
108
  # Absolute now: safe against a later chdir.
@@ -132,6 +133,19 @@ module Samagotchi
132
133
  # record_exit_status's threads' name.
133
134
  EXIT_STATUS_THREAD = "task_exit_status"
134
135
 
136
+ # Joins every live EXIT_STATUS_THREAD, for up to +timeout+ s in all: a
137
+ # spec calls it (with_task_dir) after stopping its tasks and before it
138
+ # removes their folder, which a recorder may still write into. chi
139
+ # itself doesn't call it.
140
+ # @return [Array<Thread>] the ones still alive after +timeout+ (empty
141
+ # when quiet)
142
+ def quiesce!(timeout: 5)
143
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
144
+ threads = Thread.list.select { |thread| thread.name == EXIT_STATUS_THREAD && thread != Thread.current }
145
+ threads.each { |thread| thread.join([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max) }
146
+ threads.select(&:alive?)
147
+ end
148
+
135
149
  # Reasons a refresh finishes a record with when it read no exit code.
136
150
  MISSING_CODE_REASONS = ["process_ended_without_exit_code", NOT_CHIS_PROCESS].freeze
137
151
 
@@ -159,13 +159,16 @@ module Samagotchi
159
159
  # Every mark of the turn carries +enqueued_id+ (InterruptedTurn's).
160
160
  # @param prompt [String, nil]
161
161
  # @param enqueued_id [String, nil] the input the turn runs
162
- def begin_turn(prompt: nil, enqueued_id: nil)
162
+ # @param author [Hash] a participant's prompt's author fields
163
+ # (Engine#author_fields), so a kill before the first boundary leaves
164
+ # it theirs; {} for the owner's
165
+ def begin_turn(prompt: nil, enqueued_id: nil, author: {})
163
166
  @boundaries = 0
164
167
  @enqueued_id = enqueued_id.to_s.empty? ? nil : enqueued_id.to_s
165
168
  @written_at = nil
166
169
  @started_at = Time.now.iso8601(3)
167
170
  messages = Array(@session.messages).dup
168
- messages << { role: "user", content: prompt } unless prompt.to_s.empty?
171
+ messages << { role: "user", content: prompt, **author } unless prompt.to_s.empty?
169
172
  @session.turn_checkpoint = { messages: messages, marker: InterruptedTurn.new(started_at: @started_at, at: @started_at, iteration: 0,
170
173
  enqueued_id: @enqueued_id) }
171
174
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.103.0"
4
+ VERSION = "0.105.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end