samagotchi 0.160.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 +86 -1
- data/docs/architecture.md +7 -3
- data/docs/cli.md +6 -4
- data/docs/context.md +10 -1
- data/docs/guardrails.md +12 -3
- data/docs/internals/bridge.md +21 -6
- data/docs/internals/bundles.md +24 -5
- data/docs/internals/objectives.md +8 -2
- data/docs/internals/remote-sessions.md +2 -1
- data/docs/memory.md +4 -2
- data/docs/plugins.md +13 -4
- data/docs/releasing.md +7 -4
- data/docs/scheduling.md +37 -5
- data/docs/sessions.md +14 -11
- 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 +92 -42
- data/lib/samagotchi/bridge_client.rb +9 -6
- data/lib/samagotchi/broadcast_command.rb +24 -8
- data/lib/samagotchi/bundle_command.rb +56 -6
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +29 -23
- data/lib/samagotchi/client_id.rb +2 -0
- data/lib/samagotchi/compat.rb +32 -0
- data/lib/samagotchi/context_candidates_log.rb +10 -1
- data/lib/samagotchi/context_command.rb +1 -1
- data/lib/samagotchi/context_move.rb +6 -1
- data/lib/samagotchi/engine/events.rb +12 -0
- data/lib/samagotchi/engine/plugins.rb +1 -1
- data/lib/samagotchi/engine/steering.rb +2 -2
- 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/live_versions.rb +7 -2
- data/lib/samagotchi/memory_bundle/asset_installer.rb +59 -12
- 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/installer.rb +79 -12
- data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
- data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
- data/lib/samagotchi/memory_bundle/status.rb +1 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +27 -13
- data/lib/samagotchi/memory_orphans.rb +40 -9
- 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/project_init.rb +3 -3
- data/lib/samagotchi/review_start.rb +5 -3
- data/lib/samagotchi/schedule_command.rb +12 -0
- data/lib/samagotchi/schedules.rb +286 -48
- data/lib/samagotchi/send_command.rb +2 -15
- data/lib/samagotchi/session.rb +42 -5
- data/lib/samagotchi/session_access/local.rb +45 -17
- data/lib/samagotchi/session_access/remote.rb +11 -4
- data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
- data/lib/samagotchi/session_access.rb +16 -3
- data/lib/samagotchi/session_continue.rb +155 -21
- 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 +164 -104
- 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 +18 -7
- 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/delegate.rb +9 -4
- data/lib/samagotchi/tools/schedule_task.rb +5 -3
- data/lib/samagotchi/tools/send_note.rb +10 -4
- data/lib/samagotchi/turn_state.rb +108 -4
- data/lib/samagotchi/update_command.rb +7 -2
- data/lib/samagotchi/verify_fresh.rb +27 -9
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +1 -1
- 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 +12 -8
- 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 +45 -7
- data/lib/samagotchi/worker_boot.rb +22 -11
- data/lib/samagotchi/worker_inbound.rb +41 -8
- 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 +5 -1
|
@@ -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
|
|
@@ -24,20 +24,27 @@ module Samagotchi
|
|
|
24
24
|
# +parent_answers+ the session's own guardrails.parent_answers (a level
|
|
25
25
|
# String, tighten-only: Guardrails::ParentPolicy.effective) or nil,
|
|
26
26
|
# +working_set_pins+ the items the user pinned in the working set
|
|
27
|
-
# (WorkingSetPin; [] with none)
|
|
27
|
+
# (WorkingSetPin; [] with none), +preloaded_memory_names+ and
|
|
28
|
+
# +muted_memory_names+ the names --memory and --mute selected ([] with
|
|
29
|
+
# none). A delegate source's reserved memory is not copied to a normal
|
|
30
|
+
# child, and a fork cannot inherit it because its parent_id would mark it
|
|
31
|
+
# as a delegate.
|
|
28
32
|
#
|
|
29
33
|
# It doesn't save itself: the session keeps each setting in its own field
|
|
30
34
|
# and file key, and resolves it on its own. A delegate child starts
|
|
31
35
|
# without one (but the toolkit its delegate call named, and its parent's
|
|
32
36
|
# parent_answers); a continue and a fork copy their session's (.of).
|
|
33
|
-
SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files,
|
|
34
|
-
:
|
|
37
|
+
SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files,
|
|
38
|
+
:preloaded_memory_names, :muted_memory_names, :toolkit, :toolkit_origin, :settings,
|
|
39
|
+
:parent_answers, :working_set_pins) do
|
|
35
40
|
def initialize(llm_context: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [], toolkit: nil,
|
|
36
|
-
|
|
41
|
+
preloaded_memory_names: [], muted_memory_names: [], toolkit_origin: nil, settings: nil,
|
|
42
|
+
parent_answers: nil, working_set_pins: [])
|
|
37
43
|
toolkit_origin ||= "chosen" if toolkit
|
|
38
44
|
super(llm_context: llm_context, thinking: thinking, reasoning_history: reasoning_history,
|
|
39
45
|
appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze, toolkit: toolkit,
|
|
40
|
-
|
|
46
|
+
preloaded_memory_names: name_list(preloaded_memory_names),
|
|
47
|
+
muted_memory_names: name_list(muted_memory_names), toolkit_origin: toolkit_origin&.to_s,
|
|
41
48
|
settings: SessionConfig::Settings.of(settings), parent_answers: parent_answers&.to_s,
|
|
42
49
|
working_set_pins: WorkingSetPin.list(working_set_pins))
|
|
43
50
|
end
|
|
@@ -51,12 +58,25 @@ module Samagotchi
|
|
|
51
58
|
# always: one from before toolkits is full (Toolkit.of), not whatever
|
|
52
59
|
# config's default is now.
|
|
53
60
|
# @param session [Session, nil]
|
|
61
|
+
# @param forked [Boolean] whether the new session is a forked child
|
|
54
62
|
# @return [SessionSetup]
|
|
55
|
-
def self.of(session)
|
|
63
|
+
def self.of(session, forked: false)
|
|
56
64
|
return new if session.nil?
|
|
57
65
|
|
|
58
|
-
|
|
66
|
+
values = members.to_h do |member|
|
|
67
|
+
[member, self::CARRY.fetch(member) { ->(s) { s.public_send(member) } }.call(session)]
|
|
68
|
+
end
|
|
69
|
+
if forked || session.delegate?
|
|
70
|
+
values[:preloaded_memory_names] = values[:preloaded_memory_names].reject do |name|
|
|
71
|
+
name == Session::DELEGATE_MEMORY
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
new(**values)
|
|
59
75
|
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def name_list(names) = Array(names).map { |name| name.to_s.strip }.reject(&:empty?).uniq.freeze
|
|
60
80
|
end
|
|
61
81
|
|
|
62
82
|
# How SessionSetup.of carries a member that isn't its session's field as
|
|
@@ -2,51 +2,120 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "json"
|
|
5
|
+
require "securerandom"
|
|
5
6
|
require "time"
|
|
6
7
|
require_relative "atomic_file"
|
|
7
8
|
require_relative "session"
|
|
8
9
|
|
|
9
10
|
module Samagotchi
|
|
10
|
-
# A
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
# read and cleared by other chi versions
|
|
11
|
+
# A session's "starting" marker: a worker is being started for it. Every
|
|
12
|
+
# spawn writes it (SpawnPart's spawn_worker_for_session: the writer's pid
|
|
13
|
+
# before the spawn, the starter's after; a continue's before it moves
|
|
14
|
+
# delegates to its new link), and the worker removes it once it holds the
|
|
15
|
+
# owner lock (Worker#start). While it is #fresh?, nothing else spawns a
|
|
16
|
+
# worker for the session (WorkerPresence#starting?). CROSS-VERSION: the
|
|
17
|
+
# file name and its {pid, at} are read and cleared by other chi versions
|
|
18
|
+
# (spec/worker_contract_spec.rb).
|
|
17
19
|
module StartingMarker
|
|
18
|
-
# <
|
|
20
|
+
# <session dir>/starting: {pid, at}, from the decision to spawn to the
|
|
21
|
+
# worker holding the lock.
|
|
19
22
|
FILE = "starting"
|
|
20
23
|
# A starting marker older than this, or whose process is gone, is left
|
|
21
|
-
# over from a
|
|
24
|
+
# over from a start that died: it holds nothing back.
|
|
22
25
|
STALE = 60.0
|
|
23
26
|
|
|
24
27
|
module_function
|
|
25
28
|
|
|
26
|
-
# Hold off
|
|
27
|
-
#
|
|
28
|
-
# once it has the owner lock (Worker#start).
|
|
29
|
-
|
|
29
|
+
# Hold off any other worker for +session_id+ (#fresh?) until the one
|
|
30
|
+
# being started holds it, for as long as +pid+ runs: removed by that
|
|
31
|
+
# worker once it has the owner lock (Worker#start).
|
|
32
|
+
# @param pid [Integer] the process the hold lasts for: the writer, or
|
|
33
|
+
# the worker's starter once it is spawned
|
|
34
|
+
def mark(session_id, state_dir:, pid: Process.pid)
|
|
30
35
|
dir = Session.session_dir(session_id, state_dir: state_dir)
|
|
31
36
|
FileUtils.mkdir_p(dir)
|
|
32
|
-
AtomicFile.write(File.join(dir, FILE), JSON.generate({ "pid" =>
|
|
37
|
+
AtomicFile.write(File.join(dir, FILE), JSON.generate({ "pid" => pid, "at" => Time.now.iso8601(6) }))
|
|
33
38
|
end
|
|
34
39
|
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
# Claim the start of +session_id+'s worker: write the marker only when
|
|
41
|
+
# no fresh one holds it (a link(2) of a written temp file: of starters
|
|
42
|
+
# racing, one wins). A stale or dead one is taken over (renamed away,
|
|
43
|
+
# then checked to be the one judged, so a takeover never removes a new
|
|
44
|
+
# claim), and so is a fresh one written before +over_before+ (a stop
|
|
45
|
+
# cancels the starts before it, not the ones after it).
|
|
46
|
+
# @param pid [Integer] the process the hold lasts for (#mark)
|
|
47
|
+
# @param over_before [Time, nil] take over a start marked at or before it
|
|
48
|
+
# @return [Boolean] whether this caller holds the start now
|
|
49
|
+
def claim(session_id, state_dir:, pid: Process.pid, over_before: nil)
|
|
50
|
+
dir = Session.session_dir(session_id, state_dir: state_dir)
|
|
51
|
+
FileUtils.mkdir_p(dir)
|
|
52
|
+
path = File.join(dir, FILE)
|
|
53
|
+
temp = File.join(dir, "#{FILE}.#{Process.pid}.#{SecureRandom.hex(4)}")
|
|
54
|
+
File.write(temp, JSON.generate({ "pid" => pid, "at" => Time.now.iso8601(6) }))
|
|
55
|
+
3.times do
|
|
56
|
+
File.link(temp, path)
|
|
57
|
+
return true
|
|
58
|
+
rescue Errno::EEXIST
|
|
59
|
+
seen = read_raw(path) or next
|
|
60
|
+
return false if holds?(seen, over_before)
|
|
61
|
+
return false unless take_over(path, seen)
|
|
62
|
+
end
|
|
63
|
+
false
|
|
64
|
+
ensure
|
|
65
|
+
FileUtils.rm_f(temp) if temp
|
|
37
66
|
end
|
|
38
67
|
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
def
|
|
42
|
-
|
|
43
|
-
|
|
68
|
+
# Move the marker +seen+ out of the way: false when what was renamed is
|
|
69
|
+
# another claim's (put back), true when it was +seen+ or already gone.
|
|
70
|
+
def take_over(path, seen)
|
|
71
|
+
tomb = "#{path}.old.#{Process.pid}.#{SecureRandom.hex(4)}"
|
|
72
|
+
File.rename(path, tomb)
|
|
73
|
+
return true if File.read(tomb) == seen
|
|
74
|
+
|
|
75
|
+
begin
|
|
76
|
+
File.link(tomb, path)
|
|
77
|
+
rescue Errno::EEXIST
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
false
|
|
81
|
+
rescue Errno::ENOENT
|
|
82
|
+
true
|
|
83
|
+
ensure
|
|
84
|
+
FileUtils.rm_f(tomb) if tomb
|
|
85
|
+
end
|
|
86
|
+
private_class_method :take_over
|
|
44
87
|
|
|
45
|
-
|
|
46
|
-
|
|
88
|
+
def read_raw(path)
|
|
89
|
+
File.read(path)
|
|
90
|
+
rescue Errno::ENOENT
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
private_class_method :read_raw
|
|
94
|
+
|
|
95
|
+
# Whether the marker text +raw+ holds a start: fresh, its process
|
|
96
|
+
# running, and (with +over_before+) marked after it.
|
|
97
|
+
def holds?(raw, over_before = nil)
|
|
98
|
+
data = JSON.parse(raw)
|
|
99
|
+
at = Time.iso8601(data["at"].to_s)
|
|
100
|
+
return false if Time.now - at > STALE
|
|
101
|
+
return false if over_before && at <= over_before
|
|
47
102
|
|
|
48
103
|
process_alive?(data["pid"].to_i)
|
|
49
|
-
rescue JSON::ParserError, ArgumentError, SystemCallError
|
|
104
|
+
rescue JSON::ParserError, ArgumentError, TypeError, RangeError, SystemCallError
|
|
105
|
+
false
|
|
106
|
+
end
|
|
107
|
+
private_class_method :holds?
|
|
108
|
+
|
|
109
|
+
def clear(session_id, state_dir:)
|
|
110
|
+
FileUtils.rm_f(File.join(Session.session_dir(session_id, state_dir: state_dir), FILE))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Whether a worker is being started for +session_id+ right now: its
|
|
114
|
+
# marker is there, fresh, and the process it names runs.
|
|
115
|
+
def fresh?(session_id, state_dir:)
|
|
116
|
+
raw = read_raw(File.join(Session.session_dir(session_id, state_dir: state_dir), FILE))
|
|
117
|
+
!raw.nil? && holds?(raw)
|
|
118
|
+
rescue SystemCallError
|
|
50
119
|
false
|
|
51
120
|
end
|
|
52
121
|
|
|
@@ -22,7 +22,7 @@ module Samagotchi
|
|
|
22
22
|
end
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
Document = Data.define(:version, :extra, :entries)
|
|
25
|
+
Document = Data.define(:version, :extra, :entries, :attributes)
|
|
26
26
|
|
|
27
27
|
class InvalidDocument < StandardError; end
|
|
28
28
|
|
|
@@ -38,16 +38,18 @@ module Samagotchi
|
|
|
38
38
|
Member.new(name: name.to_sym, introduced_in: in_version, default: ->(_attributes) { {} }, persisted: false)
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
attr_reader :members
|
|
41
|
+
attr_reader :members, :document_members
|
|
42
42
|
|
|
43
|
-
def initialize(version:, members:)
|
|
43
|
+
def initialize(version:, members:, document_members: [])
|
|
44
44
|
@version = version
|
|
45
45
|
@members = members.freeze
|
|
46
|
+
@document_members = document_members.freeze
|
|
46
47
|
@extra_member = members.find { |member| !member.persisted? }
|
|
47
48
|
raise ArgumentError, "a state format can declare only one extra member" if members.count { |member| !member.persisted? } > 1
|
|
48
49
|
raise ArgumentError, "duplicate state format members" unless members.map(&:name).uniq == members.map(&:name)
|
|
50
|
+
raise ArgumentError, "duplicate state format document members" unless document_members.map(&:name).uniq == document_members.map(&:name)
|
|
49
51
|
|
|
50
|
-
members.each do |member|
|
|
52
|
+
(members + document_members).each do |member|
|
|
51
53
|
required = member.required? && member.default.equal?(REQUIRED)
|
|
52
54
|
defaulted = member.defaulted? && member.introduced_in.is_a?(Integer) &&
|
|
53
55
|
member.introduced_in.positive? && member.introduced_in <= version &&
|
|
@@ -63,17 +65,26 @@ module Samagotchi
|
|
|
63
65
|
|
|
64
66
|
collection = collection.to_s
|
|
65
67
|
entries = Array(data[collection]).map { |raw| read_record(raw) }
|
|
66
|
-
|
|
68
|
+
attributes = {}
|
|
69
|
+
document_members.each do |member|
|
|
70
|
+
key = member.name.to_s
|
|
71
|
+
attributes[member.name] = if data.key?(key)
|
|
72
|
+
data[key]
|
|
73
|
+
else
|
|
74
|
+
default_value(member.default, attributes)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
known = %w[version updated_at] + [collection] + document_members.map { |member| member.name.to_s }
|
|
67
78
|
stored_version = data["version"]
|
|
68
79
|
stored_version = 0 unless stored_version.is_a?(Integer) && stored_version >= 0
|
|
69
80
|
|
|
70
81
|
Document.new(version: [@version, stored_version].max,
|
|
71
82
|
extra: data.reject { |key, _value| known.include?(key.to_s) },
|
|
72
|
-
entries: entries)
|
|
83
|
+
entries: entries, attributes: attributes)
|
|
73
84
|
end
|
|
74
85
|
|
|
75
86
|
def write(document, collection:, updated_at:)
|
|
76
|
-
document.extra.transform_keys(&:to_s).merge(
|
|
87
|
+
document.extra.transform_keys(&:to_s).merge(document.attributes.transform_keys(&:to_s)).merge(
|
|
77
88
|
"version" => [document.version, @version].max,
|
|
78
89
|
"updated_at" => updated_at,
|
|
79
90
|
collection.to_s => document.entries.map(&:to_h)
|
|
@@ -69,7 +69,12 @@ module Samagotchi
|
|
|
69
69
|
# Explicit `chi sessions delete`: that session's runs and their later
|
|
70
70
|
# lines go, whatever system_one.runs says now.
|
|
71
71
|
def self.purge_session(session_id, sessions_dir:)
|
|
72
|
-
|
|
72
|
+
purge_sessions([session_id], sessions_dir: sessions_dir)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A retention sweep's runs, purged with one pass over the store.
|
|
76
|
+
def self.purge_sessions(session_ids, sessions_dir:)
|
|
77
|
+
RunsStore.new(File.dirname(sessions_dir)).purge_sessions(session_ids)
|
|
73
78
|
end
|
|
74
79
|
|
|
75
80
|
# Whether a bundle may declare +name+ (safe as a folder, not core's).
|
|
@@ -114,16 +114,29 @@ module Samagotchi
|
|
|
114
114
|
# day files under the lock.
|
|
115
115
|
# @return [Integer] the runs purged
|
|
116
116
|
def purge_session(session_id)
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
purge_sessions([session_id])
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Many sessions' runs, found with one pass over the judgment day files.
|
|
121
|
+
# @return [Integer] the runs purged
|
|
122
|
+
def purge_sessions(session_ids)
|
|
123
|
+
needles = Array(session_ids).map(&:to_s).reject(&:empty?).uniq
|
|
124
|
+
return 0 if needles.empty?
|
|
119
125
|
|
|
120
126
|
names.sum do |name|
|
|
121
|
-
files = day_files(name)
|
|
122
|
-
ids = files.flat_map
|
|
127
|
+
files = day_files(name)
|
|
128
|
+
ids = files.flat_map do |path|
|
|
129
|
+
read(path).each_line.filter_map do |text|
|
|
130
|
+
next unless needles.any? { |needle| text.include?(needle) }
|
|
131
|
+
|
|
132
|
+
data = parse(text)
|
|
133
|
+
data["id"] if data && data["kind"] == "run" && needles.include?(data["session_id"])
|
|
134
|
+
end
|
|
135
|
+
end.uniq
|
|
123
136
|
next 0 if ids.empty?
|
|
124
137
|
|
|
125
138
|
known = ids.to_h { |id| [id, true] }
|
|
126
|
-
|
|
139
|
+
files.each { |path| rewrite_without(path, known) }
|
|
127
140
|
remove_if_empty(name)
|
|
128
141
|
ids.size
|
|
129
142
|
end
|
|
@@ -197,15 +210,6 @@ module Samagotchi
|
|
|
197
210
|
nil
|
|
198
211
|
end
|
|
199
212
|
|
|
200
|
-
def session_run_ids(path, session_id)
|
|
201
|
-
read(path).each_line.filter_map do |text|
|
|
202
|
-
next unless text.include?(session_id)
|
|
203
|
-
|
|
204
|
-
data = parse(text)
|
|
205
|
-
data["id"] if data && data["kind"] == "run" && data["session_id"] == session_id
|
|
206
|
-
end
|
|
207
|
-
end
|
|
208
|
-
|
|
209
213
|
def rewrite_without(path, ids)
|
|
210
214
|
with_file(path) do |file|
|
|
211
215
|
kept = file.each_line.reject do |text|
|
|
@@ -7,6 +7,7 @@ require_relative "../session_manager"
|
|
|
7
7
|
require_relative "../thinking_command"
|
|
8
8
|
require_relative "../bridge_client"
|
|
9
9
|
require_relative "../worker_link"
|
|
10
|
+
require_relative "../worker_presence"
|
|
10
11
|
require_relative "../parent_report"
|
|
11
12
|
require_relative "../cli/exit"
|
|
12
13
|
require_relative "../launch_mode"
|
|
@@ -154,7 +155,7 @@ module Samagotchi
|
|
|
154
155
|
def worker_running?(session_id, state_dir)
|
|
155
156
|
return false unless session_id && Session.valid_id?(session_id)
|
|
156
157
|
|
|
157
|
-
|
|
158
|
+
WorkerPresence.read(session_id, state_dir: state_dir, link: :look).live?
|
|
158
159
|
end
|
|
159
160
|
|
|
160
161
|
# The session's debug records, dim, above the live region.
|
|
@@ -261,8 +262,10 @@ module Samagotchi
|
|
|
261
262
|
SessionManager.resume_session(resume, state_dir: sd)
|
|
262
263
|
else
|
|
263
264
|
SessionManager.spawn_session(prompt: nil, model_name: model && model_ref(model), state_dir: sd,
|
|
264
|
-
|
|
265
|
-
setup: SessionSetup.new(
|
|
265
|
+
client_id: nil,
|
|
266
|
+
setup: SessionSetup.new(preloaded_memory_names: memories,
|
|
267
|
+
muted_memory_names: muted_memories,
|
|
268
|
+
llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context),
|
|
266
269
|
thinking: Thinking.session_level(thinking),
|
|
267
270
|
appended_prompt_files: appended_prompt_files,
|
|
268
271
|
toolkit: toolkit, settings: settings,
|
|
@@ -319,7 +322,7 @@ module Samagotchi
|
|
|
319
322
|
# @raise [Error] its worker is newer than this chi (Skew)
|
|
320
323
|
def connect_existing(session_id, state_dir)
|
|
321
324
|
Session.load(session_id, state_dir: state_dir)
|
|
322
|
-
link =
|
|
325
|
+
link = WorkerPresence.read(session_id, state_dir: state_dir, link: :unlink).link
|
|
323
326
|
raise Error, link.message(session_id) if link.is_a?(WorkerLink::Skew)
|
|
324
327
|
|
|
325
328
|
link.live? ? link.client(session_id) : nil
|
|
@@ -12,7 +12,7 @@ module Samagotchi
|
|
|
12
12
|
# Input composition and Ctrl-C handling for the attached loop.
|
|
13
13
|
# Reads @client, @clock, @command_registry, @continue_offer, @exit_ask,
|
|
14
14
|
# @gaps, @link, @prefilled, @question, @queue, @queued_ids, @reader,
|
|
15
|
-
# @running, @scratch, @screen; writes @prefilled, @last_idle_interrupt_at.
|
|
15
|
+
# @running, @running_turn_id, @scratch, @screen; writes @prefilled, @last_idle_interrupt_at.
|
|
16
16
|
# Calls the loop helpers #answer_continue, #answer_exit_ask, #answer_question,
|
|
17
17
|
# #archive_after_turn, #command_registry, #detach, #echo_answer, #exit_and_archive,
|
|
18
18
|
# #end_scratch, #exit_and_delete, #exit_worker, #refuse_scratch_archive, #gap_divider,
|
|
@@ -185,7 +185,10 @@ module Samagotchi
|
|
|
185
185
|
typed = pressed&.fetch(:text, nil).to_s
|
|
186
186
|
if @running
|
|
187
187
|
begin
|
|
188
|
-
|
|
188
|
+
# Names the turn this terminal shows, so a Ctrl-C as it ends
|
|
189
|
+
# doesn't stop the next one; a snapshot without an id sends none.
|
|
190
|
+
reply = @client.cancel(reason: "ctrl_c", **{ turn_id: @running_turn_id }.compact)
|
|
191
|
+
@screen.commit("a new turn started; Ctrl-C again stops it") if reply&.status == 409 && reply.json&.dig("error") == "turn_mismatch"
|
|
189
192
|
rescue SystemCallError, IOError => e
|
|
190
193
|
@screen.commit("could not cancel the turn (#{worker_down(e)})")
|
|
191
194
|
end
|
|
@@ -138,6 +138,7 @@ module Samagotchi
|
|
|
138
138
|
@renderer = EventRenderer.new(@view)
|
|
139
139
|
@shown_enqueued = Set.new
|
|
140
140
|
@running = false
|
|
141
|
+
@running_turn_id = nil
|
|
141
142
|
@joined_mid_turn = false
|
|
142
143
|
@attached = false
|
|
143
144
|
# The stream moved to a new worker; its snapshot resyncs (#switch_worker).
|
|
@@ -593,6 +594,7 @@ module Samagotchi
|
|
|
593
594
|
|
|
594
595
|
def render_current_turn(turn)
|
|
595
596
|
@running = !turn.nil?
|
|
597
|
+
@running_turn_id = turn&.dig(:turn_id)
|
|
596
598
|
@joined_mid_turn = @running
|
|
597
599
|
# A turn running at the join: the status row's steer hint.
|
|
598
600
|
@status.update(running: @running)
|
|
@@ -628,6 +630,7 @@ module Samagotchi
|
|
|
628
630
|
|
|
629
631
|
def start_turn(event)
|
|
630
632
|
@running = true
|
|
633
|
+
@running_turn_id = event[:turn_id]
|
|
631
634
|
@joined_mid_turn = false
|
|
632
635
|
origin = origin_of(event[:origin])
|
|
633
636
|
@my_queued.delete(origin.enqueued_id)
|
|
@@ -663,12 +666,14 @@ module Samagotchi
|
|
|
663
666
|
# The turn's summary carries its last context estimate.
|
|
664
667
|
@status.update(context: @view.context_status) if @view.context_status
|
|
665
668
|
@running = false
|
|
669
|
+
@running_turn_id = nil
|
|
666
670
|
@joined_mid_turn = false
|
|
667
671
|
close_question("(the turn ended)") if @question
|
|
668
672
|
end
|
|
669
673
|
|
|
670
674
|
def end_turn
|
|
671
675
|
@running = false
|
|
676
|
+
@running_turn_id = nil
|
|
672
677
|
@joined_mid_turn = false
|
|
673
678
|
close_question("(the turn ended)") if @question
|
|
674
679
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../installed_versions"
|
|
4
|
+
require_relative "../compat"
|
|
4
5
|
require_relative "../version"
|
|
5
6
|
require_relative "../chi_invocation"
|
|
6
7
|
|
|
@@ -24,7 +25,7 @@ module Samagotchi
|
|
|
24
25
|
|
|
25
26
|
newest = [installed, terminal].select { |v| Gem::Version.correct?(v.to_s) && !v.to_s.empty? }
|
|
26
27
|
.max_by { |v| Gem::Version.new(v) }
|
|
27
|
-
worker_old =
|
|
28
|
+
worker_old = Compat.verdict(worker_version: worker, version: newest) == :restart_suggested
|
|
28
29
|
terminal_old = InstalledVersions.newer?(newest, terminal)
|
|
29
30
|
return nil unless worker_old || terminal_old
|
|
30
31
|
|
|
@@ -51,7 +52,8 @@ module Samagotchi
|
|
|
51
52
|
def across_machines(worker:, installed:, session_id:, terminal:, via:)
|
|
52
53
|
there = [installed, worker].select { |v| Gem::Version.correct?(v.to_s) && !v.to_s.empty? }
|
|
53
54
|
.max_by { |v| Gem::Version.new(v) }
|
|
54
|
-
worker_old =
|
|
55
|
+
worker_old = installed && !installed.to_s.empty? &&
|
|
56
|
+
Compat.verdict(worker_version: worker, version: installed) == :restart_suggested
|
|
55
57
|
terminal_old = InstalledVersions.newer?(there, terminal)
|
|
56
58
|
return nil unless worker_old || terminal_old
|
|
57
59
|
|
|
@@ -189,12 +189,11 @@ module Samagotchi
|
|
|
189
189
|
mode: "assist",
|
|
190
190
|
model_name: @effective_model_name,
|
|
191
191
|
working_directory: Dir.pwd,
|
|
192
|
-
preloaded_memory_names: @requested_memories,
|
|
193
|
-
muted_memory_names: @muted_memory_names,
|
|
194
192
|
scratch: @scratch,
|
|
195
193
|
setup: SessionSetup.new(appended_prompt_files: @appended_prompt_files, toolkit: @toolkit || Toolkit.configured,
|
|
196
194
|
toolkit_origin: @toolkit ? "chosen" : "default", settings: @session_settings,
|
|
197
|
-
parent_answers: @parent_answers
|
|
195
|
+
parent_answers: @parent_answers, preloaded_memory_names: @requested_memories,
|
|
196
|
+
muted_memory_names: @muted_memory_names)
|
|
198
197
|
))
|
|
199
198
|
# --llm-context*: the session's own values, over a resumed one's.
|
|
200
199
|
# Saved at once on a resumed one: leaving before a turn keeps them.
|