samagotchi 0.155.0 → 0.156.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 (124) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -1
  3. data/bin/chi +1 -1
  4. data/docs/architecture.md +10 -6
  5. data/docs/internals/anthropic.md +1 -1
  6. data/docs/internals/attached-context.md +3 -2
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/engine.md +625 -935
  9. data/docs/internals/llm-context-forget.md +8 -3
  10. data/docs/internals/objectives.md +2 -2
  11. data/docs/internals/prompt-caching.md +4 -1
  12. data/docs/internals/remote-sessions.md +3 -1
  13. data/docs/internals/working-set.md +1 -1
  14. data/docs/sessions.md +2 -2
  15. data/docs/system-one.md +3 -2
  16. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  17. data/lib/samagotchi/broadcast_command.rb +3 -3
  18. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  19. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  20. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  21. data/lib/samagotchi/child_move.rb +7 -51
  22. data/lib/samagotchi/children_status.rb +1 -1
  23. data/lib/samagotchi/cli/parent_wait.rb +1 -1
  24. data/lib/samagotchi/config.rb +17 -112
  25. data/lib/samagotchi/context_command.rb +1 -2
  26. data/lib/samagotchi/context_window.rb +3 -3
  27. data/lib/samagotchi/desktop/macos.rb +1 -1
  28. data/lib/samagotchi/engine/context_judge.rb +19 -11
  29. data/lib/samagotchi/engine/messages.rb +1 -1
  30. data/lib/samagotchi/engine/models.rb +5 -2
  31. data/lib/samagotchi/engine/objectives.rb +29 -11
  32. data/lib/samagotchi/engine/side_requests.rb +21 -11
  33. data/lib/samagotchi/engine/toolkit.rb +10 -9
  34. data/lib/samagotchi/engine/turn_end.rb +1 -1
  35. data/lib/samagotchi/engine/turn_helpers.rb +7 -7
  36. data/lib/samagotchi/engine/verify.rb +1 -1
  37. data/lib/samagotchi/engine.rb +14 -9
  38. data/lib/samagotchi/guardrails/targets.rb +24 -9
  39. data/lib/samagotchi/host_config.rb +72 -1
  40. data/lib/samagotchi/host_model.rb +2 -6
  41. data/lib/samagotchi/host_registry.rb +1 -1
  42. data/lib/samagotchi/kernel_loop.rb +46 -121
  43. data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
  44. data/lib/samagotchi/llm/chat_loop.rb +34 -18
  45. data/lib/samagotchi/llm/native_backend.rb +1 -1
  46. data/lib/samagotchi/llm_context_apply.rb +54 -1
  47. data/lib/samagotchi/llm_context_notice.rb +1 -1
  48. data/lib/samagotchi/llm_context_strategy.rb +7 -9
  49. data/lib/samagotchi/llm_context_view.rb +9 -0
  50. data/lib/samagotchi/model_price.rb +4 -10
  51. data/lib/samagotchi/model_profile.rb +6 -6
  52. data/lib/samagotchi/model_settings.rb +37 -2
  53. data/lib/samagotchi/note_command.rb +1 -2
  54. data/lib/samagotchi/objectives_audit.rb +4 -3
  55. data/lib/samagotchi/participants.rb +2 -2
  56. data/lib/samagotchi/plugin/context.rb +1 -1
  57. data/lib/samagotchi/plugin/sessions.rb +2 -5
  58. data/lib/samagotchi/plugin/tool_spec.rb +5 -3
  59. data/lib/samagotchi/project_env.rb +1 -1
  60. data/lib/samagotchi/reasoning_history.rb +3 -3
  61. data/lib/samagotchi/reminder_carry.rb +1 -1
  62. data/lib/samagotchi/reprefill_meter.rb +45 -0
  63. data/lib/samagotchi/review_command.rb +1 -1
  64. data/lib/samagotchi/sampling_settings.rb +3 -3
  65. data/lib/samagotchi/self_report.rb +7 -7
  66. data/lib/samagotchi/session.rb +50 -7
  67. data/lib/samagotchi/session_access/local.rb +7 -5
  68. data/lib/samagotchi/session_access/remote.rb +17 -3
  69. data/lib/samagotchi/session_archive_command.rb +2 -2
  70. data/lib/samagotchi/session_chain.rb +7 -6
  71. data/lib/samagotchi/session_continue.rb +6 -5
  72. data/lib/samagotchi/session_delete_command.rb +1 -1
  73. data/lib/samagotchi/session_manager/delivery.rb +254 -0
  74. data/lib/samagotchi/session_manager/emptiness.rb +138 -0
  75. data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
  76. data/lib/samagotchi/session_manager/listing.rb +189 -0
  77. data/lib/samagotchi/session_manager/removal.rb +190 -0
  78. data/lib/samagotchi/session_manager/spawn.rb +242 -0
  79. data/lib/samagotchi/session_manager/worker_command.rb +157 -0
  80. data/lib/samagotchi/session_manager.rb +26 -1540
  81. data/lib/samagotchi/session_metrics.rb +1 -1
  82. data/lib/samagotchi/sessions_command.rb +1 -1
  83. data/lib/samagotchi/share_command.rb +1 -1
  84. data/lib/samagotchi/starting_marker.rb +65 -0
  85. data/lib/samagotchi/steer_cut_after.rb +3 -3
  86. data/lib/samagotchi/system_one/recorder.rb +49 -0
  87. data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
  88. data/lib/samagotchi/terminal_ui.rb +2 -6
  89. data/lib/samagotchi/testing/sessions.rb +1 -1
  90. data/lib/samagotchi/thinking.rb +3 -3
  91. data/lib/samagotchi/token_usage.rb +1 -1
  92. data/lib/samagotchi/tool_activity.rb +10 -4
  93. data/lib/samagotchi/tool_declarations.rb +2 -2
  94. data/lib/samagotchi/tool_output_cut.rb +17 -0
  95. data/lib/samagotchi/tool_runner.rb +7 -4
  96. data/lib/samagotchi/toolkit_command.rb +1 -1
  97. data/lib/samagotchi/tools/delegate.rb +10 -5
  98. data/lib/samagotchi/tools/delegate_result.rb +4 -4
  99. data/lib/samagotchi/tools/index_hint.rb +14 -0
  100. data/lib/samagotchi/tools/registry.rb +28 -11
  101. data/lib/samagotchi/tools/send_note.rb +4 -6
  102. data/lib/samagotchi/tools/tool_index.rb +52 -51
  103. data/lib/samagotchi/tools/tool_surface.rb +22 -8
  104. data/lib/samagotchi/turn_context.rb +2 -1
  105. data/lib/samagotchi/turn_stages/context_judge.rb +38 -36
  106. data/lib/samagotchi/turn_stages/objectives.rb +56 -52
  107. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +13 -17
  108. data/lib/samagotchi/turn_stages/verify_anchors.rb +9 -10
  109. data/lib/samagotchi/turn_stages/warmup.rb +14 -15
  110. data/lib/samagotchi/version.rb +1 -1
  111. data/lib/samagotchi/vision_support.rb +3 -3
  112. data/lib/samagotchi/web/app.rb +15 -8
  113. data/lib/samagotchi/web/participant_access.rb +2 -2
  114. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  115. data/lib/samagotchi/web/session_control.rb +3 -3
  116. data/lib/samagotchi/web/session_create.rb +3 -3
  117. data/lib/samagotchi/web/session_hub.rb +1 -1
  118. data/lib/samagotchi/web/session_show.rb +3 -3
  119. data/lib/samagotchi/web/session_summary.rb +3 -4
  120. data/lib/samagotchi/worker.rb +55 -27
  121. data/lib/samagotchi/worker_boot.rb +184 -0
  122. data/lib/samagotchi/worker_signals.rb +1 -1
  123. data/lib/samagotchi/worker_task_wakes.rb +2 -2
  124. metadata +13 -1
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../worker_link"
4
+
5
+ module Samagotchi
6
+ class SessionManager
7
+ # How long after a spawn a resume trusts that worker to take the
8
+ # session, while it runs (and the session wasn't stopped since).
9
+ WORKER_START_GRACE = WorkerLink::START_LIMIT
10
+
11
+ @spawns = {}
12
+ @spawns_mutex = Mutex.new
13
+
14
+ # Spawn: deciding to start a session's worker (a new session, a resume, a
15
+ # delegate's report waking its parent) and the spawns this process
16
+ # remembers. Calls WorkerCommandPart's spawn_options and worker_command and
17
+ # the facade's ownership checks. Meant to be loaded through samagotchi/session_manager.
18
+ module SpawnPart
19
+ # Spawn a new background session that processes the given prompt (or,
20
+ # with none, waits idle for input).
21
+ #
22
+ # Returns the session object with its ID. Every worker always starts its
23
+ # per-session Bridge (the single live client transport), so external
24
+ # clients can reach it once the sidecar is published.
25
+ # @param memories [Array<String>] --memory: preloaded into the worker's prompt
26
+ # @param muted_memories [Array<String>] --mute: hidden from the session
27
+ # (both are session fields, so a respawn keeps them)
28
+ # @param parent_id [String, nil] the session that delegates this one (the
29
+ # `delegate` tool) or was forked from; a session field too
30
+ # @param messages [Array<Hash>] a conversation to start from (a plugin's
31
+ # ctx.sessions.fork); its image refs are copied from +images_from+
32
+ # @param images_from [String, nil] the session dir the seed's images are in
33
+ # @param preview [String, nil] what the lists show before the first turn
34
+ # (the prompt's preview by default, else the seed's first user message)
35
+ # @param title [String, nil] the session's own name (Session#title; a
36
+ # continue's link inherits its previous link's), saved before the
37
+ # worker starts: the worker owns the file from then on
38
+ # @param setup [SessionSetup] the session's own settings (chi
39
+ # --llm-context; a fork and a continue copy their session's; a delegate
40
+ # child the toolkit its delegate call named); without a toolkit the
41
+ # session is stored on config's toolkit.default, a delegate child on
42
+ # toolkit.child (Toolkit.configured)
43
+ # @param continues [String, nil] the session this one continues (a
44
+ # session field; SessionContinue)
45
+ # @param note [String, nil] a context note the session starts with,
46
+ # from +note_source+: queued before its worker spawns, so its first
47
+ # turn sees it
48
+ # @param review [SessionReview, nil] a review session's (ReviewStart; a
49
+ # continue keeps its previous link's)
50
+ # @param verify_fresh [VerifyFreshOrigin, nil] a Verify fresh child's
51
+ # requester, rules and claim (VerifyFresh.start)
52
+ # @param before_spawn [#call, nil] (session) runs once the session and
53
+ # its note are saved, before its worker spawns
54
+ # @param scratch [Boolean] a `chi scratch` session (its terminal's
55
+ # AttachLauncher): its worker runs a scratch Engine and deletes it
56
+ # once its last client is gone
57
+ # @return [Session] with #seed_images_dropped: refs whose file was gone,
58
+ # and #model_warning: an id its host's saved list doesn't have
59
+ def spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
60
+ memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
61
+ preview: nil, title: nil, delegate: false, setup: SessionSetup.new, continues: nil, note: nil,
62
+ note_source: "chi", before_spawn: nil, review: nil, verify_fresh: nil, scratch: false)
63
+ sd = state_dir || Session.default_state_dir
64
+ # The resolved ref is stored (a resumed session keeps its model when an
65
+ # alias is retargeted), with the name as typed beside it. A wrong host
66
+ # refuses here, before anything is saved or spawned; a model id the
67
+ # host's saved list doesn't have (ModelListStore) only warns
68
+ # (#model_warning on the session), as some hosts serve ids they don't
69
+ # list.
70
+ typed = Samagotchi::ModelProfile.check_host!(Samagotchi::ModelProfile.required_model_name(model_name))
71
+ model_warning = Samagotchi::ModelProfile.model_warning(typed)
72
+ ref = HostConfig.model_ref(typed).ref
73
+ session = Session.new_session(
74
+ mode: mode,
75
+ model_name: ref,
76
+ model_typed: typed == ref ? nil : typed,
77
+ working_directory: working_directory || Dir.pwd,
78
+ preloaded_memory_names: memories,
79
+ muted_memory_names: muted_memories,
80
+ parent_id: parent_id,
81
+ messages: messages,
82
+ delegate: delegate,
83
+ setup: setup,
84
+ continues: continues,
85
+ review: review,
86
+ scratch: scratch
87
+ )
88
+ session.toolkit = Toolkit.configured(delegate: delegate) if session.toolkit.nil?
89
+ session.toolkit_origin = setup.toolkit_origin || "default"
90
+ # Saved before the worker starts: its first turn is already the requester's.
91
+ session.verify_fresh = verify_fresh
92
+ session.model_warning = model_warning
93
+ # With no prompt there is no first turn to run (an attaching UI sends
94
+ # the prompts), so the session starts idle.
95
+ session.status = prompt.to_s.strip.empty? ? Session::STATUS_IDLE : Session::STATUS_RUNNING
96
+ session.last_prompt = prompt
97
+ # The first prompt's turn gets an id like a sent message's, so a wait
98
+ # for it can name it (chi send --new --wait, chi web's create): the
99
+ # worker runs it with that origin, and its reply file and last_turn
100
+ # carry the id.
101
+ session.first_turn_id = SecureRandom.uuid unless prompt.to_s.strip.empty?
102
+ # The worker takes last_prompt and clears it, and messages are saved at
103
+ # the turn's end: until then this is the only preview a list has.
104
+ session.first_preview = Session.preview_of(preview.to_s.strip.empty? ? prompt : preview)
105
+ session.title = title
106
+ session_dir = Session.session_dir(session.id, state_dir: sd)
107
+ unless session.messages.empty?
108
+ session.messages, dropped = ImageStore.copy_refs(session.messages, from: images_from, to: session_dir)
109
+ session.seed_images_dropped = dropped
110
+ end
111
+ setup_session_directory(session_dir, session, state_dir: sd)
112
+ SessionInbox.write_note(session.id, text: note, source: note_source, state_dir: sd) if note
113
+ before_spawn&.call(session)
114
+ spawn_worker_for_session(session, state_dir: sd)
115
+ session
116
+ end
117
+
118
+ # Ensure an existing session has a live worker process.
119
+ # Returns the loaded session after state reconciliation.
120
+ # @raise [OwnedByTUI] when the interactive TUI owns the session
121
+ def resume_session(session_id, state_dir: nil)
122
+ sd = state_dir || Session.default_state_dir
123
+ session = Session.load(session_id, state_dir: sd)
124
+ # Its terminal's worker came from spawn_session; nothing wakes another.
125
+ raise ScratchSession, session.id if session.scratch
126
+ return session if refuse_tui!(session.id, state_dir: sd)
127
+
128
+ # status is turn state, not liveness: a new worker runs no turn yet,
129
+ # and must not find the session stopped (it would exit): neither the
130
+ # marker nor a status field from before it.
131
+ # One this process just spawned may not hold the lock yet (a slow
132
+ # start, deliver_turn's second resume): it would only lose it.
133
+ return session if still_starting?(session.id, state_dir: sd)
134
+ # A continue moving delegates to it spawns its worker once they are
135
+ # moved; one spawned now (a moved child's ring) would race the move.
136
+ return session if StartingMarker.fresh?(session.id, state_dir: sd)
137
+
138
+ Session.clear_stopped(session.id, state_dir: sd)
139
+ session.status = Session::STATUS_IDLE
140
+ session.save(state_dir: sd)
141
+ spawn_worker_for_session(session, state_dir: sd)
142
+ session
143
+ end
144
+
145
+ # A delegate child rang a parent that has no worker (ChildRing): start
146
+ # one, which runs a turn for the report. Not for a parent that is gone,
147
+ # stopped, archived, a scratch session, a one-turn run's, or a
148
+ # continue's new link still taking its delegates (its own spawn comes):
149
+ # the ring waits on disk for whoever runs it next (#resume_session
150
+ # itself would clear the stop). A stop landing between the check and
151
+ # the resume is a small window, left as is. An archived parent with a
152
+ # later link in its chain wakes that link instead: a ring a moved child
153
+ # wrote just as the continue moved it reaches the new link that way
154
+ # (ChildReports reads its predecessor's rings).
155
+ # @return [Symbol] :woken, or why not (:gone, :stopped, :archived,
156
+ # :scratch, :owned, :starting)
157
+ def wake_for_report(session_id, state_dir: nil, forward: true)
158
+ sd = state_dir || Session.default_state_dir
159
+ return :gone unless Session.exist?(session_id, state_dir: sd)
160
+ return :owned if session_owner(session_id, state_dir: sd)
161
+ return :stopped if Session.stopped_marker?(session_id, state_dir: sd)
162
+
163
+ if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: sd))
164
+ following = forward && SessionChain.next_of(session_id, state_dir: sd)
165
+ return following ? wake_for_report(following, state_dir: sd, forward: false) : :archived
166
+ end
167
+ return :starting if StartingMarker.fresh?(session_id, state_dir: sd)
168
+
169
+ session = Session.load(session_id, state_dir: sd)
170
+ return :stopped if session.status == Session::STATUS_STOPPED
171
+ return :scratch if session.scratch
172
+
173
+ resume_session(session_id, state_dir: sd)
174
+ Log.info(:worker, "delegate_woke_parent", parent: session_id[0, 8])
175
+ :woken
176
+ rescue OwnedByTUI
177
+ :owned
178
+ end
179
+
180
+ # A worker this process spawned for the session within
181
+ # WORKER_START_GRACE is still running, and no stop came since.
182
+ private def still_starting?(session_id, state_dir:)
183
+ return false if Session.stopped_marker?(session_id, state_dir: state_dir)
184
+
185
+ at, waiter = @spawns_mutex.synchronize { @spawns[[state_dir, session_id]] }
186
+ return false unless at && waiter.respond_to?(:alive?) && waiter.alive?
187
+
188
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - at < WORKER_START_GRACE
189
+ end
190
+
191
+ private def note_spawn(session_id, state_dir:, waiter:)
192
+ @spawns_mutex.synchronize do
193
+ @spawns.delete_if { |_, (_, w)| !(w.respond_to?(:alive?) && w.alive?) }
194
+ @spawns[[state_dir, session_id]] = [Process.clock_gettime(Process::CLOCK_MONOTONIC), waiter]
195
+ end
196
+ end
197
+
198
+ # Forget the spawns #still_starting? remembers (specs).
199
+ def forget_spawns
200
+ @spawns_mutex.synchronize { @spawns.clear }
201
+ end
202
+
203
+ # Starter processes are children of chi web, unlike the detached workers
204
+ # they launch; the web restart adopts these pids before replacing itself.
205
+ def live_spawn_pids
206
+ @spawns_mutex.synchronize do
207
+ @spawns.delete_if { |_, (_, waiter)| !(waiter.respond_to?(:alive?) && waiter.alive?) }
208
+ @spawns.values.filter_map do |_, waiter|
209
+ waiter.pid if waiter.respond_to?(:pid) && waiter.alive?
210
+ end.uniq.sort
211
+ end
212
+ end
213
+
214
+ private def setup_session_directory(session_dir, session, state_dir:)
215
+ FileUtils.mkdir_p(session_dir)
216
+ FileUtils.mkdir_p(File.join(session_dir, SessionInbox::INPUT_DIR))
217
+ FileUtils.mkdir_p(File.join(session_dir, SessionInbox::OUTPUT_DIR))
218
+ session.save(state_dir: state_dir)
219
+ end
220
+
221
+ private def spawn_worker_for_session(session, state_dir:)
222
+ session_dir = Session.session_dir(session.id, state_dir: state_dir)
223
+ FileUtils.mkdir_p(session_dir)
224
+ FileUtils.mkdir_p(File.join(session_dir, SessionInbox::INPUT_DIR))
225
+ FileUtils.mkdir_p(File.join(session_dir, SessionInbox::OUTPUT_DIR))
226
+
227
+ opts = spawn_options(session)
228
+ env = opts.delete(:env)
229
+ command = worker_command(session.id, state_dir: state_dir)
230
+ pid = env ? Process.spawn(env, *command, **opts) : Process.spawn(*command, **opts)
231
+ # Reaped when it exits: a long-lived spawner (chi web) keeps no zombies.
232
+ # +pid+ is the worker's starter, which exits once the worker it forked
233
+ # holds the lock (WorkerBoot.detach_from_spawner): the lock's pid is the worker's.
234
+ note_spawn(session.id, state_dir: state_dir, waiter: Process.detach(pid))
235
+ Log.info(:worker, "spawn", sid: session.id, child_pid: pid)
236
+ pid
237
+ end
238
+ end
239
+
240
+ extend SpawnPart
241
+ end
242
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class SessionManager
5
+ # CROSS-VERSION CONTRACT: this boot string, the entry it names
6
+ # (run_session_loop(id, state_dir:), the forwarder below), the env keys
7
+ # spawn_options sets and the input/ file
8
+ # format are read by a NEWER chi than the one writing them. Change them
9
+ # only additively (spec/worker_contract_spec.rb).
10
+ BOOT_FALLBACK_ENV = "SAMAGOTCHI_BOOT_FALLBACK"
11
+ # "1" in a worker spawn_options started: it traps signals (WorkerSignals).
12
+ # WorkerBoot.run_session_loop deletes it from ENV as it reads it.
13
+ WORKER_PROCESS_ENV = "SAMAGOTCHI_WORKER_PROCESS"
14
+ # "1" in a worker spawn_options started: WorkerBoot.run_session_loop
15
+ # leaves the spawner's process tree first (detach_from_spawner); deleted
16
+ # from ENV as it is read, so an rspec run by the worker's execute never
17
+ # forks.
18
+ WORKER_DETACH_ENV = "SAMAGOTCHI_WORKER_DETACH"
19
+
20
+ # The boot string's rescue under a chi home: one line in the spawner's
21
+ # log file (SAMAGOTCHI_LOG_FILE, set by spawn_options), LogLine's shape,
22
+ # then exit. Single-quoted: the #{} run in the worker. SID is replaced.
23
+ # rubocop:disable-next Lint/InterpolationCheck -- the worker interpolates these, not this process
24
+ HOME_BOOT_FAILED = 'rescue ScriptError, StandardError => e; f = ENV["SAMAGOTCHI_LOG_FILE"].to_s; begin; ' \
25
+ 'File.open(f, "a") { |io| io.puts(Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ") + ' \
26
+ '" ERROR worker pid=#{Process.pid} sid=SID boot_failed ruby=#{RbConfig.ruby.inspect} error=" + ' \
27
+ "e.message.lines.first.to_s.strip.inspect) } unless f.empty?; rescue SystemCallError; end; exit 1"
28
+
29
+ # WorkerCommand: the worker's command line and env (CROSS-VERSION
30
+ # CONTRACT: spec/worker_contract_spec.rb); the worker process itself is
31
+ # WorkerBoot (worker_boot.rb), which only the forwarder (run_session_loop)
32
+ # loads, as a worker boots. Calls no other part. Meant to be loaded
33
+ # through samagotchi/session_manager.
34
+ module WorkerCommandPart
35
+ # Build the opts hash passed to Process.spawn for a forked worker. The
36
+ # child inherits this process's ENV and reads config.yml itself (as it
37
+ # is when the worker starts); opts[:env] adds to it (merged, not
38
+ # replaced) what it can't read there: this `chi`'s CLI settings
39
+ # (Config.cli_env) and the absolute log file. It unsets
40
+ # what ChildEnv lists (SAMAGOTCHI_GUARDRAILS_*, the session markers).
41
+ # XDG_CONFIG_HOME passes: whoever starts or
42
+ # wakes the worker picks which config.yml it reads (docs/guardrails.md).
43
+ # XDG_STATE_HOME and XDG_CONFIG_HOME, when set, pass made absolute
44
+ # (Paths): the worker starts in the session's directory.
45
+ private def spawn_options(session)
46
+ # Own process group: workers outlive `chi web`, and a Ctrl-C in its
47
+ # terminal must not reach them (the worker then leaves the process
48
+ # tree too: WORKER_DETACH_ENV).
49
+ opts = { out: File::NULL, err: File::NULL, pgroup: true }
50
+ # The worker's tools (and `!cmd`) run in the session's directory, not
51
+ # in the cwd of whoever woke it (`chi web`, another terminal).
52
+ dir = session.working_directory.to_s
53
+ if !dir.empty? && File.directory?(dir)
54
+ opts[:chdir] = dir
55
+ else
56
+ Log.warn(:worker, "cwd_gone", sid: session.id, dir: dir, cwd: Dir.pwd)
57
+ end
58
+ # (A chi a session's tool runs took that session's project env out at
59
+ # its start: bin/chi, ProjectEnv.take_orig!.)
60
+ child_env = {}
61
+ # A worker gets no CLI args: --model, --thinking, --log-level and the
62
+ # other flags this chi was started with.
63
+ child_env.merge!(Config.cli_env)
64
+ child_env[ModelProfile::MODEL_FROM_CLI_ENV] = "1" if child_env.key?(ModelProfile::MODEL_ENV)
65
+ # And the spawner's debug log, absolute: a relative log.file would
66
+ # otherwise land in the worker's (the session's) directory.
67
+ log_path = begin LogPath.resolve rescue nil end # degrade-ok: no log to say it in; the worker's is off (below)
68
+ if log_path
69
+ child_env["SAMAGOTCHI_LOG_FILE"] = log_path
70
+ else
71
+ child_env["SAMAGOTCHI_LOG_DISABLE"] = "true"
72
+ end
73
+ # And the spawner's XDG dirs, absolute: a relative XDG_STATE_HOME or
74
+ # XDG_CONFIG_HOME would otherwise be read from the session's directory.
75
+ child_env.merge!(Paths.absolute_xdg)
76
+ # Unset (Process.spawn unsets a key mapped to nil) what no child chi
77
+ # takes from its spawner (ChildEnv): the spawner's guardrails settings
78
+ # (a parent agent starting or waking a session; config.yml no longer
79
+ # reads them, this keeps an older or future env-exposed one out as
80
+ # well); the session markers, a session's own (a worker started from
81
+ # its commands, chi send, is another session: its execute and tasks
82
+ # set its own; nothing else it runs has one); and a smoke's fake
83
+ # "newest installed", for this process's notices only.
84
+ child_env.merge!(ChildEnv.unset)
85
+ # A worker process: WorkerBoot.run_session_loop traps signals, and
86
+ # leaves this process's tree.
87
+ child_env[WORKER_PROCESS_ENV] = "1"
88
+ child_env[WORKER_DETACH_ENV] = "1"
89
+ # A gem-mode worker activates the newest installed chi itself: under
90
+ # `bundle exec` (a Gemfile that has samagotchi) Bundler's RUBYOPT would
91
+ # hold it to the bundle's version, and its badge would never clear.
92
+ child_env.merge!(ChildEnv.unbundled)
93
+ opts[:env] = child_env unless child_env.empty?
94
+ opts
95
+ end
96
+
97
+ # The worker's entry, the one every boot string names (CROSS-VERSION
98
+ # CONTRACT: spec/worker_contract_spec.rb): it only forwards to
99
+ # WorkerBoot.run_session_loop, which runs the worker process. Looked up
100
+ # by name on purpose: a constant reference here would put SessionManager
101
+ # back into one cycle with Worker, Engine and Bridge (the class map).
102
+ # REMOVE one release after the boot string switches (see
103
+ # ~/.claude/plans/arch-worker-boot.md).
104
+ def run_session_loop(session_id, state_dir: nil, **)
105
+ require_relative "../worker_boot"
106
+ Samagotchi.const_get(:WorkerBoot).run_session_loop(session_id, state_dir: state_dir, **)
107
+ end
108
+
109
+ # The worker's command line. Run from an installed gem, the worker
110
+ # activates the newest installed samagotchi (`>= 0.a`, prereleases
111
+ # count), whoever spawns it: an old chi web, chi send or --attach then
112
+ # starts new workers on the newest chi rather than on its own. Activating
113
+ # the gem also makes its dependencies resolve as its gemspec pins them
114
+ # (reline ~> 0.6.3). Should that fail (a broken newest install), the
115
+ # worker falls back to the spawner's own version and logs why.
116
+ #
117
+ # A source checkout (bin/chi, bundle exec; InstalledGem.spec is nil
118
+ # there) keeps the plain -I lib: a worktree's bin/chi spawns its own code.
119
+ def worker_command(session_id, state_dir:, gem_spec: InstalledGem.spec,
120
+ home: ChiHome.current(gem_spec: gem_spec))
121
+ boot = "require 'samagotchi/session_manager'; " \
122
+ "Samagotchi::SessionManager.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})"
123
+ return [RbConfig.ruby, "-I", File.expand_path("../..", __dir__), "-e", boot] unless gem_spec
124
+ return home_worker_command(session_id, state_dir: state_dir, gem_spec: gem_spec, home: home) if home
125
+
126
+ activate = "begin; gem 'samagotchi', '>= 0.a'; rescue LoadError => e; " \
127
+ "ENV['#{BOOT_FALLBACK_ENV}'] = e.message; gem 'samagotchi', '= #{gem_spec.version}'; end"
128
+ [RbConfig.ruby, "-e", "#{activate}; #{boot}"]
129
+ end
130
+
131
+ # With a chi home (ChiHome.current, docs/architecture.md "How chi
132
+ # launches itself"): the home's ruby, `Gem.paths` set to its gems in the
133
+ # boot string (no GEM_* in the worker's env), the newest chi there, else
134
+ # the fallback pin (ChiHome.fallback_version). A home that still can't
135
+ # load chi (a broken install) logs HOME_BOOT_FAILED and exits: never a
136
+ # silent spawn timeout.
137
+ private def home_worker_command(session_id, state_dir:, gem_spec:, home:)
138
+ newest = home.newest_version
139
+ if newest && newest < Gem::Version.new(gem_spec.version.to_s)
140
+ ChiHome.warn_once(:older_than_spawner, home: newest.to_s, spawner: gem_spec.version.to_s)
141
+ end
142
+ fallback = ChiHome.fallback_version(home, gem_spec)
143
+ activate = if fallback
144
+ "begin; gem 'samagotchi', '>= 0.a'; rescue LoadError => e; " \
145
+ "ENV['#{BOOT_FALLBACK_ENV}'] = e.message; gem 'samagotchi', '= #{fallback}'; end"
146
+ else
147
+ "gem 'samagotchi', '>= 0.a'"
148
+ end
149
+ home.boot_argv(run: "begin; #{activate}; require 'samagotchi/session_manager'; " \
150
+ "#{HOME_BOOT_FAILED.sub("SID", session_id)}; end; " \
151
+ "Samagotchi::SessionManager.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})")
152
+ end
153
+ end
154
+
155
+ extend WorkerCommandPart
156
+ end
157
+ end