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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/bin/chi +39 -2
  4. data/docs/architecture.md +36 -24
  5. data/docs/cli.md +86 -12
  6. data/docs/guardrails.md +2 -1
  7. data/docs/internals/context-telemetry.md +6 -2
  8. data/docs/internals/engine.md +4 -1
  9. data/docs/internals/participants.md +137 -0
  10. data/docs/internals/remote-sessions.md +153 -27
  11. data/docs/plugins.md +7 -1
  12. data/docs/sessions.md +4 -2
  13. data/docs/sub-agent.md +8 -0
  14. data/docs/testing.md +68 -4
  15. data/lib/samagotchi/bridge.rb +7 -3
  16. data/lib/samagotchi/bridge_client/event_stream.rb +9 -4
  17. data/lib/samagotchi/bridge_client.rb +4 -1
  18. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  19. data/lib/samagotchi/chi_invocation.rb +13 -16
  20. data/lib/samagotchi/context_status.rb +15 -2
  21. data/lib/samagotchi/engine.rb +16 -6
  22. data/lib/samagotchi/guardrail_wiring.rb +7 -1
  23. data/lib/samagotchi/idle_recap.rb +1 -1
  24. data/lib/samagotchi/idle_scheduler.rb +34 -9
  25. data/lib/samagotchi/kernel_loop.rb +8 -1
  26. data/lib/samagotchi/launch_mode.rb +4 -0
  27. data/lib/samagotchi/llm/chat_loop.rb +8 -1
  28. data/lib/samagotchi/owner_lock.rb +65 -5
  29. data/lib/samagotchi/participants.rb +122 -0
  30. data/lib/samagotchi/remote_attach_command.rb +119 -0
  31. data/lib/samagotchi/run_options.rb +14 -2
  32. data/lib/samagotchi/send_command.rb +1 -1
  33. data/lib/samagotchi/session.rb +8 -4
  34. data/lib/samagotchi/session_access/contract.rb +42 -4
  35. data/lib/samagotchi/session_access/dispatch.rb +29 -10
  36. data/lib/samagotchi/session_access/local.rb +52 -1
  37. data/lib/samagotchi/session_access/remote.rb +112 -16
  38. data/lib/samagotchi/session_access/web_bridge_client.rb +56 -1
  39. data/lib/samagotchi/session_access.rb +85 -3
  40. data/lib/samagotchi/session_continue.rb +1 -1
  41. data/lib/samagotchi/session_inbox.rb +18 -9
  42. data/lib/samagotchi/session_manager.rb +21 -5
  43. data/lib/samagotchi/sessions_command.rb +4 -4
  44. data/lib/samagotchi/share_command.rb +174 -0
  45. data/lib/samagotchi/terminal_ui/attach_launcher.rb +17 -4
  46. data/lib/samagotchi/terminal_ui/attach_link.rb +10 -0
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +83 -20
  48. data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
  49. data/lib/samagotchi/terminal_ui/local_link.rb +5 -0
  50. data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
  51. data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
  52. data/lib/samagotchi/terminal_ui/version_lines.rb +23 -0
  53. data/lib/samagotchi/terminal_ui.rb +4 -1
  54. data/lib/samagotchi/testing/waiting.rb +92 -9
  55. data/lib/samagotchi/tools/task_runtime.rb +17 -3
  56. data/lib/samagotchi/version.rb +1 -1
  57. data/lib/samagotchi/web/app.rb +187 -74
  58. data/lib/samagotchi/web/grants.rb +302 -0
  59. data/lib/samagotchi/web/participant_access.rb +286 -0
  60. data/lib/samagotchi/web/principal.rb +29 -0
  61. data/lib/samagotchi/web/server.rb +3 -1
  62. data/lib/samagotchi/web/session_access_routes.rb +120 -4
  63. data/lib/samagotchi/worker.rb +26 -5
  64. 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("Lost the session's worker (#{event[:reason]}). " \
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): nil while there is none yet, :gone once no worker is
362
- # coming (AttachLink#live_worker). The reason isn't passed on: the
363
- # stream closes as "unreachable", the words a stopped session has
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
- send_prompt(route.text, delivery: route.delivery)
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
- @screen.commit("(the removed message is in the input history: ↑)") unless @reader&.prefill(prompt)
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
- def send_prompt(text, delivery: nil)
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 > width ? value[0, width] : value
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
- 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