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
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
class SessionManager
|
|
5
|
+
# Listing: read-only queries over the saved sessions (the lists, their
|
|
6
|
+
# summary rows, a parent's delegates, a session's replies). Calls the
|
|
7
|
+
# facade's session_owner. Meant to be loaded through samagotchi/session_manager.
|
|
8
|
+
module ListingPart
|
|
9
|
+
# List all sessions, reading status from persisted session.json files.
|
|
10
|
+
# +project_root+: only that project's sessions (nil: every session).
|
|
11
|
+
# +include_archived+: archived sessions too (Session#archived says which).
|
|
12
|
+
def list_sessions(state_dir: nil, sort: "updated_at", order: "desc", limit: nil, offset: 0, project_root: nil,
|
|
13
|
+
include_archived: false)
|
|
14
|
+
Session.list(state_dir: state_dir || Session.default_state_dir, sort: sort, order: order, limit: limit,
|
|
15
|
+
offset: offset, project_root: project_root, include_archived: include_archived)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Short summaries of sessions, newest first: the picker behind
|
|
19
|
+
# `chi sessions list --live --format json|tsv` (chi note from a script)
|
|
20
|
+
# and the agent's list_sessions tool; both pass the current project as
|
|
21
|
+
# +project_root+ unless asked for every project.
|
|
22
|
+
# @param live [Boolean] only sessions a worker owns now (WorkerLink's
|
|
23
|
+
# worker_owned?: the owner lock, not the saved status, which a dead
|
|
24
|
+
# worker leaves at "running"); a session a one-shot (or an older
|
|
25
|
+
# chi's REPL) owns is left out: it can't take notes
|
|
26
|
+
# @param cwd [String, nil] only sessions in this folder or below it
|
|
27
|
+
# @param project_root [String, nil] only this project's sessions
|
|
28
|
+
# (Session#project_root)
|
|
29
|
+
# @param limit [Integer, nil] taken after the filters
|
|
30
|
+
# @param include_tests [Boolean] false leaves out test runs
|
|
31
|
+
# @param exclude [String, nil] a session id to leave out (the asker)
|
|
32
|
+
# @param include_archived [Boolean] archived sessions too
|
|
33
|
+
# @return [Array<SessionRow>] see SessionRow for the fields
|
|
34
|
+
def session_rows(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
|
|
35
|
+
project_root: nil, include_archived: false, sort: nil, order: nil)
|
|
36
|
+
sd = state_dir || Session.default_state_dir
|
|
37
|
+
root = cwd && folder_path(cwd)
|
|
38
|
+
roots = {}
|
|
39
|
+
# One registry per listing, built only when a row has a saved context
|
|
40
|
+
# to count: its session's llm_context budget is what ctx_pct counts
|
|
41
|
+
# against (SessionMetrics.saved_context_pct).
|
|
42
|
+
budgets = SessionRow.budgets
|
|
43
|
+
rows = Session.list(state_dir: sd, sort: sort || "updated_at", order: order || "desc", project_root: project_root,
|
|
44
|
+
include_archived: include_archived).lazy
|
|
45
|
+
.reject { |s| (!include_tests && s.test_run) || s.id == exclude }
|
|
46
|
+
.select { |s| root.nil? || in_folder?(s.working_directory, root) }
|
|
47
|
+
.filter_map do |s|
|
|
48
|
+
dir = Session.session_dir(s.id, state_dir: sd)
|
|
49
|
+
owner = session_owner(s.id, state_dir: sd)
|
|
50
|
+
link = WorkerLink.resolve(dir, unlink: false, owner: owner)
|
|
51
|
+
next if live && !link.worker_owned?
|
|
52
|
+
|
|
53
|
+
SessionRow.build(s, session_dir: dir, owner: owner, link: link, roots: roots, budgets: budgets)
|
|
54
|
+
end
|
|
55
|
+
(limit ? rows.first(limit) : rows.to_a)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# .session_rows as the summaries route's hashes (SessionRow#to_summary_h,
|
|
59
|
+
# its 28 keys in order): chi web's GET /api/sessions/summaries.
|
|
60
|
+
# @return [Array<Hash>]
|
|
61
|
+
def session_summaries(**filters) = session_rows(**filters).map(&:to_summary_h)
|
|
62
|
+
|
|
63
|
+
# The sessions delegated by +parent_id+ (the `delegate` tool), newest
|
|
64
|
+
# first, as .session_rows. A running one (busy) counts against
|
|
65
|
+
# session.max_children. Archived ones too.
|
|
66
|
+
# @return [Array<SessionRow>]
|
|
67
|
+
def children_of(parent_id, state_dir: nil)
|
|
68
|
+
return [] if parent_id.to_s.empty?
|
|
69
|
+
|
|
70
|
+
session_rows(state_dir: state_dir, include_tests: true, include_archived: true)
|
|
71
|
+
.select { |s| s.parent_id == parent_id.to_s }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Children, their children, …: a delegated session doesn't delegate
|
|
75
|
+
# further today, a plugin's fork may. The walk doesn't enter +except+.
|
|
76
|
+
def descendant_ids(id, state_dir, except: [])
|
|
77
|
+
seen = [id]
|
|
78
|
+
queue = [id]
|
|
79
|
+
until queue.empty?
|
|
80
|
+
children_of(queue.shift, state_dir: state_dir).each do |child|
|
|
81
|
+
next if seen.include?(child.id) || except.include?(child.id)
|
|
82
|
+
|
|
83
|
+
seen << child.id
|
|
84
|
+
queue << child.id
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
seen.drop(1)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private def folder_path(path)
|
|
91
|
+
File.realpath(path)
|
|
92
|
+
rescue SystemCallError
|
|
93
|
+
File.expand_path(path)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private def in_folder?(dir, root)
|
|
97
|
+
dir = dir.to_s.chomp("/")
|
|
98
|
+
root = root.chomp("/")
|
|
99
|
+
dir == root || dir.start_with?("#{root}/")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Read any new output files from a session directory.
|
|
103
|
+
# Optionally filters to only files newer than `since_time`.
|
|
104
|
+
def read_responses(session_id, since_time: nil, state_dir: nil)
|
|
105
|
+
SessionInbox.read_outputs(Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir),
|
|
106
|
+
since_time: since_time)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def stopped_on_disk?(session_id, state_dir:)
|
|
110
|
+
Session.load(session_id, state_dir: state_dir).status == Session::STATUS_STOPPED
|
|
111
|
+
rescue ArgumentError
|
|
112
|
+
false
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
extend ListingPart
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
class SessionManager
|
|
5
|
+
# A delete that would pull the session from under its live worker.
|
|
6
|
+
# reason: :worker_running (not asked to stop it) or :still_stopping
|
|
7
|
+
# (stopped, but the worker outlived the wait).
|
|
8
|
+
class DeleteRefused < StandardError
|
|
9
|
+
attr_reader :session_id, :reason
|
|
10
|
+
|
|
11
|
+
def initialize(session_id, reason)
|
|
12
|
+
@session_id = session_id
|
|
13
|
+
@reason = reason
|
|
14
|
+
super(reason == :still_stopping ? "session #{session_id}'s worker is still shutting down" : "session #{session_id}'s worker is running")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# An archive that can't happen now: a turn runs in the session or in one
|
|
19
|
+
# of its children (+busy_id+ names which), or it is a scratch session.
|
|
20
|
+
class ArchiveRefused < StandardError
|
|
21
|
+
attr_reader :session_id, :reason, :busy_id
|
|
22
|
+
|
|
23
|
+
def initialize(session_id, reason, busy_id: nil)
|
|
24
|
+
@session_id = session_id
|
|
25
|
+
@reason = reason
|
|
26
|
+
@busy_id = busy_id
|
|
27
|
+
super(archive_refused_message)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def archive_refused_message
|
|
33
|
+
case @reason
|
|
34
|
+
when :scratch then "a scratch session is deleted when you leave; nothing to archive"
|
|
35
|
+
when :busy_child then "its delegate #{@busy_id[0, 8]} is running a turn; wait for it or stop it first"
|
|
36
|
+
when :queued then "a prompt is queued, which a resume would run; let it run first"
|
|
37
|
+
when :queued_child then "its delegate #{@busy_id[0, 8]} has a prompt queued, which a resume would run; let it run first"
|
|
38
|
+
when :offer then "the step-limit question waits for an answer; answer Continue or Stop first"
|
|
39
|
+
when :offer_child then "its delegate #{@busy_id[0, 8]} waits for an answer to its step-limit question; answer it first"
|
|
40
|
+
else "a turn is running; wait for it or cancel it first"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# What one archive did: id: the session it was asked for; archived: the
|
|
46
|
+
# ids (it and its delegates) now hidden for good; stopped: the live
|
|
47
|
+
# workers stopped first; discarded: empty sessions its stopping worker deleted.
|
|
48
|
+
ArchiveResult = Data.define(:id, :archived, :stopped, :discarded)
|
|
49
|
+
|
|
50
|
+
# Removal: archive, unarchive and delete a session (an archive takes its
|
|
51
|
+
# delegates too), and the retention sweep's entry. Calls LifecyclePart's
|
|
52
|
+
# stop_session, ListingPart's descendant_ids and the facade's ownership
|
|
53
|
+
# checks. Meant to be loaded through samagotchi/session_manager.
|
|
54
|
+
module RemovalPart
|
|
55
|
+
# Archive a session and its delegated children (ArchiveStore): hidden
|
|
56
|
+
# from every list, kept by the retention sweep. A live idle worker is
|
|
57
|
+
# stopped first; the marker is written even while it shuts down.
|
|
58
|
+
# @return [ArchiveResult]
|
|
59
|
+
# @raise [ArgumentError] unknown id (Session::AmbiguousId for a prefix of several)
|
|
60
|
+
# @raise [OwnedByTUI] a one-turn chi run owns it or one of its children
|
|
61
|
+
# @raise [ArchiveRefused] a turn runs in it or in a child, a prompt is
|
|
62
|
+
# queued in one (a later resume would run it), a live one offers to
|
|
63
|
+
# continue past the step limit, or it is a scratch session
|
|
64
|
+
# @param except [Array<String>] descendants left alone with their own
|
|
65
|
+
# subtrees (delegates a continue moves to the chain's new link)
|
|
66
|
+
def archive_session(id_or_prefix, state_dir: nil, wait: 5, except: [])
|
|
67
|
+
sd = state_dir || Session.default_state_dir
|
|
68
|
+
id = archive_target(id_or_prefix, sd)
|
|
69
|
+
tree, owners = archivable!(id, sd, except: except)
|
|
70
|
+
stopped = tree.select { |sid| owners[sid] }
|
|
71
|
+
stopped.each { |sid| stop_session(sid, state_dir: sd, wait: wait) }
|
|
72
|
+
archived, discarded = tree.partition { |sid| ArchiveStore.archive(sid, state_dir: sd) }
|
|
73
|
+
# A restart's reminders hand-off no successor took (the stop above
|
|
74
|
+
# removes a live one's): archived sessions keep no reminders.
|
|
75
|
+
archived.each { |sid| ReminderCarry.remove(Session.session_dir(sid, state_dir: sd)) }
|
|
76
|
+
# D8: an archive ends the review (unarchiving doesn't restore it).
|
|
77
|
+
Participants.end_sessions(archived, by: "archive", state_dir: sd)
|
|
78
|
+
ArchiveResult.new(id: id, archived: archived, stopped: stopped, discarded: discarded)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The session and its descendants, with their owners, when they can be
|
|
82
|
+
# archived now (#archive_refusal for each), else the refusal. Public for
|
|
83
|
+
# SessionContinue, which checks before its recap wait.
|
|
84
|
+
# @return [Array(Array<String>, Hash)] the ids, and id => OwnerLock::Owner or nil
|
|
85
|
+
# @raise [ArchiveRefused, OwnedByTUI]
|
|
86
|
+
def archivable!(id, state_dir, except: [])
|
|
87
|
+
raise ArchiveRefused.new(id, :scratch) if Session.load(id, state_dir: state_dir).scratch
|
|
88
|
+
|
|
89
|
+
tree = [id, *descendant_ids(id, state_dir, except: except)]
|
|
90
|
+
owners = tree.to_h { |sid| [sid, session_owner(sid, state_dir: state_dir)] }
|
|
91
|
+
tree.each do |sid|
|
|
92
|
+
refusal = archive_refusal(sid, owners[sid], state_dir)
|
|
93
|
+
raise ArchiveRefused.new(id, sid == id ? refusal : :"#{refusal}_child", busy_id: sid) if refusal
|
|
94
|
+
end
|
|
95
|
+
[tree, owners]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# What keeps +sid+ (in an archive's tree) from being archived: :busy (a
|
|
99
|
+
# turn runs), :queued (input/ holds a prompt, which outlives the stop and
|
|
100
|
+
# runs on a resume, live worker or not), :offer (a live worker's continue
|
|
101
|
+
# offer, which the stop would drop), or nil.
|
|
102
|
+
# @raise [OwnedByTUI] a one-turn chi run owns it
|
|
103
|
+
private def archive_refusal(sid, owner, state_dir)
|
|
104
|
+
raise OwnedByTUI, sid if owner&.tui?
|
|
105
|
+
|
|
106
|
+
session = owner && Session.load(sid, state_dir: state_dir)
|
|
107
|
+
return :busy if session&.status == Session::STATUS_RUNNING
|
|
108
|
+
return :queued unless SessionInbox.find_new_input_files(Session.session_dir(sid, state_dir: state_dir)).empty?
|
|
109
|
+
|
|
110
|
+
:offer if session&.waiting_question(live: true)&.dig(:kind) == ContinueOffer::KIND
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Unarchive a session and its delegated children.
|
|
114
|
+
# @return [Hash] {id:, unarchived: [ids that were archived]}
|
|
115
|
+
# @raise [ArgumentError] unknown id
|
|
116
|
+
def unarchive_session(id_or_prefix, state_dir: nil)
|
|
117
|
+
sd = state_dir || Session.default_state_dir
|
|
118
|
+
id = archive_target(id_or_prefix, sd)
|
|
119
|
+
unarchived = [id, *descendant_ids(id, sd)].select { |sid| ArchiveStore.unarchive(sid, state_dir: sd) }
|
|
120
|
+
{ id: id, unarchived: unarchived }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private def archive_target(id_or_prefix, state_dir) = Session.resolve!(id_or_prefix.to_s, state_dir: state_dir)
|
|
124
|
+
|
|
125
|
+
# The retention sweep (SessionRetention.sweep_if_due), here for the web
|
|
126
|
+
# app and hub, which take this class as their manager.
|
|
127
|
+
def retention_sweep_if_due(state_dir: nil) = SessionRetention.sweep_if_due(state_dir: state_dir)
|
|
128
|
+
|
|
129
|
+
# Delete one session: its <id>.json, the whole <id>/ directory
|
|
130
|
+
# (history sidecars, input, output, notes, images) and its attached
|
|
131
|
+
# context (ContextSources: its sources, subscriptions, mute markers). The CLI, the TUI's
|
|
132
|
+
# /exit --delete and the web all come here.
|
|
133
|
+
# @param id_or_prefix [String] a session id or a unique prefix of one
|
|
134
|
+
# @param stop [Boolean] stop a live worker first (waits +wait+ seconds)
|
|
135
|
+
# @return [Hash] {id:, removed: [paths], stopped: whether a worker was stopped}
|
|
136
|
+
# @raise [ArgumentError] unknown id (Session::AmbiguousId for a prefix of several)
|
|
137
|
+
# @raise [OwnedByTUI] a one-turn chi run owns it (never stopped from here)
|
|
138
|
+
# @raise [DeleteRefused] a worker owns it and +stop+ is false, or it
|
|
139
|
+
# outlived the wait
|
|
140
|
+
# @param purge_runs [Boolean] also delete System 1's saved runs of it and
|
|
141
|
+
# its candidate-log lines; a session a worker left empty has none worth
|
|
142
|
+
# the read
|
|
143
|
+
def delete_session(id_or_prefix, state_dir: nil, stop: false, wait: STOP_WAIT, purge_runs: true)
|
|
144
|
+
sd = state_dir || Session.default_state_dir
|
|
145
|
+
given = id_or_prefix.to_s
|
|
146
|
+
# Only a plain id: anything else could name a path outside the state dir.
|
|
147
|
+
raise Session::NotFound, given unless Session.valid_id?(given)
|
|
148
|
+
|
|
149
|
+
id = Session.resolve_id(given, state_dir: sd)
|
|
150
|
+
path = Session.session_file(id, state_dir: sd)
|
|
151
|
+
dir = Session.session_dir(id, state_dir: sd)
|
|
152
|
+
# A folder with no file left is still deleted (not resolve!).
|
|
153
|
+
raise Session::NotFound, given unless File.exist?(path) || Dir.exist?(dir)
|
|
154
|
+
|
|
155
|
+
owner = refuse_tui!(id, state_dir: sd)
|
|
156
|
+
if owner
|
|
157
|
+
raise DeleteRefused.new(id, :worker_running) unless stop
|
|
158
|
+
raise DeleteRefused.new(id, :still_stopping) unless stop_session(id, state_dir: sd, wait: wait)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
removed = []
|
|
162
|
+
if File.exist?(path)
|
|
163
|
+
FileUtils.rm_f(path)
|
|
164
|
+
removed << path
|
|
165
|
+
end
|
|
166
|
+
if Dir.exist?(dir)
|
|
167
|
+
FileUtils.rm_rf(dir)
|
|
168
|
+
removed << dir
|
|
169
|
+
end
|
|
170
|
+
PluginSessionState.remove(id)
|
|
171
|
+
removed.concat(ContextSources.remove_session(id, state_dir: sd))
|
|
172
|
+
Participants.end_sessions([id], by: "delete", state_dir: sd)
|
|
173
|
+
if purge_runs
|
|
174
|
+
purge_session_runs(id, sd)
|
|
175
|
+
ContextCandidatesLog.purge_session(id, state_dir: sd)
|
|
176
|
+
end
|
|
177
|
+
{ id: id, removed: removed, stopped: !owner.nil? }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Never a reason a delete fails: one warn, and the sweep ages them out.
|
|
181
|
+
private def purge_session_runs(id, state_dir)
|
|
182
|
+
SystemOne::Runs.purge_session(id, sessions_dir: state_dir)
|
|
183
|
+
rescue StandardError => e
|
|
184
|
+
Log.warn(:worker, "session_runs_purge_failed", sid: id, error: e.class.name, msg: e.message.to_s[0, 200])
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
extend RemovalPart
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../worker_link"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
class SessionManager
|
|
7
|
+
# How long after a spawn a resume trusts that worker to take the
|
|
8
|
+
# session, while it runs (and the session wasn't stopped since).
|
|
9
|
+
WORKER_START_GRACE = WorkerLink::START_LIMIT
|
|
10
|
+
|
|
11
|
+
@spawns = {}
|
|
12
|
+
@spawns_mutex = Mutex.new
|
|
13
|
+
|
|
14
|
+
# Spawn: deciding to start a session's worker (a new session, a resume, a
|
|
15
|
+
# delegate's report waking its parent) and the spawns this process
|
|
16
|
+
# remembers. Calls WorkerCommandPart's spawn_options and worker_command and
|
|
17
|
+
# the facade's ownership checks. Meant to be loaded through samagotchi/session_manager.
|
|
18
|
+
module SpawnPart
|
|
19
|
+
# Spawn a new background session that processes the given prompt (or,
|
|
20
|
+
# with none, waits idle for input).
|
|
21
|
+
#
|
|
22
|
+
# Returns the session object with its ID. Every worker always starts its
|
|
23
|
+
# per-session Bridge (the single live client transport), so external
|
|
24
|
+
# clients can reach it once the sidecar is published.
|
|
25
|
+
# @param memories [Array<String>] --memory: preloaded into the worker's prompt
|
|
26
|
+
# @param muted_memories [Array<String>] --mute: hidden from the session
|
|
27
|
+
# (both are session fields, so a respawn keeps them)
|
|
28
|
+
# @param parent_id [String, nil] the session that delegates this one (the
|
|
29
|
+
# `delegate` tool) or was forked from; a session field too
|
|
30
|
+
# @param messages [Array<Hash>] a conversation to start from (a plugin's
|
|
31
|
+
# ctx.sessions.fork); its image refs are copied from +images_from+
|
|
32
|
+
# @param images_from [String, nil] the session dir the seed's images are in
|
|
33
|
+
# @param preview [String, nil] what the lists show before the first turn
|
|
34
|
+
# (the prompt's preview by default, else the seed's first user message)
|
|
35
|
+
# @param title [String, nil] the session's own name (Session#title; a
|
|
36
|
+
# continue's link inherits its previous link's), saved before the
|
|
37
|
+
# worker starts: the worker owns the file from then on
|
|
38
|
+
# @param setup [SessionSetup] the session's own settings (chi
|
|
39
|
+
# --llm-context; a fork and a continue copy their session's; a delegate
|
|
40
|
+
# child the toolkit its delegate call named); without a toolkit the
|
|
41
|
+
# session is stored on config's toolkit.default, a delegate child on
|
|
42
|
+
# toolkit.child (Toolkit.configured)
|
|
43
|
+
# @param continues [String, nil] the session this one continues (a
|
|
44
|
+
# session field; SessionContinue)
|
|
45
|
+
# @param note [String, nil] a context note the session starts with,
|
|
46
|
+
# from +note_source+: queued before its worker spawns, so its first
|
|
47
|
+
# turn sees it
|
|
48
|
+
# @param review [SessionReview, nil] a review session's (ReviewStart; a
|
|
49
|
+
# continue keeps its previous link's)
|
|
50
|
+
# @param verify_fresh [VerifyFreshOrigin, nil] a Verify fresh child's
|
|
51
|
+
# requester, rules and claim (VerifyFresh.start)
|
|
52
|
+
# @param before_spawn [#call, nil] (session) runs once the session and
|
|
53
|
+
# its note are saved, before its worker spawns
|
|
54
|
+
# @param scratch [Boolean] a `chi scratch` session (its terminal's
|
|
55
|
+
# AttachLauncher): its worker runs a scratch Engine and deletes it
|
|
56
|
+
# once its last client is gone
|
|
57
|
+
# @return [Session] with #seed_images_dropped: refs whose file was gone,
|
|
58
|
+
# and #model_warning: an id its host's saved list doesn't have
|
|
59
|
+
def spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
|
|
60
|
+
memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
|
|
61
|
+
preview: nil, title: nil, delegate: false, setup: SessionSetup.new, continues: nil, note: nil,
|
|
62
|
+
note_source: "chi", before_spawn: nil, review: nil, verify_fresh: nil, scratch: false)
|
|
63
|
+
sd = state_dir || Session.default_state_dir
|
|
64
|
+
# The resolved ref is stored (a resumed session keeps its model when an
|
|
65
|
+
# alias is retargeted), with the name as typed beside it. A wrong host
|
|
66
|
+
# refuses here, before anything is saved or spawned; a model id the
|
|
67
|
+
# host's saved list doesn't have (ModelListStore) only warns
|
|
68
|
+
# (#model_warning on the session), as some hosts serve ids they don't
|
|
69
|
+
# list.
|
|
70
|
+
typed = Samagotchi::ModelProfile.check_host!(Samagotchi::ModelProfile.required_model_name(model_name))
|
|
71
|
+
model_warning = Samagotchi::ModelProfile.model_warning(typed)
|
|
72
|
+
ref = HostConfig.model_ref(typed).ref
|
|
73
|
+
session = Session.new_session(
|
|
74
|
+
mode: mode,
|
|
75
|
+
model_name: ref,
|
|
76
|
+
model_typed: typed == ref ? nil : typed,
|
|
77
|
+
working_directory: working_directory || Dir.pwd,
|
|
78
|
+
preloaded_memory_names: memories,
|
|
79
|
+
muted_memory_names: muted_memories,
|
|
80
|
+
parent_id: parent_id,
|
|
81
|
+
messages: messages,
|
|
82
|
+
delegate: delegate,
|
|
83
|
+
setup: setup,
|
|
84
|
+
continues: continues,
|
|
85
|
+
review: review,
|
|
86
|
+
scratch: scratch
|
|
87
|
+
)
|
|
88
|
+
session.toolkit = Toolkit.configured(delegate: delegate) if session.toolkit.nil?
|
|
89
|
+
session.toolkit_origin = setup.toolkit_origin || "default"
|
|
90
|
+
# Saved before the worker starts: its first turn is already the requester's.
|
|
91
|
+
session.verify_fresh = verify_fresh
|
|
92
|
+
session.model_warning = model_warning
|
|
93
|
+
# With no prompt there is no first turn to run (an attaching UI sends
|
|
94
|
+
# the prompts), so the session starts idle.
|
|
95
|
+
session.status = prompt.to_s.strip.empty? ? Session::STATUS_IDLE : Session::STATUS_RUNNING
|
|
96
|
+
session.last_prompt = prompt
|
|
97
|
+
# The first prompt's turn gets an id like a sent message's, so a wait
|
|
98
|
+
# for it can name it (chi send --new --wait, chi web's create): the
|
|
99
|
+
# worker runs it with that origin, and its reply file and last_turn
|
|
100
|
+
# carry the id.
|
|
101
|
+
session.first_turn_id = SecureRandom.uuid unless prompt.to_s.strip.empty?
|
|
102
|
+
# The worker takes last_prompt and clears it, and messages are saved at
|
|
103
|
+
# the turn's end: until then this is the only preview a list has.
|
|
104
|
+
session.first_preview = Session.preview_of(preview.to_s.strip.empty? ? prompt : preview)
|
|
105
|
+
session.title = title
|
|
106
|
+
session_dir = Session.session_dir(session.id, state_dir: sd)
|
|
107
|
+
unless session.messages.empty?
|
|
108
|
+
session.messages, dropped = ImageStore.copy_refs(session.messages, from: images_from, to: session_dir)
|
|
109
|
+
session.seed_images_dropped = dropped
|
|
110
|
+
end
|
|
111
|
+
setup_session_directory(session_dir, session, state_dir: sd)
|
|
112
|
+
SessionInbox.write_note(session.id, text: note, source: note_source, state_dir: sd) if note
|
|
113
|
+
before_spawn&.call(session)
|
|
114
|
+
spawn_worker_for_session(session, state_dir: sd)
|
|
115
|
+
session
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Ensure an existing session has a live worker process.
|
|
119
|
+
# Returns the loaded session after state reconciliation.
|
|
120
|
+
# @raise [OwnedByTUI] when the interactive TUI owns the session
|
|
121
|
+
def resume_session(session_id, state_dir: nil)
|
|
122
|
+
sd = state_dir || Session.default_state_dir
|
|
123
|
+
session = Session.load(session_id, state_dir: sd)
|
|
124
|
+
# Its terminal's worker came from spawn_session; nothing wakes another.
|
|
125
|
+
raise ScratchSession, session.id if session.scratch
|
|
126
|
+
return session if refuse_tui!(session.id, state_dir: sd)
|
|
127
|
+
|
|
128
|
+
# status is turn state, not liveness: a new worker runs no turn yet,
|
|
129
|
+
# and must not find the session stopped (it would exit): neither the
|
|
130
|
+
# marker nor a status field from before it.
|
|
131
|
+
# One this process just spawned may not hold the lock yet (a slow
|
|
132
|
+
# start, deliver_turn's second resume): it would only lose it.
|
|
133
|
+
return session if still_starting?(session.id, state_dir: sd)
|
|
134
|
+
# A continue moving delegates to it spawns its worker once they are
|
|
135
|
+
# moved; one spawned now (a moved child's ring) would race the move.
|
|
136
|
+
return session if StartingMarker.fresh?(session.id, state_dir: sd)
|
|
137
|
+
|
|
138
|
+
Session.clear_stopped(session.id, state_dir: sd)
|
|
139
|
+
session.status = Session::STATUS_IDLE
|
|
140
|
+
session.save(state_dir: sd)
|
|
141
|
+
spawn_worker_for_session(session, state_dir: sd)
|
|
142
|
+
session
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# A delegate child rang a parent that has no worker (ChildRing): start
|
|
146
|
+
# one, which runs a turn for the report. Not for a parent that is gone,
|
|
147
|
+
# stopped, archived, a scratch session, a one-turn run's, or a
|
|
148
|
+
# continue's new link still taking its delegates (its own spawn comes):
|
|
149
|
+
# the ring waits on disk for whoever runs it next (#resume_session
|
|
150
|
+
# itself would clear the stop). A stop landing between the check and
|
|
151
|
+
# the resume is a small window, left as is. An archived parent with a
|
|
152
|
+
# later link in its chain wakes that link instead: a ring a moved child
|
|
153
|
+
# wrote just as the continue moved it reaches the new link that way
|
|
154
|
+
# (ChildReports reads its predecessor's rings).
|
|
155
|
+
# @return [Symbol] :woken, or why not (:gone, :stopped, :archived,
|
|
156
|
+
# :scratch, :owned, :starting)
|
|
157
|
+
def wake_for_report(session_id, state_dir: nil, forward: true)
|
|
158
|
+
sd = state_dir || Session.default_state_dir
|
|
159
|
+
return :gone unless Session.exist?(session_id, state_dir: sd)
|
|
160
|
+
return :owned if session_owner(session_id, state_dir: sd)
|
|
161
|
+
return :stopped if Session.stopped_marker?(session_id, state_dir: sd)
|
|
162
|
+
|
|
163
|
+
if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: sd))
|
|
164
|
+
following = forward && SessionChain.next_of(session_id, state_dir: sd)
|
|
165
|
+
return following ? wake_for_report(following, state_dir: sd, forward: false) : :archived
|
|
166
|
+
end
|
|
167
|
+
return :starting if StartingMarker.fresh?(session_id, state_dir: sd)
|
|
168
|
+
|
|
169
|
+
session = Session.load(session_id, state_dir: sd)
|
|
170
|
+
return :stopped if session.status == Session::STATUS_STOPPED
|
|
171
|
+
return :scratch if session.scratch
|
|
172
|
+
|
|
173
|
+
resume_session(session_id, state_dir: sd)
|
|
174
|
+
Log.info(:worker, "delegate_woke_parent", parent: session_id[0, 8])
|
|
175
|
+
:woken
|
|
176
|
+
rescue OwnedByTUI
|
|
177
|
+
:owned
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# A worker this process spawned for the session within
|
|
181
|
+
# WORKER_START_GRACE is still running, and no stop came since.
|
|
182
|
+
private def still_starting?(session_id, state_dir:)
|
|
183
|
+
return false if Session.stopped_marker?(session_id, state_dir: state_dir)
|
|
184
|
+
|
|
185
|
+
at, waiter = @spawns_mutex.synchronize { @spawns[[state_dir, session_id]] }
|
|
186
|
+
return false unless at && waiter.respond_to?(:alive?) && waiter.alive?
|
|
187
|
+
|
|
188
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - at < WORKER_START_GRACE
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
private def note_spawn(session_id, state_dir:, waiter:)
|
|
192
|
+
@spawns_mutex.synchronize do
|
|
193
|
+
@spawns.delete_if { |_, (_, w)| !(w.respond_to?(:alive?) && w.alive?) }
|
|
194
|
+
@spawns[[state_dir, session_id]] = [Process.clock_gettime(Process::CLOCK_MONOTONIC), waiter]
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Forget the spawns #still_starting? remembers (specs).
|
|
199
|
+
def forget_spawns
|
|
200
|
+
@spawns_mutex.synchronize { @spawns.clear }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Starter processes are children of chi web, unlike the detached workers
|
|
204
|
+
# they launch; the web restart adopts these pids before replacing itself.
|
|
205
|
+
def live_spawn_pids
|
|
206
|
+
@spawns_mutex.synchronize do
|
|
207
|
+
@spawns.delete_if { |_, (_, waiter)| !(waiter.respond_to?(:alive?) && waiter.alive?) }
|
|
208
|
+
@spawns.values.filter_map do |_, waiter|
|
|
209
|
+
waiter.pid if waiter.respond_to?(:pid) && waiter.alive?
|
|
210
|
+
end.uniq.sort
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private def setup_session_directory(session_dir, session, state_dir:)
|
|
215
|
+
FileUtils.mkdir_p(session_dir)
|
|
216
|
+
FileUtils.mkdir_p(File.join(session_dir, SessionInbox::INPUT_DIR))
|
|
217
|
+
FileUtils.mkdir_p(File.join(session_dir, SessionInbox::OUTPUT_DIR))
|
|
218
|
+
session.save(state_dir: state_dir)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
private def spawn_worker_for_session(session, state_dir:)
|
|
222
|
+
session_dir = Session.session_dir(session.id, state_dir: state_dir)
|
|
223
|
+
FileUtils.mkdir_p(session_dir)
|
|
224
|
+
FileUtils.mkdir_p(File.join(session_dir, SessionInbox::INPUT_DIR))
|
|
225
|
+
FileUtils.mkdir_p(File.join(session_dir, SessionInbox::OUTPUT_DIR))
|
|
226
|
+
|
|
227
|
+
opts = spawn_options(session)
|
|
228
|
+
env = opts.delete(:env)
|
|
229
|
+
command = worker_command(session.id, state_dir: state_dir)
|
|
230
|
+
pid = env ? Process.spawn(env, *command, **opts) : Process.spawn(*command, **opts)
|
|
231
|
+
# Reaped when it exits: a long-lived spawner (chi web) keeps no zombies.
|
|
232
|
+
# +pid+ is the worker's starter, which exits once the worker it forked
|
|
233
|
+
# holds the lock (WorkerBoot.detach_from_spawner): the lock's pid is the worker's.
|
|
234
|
+
note_spawn(session.id, state_dir: state_dir, waiter: Process.detach(pid))
|
|
235
|
+
Log.info(:worker, "spawn", sid: session.id, child_pid: pid)
|
|
236
|
+
pid
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
extend SpawnPart
|
|
241
|
+
end
|
|
242
|
+
end
|