samagotchi 0.159.0 → 0.161.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 +125 -1
- data/docs/architecture.md +12 -9
- data/docs/cli.md +7 -5
- data/docs/context.md +10 -1
- data/docs/guardrails.md +33 -5
- data/docs/internals/attached-context.md +4 -2
- data/docs/internals/bridge.md +28 -6
- data/docs/internals/bundles.md +24 -5
- data/docs/internals/engine.md +7 -1
- data/docs/internals/objectives.md +8 -2
- data/docs/internals/remote-sessions.md +2 -1
- data/docs/memory.md +43 -13
- data/docs/plugins.md +13 -4
- data/docs/releasing.md +7 -4
- data/docs/scheduling.md +53 -8
- data/docs/sessions.md +17 -12
- data/docs/testing.md +9 -1
- data/lib/samagotchi/bridge/card_store.rb +31 -3
- data/lib/samagotchi/bridge/ring_buffer.rb +11 -1
- data/lib/samagotchi/bridge/sse_writer.rb +8 -5
- data/lib/samagotchi/bridge.rb +103 -44
- data/lib/samagotchi/bridge_client.rb +9 -6
- data/lib/samagotchi/broadcast/scope_card.rb +2 -1
- data/lib/samagotchi/broadcast_command.rb +24 -8
- data/lib/samagotchi/bundle_command.rb +57 -6
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
- data/lib/samagotchi/child_reports.rb +29 -23
- data/lib/samagotchi/client_id.rb +6 -0
- data/lib/samagotchi/command_intake.rb +56 -0
- data/lib/samagotchi/compat.rb +32 -0
- data/lib/samagotchi/context_absorber.rb +6 -3
- data/lib/samagotchi/context_candidate_choice.rb +8 -5
- data/lib/samagotchi/context_candidates.rb +11 -8
- data/lib/samagotchi/context_candidates_log.rb +10 -1
- data/lib/samagotchi/context_command.rb +10 -4
- data/lib/samagotchi/context_move.rb +6 -1
- data/lib/samagotchi/context_poller.rb +5 -2
- data/lib/samagotchi/context_sources.rb +6 -4
- data/lib/samagotchi/engine/events.rb +25 -2
- data/lib/samagotchi/engine/guardrails.rb +3 -4
- data/lib/samagotchi/engine/plugins.rb +1 -1
- data/lib/samagotchi/engine/steering.rb +2 -2
- data/lib/samagotchi/engine.rb +1 -0
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/events.rb +71 -0
- data/lib/samagotchi/guardrails/approvals.rb +27 -3
- data/lib/samagotchi/guardrails/child_boundary.rb +37 -4
- data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
- data/lib/samagotchi/guardrails/targets.rb +25 -6
- data/lib/samagotchi/hook_routes.rb +0 -1
- data/lib/samagotchi/live_versions.rb +7 -2
- data/lib/samagotchi/memory_bundle/asset_installer.rb +86 -18
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
- data/lib/samagotchi/memory_bundle/file_actions.rb +3 -4
- data/lib/samagotchi/memory_bundle/file_state.rb +11 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +7 -4
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installer.rb +93 -23
- data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
- data/lib/samagotchi/memory_bundle/shipped_update.rb +7 -4
- data/lib/samagotchi/memory_bundle/status.rb +3 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +29 -13
- data/lib/samagotchi/memory_orphans.rb +54 -13
- data/lib/samagotchi/memory_paths.rb +29 -6
- data/lib/samagotchi/memory_usage.rb +23 -12
- data/lib/samagotchi/note_command.rb +9 -1
- data/lib/samagotchi/note_delivery.rb +34 -24
- data/lib/samagotchi/note_move.rb +205 -0
- data/lib/samagotchi/objectives_command.rb +3 -1
- data/lib/samagotchi/owner_lock.rb +7 -1
- data/lib/samagotchi/plugin/attached_context.rb +3 -1
- data/lib/samagotchi/plugin/sessions.rb +4 -1
- data/lib/samagotchi/project_command.rb +58 -20
- data/lib/samagotchi/project_id.rb +102 -0
- data/lib/samagotchi/project_id_clash.rb +62 -0
- data/lib/samagotchi/project_init.rb +214 -10
- data/lib/samagotchi/project_scope.rb +11 -2
- data/lib/samagotchi/review_start.rb +5 -3
- data/lib/samagotchi/schedule_command.rb +12 -0
- data/lib/samagotchi/schedules.rb +374 -76
- data/lib/samagotchi/send_command.rb +2 -15
- data/lib/samagotchi/session.rb +59 -7
- data/lib/samagotchi/session_access/local.rb +53 -22
- data/lib/samagotchi/session_access/remote.rb +13 -5
- data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
- data/lib/samagotchi/session_access.rb +20 -4
- data/lib/samagotchi/session_commands.rb +7 -3
- data/lib/samagotchi/session_continue.rb +162 -24
- data/lib/samagotchi/session_delete_command.rb +3 -1
- data/lib/samagotchi/session_inbox.rb +41 -1
- data/lib/samagotchi/session_inbox_delivery.rb +254 -0
- data/lib/samagotchi/session_manager/delivery.rb +66 -12
- data/lib/samagotchi/session_manager/emptiness.rb +7 -2
- data/lib/samagotchi/session_manager/lifecycle.rb +2 -1
- data/lib/samagotchi/session_manager/listing.rb +4 -1
- data/lib/samagotchi/session_manager/removal.rb +130 -26
- data/lib/samagotchi/session_manager/spawn.rb +167 -100
- data/lib/samagotchi/session_manager/worker_command.rb +7 -19
- data/lib/samagotchi/session_manager.rb +1 -0
- data/lib/samagotchi/session_retention.rb +53 -23
- data/lib/samagotchi/session_setup.rb +27 -7
- data/lib/samagotchi/starting_marker.rb +93 -24
- data/lib/samagotchi/state_format.rb +129 -0
- data/lib/samagotchi/system_one/runs.rb +6 -1
- data/lib/samagotchi/system_one/runs_store.rb +18 -14
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +7 -4
- data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +5 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +4 -2
- data/lib/samagotchi/terminal_ui.rb +2 -3
- data/lib/samagotchi/tools/context_read.rb +7 -2
- data/lib/samagotchi/tools/delegate.rb +19 -6
- data/lib/samagotchi/tools/schedule_task.rb +5 -3
- data/lib/samagotchi/tools/send_note.rb +10 -4
- data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
- data/lib/samagotchi/turn_state.rb +108 -4
- data/lib/samagotchi/update_command.rb +9 -4
- data/lib/samagotchi/verify_fresh.rb +27 -9
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +6 -5
- data/lib/samagotchi/web/public/app.js +2 -7
- data/lib/samagotchi/web/public/chains.js +4 -4
- data/lib/samagotchi/web/public/data.js +18 -2
- data/lib/samagotchi/web/public/info_bar_model.js +1 -1
- data/lib/samagotchi/web/public/session_fields.js +3 -2
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/update_notice.js +2 -2
- data/lib/samagotchi/web/session_access_routes.rb +15 -11
- data/lib/samagotchi/web/session_control.rb +9 -3
- data/lib/samagotchi/web/session_create.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +50 -5
- data/lib/samagotchi/web/session_summary.rb +5 -2
- data/lib/samagotchi/worker.rb +61 -11
- data/lib/samagotchi/worker_boot.rb +22 -11
- data/lib/samagotchi/worker_inbound.rb +62 -10
- data/lib/samagotchi/worker_link.rb +4 -1
- data/lib/samagotchi/worker_presence.rb +106 -0
- data/lib/samagotchi/worker_sidecar.rb +3 -4
- metadata +9 -1
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../worker_link"
|
|
4
|
+
require_relative "../starting_marker"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
6
7
|
class SessionManager
|
|
7
|
-
# How long
|
|
8
|
-
#
|
|
8
|
+
# How long a worker's starter waits for it to take the session
|
|
9
|
+
# (WorkerBoot.detach_from_spawner): the starting marker naming the
|
|
10
|
+
# starter holds that long at most.
|
|
9
11
|
WORKER_START_GRACE = WorkerLink::START_LIMIT
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
# The starters this process spawned (their Process.detach waiters), for
|
|
14
|
+
# chi web's restart to adopt (#live_spawn_pids). Decides nothing: the
|
|
15
|
+
# starting marker is what holds a second spawn off.
|
|
16
|
+
@starters = []
|
|
17
|
+
@starters_mutex = Mutex.new
|
|
13
18
|
|
|
14
19
|
# Spawn: deciding to start a session's worker (a new session, a resume, a
|
|
15
|
-
# delegate's report waking its parent) and the
|
|
16
|
-
#
|
|
20
|
+
# delegate's report waking its parent) and the starters this process
|
|
21
|
+
# spawned. Calls WorkerCommandPart's spawn_options and worker_command and
|
|
17
22
|
# the facade's ownership checks. Meant to be loaded through samagotchi/session_manager.
|
|
18
23
|
module SpawnPart
|
|
19
24
|
# Spawn a new background session that processes the given prompt (or,
|
|
@@ -22,9 +27,6 @@ module Samagotchi
|
|
|
22
27
|
# Returns the session object with its ID. Every worker always starts its
|
|
23
28
|
# per-session Bridge (the single live client transport), so external
|
|
24
29
|
# 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
30
|
# @param parent_id [String, nil] the session that delegates this one (the
|
|
29
31
|
# `delegate` tool) or was forked from; a session field too
|
|
30
32
|
# @param messages [Array<Hash>] a conversation to start from (a plugin's
|
|
@@ -35,9 +37,9 @@ module Samagotchi
|
|
|
35
37
|
# @param title [String, nil] the session's own name (Session#title; a
|
|
36
38
|
# continue's link inherits its previous link's), saved before the
|
|
37
39
|
# 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;
|
|
40
|
-
# child the toolkit its delegate call named); without a toolkit the
|
|
40
|
+
# @param setup [SessionSetup] the session's own settings (chi --memory,
|
|
41
|
+
# --mute, --llm-context; a fork and a continue copy their session's;
|
|
42
|
+
# a delegate child the memory and toolkit its delegate call named); without a toolkit the
|
|
41
43
|
# session is stored on config's toolkit.default, a delegate child on
|
|
42
44
|
# toolkit.child (Toolkit.configured)
|
|
43
45
|
# @param continues [String, nil] the session this one continues (a
|
|
@@ -54,13 +56,20 @@ module Samagotchi
|
|
|
54
56
|
# @param scratch [Boolean] a `chi scratch` session (its terminal's
|
|
55
57
|
# AttachLauncher): its worker runs a scratch Engine and deletes it
|
|
56
58
|
# once its last client is gone
|
|
59
|
+
# @param client_id [String, nil] who sent +prompt+ (a ClientId); required
|
|
60
|
+
# even when nil is intentional for a session with no initial prompt
|
|
61
|
+
# @param session_id [String, nil] an id reserved by a durable operation
|
|
57
62
|
# @return [Session] with #seed_images_dropped: refs whose file was gone,
|
|
58
63
|
# 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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
def spawn_session(prompt:, client_id:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
|
|
65
|
+
parent_id: nil, messages: [], images_from: nil, preview: nil, title: nil, delegate: false,
|
|
66
|
+
setup: SessionSetup.new, continues: nil, note: nil, note_source: "chi", before_spawn: nil,
|
|
67
|
+
review: nil, verify_fresh: nil, scratch: false, session_id: nil)
|
|
63
68
|
sd = state_dir || Session.default_state_dir
|
|
69
|
+
if session_id && (!Session.valid_id?(session_id) || Session.exist?(session_id, state_dir: sd))
|
|
70
|
+
raise ArgumentError, "invalid or existing session id #{session_id.inspect}"
|
|
71
|
+
end
|
|
72
|
+
|
|
64
73
|
# The resolved ref is stored (a resumed session keeps its model when an
|
|
65
74
|
# alias is retargeted), with the name as typed beside it. A wrong host
|
|
66
75
|
# refuses here, before anything is saved or spawned; a model id the
|
|
@@ -75,8 +84,6 @@ module Samagotchi
|
|
|
75
84
|
model_name: ref,
|
|
76
85
|
model_typed: typed == ref ? nil : typed,
|
|
77
86
|
working_directory: working_directory || Dir.pwd,
|
|
78
|
-
preloaded_memory_names: memories,
|
|
79
|
-
muted_memory_names: muted_memories,
|
|
80
87
|
parent_id: parent_id,
|
|
81
88
|
messages: messages,
|
|
82
89
|
delegate: delegate,
|
|
@@ -85,6 +92,7 @@ module Samagotchi
|
|
|
85
92
|
review: review,
|
|
86
93
|
scratch: scratch
|
|
87
94
|
)
|
|
95
|
+
session.id = session_id if session_id
|
|
88
96
|
session.toolkit = Toolkit.configured(delegate: delegate) if session.toolkit.nil?
|
|
89
97
|
session.toolkit_origin = setup.toolkit_origin || "default"
|
|
90
98
|
# Saved before the worker starts: its first turn is already the requester's.
|
|
@@ -98,92 +106,117 @@ module Samagotchi
|
|
|
98
106
|
# for it can name it (chi send --new --wait, chi web's create): the
|
|
99
107
|
# worker runs it with that origin, and its reply file and last_turn
|
|
100
108
|
# carry the id.
|
|
101
|
-
|
|
109
|
+
unless prompt.to_s.strip.empty?
|
|
110
|
+
session.first_turn_id = SecureRandom.uuid
|
|
111
|
+
session.first_turn_client_id = client_id unless ClientId.human?(client_id)
|
|
112
|
+
end
|
|
102
113
|
# The worker takes last_prompt and clears it, and messages are saved at
|
|
103
114
|
# the turn's end: until then this is the only preview a list has.
|
|
104
115
|
session.first_preview = Session.preview_of(preview.to_s.strip.empty? ? prompt : preview)
|
|
105
116
|
session.title = title
|
|
106
117
|
session_dir = Session.session_dir(session.id, state_dir: sd)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
118
|
+
# Starting before its file exists: the file is never there unowned and
|
|
119
|
+
# unmarked (a sweep would take a scratch session from under its
|
|
120
|
+
# worker). spawn_worker_for_session hands it to the starter.
|
|
121
|
+
StartingMarker.mark(session.id, state_dir: sd)
|
|
122
|
+
begin
|
|
123
|
+
unless session.messages.empty?
|
|
124
|
+
session.messages, dropped = ImageStore.copy_refs(session.messages, from: images_from, to: session_dir)
|
|
125
|
+
session.seed_images_dropped = dropped
|
|
126
|
+
end
|
|
127
|
+
setup_session_directory(session_dir, session, state_dir: sd)
|
|
128
|
+
SessionInbox.write_note(session.id, text: note, source: note_source, state_dir: sd) if note
|
|
129
|
+
before_spawn&.call(session)
|
|
130
|
+
spawn_worker_for_session(session, state_dir: sd)
|
|
131
|
+
rescue StandardError
|
|
132
|
+
# Nothing is starting: a caller's cleanup (a continue's failed link) may delete it.
|
|
133
|
+
StartingMarker.clear(session.id, state_dir: sd)
|
|
134
|
+
raise
|
|
110
135
|
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
136
|
session
|
|
116
137
|
end
|
|
117
138
|
|
|
118
139
|
# Ensure an existing session has a live worker process.
|
|
119
140
|
# Returns the loaded session after state reconciliation.
|
|
120
141
|
# @raise [OwnedByTUI] when the interactive TUI owns the session
|
|
142
|
+
# @yieldparam outcome [Symbol] what it did, when a block is given:
|
|
143
|
+
# :woken (it spawned the worker), :live (a worker holds the session,
|
|
144
|
+
# or took it since the first read) or :starting (another start is
|
|
145
|
+
# pending, or won the claim)
|
|
121
146
|
def resume_session(session_id, state_dir: nil)
|
|
147
|
+
session, outcome = resume(session_id, state_dir: state_dir)
|
|
148
|
+
yield outcome if block_given?
|
|
149
|
+
session
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# resume_session's work, with its outcome.
|
|
153
|
+
# @return [Array(Session, Symbol)]
|
|
154
|
+
private def resume(session_id, state_dir:)
|
|
122
155
|
sd = state_dir || Session.default_state_dir
|
|
123
156
|
session = Session.load(session_id, state_dir: sd)
|
|
124
157
|
# Its terminal's worker came from spawn_session; nothing wakes another.
|
|
125
158
|
raise ScratchSession, session.id if session.scratch
|
|
126
|
-
return session if refuse_tui!(session.id, state_dir: sd)
|
|
127
159
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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)
|
|
160
|
+
presence = WorkerPresence.read(session.id, state_dir: sd, owner: session_owner(session.id, state_dir: sd))
|
|
161
|
+
raise OwnedByTUI, session.id if presence.tui_owned?
|
|
162
|
+
return [session, :live] if presence.owned?
|
|
137
163
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
session
|
|
143
|
-
end
|
|
164
|
+
# A worker being started for it, by this process or another (chi web,
|
|
165
|
+
# a `chi send`, a child's ring; a continue moving delegates to its new
|
|
166
|
+
# link), may not hold the lock yet: a second one would only lose it,
|
|
167
|
+
# or race the move (#resume_held_off?).
|
|
168
|
+
return [session, :starting] if presence.resume_held_off?
|
|
144
169
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
170
|
+
claimed = claim_start(session.id, state_dir: sd, stopped: presence.stop_marked?)
|
|
171
|
+
return [session, claimed] unless claimed == :claimed
|
|
172
|
+
|
|
173
|
+
# The new worker must not find the session stopped (it would exit).
|
|
174
|
+
# Nothing else is written: the file is the worker's, which turns a
|
|
175
|
+
# status from before it idle under the lock (Worker#settle_stale_status).
|
|
176
|
+
begin
|
|
177
|
+
Session.clear_stopped(session.id, state_dir: sd)
|
|
178
|
+
spawn_worker_for_session(session, state_dir: sd)
|
|
179
|
+
rescue StandardError
|
|
180
|
+
# Its claim holds nothing now (a command or the options failed
|
|
181
|
+
# before Process.spawn): no 60 s hold-off.
|
|
182
|
+
StartingMarker.clear(session.id, state_dir: sd)
|
|
183
|
+
raise
|
|
184
|
+
end
|
|
185
|
+
[session, :woken]
|
|
160
186
|
end
|
|
161
187
|
|
|
162
|
-
# A
|
|
163
|
-
#
|
|
188
|
+
# A ring waits in a parent's children/ (a failed continue's previous
|
|
189
|
+
# link got rings back): wake it as the ring's own delivery would
|
|
190
|
+
# (SessionInbox.wake :ring, its WakePolicy). Not for a parent that is
|
|
191
|
+
# gone, stopped (the stop marker; the file's status never decides),
|
|
192
|
+
# archived, a scratch session, a one-turn run's or owned, or a
|
|
193
|
+
# continue's new link still taking its delegates (its own spawn
|
|
194
|
+
# comes): the ring waits on disk for whoever runs it next. A stop
|
|
195
|
+
# landing between the check and the resume is a small window, left as
|
|
196
|
+
# is. An archived parent with a later link wakes that link (one hop:
|
|
197
|
+
# ChildReports reads its predecessor's rings, no further).
|
|
164
198
|
# @return [Symbol] :woken, or why not (:gone, :stopped, :archived,
|
|
165
199
|
# :scratch, :owned, :starting)
|
|
166
|
-
def
|
|
167
|
-
|
|
168
|
-
|
|
200
|
+
def wake_for_report(session_id, state_dir: nil)
|
|
201
|
+
sd = state_dir || Session.default_state_dir
|
|
202
|
+
outcome = SessionInbox.wake(:ring, session_id, state_dir: sd, owner: ->(id) { session_owner(id, state_dir: sd) }).wake
|
|
203
|
+
outcome == :live ? :owned : outcome
|
|
169
204
|
end
|
|
170
205
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
return
|
|
184
|
-
return :scratch if session.scratch
|
|
206
|
+
# Start a worker for a delivery whose WakePolicy decided :start
|
|
207
|
+
# (SessionInbox.deliver): resume_session, which keeps its own checks
|
|
208
|
+
# (a claim on the starting marker: of starts racing, one spawns),
|
|
209
|
+
# logged as +event+.
|
|
210
|
+
# @param event [Symbol] :delegate_woke_parent (a ring) or :context_woke_session (a push)
|
|
211
|
+
# @return [Symbol] :woken (this start spawned it), :starting (another
|
|
212
|
+
# start won the claim, or is pending), :live (a worker took it since)
|
|
213
|
+
# or :owned (a one-turn chi run or its terminal's scratch session did)
|
|
214
|
+
def start_for_inbox(session_id, state_dir:, event:)
|
|
215
|
+
# A stand-in resume_session that yields nothing counts as a start.
|
|
216
|
+
outcome = :woken
|
|
217
|
+
resume_session(session_id, state_dir: state_dir) { |done| outcome = done }
|
|
218
|
+
return outcome unless outcome == :woken
|
|
185
219
|
|
|
186
|
-
resume_session(session_id, state_dir: state_dir)
|
|
187
220
|
details = event == :delegate_woke_parent ? { parent: session_id[0, 8] } : { sid: session_id[0, 8] }
|
|
188
221
|
Log.info(:worker, event, **details)
|
|
189
222
|
:woken
|
|
@@ -191,37 +224,46 @@ module Samagotchi
|
|
|
191
224
|
:owned
|
|
192
225
|
end
|
|
193
226
|
|
|
194
|
-
#
|
|
195
|
-
#
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
227
|
+
# Take the start for resume_session: the marker claimed (of resumes
|
|
228
|
+
# racing from any processes, one claims it), then the owner read
|
|
229
|
+
# again: a worker that took the lock since the first read cleared an
|
|
230
|
+
# earlier start's marker, and keeps the session. After a stop, a start
|
|
231
|
+
# marked before it is taken over (a stop cancels the hold-off), one a
|
|
232
|
+
# resume made after it holds (two sends after a stop start one worker).
|
|
233
|
+
# @return [Symbol] :claimed (this resume spawns), :starting (another
|
|
234
|
+
# start holds the claim) or :live (a worker took the lock since)
|
|
235
|
+
private def claim_start(session_id, state_dir:, stopped:)
|
|
236
|
+
over_before = stopped ? stopped_at(session_id, state_dir) : nil
|
|
237
|
+
return :starting unless StartingMarker.claim(session_id, state_dir: state_dir, over_before: over_before)
|
|
238
|
+
return :claimed unless session_owner(session_id, state_dir: state_dir)
|
|
201
239
|
|
|
202
|
-
|
|
240
|
+
StartingMarker.clear(session_id, state_dir: state_dir)
|
|
241
|
+
:live
|
|
203
242
|
end
|
|
204
243
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
244
|
+
# When the stop marker was written (Session.stopped_at); nil when it
|
|
245
|
+
# is gone (another resume cleared it: its start came after the stop,
|
|
246
|
+
# and holds).
|
|
247
|
+
private def stopped_at(session_id, state_dir) = Session.stopped_at(session_id, state_dir: state_dir)
|
|
248
|
+
|
|
249
|
+
private def note_starter(waiter)
|
|
250
|
+
@starters_mutex.synchronize do
|
|
251
|
+
@starters.select! { |w| w.respond_to?(:alive?) && w.alive? }
|
|
252
|
+
@starters << waiter
|
|
209
253
|
end
|
|
210
254
|
end
|
|
211
255
|
|
|
212
|
-
# Forget the
|
|
256
|
+
# Forget the starters this process spawned (specs).
|
|
213
257
|
def forget_spawns
|
|
214
|
-
@
|
|
258
|
+
@starters_mutex.synchronize { @starters.clear }
|
|
215
259
|
end
|
|
216
260
|
|
|
217
261
|
# Starter processes are children of chi web, unlike the detached workers
|
|
218
262
|
# they launch; the web restart adopts these pids before replacing itself.
|
|
219
263
|
def live_spawn_pids
|
|
220
|
-
@
|
|
221
|
-
@
|
|
222
|
-
@
|
|
223
|
-
waiter.pid if waiter.respond_to?(:pid) && waiter.alive?
|
|
224
|
-
end.uniq.sort
|
|
264
|
+
@starters_mutex.synchronize do
|
|
265
|
+
@starters.select! { |waiter| waiter.respond_to?(:alive?) && waiter.alive? }
|
|
266
|
+
@starters.filter_map { |waiter| waiter.pid if waiter.respond_to?(:pid) }.uniq.sort
|
|
225
267
|
end
|
|
226
268
|
end
|
|
227
269
|
|
|
@@ -241,14 +283,39 @@ module Samagotchi
|
|
|
241
283
|
opts = spawn_options(session)
|
|
242
284
|
env = opts.delete(:env)
|
|
243
285
|
command = worker_command(session.id, state_dir: state_dir)
|
|
244
|
-
|
|
245
|
-
#
|
|
286
|
+
# Starting from here (WorkerPresence#starting?), for as long as this
|
|
287
|
+
# process runs, until the spawn below names its starter.
|
|
288
|
+
StartingMarker.mark(session.id, state_dir: state_dir)
|
|
289
|
+
begin
|
|
290
|
+
pid = env ? Process.spawn(env, *command, **opts) : Process.spawn(*command, **opts)
|
|
291
|
+
rescue StandardError
|
|
292
|
+
StartingMarker.clear(session.id, state_dir: state_dir)
|
|
293
|
+
raise
|
|
294
|
+
end
|
|
246
295
|
# +pid+ is the worker's starter, which exits once the worker it forked
|
|
247
|
-
# holds the lock (WorkerBoot.detach_from_spawner): the lock's pid is
|
|
248
|
-
|
|
296
|
+
# holds the lock (WorkerBoot.detach_from_spawner): the lock's pid is
|
|
297
|
+
# the worker's, and the marker holds exactly while the starter runs
|
|
298
|
+
# (most spawners exit right after this). The worker removes it once it
|
|
299
|
+
# holds the lock (Worker#start).
|
|
300
|
+
if starter_running?(pid)
|
|
301
|
+
StartingMarker.mark(session.id, state_dir: state_dir, pid: pid)
|
|
302
|
+
# Reaped when it exits: a long-lived spawner (chi web) keeps no zombies.
|
|
303
|
+
note_starter(Process.detach(pid))
|
|
304
|
+
else
|
|
305
|
+
# It failed to exec or boot: nothing is starting.
|
|
306
|
+
StartingMarker.clear(session.id, state_dir: state_dir)
|
|
307
|
+
end
|
|
249
308
|
Log.info(:worker, "spawn", sid: session.id, child_pid: pid)
|
|
250
309
|
pid
|
|
251
310
|
end
|
|
311
|
+
|
|
312
|
+
# Whether the starter just spawned still runs (reaped here if it
|
|
313
|
+
# already exited). Not a child of this process: nothing to wait for.
|
|
314
|
+
private def starter_running?(pid)
|
|
315
|
+
Process.waitpid(pid, Process::WNOHANG).nil?
|
|
316
|
+
rescue Errno::ECHILD
|
|
317
|
+
false
|
|
318
|
+
end
|
|
252
319
|
end
|
|
253
320
|
|
|
254
321
|
extend SpawnPart
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
module Samagotchi
|
|
4
4
|
class SessionManager
|
|
5
5
|
# CROSS-VERSION CONTRACT: this boot string, the entry it names
|
|
6
|
-
# (WorkerBoot.run_session_loop(id, state_dir:)), the
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
6
|
+
# (WorkerBoot.run_session_loop(id, state_dir:)), the env keys
|
|
7
|
+
# spawn_options sets and the input/ file format are read by a NEWER chi
|
|
8
|
+
# than the one writing them. Change them only additively
|
|
9
|
+
# (spec/worker_contract_spec.rb).
|
|
10
10
|
BOOT_FALLBACK_ENV = "SAMAGOTCHI_BOOT_FALLBACK"
|
|
11
11
|
# "1" in a worker spawn_options started: it traps signals (WorkerSignals).
|
|
12
12
|
# WorkerBoot.run_session_loop deletes it from ENV as it reads it.
|
|
@@ -28,9 +28,9 @@ module Samagotchi
|
|
|
28
28
|
|
|
29
29
|
# WorkerCommand: the worker's command line and env (CROSS-VERSION
|
|
30
30
|
# CONTRACT: spec/worker_contract_spec.rb); the worker process itself is
|
|
31
|
-
# WorkerBoot (worker_boot.rb)
|
|
32
|
-
#
|
|
33
|
-
#
|
|
31
|
+
# WorkerBoot (worker_boot.rb), which the boot command loads directly.
|
|
32
|
+
# Calls no other part. Meant to be loaded through
|
|
33
|
+
# samagotchi/session_manager.
|
|
34
34
|
module WorkerCommandPart
|
|
35
35
|
# Build the opts hash passed to Process.spawn for a forked worker. The
|
|
36
36
|
# child inherits this process's ENV and reads config.yml itself (as it
|
|
@@ -94,18 +94,6 @@ module Samagotchi
|
|
|
94
94
|
opts
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
-
# The worker's legacy entry for boot strings written by older chi
|
|
98
|
-
# (CROSS-VERSION CONTRACT: spec/worker_contract_spec.rb): it 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 in the next release: boot strings name WorkerBoot since this
|
|
103
|
-
# one (see ~/.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
97
|
# The worker's command line. Run from an installed gem, the worker
|
|
110
98
|
# activates the newest installed samagotchi (`>= 0.a`, prereleases
|
|
111
99
|
# count), whoever spawns it: an old chi web, chi send or --attach then
|
|
@@ -4,6 +4,8 @@ require "fileutils"
|
|
|
4
4
|
require "time"
|
|
5
5
|
require_relative "session"
|
|
6
6
|
require_relative "archive_store"
|
|
7
|
+
require_relative "worker_presence"
|
|
8
|
+
require_relative "starting_marker"
|
|
7
9
|
require_relative "config"
|
|
8
10
|
require_relative "log"
|
|
9
11
|
require_relative "model_profile"
|
|
@@ -18,8 +20,8 @@ module Samagotchi
|
|
|
18
20
|
# (session.retention_days), count (session.max_count, counting only the
|
|
19
21
|
# sessions that stay), statuses kept (session.keep_status), sessions left
|
|
20
22
|
# empty (SessionManager.discardable?, an hour on), leftover scratch
|
|
21
|
-
# sessions
|
|
22
|
-
# always stays.
|
|
23
|
+
# sessions and skeleton-only directories; a session with a live owner, or
|
|
24
|
+
# whose worker is being started (StartingMarker), always stays.
|
|
23
25
|
module SessionRetention
|
|
24
26
|
# The session.retention_days / max_count / keep_status defaults.
|
|
25
27
|
DEFAULT_RETENTION_DAYS = 14
|
|
@@ -53,13 +55,19 @@ module Samagotchi
|
|
|
53
55
|
dry_run: dry_run,
|
|
54
56
|
test_only: test_only,
|
|
55
57
|
any_age: any_age,
|
|
56
|
-
alive_check: ->(sid) {
|
|
58
|
+
alive_check: ->(sid) { owned?(sid, sd) },
|
|
57
59
|
empty_check: discard ? ->(sid) { left_empty?(sid, state_dir: sd, default_model: default_model) } : nil
|
|
58
60
|
)
|
|
59
61
|
result[:deleted].concat(prune_orphan_dirs(sd, dry_run: dry_run)) if discard && !test_only
|
|
60
62
|
result
|
|
61
63
|
end
|
|
62
64
|
|
|
65
|
+
# Someone holds the session's owner lock (a worker, a one-turn run).
|
|
66
|
+
private_class_method def self.owned?(session_id, state_dir)
|
|
67
|
+
WorkerPresence.read(session_id, state_dir: state_dir,
|
|
68
|
+
owner: SessionManager.session_owner(session_id, state_dir: state_dir)).owned?
|
|
69
|
+
end
|
|
70
|
+
|
|
63
71
|
private_class_method def self.left_empty?(session_id, state_dir:, default_model:)
|
|
64
72
|
path = Session.session_file(session_id, state_dir: state_dir)
|
|
65
73
|
Time.now - File.mtime(path) > EMPTY_GRACE_SECONDS &&
|
|
@@ -80,7 +88,7 @@ module Samagotchi
|
|
|
80
88
|
next unless Session.valid_id?(name) && File.directory?(dir)
|
|
81
89
|
next if File.exist?(File.join(state_dir, "#{name}#{SessionPaths::FILE_EXT}"))
|
|
82
90
|
next unless Time.now - File.mtime(dir) > EMPTY_GRACE_SECONDS && SessionManager.empty_session_dir?(dir)
|
|
83
|
-
next if
|
|
91
|
+
next if owned?(name, state_dir) || starting?(name, state_dir)
|
|
84
92
|
|
|
85
93
|
unless dry_run
|
|
86
94
|
FileUtils.rm_rf(dir)
|
|
@@ -93,14 +101,18 @@ module Samagotchi
|
|
|
93
101
|
end
|
|
94
102
|
end
|
|
95
103
|
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
|
|
104
|
+
# A worker is being started for it (every spawn marks it, a new
|
|
105
|
+
# session's before its file is saved): no owner yet doesn't mean nobody
|
|
106
|
+
# runs it.
|
|
107
|
+
private_class_method def self.starting?(session_id, state_dir)
|
|
108
|
+
StartingMarker.fresh?(session_id, state_dir: state_dir)
|
|
109
|
+
end
|
|
99
110
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
111
|
+
# Being started or owned now (+alive_check+, when given, reads the owner).
|
|
112
|
+
private_class_method def self.held?(session_id, state_dir, alive_check)
|
|
113
|
+
starting?(session_id, state_dir) || alive_check&.call(session_id) == true
|
|
114
|
+
rescue StandardError
|
|
115
|
+
true
|
|
104
116
|
end
|
|
105
117
|
|
|
106
118
|
# Lazy sweep guard: runs prune at most once per SWEEP_INTERVAL_HOURS
|
|
@@ -172,9 +184,11 @@ module Samagotchi
|
|
|
172
184
|
# count (`chi sessions clean` with no --days: test runs are throwaway).
|
|
173
185
|
#
|
|
174
186
|
# A scratch session nobody owns (its terminal and worker were killed) goes whatever its
|
|
175
|
-
# age, its status and the count; +test_only+ takes it too.
|
|
176
|
-
#
|
|
177
|
-
#
|
|
187
|
+
# age, its status and the count; +test_only+ takes it too.
|
|
188
|
+
#
|
|
189
|
+
# A session whose worker is being started (StartingMarker, fresh) stays,
|
|
190
|
+
# whatever it is, and takes no count slot: its worker may not have taken
|
|
191
|
+
# the owner lock yet.
|
|
178
192
|
#
|
|
179
193
|
# +max_count+ counts the sessions that stay: one deleted whatever its
|
|
180
194
|
# place (scratch, left empty, expired, +any_age+) takes no slot.
|
|
@@ -186,6 +200,9 @@ module Samagotchi
|
|
|
186
200
|
def self.apply(state_dir: Session.default_state_dir, days: DEFAULT_RETENTION_DAYS, max_count: DEFAULT_MAX_COUNT,
|
|
187
201
|
keep_status: DEFAULT_KEEP_STATUS, dry_run: false, test_only: false, alive_check: nil,
|
|
188
202
|
empty_check: nil, any_age: false, now: Time.now)
|
|
203
|
+
require_relative "schedules"
|
|
204
|
+
schedules = Schedules.new(state_dir: state_dir)
|
|
205
|
+
scheduled_targets = schedules.protected_session_ids
|
|
189
206
|
keep_status = Array(keep_status).map(&:to_s)
|
|
190
207
|
# Fetch all sessions sorted newest-first for count logic
|
|
191
208
|
all = Session.list(state_dir: state_dir, sort: "updated_at", order: "desc")
|
|
@@ -200,6 +217,7 @@ module Samagotchi
|
|
|
200
217
|
deleted = []
|
|
201
218
|
kept = []
|
|
202
219
|
skipped = []
|
|
220
|
+
purge_ids = []
|
|
203
221
|
passed_over = 0
|
|
204
222
|
|
|
205
223
|
all.each_with_index do |session, idx|
|
|
@@ -210,6 +228,11 @@ module Samagotchi
|
|
|
210
228
|
next
|
|
211
229
|
end
|
|
212
230
|
|
|
231
|
+
if scheduled_targets.include?(session.id)
|
|
232
|
+
kept << session.id
|
|
233
|
+
next
|
|
234
|
+
end
|
|
235
|
+
|
|
213
236
|
# Protected by keep_status
|
|
214
237
|
if keep_status.include?(session.status.to_s) && !session.scratch
|
|
215
238
|
kept << session.id
|
|
@@ -228,10 +251,9 @@ module Samagotchi
|
|
|
228
251
|
end
|
|
229
252
|
end
|
|
230
253
|
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
|
|
234
|
-
if session.scratch && scratch_starting?(path, now)
|
|
254
|
+
# Being started (a resume, a new or scratch session): its worker
|
|
255
|
+
# takes the owner lock in a moment. Kept, it takes no count slot.
|
|
256
|
+
if starting?(session.id, state_dir)
|
|
235
257
|
kept << session.id
|
|
236
258
|
passed_over += 1
|
|
237
259
|
next
|
|
@@ -277,20 +299,28 @@ module Samagotchi
|
|
|
277
299
|
# Eligible for deletion
|
|
278
300
|
if dry_run
|
|
279
301
|
deleted << session.id
|
|
302
|
+
# Looked at again right before its files go (the list was read
|
|
303
|
+
# once, at the start): resumed or taken by a worker since, it stays.
|
|
304
|
+
elsif held?(session.id, state_dir, alive_check)
|
|
305
|
+
kept << session.id
|
|
306
|
+
elsif scheduled_targets.include?(session.id)
|
|
307
|
+
kept << session.id
|
|
280
308
|
else
|
|
281
309
|
begin
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
PluginSessionState.remove(session.id)
|
|
286
|
-
ContextSources.remove_session(session.id, state_dir: state_dir)
|
|
310
|
+
# Under the owner lock, the start read again (as a delete).
|
|
311
|
+
SessionManager.remove_files(session.id, path, File.join(state_dir, session.id), state_dir: state_dir,
|
|
312
|
+
purge_runs: false)
|
|
287
313
|
deleted << session.id
|
|
314
|
+
purge_ids << session.id
|
|
315
|
+
rescue SessionManager::DeleteRefused
|
|
316
|
+
kept << session.id
|
|
288
317
|
rescue StandardError
|
|
289
318
|
skipped << session.id
|
|
290
319
|
end
|
|
291
320
|
end
|
|
292
321
|
end
|
|
293
322
|
|
|
323
|
+
SessionManager.purge_removal_stores(purge_ids, state_dir: state_dir)
|
|
294
324
|
{ deleted: deleted, kept: kept, skipped: skipped }
|
|
295
325
|
end
|
|
296
326
|
end
|