samagotchi 0.155.0 → 0.157.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 +57 -1
- data/bin/chi +2 -1
- data/docs/architecture.md +22 -13
- data/docs/bundles.md +3 -1
- data/docs/cli.md +9 -8
- data/docs/configuration.md +36 -4
- data/docs/guardrails.md +9 -5
- data/docs/hooks.md +11 -5
- 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/bridge.md +83 -6
- data/docs/internals/bundles.md +28 -18
- data/docs/internals/context-telemetry.md +4 -3
- data/docs/internals/engine.md +641 -937
- data/docs/internals/llm-context-forget.md +8 -3
- data/docs/internals/objectives.md +2 -2
- data/docs/internals/participants.md +3 -2
- data/docs/internals/prompt-caching.md +4 -1
- data/docs/internals/remote-sessions.md +8 -3
- data/docs/internals/web-frontend.md +3 -2
- data/docs/internals/working-set.md +2 -2
- data/docs/memory.md +13 -2
- data/docs/plugins.md +35 -2
- data/docs/sessions.md +11 -11
- data/docs/sub-agent.md +17 -2
- data/docs/system-one.md +3 -2
- data/docs/testing.md +53 -2
- data/lib/samagotchi/answer_command.rb +2 -1
- data/lib/samagotchi/archive_store.rb +4 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
- data/lib/samagotchi/bootstrap_command.rb +3 -2
- data/lib/samagotchi/bridge/card_places.rb +10 -4
- data/lib/samagotchi/bridge/card_store.rb +3 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +109 -9
- data/lib/samagotchi/bridge/turn_drawings.rb +265 -0
- data/lib/samagotchi/bridge.rb +31 -6
- data/lib/samagotchi/broadcast/recipients.rb +8 -8
- data/lib/samagotchi/broadcast/triage_model.rb +1 -1
- data/lib/samagotchi/broadcast_command.rb +3 -3
- data/lib/samagotchi/bundle_command/check.rb +18 -0
- data/lib/samagotchi/bundle_command/try/sandbox.rb +2 -2
- data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +5 -2
- data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
- data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
- data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/child_move.rb +13 -57
- data/lib/samagotchi/child_reports.rb +2 -2
- data/lib/samagotchi/children_status.rb +15 -21
- data/lib/samagotchi/cli/parent_wait.rb +9 -23
- data/lib/samagotchi/config.rb +20 -112
- data/lib/samagotchi/context_command.rb +1 -2
- data/lib/samagotchi/context_sources.rb +5 -4
- data/lib/samagotchi/context_window.rb +9 -12
- data/lib/samagotchi/desktop/macos.rb +1 -1
- data/lib/samagotchi/effective_model.rb +57 -22
- 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/plugins.rb +1 -1
- data/lib/samagotchi/engine/recap.rb +8 -4
- 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 +43 -12
- data/lib/samagotchi/events.rb +3 -2
- data/lib/samagotchi/extension_load.rb +6 -3
- data/lib/samagotchi/guardrails/targets.rb +24 -9
- data/lib/samagotchi/guardrails_command.rb +1 -1
- data/lib/samagotchi/host_address.rb +52 -0
- data/lib/samagotchi/host_config.rb +72 -1
- data/lib/samagotchi/host_model.rb +2 -6
- data/lib/samagotchi/host_registry.rb +58 -46
- data/lib/samagotchi/kernel_loop.rb +53 -124
- data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
- data/lib/samagotchi/llm/chat_loop.rb +40 -21
- 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 +29 -67
- data/lib/samagotchi/llm_context_view.rb +9 -0
- data/lib/samagotchi/memory_bundle/asset_installer.rb +71 -76
- data/lib/samagotchi/memory_bundle/builder.rb +2 -1
- data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
- data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
- data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
- data/lib/samagotchi/memory_bundle/installed_copy.rb +2 -1
- data/lib/samagotchi/memory_bundle/installed_manifest.rb +8 -1
- data/lib/samagotchi/memory_bundle/installer.rb +185 -169
- data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
- data/lib/samagotchi/memory_bundle/merger.rb +69 -65
- data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
- data/lib/samagotchi/memory_bundle/profile.rb +5 -5
- data/lib/samagotchi/memory_bundle/provenance.rb +15 -1
- data/lib/samagotchi/memory_bundle/remerge.rb +25 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -6
- data/lib/samagotchi/memory_bundle/status.rb +14 -20
- data/lib/samagotchi/memory_bundle/system_bundle.rb +24 -6
- data/lib/samagotchi/memory_bundle/trash.rb +23 -8
- data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
- data/lib/samagotchi/memory_usage.rb +1 -1
- data/lib/samagotchi/model_lookup.rb +75 -0
- data/lib/samagotchi/model_price.rb +4 -10
- data/lib/samagotchi/model_profile.rb +7 -6
- data/lib/samagotchi/model_settings.rb +37 -2
- data/lib/samagotchi/models_command.rb +1 -1
- data/lib/samagotchi/note_command.rb +2 -3
- data/lib/samagotchi/objectives_audit.rb +4 -3
- data/lib/samagotchi/participant_mode.rb +2 -1
- data/lib/samagotchi/participants.rb +2 -2
- data/lib/samagotchi/plugin/api.rb +41 -6
- data/lib/samagotchi/plugin/context.rb +1 -1
- data/lib/samagotchi/plugin/loader.rb +33 -10
- data/lib/samagotchi/plugin/sessions.rb +3 -6
- data/lib/samagotchi/plugin/tool_spec.rb +5 -3
- data/lib/samagotchi/project_env.rb +1 -1
- data/lib/samagotchi/reasoning_history.rb +14 -20
- data/lib/samagotchi/recap_place.rb +27 -0
- data/lib/samagotchi/reminder_carry.rb +1 -1
- data/lib/samagotchi/reply_wait/liveness.rb +38 -0
- data/lib/samagotchi/reply_wait/poll.rb +44 -15
- data/lib/samagotchi/reply_wait/target.rb +113 -0
- data/lib/samagotchi/reply_wait.rb +22 -2
- data/lib/samagotchi/reprefill_meter.rb +45 -0
- data/lib/samagotchi/review_command.rb +1 -1
- data/lib/samagotchi/sampling_settings.rb +9 -16
- data/lib/samagotchi/self_report.rb +36 -37
- data/lib/samagotchi/send_command.rb +17 -23
- data/lib/samagotchi/session.rb +370 -445
- data/lib/samagotchi/session_access/local.rb +12 -11
- data/lib/samagotchi/session_access/remote.rb +22 -6
- 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_field.rb +133 -0
- data/lib/samagotchi/session_inbox.rb +49 -1
- data/lib/samagotchi/session_manager/delivery.rb +254 -0
- data/lib/samagotchi/session_manager/emptiness.rb +139 -0
- data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
- data/lib/samagotchi/session_manager/listing.rb +118 -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 +28 -1541
- data/lib/samagotchi/session_metrics.rb +8 -5
- data/lib/samagotchi/session_paths.rb +57 -0
- data/lib/samagotchi/session_retention.rb +2 -2
- data/lib/samagotchi/session_row.rb +200 -0
- data/lib/samagotchi/session_setup.rb +15 -12
- data/lib/samagotchi/sessions_command.rb +11 -129
- data/lib/samagotchi/share_command.rb +1 -1
- data/lib/samagotchi/starting_marker.rb +65 -0
- data/lib/samagotchi/steer_cut_after.rb +7 -14
- data/lib/samagotchi/system_one/recorder.rb +49 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
- data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
- data/lib/samagotchi/terminal_ui/join_replay.rb +86 -8
- data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
- data/lib/samagotchi/terminal_ui.rb +5 -7
- data/lib/samagotchi/testing/install.rb +2 -1
- data/lib/samagotchi/testing/sessions.rb +1 -1
- data/lib/samagotchi/thinking.rb +16 -21
- 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_response.rb +5 -3
- data/lib/samagotchi/tool_runner.rb +25 -5
- data/lib/samagotchi/toolkit_command.rb +1 -1
- data/lib/samagotchi/tools/delegate.rb +55 -45
- data/lib/samagotchi/tools/delegate_relay.rb +5 -5
- data/lib/samagotchi/tools/delegate_result.rb +5 -5
- data/lib/samagotchi/tools/delegate_wait.rb +41 -17
- data/lib/samagotchi/tools/index_hint.rb +14 -0
- data/lib/samagotchi/tools/list_sessions.rb +7 -7
- 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/update_command.rb +11 -7
- data/lib/samagotchi/verify_fresh.rb +7 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +11 -14
- data/lib/samagotchi/web/app.rb +45 -28
- data/lib/samagotchi/web/message_display.rb +57 -7
- data/lib/samagotchi/web/participant_access.rb +3 -3
- data/lib/samagotchi/web/public/activity.js +16 -7
- data/lib/samagotchi/web/public/app.js +29 -8
- data/lib/samagotchi/web/public/bubbles.js +8 -3
- data/lib/samagotchi/web/public/cards_place.js +15 -0
- data/lib/samagotchi/web/public/commands.js +5 -1
- data/lib/samagotchi/web/public/data.js +4 -2
- data/lib/samagotchi/web/public/history_shape.js +220 -0
- data/lib/samagotchi/web/public/recap_stamp.js +17 -0
- data/lib/samagotchi/web/public/reload_check.js +71 -0
- data/lib/samagotchi/web/public/stage_model.js +7 -5
- data/lib/samagotchi/web/public/stage_view.js +7 -3
- data/lib/samagotchi/web/public/stream_handlers.js +8 -2
- data/lib/samagotchi/web/public/timing.js +43 -2
- data/lib/samagotchi/web/public/turn_block.js +215 -30
- data/lib/samagotchi/web/public/turn_drawing.js +43 -0
- data/lib/samagotchi/web/public/turn_events.js +10 -1
- data/lib/samagotchi/web/public/turn_pieces.js +29 -0
- data/lib/samagotchi/web/reload_check_route.rb +95 -0
- data/lib/samagotchi/web/server.rb +4 -2
- data/lib/samagotchi/web/session_access_routes.rb +6 -6
- data/lib/samagotchi/web/session_control.rb +4 -4
- data/lib/samagotchi/web/session_create.rb +3 -3
- data/lib/samagotchi/web/session_hub.rb +9 -17
- data/lib/samagotchi/web/session_show.rb +67 -5
- data/lib/samagotchi/web/session_summary.rb +39 -28
- data/lib/samagotchi/worker.rb +83 -32
- data/lib/samagotchi/worker_boot.rb +184 -0
- data/lib/samagotchi/worker_link.rb +3 -0
- data/lib/samagotchi/worker_signals.rb +1 -1
- data/lib/samagotchi/worker_task_wakes.rb +2 -2
- metadata +32 -4
- data/lib/samagotchi/send_command/wait_target.rb +0 -85
|
@@ -5,6 +5,13 @@ require "json"
|
|
|
5
5
|
require "time"
|
|
6
6
|
require "securerandom"
|
|
7
7
|
require "rbconfig"
|
|
8
|
+
# Everything the parts (session_manager/*.rb, required at the end) need loads
|
|
9
|
+
# here, before the class opens: a file declaring `autoload :SessionManager`
|
|
10
|
+
# (child_move, reply_wait, ...) loaded after that point, while
|
|
11
|
+
# SessionManager autoloads, drops the half-built class, and the parts then
|
|
12
|
+
# build another without the facade's methods (spec/require_cycle_spec.rb).
|
|
13
|
+
# Parts require only the leaves their class bodies read (none autoloads
|
|
14
|
+
# SessionManager: spec/require_cycle_spec.rb).
|
|
8
15
|
require_relative "chi_home"
|
|
9
16
|
require_relative "system_one/runs"
|
|
10
17
|
require_relative "child_env"
|
|
@@ -19,7 +26,7 @@ require_relative "participant_messages"
|
|
|
19
26
|
require_relative "session"
|
|
20
27
|
require_relative "session_inbox"
|
|
21
28
|
require_relative "session_metrics"
|
|
22
|
-
require_relative "
|
|
29
|
+
require_relative "effective_model"
|
|
23
30
|
require_relative "session_setup"
|
|
24
31
|
require_relative "host_registry"
|
|
25
32
|
require_relative "utf8_default"
|
|
@@ -41,6 +48,7 @@ require_relative "model_profile"
|
|
|
41
48
|
require_relative "installed_gem"
|
|
42
49
|
require_relative "installed_versions"
|
|
43
50
|
require_relative "recap_store"
|
|
51
|
+
require_relative "session_row"
|
|
44
52
|
require_relative "reminder_carry"
|
|
45
53
|
require_relative "archive_store"
|
|
46
54
|
require_relative "continue_offer"
|
|
@@ -50,6 +58,7 @@ require_relative "context_sources"
|
|
|
50
58
|
require_relative "context_candidates_log"
|
|
51
59
|
require_relative "session_chain"
|
|
52
60
|
require_relative "child_move"
|
|
61
|
+
require_relative "starting_marker"
|
|
53
62
|
require_relative "tools/task_runtime"
|
|
54
63
|
|
|
55
64
|
module Samagotchi
|
|
@@ -76,8 +85,6 @@ module Samagotchi
|
|
|
76
85
|
# ├── output/ # agent writes responses here
|
|
77
86
|
# │ └── <timestamp>.txt # one file per agent response
|
|
78
87
|
# └── bridge.json # Bridge sidecar (how clients reach the worker)
|
|
79
|
-
# Needed only at call time (run_session_loop); worker.rb requires this file.
|
|
80
|
-
autoload :Worker, File.expand_path("worker", __dir__)
|
|
81
88
|
# session_retention requires this file.
|
|
82
89
|
autoload :SessionRetention, File.expand_path("session_retention", __dir__)
|
|
83
90
|
# session_continue requires this file.
|
|
@@ -85,6 +92,16 @@ module Samagotchi
|
|
|
85
92
|
# Needed only on an archive or a delete (it loads Grants).
|
|
86
93
|
autoload :Participants, File.expand_path("participants", __dir__)
|
|
87
94
|
|
|
95
|
+
# Its methods live in parts, each a module this class extends (required
|
|
96
|
+
# at the end of this file; each loads alone, but is meant to be loaded
|
|
97
|
+
# through this file):
|
|
98
|
+
# session_manager/spawn.rb SpawnPart: start a session's worker, the spawns remembered
|
|
99
|
+
# session_manager/worker_command.rb WorkerCommandPart: the worker process (CROSS-VERSION CONTRACT)
|
|
100
|
+
# session_manager/listing.rb ListingPart: the session lists and summary rows, children, replies
|
|
101
|
+
# session_manager/delivery.rb DeliveryPart: a user message into a session (Bridge or input file)
|
|
102
|
+
# session_manager/lifecycle.rb LifecyclePart: stop and restart a worker
|
|
103
|
+
# session_manager/removal.rb RemovalPart: archive, unarchive, delete
|
|
104
|
+
# session_manager/emptiness.rb EmptinessPart: is a session empty, is a left one deleted
|
|
88
105
|
class SessionManager
|
|
89
106
|
# Raised when a terminal's own chi owns the session (OwnerLock kind
|
|
90
107
|
# "tui": a -p --non-interactive one-shot, TerminalUI): it runs its own
|
|
@@ -108,1544 +125,6 @@ module Samagotchi
|
|
|
108
125
|
def initialize(session_id) = super(session_id, Session::SCRATCH_REFUSED)
|
|
109
126
|
end
|
|
110
127
|
|
|
111
|
-
# A delete that would pull the session from under its live worker.
|
|
112
|
-
# reason: :worker_running (not asked to stop it) or :still_stopping
|
|
113
|
-
# (stopped, but the worker outlived the wait).
|
|
114
|
-
class DeleteRefused < StandardError
|
|
115
|
-
attr_reader :session_id, :reason
|
|
116
|
-
|
|
117
|
-
def initialize(session_id, reason)
|
|
118
|
-
@session_id = session_id
|
|
119
|
-
@reason = reason
|
|
120
|
-
super(reason == :still_stopping ? "session #{session_id}'s worker is still shutting down" : "session #{session_id}'s worker is running")
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
# An archive that can't happen now: a turn runs in the session or in one
|
|
125
|
-
# of its children (+busy_id+ names which), or it is a scratch session.
|
|
126
|
-
class ArchiveRefused < StandardError
|
|
127
|
-
attr_reader :session_id, :reason, :busy_id
|
|
128
|
-
|
|
129
|
-
def initialize(session_id, reason, busy_id: nil)
|
|
130
|
-
@session_id = session_id
|
|
131
|
-
@reason = reason
|
|
132
|
-
@busy_id = busy_id
|
|
133
|
-
super(archive_refused_message)
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
private
|
|
137
|
-
|
|
138
|
-
def archive_refused_message
|
|
139
|
-
case @reason
|
|
140
|
-
when :scratch then "a scratch session is deleted when you leave; nothing to archive"
|
|
141
|
-
when :busy_child then "its delegate #{@busy_id[0, 8]} is running a turn; wait for it or stop it first"
|
|
142
|
-
when :queued then "a prompt is queued, which a resume would run; let it run first"
|
|
143
|
-
when :queued_child then "its delegate #{@busy_id[0, 8]} has a prompt queued, which a resume would run; let it run first"
|
|
144
|
-
when :offer then "the step-limit question waits for an answer; answer Continue or Stop first"
|
|
145
|
-
when :offer_child then "its delegate #{@busy_id[0, 8]} waits for an answer to its step-limit question; answer it first"
|
|
146
|
-
else "a turn is running; wait for it or cancel it first"
|
|
147
|
-
end
|
|
148
|
-
end
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
# Spawn a new background session that processes the given prompt (or,
|
|
152
|
-
# with none, waits idle for input).
|
|
153
|
-
#
|
|
154
|
-
# Returns the session object with its ID. Every worker always starts its
|
|
155
|
-
# per-session Bridge (the single live client transport), so external
|
|
156
|
-
# clients can reach it once the sidecar is published.
|
|
157
|
-
# @param memories [Array<String>] --memory: preloaded into the worker's prompt
|
|
158
|
-
# @param muted_memories [Array<String>] --mute: hidden from the session
|
|
159
|
-
# (both are session fields, so a respawn keeps them)
|
|
160
|
-
# @param parent_id [String, nil] the session that delegates this one (the
|
|
161
|
-
# `delegate` tool) or was forked from; a session field too
|
|
162
|
-
# @param messages [Array<Hash>] a conversation to start from (a plugin's
|
|
163
|
-
# ctx.sessions.fork); its image refs are copied from +images_from+
|
|
164
|
-
# @param images_from [String, nil] the session dir the seed's images are in
|
|
165
|
-
# @param preview [String, nil] what the lists show before the first turn
|
|
166
|
-
# (the prompt's preview by default, else the seed's first user message)
|
|
167
|
-
# @param title [String, nil] the session's own name (Session#title; a
|
|
168
|
-
# continue's link inherits its previous link's), saved before the
|
|
169
|
-
# worker starts: the worker owns the file from then on
|
|
170
|
-
# @param setup [SessionSetup] the session's own settings (chi
|
|
171
|
-
# --llm-context; a fork and a continue copy their session's; a delegate
|
|
172
|
-
# child the toolkit its delegate call named); without a toolkit the
|
|
173
|
-
# session is stored on config's toolkit.default, a delegate child on
|
|
174
|
-
# toolkit.child (Toolkit.configured)
|
|
175
|
-
# @param continues [String, nil] the session this one continues (a
|
|
176
|
-
# session field; SessionContinue)
|
|
177
|
-
# @param note [String, nil] a context note the session starts with,
|
|
178
|
-
# from +note_source+: queued before its worker spawns, so its first
|
|
179
|
-
# turn sees it
|
|
180
|
-
# @param review [SessionReview, nil] a review session's (ReviewStart; a
|
|
181
|
-
# continue keeps its previous link's)
|
|
182
|
-
# @param verify_fresh [VerifyFreshOrigin, nil] a Verify fresh child's
|
|
183
|
-
# requester, rules and claim (VerifyFresh.start)
|
|
184
|
-
# @param before_spawn [#call, nil] (session) runs once the session and
|
|
185
|
-
# its note are saved, before its worker spawns
|
|
186
|
-
# @param scratch [Boolean] a `chi scratch` session (its terminal's
|
|
187
|
-
# AttachLauncher): its worker runs a scratch Engine and deletes it
|
|
188
|
-
# once its last client is gone
|
|
189
|
-
# @return [Session] with #seed_images_dropped: refs whose file was gone,
|
|
190
|
-
# and #model_warning: an id its host's saved list doesn't have
|
|
191
|
-
def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
|
|
192
|
-
memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
|
|
193
|
-
preview: nil, title: nil, delegate: false, setup: SessionSetup.new, continues: nil, note: nil,
|
|
194
|
-
note_source: "chi", before_spawn: nil, review: nil, verify_fresh: nil, scratch: false)
|
|
195
|
-
sd = state_dir || Session.default_state_dir
|
|
196
|
-
# The resolved ref is stored (a resumed session keeps its model when an
|
|
197
|
-
# alias is retargeted), with the name as typed beside it. A wrong host
|
|
198
|
-
# refuses here, before anything is saved or spawned; a model id the
|
|
199
|
-
# host's saved list doesn't have (ModelListStore) only warns
|
|
200
|
-
# (#model_warning on the session), as some hosts serve ids they don't
|
|
201
|
-
# list.
|
|
202
|
-
typed = Samagotchi::ModelProfile.check_host!(Samagotchi::ModelProfile.required_model_name(model_name))
|
|
203
|
-
model_warning = Samagotchi::ModelProfile.model_warning(typed)
|
|
204
|
-
ref = ConfigFile.model_ref(typed).ref
|
|
205
|
-
session = Session.new_session(
|
|
206
|
-
mode: mode,
|
|
207
|
-
model_name: ref,
|
|
208
|
-
model_typed: typed == ref ? nil : typed,
|
|
209
|
-
working_directory: working_directory || Dir.pwd,
|
|
210
|
-
preloaded_memory_names: memories,
|
|
211
|
-
muted_memory_names: muted_memories,
|
|
212
|
-
parent_id: parent_id,
|
|
213
|
-
messages: messages,
|
|
214
|
-
delegate: delegate,
|
|
215
|
-
setup: setup,
|
|
216
|
-
continues: continues,
|
|
217
|
-
review: review,
|
|
218
|
-
scratch: scratch
|
|
219
|
-
)
|
|
220
|
-
session.toolkit = Toolkit.configured(delegate: delegate) if session.toolkit.nil?
|
|
221
|
-
session.toolkit_origin = setup.toolkit_origin || "default"
|
|
222
|
-
# Saved before the worker starts: its first turn is already the requester's.
|
|
223
|
-
session.verify_fresh = verify_fresh
|
|
224
|
-
session.model_warning = model_warning
|
|
225
|
-
# With no prompt there is no first turn to run (an attaching UI sends
|
|
226
|
-
# the prompts), so the session starts idle.
|
|
227
|
-
session.status = prompt.to_s.strip.empty? ? Session::STATUS_IDLE : Session::STATUS_RUNNING
|
|
228
|
-
session.last_prompt = prompt
|
|
229
|
-
# The first prompt's turn gets an id like a sent message's, so a wait
|
|
230
|
-
# for it can name it (chi send --new --wait, chi web's create): the
|
|
231
|
-
# worker runs it with that origin, and its reply file and last_turn
|
|
232
|
-
# carry the id.
|
|
233
|
-
session.first_turn_id = SecureRandom.uuid unless prompt.to_s.strip.empty?
|
|
234
|
-
# The worker takes last_prompt and clears it, and messages are saved at
|
|
235
|
-
# the turn's end: until then this is the only preview a list has.
|
|
236
|
-
session.first_preview = Session.preview_of(preview.to_s.strip.empty? ? prompt : preview)
|
|
237
|
-
session.title = title
|
|
238
|
-
session_dir = Session.session_dir(session.id, state_dir: sd)
|
|
239
|
-
unless session.messages.empty?
|
|
240
|
-
session.messages, dropped = ImageStore.copy_refs(session.messages, from: images_from, to: session_dir)
|
|
241
|
-
session.seed_images_dropped = dropped
|
|
242
|
-
end
|
|
243
|
-
setup_session_directory(session_dir, session, state_dir: sd)
|
|
244
|
-
SessionInbox.write_note(session.id, text: note, source: note_source, state_dir: sd) if note
|
|
245
|
-
before_spawn&.call(session)
|
|
246
|
-
spawn_worker_for_session(session, state_dir: sd)
|
|
247
|
-
session
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
# Start the next link of a chain (SessionContinue.run, which has the
|
|
251
|
-
# rules and raises SessionContinue::Refused).
|
|
252
|
-
def self.continue_session(...) = SessionContinue.run(...)
|
|
253
|
-
|
|
254
|
-
# Build the opts hash passed to Process.spawn for a forked worker. The
|
|
255
|
-
# child inherits this process's ENV and reads config.yml itself (as it
|
|
256
|
-
# is when the worker starts); opts[:env] adds to it (merged, not
|
|
257
|
-
# replaced) what it can't read there: this `chi`'s CLI settings
|
|
258
|
-
# (Config.cli_env) and the absolute log file. It unsets
|
|
259
|
-
# what ChildEnv lists (SAMAGOTCHI_GUARDRAILS_*, the session markers).
|
|
260
|
-
# XDG_CONFIG_HOME passes: whoever starts or
|
|
261
|
-
# wakes the worker picks which config.yml it reads (docs/guardrails.md).
|
|
262
|
-
# XDG_STATE_HOME and XDG_CONFIG_HOME, when set, pass made absolute
|
|
263
|
-
# (Paths): the worker starts in the session's directory.
|
|
264
|
-
private_class_method def self.spawn_options(session)
|
|
265
|
-
# Own process group: workers outlive `chi web`, and a Ctrl-C in its
|
|
266
|
-
# terminal must not reach them (the worker then leaves the process
|
|
267
|
-
# tree too: WORKER_DETACH_ENV).
|
|
268
|
-
opts = { out: File::NULL, err: File::NULL, pgroup: true }
|
|
269
|
-
# The worker's tools (and `!cmd`) run in the session's directory, not
|
|
270
|
-
# in the cwd of whoever woke it (`chi web`, another terminal).
|
|
271
|
-
dir = session.working_directory.to_s
|
|
272
|
-
if !dir.empty? && File.directory?(dir)
|
|
273
|
-
opts[:chdir] = dir
|
|
274
|
-
else
|
|
275
|
-
Log.warn(:worker, "cwd_gone", sid: session.id, dir: dir, cwd: Dir.pwd)
|
|
276
|
-
end
|
|
277
|
-
# (A chi a session's tool runs took that session's project env out at
|
|
278
|
-
# its start: bin/chi, ProjectEnv.take_orig!.)
|
|
279
|
-
child_env = {}
|
|
280
|
-
# A worker gets no CLI args: --model, --thinking, --log-level and the
|
|
281
|
-
# other flags this chi was started with.
|
|
282
|
-
child_env.merge!(Config.cli_env)
|
|
283
|
-
child_env[ModelProfile::MODEL_FROM_CLI_ENV] = "1" if child_env.key?(ModelProfile::MODEL_ENV)
|
|
284
|
-
# And the spawner's debug log, absolute: a relative log.file would
|
|
285
|
-
# otherwise land in the worker's (the session's) directory.
|
|
286
|
-
log_path = begin LogPath.resolve rescue nil end # degrade-ok: no log to say it in; the worker's is off (below)
|
|
287
|
-
if log_path
|
|
288
|
-
child_env["SAMAGOTCHI_LOG_FILE"] = log_path
|
|
289
|
-
else
|
|
290
|
-
child_env["SAMAGOTCHI_LOG_DISABLE"] = "true"
|
|
291
|
-
end
|
|
292
|
-
# And the spawner's XDG dirs, absolute: a relative XDG_STATE_HOME or
|
|
293
|
-
# XDG_CONFIG_HOME would otherwise be read from the session's directory.
|
|
294
|
-
child_env.merge!(Paths.absolute_xdg)
|
|
295
|
-
# Unset (Process.spawn unsets a key mapped to nil) what no child chi
|
|
296
|
-
# takes from its spawner (ChildEnv): the spawner's guardrails settings
|
|
297
|
-
# (a parent agent starting or waking a session; config.yml no longer
|
|
298
|
-
# reads them, this keeps an older or future env-exposed one out as
|
|
299
|
-
# well); the session markers, a session's own (a worker started from
|
|
300
|
-
# its commands, chi send, is another session: its execute and tasks
|
|
301
|
-
# set its own; nothing else it runs has one); and a smoke's fake
|
|
302
|
-
# "newest installed", for this process's notices only.
|
|
303
|
-
child_env.merge!(ChildEnv.unset)
|
|
304
|
-
# A worker process: run_session_loop traps signals, and leaves this
|
|
305
|
-
# process's tree.
|
|
306
|
-
child_env[WORKER_PROCESS_ENV] = "1"
|
|
307
|
-
child_env[WORKER_DETACH_ENV] = "1"
|
|
308
|
-
# A gem-mode worker activates the newest installed chi itself: under
|
|
309
|
-
# `bundle exec` (a Gemfile that has samagotchi) Bundler's RUBYOPT would
|
|
310
|
-
# hold it to the bundle's version, and its badge would never clear.
|
|
311
|
-
child_env.merge!(ChildEnv.unbundled)
|
|
312
|
-
opts[:env] = child_env unless child_env.empty?
|
|
313
|
-
opts
|
|
314
|
-
end
|
|
315
|
-
|
|
316
|
-
# List all sessions, reading status from persisted session.json files.
|
|
317
|
-
# +project_root+: only that project's sessions (nil: every session).
|
|
318
|
-
# +include_archived+: archived sessions too (Session#archived says which).
|
|
319
|
-
def self.list_sessions(state_dir: nil, sort: "updated_at", order: "desc", limit: nil, offset: 0, project_root: nil,
|
|
320
|
-
include_archived: false)
|
|
321
|
-
Session.list(state_dir: state_dir || Session.default_state_dir, sort: sort, order: order, limit: limit,
|
|
322
|
-
offset: offset, project_root: project_root, include_archived: include_archived)
|
|
323
|
-
end
|
|
324
|
-
|
|
325
|
-
SUMMARY_DESC_LIMIT = 60
|
|
326
|
-
|
|
327
|
-
# Short summaries of sessions, newest first: the picker behind
|
|
328
|
-
# `chi sessions list --live --format json|tsv` (chi note from a script)
|
|
329
|
-
# and the agent's list_sessions tool; both pass the current project as
|
|
330
|
-
# +project_root+ unless asked for every project.
|
|
331
|
-
# @param live [Boolean] only sessions a worker owns now (the owner lock,
|
|
332
|
-
# not the saved status, which a dead worker leaves at "running"); a
|
|
333
|
-
# session a one-shot (or an older chi's REPL) owns is left out: it
|
|
334
|
-
# can't take notes
|
|
335
|
-
# @param cwd [String, nil] only sessions in this folder or below it
|
|
336
|
-
# @param project_root [String, nil] only this project's sessions
|
|
337
|
-
# (Session#project_root)
|
|
338
|
-
# @param limit [Integer, nil] taken after the filters
|
|
339
|
-
# @param include_tests [Boolean] false leaves out test runs
|
|
340
|
-
# @param exclude [String, nil] a session id to leave out (the asker)
|
|
341
|
-
# @param include_archived [Boolean] archived sessions too
|
|
342
|
-
# @return [Array<Hash>] {id:, short_id:, desc:, preview:, cwd:, project:,
|
|
343
|
-
# updated_at:, status:, live:, busy:, owner:, recap:, auto_title:, parent_id:,
|
|
344
|
-
# parent_short_id:, continues:, continues_short_id:, delegate:, archived:, scratch:, test_run:, waiting:,
|
|
345
|
-
# waiting_id:, stopped_by:}; busy =
|
|
346
|
-
# live with a turn running, delegate = started by the delegate tool
|
|
347
|
-
# (Session#delegate?; a fork has a parent_id but isn't one), continues = the session it continues (the
|
|
348
|
-
# previous link of its chain, Session#continues), recap = the saved recap's first sentence, auto_title =
|
|
349
|
-
# the auto title its reply led with (RecapTitle) or nil, project =
|
|
350
|
-
# Session#project_root, waiting = the kind of question it waits on
|
|
351
|
-
# ("question", "approval", "hook"; Session#waiting_question) or nil,
|
|
352
|
-
# waiting_id = that question's id or nil, relayed_to = the parent
|
|
353
|
-
# whose card it waits in too (the approval relay), its short id, or nil,
|
|
354
|
-
# stopped_by = the hook that stopped the last turn (Session#stopped_by)
|
|
355
|
-
def self.session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
|
|
356
|
-
project_root: nil, include_archived: false, sort: nil, order: nil)
|
|
357
|
-
sd = state_dir || Session.default_state_dir
|
|
358
|
-
root = cwd && folder_path(cwd)
|
|
359
|
-
roots = {}
|
|
360
|
-
# One registry per listing, built only when a row has a saved context
|
|
361
|
-
# to count: its session's llm_context budget is what ctx_pct counts
|
|
362
|
-
# against (SessionMetrics.saved_context_pct).
|
|
363
|
-
budgets = listing_budgets
|
|
364
|
-
summaries = Session.list(state_dir: sd, sort: sort || "updated_at", order: order || "desc", project_root: project_root,
|
|
365
|
-
include_archived: include_archived).lazy
|
|
366
|
-
.reject { |s| (!include_tests && s.test_run) || s.id == exclude }
|
|
367
|
-
.select { |s| root.nil? || in_folder?(s.working_directory, root) }
|
|
368
|
-
.filter_map do |s|
|
|
369
|
-
owner = session_owner(s.id, state_dir: sd)
|
|
370
|
-
owned = !!owner&.worker?
|
|
371
|
-
next if live && !owned
|
|
372
|
-
|
|
373
|
-
{ id: s.id, short_id: s.id[0, 8], desc: summary_desc(s), preview: summary_preview(s), title: s.title, cwd: s.working_directory,
|
|
374
|
-
project: s.project_root(cache: roots), updated_at: s.updated_at, status: s.status, live: owned, busy: owned && s.status == Session::STATUS_RUNNING,
|
|
375
|
-
owner: owner&.kind, **recap_fields(Session.session_dir(s.id, state_dir: sd)),
|
|
376
|
-
ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd),
|
|
377
|
-
budget_tokens: -> { budgets.call(s) })&.round(1),
|
|
378
|
-
parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8), continues: s.continues,
|
|
379
|
-
continues_short_id: s.continues&.[](0, 8), delegate: s.delegate?, archived: s.archived,
|
|
380
|
-
scratch: s.scratch, test_run: s.test_run, stopped_by: s.stopped_by }.merge(waiting_fields(s.waiting_question(live: owned)))
|
|
381
|
-
end
|
|
382
|
-
(limit ? summaries.first(limit) : summaries.to_a)
|
|
383
|
-
end
|
|
384
|
-
|
|
385
|
-
# waiting: the question's kind, waiting_id: its id (what chi answer
|
|
386
|
-
# --question takes); both nil with none.
|
|
387
|
-
def self.waiting_fields(waiting)
|
|
388
|
-
{ waiting: waiting&.dig(:kind), waiting_id: waiting&.dig(:id), relayed_to: waiting&.dig(:relayed_to) }
|
|
389
|
-
end
|
|
390
|
-
private_class_method :waiting_fields
|
|
391
|
-
|
|
392
|
-
# One HostRegistry for a whole listing, built on the first row that
|
|
393
|
-
# needs it (a saved context to count) and kept for the rest: resolving a
|
|
394
|
-
# session's llm_context budget reads config.yml (mtime-cached), so not
|
|
395
|
-
# one registry per row.
|
|
396
|
-
# @return [#call] (session) -> the session's budget in tokens or nil
|
|
397
|
-
def self.listing_budgets
|
|
398
|
-
registry = nil
|
|
399
|
-
->(session) { LLMContextStrategy.session_budget(session, registry: registry ||= HostRegistry.new) }
|
|
400
|
-
end
|
|
401
|
-
private_class_method :listing_budgets
|
|
402
|
-
|
|
403
|
-
# The sessions delegated by +parent_id+ (the `delegate` tool), newest
|
|
404
|
-
# first, as .session_summaries rows. A running one (busy) counts against
|
|
405
|
-
# session.max_children. Archived ones too.
|
|
406
|
-
def self.children_of(parent_id, state_dir: nil)
|
|
407
|
-
return [] if parent_id.to_s.empty?
|
|
408
|
-
|
|
409
|
-
session_summaries(state_dir: state_dir, include_tests: true, include_archived: true)
|
|
410
|
-
.select { |s| s[:parent_id] == parent_id.to_s }
|
|
411
|
-
end
|
|
412
|
-
|
|
413
|
-
# What one archive did: id: the session it was asked for; archived: the
|
|
414
|
-
# ids (it and its delegates) now hidden for good; stopped: the live
|
|
415
|
-
# workers stopped first; discarded: empty sessions its stopping worker deleted.
|
|
416
|
-
ArchiveResult = Data.define(:id, :archived, :stopped, :discarded)
|
|
417
|
-
|
|
418
|
-
# Archive a session and its delegated children (ArchiveStore): hidden
|
|
419
|
-
# from every list, kept by the retention sweep. A live idle worker is
|
|
420
|
-
# stopped first; the marker is written even while it shuts down.
|
|
421
|
-
# @return [ArchiveResult]
|
|
422
|
-
# @raise [ArgumentError] unknown id (Session::AmbiguousId for a prefix of several)
|
|
423
|
-
# @raise [OwnedByTUI] a one-turn chi run owns it or one of its children
|
|
424
|
-
# @raise [ArchiveRefused] a turn runs in it or in a child, a prompt is
|
|
425
|
-
# queued in one (a later resume would run it), a live one offers to
|
|
426
|
-
# continue past the step limit, or it is a scratch session
|
|
427
|
-
# @param except [Array<String>] descendants left alone with their own
|
|
428
|
-
# subtrees (delegates a continue moves to the chain's new link)
|
|
429
|
-
def self.archive_session(id_or_prefix, state_dir: nil, wait: 5, except: [])
|
|
430
|
-
sd = state_dir || Session.default_state_dir
|
|
431
|
-
id = archive_target(id_or_prefix, sd)
|
|
432
|
-
tree, owners = archivable!(id, sd, except: except)
|
|
433
|
-
stopped = tree.select { |sid| owners[sid] }
|
|
434
|
-
stopped.each { |sid| stop_session(sid, state_dir: sd, wait: wait) }
|
|
435
|
-
archived, discarded = tree.partition { |sid| ArchiveStore.archive(sid, state_dir: sd) }
|
|
436
|
-
# A restart's reminders hand-off no successor took (the stop above
|
|
437
|
-
# removes a live one's): archived sessions keep no reminders.
|
|
438
|
-
archived.each { |sid| ReminderCarry.remove(Session.session_dir(sid, state_dir: sd)) }
|
|
439
|
-
# D8: an archive ends the review (unarchiving doesn't restore it).
|
|
440
|
-
Participants.end_sessions(archived, by: "archive", state_dir: sd)
|
|
441
|
-
ArchiveResult.new(id: id, archived: archived, stopped: stopped, discarded: discarded)
|
|
442
|
-
end
|
|
443
|
-
|
|
444
|
-
# The session and its descendants, with their owners, when they can be
|
|
445
|
-
# archived now (#archive_refusal for each), else the refusal. Public for
|
|
446
|
-
# SessionContinue, which checks before its recap wait.
|
|
447
|
-
# @return [Array(Array<String>, Hash)] the ids, and id => OwnerLock::Owner or nil
|
|
448
|
-
# @raise [ArchiveRefused, OwnedByTUI]
|
|
449
|
-
def self.archivable!(id, state_dir, except: [])
|
|
450
|
-
raise ArchiveRefused.new(id, :scratch) if Session.load(id, state_dir: state_dir).scratch
|
|
451
|
-
|
|
452
|
-
tree = [id, *descendant_ids(id, state_dir, except: except)]
|
|
453
|
-
owners = tree.to_h { |sid| [sid, session_owner(sid, state_dir: state_dir)] }
|
|
454
|
-
tree.each do |sid|
|
|
455
|
-
refusal = archive_refusal(sid, owners[sid], state_dir)
|
|
456
|
-
raise ArchiveRefused.new(id, sid == id ? refusal : :"#{refusal}_child", busy_id: sid) if refusal
|
|
457
|
-
end
|
|
458
|
-
[tree, owners]
|
|
459
|
-
end
|
|
460
|
-
|
|
461
|
-
# What keeps +sid+ (in an archive's tree) from being archived: :busy (a
|
|
462
|
-
# turn runs), :queued (input/ holds a prompt, which outlives the stop and
|
|
463
|
-
# runs on a resume, live worker or not), :offer (a live worker's continue
|
|
464
|
-
# offer, which the stop would drop), or nil.
|
|
465
|
-
# @raise [OwnedByTUI] a one-turn chi run owns it
|
|
466
|
-
def self.archive_refusal(sid, owner, state_dir)
|
|
467
|
-
raise OwnedByTUI, sid if owner&.tui?
|
|
468
|
-
|
|
469
|
-
session = owner && Session.load(sid, state_dir: state_dir)
|
|
470
|
-
return :busy if session&.status == Session::STATUS_RUNNING
|
|
471
|
-
return :queued unless SessionInbox.find_new_input_files(Session.session_dir(sid, state_dir: state_dir)).empty?
|
|
472
|
-
|
|
473
|
-
:offer if session&.waiting_question(live: true)&.dig(:kind) == ContinueOffer::KIND
|
|
474
|
-
end
|
|
475
|
-
private_class_method :archive_refusal
|
|
476
|
-
|
|
477
|
-
# Unarchive a session and its delegated children.
|
|
478
|
-
# @return [Hash] {id:, unarchived: [ids that were archived]}
|
|
479
|
-
# @raise [ArgumentError] unknown id
|
|
480
|
-
def self.unarchive_session(id_or_prefix, state_dir: nil)
|
|
481
|
-
sd = state_dir || Session.default_state_dir
|
|
482
|
-
id = archive_target(id_or_prefix, sd)
|
|
483
|
-
unarchived = [id, *descendant_ids(id, sd)].select { |sid| ArchiveStore.unarchive(sid, state_dir: sd) }
|
|
484
|
-
{ id: id, unarchived: unarchived }
|
|
485
|
-
end
|
|
486
|
-
|
|
487
|
-
private_class_method def self.archive_target(id_or_prefix, state_dir)
|
|
488
|
-
given = id_or_prefix.to_s
|
|
489
|
-
raise ArgumentError, "no session #{given}" unless Session.valid_id?(given)
|
|
490
|
-
|
|
491
|
-
id = Session.resolve_id(given, state_dir: state_dir)
|
|
492
|
-
raise ArgumentError, "no session #{given}" unless Session.exist?(id, state_dir: state_dir)
|
|
493
|
-
|
|
494
|
-
id
|
|
495
|
-
end
|
|
496
|
-
|
|
497
|
-
# Children, their children, …: a delegated session doesn't delegate
|
|
498
|
-
# further today, a plugin's fork may. The walk doesn't enter +except+.
|
|
499
|
-
def self.descendant_ids(id, state_dir, except: [])
|
|
500
|
-
seen = [id]
|
|
501
|
-
queue = [id]
|
|
502
|
-
until queue.empty?
|
|
503
|
-
children_of(queue.shift, state_dir: state_dir).each do |child|
|
|
504
|
-
next if seen.include?(child[:id]) || except.include?(child[:id])
|
|
505
|
-
|
|
506
|
-
seen << child[:id]
|
|
507
|
-
queue << child[:id]
|
|
508
|
-
end
|
|
509
|
-
end
|
|
510
|
-
seen.drop(1)
|
|
511
|
-
end
|
|
512
|
-
|
|
513
|
-
SUMMARY_PREVIEW_LIMIT = 120
|
|
514
|
-
|
|
515
|
-
# "<cwd basename> · <last prompt, or the first preview>", one line.
|
|
516
|
-
private_class_method def self.summary_desc(session)
|
|
517
|
-
desc = [File.basename(session.working_directory.to_s), summary_text(session)].reject(&:empty?).join(" · ")
|
|
518
|
-
cut(desc, SUMMARY_DESC_LIMIT)
|
|
519
|
-
end
|
|
520
|
-
|
|
521
|
-
# A session row's recap: and auto_title: (RecapStore.card, one read).
|
|
522
|
-
private_class_method def self.recap_fields(session_dir)
|
|
523
|
-
card = RecapStore.card(session_dir)
|
|
524
|
-
{ recap: card.preview, auto_title: card.title }
|
|
525
|
-
end
|
|
526
|
-
|
|
527
|
-
private_class_method def self.summary_preview(session)
|
|
528
|
-
cut(summary_text(session), SUMMARY_PREVIEW_LIMIT)
|
|
529
|
-
end
|
|
530
|
-
|
|
531
|
-
# A session as one line of text for the lists: its title (/title), else
|
|
532
|
-
# its last prompt, else the first message it was started with (a session
|
|
533
|
-
# command like `/model x`, or a first turn not saved yet).
|
|
534
|
-
def self.summary_text(session)
|
|
535
|
-
return one_line(session.title) unless session.title.to_s.strip.empty?
|
|
536
|
-
|
|
537
|
-
one_line(session.last_prompt.to_s.strip.empty? ? session.first_preview.to_s : session.last_prompt.to_s)
|
|
538
|
-
end
|
|
539
|
-
|
|
540
|
-
# A prompt as one line for a session list: whitespace collapsed, and a
|
|
541
|
-
# quoted or annotated message (ContextQuote, the web's annotations)
|
|
542
|
-
# without its "> " markers, so the words show.
|
|
543
|
-
def self.one_line(text)
|
|
544
|
-
text.to_s.gsub(/^[ \t]*(?:>[ \t]?)+/, "").gsub(/\s+/, " ").strip
|
|
545
|
-
end
|
|
546
|
-
|
|
547
|
-
private_class_method def self.cut(text, limit)
|
|
548
|
-
text.length > limit ? "#{text[0, limit - 1]}…" : text
|
|
549
|
-
end
|
|
550
|
-
|
|
551
|
-
private_class_method def self.folder_path(path)
|
|
552
|
-
File.realpath(path)
|
|
553
|
-
rescue SystemCallError
|
|
554
|
-
File.expand_path(path)
|
|
555
|
-
end
|
|
556
|
-
|
|
557
|
-
private_class_method def self.in_folder?(dir, root)
|
|
558
|
-
dir = dir.to_s.chomp("/")
|
|
559
|
-
root = root.chomp("/")
|
|
560
|
-
dir == root || dir.start_with?("#{root}/")
|
|
561
|
-
end
|
|
562
|
-
|
|
563
|
-
# The retention sweep (SessionRetention.sweep_if_due), here for the web
|
|
564
|
-
# app and hub, which take this class as their manager.
|
|
565
|
-
def self.retention_sweep_if_due(state_dir: nil) = SessionRetention.sweep_if_due(state_dir: state_dir)
|
|
566
|
-
|
|
567
|
-
# Ensure an existing session has a live worker process.
|
|
568
|
-
# Returns the loaded session after state reconciliation.
|
|
569
|
-
# @raise [OwnedByTUI] when the interactive TUI owns the session
|
|
570
|
-
def self.resume_session(session_id, state_dir: nil)
|
|
571
|
-
sd = state_dir || Session.default_state_dir
|
|
572
|
-
session = Session.load(session_id, state_dir: sd)
|
|
573
|
-
# Its terminal's worker came from spawn_session; nothing wakes another.
|
|
574
|
-
raise ScratchSession, session.id if session.scratch
|
|
575
|
-
return session if refuse_tui!(session.id, state_dir: sd)
|
|
576
|
-
|
|
577
|
-
# status is turn state, not liveness: a new worker runs no turn yet,
|
|
578
|
-
# and must not find the session stopped (it would exit): neither the
|
|
579
|
-
# marker nor a status field from before it.
|
|
580
|
-
# One this process just spawned may not hold the lock yet (a slow
|
|
581
|
-
# start, deliver_turn's second resume): it would only lose it.
|
|
582
|
-
return session if still_starting?(session.id, state_dir: sd)
|
|
583
|
-
# A continue moving delegates to it spawns its worker once they are
|
|
584
|
-
# moved; one spawned now (a moved child's ring) would race the move.
|
|
585
|
-
return session if ChildMove.starting?(session.id, state_dir: sd)
|
|
586
|
-
|
|
587
|
-
Session.clear_stopped(session.id, state_dir: sd)
|
|
588
|
-
session.status = Session::STATUS_IDLE
|
|
589
|
-
session.save(state_dir: sd)
|
|
590
|
-
spawn_worker_for_session(session, state_dir: sd)
|
|
591
|
-
session
|
|
592
|
-
end
|
|
593
|
-
|
|
594
|
-
# A delegate child rang a parent that has no worker (ChildRing): start
|
|
595
|
-
# one, which runs a turn for the report. Not for a parent that is gone,
|
|
596
|
-
# stopped, archived, a scratch session, a one-turn run's, or a
|
|
597
|
-
# continue's new link still taking its delegates (its own spawn comes):
|
|
598
|
-
# the ring waits on disk for whoever runs it next (#resume_session
|
|
599
|
-
# itself would clear the stop). A stop landing between the check and
|
|
600
|
-
# the resume is a small window, left as is. An archived parent with a
|
|
601
|
-
# later link in its chain wakes that link instead: a ring a moved child
|
|
602
|
-
# wrote just as the continue moved it reaches the new link that way
|
|
603
|
-
# (ChildReports reads its predecessor's rings).
|
|
604
|
-
# @return [Symbol] :woken, or why not (:gone, :stopped, :archived,
|
|
605
|
-
# :scratch, :owned, :starting)
|
|
606
|
-
def self.wake_for_report(session_id, state_dir: nil, forward: true)
|
|
607
|
-
sd = state_dir || Session.default_state_dir
|
|
608
|
-
return :gone unless Session.exist?(session_id, state_dir: sd)
|
|
609
|
-
return :owned if session_owner(session_id, state_dir: sd)
|
|
610
|
-
return :stopped if Session.stopped_marker?(session_id, state_dir: sd)
|
|
611
|
-
|
|
612
|
-
if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: sd))
|
|
613
|
-
following = forward && SessionChain.next_of(session_id, state_dir: sd)
|
|
614
|
-
return following ? wake_for_report(following, state_dir: sd, forward: false) : :archived
|
|
615
|
-
end
|
|
616
|
-
return :starting if ChildMove.starting?(session_id, state_dir: sd)
|
|
617
|
-
|
|
618
|
-
session = Session.load(session_id, state_dir: sd)
|
|
619
|
-
return :stopped if session.status == Session::STATUS_STOPPED
|
|
620
|
-
return :scratch if session.scratch
|
|
621
|
-
|
|
622
|
-
resume_session(session_id, state_dir: sd)
|
|
623
|
-
Log.info(:worker, "delegate_woke_parent", parent: session_id[0, 8])
|
|
624
|
-
:woken
|
|
625
|
-
rescue OwnedByTUI
|
|
626
|
-
:owned
|
|
627
|
-
end
|
|
628
|
-
|
|
629
|
-
# How long after a spawn a resume trusts that worker to take the
|
|
630
|
-
# session, while it runs (and the session wasn't stopped since).
|
|
631
|
-
WORKER_START_GRACE = WorkerLink::START_LIMIT
|
|
632
|
-
|
|
633
|
-
@spawns = {}
|
|
634
|
-
@spawns_mutex = Mutex.new
|
|
635
|
-
|
|
636
|
-
# A worker this process spawned for the session within
|
|
637
|
-
# WORKER_START_GRACE is still running, and no stop came since.
|
|
638
|
-
private_class_method def self.still_starting?(session_id, state_dir:)
|
|
639
|
-
return false if Session.stopped_marker?(session_id, state_dir: state_dir)
|
|
640
|
-
|
|
641
|
-
at, waiter = @spawns_mutex.synchronize { @spawns[[state_dir, session_id]] }
|
|
642
|
-
return false unless at && waiter.respond_to?(:alive?) && waiter.alive?
|
|
643
|
-
|
|
644
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC) - at < WORKER_START_GRACE
|
|
645
|
-
end
|
|
646
|
-
|
|
647
|
-
private_class_method def self.note_spawn(session_id, state_dir:, waiter:)
|
|
648
|
-
@spawns_mutex.synchronize do
|
|
649
|
-
@spawns.delete_if { |_, (_, w)| !(w.respond_to?(:alive?) && w.alive?) }
|
|
650
|
-
@spawns[[state_dir, session_id]] = [Process.clock_gettime(Process::CLOCK_MONOTONIC), waiter]
|
|
651
|
-
end
|
|
652
|
-
end
|
|
653
|
-
|
|
654
|
-
# Forget the spawns #still_starting? remembers (specs).
|
|
655
|
-
def self.forget_spawns
|
|
656
|
-
@spawns_mutex.synchronize { @spawns.clear }
|
|
657
|
-
end
|
|
658
|
-
|
|
659
|
-
# Starter processes are children of chi web, unlike the detached workers
|
|
660
|
-
# they launch; the web restart adopts these pids before replacing itself.
|
|
661
|
-
def self.live_spawn_pids
|
|
662
|
-
@spawns_mutex.synchronize do
|
|
663
|
-
@spawns.delete_if { |_, (_, waiter)| !(waiter.respond_to?(:alive?) && waiter.alive?) }
|
|
664
|
-
@spawns.values.filter_map do |_, waiter|
|
|
665
|
-
waiter.pid if waiter.respond_to?(:pid) && waiter.alive?
|
|
666
|
-
end.uniq.sort
|
|
667
|
-
end
|
|
668
|
-
end
|
|
669
|
-
|
|
670
|
-
# Read any new output files from a session directory.
|
|
671
|
-
# Optionally filters to only files newer than `since_time`.
|
|
672
|
-
def self.read_responses(session_id, since_time: nil, state_dir: nil)
|
|
673
|
-
SessionInbox.read_outputs(Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir),
|
|
674
|
-
since_time: since_time)
|
|
675
|
-
end
|
|
676
|
-
|
|
677
|
-
# A restart that can't happen now. reason: :not_running (no live worker:
|
|
678
|
-
# the next prompt starts one on the newest chi anyway), :unsupported (a
|
|
679
|
-
# worker from before restarts, running +version+: stop it instead), a
|
|
680
|
-
# WorkerIdleExit#hold_for_restart hold (:turn_running, :reminders, ...),
|
|
681
|
-
# :fallback_held (disk-visible work blocks an unresponsive-worker restart),
|
|
682
|
-
# :stopped (its worker, +pid+, is SIGSTOPped: neither the ask nor a TERM
|
|
683
|
-
# reaches it until it is continued), or :failed (the worker answered something else or didn't release).
|
|
684
|
-
class RestartRefused < StandardError
|
|
685
|
-
attr_reader :session_id, :reason, :version, :holds, :pid
|
|
686
|
-
|
|
687
|
-
def initialize(session_id, reason, version: nil, holds: [], pid: nil, via: ChiInvocation::HERE)
|
|
688
|
-
@session_id = session_id
|
|
689
|
-
@reason = reason.to_sym
|
|
690
|
-
@version = version
|
|
691
|
-
@holds = Array(holds).map(&:to_sym).freeze
|
|
692
|
-
@pid = pid
|
|
693
|
-
super(RestartRefused.words(session_id, @reason, version, @holds, pid: pid, via: via))
|
|
694
|
-
end
|
|
695
|
-
|
|
696
|
-
HOLDS = {
|
|
697
|
-
starting: "its worker is still starting",
|
|
698
|
-
turn_running: "a turn is running",
|
|
699
|
-
input_queued: "a prompt is queued",
|
|
700
|
-
continue_offered: "the step-limit question waits for an answer",
|
|
701
|
-
question_pending: "a question or approval waits for an answer",
|
|
702
|
-
# Only a worker from before ReminderCarry still holds a restart for its reminders.
|
|
703
|
-
reminders: "it has reminders, which its worker's older chi can't carry over a restart",
|
|
704
|
-
command_running: "a /btw (or another command) is still running",
|
|
705
|
-
relays_open: "a delegate's approval relayed here is open or just answered",
|
|
706
|
-
background_tasks: "background tasks it started are still running",
|
|
707
|
-
scratch: "a chi scratch session's worker ends with its terminal"
|
|
708
|
-
}.merge(WorkerIdleExit::LEAVING_WORDS).freeze
|
|
709
|
-
|
|
710
|
-
# What the CLI and the web say.
|
|
711
|
-
def self.words(session_id, reason, version = nil, holds = [], pid: nil, via: ChiInvocation::HERE)
|
|
712
|
-
stop = via.command("sessions stop #{session_id}")
|
|
713
|
-
case reason
|
|
714
|
-
when :stopped
|
|
715
|
-
where = via.remote? ? " on #{via.remote}" : ""
|
|
716
|
-
"session #{session_id}'s worker#{" (pid #{pid})" if pid} is stopped (SIGSTOP or Ctrl-Z) and can't take " \
|
|
717
|
-
"a restart: continue it#{" with `kill -CONT #{pid}`" if pid}#{where}, then restart again"
|
|
718
|
-
when :not_running
|
|
719
|
-
"session #{session_id} has no running worker; the next prompt starts one on the newest chi"
|
|
720
|
-
when :unsupported
|
|
721
|
-
"session #{session_id}'s worker runs chi #{version || "(older)"}, which can't restart: " \
|
|
722
|
-
"#{stop}, then any prompt starts it on the newest chi"
|
|
723
|
-
when :fallback_held
|
|
724
|
-
why = holds.map { |hold| HOLDS.fetch(hold, hold.to_s.tr("_", " ")) }.join("; ")
|
|
725
|
-
"session #{session_id} has disk-visible work that a restart might lose#{": #{why}" unless why.empty?}; " \
|
|
726
|
-
"run `#{stop}`, then any prompt starts it on the newest chi"
|
|
727
|
-
when :failed then "session #{session_id}'s worker didn't take the restart"
|
|
728
|
-
else "not now: #{HOLDS.fetch(reason, reason.to_s.tr("_", " "))}"
|
|
729
|
-
end
|
|
730
|
-
end
|
|
731
|
-
end
|
|
732
|
-
|
|
733
|
-
# What a restart did: the worker's version before and after (nil when
|
|
734
|
-
# the new worker hadn't published its Bridge within the wait).
|
|
735
|
-
Restarted = Struct.new(:session_id, :from_version, :version, keyword_init: true)
|
|
736
|
-
|
|
737
|
-
RESTART_WAIT = 10.0
|
|
738
|
-
# A TERMed worker writes its recap on leave (an LLM call) while it still
|
|
739
|
-
# holds the lock: STOP_WAIT plus the recap's own wait (SessionContinue::RECAP_WAIT).
|
|
740
|
-
FALLBACK_STOP_WAIT = 40
|
|
741
|
-
|
|
742
|
-
# Hand a session to a new worker on the newest chi installed: ask its
|
|
743
|
-
# worker (POST /exit restart: true), which leaves only when nothing would
|
|
744
|
-
# be lost and starts its successor. If the worker cannot be asked, stop
|
|
745
|
-
# it without a stop marker and resume only when disk shows no work to lose.
|
|
746
|
-
# @return [Restarted]
|
|
747
|
-
# @raise [OwnedByTUI] a one-turn chi run owns the session
|
|
748
|
-
# @raise [RestartRefused]
|
|
749
|
-
# @raise [ArgumentError] no such session
|
|
750
|
-
def self.restart_session(session_id, state_dir: nil, wait: RESTART_WAIT, client_id: ClientId::CLI_RESTART)
|
|
751
|
-
sd = state_dir || Session.default_state_dir
|
|
752
|
-
Session.load(session_id, state_dir: sd) # ArgumentError for none
|
|
753
|
-
owner = refuse_tui!(session_id, state_dir: sd)
|
|
754
|
-
# A stopped worker answers neither the ask (READ_TIMEOUT) nor the
|
|
755
|
-
# fallback's TERM (FALLBACK_STOP_WAIT): say so now, not after ~70 s.
|
|
756
|
-
raise RestartRefused.new(session_id, :stopped, pid: owner.pid) if owner&.worker? && ProcessGroup.stopped?(owner.pid)
|
|
757
|
-
|
|
758
|
-
dir = Session.session_dir(session_id, state_dir: sd)
|
|
759
|
-
link = WorkerLink.resolve(dir, unlink: false, owner: owner)
|
|
760
|
-
if link.is_a?(WorkerLink::Skew)
|
|
761
|
-
return restart_fallback(session_id, owner: owner, sidecar: link.sidecar, state_dir: sd, wait: wait, why: :skew)
|
|
762
|
-
end
|
|
763
|
-
|
|
764
|
-
if link.is_a?(WorkerLink::Starting)
|
|
765
|
-
return restart_fallback(session_id, owner: owner, sidecar: nil, state_dir: sd, wait: wait, why: :starting_stuck) if link.stuck
|
|
766
|
-
|
|
767
|
-
raise RestartRefused.new(session_id, :starting)
|
|
768
|
-
end
|
|
769
|
-
raise RestartRefused.new(session_id, :not_running) unless link.live?
|
|
770
|
-
|
|
771
|
-
sidecar = link.sidecar
|
|
772
|
-
unless sidecar.features.include?("restart")
|
|
773
|
-
raise RestartRefused.new(session_id, :unsupported, version: sidecar.version)
|
|
774
|
-
end
|
|
775
|
-
|
|
776
|
-
begin
|
|
777
|
-
reply = BridgeClient.for_sidecar(session_id, sidecar).request_exit(client_id: client_id, restart: true)
|
|
778
|
-
rescue SystemCallError, IOError => e
|
|
779
|
-
return restart_fallback(session_id, owner: owner, sidecar: sidecar, state_dir: sd, wait: wait, why: :unreachable,
|
|
780
|
-
error: e)
|
|
781
|
-
end
|
|
782
|
-
raise RestartRefused.new(session_id, reply.json&.fetch("reason", nil) || :failed) if reply.status == 409
|
|
783
|
-
if reply.secret_refused?
|
|
784
|
-
return restart_fallback(session_id, owner: owner, sidecar: sidecar, state_dir: sd, wait: wait, why: :bridge_secret)
|
|
785
|
-
end
|
|
786
|
-
raise RestartRefused.new(session_id, :failed) unless reply.status == 200
|
|
787
|
-
|
|
788
|
-
Log.info(:worker, "restart", sid: session_id, from: sidecar.version, by: client_id)
|
|
789
|
-
fresh = WorkerLink.await(session_id, session_dir: dir, timeout: wait, unlink: false, interval: 0.1,
|
|
790
|
-
until: ->(live) { live.epoch != sidecar.epoch || live.started_at != sidecar.started_at })
|
|
791
|
-
# Timed out: the last outcome, the old worker's Live too, is no successor.
|
|
792
|
-
changed = fresh.live? && (fresh.epoch != sidecar.epoch || fresh.started_at != sidecar.started_at)
|
|
793
|
-
version = fresh.version if changed
|
|
794
|
-
Restarted.new(session_id: session_id, from_version: sidecar.version, version: version)
|
|
795
|
-
end
|
|
796
|
-
|
|
797
|
-
def self.restart_fallback(session_id, owner:, sidecar:, state_dir:, wait:, why:, error: nil)
|
|
798
|
-
Log.info(:worker, "restart_fallback", sid: session_id, why: why, error: error&.class&.name, msg: error&.message)
|
|
799
|
-
dir = Session.session_dir(session_id, state_dir: state_dir)
|
|
800
|
-
session = Session.load(session_id, state_dir: state_dir)
|
|
801
|
-
holds = restart_fallback_holds(session, dir)
|
|
802
|
-
raise RestartRefused.new(session_id, :fallback_held, holds: holds) unless holds.empty?
|
|
803
|
-
raise RestartRefused.new(session_id, :failed, version: sidecar&.version) unless (owner&.pid || 0).to_i.positive?
|
|
804
|
-
|
|
805
|
-
# The ask may have taken READ_TIMEOUT: TERM only the owner read at the
|
|
806
|
-
# start; a successor that took the lock meanwhile is awaited, not killed.
|
|
807
|
-
current = session_owner(session_id, state_dir: state_dir)
|
|
808
|
-
if current.nil?
|
|
809
|
-
resume_session(session_id, state_dir: state_dir)
|
|
810
|
-
elsif current.pid == owner.pid && current.started_at == owner.started_at
|
|
811
|
-
begin
|
|
812
|
-
Process.kill("TERM", owner.pid)
|
|
813
|
-
rescue Errno::ESRCH
|
|
814
|
-
nil
|
|
815
|
-
rescue SystemCallError => e
|
|
816
|
-
Log.warn(:worker, "restart_fallback_failed", sid: session_id, why: why, error: e.class.name, msg: e.message)
|
|
817
|
-
raise RestartRefused.new(session_id, :failed, version: sidecar&.version)
|
|
818
|
-
end
|
|
819
|
-
unless wait_for_owner_release(session_id, timeout: FALLBACK_STOP_WAIT, state_dir: state_dir)
|
|
820
|
-
raise RestartRefused.new(session_id, :failed, version: sidecar&.version)
|
|
821
|
-
end
|
|
822
|
-
|
|
823
|
-
resume_session(session_id, state_dir: state_dir)
|
|
824
|
-
end
|
|
825
|
-
previous_epoch = sidecar&.epoch
|
|
826
|
-
previous_started_at = sidecar&.started_at
|
|
827
|
-
fresh = WorkerLink.await(session_id, session_dir: dir, timeout: wait, unlink: false, interval: 0.1,
|
|
828
|
-
until: ->(live) { live.epoch != previous_epoch || live.started_at != previous_started_at })
|
|
829
|
-
changed = fresh.live? && (fresh.epoch != previous_epoch || fresh.started_at != previous_started_at)
|
|
830
|
-
Restarted.new(session_id: session_id, from_version: sidecar&.version, version: (fresh.version if changed))
|
|
831
|
-
end
|
|
832
|
-
private_class_method :restart_fallback
|
|
833
|
-
|
|
834
|
-
def self.restart_fallback_holds(session, session_dir)
|
|
835
|
-
holds = []
|
|
836
|
-
holds << :turn_running if session.status == Session::STATUS_RUNNING
|
|
837
|
-
holds << :question_pending if session.pending_question
|
|
838
|
-
holds << :input_queued unless SessionInbox.find_new_input_files(session_dir).empty?
|
|
839
|
-
holds << :background_tasks if running_created_task?(session)
|
|
840
|
-
holds
|
|
841
|
-
end
|
|
842
|
-
private_class_method :restart_fallback_holds
|
|
843
|
-
|
|
844
|
-
def self.running_created_task?(session)
|
|
845
|
-
root = session.working_directory.to_s
|
|
846
|
-
return false if root.empty?
|
|
847
|
-
|
|
848
|
-
Tools::TaskRuntime.created_ids_in(session.messages).any? do |id|
|
|
849
|
-
next false unless Tools::TaskRuntime::TASK_ID.match?(id)
|
|
850
|
-
|
|
851
|
-
path = File.join(root, Tools::TaskRuntime::TASKS_DIR, id, Tools::TaskRuntime::METADATA_FILENAME)
|
|
852
|
-
next false unless File.file?(path)
|
|
853
|
-
|
|
854
|
-
Tools::TaskRecord.from_file(JSON.parse(File.read(path)))&.running?
|
|
855
|
-
rescue JSON::ParserError, SystemCallError => e
|
|
856
|
-
Log.warn(:worker, "restart_task_check_failed", sid: session.id, task_id: id,
|
|
857
|
-
error: e.class.name, msg: e.message)
|
|
858
|
-
true
|
|
859
|
-
end
|
|
860
|
-
end
|
|
861
|
-
private_class_method :running_created_task?
|
|
862
|
-
|
|
863
|
-
# Stop a session by sending TERM (then CONT, for a stopped one) to its process.
|
|
864
|
-
# With +wait+ (seconds), also wait for the owner to let go of the session,
|
|
865
|
-
# so a resume right after spawns a fresh worker instead of finding the
|
|
866
|
-
# dying one.
|
|
867
|
-
# A turn still running is canceled first (STOP_CANCEL_WAIT), as the
|
|
868
|
-
# web's Cancel does, with reason STOP_CANCEL_REASON: its worker saves it. The TERM stops (and saves)
|
|
869
|
-
# what still runs (WorkerSignals), with a short grace after the marker.
|
|
870
|
-
# @return [Boolean, nil] with +wait+: whether the owner was gone in time
|
|
871
|
-
# @raise [OwnedByTUI] when the interactive TUI owns the session
|
|
872
|
-
def self.stop_session(session_id, state_dir: nil, wait: nil)
|
|
873
|
-
sd = state_dir || Session.default_state_dir
|
|
874
|
-
owner = refuse_tui!(session_id, state_dir: sd)
|
|
875
|
-
|
|
876
|
-
cancel_running_turn(session_id, state_dir: sd)
|
|
877
|
-
# Mark first: a worker that has not taken the lock yet sees it and exits.
|
|
878
|
-
Session.mark_stopped(session_id, state_dir: sd)
|
|
879
|
-
# A stop drops the reminders: a restart's hand-off no successor took
|
|
880
|
-
# yet must not bring them back on the next resume.
|
|
881
|
-
ReminderCarry.remove(Session.session_dir(session_id, state_dir: sd))
|
|
882
|
-
pid = owner&.pid.to_i
|
|
883
|
-
begin
|
|
884
|
-
Process.kill("TERM", pid) if pid && pid > 0
|
|
885
|
-
rescue Errno::ESRCH
|
|
886
|
-
nil # already exited
|
|
887
|
-
end
|
|
888
|
-
continue_stopped(pid)
|
|
889
|
-
wait_for_owner_release(session_id, timeout: wait, state_dir: sd) if wait
|
|
890
|
-
end
|
|
891
|
-
|
|
892
|
-
# A stopped worker (SIGSTOP, Ctrl-Z) runs its TERM handler only once
|
|
893
|
-
# continued: CONT it after the TERM (as ProcessGroup.stop does), so it
|
|
894
|
-
# leaves now rather than outliving the stop's wait. Best effort: one the
|
|
895
|
-
# TERM ended may be gone already.
|
|
896
|
-
private_class_method def self.continue_stopped(pid)
|
|
897
|
-
Process.kill("CONT", pid) if pid && pid > 0
|
|
898
|
-
rescue Errno::ESRCH, Errno::EPERM
|
|
899
|
-
nil
|
|
900
|
-
end
|
|
901
|
-
|
|
902
|
-
STOP_CANCEL_WAIT = 3.0
|
|
903
|
-
# How long a stop from the CLI (chi sessions stop, delete, a continue's
|
|
904
|
-
# orphan) waits for the worker to let go of the session, here or
|
|
905
|
-
# through chi web (POST …/stop's wait, capped at it).
|
|
906
|
-
STOP_WAIT = 10
|
|
907
|
-
# The cancel reason of a turn a session stop cancels (Bridge::CANCEL_REASONS):
|
|
908
|
-
# not the user's Stop, which only ends the turn ("user").
|
|
909
|
-
STOP_CANCEL_REASON = "session_stopped"
|
|
910
|
-
|
|
911
|
-
# Cancel the session's running turn over its Bridge and wait (up to
|
|
912
|
-
# STOP_CANCEL_WAIT) for the worker to save it. Best effort: without a
|
|
913
|
-
# live Bridge, or when it doesn't answer, the stop goes on as before.
|
|
914
|
-
private_class_method def self.cancel_running_turn(session_id, state_dir:)
|
|
915
|
-
return unless Session.load(session_id, state_dir: state_dir).status == Session::STATUS_RUNNING
|
|
916
|
-
|
|
917
|
-
client = WorkerLink.client(session_id, session_dir: Session.session_dir(session_id, state_dir: state_dir))
|
|
918
|
-
return unless client && client.cancel(reason: STOP_CANCEL_REASON).status == 202
|
|
919
|
-
|
|
920
|
-
BridgeClient.poll(STOP_CANCEL_WAIT) do
|
|
921
|
-
Session.load(session_id, state_dir: state_dir).status != Session::STATUS_RUNNING
|
|
922
|
-
end
|
|
923
|
-
rescue StandardError => e
|
|
924
|
-
Log.info(:worker, "stop_cancel_failed", sid: session_id, error: e.class.name, msg: e.message)
|
|
925
|
-
nil
|
|
926
|
-
end
|
|
927
|
-
|
|
928
|
-
# What a session's directory holds before anything happened in it. Any
|
|
929
|
-
# other entry (or a file in one of the EMPTY_DIRS) is something the
|
|
930
|
-
# session keeps. "pid" is the pid file workers wrote until 2026-10-01:
|
|
931
|
-
# nothing writes or reads it now, but a folder from before still has one
|
|
932
|
-
# and is still empty.
|
|
933
|
-
EMPTY_SKELETON_FILES = ["pid", "owner.lock", WorkerLink::FILE, AnalyticsFile::NAME].freeze
|
|
934
|
-
EMPTY_DIRS = [SessionInbox::INPUT_DIR, SessionInbox::NOTES_DIR, "images"].freeze
|
|
935
|
-
EMPTY_SKELETON_DIRS = (EMPTY_DIRS + [SessionInbox::OUTPUT_DIR]).freeze
|
|
936
|
-
|
|
937
|
-
# session.keep_empty off: sessions left with nothing in them are deleted
|
|
938
|
-
# (the worker as it exits, the retention sweep).
|
|
939
|
-
def self.discard_empty?
|
|
940
|
-
Degrade.call(:config, "keep_empty_unreadable", fallback: false) { Samagotchi::Config.get("session.keep_empty") != true }
|
|
941
|
-
end
|
|
942
|
-
|
|
943
|
-
# Whether a session being left (or found left) is deleted: session.keep_empty
|
|
944
|
-
# is off and nothing happened in it (#empty_session?). The worker as it
|
|
945
|
-
# leaves and the retention sweep both ask this.
|
|
946
|
-
# @param default_model [String, nil] what a new session starts on
|
|
947
|
-
# @param used_memory_names [Array<String>] memory its owner's Engine used
|
|
948
|
-
# before any save
|
|
949
|
-
def self.discardable?(session_id, default_model:, state_dir: nil, used_memory_names: [])
|
|
950
|
-
return false unless discard_empty? && Array(used_memory_names).empty?
|
|
951
|
-
|
|
952
|
-
empty_session?(session_id, state_dir: state_dir || Session.default_state_dir, default_model: default_model)
|
|
953
|
-
end
|
|
954
|
-
|
|
955
|
-
# A session nothing happened in: no conversation, no turn tried (a failed
|
|
956
|
-
# one leaves no messages but a last_prompt and analytics turns), nothing
|
|
957
|
-
# queued or attached, and the model and mode a new session gets. A
|
|
958
|
-
# session prepared for later (/model, --model, a note, an image, memory)
|
|
959
|
-
# is not empty. Anything unreadable or unknown counts as not empty.
|
|
960
|
-
# @param default_model [String, nil] what a new session starts on
|
|
961
|
-
def self.empty_session?(session_id, state_dir: nil, default_model: nil)
|
|
962
|
-
sd = state_dir || Session.default_state_dir
|
|
963
|
-
session = Session.load(session_id, state_dir: sd)
|
|
964
|
-
return false unless no_conversation?(session.messages) && session.pending_question.nil? && session.used_memory_names.empty?
|
|
965
|
-
return false unless session.last_prompt.to_s.strip.empty? && session.first_preview.to_s.strip.empty?
|
|
966
|
-
return false unless session.mode.to_s == "assist" && !default_model.nil? && same_model?(session.model_name, default_model)
|
|
967
|
-
|
|
968
|
-
empty_session_dir?(Session.session_dir(session_id, state_dir: sd))
|
|
969
|
-
rescue ArgumentError, SystemCallError, JSON::ParserError
|
|
970
|
-
false
|
|
971
|
-
end
|
|
972
|
-
|
|
973
|
-
# Whether two model names resolve to the same ref (ModelRef#ref): a
|
|
974
|
-
# session stores the resolved ref, the default may be an alias.
|
|
975
|
-
def self.same_model?(one, other)
|
|
976
|
-
return true if one.to_s == other.to_s
|
|
977
|
-
return false if one.nil? || other.nil?
|
|
978
|
-
|
|
979
|
-
Degrade.call(:config, "model_ref_unresolved", fallback: false) do
|
|
980
|
-
ConfigFile.model_ref(one).ref == ConfigFile.model_ref(other).ref
|
|
981
|
-
end
|
|
982
|
-
end
|
|
983
|
-
|
|
984
|
-
# Only the system prompt (an Engine seeds one, a saved session keeps
|
|
985
|
-
# it); a context note is a system message too, but with its kind.
|
|
986
|
-
# A turn note (the tail system message after a failed, cancelled or empty
|
|
987
|
-
# turn) is not a conversation either: a session whose only turn failed
|
|
988
|
-
# before any answer is still empty.
|
|
989
|
-
def self.no_conversation?(messages)
|
|
990
|
-
Array(messages).all? do |msg|
|
|
991
|
-
(msg[:role] || msg["role"]).to_s == "system" &&
|
|
992
|
-
((msg[:kind] || msg["kind"]).nil? || TurnNote.note?(msg))
|
|
993
|
-
end
|
|
994
|
-
end
|
|
995
|
-
|
|
996
|
-
# @return [Boolean] whether a session's directory holds only the skeleton
|
|
997
|
-
def self.empty_session_dir?(dir)
|
|
998
|
-
return true unless Dir.exist?(dir)
|
|
999
|
-
|
|
1000
|
-
Dir.children(dir).all? do |name|
|
|
1001
|
-
path = File.join(dir, name)
|
|
1002
|
-
if EMPTY_SKELETON_DIRS.include?(name)
|
|
1003
|
-
File.directory?(path) && (!EMPTY_DIRS.include?(name) || Dir.empty?(path))
|
|
1004
|
-
elsif name == AnalyticsFile::NAME
|
|
1005
|
-
analytics_empty?(path)
|
|
1006
|
-
# Cards and notices from before any turn (Bridge::CardStore).
|
|
1007
|
-
elsif name == "cards.json"
|
|
1008
|
-
cards_empty?(path)
|
|
1009
|
-
else
|
|
1010
|
-
EMPTY_SKELETON_FILES.include?(name)
|
|
1011
|
-
end
|
|
1012
|
-
end
|
|
1013
|
-
end
|
|
1014
|
-
|
|
1015
|
-
# A folder's analytics.json is empty only when it is a JSON object with
|
|
1016
|
-
# an empty turn_records array. Anything unreadable, not JSON, not an
|
|
1017
|
-
# object, the wrong type, or turn_records the wrong shape reads as
|
|
1018
|
-
# "not empty": a broken analytics keeps the folder instead of crashing
|
|
1019
|
-
# the retention sweep (or of a wrong-shape one reading as empty).
|
|
1020
|
-
def self.analytics_empty?(path)
|
|
1021
|
-
data = read_json(path)
|
|
1022
|
-
turns = data.is_a?(Hash) ? data["turn_records"] : nil
|
|
1023
|
-
turns.is_a?(Array) && turns.empty?
|
|
1024
|
-
end
|
|
1025
|
-
|
|
1026
|
-
# A folder's cards.json is empty only when it is a JSON object with no
|
|
1027
|
-
# turn in it: an empty turn_log, or an older file's (Bridge::CardStore
|
|
1028
|
-
# version 1) turns an integer zero. Anything unreadable, not JSON, not an
|
|
1029
|
-
# object, the wrong type, or neither of those reads as "not empty": a
|
|
1030
|
-
# broken or wrong-shape cards file keeps the folder instead of raising
|
|
1031
|
-
# (Array#/Hash#to_i) or reading as empty.
|
|
1032
|
-
def self.cards_empty?(path)
|
|
1033
|
-
data = read_json(path)
|
|
1034
|
-
return false unless data.is_a?(Hash)
|
|
1035
|
-
|
|
1036
|
-
turns = data["turns"]
|
|
1037
|
-
data["turn_log"] == [] || (turns.is_a?(Integer) && turns.zero?)
|
|
1038
|
-
end
|
|
1039
|
-
|
|
1040
|
-
# A session-dir JSON file's contents, or nil when it is missing,
|
|
1041
|
-
# unreadable, not JSON or not an object (mirrors AnalyticsFile#read): a
|
|
1042
|
-
# broken file reads as none, so its folder is kept, not deleted.
|
|
1043
|
-
def self.read_json(path)
|
|
1044
|
-
JSON.parse(File.read(path))
|
|
1045
|
-
rescue JSON::ParserError, SystemCallError
|
|
1046
|
-
nil
|
|
1047
|
-
end
|
|
1048
|
-
|
|
1049
|
-
# Delete one session: its <id>.json, the whole <id>/ directory
|
|
1050
|
-
# (history sidecars, input, output, notes, images) and its attached
|
|
1051
|
-
# context (ContextSources: its sources, subscriptions, mute markers). The CLI, the TUI's
|
|
1052
|
-
# /exit --delete and the web all come here.
|
|
1053
|
-
# @param id_or_prefix [String] a session id or a unique prefix of one
|
|
1054
|
-
# @param stop [Boolean] stop a live worker first (waits +wait+ seconds)
|
|
1055
|
-
# @return [Hash] {id:, removed: [paths], stopped: whether a worker was stopped}
|
|
1056
|
-
# @raise [ArgumentError] unknown id (Session::AmbiguousId for a prefix of several)
|
|
1057
|
-
# @raise [OwnedByTUI] a one-turn chi run owns it (never stopped from here)
|
|
1058
|
-
# @raise [DeleteRefused] a worker owns it and +stop+ is false, or it
|
|
1059
|
-
# outlived the wait
|
|
1060
|
-
# @param purge_runs [Boolean] also delete System 1's saved runs of it and
|
|
1061
|
-
# its candidate-log lines; a session a worker left empty has none worth
|
|
1062
|
-
# the read
|
|
1063
|
-
def self.delete_session(id_or_prefix, state_dir: nil, stop: false, wait: STOP_WAIT, purge_runs: true)
|
|
1064
|
-
sd = state_dir || Session.default_state_dir
|
|
1065
|
-
given = id_or_prefix.to_s
|
|
1066
|
-
# Only a plain id: anything else could name a path outside the state dir.
|
|
1067
|
-
raise ArgumentError, "no session #{given}" unless Session.valid_id?(given)
|
|
1068
|
-
|
|
1069
|
-
id = Session.resolve_id(given, state_dir: sd)
|
|
1070
|
-
path = Session.session_file(id, state_dir: sd)
|
|
1071
|
-
dir = Session.session_dir(id, state_dir: sd)
|
|
1072
|
-
raise ArgumentError, "no session #{given}" unless File.exist?(path) || Dir.exist?(dir)
|
|
1073
|
-
|
|
1074
|
-
owner = refuse_tui!(id, state_dir: sd)
|
|
1075
|
-
if owner
|
|
1076
|
-
raise DeleteRefused.new(id, :worker_running) unless stop
|
|
1077
|
-
raise DeleteRefused.new(id, :still_stopping) unless stop_session(id, state_dir: sd, wait: wait)
|
|
1078
|
-
end
|
|
1079
|
-
|
|
1080
|
-
removed = []
|
|
1081
|
-
if File.exist?(path)
|
|
1082
|
-
FileUtils.rm_f(path)
|
|
1083
|
-
removed << path
|
|
1084
|
-
end
|
|
1085
|
-
if Dir.exist?(dir)
|
|
1086
|
-
FileUtils.rm_rf(dir)
|
|
1087
|
-
removed << dir
|
|
1088
|
-
end
|
|
1089
|
-
PluginSessionState.remove(id)
|
|
1090
|
-
removed.concat(ContextSources.remove_session(id, state_dir: sd))
|
|
1091
|
-
Participants.end_sessions([id], by: "delete", state_dir: sd)
|
|
1092
|
-
if purge_runs
|
|
1093
|
-
purge_session_runs(id, sd)
|
|
1094
|
-
ContextCandidatesLog.purge_session(id, state_dir: sd)
|
|
1095
|
-
end
|
|
1096
|
-
{ id: id, removed: removed, stopped: !owner.nil? }
|
|
1097
|
-
end
|
|
1098
|
-
|
|
1099
|
-
# Never a reason a delete fails: one warn, and the sweep ages them out.
|
|
1100
|
-
private_class_method def self.purge_session_runs(id, state_dir)
|
|
1101
|
-
SystemOne::Runs.purge_session(id, sessions_dir: state_dir)
|
|
1102
|
-
rescue StandardError => e
|
|
1103
|
-
Log.warn(:worker, "session_runs_purge_failed", sid: id, error: e.class.name, msg: e.message.to_s[0, 200])
|
|
1104
|
-
end
|
|
1105
|
-
|
|
1106
|
-
# @return [Boolean] whether the session had no owner within +timeout+ seconds
|
|
1107
|
-
def self.wait_for_owner_release(session_id, timeout:, state_dir:)
|
|
1108
|
-
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout.to_f
|
|
1109
|
-
while session_owner(session_id, state_dir: state_dir)
|
|
1110
|
-
return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
1111
|
-
|
|
1112
|
-
sleep(OwnerLock::RETRY_INTERVAL)
|
|
1113
|
-
end
|
|
1114
|
-
true
|
|
1115
|
-
end
|
|
1116
|
-
|
|
1117
|
-
# Run the session loop inside the forked process.
|
|
1118
|
-
# This is the entry point called by Process.spawn.
|
|
1119
|
-
#
|
|
1120
|
-
# Every worker starts its per-session Bridge (the single live client
|
|
1121
|
-
# transport) on a port bound to 127.0.0.1 before the loop and stops it on
|
|
1122
|
-
# exit. If the bridge fails to start the worker degrades: turns still flow
|
|
1123
|
-
# through the input-dir loop, but there is no live SSE or in-process
|
|
1124
|
-
# cancel/answer.
|
|
1125
|
-
#
|
|
1126
|
-
# The worker first takes the session's OwnerLock; when another owner holds
|
|
1127
|
-
# it (a racing resume spawned two workers, or the TUI has the session) it
|
|
1128
|
-
# exits quietly.
|
|
1129
|
-
#
|
|
1130
|
-
# A worker nobody uses returns once session.idle_exit_minutes have passed
|
|
1131
|
-
# (see WorkerIdleExit), and one a client asked to exit (Bridge POST /exit)
|
|
1132
|
-
# returns as soon as nothing keeps it. The next send wakes a new one.
|
|
1133
|
-
# @param idle_exit_minutes [Numeric, nil] nil: session.idle_exit_minutes
|
|
1134
|
-
# @param poll_interval [Numeric, nil] seconds between the loop's fallback
|
|
1135
|
-
# ticks (nil: Worker::FALLBACK_TICK_SECONDS); queued turns wake it at once
|
|
1136
|
-
# A stopped session's worker exits 0 (a signalled one too) and a
|
|
1137
|
-
# crashed one 1, there.
|
|
1138
|
-
# @return [Symbol] :idle_exit, :exit_requested, :restart or :scratch_left
|
|
1139
|
-
def self.run_session_loop(session_id, state_dir: nil, owner_wait: OwnerLock::DEFAULT_WAIT,
|
|
1140
|
-
idle_exit_minutes: nil, poll_interval: nil)
|
|
1141
|
-
# A worker inherits chi's locale (LC_ALL=C too): read files as UTF-8.
|
|
1142
|
-
Utf8Default.apply!
|
|
1143
|
-
sd = state_dir || Session.default_state_dir
|
|
1144
|
-
# The spawner passed the file and level through ENV; a worker's stderr
|
|
1145
|
-
# is /dev/null, so warnings only reach the file.
|
|
1146
|
-
Log.configure(stderr: false)
|
|
1147
|
-
Log.session_id = session_id
|
|
1148
|
-
session_dir = Session.session_dir(session_id, state_dir: sd)
|
|
1149
|
-
# Read (and taken out of ENV, so nothing the worker runs inherits it)
|
|
1150
|
-
# before anything else: set by spawn_options for a spawned worker.
|
|
1151
|
-
spawned = ENV.delete(WORKER_PROCESS_ENV) == "1"
|
|
1152
|
-
# Out of the spawner's process tree before the lock (it records this
|
|
1153
|
-
# process's pid) and before any thread starts.
|
|
1154
|
-
detach_from_spawner(session_dir) if ENV.delete(WORKER_DETACH_ENV) == "1"
|
|
1155
|
-
# Kept in a class ivar so the lock's File lives as long as the worker.
|
|
1156
|
-
@owner_lock = OwnerLock.acquire(session_dir, kind: "worker", wait: owner_wait)
|
|
1157
|
-
exit(0) unless @owner_lock
|
|
1158
|
-
# Set by the boot string when the newest installed chi didn't load.
|
|
1159
|
-
fallback = ENV.delete(BOOT_FALLBACK_ENV)
|
|
1160
|
-
Log.info(:worker, "start", cwd: Dir.pwd, version: VERSION)
|
|
1161
|
-
Log.warn(:worker, "boot_fallback", version: VERSION, error: fallback) if fallback
|
|
1162
|
-
# Tools follow the project (its `mise env`, computed once per worker;
|
|
1163
|
-
# this process's ENV is left as it is); never in a loop run in-process.
|
|
1164
|
-
apply_project_env(session_id, state_dir: sd) if spawned
|
|
1165
|
-
# A spawned worker stops its turn on TERM/HUP/INT and saves it; a
|
|
1166
|
-
# loop run in-process (specs) traps nothing.
|
|
1167
|
-
signals = spawned ? WorkerSignals.new.install! : WorkerSignals::NONE
|
|
1168
|
-
worker = Worker.new(session_id: session_id, state_dir: sd, session_dir: session_dir,
|
|
1169
|
-
idle_exit_minutes: idle_exit_minutes, poll_interval: poll_interval, signals: signals,
|
|
1170
|
-
owner_lock: @owner_lock)
|
|
1171
|
-
result = begin
|
|
1172
|
-
worker.run
|
|
1173
|
-
rescue StandardError, ScriptError => e
|
|
1174
|
-
# The worker's stderr is /dev/null: the log is the only trace.
|
|
1175
|
-
Log.exception(:worker, "crashed", e)
|
|
1176
|
-
raise
|
|
1177
|
-
ensure
|
|
1178
|
-
@owner_lock.release
|
|
1179
|
-
# A signal from here on ends the process as Ruby's default does.
|
|
1180
|
-
signals.restore!
|
|
1181
|
-
end
|
|
1182
|
-
Log.info(:worker, "stop", reason: result)
|
|
1183
|
-
# A scratch session goes with its worker however it leaves (its
|
|
1184
|
-
# terminal deletes it too, whichever comes first); nothing resumes it.
|
|
1185
|
-
if worker.scratch?
|
|
1186
|
-
delete_left_scratch(session_id, state_dir: sd)
|
|
1187
|
-
exit(result == :crashed ? 1 : 0) if %i[stopped signal crashed].include?(result)
|
|
1188
|
-
return result
|
|
1189
|
-
end
|
|
1190
|
-
# A stopped session is left as it is: no resume for input that came
|
|
1191
|
-
# in, no discard. One whose worker a signal stopped isn't discarded
|
|
1192
|
-
# either; input that came in (queued behind the turn it stopped)
|
|
1193
|
-
# wakes a new worker, as after an idle exit.
|
|
1194
|
-
exit(0) if result == :stopped
|
|
1195
|
-
if result == :signal
|
|
1196
|
-
resume_session(session_id, state_dir: sd) unless SessionInbox.find_new_input_files(session_dir).empty?
|
|
1197
|
-
exit(0)
|
|
1198
|
-
end
|
|
1199
|
-
exit(1) if result == :crashed
|
|
1200
|
-
# Only after the release: a writer that still saw this worker as the
|
|
1201
|
-
# owner may have queued input since the last check. Either it finds no
|
|
1202
|
-
# owner after its write and wakes one, or this finds its input.
|
|
1203
|
-
# A restart starts its successor here, on the newest chi installed
|
|
1204
|
-
# (worker_command), once this one's lock is free.
|
|
1205
|
-
# Delegate reports that rang while it left wake one too.
|
|
1206
|
-
if result == :restart && Session.stopped_marker?(session_id, state_dir: sd)
|
|
1207
|
-
# A stop landed as it left: no successor (resume_session would clear
|
|
1208
|
-
# the stop), and the reminders go with it.
|
|
1209
|
-
ReminderCarry.remove(session_dir)
|
|
1210
|
-
Log.info(:worker, "restart_dropped", why: :stopped)
|
|
1211
|
-
elsif result == :restart ||
|
|
1212
|
-
(%i[idle_exit exit_requested].include?(result) &&
|
|
1213
|
-
(!SessionInbox.find_new_input_files(session_dir).empty? || worker.wake_due?))
|
|
1214
|
-
resume_session(session_id, state_dir: sd)
|
|
1215
|
-
elsif worker.discard?
|
|
1216
|
-
discard_left_session(session_id, state_dir: sd, default_model: worker.default_model)
|
|
1217
|
-
end
|
|
1218
|
-
result
|
|
1219
|
-
end
|
|
1220
|
-
|
|
1221
|
-
# A spawned worker leaves its spawner's process tree: a killer that
|
|
1222
|
-
# walks the tree by parent pid (a terminal closing, a tool's teardown
|
|
1223
|
-
# of a `chi send --wait` it ran in the background) no longer reaches it.
|
|
1224
|
-
# This process (the spawner's child, which its Process.detach waits on)
|
|
1225
|
-
# forks the worker, which starts a session of its own (setsid: no
|
|
1226
|
-
# controlling terminal, no SIGHUP from one) and returns to run the loop;
|
|
1227
|
-
# this one waits until the worker holds the session's OwnerLock, or has
|
|
1228
|
-
# exited, or WORKER_START_GRACE passed, and exits: the spawner's waiter
|
|
1229
|
-
# stays alive exactly while the worker starts (#still_starting?). The
|
|
1230
|
-
# worker is reparented to init (launchd), which reaps it.
|
|
1231
|
-
private_class_method def self.detach_from_spawner(session_dir)
|
|
1232
|
-
worker = Process.fork
|
|
1233
|
-
unless worker
|
|
1234
|
-
Process.setsid
|
|
1235
|
-
return
|
|
1236
|
-
end
|
|
1237
|
-
|
|
1238
|
-
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + WORKER_START_GRACE
|
|
1239
|
-
until Process.waitpid(worker, Process::WNOHANG) || OwnerLock.owner(session_dir)&.pid == worker ||
|
|
1240
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
1241
|
-
sleep(OwnerLock::RETRY_INTERVAL)
|
|
1242
|
-
end
|
|
1243
|
-
Process.exit!(0)
|
|
1244
|
-
end
|
|
1245
|
-
|
|
1246
|
-
# Delete a scratch session its worker left (its terminal gone:
|
|
1247
|
-
# Worker#leave_scratch, or any other way out), now the lock is free.
|
|
1248
|
-
# Its terminal may have deleted it first.
|
|
1249
|
-
private_class_method def self.delete_left_scratch(session_id, state_dir:)
|
|
1250
|
-
delete_session(session_id, state_dir: state_dir)
|
|
1251
|
-
Log.info(:worker, "scratch_deleted", sid: session_id)
|
|
1252
|
-
rescue DeleteRefused, ArgumentError, SystemCallError => e
|
|
1253
|
-
Log.info(:worker, "scratch_kept", sid: session_id, error: e.class.name, msg: e.message)
|
|
1254
|
-
end
|
|
1255
|
-
|
|
1256
|
-
# Delete a session its worker left empty. Checked again now the lock is
|
|
1257
|
-
# free: a note or input may have come in since the worker looked, and a
|
|
1258
|
-
# worker woken meanwhile owns it (delete_session refuses). A `chi send`
|
|
1259
|
-
# landing between this check and the delete fails with "no session"
|
|
1260
|
-
# (a window of an empty session's last moments, left as is).
|
|
1261
|
-
private_class_method def self.discard_left_session(session_id, state_dir:, default_model:)
|
|
1262
|
-
return unless discardable?(session_id, state_dir: state_dir, default_model: default_model)
|
|
1263
|
-
|
|
1264
|
-
delete_session(session_id, state_dir: state_dir, purge_runs: false)
|
|
1265
|
-
Log.info(:worker, "discarded_empty", sid: session_id)
|
|
1266
|
-
rescue DeleteRefused, OwnedByTUI, ArgumentError, SystemCallError => e
|
|
1267
|
-
Log.info(:worker, "kept_session", sid: session_id, error: e.class.name, msg: e.message)
|
|
1268
|
-
end
|
|
1269
|
-
|
|
1270
|
-
# The tools' ProjectEnv for the session's folder (its working_directory;
|
|
1271
|
-
# spawn_options chdirs there unless it is gone), installed for this
|
|
1272
|
-
# process's tool spawns. Never fatal: a worker without it still runs.
|
|
1273
|
-
private_class_method def self.apply_project_env(session_id, state_dir:)
|
|
1274
|
-
session = Session.summary_from_file(Session.session_file(session_id, state_dir: state_dir))
|
|
1275
|
-
dir = session&.working_directory.to_s
|
|
1276
|
-
overlay = ProjectEnv.install!(ProjectEnv.compute(dir))
|
|
1277
|
-
Log.info(:worker, "project_env_applied", dir: dir, ruby: overlay.ruby) if overlay.applied
|
|
1278
|
-
rescue StandardError => e
|
|
1279
|
-
Log.warn(:worker, "project_env", reason: "error", error: e.class.name, msg: e.message)
|
|
1280
|
-
end
|
|
1281
|
-
|
|
1282
|
-
def self.config_idle_exit_minutes
|
|
1283
|
-
Degrade.call(:config, "idle_exit_minutes_unreadable") { Samagotchi::Config.get("session.idle_exit_minutes") }
|
|
1284
|
-
end
|
|
1285
|
-
|
|
1286
|
-
private_class_method def self.setup_session_directory(session_dir, session, state_dir:)
|
|
1287
|
-
FileUtils.mkdir_p(session_dir)
|
|
1288
|
-
FileUtils.mkdir_p(File.join(session_dir, SessionInbox::INPUT_DIR))
|
|
1289
|
-
FileUtils.mkdir_p(File.join(session_dir, SessionInbox::OUTPUT_DIR))
|
|
1290
|
-
session.save(state_dir: state_dir)
|
|
1291
|
-
end
|
|
1292
|
-
|
|
1293
|
-
# The worker's command line. Run from an installed gem, the worker
|
|
1294
|
-
# activates the newest installed samagotchi (`>= 0.a`, prereleases
|
|
1295
|
-
# count), whoever spawns it: an old chi web, chi send or --attach then
|
|
1296
|
-
# starts new workers on the newest chi rather than on its own. Activating
|
|
1297
|
-
# the gem also makes its dependencies resolve as its gemspec pins them
|
|
1298
|
-
# (reline ~> 0.6.3). Should that fail (a broken newest install), the
|
|
1299
|
-
# worker falls back to the spawner's own version and logs why.
|
|
1300
|
-
#
|
|
1301
|
-
# A source checkout (bin/chi, bundle exec; InstalledGem.spec is nil
|
|
1302
|
-
# there) keeps the plain -I lib: a worktree's bin/chi spawns its own code.
|
|
1303
|
-
#
|
|
1304
|
-
# CROSS-VERSION CONTRACT: this boot string, run_session_loop(id,
|
|
1305
|
-
# state_dir:), the env keys spawn_options sets and the input/ file
|
|
1306
|
-
# format are read by a NEWER chi than the one writing them. Change them
|
|
1307
|
-
# only additively (spec/worker_contract_spec.rb).
|
|
1308
|
-
BOOT_FALLBACK_ENV = "SAMAGOTCHI_BOOT_FALLBACK"
|
|
1309
|
-
# "1" in a worker spawn_options started: it traps signals (WorkerSignals).
|
|
1310
|
-
# run_session_loop deletes it from ENV as it reads it.
|
|
1311
|
-
WORKER_PROCESS_ENV = "SAMAGOTCHI_WORKER_PROCESS"
|
|
1312
|
-
# "1" in a worker spawn_options started: run_session_loop leaves the
|
|
1313
|
-
# spawner's process tree first (detach_from_spawner); deleted from ENV
|
|
1314
|
-
# as it is read, so an rspec run by the worker's execute never forks.
|
|
1315
|
-
WORKER_DETACH_ENV = "SAMAGOTCHI_WORKER_DETACH"
|
|
1316
|
-
|
|
1317
|
-
# The boot string's rescue under a chi home: one line in the spawner's
|
|
1318
|
-
# log file (SAMAGOTCHI_LOG_FILE, set by spawn_options), LogLine's shape,
|
|
1319
|
-
# then exit. Single-quoted: the #{} run in the worker. SID is replaced.
|
|
1320
|
-
# rubocop:disable-next Lint/InterpolationCheck -- the worker interpolates these, not this process
|
|
1321
|
-
HOME_BOOT_FAILED = 'rescue ScriptError, StandardError => e; f = ENV["SAMAGOTCHI_LOG_FILE"].to_s; begin; ' \
|
|
1322
|
-
'File.open(f, "a") { |io| io.puts(Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ") + ' \
|
|
1323
|
-
'" ERROR worker pid=#{Process.pid} sid=SID boot_failed ruby=#{RbConfig.ruby.inspect} error=" + ' \
|
|
1324
|
-
"e.message.lines.first.to_s.strip.inspect) } unless f.empty?; rescue SystemCallError; end; exit 1"
|
|
1325
|
-
|
|
1326
|
-
def self.worker_command(session_id, state_dir:, gem_spec: InstalledGem.spec,
|
|
1327
|
-
home: ChiHome.current(gem_spec: gem_spec))
|
|
1328
|
-
boot = "require 'samagotchi/session_manager'; " \
|
|
1329
|
-
"Samagotchi::SessionManager.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})"
|
|
1330
|
-
return [RbConfig.ruby, "-I", File.expand_path("..", __dir__), "-e", boot] unless gem_spec
|
|
1331
|
-
return home_worker_command(session_id, state_dir: state_dir, gem_spec: gem_spec, home: home) if home
|
|
1332
|
-
|
|
1333
|
-
activate = "begin; gem 'samagotchi', '>= 0.a'; rescue LoadError => e; " \
|
|
1334
|
-
"ENV['#{BOOT_FALLBACK_ENV}'] = e.message; gem 'samagotchi', '= #{gem_spec.version}'; end"
|
|
1335
|
-
[RbConfig.ruby, "-e", "#{activate}; #{boot}"]
|
|
1336
|
-
end
|
|
1337
|
-
|
|
1338
|
-
# With a chi home (ChiHome.current, docs/architecture.md "How chi
|
|
1339
|
-
# launches itself"): the home's ruby, `Gem.paths` set to its gems in the
|
|
1340
|
-
# boot string (no GEM_* in the worker's env), the newest chi there, else
|
|
1341
|
-
# the fallback pin (ChiHome.fallback_version). A home that still can't
|
|
1342
|
-
# load chi (a broken install) logs HOME_BOOT_FAILED and exits: never a
|
|
1343
|
-
# silent spawn timeout.
|
|
1344
|
-
private_class_method def self.home_worker_command(session_id, state_dir:, gem_spec:, home:)
|
|
1345
|
-
newest = home.newest_version
|
|
1346
|
-
if newest && newest < Gem::Version.new(gem_spec.version.to_s)
|
|
1347
|
-
ChiHome.warn_once(:older_than_spawner, home: newest.to_s, spawner: gem_spec.version.to_s)
|
|
1348
|
-
end
|
|
1349
|
-
fallback = ChiHome.fallback_version(home, gem_spec)
|
|
1350
|
-
activate = if fallback
|
|
1351
|
-
"begin; gem 'samagotchi', '>= 0.a'; rescue LoadError => e; " \
|
|
1352
|
-
"ENV['#{BOOT_FALLBACK_ENV}'] = e.message; gem 'samagotchi', '= #{fallback}'; end"
|
|
1353
|
-
else
|
|
1354
|
-
"gem 'samagotchi', '>= 0.a'"
|
|
1355
|
-
end
|
|
1356
|
-
home.boot_argv(run: "begin; #{activate}; require 'samagotchi/session_manager'; " \
|
|
1357
|
-
"#{HOME_BOOT_FAILED.sub("SID", session_id)}; end; " \
|
|
1358
|
-
"Samagotchi::SessionManager.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})")
|
|
1359
|
-
end
|
|
1360
|
-
|
|
1361
|
-
private_class_method def self.spawn_worker_for_session(session, state_dir:)
|
|
1362
|
-
session_dir = Session.session_dir(session.id, state_dir: state_dir)
|
|
1363
|
-
FileUtils.mkdir_p(session_dir)
|
|
1364
|
-
FileUtils.mkdir_p(File.join(session_dir, SessionInbox::INPUT_DIR))
|
|
1365
|
-
FileUtils.mkdir_p(File.join(session_dir, SessionInbox::OUTPUT_DIR))
|
|
1366
|
-
|
|
1367
|
-
opts = spawn_options(session)
|
|
1368
|
-
env = opts.delete(:env)
|
|
1369
|
-
command = worker_command(session.id, state_dir: state_dir)
|
|
1370
|
-
pid = env ? Process.spawn(env, *command, **opts) : Process.spawn(*command, **opts)
|
|
1371
|
-
# Reaped when it exits: a long-lived spawner (chi web) keeps no zombies.
|
|
1372
|
-
# +pid+ is the worker's starter, which exits once the worker it forked
|
|
1373
|
-
# holds the lock (detach_from_spawner): the lock's pid is the worker's.
|
|
1374
|
-
note_spawn(session.id, state_dir: state_dir, waiter: Process.detach(pid))
|
|
1375
|
-
Log.info(:worker, "spawn", sid: session.id, child_pid: pid)
|
|
1376
|
-
pid
|
|
1377
|
-
end
|
|
1378
|
-
|
|
1379
|
-
# Start the in-process Bridge transport for this worker. The bridge is
|
|
1380
|
-
# the single live client transport, so every worker starts it. Bridge
|
|
1381
|
-
# creation happens *before* the loop so the capture observer is in place
|
|
1382
|
-
# for the whole session; the caller stops the returned instance on exit
|
|
1383
|
-
# (see Worker#run's ensure).
|
|
1384
|
-
#
|
|
1385
|
-
# @return [Samagotchi::Bridge, nil] nil when the transport failed to start
|
|
1386
|
-
# (the worker degrades: turns still flow through the input-dir loop, but
|
|
1387
|
-
# there is no live SSE or in-process cancel/answer).
|
|
1388
|
-
# @param on_input [#call, nil] called after the Bridge queues a turn
|
|
1389
|
-
def self.start_bridge(engine:, state_dir:, session_id:, on_input: nil, on_command: nil, on_exit_request: nil,
|
|
1390
|
-
exit_discards: nil, exit_stopped: nil, on_holds: nil, on_holds_stop: nil)
|
|
1391
|
-
require_relative "bridge"
|
|
1392
|
-
Samagotchi::Bridge.new(
|
|
1393
|
-
engine: engine, state_dir: state_dir, session_id: session_id, input_format: SessionInbox::INPUT_FORMAT,
|
|
1394
|
-
on_input: on_input, on_command: on_command, on_exit_request: on_exit_request, exit_discards: exit_discards,
|
|
1395
|
-
exit_stopped: exit_stopped, on_holds: on_holds, on_holds_stop: on_holds_stop
|
|
1396
|
-
).start
|
|
1397
|
-
rescue StandardError => e
|
|
1398
|
-
Log.error(:bridge, "start_failed", echo: "Bridge: failed to start for session #{session_id}: #{e.class}: #{e.message}", sid: session_id, error: e.class.name)
|
|
1399
|
-
nil
|
|
1400
|
-
end
|
|
1401
|
-
|
|
1402
|
-
# Write a user turn into a session's input directory via the same file IPC
|
|
1403
|
-
# the worker polls. Reused by the bridge's POST surface so a turn is
|
|
1404
|
-
# fire-and-forget and never calls run_turn across the thread/process
|
|
1405
|
-
# boundary. The file is JSON carrying the sender's ids.
|
|
1406
|
-
# @param client_id [String, nil] the sending UI
|
|
1407
|
-
# @param enqueued_id [String, nil] the id its ACK / :turn_enqueued carry
|
|
1408
|
-
# @param delivery [String, nil] the wire value: "next_step" (default),
|
|
1409
|
-
# "cut" or "queue"
|
|
1410
|
-
# @param images [Array<Hash>] image refs ({file:, name:}) in the
|
|
1411
|
-
# session's images/
|
|
1412
|
-
# @return [String, false] the input file's path, or false.
|
|
1413
|
-
def self.write_turn_input(session_id, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, state_dir: nil,
|
|
1414
|
-
images: [], delivery: nil, author: nil, anchors: nil)
|
|
1415
|
-
session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
1416
|
-
SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
|
|
1417
|
-
no_interrupt: no_interrupt, images: images, delivery: delivery, author: author,
|
|
1418
|
-
anchors: anchors)
|
|
1419
|
-
end
|
|
1420
|
-
|
|
1421
|
-
# How long a turn waits for the Bridge of a worker a resume just spawned.
|
|
1422
|
-
TURN_BRIDGE_WAIT = 5.0
|
|
1423
|
-
|
|
1424
|
-
# What #deliver_turn did with a message.
|
|
1425
|
-
# +status+ :accepted; :refused (the Bridge refused it, or a queue would
|
|
1426
|
-
# reach a worker that doesn't queue); :timeout (the Bridge took it and
|
|
1427
|
-
# never answered); :failed (the input file couldn't be written).
|
|
1428
|
-
# +via+ :bridge (the worker's Bridge answered) or :file (the input-file
|
|
1429
|
-
# fallback); nil when nothing went in.
|
|
1430
|
-
# +ack+ the Bridge's reply body, or the file's {"status", "enqueued_id",
|
|
1431
|
-
# "session_id"}: string keys either way, {} for none.
|
|
1432
|
-
# +code+ a refusal's HTTP status.
|
|
1433
|
-
DeliverResult = Data.define(:status, :via, :ack, :code) do
|
|
1434
|
-
def initialize(status:, via: nil, ack: {}, code: nil) = super(status:, via:, ack: ack || {}, code:)
|
|
1435
|
-
|
|
1436
|
-
def accepted? = status == :accepted
|
|
1437
|
-
|
|
1438
|
-
# Sent as an input file (no Bridge): its turn and outcome carry the
|
|
1439
|
-
# file's enqueued_id, which a wait follows (chi send --wait).
|
|
1440
|
-
def file? = via == :file
|
|
1441
|
-
|
|
1442
|
-
# @return [String, nil] the id the message's turn will carry
|
|
1443
|
-
def enqueued_id
|
|
1444
|
-
id = ack["enqueued_id"].to_s
|
|
1445
|
-
id.empty? ? nil : id
|
|
1446
|
-
end
|
|
1447
|
-
|
|
1448
|
-
# Whether the worker took the line as a session command (a POST /turn
|
|
1449
|
-
# answered with its command_id).
|
|
1450
|
-
def command? = !ack["command_id"].to_s.empty?
|
|
1451
|
-
|
|
1452
|
-
def error = ack["error"]
|
|
1453
|
-
|
|
1454
|
-
def detail = ack["detail"]
|
|
1455
|
-
|
|
1456
|
-
# What the worker applied (a Bridge ack only): the delivery asked for,
|
|
1457
|
-
# or next_step for a --queue that found no turn running; nil from the
|
|
1458
|
-
# input file or a worker older than delivery.
|
|
1459
|
-
def delivery = ack["delivery"]
|
|
1460
|
-
|
|
1461
|
-
# A --cut's outcome ("now", "waits", "off"), nil when nothing was cut.
|
|
1462
|
-
def cut = ack["cut"]
|
|
1463
|
-
|
|
1464
|
-
# The steer cut-after seconds, with a cut that waits only.
|
|
1465
|
-
def cut_after = ack["cut_after"]
|
|
1466
|
-
end
|
|
1467
|
-
|
|
1468
|
-
# Hand a user message to the session the way a UI does, for the web
|
|
1469
|
-
# composer and `chi send` alike: wakes the worker when none runs, posts
|
|
1470
|
-
# through its Bridge so every live UI sees :turn_enqueued, and falls back
|
|
1471
|
-
# to the input file when the Bridge is gone (a worker closing it on idle
|
|
1472
|
-
# exit). Race-safe against a TUI taking the session, or the worker
|
|
1473
|
-
# exiting, between the resume and the write.
|
|
1474
|
-
# @param images [Array<Hash>] refs ({file:, name:}) already in images/
|
|
1475
|
-
# @param delivery [String, nil] what the message asks the running turn
|
|
1476
|
-
# for: "next_step" (default), "cut" or "queue"
|
|
1477
|
-
# @param no_interrupt [Boolean] the turn runs with the raised iteration
|
|
1478
|
-
# limit (an attached TUI's --no-interrupt), through the Bridge or the
|
|
1479
|
-
# file; sent only when true, so older callees see no new field
|
|
1480
|
-
# @param author [String, nil] who wrote it when it isn't the session's
|
|
1481
|
-
# owner (Web::Principal#author: chi web stamps it, never the body);
|
|
1482
|
-
# to the Bridge or the file only when given, so an owner's turn is
|
|
1483
|
-
# sent and saved as before
|
|
1484
|
-
# @param pending_limit [Integer, nil] with +author+: how many of their
|
|
1485
|
-
# turns may be running or waiting (the Bridge counts them; with no
|
|
1486
|
-
# worker, their waiting files): :refused 429 too_many_turns past it
|
|
1487
|
-
# @param anchors [TurnAnchors, Hash, nil] a Verify turn's claim, a Revise
|
|
1488
|
-
# turn's draft: each refused (409, its UNAVAILABLE) by a worker without
|
|
1489
|
-
# its feature
|
|
1490
|
-
# @param manager [#resume_session, #write_turn_input] this class, or a
|
|
1491
|
-
# stand-in (the web's specs); #session_owner is optional
|
|
1492
|
-
# @param bridge [#call, nil] returns the BridgeClient or nil; defaults to
|
|
1493
|
-
# the session's sidecar, waiting TURN_BRIDGE_WAIT for a new worker's
|
|
1494
|
-
# @return [DeliverResult] :accepted (via the Bridge or a file),
|
|
1495
|
-
# :refused when the Bridge refused the request (any 4xx with an
|
|
1496
|
-
# error: bad images, a delivery it doesn't run) or a queue would reach
|
|
1497
|
-
# a worker that doesn't queue (no file then: it would steer), :timeout
|
|
1498
|
-
# when it took the request but never answered (no file then), or
|
|
1499
|
-
# :failed when the input file couldn't be written
|
|
1500
|
-
# @raise [OwnedByTUI] a one-turn chi run owns the session
|
|
1501
|
-
# @raise [ArgumentError] no such session
|
|
1502
|
-
def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, no_interrupt: false,
|
|
1503
|
-
author: nil, pending_limit: nil, anchors: nil, state_dir: nil, manager: self, bridge: nil)
|
|
1504
|
-
anchors = TurnAnchors.from(anchors)
|
|
1505
|
-
session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
1506
|
-
bridge ||= lambda do
|
|
1507
|
-
link = WorkerLink.await(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT)
|
|
1508
|
-
link.live? ? link.client(session_id) : nil
|
|
1509
|
-
end
|
|
1510
|
-
manager.resume_session(session_id, state_dir: state_dir)
|
|
1511
|
-
if (client = bridge.call)
|
|
1512
|
-
if (missing = anchors.present.find { |anchor| !worker_has?(client, anchor.class::FEATURE) })
|
|
1513
|
-
return DeliverResult.new(status: :refused, code: 409, ack: missing.class::UNAVAILABLE)
|
|
1514
|
-
end
|
|
1515
|
-
# A worker from before queueing (no "delivery" feature) ignores the
|
|
1516
|
-
# field or refuses it; either way a file would steer the running turn.
|
|
1517
|
-
return queue_unavailable if Delivery.queue?(delivery) && !worker_has?(client, Delivery::QUEUE_FEATURE)
|
|
1518
|
-
# A participant's turn only to a worker with the review boundary.
|
|
1519
|
-
return review_unavailable if Steer.participant_author?(author) && !worker_has?(client, Delivery::REVIEW_FEATURE)
|
|
1520
|
-
|
|
1521
|
-
begin
|
|
1522
|
-
options = { prompt: prompt, client_id: client_id }
|
|
1523
|
-
options[:images] = images unless images.empty?
|
|
1524
|
-
# An old worker ignores the field: it cuts anyway (its own default).
|
|
1525
|
-
options[:delivery] = delivery unless Delivery.next_step?(delivery)
|
|
1526
|
-
options[:no_interrupt] = true if no_interrupt
|
|
1527
|
-
options[:author] = author if author
|
|
1528
|
-
options[:pending_limit] = pending_limit if author && pending_limit
|
|
1529
|
-
options[:anchors] = anchors unless anchors.empty?
|
|
1530
|
-
reply = client.post_turn(**options)
|
|
1531
|
-
ack = reply.json
|
|
1532
|
-
return DeliverResult.new(status: :accepted, via: :bridge, ack: ack) if reply.status == 202 && ack.is_a?(Hash)
|
|
1533
|
-
# Read after its deadline and dropped: a file would run it after all.
|
|
1534
|
-
return turn_timeout if ack.is_a?(Hash) && ack["error"] == "deadline_passed"
|
|
1535
|
-
# Any other 4xx with an error is a refusal (bad images, a delivery
|
|
1536
|
-
# it doesn't run, a client bug): a file would bypass it, and a
|
|
1537
|
-
# refused queue would steer. A 413 (a body over the Bridge's
|
|
1538
|
-
# limit, a big piped chi send) and a 5xx keep the file fallback
|
|
1539
|
-
# below, which carries the delivery (a queue too).
|
|
1540
|
-
if (400..499).cover?(reply.status) && reply.status != 413 && ack.is_a?(Hash) && ack["error"]
|
|
1541
|
-
return DeliverResult.new(status: :refused, via: :bridge, code: reply.status, ack: ack)
|
|
1542
|
-
end
|
|
1543
|
-
rescue Errno::ETIMEDOUT
|
|
1544
|
-
# The Bridge drops a turn it reads after the request's deadline
|
|
1545
|
-
# (BridgeClient#post_turn), so a worker that wakes later won't run
|
|
1546
|
-
# it; a file would.
|
|
1547
|
-
return turn_timeout
|
|
1548
|
-
rescue SystemCallError, IOError
|
|
1549
|
-
nil # the worker closed its Bridge on the way out: queue the file
|
|
1550
|
-
end
|
|
1551
|
-
end
|
|
1552
|
-
# A participant's file only for a worker started after it: a worker
|
|
1553
|
-
# still holding the session with its Bridge gone (exiting) may be an
|
|
1554
|
-
# older one, which would run it without the review boundary.
|
|
1555
|
-
# (A one-turn chi run holding it is refused as for anyone, below.)
|
|
1556
|
-
if Steer.participant_author?(author) && (holder = delivery_owner(manager, session_id, state_dir)) && !holder.tui?
|
|
1557
|
-
return DeliverResult.new(status: :timeout, via: :file,
|
|
1558
|
-
ack: { "error" => "worker_timeout",
|
|
1559
|
-
"detail" => "the session's worker is exiting, so the message was not sent; send it again" })
|
|
1560
|
-
end
|
|
1561
|
-
enqueued_id = SecureRandom.uuid
|
|
1562
|
-
input = { prompt: prompt, client_id: client_id, enqueued_id: enqueued_id, state_dir: state_dir }
|
|
1563
|
-
input[:images] = images unless images.empty?
|
|
1564
|
-
input[:delivery] = delivery unless Delivery.next_step?(delivery)
|
|
1565
|
-
input[:no_interrupt] = true if no_interrupt
|
|
1566
|
-
input[:author] = author if author
|
|
1567
|
-
input[:anchors] = anchors unless anchors.empty?
|
|
1568
|
-
path = participant_queue_lock(session_dir, author && pending_limit) do
|
|
1569
|
-
# No worker: the participant's waiting files are all their turns,
|
|
1570
|
-
# counted and written under one lock (the Bridge counts under its).
|
|
1571
|
-
next :too_many if author && pending_limit && pending_files(session_dir, author) >= pending_limit
|
|
1572
|
-
|
|
1573
|
-
manager.write_turn_input(session_id, **input)
|
|
1574
|
-
end
|
|
1575
|
-
if path == :too_many
|
|
1576
|
-
return DeliverResult.new(status: :refused, code: 429,
|
|
1577
|
-
ack: { "error" => "too_many_turns", "detail" => ParticipantMessages::TOO_MANY_TURNS })
|
|
1578
|
-
end
|
|
1579
|
-
return DeliverResult.new(status: :failed) unless path
|
|
1580
|
-
|
|
1581
|
-
owner = delivery_owner(manager, session_id, state_dir)
|
|
1582
|
-
# A TUI that took the session between the resume and the write never
|
|
1583
|
-
# reads input files; a later worker would replay this one.
|
|
1584
|
-
if owner&.tui?
|
|
1585
|
-
FileUtils.rm_f(path) if path.is_a?(String)
|
|
1586
|
-
raise OwnedByTUI, session_id
|
|
1587
|
-
end
|
|
1588
|
-
# A worker that idle-exited since the resume never reads it either:
|
|
1589
|
-
# wake a new one. (The exiting worker also looks for input it left;
|
|
1590
|
-
# one the first resume spawned that is still starting is left be.)
|
|
1591
|
-
manager.resume_session(session_id, state_dir: state_dir) if owner.nil?
|
|
1592
|
-
DeliverResult.new(status: :accepted, via: :file,
|
|
1593
|
-
ack: { "status" => "accepted", "enqueued_id" => enqueued_id, "session_id" => session_id })
|
|
1594
|
-
end
|
|
1595
|
-
|
|
1596
|
-
# Whether the worker +client+ talks to names +feature+: the features of
|
|
1597
|
-
# the sidecar the client was made from (the Live link WorkerLink.await
|
|
1598
|
-
# found), not a second read of bridge.json.
|
|
1599
|
-
private_class_method def self.worker_has?(client, feature) = client.features.include?(feature)
|
|
1600
|
-
|
|
1601
|
-
# The unclaimed input files +author+ wrote.
|
|
1602
|
-
PARTICIPANT_QUEUE_LOCK = "participants.lock"
|
|
1603
|
-
|
|
1604
|
-
# The block under the session's participant queue lock (a file lock:
|
|
1605
|
-
# chi web's threads, and another chi web), when +locked+; else as is.
|
|
1606
|
-
private_class_method def self.participant_queue_lock(session_dir, locked)
|
|
1607
|
-
return yield unless locked
|
|
1608
|
-
|
|
1609
|
-
FileUtils.mkdir_p(session_dir)
|
|
1610
|
-
File.open(File.join(session_dir, PARTICIPANT_QUEUE_LOCK), File::RDWR | File::CREAT, 0o600) do |lock|
|
|
1611
|
-
lock.flock(File::LOCK_EX)
|
|
1612
|
-
yield
|
|
1613
|
-
end
|
|
1614
|
-
end
|
|
1615
|
-
|
|
1616
|
-
private_class_method def self.pending_files(session_dir, author)
|
|
1617
|
-
SessionInbox.find_new_input_files(session_dir).count do |file|
|
|
1618
|
-
data = JSON.parse(File.read(file))
|
|
1619
|
-
data.is_a?(Hash) && data["author"] == author
|
|
1620
|
-
rescue JSON::ParserError, SystemCallError
|
|
1621
|
-
false
|
|
1622
|
-
end
|
|
1623
|
-
end
|
|
1624
|
-
|
|
1625
|
-
private_class_method def self.queue_unavailable
|
|
1626
|
-
DeliverResult.new(status: :refused, code: 409, ack: Delivery::QUEUE_UNAVAILABLE)
|
|
1627
|
-
end
|
|
1628
|
-
|
|
1629
|
-
private_class_method def self.review_unavailable
|
|
1630
|
-
DeliverResult.new(status: :refused, code: 409, ack: Delivery::REVIEW_UNAVAILABLE)
|
|
1631
|
-
end
|
|
1632
|
-
|
|
1633
|
-
private_class_method def self.turn_timeout
|
|
1634
|
-
DeliverResult.new(status: :timeout, via: :bridge,
|
|
1635
|
-
ack: { "error" => "worker_timeout",
|
|
1636
|
-
"detail" => "the session's worker did not answer, so the message was not sent" })
|
|
1637
|
-
end
|
|
1638
|
-
|
|
1639
|
-
private_class_method def self.delivery_owner(manager, session_id, state_dir)
|
|
1640
|
-
Degrade.call(:worker, "owner_unreadable", sid: session_id) { manager.session_owner(session_id, state_dir: state_dir) }
|
|
1641
|
-
end
|
|
1642
|
-
|
|
1643
|
-
def self.stopped_on_disk?(session_id, state_dir:)
|
|
1644
|
-
Session.load(session_id, state_dir: state_dir).status == Session::STATUS_STOPPED
|
|
1645
|
-
rescue ArgumentError
|
|
1646
|
-
false
|
|
1647
|
-
end
|
|
1648
|
-
|
|
1649
128
|
# The session's live owner: the OwnerLock holder. The pid file workers
|
|
1650
129
|
# once wrote is never read: a stale one whose pid the OS reused would
|
|
1651
130
|
# make a session look owned.
|
|
@@ -1666,3 +145,11 @@ module Samagotchi
|
|
|
1666
145
|
end
|
|
1667
146
|
end
|
|
1668
147
|
end
|
|
148
|
+
|
|
149
|
+
require_relative "session_manager/spawn"
|
|
150
|
+
require_relative "session_manager/worker_command"
|
|
151
|
+
require_relative "session_manager/listing"
|
|
152
|
+
require_relative "session_manager/delivery"
|
|
153
|
+
require_relative "session_manager/lifecycle"
|
|
154
|
+
require_relative "session_manager/removal"
|
|
155
|
+
require_relative "session_manager/emptiness"
|