samagotchi 0.121.0 → 0.122.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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -1
  3. data/bin/chi +7 -3
  4. data/docs/architecture.md +37 -11
  5. data/docs/bundle-authoring.md +2 -1
  6. data/docs/cli.md +11 -4
  7. data/docs/configuration.md +36 -7
  8. data/docs/guardrails.md +63 -19
  9. data/docs/internals/anthropic.md +1 -1
  10. data/docs/internals/background-tasks.md +1 -1
  11. data/docs/internals/engine.md +2 -0
  12. data/docs/internals/participants.md +16 -8
  13. data/docs/internals/remote-sessions.md +15 -11
  14. data/docs/internals/web-push.md +22 -11
  15. data/docs/memory.md +33 -0
  16. data/docs/plugins.md +8 -3
  17. data/docs/sessions.md +21 -0
  18. data/docs/testing.md +41 -1
  19. data/lib/samagotchi/answer_command.rb +1 -1
  20. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  21. data/lib/samagotchi/bridge.rb +12 -11
  22. data/lib/samagotchi/bridge_client/event_stream.rb +14 -26
  23. data/lib/samagotchi/bridge_client/stream_open.rb +85 -0
  24. data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
  25. data/lib/samagotchi/bridge_client.rb +52 -65
  26. data/lib/samagotchi/bridge_secret.rb +9 -7
  27. data/lib/samagotchi/bundle_command/check.rb +27 -2
  28. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/claude-peer/scripts/pair +20 -6
  30. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
  32. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  33. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
  34. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
  36. data/lib/samagotchi/child_move.rb +5 -7
  37. data/lib/samagotchi/children_status.rb +2 -1
  38. data/lib/samagotchi/config.rb +5 -0
  39. data/lib/samagotchi/engine.rb +107 -1
  40. data/lib/samagotchi/event_fanout.rb +0 -1
  41. data/lib/samagotchi/guardrail_wiring.rb +24 -6
  42. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
  43. data/lib/samagotchi/guardrails_command.rb +41 -6
  44. data/lib/samagotchi/image_store.rb +6 -3
  45. data/lib/samagotchi/interrupted_turn.rb +6 -3
  46. data/lib/samagotchi/live_versions.rb +6 -7
  47. data/lib/samagotchi/log_line.rb +1 -1
  48. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  49. data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
  50. data/lib/samagotchi/memory_orphans.rb +11 -0
  51. data/lib/samagotchi/participant_mode.rb +11 -0
  52. data/lib/samagotchi/plugin/api.rb +14 -8
  53. data/lib/samagotchi/plugin/sessions.rb +13 -4
  54. data/lib/samagotchi/process_group.rb +13 -0
  55. data/lib/samagotchi/push_command.rb +1 -1
  56. data/lib/samagotchi/relay_verifier.rb +2 -3
  57. data/lib/samagotchi/relay_watcher.rb +2 -2
  58. data/lib/samagotchi/remote_attach_command.rb +1 -1
  59. data/lib/samagotchi/reply_wait/poll.rb +8 -2
  60. data/lib/samagotchi/reply_wait.rb +4 -3
  61. data/lib/samagotchi/review_command.rb +1 -1
  62. data/lib/samagotchi/review_start.rb +6 -2
  63. data/lib/samagotchi/run_options.rb +2 -0
  64. data/lib/samagotchi/session.rb +18 -1
  65. data/lib/samagotchi/session_access/contract.rb +1 -1
  66. data/lib/samagotchi/session_access/local.rb +18 -8
  67. data/lib/samagotchi/session_access/remote.rb +37 -12
  68. data/lib/samagotchi/session_access/web_bridge_client.rb +38 -19
  69. data/lib/samagotchi/session_access.rb +21 -9
  70. data/lib/samagotchi/session_continue.rb +2 -3
  71. data/lib/samagotchi/session_manager.rb +133 -29
  72. data/lib/samagotchi/session_setup.rb +8 -4
  73. data/lib/samagotchi/sessions_command.rb +10 -5
  74. data/lib/samagotchi/steer.rb +7 -6
  75. data/lib/samagotchi/system_one/client.rb +12 -6
  76. data/lib/samagotchi/system_one/fake_backend.rb +0 -2
  77. data/lib/samagotchi/system_one/log.rb +44 -9
  78. data/lib/samagotchi/system_one/self_protect.rb +39 -43
  79. data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
  80. data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
  81. data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
  82. data/lib/samagotchi/terminal_ui.rb +2 -1
  83. data/lib/samagotchi/tool_runner.rb +2 -1
  84. data/lib/samagotchi/toolkit.rb +95 -10
  85. data/lib/samagotchi/toolkit_binding.rb +115 -7
  86. data/lib/samagotchi/toolkit_command.rb +15 -3
  87. data/lib/samagotchi/toolkit_report_command.rb +318 -0
  88. data/lib/samagotchi/toolkit_suggest.rb +211 -0
  89. data/lib/samagotchi/tools/delegate.rb +1 -1
  90. data/lib/samagotchi/tools/delegate_relay.rb +9 -7
  91. data/lib/samagotchi/tools/memory.rb +1 -1
  92. data/lib/samagotchi/turn_checkpoint.rb +15 -9
  93. data/lib/samagotchi/version.rb +1 -1
  94. data/lib/samagotchi/web/app.rb +127 -69
  95. data/lib/samagotchi/web/participant_access.rb +30 -8
  96. data/lib/samagotchi/web/participant_review.rb +8 -5
  97. data/lib/samagotchi/web/participant_view.rb +7 -3
  98. data/lib/samagotchi/web/participants_admin.rb +13 -0
  99. data/lib/samagotchi/web/principal.rb +3 -4
  100. data/lib/samagotchi/web/public/app.js +1 -1
  101. data/lib/samagotchi/web/public/bubbles.js +3 -2
  102. data/lib/samagotchi/web/public/notifications.js +21 -1
  103. data/lib/samagotchi/web/public/participant.html +3 -2
  104. data/lib/samagotchi/web/public/participant.js +25 -10
  105. data/lib/samagotchi/web/public/participants_panel.js +46 -11
  106. data/lib/samagotchi/web/public/push.js +4 -3
  107. data/lib/samagotchi/web/public/push_devices.js +17 -3
  108. data/lib/samagotchi/web/public/review_history.js +14 -2
  109. data/lib/samagotchi/web/public/review_start.js +1 -3
  110. data/lib/samagotchi/web/public/session_actions.js +14 -2
  111. data/lib/samagotchi/web/public/storage.js +16 -0
  112. data/lib/samagotchi/web/public/turn_events.js +2 -1
  113. data/lib/samagotchi/web/public/turn_html.js +1 -1
  114. data/lib/samagotchi/web/push/client.rb +5 -0
  115. data/lib/samagotchi/web/push/store.rb +16 -3
  116. data/lib/samagotchi/web/push_routes.rb +3 -2
  117. data/lib/samagotchi/web/server.rb +2 -2
  118. data/lib/samagotchi/web/session_access_routes.rb +28 -20
  119. data/lib/samagotchi/web/session_control.rb +4 -2
  120. data/lib/samagotchi/web/session_create.rb +4 -4
  121. data/lib/samagotchi/web/session_hub.rb +6 -4
  122. data/lib/samagotchi/web/session_summary.rb +8 -16
  123. data/lib/samagotchi/web/start_dir.rb +10 -5
  124. data/lib/samagotchi/worker.rb +17 -3
  125. data/lib/samagotchi/worker_link.rb +214 -0
  126. data/lib/samagotchi/worker_sidecar.rb +15 -37
  127. metadata +6 -1
