samagotchi 0.103.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 +54 -1
- data/bin/chi +39 -2
- data/docs/architecture.md +36 -24
- data/docs/cli.md +86 -12
- data/docs/guardrails.md +2 -1
- data/docs/internals/context-telemetry.md +6 -2
- data/docs/internals/engine.md +4 -1
- data/docs/internals/participants.md +137 -0
- data/docs/internals/remote-sessions.md +153 -27
- data/docs/plugins.md +7 -1
- data/docs/sessions.md +4 -2
- data/docs/sub-agent.md +8 -0
- data/docs/testing.md +68 -4
- data/lib/samagotchi/bridge.rb +7 -3
- 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/context_status.rb +15 -2
- data/lib/samagotchi/engine.rb +16 -6
- 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/launch_mode.rb +4 -0
- data/lib/samagotchi/llm/chat_loop.rb +8 -1
- data/lib/samagotchi/owner_lock.rb +65 -5
- data/lib/samagotchi/participants.rb +122 -0
- 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 +56 -1
- 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/sessions_command.rb +4 -4
- data/lib/samagotchi/share_command.rb +174 -0
- 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 +83 -20
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +5 -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 +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/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +187 -74
- 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/server.rb +3 -1
- data/lib/samagotchi/web/session_access_routes.rb +120 -4
- data/lib/samagotchi/worker.rb +26 -5
- metadata +10 -1
|
@@ -49,6 +49,8 @@ module Samagotchi
|
|
|
49
49
|
|
|
50
50
|
# The session's metrics for /stats, nil when the worker did not answer.
|
|
51
51
|
# @return [Hash, nil] string keys
|
|
52
|
+
# @raise [IOError] the session's machine couldn't be asked (a remote's
|
|
53
|
+
# Unreachable: the loop shows its message)
|
|
52
54
|
def stats_metrics = raise(NotImplementedError)
|
|
53
55
|
|
|
54
56
|
# Store a prompt's @path images where the worker reads them.
|
|
@@ -99,6 +101,14 @@ module Samagotchi
|
|
|
99
101
|
# How the commands the TUI prints are spelled.
|
|
100
102
|
# @return [ChiInvocation]
|
|
101
103
|
def via = raise(NotImplementedError)
|
|
104
|
+
|
|
105
|
+
# Why the session's machine couldn't be asked the last time (for the
|
|
106
|
+
# stream's closing line), nil when there is nothing more to say.
|
|
107
|
+
# @return [String, nil]
|
|
108
|
+
def failure = raise(NotImplementedError)
|
|
109
|
+
|
|
110
|
+
# The attach is over (AttachLauncher.run): let go of what it held.
|
|
111
|
+
def close = raise(NotImplementedError)
|
|
102
112
|
end
|
|
103
113
|
end
|
|
104
114
|
end
|
|
@@ -335,10 +335,13 @@ module Samagotchi
|
|
|
335
335
|
@status.take_event(event)
|
|
336
336
|
@renderer.call(event)
|
|
337
337
|
when :worker_changed then switch_worker(event)
|
|
338
|
+
# A stream through chi web (never a Bridge here): a reconnect failed,
|
|
339
|
+
# and the next frame came.
|
|
340
|
+
when :stream_retrying then @screen.commit("(lost the connection to #{via.remote || "the worker"}; reconnecting…)")
|
|
341
|
+
when :stream_resumed then @screen.commit("(reconnected)")
|
|
338
342
|
when :stream_closed
|
|
339
343
|
@view.finish_thinking_spinner
|
|
340
|
-
@screen.commit(
|
|
341
|
-
"Resume it with: #{via.command("--shared --resume #{@client.session_id}")}")
|
|
344
|
+
@screen.commit(closed_line(event[:reason].to_s))
|
|
342
345
|
return :closed
|
|
343
346
|
else @renderer.call(event)
|
|
344
347
|
end
|
|
@@ -358,24 +361,47 @@ module Samagotchi
|
|
|
358
361
|
def via = @link.via
|
|
359
362
|
|
|
360
363
|
# The session's live worker for the stream to follow (EventStream's
|
|
361
|
-
# rediscover)
|
|
362
|
-
#
|
|
363
|
-
|
|
364
|
-
# always had here.
|
|
365
|
-
def live_worker
|
|
366
|
-
found, _reason = @link.live_worker
|
|
367
|
-
found
|
|
368
|
-
end
|
|
364
|
+
# rediscover), with its reason (AttachLink#live_worker's pair): the
|
|
365
|
+
# stream's closing event carries the last one (#closed_line).
|
|
366
|
+
def live_worker = @link.live_worker
|
|
369
367
|
|
|
370
368
|
# The stream moved to a new worker (a restart): requests go there too.
|
|
371
369
|
def switch_worker(event)
|
|
372
370
|
@client = @link.switched(event)
|
|
373
371
|
@worker_changed = true
|
|
372
|
+
# The new worker's machine may name another default model.
|
|
373
|
+
@default_model_name = nil
|
|
374
374
|
@view.finish_thinking_spinner
|
|
375
|
-
Log.info(:attached, "worker_changed", port: AttachLink.event_port(event))
|
|
375
|
+
Log.info(:attached, "worker_changed", port: AttachLink.event_port(event), epoch: event[:epoch])
|
|
376
376
|
nil
|
|
377
377
|
end
|
|
378
378
|
|
|
379
|
+
# The stream ended for good: why, and what brings the session back.
|
|
380
|
+
def closed_line(reason)
|
|
381
|
+
id = @client.session_id
|
|
382
|
+
return remote_closed_line(reason, id) if via.remote?
|
|
383
|
+
|
|
384
|
+
# Here a stopped session has always read as unreachable.
|
|
385
|
+
reason = "unreachable" if reason == "stopped"
|
|
386
|
+
"Lost the session's worker (#{reason}). Resume it with: #{via.command("--shared --resume #{id}")}"
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Each reason in its own words (the remote's name, its chi web's
|
|
390
|
+
# failure as AttachLink#failure gave it).
|
|
391
|
+
def remote_closed_line(reason, id)
|
|
392
|
+
name = via.remote
|
|
393
|
+
failure = @link.failure
|
|
394
|
+
case reason
|
|
395
|
+
when "stopped" then "The session was stopped on #{name}. Resume it with: #{via.resume(id)}"
|
|
396
|
+
when "auth_failed", "too_old" then "Lost the session: #{failure || reason}"
|
|
397
|
+
when "worker_gone" then "The session's worker on #{name} is gone. Resume it with: #{via.resume(id)}"
|
|
398
|
+
when "unknown_session" then "The session is gone from #{name} (deleted)."
|
|
399
|
+
else
|
|
400
|
+
cause = failure&.sub(/\Aremote \S+ \([^)]*\): /, "")
|
|
401
|
+
"Lost the connection to #{name}#{" (#{cause})" if cause}. Re-attach with: #{via.attach(id)}"
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
379
405
|
def next_item(queue)
|
|
380
406
|
queue.pop
|
|
381
407
|
rescue Interrupt
|
|
@@ -404,6 +430,31 @@ module Samagotchi
|
|
|
404
430
|
return answer_question(text)
|
|
405
431
|
end
|
|
406
432
|
|
|
433
|
+
text, kept = split_prefilled(text)
|
|
434
|
+
submitted = submit_line(text)
|
|
435
|
+
# The removed text the command was typed after, back in the input
|
|
436
|
+
# (unless an /unqueue put its own there, or the loop ends).
|
|
437
|
+
@prefilled = kept if kept && submitted != :detach && @prefilled.nil? && @reader&.prefill(kept)
|
|
438
|
+
submitted
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# A line typed after an /unqueue's text (#unqueued) without clearing
|
|
442
|
+
# it, the text untouched and then a command ("fix it/unqueue"): the
|
|
443
|
+
# command alone, and the text to put back. Any other line as is.
|
|
444
|
+
# @return [Array(String, String|nil)]
|
|
445
|
+
def split_prefilled(text)
|
|
446
|
+
kept = @prefilled
|
|
447
|
+
@prefilled = nil
|
|
448
|
+
return [text, nil] unless kept && text.length > kept.length && text.start_with?(kept)
|
|
449
|
+
|
|
450
|
+
rest = text[kept.length..].strip
|
|
451
|
+
route = ComposerRoute.call(rest, running: @running, registry: command_registry)
|
|
452
|
+
command = route.choice || %i[local command usage].include?(route.kind)
|
|
453
|
+
command ? [rest, kept] : [text, nil]
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# @return [Symbol, nil] :detach to end the loop
|
|
457
|
+
def submit_line(text)
|
|
407
458
|
# /cut TEXT and /queue TEXT are this UI's own deliveries, read before
|
|
408
459
|
# every command check (ComposerRoute; SessionCommands registers them
|
|
409
460
|
# for Tab and /help only). A plain line sends no delivery: it goes in
|
|
@@ -460,8 +511,9 @@ module Samagotchi
|
|
|
460
511
|
return @screen.commit(STEER_CHOICE_USAGE) if route.kind == :usage
|
|
461
512
|
return @screen.commit(queue_needs_newer_worker) if Delivery.queue?(route.delivery) && !worker_queues?
|
|
462
513
|
|
|
463
|
-
# To an idle session it is an ordinary prompt: no delivery to send
|
|
464
|
-
|
|
514
|
+
# To an idle session it is an ordinary prompt: no delivery to send,
|
|
515
|
+
# and its ack says it was sent now (#sent_note).
|
|
516
|
+
send_prompt(route.text, delivery: route.delivery, choice: route.choice)
|
|
465
517
|
nil
|
|
466
518
|
end
|
|
467
519
|
|
|
@@ -634,6 +686,9 @@ module Samagotchi
|
|
|
634
686
|
# Why a Bridge request raised, for the line that says what didn't go
|
|
635
687
|
# through: a stopped (or wedged) worker times out, a gone one refuses.
|
|
636
688
|
def worker_down(error)
|
|
689
|
+
# Through chi web: the message names the remote and what failed.
|
|
690
|
+
return error.message if via.remote?
|
|
691
|
+
|
|
637
692
|
head, detail = error.message.split(" - ", 2)
|
|
638
693
|
return "worker not answering: #{(detail || head).sub(/\Abridge \S+: /, "")}" if error.is_a?(Errno::ETIMEDOUT)
|
|
639
694
|
|
|
@@ -685,7 +740,9 @@ module Samagotchi
|
|
|
685
740
|
@screen.commit("removed: #{first_line_clip(prompt)}")
|
|
686
741
|
return if @wait_at_eof || prompt.strip.empty?
|
|
687
742
|
|
|
688
|
-
@
|
|
743
|
+
return @prefilled = prompt.strip if @reader&.prefill(prompt)
|
|
744
|
+
|
|
745
|
+
@screen.commit("(the removed message is in the input history: ↑)")
|
|
689
746
|
end
|
|
690
747
|
|
|
691
748
|
def first_line_clip(text, max = 60)
|
|
@@ -807,11 +864,13 @@ module Samagotchi
|
|
|
807
864
|
# from the ack (the worker knows whether cutting is on for its model);
|
|
808
865
|
# a queued one waits for the turn's end (the ack says queue).
|
|
809
866
|
# An idle session's prompt simply runs, and a prompt with images runs
|
|
810
|
-
# as its own next turn (never merging mid-turn), so nothing is said
|
|
867
|
+
# as its own next turn (never merging mid-turn), so nothing is said;
|
|
868
|
+
# one typed as /cut or /queue says it was sent now (+choice+).
|
|
811
869
|
# @param ack [Hash, nil] the Bridge's 202 body
|
|
812
870
|
# @param delivery [String, nil] what was asked for
|
|
813
871
|
# @return [String, nil]
|
|
814
|
-
def sent_note(ack, delivery: nil, images: [], running: false)
|
|
872
|
+
def sent_note(ack, delivery: nil, images: [], running: false, choice: nil)
|
|
873
|
+
return STEER_CHOICE_IDLE if choice && !running
|
|
815
874
|
return nil unless running && ack.is_a?(Hash)
|
|
816
875
|
# The Bridge acks queue only while a turn ran at enqueue; a /queue
|
|
817
876
|
# it acks next_step found the turn over: it is the next turn.
|
|
@@ -838,7 +897,8 @@ module Samagotchi
|
|
|
838
897
|
@client_id
|
|
839
898
|
end
|
|
840
899
|
|
|
841
|
-
|
|
900
|
+
# @param choice [Symbol, nil] :cut or :queue when typed with that word
|
|
901
|
+
def send_prompt(text, delivery: nil, choice: nil)
|
|
842
902
|
persist_recent_history(text)
|
|
843
903
|
images = attach_images(text)
|
|
844
904
|
return own_prompt_failed if images.nil?
|
|
@@ -854,7 +914,7 @@ module Samagotchi
|
|
|
854
914
|
@sent_ids << enqueued_id if enqueued_id
|
|
855
915
|
@open_ids << enqueued_id if enqueued_id && @wait_at_eof
|
|
856
916
|
@my_queued << enqueued_id if enqueued_id && reply.json&.fetch("delivery", nil) == Delivery::QUEUE
|
|
857
|
-
note = sent_note(reply.json, delivery: delivery, images: images, running: running)
|
|
917
|
+
note = sent_note(reply.json, delivery: delivery, images: images, running: running, choice: choice)
|
|
858
918
|
@screen.commit(note) if note
|
|
859
919
|
return
|
|
860
920
|
end
|
|
@@ -938,6 +998,9 @@ module Samagotchi
|
|
|
938
998
|
def show_stats
|
|
939
999
|
metrics = @link.stats_metrics
|
|
940
1000
|
@screen.commit(metrics ? format_session_metrics(EventRenderer.deep_symbolize_keys(metrics)) : "(no metrics: the worker did not answer)")
|
|
1001
|
+
rescue SystemCallError, IOError => e
|
|
1002
|
+
# Through chi web: the message names the remote and what failed.
|
|
1003
|
+
@screen.commit("(no metrics: #{via.remote? ? e.message : "the worker did not answer"})")
|
|
941
1004
|
end
|
|
942
1005
|
|
|
943
1006
|
# One read on the reader thread: the REPL's multiline read with Tab
|
|
@@ -980,8 +1043,8 @@ module Samagotchi
|
|
|
980
1043
|
@screen.commit(recap_command_text(enabled: answer["enabled"], saved: answer["saved"],
|
|
981
1044
|
request: answer["request"], min_user_turns: answer["min_user_turns"],
|
|
982
1045
|
session_off: answer["session_off"] == true))
|
|
983
|
-
rescue SystemCallError, IOError
|
|
984
|
-
@screen.commit("(no recap: the worker did not answer)")
|
|
1046
|
+
rescue SystemCallError, IOError => e
|
|
1047
|
+
@screen.commit("(no recap: #{via.remote? ? e.message : "the worker did not answer"})")
|
|
985
1048
|
end
|
|
986
1049
|
|
|
987
1050
|
def prompt_text
|
|
@@ -118,6 +118,9 @@ module Samagotchi
|
|
|
118
118
|
CUT_OFF_NOTE = "(cutting is off; goes in at the next step)"
|
|
119
119
|
# A bare /cut or /queue (ComposerRoute's :usage).
|
|
120
120
|
STEER_CHOICE_USAGE = "(/cut and /queue need a message: /cut TEXT, /queue TEXT)"
|
|
121
|
+
# A /cut TEXT or /queue TEXT with no turn running: the text is sent
|
|
122
|
+
# at once, a normal turn (ComposerRoute routes it on as a line).
|
|
123
|
+
STEER_CHOICE_IDLE = "(nothing is running: sent now)"
|
|
121
124
|
|
|
122
125
|
# @param outcome [Symbol] SteerCut#cut_for_steer's answer: :now, :waits or :off
|
|
123
126
|
# @param after [Integer, nil] the run's cut wait (the model's own
|
|
@@ -559,11 +562,20 @@ module Samagotchi
|
|
|
559
562
|
"#{label}: #{visible.join(", ")}#{suffix}"
|
|
560
563
|
end
|
|
561
564
|
|
|
565
|
+
# +text+ cut to +width+ with an ellipsis, at a word (or a " | "
|
|
566
|
+
# between segments) when one ends in the second half, so the row never
|
|
567
|
+
# stops mid-word (a plain surface prints it as is).
|
|
562
568
|
def cap_preview_text(text, width)
|
|
563
569
|
return "" if width <= 0
|
|
564
570
|
|
|
565
571
|
value = text.to_s
|
|
566
|
-
value.length
|
|
572
|
+
return value if value.length <= width
|
|
573
|
+
return value[0, width] if width < 2
|
|
574
|
+
|
|
575
|
+
cut = value[0, width - 1]
|
|
576
|
+
space = cut.rindex(" ")
|
|
577
|
+
cut = cut[0, space] if space && space >= width / 2
|
|
578
|
+
"#{cut.sub(/[\s,;:|·-]+\z/, "")}…"
|
|
567
579
|
end
|
|
568
580
|
|
|
569
581
|
public
|
|
@@ -117,6 +117,11 @@ module Samagotchi
|
|
|
117
117
|
|
|
118
118
|
def via = ChiInvocation::HERE
|
|
119
119
|
|
|
120
|
+
# The closing line says it all (unreachable, unknown_session).
|
|
121
|
+
def failure = nil
|
|
122
|
+
|
|
123
|
+
def close = nil
|
|
124
|
+
|
|
120
125
|
private
|
|
121
126
|
|
|
122
127
|
def session_dir = Session.session_dir(@client.session_id, state_dir: @state_dir)
|
|
@@ -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
|