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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +76 -1
- data/bin/chi +39 -2
- data/docs/architecture.md +36 -24
- data/docs/cli.md +97 -21
- data/docs/configuration.md +1 -1
- data/docs/guardrails.md +15 -1
- data/docs/internals/anthropic.md +2 -1
- data/docs/internals/context-telemetry.md +6 -2
- data/docs/internals/engine.md +22 -1
- data/docs/internals/participants.md +167 -0
- data/docs/internals/prompt-caching.md +8 -0
- data/docs/internals/remote-sessions.md +153 -27
- data/docs/internals/working-set.md +4 -3
- data/docs/plugins.md +7 -1
- data/docs/sessions.md +4 -2
- data/docs/sub-agent.md +8 -0
- data/docs/testing.md +89 -4
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -1
- data/lib/samagotchi/bridge.rb +17 -10
- data/lib/samagotchi/bridge_client/event_stream.rb +9 -4
- data/lib/samagotchi/bridge_client.rb +4 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_invocation.rb +13 -16
- data/lib/samagotchi/config.rb +1 -1
- data/lib/samagotchi/context_status.rb +15 -2
- data/lib/samagotchi/engine.rb +52 -8
- data/lib/samagotchi/guardrail_wiring.rb +7 -1
- data/lib/samagotchi/idle_recap.rb +1 -1
- data/lib/samagotchi/idle_scheduler.rb +34 -9
- data/lib/samagotchi/kernel_loop.rb +8 -1
- data/lib/samagotchi/last_turn.rb +5 -2
- data/lib/samagotchi/launch_mode.rb +4 -0
- data/lib/samagotchi/llm/chat_loop.rb +9 -1
- data/lib/samagotchi/owner_lock.rb +65 -5
- data/lib/samagotchi/participant_name.rb +21 -0
- data/lib/samagotchi/participants.rb +122 -0
- data/lib/samagotchi/queued_command.rb +9 -5
- data/lib/samagotchi/remote_attach_command.rb +119 -0
- data/lib/samagotchi/run_options.rb +14 -2
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session.rb +8 -4
- data/lib/samagotchi/session_access/contract.rb +42 -4
- data/lib/samagotchi/session_access/dispatch.rb +29 -10
- data/lib/samagotchi/session_access/local.rb +52 -1
- data/lib/samagotchi/session_access/remote.rb +112 -16
- data/lib/samagotchi/session_access/web_bridge_client.rb +60 -3
- data/lib/samagotchi/session_access.rb +85 -3
- data/lib/samagotchi/session_continue.rb +1 -1
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +21 -5
- data/lib/samagotchi/session_metrics.rb +7 -1
- data/lib/samagotchi/session_participants.rb +59 -0
- data/lib/samagotchi/sessions_command.rb +14 -11
- data/lib/samagotchi/share_command.rb +174 -0
- data/lib/samagotchi/steer.rb +83 -10
- data/lib/samagotchi/system_prompt.rb +21 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +17 -4
- data/lib/samagotchi/terminal_ui/attach_link.rb +10 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +117 -29
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +5 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +21 -1
- data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
- data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +23 -0
- data/lib/samagotchi/terminal_ui.rb +4 -1
- data/lib/samagotchi/testing/waiting.rb +92 -9
- data/lib/samagotchi/tools/task_runtime.rb +17 -3
- data/lib/samagotchi/turn_checkpoint.rb +5 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +187 -74
- data/lib/samagotchi/web/grants.rb +303 -0
- data/lib/samagotchi/web/participant_access.rb +286 -0
- data/lib/samagotchi/web/principal.rb +29 -0
- data/lib/samagotchi/web/public/index.html +6 -5
- data/lib/samagotchi/web/public/working_set.js +6 -4
- data/lib/samagotchi/web/public/working_set_view.js +1 -1
- data/lib/samagotchi/web/server.rb +3 -1
- data/lib/samagotchi/web/session_access_routes.rb +120 -4
- data/lib/samagotchi/worker.rb +44 -11
- data/lib/samagotchi/worker_inbound.rb +5 -2
- 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
|
-
|
|
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
|
-
#
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
|
|
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:
|
|
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
|
|
103
|
-
# removes the tasks' folder) here, before it
|
|
104
|
-
# inside, a thread not yet scheduled was missed by
|
|
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
|
-
|
|
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
|