samagotchi 0.102.0 → 0.104.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -1
  3. data/bin/chi +60 -191
  4. data/docs/architecture.md +67 -1
  5. data/docs/cli.md +190 -9
  6. data/docs/configuration.md +7 -1
  7. data/docs/guardrails.md +4 -2
  8. data/docs/internals/context-telemetry.md +6 -2
  9. data/docs/internals/engine.md +5 -2
  10. data/docs/internals/llm-context-forget.md +11 -1
  11. data/docs/internals/participants.md +137 -0
  12. data/docs/internals/remote-sessions.md +429 -0
  13. data/docs/internals/working-set.md +95 -11
  14. data/docs/plugins.md +33 -3
  15. data/docs/sessions.md +16 -7
  16. data/docs/sub-agent.md +16 -0
  17. data/docs/testing.md +68 -4
  18. data/lib/samagotchi/answer_command.rb +27 -25
  19. data/lib/samagotchi/bridge.rb +31 -7
  20. data/lib/samagotchi/bridge_client/event_stream.rb +86 -12
  21. data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
  22. data/lib/samagotchi/bridge_client.rb +26 -39
  23. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
  25. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
  27. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
  28. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  29. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  30. data/lib/samagotchi/chi_invocation.rb +40 -0
  31. data/lib/samagotchi/cli/command.rb +3 -2
  32. data/lib/samagotchi/cli/flags.rb +5 -0
  33. data/lib/samagotchi/cli/parent_wait.rb +22 -13
  34. data/lib/samagotchi/context_command.rb +80 -73
  35. data/lib/samagotchi/context_status.rb +15 -2
  36. data/lib/samagotchi/engine.rb +28 -14
  37. data/lib/samagotchi/guardrail_wiring.rb +7 -1
  38. data/lib/samagotchi/idle_recap.rb +8 -2
  39. data/lib/samagotchi/idle_scheduler.rb +34 -9
  40. data/lib/samagotchi/kernel_loop.rb +21 -2
  41. data/lib/samagotchi/launch_mode.rb +26 -0
  42. data/lib/samagotchi/llm/chat_loop.rb +8 -1
  43. data/lib/samagotchi/llm_context_forget.rb +13 -6
  44. data/lib/samagotchi/note_command.rb +8 -7
  45. data/lib/samagotchi/owner_lock.rb +65 -5
  46. data/lib/samagotchi/parent_report.rb +55 -43
  47. data/lib/samagotchi/participants.rb +122 -0
  48. data/lib/samagotchi/remote_attach_command.rb +119 -0
  49. data/lib/samagotchi/remote_command.rb +170 -0
  50. data/lib/samagotchi/remotes.rb +130 -0
  51. data/lib/samagotchi/run_options.rb +245 -0
  52. data/lib/samagotchi/send_command/busy.rb +58 -0
  53. data/lib/samagotchi/send_command/parent_gate.rb +23 -10
  54. data/lib/samagotchi/send_command/wait_target.rb +16 -3
  55. data/lib/samagotchi/send_command.rb +173 -108
  56. data/lib/samagotchi/session.rb +38 -9
  57. data/lib/samagotchi/session_access/contract.rb +167 -0
  58. data/lib/samagotchi/session_access/dispatch.rb +152 -0
  59. data/lib/samagotchi/session_access/http_transport.rb +109 -0
  60. data/lib/samagotchi/session_access/local.rb +400 -0
  61. data/lib/samagotchi/session_access/remote.rb +734 -0
  62. data/lib/samagotchi/session_access/web_bridge_client.rb +236 -0
  63. data/lib/samagotchi/session_access.rb +223 -0
  64. data/lib/samagotchi/session_archive_command.rb +9 -5
  65. data/lib/samagotchi/session_commands.rb +20 -15
  66. data/lib/samagotchi/session_continue.rb +1 -1
  67. data/lib/samagotchi/session_delete_command.rb +8 -4
  68. data/lib/samagotchi/session_inbox.rb +18 -9
  69. data/lib/samagotchi/session_manager.rb +27 -6
  70. data/lib/samagotchi/session_setup.rb +11 -6
  71. data/lib/samagotchi/sessions_command.rb +36 -14
  72. data/lib/samagotchi/share_command.rb +174 -0
  73. data/lib/samagotchi/terminal_ui/attach_launcher.rb +66 -17
  74. data/lib/samagotchi/terminal_ui/attach_link.rb +114 -0
  75. data/lib/samagotchi/terminal_ui/attached_loop.rb +155 -87
  76. data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
  77. data/lib/samagotchi/terminal_ui/local_link.rb +135 -0
  78. data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
  79. data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
  80. data/lib/samagotchi/terminal_ui/version_lines.rb +28 -3
  81. data/lib/samagotchi/terminal_ui.rb +6 -3
  82. data/lib/samagotchi/testing/waiting.rb +92 -9
  83. data/lib/samagotchi/title_command.rb +9 -11
  84. data/lib/samagotchi/tools/task_runtime.rb +17 -3
  85. data/lib/samagotchi/version.rb +1 -1
  86. data/lib/samagotchi/web/app.rb +530 -126
  87. data/lib/samagotchi/web/grants.rb +302 -0
  88. data/lib/samagotchi/web/participant_access.rb +286 -0
  89. data/lib/samagotchi/web/principal.rb +29 -0
  90. data/lib/samagotchi/web/public/data.js +3 -2
  91. data/lib/samagotchi/web/public/index.html +20 -4
  92. data/lib/samagotchi/web/public/stage_frame.js +7 -2
  93. data/lib/samagotchi/web/public/stage_view.js +3 -2
  94. data/lib/samagotchi/web/public/stream_handlers.js +3 -1
  95. data/lib/samagotchi/web/public/working_set.js +159 -28
  96. data/lib/samagotchi/web/public/working_set_rail.js +39 -12
  97. data/lib/samagotchi/web/public/working_set_view.js +91 -33
  98. data/lib/samagotchi/web/server.rb +3 -1
  99. data/lib/samagotchi/web/session_access_routes.rb +459 -0
  100. data/lib/samagotchi/worker.rb +26 -5
  101. data/lib/samagotchi/worker_sidecar.rb +12 -5
  102. data/lib/samagotchi/working_set.rb +88 -14
  103. data/lib/samagotchi/working_set_command.rb +133 -30
  104. data/lib/samagotchi/working_set_pin.rb +34 -0
  105. data/lib/samagotchi/working_set_steer.rb +191 -27
  106. metadata +28 -1
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "attach_link"
4
+ require_relative "../session"
5
+ require_relative "../bridge_client"
6
+ require_relative "../worker_sidecar"
7
+ require_relative "../delivery"
8
+ require_relative "../session_inbox"
9
+ require_relative "../image_store"
10
+ require_relative "../session_metrics"
11
+ require_relative "../children_status"
12
+ require_relative "../session_manager"
13
+ require_relative "../installed_versions"
14
+ require_relative "../model_profile"
15
+ require_relative "../parent_report"
16
+ require_relative "../chi_invocation"
17
+
18
+ module Samagotchi
19
+ class TerminalUI
20
+ # The AttachLink of a session on this machine: its worker's Bridge
21
+ # (found through the sidecar in the session folder) and the session's
22
+ # files under +state_dir+.
23
+ class LocalLink
24
+ include AttachLink
25
+
26
+ # How long /exit --delete and /archive wait for the worker to let go
27
+ # (AttachedLoop's defaults too).
28
+ DELETE_WAIT = 10
29
+
30
+ attr_reader :client
31
+
32
+ # @param client [BridgeClient] the live worker's
33
+ # @param state_dir [String] the state dir the worker was found in
34
+ # @param delete_session [#call] session id -> deletes it
35
+ # @param archive_session [#call] session id -> archives it
36
+ # (SessionManager.archive_session's result)
37
+ # @param installed_version [#call] -> the newest chi installed
38
+ # @param children_counts [#call] session id -> ChildrenStatus::Counts
39
+ def initialize(client:, state_dir: Session.default_state_dir,
40
+ delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
41
+ archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) },
42
+ installed_version: -> { InstalledVersions.new.newest },
43
+ children_counts: ->(id) { ChildrenStatus.counts(id, state_dir: state_dir) })
44
+ @client = client
45
+ @state_dir = state_dir
46
+ @delete_session = delete_session
47
+ @archive_session = archive_session
48
+ @installed_version = installed_version
49
+ @children_counts = children_counts
50
+ end
51
+
52
+ # The sidecar's worker, nil while none runs; gone once the session was
53
+ # stopped (no worker is coming): its stop marker in +state_dir+.
54
+ def live_worker
55
+ id = @client.session_id
56
+ return [:gone, "stopped"] if Session.stopped_marker?(id, state_dir: @state_dir)
57
+
58
+ [BridgeClient.discover(id, session_dir: session_dir, host: @client.host), nil]
59
+ end
60
+
61
+ # A new worker's Bridge, on the port its event names.
62
+ def switched(event)
63
+ @client = BridgeClient.new(session_id: @client.session_id, port: AttachLink.event_port(event), host: @client.host)
64
+ end
65
+
66
+ # No sidecar to read: true, its Bridge answers (a P0-P2 worker refuses
67
+ # a queue).
68
+ def queues? = feature?(Delivery::QUEUE_FEATURE)
69
+
70
+ # No sidecar to read: true, its Bridge answers (an older one's 404
71
+ # not_found says so).
72
+ def unqueues? = feature?(SessionInbox::UNQUEUE_FEATURE)
73
+
74
+ # The worker's stats; a worker from before the stats route answers
75
+ # only /state.
76
+ def stats_metrics
77
+ @client.get_json("stats")&.dig("metrics") || @client.get_json("state")&.dig("session_state_snapshot", "metrics")
78
+ end
79
+
80
+ # Stored in the session's images/ here (the worker's state dir).
81
+ def ingest_images(paths)
82
+ paths.map do |path|
83
+ ref = ImageStore.ingest(session_dir, path: path)
84
+ { file: ref[:file], name: ref[:name] }
85
+ end
86
+ end
87
+
88
+ # SessionMetrics, in this machine's state dir: none for a prompt with
89
+ # no turn id (an older session) or a session with no analytics.
90
+ def saved_tool_records(turn_id)
91
+ SessionMetrics.saved_tool_records(session_dir, turn_id)
92
+ rescue Session::InvalidId
93
+ []
94
+ end
95
+
96
+ def saved_turn_record(turn_id)
97
+ SessionMetrics.saved_turn_record(session_dir, turn_id)
98
+ rescue Session::InvalidId
99
+ nil
100
+ end
101
+
102
+ def children_counts = @children_counts.call(@client.session_id)
103
+
104
+ def delete_session = @delete_session.call(@client.session_id)
105
+
106
+ def archive_session = @archive_session.call(@client.session_id)
107
+
108
+ def installed_version = @installed_version.call
109
+
110
+ def default_model_name = ModelProfile.default_model_name
111
+
112
+ def parent_strategy = ParentReport.strategy_for(@client.session_id, state_dir: @state_dir)
113
+
114
+ def completions = Completions::ALL
115
+
116
+ def reconnect_delays = BridgeClient::EventStream::REDISCOVER_DELAYS
117
+
118
+ def via = ChiInvocation::HERE
119
+
120
+ # The closing line says it all (unreachable, unknown_session).
121
+ def failure = nil
122
+
123
+ def close = nil
124
+
125
+ private
126
+
127
+ def session_dir = Session.session_dir(@client.session_id, state_dir: @state_dir)
128
+
129
+ def feature?(name)
130
+ sidecar = WorkerSidecar.read(session_dir)
131
+ sidecar.nil? || sidecar.features.include?(name)
132
+ end
133
+ end
134
+ end
135
+ end
@@ -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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../installed_versions"
4
4
  require_relative "../version"
