samagotchi 0.155.0 → 0.156.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -1
- data/bin/chi +1 -1
- data/docs/architecture.md +10 -6
- data/docs/internals/anthropic.md +1 -1
- data/docs/internals/attached-context.md +3 -2
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/engine.md +625 -935
- data/docs/internals/llm-context-forget.md +8 -3
- data/docs/internals/objectives.md +2 -2
- data/docs/internals/prompt-caching.md +4 -1
- data/docs/internals/remote-sessions.md +3 -1
- data/docs/internals/working-set.md +1 -1
- data/docs/sessions.md +2 -2
- data/docs/system-one.md +3 -2
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
- data/lib/samagotchi/broadcast_command.rb +3 -3
- data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/child_move.rb +7 -51
- data/lib/samagotchi/children_status.rb +1 -1
- data/lib/samagotchi/cli/parent_wait.rb +1 -1
- data/lib/samagotchi/config.rb +17 -112
- data/lib/samagotchi/context_command.rb +1 -2
- data/lib/samagotchi/context_window.rb +3 -3
- data/lib/samagotchi/desktop/macos.rb +1 -1
- data/lib/samagotchi/engine/context_judge.rb +19 -11
- data/lib/samagotchi/engine/messages.rb +1 -1
- data/lib/samagotchi/engine/models.rb +5 -2
- data/lib/samagotchi/engine/objectives.rb +29 -11
- data/lib/samagotchi/engine/side_requests.rb +21 -11
- data/lib/samagotchi/engine/toolkit.rb +10 -9
- data/lib/samagotchi/engine/turn_end.rb +1 -1
- data/lib/samagotchi/engine/turn_helpers.rb +7 -7
- data/lib/samagotchi/engine/verify.rb +1 -1
- data/lib/samagotchi/engine.rb +14 -9
- data/lib/samagotchi/guardrails/targets.rb +24 -9
- data/lib/samagotchi/host_config.rb +72 -1
- data/lib/samagotchi/host_model.rb +2 -6
- data/lib/samagotchi/host_registry.rb +1 -1
- data/lib/samagotchi/kernel_loop.rb +46 -121
- data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
- data/lib/samagotchi/llm/chat_loop.rb +34 -18
- data/lib/samagotchi/llm/native_backend.rb +1 -1
- data/lib/samagotchi/llm_context_apply.rb +54 -1
- data/lib/samagotchi/llm_context_notice.rb +1 -1
- data/lib/samagotchi/llm_context_strategy.rb +7 -9
- data/lib/samagotchi/llm_context_view.rb +9 -0
- data/lib/samagotchi/model_price.rb +4 -10
- data/lib/samagotchi/model_profile.rb +6 -6
- data/lib/samagotchi/model_settings.rb +37 -2
- data/lib/samagotchi/note_command.rb +1 -2
- data/lib/samagotchi/objectives_audit.rb +4 -3
- data/lib/samagotchi/participants.rb +2 -2
- data/lib/samagotchi/plugin/context.rb +1 -1
- data/lib/samagotchi/plugin/sessions.rb +2 -5
- data/lib/samagotchi/plugin/tool_spec.rb +5 -3
- data/lib/samagotchi/project_env.rb +1 -1
- data/lib/samagotchi/reasoning_history.rb +3 -3
- data/lib/samagotchi/reminder_carry.rb +1 -1
- data/lib/samagotchi/reprefill_meter.rb +45 -0
- data/lib/samagotchi/review_command.rb +1 -1
- data/lib/samagotchi/sampling_settings.rb +3 -3
- data/lib/samagotchi/self_report.rb +7 -7
- data/lib/samagotchi/session.rb +50 -7
- data/lib/samagotchi/session_access/local.rb +7 -5
- data/lib/samagotchi/session_access/remote.rb +17 -3
- data/lib/samagotchi/session_archive_command.rb +2 -2
- data/lib/samagotchi/session_chain.rb +7 -6
- data/lib/samagotchi/session_continue.rb +6 -5
- data/lib/samagotchi/session_delete_command.rb +1 -1
- data/lib/samagotchi/session_manager/delivery.rb +254 -0
- data/lib/samagotchi/session_manager/emptiness.rb +138 -0
- data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
- data/lib/samagotchi/session_manager/listing.rb +189 -0
- data/lib/samagotchi/session_manager/removal.rb +190 -0
- data/lib/samagotchi/session_manager/spawn.rb +242 -0
- data/lib/samagotchi/session_manager/worker_command.rb +157 -0
- data/lib/samagotchi/session_manager.rb +26 -1540
- data/lib/samagotchi/session_metrics.rb +1 -1
- data/lib/samagotchi/sessions_command.rb +1 -1
- data/lib/samagotchi/share_command.rb +1 -1
- data/lib/samagotchi/starting_marker.rb +65 -0
- data/lib/samagotchi/steer_cut_after.rb +3 -3
- data/lib/samagotchi/system_one/recorder.rb +49 -0
- data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +2 -6
- data/lib/samagotchi/testing/sessions.rb +1 -1
- data/lib/samagotchi/thinking.rb +3 -3
- data/lib/samagotchi/token_usage.rb +1 -1
- data/lib/samagotchi/tool_activity.rb +10 -4
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tool_output_cut.rb +17 -0
- data/lib/samagotchi/tool_runner.rb +7 -4
- data/lib/samagotchi/toolkit_command.rb +1 -1
- data/lib/samagotchi/tools/delegate.rb +10 -5
- data/lib/samagotchi/tools/delegate_result.rb +4 -4
- data/lib/samagotchi/tools/index_hint.rb +14 -0
- data/lib/samagotchi/tools/registry.rb +28 -11
- data/lib/samagotchi/tools/send_note.rb +4 -6
- data/lib/samagotchi/tools/tool_index.rb +52 -51
- data/lib/samagotchi/tools/tool_surface.rb +22 -8
- data/lib/samagotchi/turn_context.rb +2 -1
- data/lib/samagotchi/turn_stages/context_judge.rb +38 -36
- data/lib/samagotchi/turn_stages/objectives.rb +56 -52
- data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +13 -17
- data/lib/samagotchi/turn_stages/verify_anchors.rb +9 -10
- data/lib/samagotchi/turn_stages/warmup.rb +14 -15
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +3 -3
- data/lib/samagotchi/web/app.rb +15 -8
- data/lib/samagotchi/web/participant_access.rb +2 -2
- data/lib/samagotchi/web/session_access_routes.rb +3 -3
- data/lib/samagotchi/web/session_control.rb +3 -3
- data/lib/samagotchi/web/session_create.rb +3 -3
- data/lib/samagotchi/web/session_hub.rb +1 -1
- data/lib/samagotchi/web/session_show.rb +3 -3
- data/lib/samagotchi/web/session_summary.rb +3 -4
- data/lib/samagotchi/worker.rb +55 -27
- data/lib/samagotchi/worker_boot.rb +184 -0
- data/lib/samagotchi/worker_signals.rb +1 -1
- data/lib/samagotchi/worker_task_wakes.rb +2 -2
- metadata +13 -1
|
@@ -7,6 +7,7 @@ require_relative "session_inbox"
|
|
|
7
7
|
require_relative "session_chain"
|
|
8
8
|
require_relative "objectives"
|
|
9
9
|
require_relative "child_move"
|
|
10
|
+
require_relative "starting_marker"
|
|
10
11
|
require_relative "context_move"
|
|
11
12
|
require_relative "archive_store"
|
|
12
13
|
require_relative "recap_store"
|
|
@@ -17,9 +18,9 @@ require_relative "log"
|
|
|
17
18
|
require_relative "session_manager"
|
|
18
19
|
|
|
19
20
|
module Samagotchi
|
|
20
|
-
# The next link of a session chain
|
|
21
|
-
# Continue →
|
|
22
|
-
#
|
|
21
|
+
# The next link of a session chain: chi send --new --continues and web
|
|
22
|
+
# Continue → run .run. The archive, spawn, stop and wake it builds on are
|
|
23
|
+
# SessionManager's.
|
|
23
24
|
module SessionContinue
|
|
24
25
|
# A continue that can't happen (#run). reason: :continued
|
|
25
26
|
# (another session continues it already: +ids+ names it), :open_children
|
|
@@ -198,7 +199,7 @@ module Samagotchi
|
|
|
198
199
|
Log.warn(:worker, "continue_wake_failed", sid: id, error: e.class.name, msg: e.message)
|
|
199
200
|
end
|
|
200
201
|
|
|
201
|
-
# The new link, holding off any worker but its own (
|
|
202
|
+
# The new link, holding off any worker but its own (StartingMarker.fresh?)
|
|
202
203
|
# while +move+ goes to it.
|
|
203
204
|
private_class_method def self.spawn_continuation(previous, prompt:, preview:, choice:, state_dir:, move:)
|
|
204
205
|
recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
|
|
@@ -206,7 +207,7 @@ module Samagotchi
|
|
|
206
207
|
preview = previous.first_preview if preview.to_s.strip.empty? && start.nil?
|
|
207
208
|
moved_context = nil
|
|
208
209
|
before_spawn = lambda do |link|
|
|
209
|
-
|
|
210
|
+
StartingMarker.mark(link.id, state_dir: state_dir)
|
|
210
211
|
ChildMove.apply(move, from: previous.id, to: link.id, state_dir: state_dir)
|
|
211
212
|
# Its movable context too, with a note saying what moved and stayed.
|
|
212
213
|
moved_context = ContextMove.call(from: previous.id, to: link.id, mode: :continue, state_dir: state_dir)
|
|
@@ -76,7 +76,7 @@ module Samagotchi
|
|
|
76
76
|
@stdout.puts("#{short} refused: #{line}")
|
|
77
77
|
false
|
|
78
78
|
rescue ArgumentError => e
|
|
79
|
-
error_line("chi sessions delete: #{e.
|
|
79
|
+
error_line("chi sessions delete: #{e.message}")
|
|
80
80
|
false
|
|
81
81
|
rescue SystemCallError => e
|
|
82
82
|
@stdout.puts("#{short} failed: #{e.message}")
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
class SessionManager
|
|
5
|
+
# How long a turn waits for the Bridge of a worker a resume just spawned.
|
|
6
|
+
TURN_BRIDGE_WAIT = 5.0
|
|
7
|
+
|
|
8
|
+
# What #deliver_turn did with a message.
|
|
9
|
+
# +status+ :accepted; :refused (the Bridge refused it, or a queue would
|
|
10
|
+
# reach a worker that doesn't queue); :timeout (the Bridge took it and
|
|
11
|
+
# never answered); :failed (the input file couldn't be written).
|
|
12
|
+
# +via+ :bridge (the worker's Bridge answered) or :file (the input-file
|
|
13
|
+
# fallback); nil when nothing went in.
|
|
14
|
+
# +ack+ the Bridge's reply body, or the file's {"status", "enqueued_id",
|
|
15
|
+
# "session_id"}: string keys either way, {} for none.
|
|
16
|
+
# +code+ a refusal's HTTP status.
|
|
17
|
+
DeliverResult = Data.define(:status, :via, :ack, :code) do
|
|
18
|
+
def initialize(status:, via: nil, ack: {}, code: nil) = super(status:, via:, ack: ack || {}, code:)
|
|
19
|
+
|
|
20
|
+
def accepted? = status == :accepted
|
|
21
|
+
|
|
22
|
+
# Sent as an input file (no Bridge): its turn and outcome carry the
|
|
23
|
+
# file's enqueued_id, which a wait follows (chi send --wait).
|
|
24
|
+
def file? = via == :file
|
|
25
|
+
|
|
26
|
+
# @return [String, nil] the id the message's turn will carry
|
|
27
|
+
def enqueued_id
|
|
28
|
+
id = ack["enqueued_id"].to_s
|
|
29
|
+
id.empty? ? nil : id
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Whether the worker took the line as a session command (a POST /turn
|
|
33
|
+
# answered with its command_id).
|
|
34
|
+
def command? = !ack["command_id"].to_s.empty?
|
|
35
|
+
|
|
36
|
+
def error = ack["error"]
|
|
37
|
+
|
|
38
|
+
def detail = ack["detail"]
|
|
39
|
+
|
|
40
|
+
# What the worker applied (a Bridge ack only): the delivery asked for,
|
|
41
|
+
# or next_step for a --queue that found no turn running; nil from the
|
|
42
|
+
# input file or a worker older than delivery.
|
|
43
|
+
def delivery = ack["delivery"]
|
|
44
|
+
|
|
45
|
+
# A --cut's outcome ("now", "waits", "off"), nil when nothing was cut.
|
|
46
|
+
def cut = ack["cut"]
|
|
47
|
+
|
|
48
|
+
# The steer cut-after seconds, with a cut that waits only.
|
|
49
|
+
def cut_after = ack["cut_after"]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The unclaimed input files +author+ wrote.
|
|
53
|
+
PARTICIPANT_QUEUE_LOCK = "participants.lock"
|
|
54
|
+
|
|
55
|
+
# Delivery: a user message into a session, the way a UI sends one (its
|
|
56
|
+
# Bridge, else the input file). Calls SpawnPart's resume_session and the
|
|
57
|
+
# facade's session_owner through +manager+ (this class by default).
|
|
58
|
+
# Meant to be loaded through samagotchi/session_manager.
|
|
59
|
+
module DeliveryPart
|
|
60
|
+
# Write a user turn into a session's input directory via the same file IPC
|
|
61
|
+
# the worker polls. Reused by the bridge's POST surface so a turn is
|
|
62
|
+
# fire-and-forget and never calls run_turn across the thread/process
|
|
63
|
+
# boundary. The file is JSON carrying the sender's ids.
|
|
64
|
+
# @param client_id [String, nil] the sending UI
|
|
65
|
+
# @param enqueued_id [String, nil] the id its ACK / :turn_enqueued carry
|
|
66
|
+
# @param delivery [String, nil] the wire value: "next_step" (default),
|
|
67
|
+
# "cut" or "queue"
|
|
68
|
+
# @param images [Array<Hash>] image refs ({file:, name:}) in the
|
|
69
|
+
# session's images/
|
|
70
|
+
# @return [String, false] the input file's path, or false.
|
|
71
|
+
def write_turn_input(session_id, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, state_dir: nil,
|
|
72
|
+
images: [], delivery: nil, author: nil, anchors: nil)
|
|
73
|
+
session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
74
|
+
SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
|
|
75
|
+
no_interrupt: no_interrupt, images: images, delivery: delivery, author: author,
|
|
76
|
+
anchors: anchors)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Hand a user message to the session the way a UI does, for the web
|
|
80
|
+
# composer and `chi send` alike: wakes the worker when none runs, posts
|
|
81
|
+
# through its Bridge so every live UI sees :turn_enqueued, and falls back
|
|
82
|
+
# to the input file when the Bridge is gone (a worker closing it on idle
|
|
83
|
+
# exit). Race-safe against a TUI taking the session, or the worker
|
|
84
|
+
# exiting, between the resume and the write.
|
|
85
|
+
# @param images [Array<Hash>] refs ({file:, name:}) already in images/
|
|
86
|
+
# @param delivery [String, nil] what the message asks the running turn
|
|
87
|
+
# for: "next_step" (default), "cut" or "queue"
|
|
88
|
+
# @param no_interrupt [Boolean] the turn runs with the raised iteration
|
|
89
|
+
# limit (an attached TUI's --no-interrupt), through the Bridge or the
|
|
90
|
+
# file; sent only when true, so older callees see no new field
|
|
91
|
+
# @param author [String, nil] who wrote it when it isn't the session's
|
|
92
|
+
# owner (Web::Principal#author: chi web stamps it, never the body);
|
|
93
|
+
# to the Bridge or the file only when given, so an owner's turn is
|
|
94
|
+
# sent and saved as before
|
|
95
|
+
# @param pending_limit [Integer, nil] with +author+: how many of their
|
|
96
|
+
# turns may be running or waiting (the Bridge counts them; with no
|
|
97
|
+
# worker, their waiting files): :refused 429 too_many_turns past it
|
|
98
|
+
# @param anchors [TurnAnchors, Hash, nil] a Verify turn's claim, a Revise
|
|
99
|
+
# turn's draft: each refused (409, its UNAVAILABLE) by a worker without
|
|
100
|
+
# its feature
|
|
101
|
+
# @param manager [#resume_session, #write_turn_input] this class, or a
|
|
102
|
+
# stand-in (the web's specs); #session_owner is optional
|
|
103
|
+
# @param bridge [#call, nil] returns the BridgeClient or nil; defaults to
|
|
104
|
+
# the session's sidecar, waiting TURN_BRIDGE_WAIT for a new worker's
|
|
105
|
+
# @return [DeliverResult] :accepted (via the Bridge or a file),
|
|
106
|
+
# :refused when the Bridge refused the request (any 4xx with an
|
|
107
|
+
# error: bad images, a delivery it doesn't run) or a queue would reach
|
|
108
|
+
# a worker that doesn't queue (no file then: it would steer), :timeout
|
|
109
|
+
# when it took the request but never answered (no file then), or
|
|
110
|
+
# :failed when the input file couldn't be written
|
|
111
|
+
# @raise [OwnedByTUI] a one-turn chi run owns the session
|
|
112
|
+
# @raise [ArgumentError] no such session
|
|
113
|
+
def deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, no_interrupt: false,
|
|
114
|
+
author: nil, pending_limit: nil, anchors: nil, state_dir: nil, manager: self, bridge: nil)
|
|
115
|
+
anchors = TurnAnchors.from(anchors)
|
|
116
|
+
session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
117
|
+
bridge ||= lambda do
|
|
118
|
+
link = WorkerLink.await(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT)
|
|
119
|
+
link.live? ? link.client(session_id) : nil
|
|
120
|
+
end
|
|
121
|
+
manager.resume_session(session_id, state_dir: state_dir)
|
|
122
|
+
if (client = bridge.call)
|
|
123
|
+
if (missing = anchors.present.find { |anchor| !worker_has?(client, anchor.class::FEATURE) })
|
|
124
|
+
return DeliverResult.new(status: :refused, code: 409, ack: missing.class::UNAVAILABLE)
|
|
125
|
+
end
|
|
126
|
+
# A worker from before queueing (no "delivery" feature) ignores the
|
|
127
|
+
# field or refuses it; either way a file would steer the running turn.
|
|
128
|
+
return queue_unavailable if Delivery.queue?(delivery) && !worker_has?(client, Delivery::QUEUE_FEATURE)
|
|
129
|
+
# A participant's turn only to a worker with the review boundary.
|
|
130
|
+
return review_unavailable if Steer.participant_author?(author) && !worker_has?(client, Delivery::REVIEW_FEATURE)
|
|
131
|
+
|
|
132
|
+
begin
|
|
133
|
+
options = { prompt: prompt, client_id: client_id }
|
|
134
|
+
options[:images] = images unless images.empty?
|
|
135
|
+
# An old worker ignores the field: it cuts anyway (its own default).
|
|
136
|
+
options[:delivery] = delivery unless Delivery.next_step?(delivery)
|
|
137
|
+
options[:no_interrupt] = true if no_interrupt
|
|
138
|
+
options[:author] = author if author
|
|
139
|
+
options[:pending_limit] = pending_limit if author && pending_limit
|
|
140
|
+
options[:anchors] = anchors unless anchors.empty?
|
|
141
|
+
reply = client.post_turn(**options)
|
|
142
|
+
ack = reply.json
|
|
143
|
+
return DeliverResult.new(status: :accepted, via: :bridge, ack: ack) if reply.status == 202 && ack.is_a?(Hash)
|
|
144
|
+
# Read after its deadline and dropped: a file would run it after all.
|
|
145
|
+
return turn_timeout if ack.is_a?(Hash) && ack["error"] == "deadline_passed"
|
|
146
|
+
# Any other 4xx with an error is a refusal (bad images, a delivery
|
|
147
|
+
# it doesn't run, a client bug): a file would bypass it, and a
|
|
148
|
+
# refused queue would steer. A 413 (a body over the Bridge's
|
|
149
|
+
# limit, a big piped chi send) and a 5xx keep the file fallback
|
|
150
|
+
# below, which carries the delivery (a queue too).
|
|
151
|
+
if (400..499).cover?(reply.status) && reply.status != 413 && ack.is_a?(Hash) && ack["error"]
|
|
152
|
+
return DeliverResult.new(status: :refused, via: :bridge, code: reply.status, ack: ack)
|
|
153
|
+
end
|
|
154
|
+
rescue Errno::ETIMEDOUT
|
|
155
|
+
# The Bridge drops a turn it reads after the request's deadline
|
|
156
|
+
# (BridgeClient#post_turn), so a worker that wakes later won't run
|
|
157
|
+
# it; a file would.
|
|
158
|
+
return turn_timeout
|
|
159
|
+
rescue SystemCallError, IOError
|
|
160
|
+
nil # the worker closed its Bridge on the way out: queue the file
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
# A participant's file only for a worker started after it: a worker
|
|
164
|
+
# still holding the session with its Bridge gone (exiting) may be an
|
|
165
|
+
# older one, which would run it without the review boundary.
|
|
166
|
+
# (A one-turn chi run holding it is refused as for anyone, below.)
|
|
167
|
+
if Steer.participant_author?(author) && (holder = delivery_owner(manager, session_id, state_dir)) && !holder.tui?
|
|
168
|
+
return DeliverResult.new(status: :timeout, via: :file,
|
|
169
|
+
ack: { "error" => "worker_timeout",
|
|
170
|
+
"detail" => "the session's worker is exiting, so the message was not sent; send it again" })
|
|
171
|
+
end
|
|
172
|
+
enqueued_id = SecureRandom.uuid
|
|
173
|
+
input = { prompt: prompt, client_id: client_id, enqueued_id: enqueued_id, state_dir: state_dir }
|
|
174
|
+
input[:images] = images unless images.empty?
|
|
175
|
+
input[:delivery] = delivery unless Delivery.next_step?(delivery)
|
|
176
|
+
input[:no_interrupt] = true if no_interrupt
|
|
177
|
+
input[:author] = author if author
|
|
178
|
+
input[:anchors] = anchors unless anchors.empty?
|
|
179
|
+
path = participant_queue_lock(session_dir, author && pending_limit) do
|
|
180
|
+
# No worker: the participant's waiting files are all their turns,
|
|
181
|
+
# counted and written under one lock (the Bridge counts under its).
|
|
182
|
+
next :too_many if author && pending_limit && pending_files(session_dir, author) >= pending_limit
|
|
183
|
+
|
|
184
|
+
manager.write_turn_input(session_id, **input)
|
|
185
|
+
end
|
|
186
|
+
if path == :too_many
|
|
187
|
+
return DeliverResult.new(status: :refused, code: 429,
|
|
188
|
+
ack: { "error" => "too_many_turns", "detail" => ParticipantMessages::TOO_MANY_TURNS })
|
|
189
|
+
end
|
|
190
|
+
return DeliverResult.new(status: :failed) unless path
|
|
191
|
+
|
|
192
|
+
owner = delivery_owner(manager, session_id, state_dir)
|
|
193
|
+
# A TUI that took the session between the resume and the write never
|
|
194
|
+
# reads input files; a later worker would replay this one.
|
|
195
|
+
if owner&.tui?
|
|
196
|
+
FileUtils.rm_f(path) if path.is_a?(String)
|
|
197
|
+
raise OwnedByTUI, session_id
|
|
198
|
+
end
|
|
199
|
+
# A worker that idle-exited since the resume never reads it either:
|
|
200
|
+
# wake a new one. (The exiting worker also looks for input it left;
|
|
201
|
+
# one the first resume spawned that is still starting is left be.)
|
|
202
|
+
manager.resume_session(session_id, state_dir: state_dir) if owner.nil?
|
|
203
|
+
DeliverResult.new(status: :accepted, via: :file,
|
|
204
|
+
ack: { "status" => "accepted", "enqueued_id" => enqueued_id, "session_id" => session_id })
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Whether the worker +client+ talks to names +feature+: the features of
|
|
208
|
+
# the sidecar the client was made from (the Live link WorkerLink.await
|
|
209
|
+
# found), not a second read of bridge.json.
|
|
210
|
+
private def worker_has?(client, feature) = client.features.include?(feature)
|
|
211
|
+
|
|
212
|
+
# The block under the session's participant queue lock (a file lock:
|
|
213
|
+
# chi web's threads, and another chi web), when +locked+; else as is.
|
|
214
|
+
private def participant_queue_lock(session_dir, locked)
|
|
215
|
+
return yield unless locked
|
|
216
|
+
|
|
217
|
+
FileUtils.mkdir_p(session_dir)
|
|
218
|
+
File.open(File.join(session_dir, PARTICIPANT_QUEUE_LOCK), File::RDWR | File::CREAT, 0o600) do |lock|
|
|
219
|
+
lock.flock(File::LOCK_EX)
|
|
220
|
+
yield
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
private def pending_files(session_dir, author)
|
|
225
|
+
SessionInbox.find_new_input_files(session_dir).count do |file|
|
|
226
|
+
data = JSON.parse(File.read(file))
|
|
227
|
+
data.is_a?(Hash) && data["author"] == author
|
|
228
|
+
rescue JSON::ParserError, SystemCallError
|
|
229
|
+
false
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
private def queue_unavailable
|
|
234
|
+
DeliverResult.new(status: :refused, code: 409, ack: Delivery::QUEUE_UNAVAILABLE)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
private def review_unavailable
|
|
238
|
+
DeliverResult.new(status: :refused, code: 409, ack: Delivery::REVIEW_UNAVAILABLE)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
private def turn_timeout
|
|
242
|
+
DeliverResult.new(status: :timeout, via: :bridge,
|
|
243
|
+
ack: { "error" => "worker_timeout",
|
|
244
|
+
"detail" => "the session's worker did not answer, so the message was not sent" })
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
private def delivery_owner(manager, session_id, state_dir)
|
|
248
|
+
Degrade.call(:worker, "owner_unreadable", sid: session_id) { manager.session_owner(session_id, state_dir: state_dir) }
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
extend DeliveryPart
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../analytics_file"
|
|
4
|
+
require_relative "../session_inbox"
|
|
5
|
+
require_relative "../worker_link"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
class SessionManager
|
|
9
|
+
# What a session's directory holds before anything happened in it. Any
|
|
10
|
+
# other entry (or a file in one of the EMPTY_DIRS) is something the
|
|
11
|
+
# session keeps. "pid" is the pid file workers wrote until 2026-10-01:
|
|
12
|
+
# nothing writes or reads it now, but a folder from before still has one
|
|
13
|
+
# and is still empty.
|
|
14
|
+
EMPTY_SKELETON_FILES = ["pid", "owner.lock", WorkerLink::FILE, AnalyticsFile::NAME].freeze
|
|
15
|
+
EMPTY_DIRS = [SessionInbox::INPUT_DIR, SessionInbox::NOTES_DIR, "images"].freeze
|
|
16
|
+
EMPTY_SKELETON_DIRS = (EMPTY_DIRS + [SessionInbox::OUTPUT_DIR]).freeze
|
|
17
|
+
|
|
18
|
+
# Emptiness: whether nothing happened in a session (pure predicates over
|
|
19
|
+
# its file and directory) and whether one being left is deleted, asked by
|
|
20
|
+
# WorkerCommandPart and the retention sweep. Calls no other part. Meant
|
|
21
|
+
# to be loaded through samagotchi/session_manager.
|
|
22
|
+
module EmptinessPart
|
|
23
|
+
# session.keep_empty off: sessions left with nothing in them are deleted
|
|
24
|
+
# (the worker as it exits, the retention sweep).
|
|
25
|
+
def discard_empty?
|
|
26
|
+
Degrade.call(:config, "keep_empty_unreadable", fallback: false) { Samagotchi::Config.get("session.keep_empty") != true }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Whether a session being left (or found left) is deleted: session.keep_empty
|
|
30
|
+
# is off and nothing happened in it (#empty_session?). The worker as it
|
|
31
|
+
# leaves and the retention sweep both ask this.
|
|
32
|
+
# @param default_model [String, nil] what a new session starts on
|
|
33
|
+
# @param used_memory_names [Array<String>] memory its owner's Engine used
|
|
34
|
+
# before any save
|
|
35
|
+
def discardable?(session_id, default_model:, state_dir: nil, used_memory_names: [])
|
|
36
|
+
return false unless discard_empty? && Array(used_memory_names).empty?
|
|
37
|
+
|
|
38
|
+
empty_session?(session_id, state_dir: state_dir || Session.default_state_dir, default_model: default_model)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A session nothing happened in: no conversation, no turn tried (a failed
|
|
42
|
+
# one leaves no messages but a last_prompt and analytics turns), nothing
|
|
43
|
+
# queued or attached, and the model and mode a new session gets. A
|
|
44
|
+
# session prepared for later (/model, --model, a note, an image, memory)
|
|
45
|
+
# is not empty. Anything unreadable or unknown counts as not empty.
|
|
46
|
+
# @param default_model [String, nil] what a new session starts on
|
|
47
|
+
def empty_session?(session_id, state_dir: nil, default_model: nil)
|
|
48
|
+
sd = state_dir || Session.default_state_dir
|
|
49
|
+
session = Session.load(session_id, state_dir: sd)
|
|
50
|
+
return false unless no_conversation?(session.messages) && session.pending_question.nil? && session.used_memory_names.empty?
|
|
51
|
+
return false unless session.last_prompt.to_s.strip.empty? && session.first_preview.to_s.strip.empty?
|
|
52
|
+
return false unless session.mode.to_s == "assist" && !default_model.nil? && same_model?(session.model_name, default_model)
|
|
53
|
+
|
|
54
|
+
empty_session_dir?(Session.session_dir(session_id, state_dir: sd))
|
|
55
|
+
rescue ArgumentError, SystemCallError, JSON::ParserError
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Whether two model names resolve to the same ref (ModelRef#ref): a
|
|
60
|
+
# session stores the resolved ref, the default may be an alias.
|
|
61
|
+
def same_model?(one, other)
|
|
62
|
+
return true if one.to_s == other.to_s
|
|
63
|
+
return false if one.nil? || other.nil?
|
|
64
|
+
|
|
65
|
+
Degrade.call(:config, "model_ref_unresolved", fallback: false) do
|
|
66
|
+
HostConfig.model_ref(one).ref == HostConfig.model_ref(other).ref
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Only the system prompt (an Engine seeds one, a saved session keeps
|
|
71
|
+
# it); a context note is a system message too, but with its kind.
|
|
72
|
+
# A turn note (the tail system message after a failed, cancelled or empty
|
|
73
|
+
# turn) is not a conversation either: a session whose only turn failed
|
|
74
|
+
# before any answer is still empty.
|
|
75
|
+
def no_conversation?(messages)
|
|
76
|
+
Array(messages).all? do |msg|
|
|
77
|
+
(msg[:role] || msg["role"]).to_s == "system" &&
|
|
78
|
+
((msg[:kind] || msg["kind"]).nil? || TurnNote.note?(msg))
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [Boolean] whether a session's directory holds only the skeleton
|
|
83
|
+
def empty_session_dir?(dir)
|
|
84
|
+
return true unless Dir.exist?(dir)
|
|
85
|
+
|
|
86
|
+
Dir.children(dir).all? do |name|
|
|
87
|
+
path = File.join(dir, name)
|
|
88
|
+
if EMPTY_SKELETON_DIRS.include?(name)
|
|
89
|
+
File.directory?(path) && (!EMPTY_DIRS.include?(name) || Dir.empty?(path))
|
|
90
|
+
elsif name == AnalyticsFile::NAME
|
|
91
|
+
analytics_empty?(path)
|
|
92
|
+
# Cards and notices from before any turn (Bridge::CardStore).
|
|
93
|
+
elsif name == "cards.json"
|
|
94
|
+
cards_empty?(path)
|
|
95
|
+
else
|
|
96
|
+
EMPTY_SKELETON_FILES.include?(name)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A folder's analytics.json is empty only when it is a JSON object with
|
|
102
|
+
# an empty turn_records array. Anything unreadable, not JSON, not an
|
|
103
|
+
# object, the wrong type, or turn_records the wrong shape reads as
|
|
104
|
+
# "not empty": a broken analytics keeps the folder instead of crashing
|
|
105
|
+
# the retention sweep (or of a wrong-shape one reading as empty).
|
|
106
|
+
def analytics_empty?(path)
|
|
107
|
+
data = read_json(path)
|
|
108
|
+
turns = data.is_a?(Hash) ? data["turn_records"] : nil
|
|
109
|
+
turns.is_a?(Array) && turns.empty?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# A folder's cards.json is empty only when it is a JSON object with no
|
|
113
|
+
# turn in it: an empty turn_log, or an older file's (Bridge::CardStore
|
|
114
|
+
# version 1) turns an integer zero. Anything unreadable, not JSON, not an
|
|
115
|
+
# object, the wrong type, or neither of those reads as "not empty": a
|
|
116
|
+
# broken or wrong-shape cards file keeps the folder instead of raising
|
|
117
|
+
# (Array#/Hash#to_i) or reading as empty.
|
|
118
|
+
def cards_empty?(path)
|
|
119
|
+
data = read_json(path)
|
|
120
|
+
return false unless data.is_a?(Hash)
|
|
121
|
+
|
|
122
|
+
turns = data["turns"]
|
|
123
|
+
data["turn_log"] == [] || (turns.is_a?(Integer) && turns.zero?)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# A session-dir JSON file's contents, or nil when it is missing,
|
|
127
|
+
# unreadable, not JSON or not an object (mirrors AnalyticsFile#read): a
|
|
128
|
+
# broken file reads as none, so its folder is kept, not deleted.
|
|
129
|
+
def read_json(path)
|
|
130
|
+
JSON.parse(File.read(path))
|
|
131
|
+
rescue JSON::ParserError, SystemCallError
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
extend EmptinessPart
|
|
137
|
+
end
|
|
138
|
+
end
|