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.
Files changed (124) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -1
  3. data/bin/chi +1 -1
  4. data/docs/architecture.md +10 -6
  5. data/docs/internals/anthropic.md +1 -1
  6. data/docs/internals/attached-context.md +3 -2
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/engine.md +625 -935
  9. data/docs/internals/llm-context-forget.md +8 -3
  10. data/docs/internals/objectives.md +2 -2
  11. data/docs/internals/prompt-caching.md +4 -1
  12. data/docs/internals/remote-sessions.md +3 -1
  13. data/docs/internals/working-set.md +1 -1
  14. data/docs/sessions.md +2 -2
  15. data/docs/system-one.md +3 -2
  16. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  17. data/lib/samagotchi/broadcast_command.rb +3 -3
  18. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  19. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  20. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  21. data/lib/samagotchi/child_move.rb +7 -51
  22. data/lib/samagotchi/children_status.rb +1 -1
  23. data/lib/samagotchi/cli/parent_wait.rb +1 -1
  24. data/lib/samagotchi/config.rb +17 -112
  25. data/lib/samagotchi/context_command.rb +1 -2
  26. data/lib/samagotchi/context_window.rb +3 -3
  27. data/lib/samagotchi/desktop/macos.rb +1 -1
  28. data/lib/samagotchi/engine/context_judge.rb +19 -11
  29. data/lib/samagotchi/engine/messages.rb +1 -1
  30. data/lib/samagotchi/engine/models.rb +5 -2
  31. data/lib/samagotchi/engine/objectives.rb +29 -11
  32. data/lib/samagotchi/engine/side_requests.rb +21 -11
  33. data/lib/samagotchi/engine/toolkit.rb +10 -9
  34. data/lib/samagotchi/engine/turn_end.rb +1 -1
  35. data/lib/samagotchi/engine/turn_helpers.rb +7 -7
  36. data/lib/samagotchi/engine/verify.rb +1 -1
  37. data/lib/samagotchi/engine.rb +14 -9
  38. data/lib/samagotchi/guardrails/targets.rb +24 -9
  39. data/lib/samagotchi/host_config.rb +72 -1
  40. data/lib/samagotchi/host_model.rb +2 -6
  41. data/lib/samagotchi/host_registry.rb +1 -1
  42. data/lib/samagotchi/kernel_loop.rb +46 -121
  43. data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
  44. data/lib/samagotchi/llm/chat_loop.rb +34 -18
  45. data/lib/samagotchi/llm/native_backend.rb +1 -1
  46. data/lib/samagotchi/llm_context_apply.rb +54 -1
  47. data/lib/samagotchi/llm_context_notice.rb +1 -1
  48. data/lib/samagotchi/llm_context_strategy.rb +7 -9
  49. data/lib/samagotchi/llm_context_view.rb +9 -0
  50. data/lib/samagotchi/model_price.rb +4 -10
  51. data/lib/samagotchi/model_profile.rb +6 -6
  52. data/lib/samagotchi/model_settings.rb +37 -2
  53. data/lib/samagotchi/note_command.rb +1 -2
  54. data/lib/samagotchi/objectives_audit.rb +4 -3
  55. data/lib/samagotchi/participants.rb +2 -2
  56. data/lib/samagotchi/plugin/context.rb +1 -1
  57. data/lib/samagotchi/plugin/sessions.rb +2 -5
  58. data/lib/samagotchi/plugin/tool_spec.rb +5 -3
  59. data/lib/samagotchi/project_env.rb +1 -1
  60. data/lib/samagotchi/reasoning_history.rb +3 -3
  61. data/lib/samagotchi/reminder_carry.rb +1 -1
  62. data/lib/samagotchi/reprefill_meter.rb +45 -0
  63. data/lib/samagotchi/review_command.rb +1 -1
  64. data/lib/samagotchi/sampling_settings.rb +3 -3
  65. data/lib/samagotchi/self_report.rb +7 -7
  66. data/lib/samagotchi/session.rb +50 -7
  67. data/lib/samagotchi/session_access/local.rb +7 -5
  68. data/lib/samagotchi/session_access/remote.rb +17 -3
  69. data/lib/samagotchi/session_archive_command.rb +2 -2
  70. data/lib/samagotchi/session_chain.rb +7 -6
  71. data/lib/samagotchi/session_continue.rb +6 -5
  72. data/lib/samagotchi/session_delete_command.rb +1 -1
  73. data/lib/samagotchi/session_manager/delivery.rb +254 -0
  74. data/lib/samagotchi/session_manager/emptiness.rb +138 -0
  75. data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
  76. data/lib/samagotchi/session_manager/listing.rb +189 -0
  77. data/lib/samagotchi/session_manager/removal.rb +190 -0
  78. data/lib/samagotchi/session_manager/spawn.rb +242 -0
  79. data/lib/samagotchi/session_manager/worker_command.rb +157 -0
  80. data/lib/samagotchi/session_manager.rb +26 -1540
  81. data/lib/samagotchi/session_metrics.rb +1 -1
  82. data/lib/samagotchi/sessions_command.rb +1 -1
  83. data/lib/samagotchi/share_command.rb +1 -1
  84. data/lib/samagotchi/starting_marker.rb +65 -0
  85. data/lib/samagotchi/steer_cut_after.rb +3 -3
  86. data/lib/samagotchi/system_one/recorder.rb +49 -0
  87. data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
  88. data/lib/samagotchi/terminal_ui.rb +2 -6
  89. data/lib/samagotchi/testing/sessions.rb +1 -1
  90. data/lib/samagotchi/thinking.rb +3 -3
  91. data/lib/samagotchi/token_usage.rb +1 -1
  92. data/lib/samagotchi/tool_activity.rb +10 -4
  93. data/lib/samagotchi/tool_declarations.rb +2 -2
  94. data/lib/samagotchi/tool_output_cut.rb +17 -0
  95. data/lib/samagotchi/tool_runner.rb +7 -4
  96. data/lib/samagotchi/toolkit_command.rb +1 -1
  97. data/lib/samagotchi/tools/delegate.rb +10 -5
  98. data/lib/samagotchi/tools/delegate_result.rb +4 -4
  99. data/lib/samagotchi/tools/index_hint.rb +14 -0
  100. data/lib/samagotchi/tools/registry.rb +28 -11
  101. data/lib/samagotchi/tools/send_note.rb +4 -6
  102. data/lib/samagotchi/tools/tool_index.rb +52 -51
  103. data/lib/samagotchi/tools/tool_surface.rb +22 -8
  104. data/lib/samagotchi/turn_context.rb +2 -1
  105. data/lib/samagotchi/turn_stages/context_judge.rb +38 -36
  106. data/lib/samagotchi/turn_stages/objectives.rb +56 -52
  107. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +13 -17
  108. data/lib/samagotchi/turn_stages/verify_anchors.rb +9 -10
  109. data/lib/samagotchi/turn_stages/warmup.rb +14 -15
  110. data/lib/samagotchi/version.rb +1 -1
  111. data/lib/samagotchi/vision_support.rb +3 -3
  112. data/lib/samagotchi/web/app.rb +15 -8
  113. data/lib/samagotchi/web/participant_access.rb +2 -2
  114. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  115. data/lib/samagotchi/web/session_control.rb +3 -3
  116. data/lib/samagotchi/web/session_create.rb +3 -3
  117. data/lib/samagotchi/web/session_hub.rb +1 -1
  118. data/lib/samagotchi/web/session_show.rb +3 -3
  119. data/lib/samagotchi/web/session_summary.rb +3 -4
  120. data/lib/samagotchi/worker.rb +55 -27
  121. data/lib/samagotchi/worker_boot.rb +184 -0
  122. data/lib/samagotchi/worker_signals.rb +1 -1
  123. data/lib/samagotchi/worker_task_wakes.rb +2 -2
  124. 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 (chi send --new --continues, web
21
- # Continue →): SessionManager.continue_session runs .run. The archive,
22
- # spawn, stop and wake it builds on are SessionManager's.
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 (ChildMove.starting?)
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
- ChildMove.mark_starting(link.id, state_dir: state_dir)
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.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"}")
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