5
+ require_relative "../chi_invocation"
5
6
 
6
7
  module Samagotchi
7
8
  class TerminalUI
@@ -15,9 +16,11 @@ module Samagotchi
15
16
  # @param worker [String, nil] the worker's chi_version (nil: a worker
16
17
  # from before it said so; nothing is said then)
17
18
  # @param installed [String, nil] InstalledVersions#newest
19
+ # @param via [ChiInvocation] how the commands in it are spelled
18
20
  # @return [String, nil] one "chi> ..." line, or nil when all is current
19
- def at_attach(worker:, installed:, session_id:, terminal: VERSION)
21
+ def at_attach(worker:, installed:, session_id:, terminal: VERSION, via: ChiInvocation::HERE)
20
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?
21
24
 
22
25
  newest = [installed, terminal].select { |v| Gem::Version.correct?(v.to_s) && !v.to_s.empty? }
23
26
  .max_by { |v| Gem::Version.new(v) }
@@ -26,8 +29,8 @@ module Samagotchi
26
29
  return nil unless worker_old || terminal_old
27
30
 
28
31
  id = session_id.to_s[0, 8]
29
- restart = "chi sessions restart #{id}"
30
- attach = "/detach, then chi --attach #{id}"
32
+ restart = via.command("sessions restart #{id}")
33
+ attach = "/detach, then #{via.attach(id)}"
31
34
  if worker_old && terminal_old
32
35
  "chi> chi #{newest} is installed; this session's worker runs #{worker} and this terminal #{terminal}. " \
33
36
  "#{restart} moves the worker; #{attach} for the terminal."
@@ -39,6 +42,28 @@ module Samagotchi
39
42
  end
40
43
  end
41
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
+
42
67
  # After the stream moved to a new worker (a restart).
43
68
  def restarted(worker)
44
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
@@ -890,8 +893,8 @@ module Samagotchi
890
893
  show_command_result(command)
891
894
  refresh_status_row(clear_unknown_context: true) if command.changed.include?(:model)
892
895
  refresh_status_row if command.changed.include?(:title)
893
- # /recap on|off|default doesn't save itself (a worker saves a changed command).
894
- save_session(session) if command.changed.include?(:recap)
896
+ # /recap and /title don't save themselves (a worker saves a changed command).
897
+ save_session(session) if command.changed.intersect?(%i[recap title])
895
898
  persist_recent_history(input) if command.shell
896
899
  return
897
900
  end
@@ -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