@@ -2,8 +2,7 @@
2
2
 
3
3
  require_relative "client_id"
4
4
  require_relative "session"
5
- require_relative "bridge_client"
6
- require_relative "worker_sidecar"
5
+ require_relative "worker_link"
7
6
  require_relative "question_desk"
8
7
  require_relative "log"
9
8
 
@@ -87,7 +86,7 @@ module Samagotchi
87
86
  # @return [Hash, nil] what the parent's live Bridge says about the relay
88
87
  def parent_relay(parent_id, relay_id)
89
88
  dir = Session.session_dir(parent_id, state_dir: @state_dir)
90
- client = BridgeClient.discover(parent_id, session_dir: dir) or return nil
89
+ client = WorkerLink.client(parent_id, session_dir: dir) or return nil
91
90
  response = client.relay_status(relay_id)
92
91
  response.ok? ? response.json : nil
93
92
  rescue SystemCallError, IOError
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "worker_sidecar"
3
+ require_relative "worker_link"
4
4
  require_relative "log"
5
5
 
6
6
  module Samagotchi
@@ -20,7 +20,7 @@ module Samagotchi
20
20
  # @param live [#call] whether the parent's worker is up
21
21
  # @return [Thread]
22
22
  def start(engine:, question_id:, relay_id:, parent_dir:, interval: INTERVAL,
23
- live: -> { WorkerSidecar.live(parent_dir, unlink: false) })
23
+ live: -> { WorkerLink.resolve(parent_dir, unlink: false).live? })
24
24
  Thread.new do
25
25
  loop do
26
26
  sleep(interval)
@@ -48,7 +48,7 @@ module Samagotchi
48
48
  ignored(id) if existing
49
49
  launch(id, existing: existing)
50
50
  rescue TerminalUI::AttachLauncher::Error, SessionAccess::RemoteFailed, SessionAccess::Refused,
51
- SessionManager::OwnedByTUI, ModelProfile::MissingModel, ArgumentError => e
51
+ SessionManager::OwnedByTUI, ModelProfile::MissingModel, Toolkit::Refused, ArgumentError => e
52
52
  e.is_a?(SessionAccess::BadRequest) ? usage(e.message) : failed(e.message)
53
53
  rescue Interrupt
54
54
  CLI::Exit::INTERRUPTED
@@ -5,6 +5,7 @@ require_relative "../session_inbox"
5
5
  require_relative "../turn_checkpoint"
6
6
  require_relative "../continue_offer"
7
7
  require_relative "../steer"
8
+ require_relative "../worker_link"
8
9
 
