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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -1
- data/bin/chi +1 -1
- data/docs/architecture.md +10 -6
- data/docs/internals/anthropic.md +1 -1
- data/docs/internals/attached-context.md +3 -2
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/engine.md +625 -935
- data/docs/internals/llm-context-forget.md +8 -3
- data/docs/internals/objectives.md +2 -2
- data/docs/internals/prompt-caching.md +4 -1
- data/docs/internals/remote-sessions.md +3 -1
- data/docs/internals/working-set.md +1 -1
- data/docs/sessions.md +2 -2
- data/docs/system-one.md +3 -2
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
- data/lib/samagotchi/broadcast_command.rb +3 -3
- data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/child_move.rb +7 -51
- data/lib/samagotchi/children_status.rb +1 -1
- data/lib/samagotchi/cli/parent_wait.rb +1 -1
- data/lib/samagotchi/config.rb +17 -112
- data/lib/samagotchi/context_command.rb +1 -2
- data/lib/samagotchi/context_window.rb +3 -3
- data/lib/samagotchi/desktop/macos.rb +1 -1
- data/lib/samagotchi/engine/context_judge.rb +19 -11
- data/lib/samagotchi/engine/messages.rb +1 -1
- data/lib/samagotchi/engine/models.rb +5 -2
- data/lib/samagotchi/engine/objectives.rb +29 -11
- data/lib/samagotchi/engine/side_requests.rb +21 -11
- data/lib/samagotchi/engine/toolkit.rb +10 -9
- data/lib/samagotchi/engine/turn_end.rb +1 -1
- data/lib/samagotchi/engine/turn_helpers.rb +7 -7
- data/lib/samagotchi/engine/verify.rb +1 -1
- data/lib/samagotchi/engine.rb +14 -9
- data/lib/samagotchi/guardrails/targets.rb +24 -9
- data/lib/samagotchi/host_config.rb +72 -1
- data/lib/samagotchi/host_model.rb +2 -6
- data/lib/samagotchi/host_registry.rb +1 -1
- data/lib/samagotchi/kernel_loop.rb +46 -121
- data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
- data/lib/samagotchi/llm/chat_loop.rb +34 -18
- data/lib/samagotchi/llm/native_backend.rb +1 -1
- data/lib/samagotchi/llm_context_apply.rb +54 -1
- data/lib/samagotchi/llm_context_notice.rb +1 -1
- data/lib/samagotchi/llm_context_strategy.rb +7 -9
- data/lib/samagotchi/llm_context_view.rb +9 -0
- data/lib/samagotchi/model_price.rb +4 -10
- data/lib/samagotchi/model_profile.rb +6 -6
- data/lib/samagotchi/model_settings.rb +37 -2
- data/lib/samagotchi/note_command.rb +1 -2
- data/lib/samagotchi/objectives_audit.rb +4 -3
- data/lib/samagotchi/participants.rb +2 -2
- data/lib/samagotchi/plugin/context.rb +1 -1
- data/lib/samagotchi/plugin/sessions.rb +2 -5
- data/lib/samagotchi/plugin/tool_spec.rb +5 -3
- data/lib/samagotchi/project_env.rb +1 -1
- data/lib/samagotchi/reasoning_history.rb +3 -3
- data/lib/samagotchi/reminder_carry.rb +1 -1
- data/lib/samagotchi/reprefill_meter.rb +45 -0
- data/lib/samagotchi/review_command.rb +1 -1
- data/lib/samagotchi/sampling_settings.rb +3 -3
- data/lib/samagotchi/self_report.rb +7 -7
- data/lib/samagotchi/session.rb +50 -7
- data/lib/samagotchi/session_access/local.rb +7 -5
- data/lib/samagotchi/session_access/remote.rb +17 -3
- data/lib/samagotchi/session_archive_command.rb +2 -2
- data/lib/samagotchi/session_chain.rb +7 -6
- data/lib/samagotchi/session_continue.rb +6 -5
- data/lib/samagotchi/session_delete_command.rb +1 -1
- data/lib/samagotchi/session_manager/delivery.rb +254 -0
- data/lib/samagotchi/session_manager/emptiness.rb +138 -0
- data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
- data/lib/samagotchi/session_manager/listing.rb +189 -0
- data/lib/samagotchi/session_manager/removal.rb +190 -0
- data/lib/samagotchi/session_manager/spawn.rb +242 -0
- data/lib/samagotchi/session_manager/worker_command.rb +157 -0
- data/lib/samagotchi/session_manager.rb +26 -1540
- data/lib/samagotchi/session_metrics.rb +1 -1
- data/lib/samagotchi/sessions_command.rb +1 -1
- data/lib/samagotchi/share_command.rb +1 -1
- data/lib/samagotchi/starting_marker.rb +65 -0
- data/lib/samagotchi/steer_cut_after.rb +3 -3
- data/lib/samagotchi/system_one/recorder.rb +49 -0
- data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +2 -6
- data/lib/samagotchi/testing/sessions.rb +1 -1
- data/lib/samagotchi/thinking.rb +3 -3
- data/lib/samagotchi/token_usage.rb +1 -1
- data/lib/samagotchi/tool_activity.rb +10 -4
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tool_output_cut.rb +17 -0
- data/lib/samagotchi/tool_runner.rb +7 -4
- data/lib/samagotchi/toolkit_command.rb +1 -1
- data/lib/samagotchi/tools/delegate.rb +10 -5
- data/lib/samagotchi/tools/delegate_result.rb +4 -4
- data/lib/samagotchi/tools/index_hint.rb +14 -0
- data/lib/samagotchi/tools/registry.rb +28 -11
- data/lib/samagotchi/tools/send_note.rb +4 -6
- data/lib/samagotchi/tools/tool_index.rb +52 -51
- data/lib/samagotchi/tools/tool_surface.rb +22 -8
- data/lib/samagotchi/turn_context.rb +2 -1
- data/lib/samagotchi/turn_stages/context_judge.rb +38 -36
- data/lib/samagotchi/turn_stages/objectives.rb +56 -52
- data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +13 -17
- data/lib/samagotchi/turn_stages/verify_anchors.rb +9 -10
- data/lib/samagotchi/turn_stages/warmup.rb +14 -15
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +3 -3
- data/lib/samagotchi/web/app.rb +15 -8
- data/lib/samagotchi/web/participant_access.rb +2 -2
- data/lib/samagotchi/web/session_access_routes.rb +3 -3
- data/lib/samagotchi/web/session_control.rb +3 -3
- data/lib/samagotchi/web/session_create.rb +3 -3
- data/lib/samagotchi/web/session_hub.rb +1 -1
- data/lib/samagotchi/web/session_show.rb +3 -3
- data/lib/samagotchi/web/session_summary.rb +3 -4
- data/lib/samagotchi/worker.rb +55 -27
- data/lib/samagotchi/worker_boot.rb +184 -0
- data/lib/samagotchi/worker_signals.rb +1 -1
- data/lib/samagotchi/worker_task_wakes.rb +2 -2
- 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
|