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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +93 -1
- data/bin/chi +60 -191
- data/docs/architecture.md +67 -1
- data/docs/cli.md +190 -9
- data/docs/configuration.md +7 -1
- data/docs/guardrails.md +4 -2
- data/docs/internals/context-telemetry.md +6 -2
- data/docs/internals/engine.md +5 -2
- data/docs/internals/llm-context-forget.md +11 -1
- data/docs/internals/participants.md +137 -0
- data/docs/internals/remote-sessions.md +429 -0
- data/docs/internals/working-set.md +95 -11
- data/docs/plugins.md +33 -3
- data/docs/sessions.md +16 -7
- data/docs/sub-agent.md +16 -0
- data/docs/testing.md +68 -4
- data/lib/samagotchi/answer_command.rb +27 -25
- data/lib/samagotchi/bridge.rb +31 -7
- data/lib/samagotchi/bridge_client/event_stream.rb +86 -12
- data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
- data/lib/samagotchi/bridge_client.rb +26 -39
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_invocation.rb +40 -0
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/flags.rb +5 -0
- data/lib/samagotchi/cli/parent_wait.rb +22 -13
- data/lib/samagotchi/context_command.rb +80 -73
- data/lib/samagotchi/context_status.rb +15 -2
- data/lib/samagotchi/engine.rb +28 -14
- data/lib/samagotchi/guardrail_wiring.rb +7 -1
- data/lib/samagotchi/idle_recap.rb +8 -2
- data/lib/samagotchi/idle_scheduler.rb +34 -9
- data/lib/samagotchi/kernel_loop.rb +21 -2
- data/lib/samagotchi/launch_mode.rb +26 -0
- data/lib/samagotchi/llm/chat_loop.rb +8 -1
- data/lib/samagotchi/llm_context_forget.rb +13 -6
- data/lib/samagotchi/note_command.rb +8 -7
- data/lib/samagotchi/owner_lock.rb +65 -5
- data/lib/samagotchi/parent_report.rb +55 -43
- data/lib/samagotchi/participants.rb +122 -0
- data/lib/samagotchi/remote_attach_command.rb +119 -0
- data/lib/samagotchi/remote_command.rb +170 -0
- data/lib/samagotchi/remotes.rb +130 -0
- data/lib/samagotchi/run_options.rb +245 -0
- data/lib/samagotchi/send_command/busy.rb +58 -0
- data/lib/samagotchi/send_command/parent_gate.rb +23 -10
- data/lib/samagotchi/send_command/wait_target.rb +16 -3
- data/lib/samagotchi/send_command.rb +173 -108
- data/lib/samagotchi/session.rb +38 -9
- data/lib/samagotchi/session_access/contract.rb +167 -0
- data/lib/samagotchi/session_access/dispatch.rb +152 -0
- data/lib/samagotchi/session_access/http_transport.rb +109 -0
- data/lib/samagotchi/session_access/local.rb +400 -0
- data/lib/samagotchi/session_access/remote.rb +734 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +236 -0
- data/lib/samagotchi/session_access.rb +223 -0
- data/lib/samagotchi/session_archive_command.rb +9 -5
- data/lib/samagotchi/session_commands.rb +20 -15
- data/lib/samagotchi/session_continue.rb +1 -1
- data/lib/samagotchi/session_delete_command.rb +8 -4
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +27 -6
- data/lib/samagotchi/session_setup.rb +11 -6
- data/lib/samagotchi/sessions_command.rb +36 -14
- data/lib/samagotchi/share_command.rb +174 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +66 -17
- data/lib/samagotchi/terminal_ui/attach_link.rb +114 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +155 -87
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +135 -0
- 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 +28 -3
- data/lib/samagotchi/terminal_ui.rb +6 -3
- data/lib/samagotchi/testing/waiting.rb +92 -9
- data/lib/samagotchi/title_command.rb +9 -11
- data/lib/samagotchi/tools/task_runtime.rb +17 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +530 -126
- data/lib/samagotchi/web/grants.rb +302 -0
- data/lib/samagotchi/web/participant_access.rb +286 -0
- data/lib/samagotchi/web/principal.rb +29 -0
- data/lib/samagotchi/web/public/data.js +3 -2
- data/lib/samagotchi/web/public/index.html +20 -4
- data/lib/samagotchi/web/public/stage_frame.js +7 -2
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/stream_handlers.js +3 -1
- data/lib/samagotchi/web/public/working_set.js +159 -28
- data/lib/samagotchi/web/public/working_set_rail.js +39 -12
- data/lib/samagotchi/web/public/working_set_view.js +91 -33
- data/lib/samagotchi/web/server.rb +3 -1
- data/lib/samagotchi/web/session_access_routes.rb +459 -0
- data/lib/samagotchi/worker.rb +26 -5
- data/lib/samagotchi/worker_sidecar.rb +12 -5
- data/lib/samagotchi/working_set.rb +88 -14
- data/lib/samagotchi/working_set_command.rb +133 -30
- data/lib/samagotchi/working_set_pin.rb +34 -0
- data/lib/samagotchi/working_set_steer.rb +191 -27
- 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 = "
|
|
30
|
-
attach = "/detach, then
|
|
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
|
-
|
|
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
|
|
894
|
-
save_session(session) if command.changed.
|
|
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
|
-
#
|
|
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
|