9
10
  module Samagotchi
10
11
  module ReplyWait
@@ -164,11 +165,16 @@ module Samagotchi
164
165
  pending = session.pending_question
165
166
  return nil unless pending && !@baseline&.question?(pending.id) &&
166
167
  (@turn.nil? || ReplyWait.own_question?(session, @turn, @claimed)) &&
167
- session.waiting_question(live: SessionManager.worker_live?(@id, state_dir: @state_dir))
168
+ session.waiting_question(live: worker_owned?)
168
169
 
169
170
  Result.new(status: :waiting_for_answer, question: pending, session: session)
170
171
  end
171
172
 
173
+ # A worker holds the session's lock (a chi REPL's question is its own).
174
+ def worker_owned?
175
+ WorkerLink.resolve(Session.session_dir(@id, state_dir: @state_dir), unlink: false).worker_owned?
176
+ end
177
+
172
178
  # A participant's turn its worker holds while the owner's Continue
173
179
  # offer stands (Worker#run_input_files): it waits for the owner's
174
180
  # answer to that question, whenever it was asked (the baseline's
@@ -176,7 +182,7 @@ module Samagotchi
176
182
  def held(session)
177
183
  pending = session.pending_question
178
184
  return nil unless @participant_input && !@claimed && pending&.kind.to_s == ContinueOffer::KIND &&
179
- session.waiting_question(live: SessionManager.worker_live?(@id, state_dir: @state_dir))
185
+ session.waiting_question(live: worker_owned?)
180
186
 
181
187
  Result.new(status: :waiting_for_answer, question: pending, session: session)
182
188
  end
@@ -4,7 +4,7 @@ require_relative "session"
4
4
  require_relative "turn_note"
5
5
  require_relative "session_inbox"
6
6
  require_relative "turn_checkpoint"
7
- require_relative "worker_sidecar"
7
+ require_relative "worker_link"
8
8
  require_relative "reply_wait/poll"
9
9
 
10
10
  module Samagotchi
@@ -214,9 +214,10 @@ module Samagotchi
214
214
  running || queued || ended
215
215
  end
216
216
 
217
- # Whether the session's worker sidecar names TURN_IDS_FEATURE.
217
+ # Whether the session's live worker names TURN_IDS_FEATURE.
218
218
  def turn_ids?(session_dir)
219
- WorkerSidecar.read(session_dir)&.features&.include?(TURN_IDS_FEATURE) || false
219
+ link = WorkerLink.resolve(session_dir, unlink: false)
220
+ link.live? && link.features.include?(TURN_IDS_FEATURE)
220
221
  end
221
222
 
222
223
  # A pending question is a turn: wait's (its enqueued_id) only when that
@@ -61,7 +61,7 @@ module Samagotchi
61
61
  start(parsed.options, parsed.args)
62
62
  rescue ReviewStart::Refused, SessionAccess::BadRequest => e
63
63
  refused(e.message)
64
- rescue ModelProfile::MissingModel => e
64
+ rescue ModelProfile::MissingModel, Toolkit::Refused => e
65
65
  refused("could not start: #{e.message}")
66
66
  rescue ArgumentError => e
67
67
  refused(e.is_a?(Session::AmbiguousId) ? e.message : e.message.sub(/\ASession not found: /, "no session "))
@@ -7,6 +7,7 @@ require_relative "session_chain"
7
7
  require_relative "recap_store"
8
8
  require_relative "guardrails/context"
9
9
  require_relative "guardrails/review_paths"
10
+ require_relative "participant_mode"
10
11
 
11
12
  module Samagotchi
12
13
  autoload :SessionManager, File.expand_path("session_manager", __dir__)
@@ -75,6 +76,7 @@ module Samagotchi
75
76
  subjects = check_subjects(subjects, dir: dir)
76
77
  raise Refused, NO_SUBJECTS if subjects.empty?
77
78
 
79
+ setup = setup.with(toolkit_origin: "chosen")
78
80
  SessionManager.spawn_session(prompt: nil, preview: preview(subjects), working_directory: dir, model_name: model,
79
81
  setup: setup, review: SessionReview.new(subjects: subjects),
80
82
  note: note(subjects: subjects), note_source: NOTE_SOURCE, state_dir: state_dir)
@@ -103,7 +105,8 @@ module Samagotchi
103
105
  recap = RecapStore.read(Session.session_dir(source.id, state_dir: sd))
104
106
  session = SessionManager.spawn_session(
105
107
  prompt: nil, preview: preview(subjects, source: source), working_directory: dir, model_name: choice.model,
106
- setup: SessionSetup.of(source), review: SessionReview.new(subjects: subjects, from: source.id),
108
+ setup: SessionSetup.of(source).with(toolkit_origin: "chosen"),
109
+ review: SessionReview.new(subjects: subjects, from: source.id),
107
110
  note: note(subjects: subjects, source: source, recap: recap, seed: seed), note_source: NOTE_SOURCE, state_dir: sd
108
111
  )
109
112
  warnings = [choice.warning, session.model_warning].compact
@@ -145,10 +148,11 @@ module Samagotchi
145
148
  # @param session [Session]
