samagotchi 0.46.0 → 0.47.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 +75 -1
- data/docs/cli.md +14 -8
- data/docs/configuration.md +3 -1
- data/docs/desktop.md +2 -1
- data/docs/plugins.md +6 -0
- data/docs/sessions.md +24 -6
- data/lib/samagotchi/bundles/coordinator/manifest.yml +4 -4
- data/lib/samagotchi/bundles/coordinator/plugin.rb +15 -2
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +20 -0
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +26 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +5 -1
- data/lib/samagotchi/engine.rb +4 -5
- data/lib/samagotchi/host_model.rb +14 -7
- data/lib/samagotchi/host_registry.rb +6 -1
- data/lib/samagotchi/llm/chat_loop.rb +27 -7
- data/lib/samagotchi/llm/errors.rb +8 -2
- data/lib/samagotchi/llm_context_strategy.rb +1 -0
- data/lib/samagotchi/model_catalog.rb +13 -12
- data/lib/samagotchi/model_profile.rb +4 -3
- data/lib/samagotchi/models_command.rb +12 -5
- data/lib/samagotchi/question_desk.rb +29 -4
- data/lib/samagotchi/send_command.rb +59 -9
- data/lib/samagotchi/served_model.rb +9 -0
- data/lib/samagotchi/session.rb +13 -4
- data/lib/samagotchi/session_chain.rb +93 -0
- data/lib/samagotchi/session_commands.rb +8 -7
- data/lib/samagotchi/session_manager.rb +225 -14
- data/lib/samagotchi/sessions_command.rb +5 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +26 -11
- data/lib/samagotchi/terminal_ui.rb +4 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +86 -4
- data/lib/samagotchi/web/public/app.js +200 -18
- data/lib/samagotchi/web/public/chains.js +234 -0
- data/lib/samagotchi/web/public/data.js +12 -0
- data/lib/samagotchi/web/public/families_view.js +3 -2
- data/lib/samagotchi/web/public/index.html +32 -6
- data/lib/samagotchi/web/public/model_pick.js +22 -15
- data/lib/samagotchi/web/public/model_picker.js +6 -3
- data/lib/samagotchi/web/public/question_card.js +8 -0
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/sessions_list.js +4 -2
- data/lib/samagotchi/web/public/turn_events.js +14 -6
- data/lib/samagotchi/web/session_summary.rb +3 -0
- data/lib/samagotchi/worker.rb +7 -3
- metadata +3 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "session"
|
|
5
|
+
require_relative "recap_store"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# Session chains: a session that continues an earlier one (Session#continues),
|
|
9
|
+
# "the next day of the same routine". The chain is derived, never stored:
|
|
10
|
+
# follow +continues+ back for the earlier links, forward (the session whose
|
|
11
|
+
# +continues+ names this one) for the later ones. A link is continued at
|
|
12
|
+
# most once (SessionManager.continue_session refuses a second), so the
|
|
13
|
+
# chain's latest link is the one nobody continues.
|
|
14
|
+
module SessionChain
|
|
15
|
+
# `--continues last:<id>`: the latest link of the chain <id> is in.
|
|
16
|
+
LAST_PREFIX = "last:"
|
|
17
|
+
# Who the carry note is from (ContextNote's label: "note from session chain").
|
|
18
|
+
NOTE_SOURCE = "session chain"
|
|
19
|
+
# The most of the previous link's recap the carry note takes; a recap is
|
|
20
|
+
# a few sentences, this only keeps a runaway one under the note's 16 KiB.
|
|
21
|
+
RECAP_CHARS = 8000
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# The session that continues +id+, else nil. Archived ones count: an
|
|
26
|
+
# archived link is still a link.
|
|
27
|
+
# @return [String, nil]
|
|
28
|
+
def next_of(id, state_dir:)
|
|
29
|
+
Session.list(state_dir: state_dir, sort: "created_at", order: "asc", include_archived: true)
|
|
30
|
+
.find { |s| s.continues == id }&.id
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The chain's latest link from +id+ on: +id+ itself when nobody
|
|
34
|
+
# continues it. One listing, walked forward (a loop a hand-edited file
|
|
35
|
+
# could make ends where it started).
|
|
36
|
+
# @return [String]
|
|
37
|
+
def latest(id, state_dir:)
|
|
38
|
+
nexts = {}
|
|
39
|
+
Session.list(state_dir: state_dir, sort: "created_at", order: "asc", include_archived: true).each do |s|
|
|
40
|
+
nexts[s.continues] ||= s.id if s.continues
|
|
41
|
+
end
|
|
42
|
+
seen = [id]
|
|
43
|
+
while (following = nexts[seen.last]) && !seen.include?(following)
|
|
44
|
+
seen << following
|
|
45
|
+
end
|
|
46
|
+
seen.last
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A session to continue as the CLI and the web name it: an id, a unique
|
|
50
|
+
# prefix, or `last:<id or prefix>` for that chain's latest link.
|
|
51
|
+
# @return [String] the full id of an existing session
|
|
52
|
+
# @raise [ArgumentError] none such (Session::AmbiguousId for a prefix of several)
|
|
53
|
+
def resolve(ref, state_dir:)
|
|
54
|
+
given = ref.to_s.strip
|
|
55
|
+
last = given.start_with?(LAST_PREFIX)
|
|
56
|
+
given = given.delete_prefix(LAST_PREFIX) if last
|
|
57
|
+
raise ArgumentError, "no session #{ref}" unless Session.valid_id?(given)
|
|
58
|
+
|
|
59
|
+
id = Session.resolve_id(given, state_dir: state_dir)
|
|
60
|
+
raise ArgumentError, "no session #{ref}" unless Session.exist?(id, state_dir: state_dir)
|
|
61
|
+
|
|
62
|
+
last ? latest(id, state_dir: state_dir) : id
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The context note a new link starts with: which session it continues,
|
|
66
|
+
# from when, and that session's recap with its time, when it has one.
|
|
67
|
+
# Never more than the recap: no transcript.
|
|
68
|
+
# @param previous [Session]
|
|
69
|
+
# @param recap [Hash, nil] RecapStore.read's
|
|
70
|
+
# @return [String]
|
|
71
|
+
def note_text(previous, recap:)
|
|
72
|
+
head = "This session continues #{previous.id[0, 8]} (#{day(previous.created_at)}), the previous link of its chain."
|
|
73
|
+
text = recap && recap[:text].to_s.strip
|
|
74
|
+
return "#{head} It left no recap." if text.nil? || text.empty?
|
|
75
|
+
|
|
76
|
+
text = "#{text[0, RECAP_CHARS - 1]}…" if text.length > RECAP_CHARS
|
|
77
|
+
as_of = clock(recap[:created_at])
|
|
78
|
+
"#{head} Its recap#{", as of #{as_of}" if as_of}:\n#{text}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def day(iso)
|
|
82
|
+
Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d")
|
|
83
|
+
rescue ArgumentError
|
|
84
|
+
iso.to_s
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def clock(iso)
|
|
88
|
+
iso && Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d %H:%M")
|
|
89
|
+
rescue ArgumentError
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -573,14 +573,14 @@ module Samagotchi
|
|
|
573
573
|
results.keys.sort.each do |hname|
|
|
574
574
|
data = results[hname]
|
|
575
575
|
host_label = "#{hname} (#{data[:host]}:#{data[:port]})"
|
|
576
|
+
# Declared ids (hosts.<name>.models) first, as "(config)", never cut
|
|
577
|
+
# by MODELS_PER_HOST; a host that failed keeps only those, as
|
|
578
|
+
# "(config, host down)" under its unreachable line.
|
|
579
|
+
rows = host_rows.fetch(hname, [])
|
|
576
580
|
if data[:error]
|
|
577
581
|
lines << "#{host_label} — unreachable: #{data[:error]}"
|
|
578
|
-
next
|
|
579
|
-
|
|
580
|
-
# Declared ids (hosts.<name>.models) first, as "(config)", never cut
|
|
581
|
-
# by MODELS_PER_HOST.
|
|
582
|
-
rows = host_rows[hname]
|
|
583
|
-
if rows.empty?
|
|
582
|
+
next if rows.empty?
|
|
583
|
+
elsif rows.empty?
|
|
584
584
|
lines << "#{host_label} — no models discovered"
|
|
585
585
|
next
|
|
586
586
|
end
|
|
@@ -599,13 +599,14 @@ module Samagotchi
|
|
|
599
599
|
end
|
|
600
600
|
next if declared.empty? && shown.empty?
|
|
601
601
|
|
|
602
|
-
lines << "#{host_label}:"
|
|
602
|
+
lines << "#{host_label}:" unless data[:error]
|
|
603
603
|
hidden = needle.empty? ? [shown.size - MODELS_PER_HOST, 0].max : 0
|
|
604
604
|
(declared + shown.first(shown.size - hidden)).each do |row, identifier|
|
|
605
605
|
raw = row.info&.raw || {}
|
|
606
606
|
raw_status = raw["status"] || raw[:status]
|
|
607
607
|
status = raw_status.is_a?(Hash) ? (raw_status["value"] || raw_status[:value] || raw_status["status"] || raw_status[:status]) : raw_status
|
|
608
608
|
status = ["config", status].reject { |s| s.to_s.empty? }.join(", ") if row.configured
|
|
609
|
+
status = "#{status}, host down" if row.unavailable
|
|
609
610
|
base = status.to_s.empty? ? " #{identifier}" : " #{identifier} (#{status})"
|
|
610
611
|
alias_list = (by_model[identifier.to_s.downcase] || []) + (by_model["#{hname}:#{identifier}".downcase] || [])
|
|
611
612
|
alias_list.uniq!
|
|
@@ -30,6 +30,7 @@ require_relative "continue_offer"
|
|
|
30
30
|
require_relative "image_store"
|
|
31
31
|
require_relative "plugin_session_state"
|
|
32
32
|
require_relative "context_sources"
|
|
33
|
+
require_relative "session_chain"
|
|
33
34
|
require_relative "terminal_ui"
|
|
34
35
|
|
|
35
36
|
module Samagotchi
|
|
@@ -115,6 +116,33 @@ module Samagotchi
|
|
|
115
116
|
end
|
|
116
117
|
end
|
|
117
118
|
|
|
119
|
+
# A continue that can't happen (#continue_session). reason: :continued
|
|
120
|
+
# (another session continues it already: +ids+ names it), :open_children
|
|
121
|
+
# (children still running, waiting, live or with a reply it wasn't given:
|
|
122
|
+
# +ids+ names them) or :folder_gone (its folder isn't there).
|
|
123
|
+
class ContinueRefused < StandardError
|
|
124
|
+
attr_reader :session_id, :reason, :ids
|
|
125
|
+
|
|
126
|
+
def initialize(session_id, reason, ids: [], detail: nil)
|
|
127
|
+
@session_id = session_id
|
|
128
|
+
@reason = reason
|
|
129
|
+
@ids = ids
|
|
130
|
+
super(continue_refused_message(detail))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def continue_refused_message(detail)
|
|
136
|
+
short = @session_id[0, 8]
|
|
137
|
+
case @reason
|
|
138
|
+
when :continued then "#{short} is continued already, by #{@ids.first[0, 8]}; continue that one (or last:#{short})"
|
|
139
|
+
when :open_children
|
|
140
|
+
"#{short} has delegates still open: #{detail}; wait for them, stop them or archive them, then continue"
|
|
141
|
+
else "#{short}'s folder #{detail} is gone"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
118
146
|
# Spawn a new background session that processes the given prompt (or,
|
|
119
147
|
# with none, waits idle for input).
|
|
120
148
|
#
|
|
@@ -133,11 +161,17 @@ module Samagotchi
|
|
|
133
161
|
# (the prompt's preview by default, else the seed's first user message)
|
|
134
162
|
# @param llm_context [LLMContextOverride, nil] the session's own
|
|
135
163
|
# llm_context values (chi --llm-context; a fork copies its parent's)
|
|
164
|
+
# @param continues [String, nil] the session this one continues (a
|
|
165
|
+
# session field; #continue_session)
|
|
166
|
+
# @param note [String, nil] a context note the session starts with,
|
|
167
|
+
# from +note_source+: queued before its worker spawns, so its first
|
|
168
|
+
# turn sees it
|
|
136
169
|
# @return [Session] with #seed_images_dropped: refs whose file was gone,
|
|
137
170
|
# and #model_warning: an id its host's saved list doesn't have
|
|
138
171
|
def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
|
|
139
172
|
memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
|
|
140
|
-
title: nil, delegate: false, llm_context: nil
|
|
173
|
+
title: nil, delegate: false, llm_context: nil, continues: nil, note: nil,
|
|
174
|
+
note_source: "chi")
|
|
141
175
|
sd = state_dir || Session.default_state_dir
|
|
142
176
|
# The resolved ref is stored (a resumed session keeps its model when an
|
|
143
177
|
# alias is retargeted), with the name as typed beside it. A wrong host
|
|
@@ -158,7 +192,8 @@ module Samagotchi
|
|
|
158
192
|
parent_id: parent_id,
|
|
159
193
|
messages: messages,
|
|
160
194
|
delegate: delegate,
|
|
161
|
-
llm_context: llm_context
|
|
195
|
+
llm_context: llm_context,
|
|
196
|
+
continues: continues
|
|
162
197
|
)
|
|
163
198
|
session.model_warning = model_warning
|
|
164
199
|
# With no prompt there is no first turn to run (an attaching UI sends
|
|
@@ -174,10 +209,175 @@ module Samagotchi
|
|
|
174
209
|
session.seed_images_dropped = dropped
|
|
175
210
|
end
|
|
176
211
|
setup_session_directory(session_dir, session, state_dir: sd)
|
|
212
|
+
SessionInbox.write_note(session.id, text: note, source: note_source, state_dir: sd) if note
|
|
177
213
|
spawn_worker_for_session(session, state_dir: sd)
|
|
178
214
|
session
|
|
179
215
|
end
|
|
180
216
|
|
|
217
|
+
# How long #continue_session waits for the previous link's worker to
|
|
218
|
+
# write its recap (IdleRecap's own request timeout).
|
|
219
|
+
CONTINUE_RECAP_WAIT = 30.0
|
|
220
|
+
# In the previous link's folder: one continue of a session at a time.
|
|
221
|
+
CONTINUE_LOCK = "continue.lock"
|
|
222
|
+
# How often a last:<id> continue looks again when another continue
|
|
223
|
+
# moved the chain's end while it waited for the lock.
|
|
224
|
+
CONTINUE_TRIES = 3
|
|
225
|
+
|
|
226
|
+
# Start the next link of a chain: a new session that continues
|
|
227
|
+
# +id_or_ref+ (Session#continues) in its folder, on its model (the name
|
|
228
|
+
# it was typed as, so an alias holds) and with its own llm_context,
|
|
229
|
+
# starting with a context note from chi: the link and the previous
|
|
230
|
+
# link's recap (SessionChain.note_text). The previous link is archived
|
|
231
|
+
# first, with its delegates (archive_session's rules: a turn running,
|
|
232
|
+
# a prompt queued or the step-limit question refuse; an idle worker is
|
|
233
|
+
# stopped), so a refusal starts nothing. Before that its live worker is
|
|
234
|
+
# asked for a recap and given up to +recap_wait+ seconds to write it.
|
|
235
|
+
# One continue of a session at a time (a lock in its folder): a double
|
|
236
|
+
# click finds the first one's link and is refused; a last:<id> one
|
|
237
|
+
# follows the chain to its new end instead.
|
|
238
|
+
# @param id_or_ref [String] an id, a prefix, or last:<id> (SessionChain.resolve)
|
|
239
|
+
# @param prompt [String, nil] its first message (nil: it starts idle)
|
|
240
|
+
# @param title [String, nil] what the lists show before the first turn;
|
|
241
|
+
# by default the prompt, else the previous link's preview
|
|
242
|
+
# @return [Session] the new link (#model_warning as spawn_session's)
|
|
243
|
+
# @raise [ArgumentError] no such session (Session::AmbiguousId for a prefix of several)
|
|
244
|
+
# @raise [ContinueRefused] continued already, delegates still open, or its folder is gone
|
|
245
|
+
# @raise [ArchiveRefused, OwnedByTUI] as archive_session
|
|
246
|
+
def self.continue_session(id_or_ref, prompt: nil, title: nil, state_dir: nil, recap_wait: CONTINUE_RECAP_WAIT,
|
|
247
|
+
archive_wait: 5)
|
|
248
|
+
sd = state_dir || Session.default_state_dir
|
|
249
|
+
CONTINUE_TRIES.times do |try|
|
|
250
|
+
id = SessionChain.resolve(id_or_ref, state_dir: sd)
|
|
251
|
+
result = with_continue_lock(id, sd) do
|
|
252
|
+
follow = id_or_ref.to_s.strip.start_with?(SessionChain::LAST_PREFIX) && try < CONTINUE_TRIES - 1
|
|
253
|
+
next :moved if follow && SessionChain.next_of(id, state_dir: sd)
|
|
254
|
+
|
|
255
|
+
continue_locked(id, prompt: prompt, title: title, state_dir: sd, recap_wait: recap_wait,
|
|
256
|
+
archive_wait: archive_wait)
|
|
257
|
+
end
|
|
258
|
+
return result unless result == :moved
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
private_class_method def self.with_continue_lock(id, state_dir, &)
|
|
263
|
+
dir = Session.session_dir(id, state_dir: state_dir)
|
|
264
|
+
FileUtils.mkdir_p(dir)
|
|
265
|
+
File.open(File.join(dir, CONTINUE_LOCK), File::RDWR | File::CREAT, 0o600) do |lock|
|
|
266
|
+
lock.flock(File::LOCK_EX)
|
|
267
|
+
yield
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
private_class_method def self.continue_locked(id, prompt:, title:, state_dir:, recap_wait:, archive_wait:)
|
|
272
|
+
previous = Session.load(id, state_dir: state_dir)
|
|
273
|
+
check_continue!(previous, state_dir)
|
|
274
|
+
# archive_session's refusals that can be known now, before the wait.
|
|
275
|
+
archivable!(id, state_dir)
|
|
276
|
+
recap_before_archive(id, state_dir, wait: recap_wait)
|
|
277
|
+
# Again: a turn sent to it during the wait may have delegated.
|
|
278
|
+
check_open_children!(id, state_dir)
|
|
279
|
+
already = [id, *descendant_ids(id, state_dir)].select do |sid|
|
|
280
|
+
ArchiveStore.archived?(Session.session_dir(sid, state_dir: state_dir))
|
|
281
|
+
end
|
|
282
|
+
archived = archive_session(id, state_dir: state_dir, wait: archive_wait)[:archived] - already
|
|
283
|
+
begin
|
|
284
|
+
spawn_continuation(previous, prompt: prompt, title: title, state_dir: state_dir)
|
|
285
|
+
rescue StandardError
|
|
286
|
+
# Nothing started: what this archived goes back to the lists, and
|
|
287
|
+
# a new link saved before its worker failed to spawn goes (left,
|
|
288
|
+
# it would hold the chain: "continued already", last: to it).
|
|
289
|
+
discard_failed_link(id, state_dir)
|
|
290
|
+
archived.each { |sid| ArchiveStore.unarchive(sid, state_dir: state_dir) }
|
|
291
|
+
raise
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# The link a failed #continue_session saved (no other continues +id+:
|
|
296
|
+
# check_continue! made sure under the lock).
|
|
297
|
+
private_class_method def self.discard_failed_link(id, state_dir)
|
|
298
|
+
orphan = SessionChain.next_of(id, state_dir: state_dir) or return
|
|
299
|
+
delete_session(orphan, state_dir: state_dir)
|
|
300
|
+
rescue StandardError => e
|
|
301
|
+
Log.warn(:worker, "continue_cleanup_failed", sid: id, error: e.class.name, msg: e.message)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
private_class_method def self.spawn_continuation(previous, prompt:, title:, state_dir:)
|
|
305
|
+
recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
|
|
306
|
+
start = prompt.to_s.strip.empty? ? nil : prompt
|
|
307
|
+
title = previous.first_preview if title.to_s.strip.empty? && start.nil?
|
|
308
|
+
spawn_session(prompt: start, title: title, working_directory: previous.working_directory,
|
|
309
|
+
model_name: previous.model_typed || previous.model_name, llm_context: previous.llm_context,
|
|
310
|
+
continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
|
|
311
|
+
note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# The refusals that come before anything changes: continued already (no
|
|
315
|
+
# forks in a chain), a delegate still open (its report would go to the
|
|
316
|
+
# archived link), the folder gone (the worker would run elsewhere), a
|
|
317
|
+
# model whose host config.yml no longer has.
|
|
318
|
+
# @raise [ContinueRefused, ModelProfile::MissingModel]
|
|
319
|
+
private_class_method def self.check_continue!(previous, state_dir)
|
|
320
|
+
if (following = SessionChain.next_of(previous.id, state_dir: state_dir))
|
|
321
|
+
raise ContinueRefused.new(previous.id, :continued, ids: [following])
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
check_open_children!(previous.id, state_dir)
|
|
325
|
+
# Its model's host, as spawn_session checks it: before the archive
|
|
326
|
+
# stops the previous link's worker.
|
|
327
|
+
ModelProfile.check_host!(ModelProfile.required_model_name(previous.model_typed || previous.model_name))
|
|
328
|
+
dir = previous.working_directory.to_s
|
|
329
|
+
raise ContinueRefused.new(previous.id, :folder_gone, detail: dir) unless File.directory?(dir)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# A child the next link couldn't follow once its parent is archived.
|
|
333
|
+
OpenChild = Data.define(:id, :short_id, :why)
|
|
334
|
+
|
|
335
|
+
# @raise [ContinueRefused] while #open_children has any
|
|
336
|
+
private_class_method def self.check_open_children!(id, state_dir)
|
|
337
|
+
open = open_children(id, state_dir)
|
|
338
|
+
return if open.empty?
|
|
339
|
+
|
|
340
|
+
raise ContinueRefused.new(id, :open_children, ids: open.map(&:id),
|
|
341
|
+
detail: open.map { |c| "#{c.short_id} (#{c.why})" }.join(", "))
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# The unarchived children, at any depth (the archive's cascade), of the
|
|
345
|
+
# session still running, waiting for an answer, with a live worker, or
|
|
346
|
+
# (a delegate) with a reply its parent wasn't given (ChildrenStatus).
|
|
347
|
+
# @return [Array<OpenChild>]
|
|
348
|
+
private_class_method def self.open_children(id, state_dir)
|
|
349
|
+
require_relative "children_status"
|
|
350
|
+
[id, *descendant_ids(id, state_dir)].flat_map { |node| ChildrenStatus.of(node, state_dir: state_dir) }.uniq(&:id)
|
|
351
|
+
.filter_map do |child|
|
|
352
|
+
why = if %w[waiting running].include?(child.state) then child.state
|
|
353
|
+
elsif child.live then "live"
|
|
354
|
+
elsif child.delegate && child.last_reply && !child.reported then "unreported reply"
|
|
355
|
+
end
|
|
356
|
+
why && OpenChild.new(id: child.id, short_id: child.short_id, why: why)
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Ask the session's live worker for a recap now (Bridge POST /recap:
|
|
361
|
+
# IdleRecap#request_now) and wait, up to +wait+ seconds, for recap.json
|
|
362
|
+
# to change. A worker that is gone wrote its recap when it left; one
|
|
363
|
+
# that has nothing new to say, too short a conversation, or recaps off
|
|
364
|
+
# answers at once. Best effort: the continue goes on without.
|
|
365
|
+
private_class_method def self.recap_before_archive(id, state_dir, wait:)
|
|
366
|
+
return unless worker_live?(id, state_dir: state_dir)
|
|
367
|
+
|
|
368
|
+
dir = Session.session_dir(id, state_dir: state_dir)
|
|
369
|
+
client = BridgeClient.discover(id, session_dir: dir) or return
|
|
370
|
+
before = RecapStore.read(dir)
|
|
371
|
+
reply = client.request_recap
|
|
372
|
+
asked = reply.status == 200 && reply.json.is_a?(Hash) ? reply.json["request"] : nil
|
|
373
|
+
return unless %w[started in_flight].include?(asked)
|
|
374
|
+
|
|
375
|
+
BridgeClient.poll(wait, interval: 0.2) { RecapStore.read(dir) != before }
|
|
376
|
+
rescue StandardError => e
|
|
377
|
+
Log.info(:worker, "continue_recap_failed", sid: id, error: e.class.name, msg: e.message)
|
|
378
|
+
nil
|
|
379
|
+
end
|
|
380
|
+
|
|
181
381
|
# Build the opts hash passed to Process.spawn for a forked worker. The
|
|
182
382
|
# child inherits this process's ENV and reads config.yml itself (as it
|
|
183
383
|
# is when the worker starts); opts[:env] adds to it (merged, not
|
|
@@ -279,9 +479,11 @@ module Samagotchi
|
|
|
279
479
|
# @param include_archived [Boolean] archived sessions too
|
|
280
480
|
# @return [Array<Hash>] {id:, short_id:, desc:, preview:, cwd:, project:,
|
|
281
481
|
# updated_at:, status:, live:, busy:, owner:, recap:, parent_id:,
|
|
282
|
-
# parent_short_id:, delegate:, archived:, scratch:, test_run:, waiting:,
|
|
482
|
+
# parent_short_id:, continues:, continues_short_id:, delegate:, archived:, scratch:, test_run:, waiting:,
|
|
483
|
+
# waiting_id:, stopped_by:}; busy =
|
|
283
484
|
# live with a turn running, delegate = started by the delegate tool
|
|
284
|
-
# (Session#delegate?; a fork has a parent_id but isn't one),
|
|
485
|
+
# (Session#delegate?; a fork has a parent_id but isn't one), continues = the session it continues (the
|
|
486
|
+
# previous link of its chain, Session#continues), recap = the saved recap's first sentence, project =
|
|
285
487
|
# Session#project_root, waiting = the kind of question it waits on
|
|
286
488
|
# ("question", "approval", "hook"; Session#waiting_question) or nil,
|
|
287
489
|
# waiting_id = that question's id or nil, relayed_to = the parent
|
|
@@ -310,7 +512,8 @@ module Samagotchi
|
|
|
310
512
|
owner: owner&.kind, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
|
|
311
513
|
ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd),
|
|
312
514
|
budget_tokens: -> { budgets.call(s) })&.round(1),
|
|
313
|
-
parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8),
|
|
515
|
+
parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8), continues: s.continues,
|
|
516
|
+
continues_short_id: s.continues&.[](0, 8), delegate: s.delegate?, archived: s.archived,
|
|
314
517
|
scratch: s.scratch, test_run: s.test_run, stopped_by: s.stopped_by }.merge(waiting_fields(s.waiting_question(live: owned)))
|
|
315
518
|
end
|
|
316
519
|
(limit ? summaries.first(limit) : summaries.to_a)
|
|
@@ -357,21 +560,29 @@ module Samagotchi
|
|
|
357
560
|
def self.archive_session(id_or_prefix, state_dir: nil, wait: 5)
|
|
358
561
|
sd = state_dir || Session.default_state_dir
|
|
359
562
|
id = archive_target(id_or_prefix, sd)
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
tree = [id, *descendant_ids(id, sd)]
|
|
363
|
-
owners = tree.to_h { |sid| [sid, session_owner(sid, state_dir: sd)] }
|
|
364
|
-
tree.each do |sid|
|
|
365
|
-
refusal = archive_refusal(sid, owners[sid], sd)
|
|
366
|
-
raise ArchiveRefused.new(id, sid == id ? refusal : :"#{refusal}_child", busy_id: sid) if refusal
|
|
367
|
-
end
|
|
368
|
-
|
|
563
|
+
tree, owners = archivable!(id, sd)
|
|
369
564
|
stopped = tree.select { |sid| owners[sid] }
|
|
370
565
|
stopped.each { |sid| stop_session(sid, state_dir: sd, wait: wait) }
|
|
371
566
|
archived, discarded = tree.partition { |sid| ArchiveStore.archive(sid, state_dir: sd) }
|
|
372
567
|
{ id: id, archived: archived, stopped: stopped, discarded: discarded }
|
|
373
568
|
end
|
|
374
569
|
|
|
570
|
+
# The session and its descendants, with their owners, when they can be
|
|
571
|
+
# archived now (#archive_refusal for each), else the refusal.
|
|
572
|
+
# @return [Array(Array<String>, Hash)] the ids, and id => OwnerLock::Owner or nil
|
|
573
|
+
# @raise [ArchiveRefused, OwnedByTUI]
|
|
574
|
+
private_class_method def self.archivable!(id, state_dir)
|
|
575
|
+
raise ArchiveRefused.new(id, :scratch) if Session.load(id, state_dir: state_dir).scratch
|
|
576
|
+
|
|
577
|
+
tree = [id, *descendant_ids(id, state_dir)]
|
|
578
|
+
owners = tree.to_h { |sid| [sid, session_owner(sid, state_dir: state_dir)] }
|
|
579
|
+
tree.each do |sid|
|
|
580
|
+
refusal = archive_refusal(sid, owners[sid], state_dir)
|
|
581
|
+
raise ArchiveRefused.new(id, sid == id ? refusal : :"#{refusal}_child", busy_id: sid) if refusal
|
|
582
|
+
end
|
|
583
|
+
[tree, owners]
|
|
584
|
+
end
|
|
585
|
+
|
|
375
586
|
# What keeps +sid+ (in an archive's tree) from being archived: :busy (a
|
|
376
587
|
# turn runs), :queued (input/ holds a prompt, which outlives the stop and
|
|
377
588
|
# runs on a resume, live worker or not), :offer (a live worker's continue
|
|
@@ -170,6 +170,7 @@ module Samagotchi
|
|
|
170
170
|
# its git project's root, or nil
|
|
171
171
|
# parent_id: the session that delegated it (the delegate tool), or nil
|
|
172
172
|
# delegate: started by the delegate tool (a fork has a parent_id too)
|
|
173
|
+
# continues: the session it continues (its chain's previous link), or nil
|
|
173
174
|
# archived: hidden from the lists (only with --archived can it be true)
|
|
174
175
|
# scratch: a `chi scratch` session (deleted when its REPL ends, a
|
|
175
176
|
# leftover one at the next sweep)
|
|
@@ -178,7 +179,7 @@ module Samagotchi
|
|
|
178
179
|
# hook: chi answer or the web answers it), or nil; waiting_id: its
|
|
179
180
|
# id, for chi answer --question; relayed_to: the parent session (short
|
|
180
181
|
# id) whose card it also waits in (the approval relay), or nil
|
|
181
|
-
keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id delegate archived scratch ctx_pct
|
|
182
|
+
keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id continues delegate archived scratch ctx_pct
|
|
182
183
|
waiting waiting_id relayed_to stopped_by]
|
|
183
184
|
@stdout.puts JSON.generate(summaries.map { |summary| summary.slice(*keys) })
|
|
184
185
|
when "tsv"
|
|
@@ -199,6 +200,8 @@ module Samagotchi
|
|
|
199
200
|
end
|
|
200
201
|
# A delegated session points at its parent.
|
|
201
202
|
child = summary[:parent_short_id] ? " ↳ #{summary[:parent_short_id]}" : ""
|
|
203
|
+
# A chain's link points at the one it continues.
|
|
204
|
+
child += " ↪ #{summary[:continues_short_id]}" if summary[:continues_short_id]
|
|
202
205
|
flag = session_flag(scratch: summary[:scratch], test_run: summary[:test_run], archived: summary[:archived],
|
|
203
206
|
stopped_by: summary[:stopped_by])
|
|
204
207
|
ctx = Samagotchi::SessionMetrics.context_label(summary[:ctx_pct])
|
|
@@ -244,6 +247,7 @@ module Samagotchi
|
|
|
244
247
|
row = lambda do |s|
|
|
245
248
|
flag = session_flag(scratch: s.scratch, test_run: s.test_run, archived: s.archived, stopped_by: s.stopped_by)
|
|
246
249
|
child = s.parent_id ? " ↳ #{s.parent_id[0, 8]}" : ""
|
|
250
|
+
child += " ↪ #{s.continues[0, 8]}" if s.continues
|
|
247
251
|
# A question waits for an answer (chi answer, the web, chi --attach).
|
|
248
252
|
live = s.pending_question && Samagotchi::SessionManager.worker_live?(s.id, state_dir: state_dir)
|
|
249
253
|
waiting = s.waiting_question(live: !!live)
|
|
@@ -96,17 +96,29 @@ module Samagotchi
|
|
|
96
96
|
paint(TurnNote.empty_answer_line(retries), 90)
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
-
# "retrying (
|
|
100
|
-
#
|
|
101
|
-
#
|
|
99
|
+
# "↻ retrying (503) in 4.0s, 2/5": the live status while the provider
|
|
100
|
+
# is asked again (generation_retrying), worded as the web words it
|
|
101
|
+
# (turn_events.js retryStatusLine; spec/shared/labels_matrix.json):
|
|
102
|
+
# why (the HTTP status, the error's short class name, or "stream
|
|
103
|
+
# dropped" for a dropped stream's step asked again), the wait before
|
|
104
|
+
# it, and the retry of all the retries there will be.
|
|
102
105
|
def format_generation_retry_line(event)
|
|
103
|
-
|
|
106
|
+
why = event[:restarted] ? "stream dropped" : retry_cause(event)
|
|
107
|
+
delay = Float(event[:next_delay], exception: false)
|
|
108
|
+
text = +"\u21bb retrying"
|
|
109
|
+
text << " (#{why})" unless why.empty?
|
|
110
|
+
text << " in #{format("%.1f", (delay * 10).round / 10.0)}s" if delay&.finite?
|
|
111
|
+
text << ", #{event[:attempt]}#{"/#{event[:max_retries]}" if event[:max_retries]}" if event[:attempt]
|
|
112
|
+
text
|
|
113
|
+
end
|
|
104
114
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
115
|
+
# What failed, for a retry line: the HTTP status, else the error's
|
|
116
|
+
# class name without its namespace (Errno::ECONNREFUSED: ECONNREFUSED).
|
|
117
|
+
def retry_cause(event)
|
|
118
|
+
status = event[:status]
|
|
119
|
+
return status.to_s unless status.nil? || status.to_s.empty? || status.to_s == "0"
|
|
108
120
|
|
|
109
|
-
event[:error_class].to_s.
|
|
121
|
+
event[:error_class].to_s.split("::").last.to_s
|
|
110
122
|
end
|
|
111
123
|
|
|
112
124
|
# " → image 1280×800" after a tool line whose tool read an image.
|
|
@@ -356,13 +368,16 @@ module Samagotchi
|
|
|
356
368
|
end
|
|
357
369
|
|
|
358
370
|
# /stats' cost: the reported part and the estimated one, each when present.
|
|
359
|
-
|
|
371
|
+
# +price_host+ names the host whose hosts.<name>.models prices the
|
|
372
|
+
# estimate came from (Engine#stats_snapshot; nil from an older worker).
|
|
373
|
+
def stats_cost_text(tokens, price_host: nil)
|
|
360
374
|
reported = cost_text(tokens[:cost_sum])
|
|
361
375
|
estimated = cost_text(tokens[:cost_estimate_sum], estimate: true)
|
|
362
376
|
return nil if reported.empty? && estimated.empty?
|
|
363
377
|
return "#{reported} (this session only)" if estimated.empty?
|
|
364
378
|
|
|
365
|
-
|
|
379
|
+
source = price_host.to_s.empty? ? "configured prices" : "hosts.#{price_host}.models prices"
|
|
380
|
+
estimate = "#{estimated} from #{source}"
|
|
366
381
|
"#{reported.empty? ? estimate : "#{reported} reported + #{estimate}"} (this session only)"
|
|
367
382
|
end
|
|
368
383
|
|
|
@@ -492,7 +507,7 @@ module Samagotchi
|
|
|
492
507
|
"#{token_breakdown_text(tokens)}"
|
|
493
508
|
speed = stats_speed_text(tokens)
|
|
494
509
|
lines << "speed: #{speed}" if speed
|
|
495
|
-
cost = stats_cost_text(tokens)
|
|
510
|
+
cost = stats_cost_text(tokens, price_host: snapshot[:price_host])
|
|
496
511
|
lines << "cost: #{cost}" if cost
|
|
497
512
|
lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
|
|
498
513
|
lines << "cancellations: #{snapshot[:cancellations]}"
|
|
@@ -1351,9 +1351,11 @@ module Samagotchi
|
|
|
1351
1351
|
# question there): the question is dismissed at once, as an empty answer
|
|
1352
1352
|
# would be, and the model finishes its reply. Never a read of stdin: a
|
|
1353
1353
|
# pipe its writer keeps open would block it forever. Its one line goes to
|
|
1354
|
-
# stderr (#banner): stdout is the answer's alone.
|
|
1354
|
+
# stderr (#banner): stdout is the answer's alone. The model is told no
|
|
1355
|
+
# one could answer (QuestionDesk::UNANSWERABLE_NOTE), not that the user
|
|
1356
|
+
# dismissed it.
|
|
1355
1357
|
def dismiss_unaskable_question(prompt)
|
|
1356
|
-
@engine.cancel_question(
|
|
1358
|
+
@engine.cancel_question(QuestionDesk::UNANSWERABLE_REASON) rescue nil
|
|
1357
1359
|
banner(prompt.summary("(dismissed: no one to answer in a non-interactive run)", paint: method(:paint)))
|
|
1358
1360
|
false
|
|
1359
1361
|
end
|