146
149
  # @return [String, nil]
147
150
  def share_warning(session)
151
+ mode = ParticipantMode.of(session)
148
152
  return nil if session.review?
149
153
 
150
154
  count = session.tool_output_count
151
- "#{session.id[0, 8]} is a working session, not a review session: a participant's review turn can quote what " \
155
+ "#{session.id[0, 8]} is a working session, not a review session: a participant's #{mode} turn can quote what " \
152
156
  "its history holds (#{count} tool output#{"s" unless count == 1} so far). A review session starts from its recap instead."
153
157
  end
154
158
 
@@ -165,6 +165,8 @@ module Samagotchi
165
165
  chi review FILE... [--dir DIR] | --from ID a review session to share (chi share)
166
166
  chi memory <ls|rm> [--scope project|system] the memories; rm moves one to the bundle trash
167
167
  chi bundle <install|upgrade|uninstall|status|diff|list|build|trash|new|test|check|try>
168
+ chi guardrails replay [--dry-run] [--threshold T] [--model M] seed System 1's record from answered approvals
169
+ chi toolkit report [--days N] grade toolkit suggestions against the tools sessions used
168
170
  chi desktop <install|upgrade|uninstall|status> macOS "Send to chi" helper
169
171
  chi models [--format json] [TEXT] the models --model takes, from every host
170
172
  chi remote <add|ls|rm> other machines' chi web, for chi --remote NAME
@@ -32,6 +32,7 @@ module Samagotchi
32
32
  STATUS_RUNNING = "running"
33
33
  STATUS_ERROR = "error"
34
34
  STATUS_STOPPED = "stopped"
35
+ TOOLKIT_ORIGINS = %w[chosen default inherited suggested].freeze
35
36
 
36
37
  # A stopped session's marker, <session dir>/stopped: a stop (chi
37
38
  # sessions stop, from another process) creates it instead of rewriting
@@ -222,6 +223,15 @@ module Samagotchi
222
223
  @toolkit = value.is_a?(Toolkit) ? value : Toolkit.from_file(value)
223
224
  end
224
225
 
226
+ # Why this session is on its toolkit. Files from before the field read
227
+ # as chosen, so an old default session is never newly suggested.
228
+ attr_reader :toolkit_origin
229
+
230
+ def toolkit_origin=(value)
231
+ origin = value.to_s
232
+ @toolkit_origin = TOOLKIT_ORIGINS.include?(origin) ? origin : "chosen"
233
+ end
234
+
225
235
  # The session's own settings (chi --set), a SessionConfig::Settings:
226
236
  # empty for none and for a session from before them. Its core part
227
237
  # ({key => value}: recap.enabled) is the file's config_settings, its
@@ -322,7 +332,7 @@ module Samagotchi
322
332
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
323
333
  last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
324
334
  continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
325
- toolkit: nil, settings: nil, interrupted_turn: nil, parent_answers: nil, title: nil,
335
+ toolkit: nil, toolkit_origin: "chosen", settings: nil, interrupted_turn: nil, parent_answers: nil, title: nil,
326
336
  working_set_pins: [], review: nil)
327
337
  @id = id
328
338
  @metadata_version = metadata_version
@@ -355,6 +365,7 @@ module Samagotchi
355
365
  self.reasoning_history = reasoning_history
356
366
  self.appended_prompt_files = appended_prompt_files
357
367
  self.toolkit = toolkit
368
+ self.toolkit_origin = toolkit_origin
358
369
  self.settings = settings
359
370
  self.parent_answers = parent_answers
360
371
  self.working_set_pins = working_set_pins
@@ -427,10 +438,14 @@ module Samagotchi
427
438
  # seed); [] by default
428
439
  # @param setup [SessionSetup] the session's own settings (none by default)
429
440
  # @param now [Time] its created_at and updated_at (a spec's clock)
441
+ # @raise [Toolkit::Refused] its toolkit (setup's, else the configured
442
+ # one SessionManager.spawn_session gives it) builds on a config kit
443
+ # with an invalid groups: (Toolkit.check_new!)
430
444
  def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
431
445
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
432
446
  scratch: false, model_typed: nil, delegate: false, setup: SessionSetup.new, continues: nil,
433
447
  review: nil, now: Time.now)
448
+ Toolkit.check_new!(setup.toolkit || Toolkit.configured(delegate: delegate))
434
449
  now = now.iso8601(3)
435
450
  resolved_test = if test_run.nil?
436
451
  test_session_env?
@@ -460,6 +475,7 @@ module Samagotchi
460
475
  reasoning_history: setup.reasoning_history,
461
476
  appended_prompt_files: setup.appended_prompt_files,
462
477
  toolkit: setup.toolkit,
478
+ toolkit_origin: setup.toolkit_origin || "chosen",
463
479
  settings: setup.settings,
464
480
  parent_answers: setup.parent_answers,
465
481
  working_set_pins: setup.working_set_pins,
@@ -530,6 +546,7 @@ module Samagotchi
530
546
  "reasoning_history" => nil,
531
547
  "appended_prompt_files" => [],
532
548
  "toolkit" => nil,
549
+ "toolkit_origin" => "chosen",
533
550
  "bundle_settings" => {},
534
551
  "config_settings" => {},
535
552
  "interrupted_turn" => nil,
@@ -26,7 +26,7 @@ module Samagotchi
26
26
  # bin/chi subcommands that act on this machine: never sessions. chi
27
27
  # remote (this machine's list of remotes) runs here under a remote too
28
28
  # (Dispatch); the others are refused there.
29
- MACHINE = %w[bootstrap update models desktop memory bundle guardrails remote].freeze
29
+ MACHINE = %w[bootstrap update models desktop memory bundle guardrails toolkit remote].freeze
30
30
 
31
31
  # "command", "command sub" or either with a flag (its long name) →
32
32
  # SUPPORTED or LOCAL_ONLY. A LOCAL_ONLY command or subcommand lists
@@ -14,6 +14,7 @@ require_relative "../context_sources"
14
14
  require_relative "../send_command/busy"
15
15
  require_relative "../chi_invocation"
16
16
  require_relative "../worker_sidecar"
17
+ require_relative "../worker_link"
17
18
  require_relative "../review_start"
18
19
  require_relative "../launch_mode"
19
20
  require_relative "../participants"
@@ -95,13 +96,22 @@ module Samagotchi
95
96
  session = show(id)
96
97
  raise Refused.new(LaunchMode::SCRATCH_LEFTOVER, code: "scratch") if session.scratch
97
98
 
98
- live = WorkerSidecar.live(session_dir(id), unlink: true)
99
- return Woken.new(woke: false, sidecar: live) if live
99
+ link = WorkerLink.resolve(session_dir(id), unlink: true)
100
+ case link
101
+ when WorkerLink::Live then return Woken.new(woke: false, sidecar: link.sidecar)
102
+ when WorkerLink::Skew then raise Refused.new(link.message(id), code: "worker_newer")
103
+ when WorkerLink::Starting
104
+ # A worker that never publishes its Bridge (a failed start, a
105
+ # crash's teardown) lets go of the lock: then resume, not 504.
106
+ found = WorkerLink.await(id, session_dir: session_dir(id), timeout: wait,
107
+ give_up: ->(link) { !link.worker_owned? })
108
+ return Woken.new(woke: false, sidecar: found.live? ? found.sidecar : nil) if found.worker_owned?
109
+ end
100
110
 
101
111
  OwnerLock.await_release(session_dir(id))
102
112
  SessionManager.resume_session(id, **dir)
103
- found = BridgeClient.wait_for(id, session_dir: session_dir(id), timeout: wait)
104
- Woken.new(woke: true, sidecar: found && WorkerSidecar.read(session_dir(id)))
113
+ found = WorkerLink.await(id, session_dir: session_dir(id), timeout: wait)
114
+ Woken.new(woke: true, sidecar: found.live? ? found.sidecar : nil)
105
115
  end
106
116
 
107
117
  # The session's worker now, never started (chi web's GET …/worker).
@@ -109,8 +119,8 @@ module Samagotchi
109
119
  # @raise [ArgumentError] no such session
110
120
  def worker(id)
111
121
  show(id)
112
- WorkerState.new(sidecar: WorkerSidecar.live(session_dir(id), unlink: true),
113
- stopped: Session.stopped_marker?(id, state_dir: state_dir!), owner: owner(id))
122
+ WorkerState.new(link: WorkerLink.resolve(session_dir(id), unlink: true),
123
+ stopped: Session.stopped_marker?(id, state_dir: state_dir!))
114
124
  end
115
125
 
116
126
  # --- create and send
@@ -148,7 +158,7 @@ module Samagotchi
148
158
  # Whether a just-created session's worker came up (its Bridge
149
159
  # answers) within +timeout+ seconds.
150
160
  def await_worker(id, timeout:)
151
- BridgeClient.wait_for(id, session_dir: session_dir(id), timeout: timeout)
161
+ WorkerLink.await(id, session_dir: session_dir(id), timeout: timeout).live?
152
162
  end
153
163
 
154
164
  # Copies of +refs+ (ImageStore refs stored in +from+) in the
@@ -479,7 +489,7 @@ module Samagotchi
479
489
  # The block's post to the session's live worker, again on a 408 (the
480
490
  # worker didn't take it in time).
481
491
  def on_worker(id)
482
- client = BridgeClient.discover(id, session_dir: session_dir(id)) or return nil
492
+ client = WorkerLink.client(id, session_dir: session_dir(id)) or return nil
483
493
  reply = nil
484
494
  2.times do
485
495
  reply = yield client
@@ -12,6 +12,7 @@ require_relative "../bridge_client"
12
12
  require_relative "../session_continue"
13
13
  require_relative "../guardrails/parent_policy"
14
14
  require_relative "../model_profile"
15
+ require_relative "../web/grants"
15
16
  require_relative "http_transport"
16
17
  require_relative "../chi_invocation"
17
18
 
@@ -192,7 +193,7 @@ module Samagotchi
192
193
  return true if owner(id)&.worker?
193
194
  return false if monotonic >= deadline
194
195
 
195
- sleep(WORKER_POLL)
196
+ @sleeper.call(WORKER_POLL)
196
197
  end
197
198
  end
198
199
 
@@ -309,7 +310,10 @@ module Samagotchi
309
310
  reply = request(:post, "#{session_path(id)}/notes", body: { text: text, source: source })
310
311
  json = reply.json.is_a?(Hash) ? reply.json : {}
311
312
  case reply.status
312
- when 200 then NoteDelivery::Result.new(id: id, status: json["status"].to_sym, queued: json["queued"].to_i)
313
+ when 200
314
+ raise_refusal(reply) unless json["status"].is_a?(String)
315
+
316
+ NoteDelivery::Result.new(id: id, status: json["status"].to_sym, queued: json["queued"].to_i)
313
317
  when 409
314
318
  return NoteDelivery::Result.new(id: id, status: :refused, queued: 0) if json["error"] == "owned_by_tui"
315
319
 
@@ -469,14 +473,13 @@ module Samagotchi
469
473
  def context_add_url(target, url, why:, hint:, added_by:)
470
474
  reply = request(:post, "#{session_path(target.session_id)}/context",
471
475
  body: { url: url, why: why, hint: hint, added_by: added_by }.compact)
472
- if reply.status == 422
476
+ if reply.status == 422 && no_provider?(reply)
473
477
  require_relative "../context_providers"
474
- detail = if reply.error == "no_provider" && reply.json["detail"].to_s.start_with?("no installed bundle resolves")
475
- "no installed bundle on #{name} resolves #{url} (chi bundle install github-pr there for GitHub PRs)"
476
- else
477
- "#{reply.json["detail"]} (on #{name})"
478
- end
478
+ detail = "no installed bundle on #{name} resolves #{url} (chi bundle install github-pr there for GitHub PRs)"
479
479
  raise ContextProviders::Invalid, detail
480
+ elsif reply.status == 422
481
+ require_relative "../context_providers"
482
+ raise ContextProviders::Invalid, "#{reply.json["detail"]} (on #{name})"
480
483
  end
481
484
  context_added(reply)
482
485
  reply.json.fetch("name")
@@ -491,7 +494,7 @@ module Samagotchi
491
494
  case reply.status
492
495
  when 200 then ContextPush.new(status: :pushed, before: reply.json["before"], revision: reply.json["revision"])
493
496
  when 404
494
- return ContextPush.new(status: :missing) if reply.error == "not_found"
497
+ return ContextPush.new(status: :missing) if context_missing?(reply)
495
498
 
496
499
  raise_refusal(reply)
497
500
  when 409 then ContextPush.new(status: :not_push)
@@ -512,7 +515,7 @@ module Samagotchi
512
515
  def context_entry(target, name)
513
516
  ContextSources.check_name!(name)
514
517
  reply = request(:get, "#{session_path(target.session_id)}/context/#{name}", query: { full: 1 })
515
- return nil if reply.status == 404 && reply.error == "not_found" && reply.json["detail"].to_s.start_with?("no source")
518
+ return nil if context_missing?(reply)
516
519
 
517
520
  raise_refusal(reply) unless reply.status == 200
518
521
 
@@ -526,7 +529,7 @@ module Samagotchi
526
529
  case reply.status
527
530
  when 200 then :removed
528
531
  when 409 then reply.error == "project_source" ? :project : raise_refusal(reply)
529
- when 404 then reply.json["detail"].to_s.start_with?("no source") ? :missing : raise_refusal(reply)
532
+ when 404 then context_missing?(reply) ? :missing : raise_refusal(reply)
530
533
  else raise_refusal(reply)
531
534
  end
532
535
  end
@@ -654,6 +657,21 @@ module Samagotchi
654
657
  reply.status == 404 && reply.error == "not_found" && reply.json["detail"].to_s.start_with?("not found: /")
655
658
  end
656
659
 
660
+ def context_missing?(reply)
661
+ return false unless reply.status == 404
662
+ return true if reply.error == "context_missing"
663
+
664
+ # Older chi web used not_found for a missing context source.
665
+ reply.error == "not_found" && reply.json["detail"].to_s.start_with?("no source")
666
+ end
667
+
668
+ def no_provider?(reply)
669
+ return true if reply.error == "no_provider"
670
+
671
+ # Older chi web didn't give this refusal its own error code.
672
+ reply.json["detail"].to_s.start_with?("no installed bundle resolves")
673
+ end
674
+
657
675
  # Raises what Local's callee raises for this refusal (chi web sends
658
676
  # the fields each class is built from; see docs/architecture.md, chi
659
677
  # web's routes for the session commands), else RemoteFailed.
@@ -673,12 +691,19 @@ module Samagotchi
673
691
  when "continued", "open_children", "folder_gone"
674
692
  raise SessionContinue::Refused.new(sid, reason, ids: Array(json["ids"]))
675
693
  when "held", "not_running", "unsupported", "failed"
676
- raise SessionManager::RestartRefused.new(sid, reason, version: json["version"]) if reason
694
+ if reason
695
+ raise SessionManager::RestartRefused.new(sid, reason, version: json["version"], holds: json["holds"],
696
+ pid: json["pid"], via: invocation)
697
+ end
677
698
  when "parent_answers_refused", "parent_continue_refused", "no_vision"
678
699
  raise Refused.new(message.to_s, code: error)
679
700
  # No model configured there, or --model's host unknown there: what
680
701
  # spawn_session raises here (chi send: could not start a session).
681
702
  when "invalid_model" then raise ModelProfile::MissingModel, message.to_s
703
+ # A toolkit whose config kit's groups: is invalid there: the same.
704
+ when "toolkit_refused" then raise Toolkit::Refused, message.to_s
705
+ # The grants store doesn't read there: chi share says what it says here.
706
+ when "grants_unwritable" then raise Web::Grants::Unwritable, message.to_s
682
707
  when /\Ainvalid_/, "missing_fields", "bad_images"
683
708
  # invalid_participant: chi share's name, kind or ttl, an archived session
684
709
  raise BadRequest.new(message.to_s, code: error)
@@ -6,6 +6,7 @@ require_relative "../session_access"
6
6
  require_relative "../bridge_client"
7
7
  require_relative "../delivery"
8
8
  require_relative "../log"
9
+ require_relative "../worker_link"
9
10
  require_relative "http_transport"
10
11
 
11
12
  module Samagotchi
@@ -39,9 +40,6 @@ module Samagotchi
39
40
  # A read a join waits on (a turn's saved records, the children
40
41
  # counts): a slow chi web can't hold the join up for long (L9).
41
42
  JOIN_READ_TIMEOUT = 5
42
- # chi web answers no live worker for the session (Mapping).
43
- NO_WORKER = "no live worker for session %s on %s"
44
-
45
43
  # chi web's answers as the session's Bridge would have given them.
46
44
  module Mapping
47
45
  module_function
@@ -60,7 +58,7 @@ module Samagotchi
60
58
  def response(route, reply, session_id:, remote:)
61
59
  json = reply.json.is_a?(Hash) ? reply.json : {}
62
60
  case [reply.status, json["error"]]
63
- in [503, "not_live"] | [404, "not_found"] then raise Unreachable, format(NO_WORKER, session_id, remote)
61
+ in [503, "not_live"] | [404, "not_found"] then raise Unreachable, "#{WorkerLink.not_live(session_id)} on #{remote}"
64
62
  in [504, _] then bridge(408, error: "deadline_passed", detail: json["detail"])
65
63
  in [501, _] if route == :command && json["command"]
66
64
  bridge(400, error: "unknown_command", detail: json["detail"], command: json["command"])
@@ -69,6 +67,15 @@ module Samagotchi
69
67
  end
70
68
  end
71
69
 
70
+ def stream(sock, status, headers, session_id:, remote:)
71
+ outcome = BridgeClient::StreamOpen.classify(sock, status, headers)
72
+ return outcome unless status.to_i == 503 && outcome.is_a?(BridgeClient::StreamOpen::Unreachable)
73
+
74
+ expected = [WorkerLink.unanswered(session_id), WorkerLink.not_live(session_id)]
75
+ detail = expected.include?(outcome.detail) ? outcome.detail : WorkerLink.not_live(session_id)
76
+ BridgeClient::StreamOpen::Unreachable.new(attempts: outcome.attempts, detail: "#{detail} on #{remote}")
77
+ end
78
+
72
79
  def bridge(status, **body) = BridgeClient::Response.new(status: status, body: JSON.generate(body.compact))
73
80
  end
74
81
 
@@ -198,24 +205,35 @@ module Samagotchi
198
205
  idle_timeout: STREAM_IDLE_TIMEOUT, &on_event).start
199
206
  end
200
207
 
201
- # GET /api/sessions/:id/stream through StreamRequest, with the token
202
- # as Bearer. A chunked response (a proxy in between: no decoding in
203
- # v1) and an https chi web (no TLS on the stream yet) are logged and
204
- # read as unreachable.
205
- # @return [Array(TCPSocket, Integer, Hash), nil] see StreamRequest.open
206
- def connect_stream(query: "", last_event_id: nil, timeout: nil)
208
+ # GET /api/sessions/:id/stream through StreamRequest, classifying chi
209
+ # web's stream answers as Bridge outcomes.
210
+ # @param header_timeout [Float] see BridgeClient::STREAM_HEADER_TIMEOUT
211
+ # @yield [sock] called after response headers arrive, before classifying a
212
+ # refusal; EventStream uses it to publish the socket for #close
213
+ # @return [BridgeClient::StreamOpen::Opened, BridgeClient::StreamOpen::Refused,
214
+ # BridgeClient::StreamOpen::Unreachable]
215
+ def open_stream(query: "", last_event_id: nil, header_timeout: BridgeClient::STREAM_HEADER_TIMEOUT, attempts: 1)
207
216
  uri = URI(@remote.entry.url)
208
217
  return refused("tls_unsupported") if uri.scheme == "https"
209
218
 
210
219
  token = @remote.entry.token
211
- opened = BridgeClient::StreamRequest.open(host: uri.hostname, port: uri.port,
212
- path: "/api/sessions/#{@session_id}/stream#{query}",
213
- last_event_id: last_event_id, timeout: timeout,
214
- headers: token ? { "Authorization" => "Bearer #{token}" } : {})
215
- return opened unless opened && opened[2]["transfer-encoding"].to_s.downcase.include?("chunked")
220
+ connection = BridgeClient::StreamRequest.open(host: uri.hostname, port: uri.port,
221
+ path: "/api/sessions/#{@session_id}/stream#{query}",
222
+ last_event_id: last_event_id, timeout: header_timeout,
223
+ headers: token ? { "Authorization" => "Bearer #{token}" } : {})
224
+ return BridgeClient::StreamOpen::Unreachable.new(attempts: 1) unless connection
225
+
226
+ sock, status, headers = connection
227
+ unless !block_given? || yield(sock)
228
+ sock.close rescue nil
229
+ return BridgeClient::StreamOpen::Unreachable.new(attempts: 1)
230
+ end
231
+ return refused("chunked_unsupported", sock) if headers["transfer-encoding"].to_s.downcase.include?("chunked")
216
232
 
217
- opened[0].close rescue nil
218
- refused("chunked_unsupported")
233
+ Mapping.stream(sock, status, headers, session_id: @session_id, remote: @remote.name)
234
+ rescue SystemCallError, SocketError, IOError
235
+ sock&.close rescue nil
236
+ BridgeClient::StreamOpen::Unreachable.new(attempts: 1)
219
237
  end
220
238
 
221
239
  private
@@ -234,9 +252,10 @@ module Samagotchi
234
252
  raise Unreachable, e.message
235
253
  end
236
254
 
237
- def refused(reason)
255
+ def refused(reason, sock = nil)
256
+ sock&.close rescue nil
238
257
  Log.warn(:attached, "stream_refused", reason: reason, remote: @remote.name)
239
- nil
258
+ BridgeClient::StreamOpen::Unreachable.new(attempts: 1)
240
259
  end
241
260
  end
242
261
  end
@@ -179,15 +179,27 @@ module Samagotchi
179
179
  end
180
180
 
181
181
  # A session's worker as an attach finds it (Local#worker, chi web's GET
182
- # …/worker): +sidecar+ the live worker's WorkerSidecar (port, version,
183
- # features, epoch; nil: none runs), +stopped+ its stop marker, +owner+
184
- # its OwnerLock::Owner (a worker, a chi REPL, nil).
185
- WorkerState = Data.define(:sidecar, :stopped, :owner) do
186
- def live? = !sidecar.nil?
187
-
188
- # No Bridge, and the owner is a worker leaving (writing its recap on
189
- # leave): a wake waits for it (Local#wake).
190
- def leaving? = !live? && !!owner&.leaving?
182
+ # …/worker): +link+ the one WorkerLink outcome every field reads, +stopped+
183
+ # its stop marker.
184
+ WorkerState = Data.define(:link, :stopped) do
185
+ # @return [WorkerSidecar, nil] the live worker's (port, version,
186
+ # features, epoch; nil: none answers)
187
+ def sidecar = link.live? ? link.sidecar : nil
188
+
189
+ # @return [OwnerLock::Owner, nil] a worker, a chi REPL, nil
190
+ def owner = link.owner
191
+
192
+ def live? = link.live?
193
+
194
+ # The owner is a worker leaving (writing its recap on leave): a wake
195
+ # waits for it (Local#wake).
196
+ def leaving? = link.is_a?(WorkerLink::Leaving)
197
+
198
+ # A worker holds the lock past WorkerLink::START_LIMIT with no Bridge.
199
+ def stuck? = link.is_a?(WorkerLink::Starting) && link.stuck
200
+
201
+ # A worker whose sidecar speaks a newer protocol than this chi.
202
+ def skew? = link.is_a?(WorkerLink::Skew)
191
203
  end
192
204
 
193
205
  # What a wake for an attach did (Local#wake, chi web's POST …/wake):
@@ -9,6 +9,7 @@ require_relative "child_move"
9
9
  require_relative "archive_store"
10
10
  require_relative "recap_store"
11
11
  require_relative "bridge_client"
12
+ require_relative "worker_link"
12
13
  require_relative "model_profile"
13
14
  require_relative "log"
14
15
  require_relative "session_manager"
@@ -303,10 +304,8 @@ module Samagotchi
303
304
  # answers at once. Best effort: the continue goes on without. A review
304
305
  # fork (ReviewStart) asks the same, with nothing archived after.
305
306
  def self.recap_before_archive(id, state_dir, wait:)
306
- return unless SessionManager.worker_live?(id, state_dir: state_dir)
307
-
308
307
  dir = Session.session_dir(id, state_dir: state_dir)
309
- client = BridgeClient.discover(id, session_dir: dir) or return
308
+ client = WorkerLink.client(id, session_dir: dir) or return
310
309
  before = RecapStore.read(dir)
311
310
  reply = client.request_recap
312
311
  asked = reply.status == 200 && reply.json.is_a?(Hash) ? reply.json["request"] : nil