samagotchi 0.2.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 +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/chi +1140 -0
- data/docs/architecture.md +299 -0
- data/docs/cli.md +490 -0
- data/docs/configuration.md +494 -0
- data/docs/desktop.md +97 -0
- data/docs/guardrails.md +218 -0
- data/docs/hooks.md +309 -0
- data/docs/internals/background-tasks.md +26 -0
- data/docs/internals/context-telemetry.md +36 -0
- data/docs/internals/gemma4-contract.md +23 -0
- data/docs/internals/tool-guardrails.md +45 -0
- data/docs/memory.md +85 -0
- data/docs/plugins.md +819 -0
- data/docs/releasing.md +135 -0
- data/docs/sessions.md +155 -0
- data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
- data/lib/samagotchi/bridge/card_store.rb +126 -0
- data/lib/samagotchi/bridge/event_id.rb +25 -0
- data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
- data/lib/samagotchi/bridge/sse_writer.rb +248 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
- data/lib/samagotchi/bridge.rb +993 -0
- data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
- data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
- data/lib/samagotchi/bridge_client.rb +330 -0
- data/lib/samagotchi/bundle_needs.rb +97 -0
- data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
- data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
- data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
- data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
- data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
- data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
- data/lib/samagotchi/bundles/system/delegated.md +10 -0
- data/lib/samagotchi/bundles/system/identity.md +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +11 -0
- data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
- data/lib/samagotchi/bundles/system/self_map.md +55 -0
- data/lib/samagotchi/cancellation_controller.rb +78 -0
- data/lib/samagotchi/client.rb +429 -0
- data/lib/samagotchi/commands/registry.rb +112 -0
- data/lib/samagotchi/config.rb +910 -0
- data/lib/samagotchi/context_note.rb +77 -0
- data/lib/samagotchi/context_quote.rb +21 -0
- data/lib/samagotchi/context_usage.rb +66 -0
- data/lib/samagotchi/context_window.rb +76 -0
- data/lib/samagotchi/debug_log.rb +110 -0
- data/lib/samagotchi/desktop/macos/App.swift +102 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
- data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
- data/lib/samagotchi/desktop/macos.rb +255 -0
- data/lib/samagotchi/desktop.rb +21 -0
- data/lib/samagotchi/desktop_command.rb +143 -0
- data/lib/samagotchi/engine.rb +2807 -0
- data/lib/samagotchi/guardrails/approval.rb +125 -0
- data/lib/samagotchi/guardrails/approvals.rb +177 -0
- data/lib/samagotchi/guardrails/context.rb +71 -0
- data/lib/samagotchi/guardrails/gate.rb +125 -0
- data/lib/samagotchi/guardrails/load_failures.rb +46 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
- data/lib/samagotchi/guardrails/rules.rb +199 -0
- data/lib/samagotchi/guardrails/targets.rb +119 -0
- data/lib/samagotchi/guardrails/verdict.rb +134 -0
- data/lib/samagotchi/guardrails.rb +18 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
- data/lib/samagotchi/hooks/loader.rb +162 -0
- data/lib/samagotchi/hooks/registry.rb +261 -0
- data/lib/samagotchi/hooks.rb +30 -0
- data/lib/samagotchi/host_registry.rb +315 -0
- data/lib/samagotchi/idle_client.rb +147 -0
- data/lib/samagotchi/idle_recap.rb +549 -0
- data/lib/samagotchi/idle_reminders.rb +101 -0
- data/lib/samagotchi/idle_scheduler.rb +76 -0
- data/lib/samagotchi/image_store.rb +393 -0
- data/lib/samagotchi/installed_gem.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +1017 -0
- data/lib/samagotchi/launch_mode.rb +34 -0
- data/lib/samagotchi/llm/backend.rb +28 -0
- data/lib/samagotchi/llm/chat_loop.rb +450 -0
- data/lib/samagotchi/llm/errors.rb +329 -0
- data/lib/samagotchi/llm/http.rb +412 -0
- data/lib/samagotchi/llm/model_result.rb +72 -0
- data/lib/samagotchi/llm/native_backend.rb +50 -0
- data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
- data/lib/samagotchi/llm/openai_chat.rb +403 -0
- data/lib/samagotchi/llm/usage.rb +79 -0
- data/lib/samagotchi/log.rb +200 -0
- data/lib/samagotchi/log_line.rb +127 -0
- data/lib/samagotchi/log_path.rb +31 -0
- data/lib/samagotchi/log_subscriber.rb +163 -0
- data/lib/samagotchi/memory_bundle/builder.rb +364 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
- data/lib/samagotchi/memory_bundle/installer.rb +528 -0
- data/lib/samagotchi/memory_bundle/listing.rb +72 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
- data/lib/samagotchi/memory_bundle/merger.rb +52 -0
- data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
- data/lib/samagotchi/memory_bundle/source.rb +153 -0
- data/lib/samagotchi/memory_bundle/status.rb +107 -0
- data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
- data/lib/samagotchi/memory_bundle.rb +17 -0
- data/lib/samagotchi/memory_paths.rb +101 -0
- data/lib/samagotchi/model_overlay.rb +53 -0
- data/lib/samagotchi/model_profile.rb +309 -0
- data/lib/samagotchi/muted_memories.rb +66 -0
- data/lib/samagotchi/note_command.rb +163 -0
- data/lib/samagotchi/output_formatter.rb +100 -0
- data/lib/samagotchi/owner_lock.rb +110 -0
- data/lib/samagotchi/pending_input_queue.rb +48 -0
- data/lib/samagotchi/plugin/api.rb +362 -0
- data/lib/samagotchi/plugin/context.rb +193 -0
- data/lib/samagotchi/plugin/loader.rb +126 -0
- data/lib/samagotchi/plugin/service.rb +117 -0
- data/lib/samagotchi/plugin/sessions.rb +150 -0
- data/lib/samagotchi/plugin/side_question.rb +60 -0
- data/lib/samagotchi/plugin/tool_result.rb +24 -0
- data/lib/samagotchi/project_scope.rb +25 -0
- data/lib/samagotchi/prompt.rb +119 -0
- data/lib/samagotchi/prompt_literal_guard.rb +70 -0
- data/lib/samagotchi/recap_store.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +165 -0
- data/lib/samagotchi/self_report.rb +195 -0
- data/lib/samagotchi/send_command.rb +170 -0
- data/lib/samagotchi/served_model.rb +32 -0
- data/lib/samagotchi/session.rb +508 -0
- data/lib/samagotchi/session_commands.rb +527 -0
- data/lib/samagotchi/session_delete_command.rb +105 -0
- data/lib/samagotchi/session_manager.rb +1049 -0
- data/lib/samagotchi/session_metrics.rb +466 -0
- data/lib/samagotchi/session_observer.rb +117 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
- data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
- data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
- data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
- data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
- data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
- data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
- data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
- data/lib/samagotchi/terminal_ui/screen.rb +316 -0
- data/lib/samagotchi/terminal_ui/surface.rb +47 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
- data/lib/samagotchi/terminal_ui.rb +1992 -0
- data/lib/samagotchi/thinking_ticker.rb +110 -0
- data/lib/samagotchi/thought_stream_splitter.rb +149 -0
- data/lib/samagotchi/token_usage.rb +88 -0
- data/lib/samagotchi/tool_activity.rb +216 -0
- data/lib/samagotchi/tool_call_parser.rb +637 -0
- data/lib/samagotchi/tool_declarations.rb +561 -0
- data/lib/samagotchi/tool_runner.rb +211 -0
- data/lib/samagotchi/tools/args.rb +259 -0
- data/lib/samagotchi/tools/ask_user_question.rb +152 -0
- data/lib/samagotchi/tools/builtins.rb +122 -0
- data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
- data/lib/samagotchi/tools/delegate.rb +167 -0
- data/lib/samagotchi/tools/delegate_result.rb +53 -0
- data/lib/samagotchi/tools/delegate_wait.rb +153 -0
- data/lib/samagotchi/tools/edit.rb +155 -0
- data/lib/samagotchi/tools/execute.rb +214 -0
- data/lib/samagotchi/tools/list_reminders.rb +20 -0
- data/lib/samagotchi/tools/list_sessions.rb +74 -0
- data/lib/samagotchi/tools/memory.rb +256 -0
- data/lib/samagotchi/tools/output_guardrails.rb +93 -0
- data/lib/samagotchi/tools/peers.rb +18 -0
- data/lib/samagotchi/tools/read.rb +182 -0
- data/lib/samagotchi/tools/register_reminder.rb +53 -0
- data/lib/samagotchi/tools/registry.rb +60 -0
- data/lib/samagotchi/tools/send_note.rb +49 -0
- data/lib/samagotchi/tools/task_create.rb +29 -0
- data/lib/samagotchi/tools/task_get.rb +39 -0
- data/lib/samagotchi/tools/task_list.rb +43 -0
- data/lib/samagotchi/tools/task_runtime.rb +311 -0
- data/lib/samagotchi/tools/task_stop.rb +29 -0
- data/lib/samagotchi/tools/task_wait.rb +104 -0
- data/lib/samagotchi/tools/tool_path.rb +18 -0
- data/lib/samagotchi/tools/web_fetch.rb +163 -0
- data/lib/samagotchi/tools/write.rb +26 -0
- data/lib/samagotchi/turn_flow.rb +242 -0
- data/lib/samagotchi/turn_note.rb +76 -0
- data/lib/samagotchi/turn_tally.rb +101 -0
- data/lib/samagotchi/version.rb +7 -0
- data/lib/samagotchi/vision_context.rb +132 -0
- data/lib/samagotchi/vision_support.rb +109 -0
- data/lib/samagotchi/web/app.rb +1349 -0
- data/lib/samagotchi/web/markdown_renderer.rb +107 -0
- data/lib/samagotchi/web/message_parts.rb +169 -0
- data/lib/samagotchi/web/public/activity.js +100 -0
- data/lib/samagotchi/web/public/annotations.js +67 -0
- data/lib/samagotchi/web/public/app.js +2382 -0
- data/lib/samagotchi/web/public/card.js +74 -0
- data/lib/samagotchi/web/public/chat_view.js +360 -0
- data/lib/samagotchi/web/public/chunk_router.js +25 -0
- data/lib/samagotchi/web/public/command_complete.js +39 -0
- data/lib/samagotchi/web/public/composer_size.js +19 -0
- data/lib/samagotchi/web/public/copy.js +142 -0
- data/lib/samagotchi/web/public/ctx.js +35 -0
- data/lib/samagotchi/web/public/data.js +256 -0
- data/lib/samagotchi/web/public/format.js +232 -0
- data/lib/samagotchi/web/public/hold.js +78 -0
- data/lib/samagotchi/web/public/images.js +77 -0
- data/lib/samagotchi/web/public/index.html +568 -0
- data/lib/samagotchi/web/public/init_row.js +60 -0
- data/lib/samagotchi/web/public/model_pick.js +23 -0
- data/lib/samagotchi/web/public/question_card.js +100 -0
- data/lib/samagotchi/web/public/route.js +17 -0
- data/lib/samagotchi/web/public/scope.js +36 -0
- data/lib/samagotchi/web/public/scroll.js +24 -0
- data/lib/samagotchi/web/public/sentences.js +88 -0
- data/lib/samagotchi/web/public/sessions_list.js +60 -0
- data/lib/samagotchi/web/public/strip.js +25 -0
- data/lib/samagotchi/web/public/tally.js +37 -0
- data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
- data/lib/samagotchi/web/public/timing.js +185 -0
- data/lib/samagotchi/web/public/turn_events.js +209 -0
- data/lib/samagotchi/web/public/turn_model.js +204 -0
- data/lib/samagotchi/web/public/turn_view.js +587 -0
- data/lib/samagotchi/web/server.rb +183 -0
- data/lib/samagotchi/web/session_hub.rb +329 -0
- data/lib/samagotchi/web/session_summary.rb +85 -0
- data/lib/samagotchi/worker.rb +635 -0
- data/lib/samagotchi/worker_idle_exit.rb +87 -0
- data/lib/samagotchi.rb +12 -0
- metadata +374 -0
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
require_relative "session"
|
|
6
|
+
require_relative "turn_note"
|
|
7
|
+
require_relative "context_note"
|
|
8
|
+
require_relative "worker_idle_exit"
|
|
9
|
+
require_relative "session_manager"
|
|
10
|
+
require_relative "log"
|
|
11
|
+
require_relative "turn_flow"
|
|
12
|
+
require_relative "session_commands"
|
|
13
|
+
require_relative "model_profile"
|
|
14
|
+
|
|
15
|
+
module Samagotchi
|
|
16
|
+
# The loop of a background session worker, once it owns the session (see
|
|
17
|
+
# SessionManager.run_session_loop, which takes the OwnerLock around #run).
|
|
18
|
+
#
|
|
19
|
+
# It runs the session's Engine and Bridge, takes queued prompts from the
|
|
20
|
+
# input dir one turn at a time, and returns when nobody has used it for the
|
|
21
|
+
# idle-exit timeout. A failed turn is rolled back and its prompt handed back
|
|
22
|
+
# (:prompt_restored), as in the REPL, and the loop goes on. A stop marked on
|
|
23
|
+
# disk exits the process; an error outside a turn marks the session and
|
|
24
|
+
# exits with 1.
|
|
25
|
+
#
|
|
26
|
+
# The file IPC stays behind SessionManager's class methods
|
|
27
|
+
# (find_new_input_files, claim_input_file, start_bridge, ...), which specs
|
|
28
|
+
# stub as seams.
|
|
29
|
+
#
|
|
30
|
+
# The loop sleeps on a Waker, which the Bridge wakes when it queues a turn
|
|
31
|
+
# or a command, and the reminder callback when it queues one. A fallback
|
|
32
|
+
# tick picks up input written by another process (the web's
|
|
33
|
+
# write_turn_input fallback) and runs the stop-on-disk and idle-exit checks.
|
|
34
|
+
#
|
|
35
|
+
# Session commands (SessionCommands: /model, /models, !rollback, !cmd,
|
|
36
|
+
# /continue) run on the loop between turns, before any queued prompt. One
|
|
37
|
+
# taken while a turn runs (at an iteration boundary, or right after the
|
|
38
|
+
# turn) is refused as busy.
|
|
39
|
+
class Worker
|
|
40
|
+
FALLBACK_TICK_SECONDS = 5
|
|
41
|
+
# How much of a command's output goes into its :command_ran.
|
|
42
|
+
COMMAND_OUTPUT_LIMIT = 4096
|
|
43
|
+
BUSY_OUTPUT = "busy: wait for the turn to end"
|
|
44
|
+
TURN_END_EVENTS = %i[turn_completed turn_canceled turn_failed].freeze
|
|
45
|
+
DEFAULT_MAX_ITERATIONS = 100
|
|
46
|
+
NO_INTERRUPT_MAX_ITERATIONS = 1000
|
|
47
|
+
|
|
48
|
+
# Wakes the worker loop. Whoever queues work writes it first and wakes
|
|
49
|
+
# after, and #wait drains every wake before the loop looks for work: a
|
|
50
|
+
# wake drained with the others was for work the loop is about to see,
|
|
51
|
+
# and one that comes later stays queued for the next #wait. So no wake is
|
|
52
|
+
# lost, and a burst of them costs one pass.
|
|
53
|
+
class Waker
|
|
54
|
+
def initialize
|
|
55
|
+
@queue = Thread::Queue.new
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def wake
|
|
59
|
+
@queue << true
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @return [Boolean] true when woken, false when the timeout passed
|
|
64
|
+
def wait(timeout)
|
|
65
|
+
woken = !@queue.pop(timeout: timeout).nil?
|
|
66
|
+
@queue.clear
|
|
67
|
+
woken
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @param idle_exit_minutes [Numeric, nil] nil: session.idle_exit_minutes
|
|
72
|
+
# @param poll_interval [Numeric, nil] seconds between fallback ticks
|
|
73
|
+
def initialize(session_id:, state_dir:, session_dir:, idle_exit_minutes: nil, poll_interval: nil)
|
|
74
|
+
@session_id = session_id
|
|
75
|
+
@state_dir = state_dir
|
|
76
|
+
@session_dir = session_dir
|
|
77
|
+
@idle_exit_minutes = idle_exit_minutes
|
|
78
|
+
@poll_interval = poll_interval || FALLBACK_TICK_SECONDS
|
|
79
|
+
@waker = Waker.new
|
|
80
|
+
@command_queue = Thread::Queue.new
|
|
81
|
+
# The client_id of a client that asked the worker to exit (POST /exit).
|
|
82
|
+
@exit_requested = nil
|
|
83
|
+
@exit_requested_by = nil
|
|
84
|
+
# Set as it leaves: nothing happened in the session (#discard?).
|
|
85
|
+
@discard = false
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Whether the session was empty as the worker left it, so the caller
|
|
89
|
+
# deletes it once the lock is free (SessionManager.run_session_loop
|
|
90
|
+
# checks again then).
|
|
91
|
+
def discard? = @discard
|
|
92
|
+
|
|
93
|
+
# What a new session starts on (and /model resets to); nil before #run.
|
|
94
|
+
attr_reader :default_model
|
|
95
|
+
|
|
96
|
+
# @return [Symbol] :idle_exit, or :exit_requested when a client asked it
|
|
97
|
+
# to exit (Bridge POST /exit)
|
|
98
|
+
def run
|
|
99
|
+
@session = Session.load(@session_id, state_dir: @state_dir)
|
|
100
|
+
@engine = build_engine
|
|
101
|
+
# Before the Bridge serves anything: a UI joining a resumed worker's
|
|
102
|
+
# stream gets the session's history and status in its snapshot, not
|
|
103
|
+
# an empty session until the first turn.
|
|
104
|
+
@engine.session = @session
|
|
105
|
+
@turn_flow = TurnFlow.new(engine: @engine)
|
|
106
|
+
# A recap written while a continue offer waits says the turn stopped
|
|
107
|
+
# unfinished (before the idle jobs start).
|
|
108
|
+
@engine.recap&.awaiting_continue = -> { @turn_flow.awaiting_continue? }
|
|
109
|
+
# The seq of the last turn's end event: a command queued before it was
|
|
110
|
+
# queued while that turn ran.
|
|
111
|
+
@turn_end_seq = 0
|
|
112
|
+
@engine.subscribe(observer: lambda { |event|
|
|
113
|
+
@turn_end_seq = event[:event_seq] if TURN_END_EVENTS.include?(event[:type])
|
|
114
|
+
})
|
|
115
|
+
# /model's default is the config's, as in the REPL; the Engine started
|
|
116
|
+
# on the session's model.
|
|
117
|
+
@default_model = ModelProfile.required_model_name(nil)
|
|
118
|
+
@commands = SessionCommands.new(engine: @engine, turn_flow: @turn_flow,
|
|
119
|
+
default_model: @default_model, registry: @engine.command_registry,
|
|
120
|
+
save: ->(session) { session.save(state_dir: @state_dir) })
|
|
121
|
+
# Start the shared idle scheduler so the worker can trigger turns when
|
|
122
|
+
# reminders are due (even with no user input).
|
|
123
|
+
@engine.start_idle
|
|
124
|
+
|
|
125
|
+
@bridge = SessionManager.start_bridge(engine: @engine, state_dir: @state_dir, session_id: @session_id,
|
|
126
|
+
on_input: -> { @waker.wake },
|
|
127
|
+
on_command: lambda { |command|
|
|
128
|
+
next start_anytime_command(command) if anytime_command?(command[:line])
|
|
129
|
+
|
|
130
|
+
# Called with the event log held: the
|
|
131
|
+
# count says which turn ends came before it.
|
|
132
|
+
@command_queue << command.merge(after_seq: @engine.event_count)
|
|
133
|
+
@waker.wake
|
|
134
|
+
},
|
|
135
|
+
on_exit_request: method(:exit_request),
|
|
136
|
+
# Decided again as it leaves (a note may still come in).
|
|
137
|
+
exit_discards: method(:empty_session?))
|
|
138
|
+
# What failed to load and what plugins showed as they loaded, now that
|
|
139
|
+
# a UI can be there (a late one gets them in the snapshot).
|
|
140
|
+
@engine.announce_load_events!
|
|
141
|
+
@idle_exit = WorkerIdleExit.new(
|
|
142
|
+
engine: @engine, bridge: @bridge,
|
|
143
|
+
timeout_minutes: @idle_exit_minutes || SessionManager.config_idle_exit_minutes,
|
|
144
|
+
input_pending: -> { !@command_queue.empty? || !SessionManager.find_new_input_files(@session_dir).empty? },
|
|
145
|
+
awaiting_continue: -> { @turn_flow.awaiting_continue? }
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
begin
|
|
149
|
+
# A session stopped before this worker took the lock (e.g. a stop
|
|
150
|
+
# right after create) must not run its initial prompt.
|
|
151
|
+
exit(0) if stopped_on_disk?
|
|
152
|
+
# Plugins' slow setup (chi.init: an MCP server's first start), in
|
|
153
|
+
# the background, shown by the UIs; a turn waits only for the ones
|
|
154
|
+
# that bring tools.
|
|
155
|
+
@engine.start_init_tasks!
|
|
156
|
+
loop do
|
|
157
|
+
# Check if the session was externally marked as stopped
|
|
158
|
+
exit(0) if Session.load(@session_id, state_dir: @state_dir).status == Session::STATUS_STOPPED
|
|
159
|
+
|
|
160
|
+
# Commands queued before a prompt run first (a /continue sent
|
|
161
|
+
# before a new prompt still answers the offer).
|
|
162
|
+
next if run_queued_commands
|
|
163
|
+
|
|
164
|
+
# Between turns, so the next turn (the first one too) sees them.
|
|
165
|
+
absorb_notes
|
|
166
|
+
|
|
167
|
+
if (prompt = take_initial_prompt)
|
|
168
|
+
run_prompt(prompt, nil)
|
|
169
|
+
next
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
input_files = SessionManager.find_new_input_files(@session_dir)
|
|
173
|
+
if input_files.empty?
|
|
174
|
+
next if run_due_reminders
|
|
175
|
+
return left(:idle_exit) if @idle_exit.due? && leave_idle
|
|
176
|
+
return left(:exit_requested) if @exit_requested && leave_on_request
|
|
177
|
+
|
|
178
|
+
@waker.wait(@poll_interval)
|
|
179
|
+
next
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
input_files.sort.each do |input_file|
|
|
183
|
+
# A stop between two queued turns leaves the rest queued.
|
|
184
|
+
break if stopped_on_disk?
|
|
185
|
+
|
|
186
|
+
absorb_notes
|
|
187
|
+
run_input_file(input_file)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
rescue StandardError => e
|
|
191
|
+
# Its stderr is /dev/null: the log is the only trace of why.
|
|
192
|
+
Log.exception(:worker, "crashed", e)
|
|
193
|
+
Session.mark_error(@session_id, reason: e.message, state_dir: @state_dir)
|
|
194
|
+
exit(1)
|
|
195
|
+
ensure
|
|
196
|
+
# The anytime commands finish and the plugins' services stop (a
|
|
197
|
+
# server process), whatever the way out; the Bridge last, so a
|
|
198
|
+
# command's command_ran still reaches its UI on a crash.
|
|
199
|
+
@engine&.shutdown
|
|
200
|
+
@bridge&.stop
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
private
|
|
205
|
+
|
|
206
|
+
def build_engine
|
|
207
|
+
waker = @waker
|
|
208
|
+
engine = nil
|
|
209
|
+
engine = Samagotchi::Engine.new(
|
|
210
|
+
mode: @session.mode.to_sym,
|
|
211
|
+
model_name: @session.model_name,
|
|
212
|
+
# The session's --memory and --mute lists: the same prompt on every
|
|
213
|
+
# (re)spawn.
|
|
214
|
+
memories: @session.preloaded_memory_names,
|
|
215
|
+
muted_memories: @session.muted_memory_names,
|
|
216
|
+
reminders: {
|
|
217
|
+
callback: lambda { |due_names|
|
|
218
|
+
# A reminder is due: the loop runs a reminder turn for it once
|
|
219
|
+
# nothing else is queued (#run_due_reminders).
|
|
220
|
+
engine.note_due_reminders(due_names)
|
|
221
|
+
waker.wake
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
)
|
|
225
|
+
# The attached TUI and the web answer approvals; with none attached
|
|
226
|
+
# one waits, like ask_user_question.
|
|
227
|
+
engine.interface = :worker
|
|
228
|
+
engine.guardrail_state_dir = @state_dir if @state_dir
|
|
229
|
+
engine.session_state_dir = @state_dir if @state_dir
|
|
230
|
+
engine
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# spawn_session hands the first prompt over in last_prompt, but
|
|
234
|
+
# last_prompt also records every later turn's prompt (and mark_error's
|
|
235
|
+
# reason), so only a session with no conversation yet has one pending; a
|
|
236
|
+
# resumed session must not replay its last turn. Taken once.
|
|
237
|
+
# @return [String, nil]
|
|
238
|
+
def take_initial_prompt
|
|
239
|
+
return nil if @initial_prompt_taken
|
|
240
|
+
|
|
241
|
+
@initial_prompt_taken = true
|
|
242
|
+
# A context note may have come before the first prompt ran.
|
|
243
|
+
return nil unless @session.messages.all? { |m| ContextNote.note?(m) } && !@session.last_prompt.to_s.strip.empty?
|
|
244
|
+
|
|
245
|
+
prompt = @session.last_prompt
|
|
246
|
+
@session.last_prompt = ""
|
|
247
|
+
@session.save(state_dir: @state_dir)
|
|
248
|
+
prompt
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Add the queued context notes to the conversation (between turns only,
|
|
252
|
+
# on this thread), save, then delete their files: a crash before the
|
|
253
|
+
# delete leaves them claimed, and Engine#add_context_note skips a note
|
|
254
|
+
# the saved conversation already holds. Not activity: a note alone
|
|
255
|
+
# neither starts a turn nor keeps an idle worker up.
|
|
256
|
+
def absorb_notes
|
|
257
|
+
files = SessionManager.find_new_note_files(@session_dir)
|
|
258
|
+
return if files.empty? || stopped_on_disk?
|
|
259
|
+
|
|
260
|
+
claimed = files.filter_map { |file| SessionManager.claim_note_file(file) }
|
|
261
|
+
claimed.each do |file|
|
|
262
|
+
note = SessionManager.read_note(file)
|
|
263
|
+
@engine.add_context_note(@session, note) if note
|
|
264
|
+
end
|
|
265
|
+
@session.save(state_dir: @state_dir)
|
|
266
|
+
claimed.each { |file| FileUtils.rm_f(file) }
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def run_input_file(input_file)
|
|
270
|
+
claimed_file = SessionManager.claim_input_file(input_file)
|
|
271
|
+
return unless claimed_file
|
|
272
|
+
|
|
273
|
+
begin
|
|
274
|
+
message, origin, no_interrupt, images = SessionManager.read_input(claimed_file)
|
|
275
|
+
run_prompt(message, origin, no_interrupt: !!no_interrupt, images: images || []) unless message.to_s.strip.empty?
|
|
276
|
+
ensure
|
|
277
|
+
FileUtils.rm_f(claimed_file)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# @param no_interrupt [Boolean] an offer this turn makes keeps it for
|
|
282
|
+
# its continue turn
|
|
283
|
+
# @param images [Array<Hash>] the prompt's image refs ({file:, name:})
|
|
284
|
+
def run_prompt(prompt, origin, no_interrupt: false, images: [])
|
|
285
|
+
# Show the turn as running to readers of the file (the web's session
|
|
286
|
+
# list); the Engine resets it to idle when it ends.
|
|
287
|
+
@session.status = Session::STATUS_RUNNING
|
|
288
|
+
@session.save(state_dir: @state_dir)
|
|
289
|
+
drop_continue_offer(origin)
|
|
290
|
+
@turn_flow.before_prompt_turn
|
|
291
|
+
@merged_this_turn = []
|
|
292
|
+
begin
|
|
293
|
+
result = @engine.run_turn(@session, prompt, pending_input: pending_input_drain, origin: origin,
|
|
294
|
+
max_iterations: max_iterations(no_interrupt), images: images)
|
|
295
|
+
rescue StandardError => e
|
|
296
|
+
# The Engine announced :turn_failed (with the error's one line).
|
|
297
|
+
restore_failed_turn([[prompt, origin, images], *@merged_this_turn], error: e)
|
|
298
|
+
return
|
|
299
|
+
ensure
|
|
300
|
+
refuse_queued_commands
|
|
301
|
+
end
|
|
302
|
+
after_turn(result, no_interrupt: no_interrupt)
|
|
303
|
+
response = result.respond_to?(:output) ? result.output : nil
|
|
304
|
+
SessionManager.write_output(@session_dir, response) unless response.nil? || response.strip.empty?
|
|
305
|
+
@session.save(state_dir: @state_dir) unless stopped_on_disk?
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# A due reminder runs as a continue turn, as in the REPL: Engine#run_turn
|
|
309
|
+
# injects the reminders as a tail system message, and no user message is
|
|
310
|
+
# added. A prompt's turn may have injected them already (a stale latch):
|
|
311
|
+
# then there is nothing to run.
|
|
312
|
+
# @return [Boolean] whether a reminder turn ran
|
|
313
|
+
def run_due_reminders
|
|
314
|
+
return false if @engine.due_reminder_names.empty?
|
|
315
|
+
|
|
316
|
+
@engine.clear_due_reminder_names!
|
|
317
|
+
return false unless @engine.reminders_due?
|
|
318
|
+
|
|
319
|
+
@session.status = Session::STATUS_RUNNING
|
|
320
|
+
@session.save(state_dir: @state_dir)
|
|
321
|
+
begin
|
|
322
|
+
result = @engine.run_turn(@session, nil, continue: true, pending_input: pending_input_drain,
|
|
323
|
+
origin: { client_id: SessionManager::REMINDER_CLIENT_ID },
|
|
324
|
+
max_iterations: DEFAULT_MAX_ITERATIONS)
|
|
325
|
+
response = result.respond_to?(:output) ? result.output : nil
|
|
326
|
+
SessionManager.write_output(@session_dir, response) unless response.nil? || response.strip.empty?
|
|
327
|
+
rescue StandardError
|
|
328
|
+
nil # the Engine announced :turn_failed; there is no prompt to hand back
|
|
329
|
+
ensure
|
|
330
|
+
refuse_queued_commands
|
|
331
|
+
end
|
|
332
|
+
# A pending continue offer stays (the REPL rule); otherwise the
|
|
333
|
+
# rollback window closes.
|
|
334
|
+
@turn_flow.after_reminder_turn
|
|
335
|
+
@session.save(state_dir: @state_dir) unless stopped_on_disk?
|
|
336
|
+
true
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def max_iterations(no_interrupt) = no_interrupt ? NO_INTERRUPT_MAX_ITERATIONS : DEFAULT_MAX_ITERATIONS
|
|
340
|
+
|
|
341
|
+
# @return [Boolean] whether any command ran
|
|
342
|
+
def run_queued_commands
|
|
343
|
+
ran = false
|
|
344
|
+
while (command = next_command)
|
|
345
|
+
run_command(command)
|
|
346
|
+
ran = true
|
|
347
|
+
end
|
|
348
|
+
ran
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# A command queued while a turn ran is refused (S1), not run after it:
|
|
352
|
+
# at the turn's iteration boundaries (mid_turn: all of them), and when
|
|
353
|
+
# it ends (the ones queued before its end event; later ones run next).
|
|
354
|
+
def refuse_queued_commands(mid_turn: false)
|
|
355
|
+
later = []
|
|
356
|
+
while (command = next_command)
|
|
357
|
+
if mid_turn || command[:after_seq].to_i < @turn_end_seq
|
|
358
|
+
announce_command(command, status: "busy", output: BUSY_OUTPUT, changed: [])
|
|
359
|
+
else
|
|
360
|
+
later << command
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
later.each { |command| @command_queue << command }
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def next_command
|
|
367
|
+
@command_queue.pop(true)
|
|
368
|
+
rescue ThreadError
|
|
369
|
+
nil
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def run_command(command)
|
|
373
|
+
awaiting = @turn_flow.awaiting_continue?
|
|
374
|
+
result, shown = run_command_line(command)
|
|
375
|
+
resolved = awaiting && result.decision && result.decision != :invalid
|
|
376
|
+
@engine.synchronize_events do
|
|
377
|
+
if resolved
|
|
378
|
+
@engine.announce(type: :continue_resolved, decision: result.decision.to_s, client_id: command[:client_id])
|
|
379
|
+
end
|
|
380
|
+
announce_command(command, status: result.status.to_s, output: result.output, changed: Array(result.changed))
|
|
381
|
+
shown.each { |event| @engine.announce(event) }
|
|
382
|
+
end
|
|
383
|
+
@session.save(state_dir: @state_dir) unless Array(result.changed).empty? || stopped_on_disk?
|
|
384
|
+
# An offer answered without a turn ("no") is activity: the recap
|
|
385
|
+
# written at the offer (it says the turn stopped) gets rewritten.
|
|
386
|
+
@engine.record_activity if resolved && !result.resume
|
|
387
|
+
run_continue_turn(command) if result.resume
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# @return [Array(SessionCommands::Result, Array<Hash>)] the result, and
|
|
391
|
+
# the cards and notices it showed, held: they follow its command_ran,
|
|
392
|
+
# as its output
|
|
393
|
+
def run_command_line(command)
|
|
394
|
+
result, shown = @engine.holding_announcements do
|
|
395
|
+
@commands.run(command[:line])
|
|
396
|
+
rescue StandardError => e
|
|
397
|
+
SessionCommands::Result.new(status: :error, output: "#{command[:line].split.first}: #{e.message}", changed: [])
|
|
398
|
+
end
|
|
399
|
+
[result || SessionCommands::Result.new(status: :error, output: "not a session command", changed: []), shown]
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def anytime_command?(line) = @engine.command_registry.lookup(line)&.anytime == true
|
|
403
|
+
|
|
404
|
+
# An anytime command (/help, a plugin's /btw; D8) runs now, on its own
|
|
405
|
+
# thread, never queued behind a turn: it is never busy. The Bridge calls
|
|
406
|
+
# this with the event log held, so its command_ran (announced when it
|
|
407
|
+
# finishes) comes after its command_queued. The cards it shows go out at
|
|
408
|
+
# once (btw's "thinking…" card), after its command_queued, which the
|
|
409
|
+
# UIs draw its line at (Engine#running_anytime). Its handler reads
|
|
410
|
+
# copies (ctx.messages) and shows things through ctx only; nothing is
|
|
411
|
+
# saved.
|
|
412
|
+
def start_anytime_command(command)
|
|
413
|
+
@engine.spawn_anytime do
|
|
414
|
+
result = begin
|
|
415
|
+
@engine.running_anytime { @commands.run(command[:line]) }
|
|
416
|
+
rescue StandardError => e
|
|
417
|
+
SessionCommands::Result.new(status: :error, output: "#{command[:line].split.first}: #{e.message}", changed: [])
|
|
418
|
+
end
|
|
419
|
+
result ||= SessionCommands::Result.new(status: :error, output: "not a session command", changed: [])
|
|
420
|
+
announce_command(command, status: result.status.to_s, output: result.output, changed: Array(result.changed),
|
|
421
|
+
anytime: true)
|
|
422
|
+
rescue StandardError => e
|
|
423
|
+
Log.warn(:worker, "anytime_command_failed", line: command[:line], error: e.class.name, msg: e.message)
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# @param anytime [Boolean] an anytime command's: its line was shown at
|
|
428
|
+
# its command_queued already
|
|
429
|
+
def announce_command(command, status:, output:, changed:, anytime: false)
|
|
430
|
+
text = output.to_s
|
|
431
|
+
event = { type: :command_ran, command_id: command[:command_id], client_id: command[:client_id], line: command[:line],
|
|
432
|
+
status: status, output: text[0, COMMAND_OUTPUT_LIMIT], changed: changed.map(&:to_s),
|
|
433
|
+
model_name: @engine.effective_model_name }
|
|
434
|
+
event[:anytime] = true if anytime
|
|
435
|
+
event[:output_truncated] = true if text.length > COMMAND_OUTPUT_LIMIT
|
|
436
|
+
@engine.announce(event)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# The continue offer was answered yes: resume the conversation without a
|
|
440
|
+
# user message, with the iteration limit the offer's turn had. A failure
|
|
441
|
+
# keeps the offer, as the REPL does ("continue prompt preserved").
|
|
442
|
+
def run_continue_turn(command)
|
|
443
|
+
offer = @turn_flow.offer
|
|
444
|
+
@turn_flow.before_continue_turn
|
|
445
|
+
@session.status = Session::STATUS_RUNNING
|
|
446
|
+
@session.save(state_dir: @state_dir)
|
|
447
|
+
begin
|
|
448
|
+
result = @engine.run_turn(@session, nil, continue: true, pending_input: pending_input_drain,
|
|
449
|
+
origin: { client_id: command[:client_id] }.compact,
|
|
450
|
+
max_iterations: max_iterations(offer[:no_interrupt]))
|
|
451
|
+
rescue StandardError
|
|
452
|
+
@engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
|
|
453
|
+
@session.save(state_dir: @state_dir) unless stopped_on_disk?
|
|
454
|
+
return
|
|
455
|
+
ensure
|
|
456
|
+
refuse_queued_commands
|
|
457
|
+
end
|
|
458
|
+
after_turn(result, continue: true, no_interrupt: offer[:no_interrupt])
|
|
459
|
+
response = result.respond_to?(:output) ? result.output : nil
|
|
460
|
+
SessionManager.write_output(@session_dir, response) unless response.nil? || response.strip.empty?
|
|
461
|
+
@session.save(state_dir: @state_dir) unless stopped_on_disk?
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# TurnFlow keeps the checkpoint (a cancelled turn's, for !rollback) and
|
|
465
|
+
# the continue offer of a turn that ran out of iterations, which every
|
|
466
|
+
# UI hears about.
|
|
467
|
+
def after_turn(result, continue: false, no_interrupt: false)
|
|
468
|
+
outcome = @turn_flow.after_turn(result, continue: continue, no_interrupt: no_interrupt)
|
|
469
|
+
return unless outcome == :continue_offered || outcome == :continue_cancelled
|
|
470
|
+
|
|
471
|
+
offer = @turn_flow.offer
|
|
472
|
+
@engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# A prompt taken while a continue is offered replaces the answer (D2):
|
|
476
|
+
# the offer goes, the partial turn stays.
|
|
477
|
+
def drop_continue_offer(origin)
|
|
478
|
+
return unless @turn_flow.awaiting_continue?
|
|
479
|
+
|
|
480
|
+
@engine.synchronize_events do
|
|
481
|
+
@turn_flow.drop_offer!
|
|
482
|
+
@engine.announce(type: :continue_resolved, decision: "dropped", client_id: origin&.dig(:client_id))
|
|
483
|
+
end
|
|
484
|
+
@engine.record_activity
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# Back to the conversation before the failed turn, as the REPL does (so
|
|
488
|
+
# failed prompts don't pile up as consecutive user messages), and each
|
|
489
|
+
# prompt it took (its own and any merged into it) goes back to its
|
|
490
|
+
# sender, who can send it again. The rollback and the announcements are
|
|
491
|
+
# one step of the event log: a snapshot shows the failed turn's messages
|
|
492
|
+
# or the restored ones, never the one without the other.
|
|
493
|
+
# @param prompts [Array<Array(String, Hash|nil, Array|nil)>] [prompt,
|
|
494
|
+
# origin, images] (a web client gets its image chips back)
|
|
495
|
+
# @param error [Exception, nil] what failed: its one line stays in the
|
|
496
|
+
# conversation as a turn note
|
|
497
|
+
def restore_failed_turn(prompts, error: nil)
|
|
498
|
+
note = error && TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message, restored: true)
|
|
499
|
+
@engine.synchronize_events do
|
|
500
|
+
@turn_flow.prompt_turn_failed(note: note)
|
|
501
|
+
prompts.each do |prompt, origin, images|
|
|
502
|
+
restored = { type: :prompt_restored, prompt: prompt, origin: origin }
|
|
503
|
+
restored[:images] = images unless Array(images).empty?
|
|
504
|
+
@engine.announce(restored)
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
@session.save(state_dir: @state_dir) unless stopped_on_disk?
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# Shared mid-turn steering drain: claims any input files that arrive
|
|
511
|
+
# while a turn is running and hands them to the agentic loop so
|
|
512
|
+
# follow-ups merge at the next iteration boundary instead of waiting
|
|
513
|
+
# for the turn to end. claim_input_file is atomic (rename), so a file
|
|
514
|
+
# consumed mid-turn simply fails the outer loop's later claim with
|
|
515
|
+
# ENOENT → nil. No double-processing risk.
|
|
516
|
+
#
|
|
517
|
+
# Runs on the turn thread; it announces who sent the merged input so
|
|
518
|
+
# every live UI can attribute it.
|
|
519
|
+
def pending_input_drain
|
|
520
|
+
@pending_input_drain ||= lambda do
|
|
521
|
+
# An iteration boundary: tell whoever sent a command now that it waits.
|
|
522
|
+
refuse_queued_commands(mid_turn: true)
|
|
523
|
+
# A line with images runs as its own next turn (steering merges text
|
|
524
|
+
# only), and so does anything queued after it, to keep the order.
|
|
525
|
+
files = SessionManager.find_new_input_files(@session_dir).sort
|
|
526
|
+
.take_while { |input_file| !SessionManager.input_has_images?(input_file) }
|
|
527
|
+
merged = files.filter_map do |input_file|
|
|
528
|
+
claimed_file = SessionManager.claim_input_file(input_file)
|
|
529
|
+
next unless claimed_file
|
|
530
|
+
|
|
531
|
+
begin
|
|
532
|
+
prompt, origin = SessionManager.read_input(claimed_file)
|
|
533
|
+
prompt = prompt.to_s.strip
|
|
534
|
+
prompt.empty? ? nil : [prompt, origin]
|
|
535
|
+
ensure
|
|
536
|
+
FileUtils.rm_f(claimed_file)
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
unless merged.empty?
|
|
540
|
+
@engine.announce(type: :input_merged, count: merged.size, origins: merged.filter_map(&:last))
|
|
541
|
+
@merged_this_turn.concat(merged)
|
|
542
|
+
end
|
|
543
|
+
merged.map(&:first)
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# Check again with the event log held, which the Bridge holds while it
|
|
548
|
+
# queues a POST /turn, then close the Bridge so no client can queue one
|
|
549
|
+
# after the check, and stop the idle jobs (reminder callback, recap).
|
|
550
|
+
# A client connecting from here on finds no worker: `chi --attach` fails
|
|
551
|
+
# and the web stream answers 503 (a small window, left as is).
|
|
552
|
+
# @return [Boolean] false when something came in since #due?
|
|
553
|
+
def leave_idle
|
|
554
|
+
@engine.synchronize_events do
|
|
555
|
+
next false unless @idle_exit.due?
|
|
556
|
+
|
|
557
|
+
@bridge&.stop
|
|
558
|
+
@engine.stop_idle
|
|
559
|
+
log_idle_exit
|
|
560
|
+
true
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
# Bridge POST /exit, on a Bridge thread with the event log held.
|
|
565
|
+
# +delete+: the session is deleted after (/exit --delete), so no recap.
|
|
566
|
+
# @return [Symbol, nil] what keeps the worker up, nil when it will leave
|
|
567
|
+
def exit_request(client_id, delete: false)
|
|
568
|
+
# The Bridge serves before the idle-exit policy exists.
|
|
569
|
+
return :starting unless @idle_exit
|
|
570
|
+
|
|
571
|
+
hold = @idle_exit.hold_for_request(requester: client_id)
|
|
572
|
+
return hold if hold
|
|
573
|
+
|
|
574
|
+
@exit_requested = true
|
|
575
|
+
@exit_requested_by = client_id
|
|
576
|
+
@exit_deletes = delete
|
|
577
|
+
@waker.wake
|
|
578
|
+
nil
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# #leave_idle for an exit a client asked for: check again with the event
|
|
582
|
+
# log held (a prompt may have come in since), then close the Bridge and
|
|
583
|
+
# stop the idle jobs. Other clients' streams no longer hold: the asker's
|
|
584
|
+
# may not have closed yet, and a UI joining now finds the worker gone
|
|
585
|
+
# (:stream_closed), the window #leave_idle has too.
|
|
586
|
+
# @return [Boolean] false when something came in since the request
|
|
587
|
+
def leave_on_request
|
|
588
|
+
@engine.synchronize_events do
|
|
589
|
+
hold = @idle_exit.hold_for_request(requester: @exit_requested_by, streams: false)
|
|
590
|
+
if hold
|
|
591
|
+
@exit_requested = nil
|
|
592
|
+
Log.info(:worker, "exit_held", reason: hold)
|
|
593
|
+
next false
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
@bridge&.stop
|
|
597
|
+
@engine.stop_idle
|
|
598
|
+
Log.info(:worker, "exit_requested", by: @exit_requested_by || "a client")
|
|
599
|
+
true
|
|
600
|
+
end
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
# @return [Symbol] +result+, with #discard? decided and, for a session
|
|
604
|
+
# kept, its recap written: the Bridge is closed and the idle jobs are
|
|
605
|
+
# stopped by now, and nothing waits on this (the TUI has detached). A
|
|
606
|
+
# `chi send` meanwhile is picked up after the exit (run_session_loop);
|
|
607
|
+
# a `chi --attach` finds no Bridge until then.
|
|
608
|
+
def left(result)
|
|
609
|
+
@discard = empty_session?
|
|
610
|
+
write_recap_on_leave unless @discard || (result == :exit_requested && @exit_deletes)
|
|
611
|
+
result
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
def write_recap_on_leave
|
|
615
|
+
written = @engine.write_recap_now
|
|
616
|
+
Log.info(:worker, "recap_on_leave") if written
|
|
617
|
+
rescue StandardError => e
|
|
618
|
+
Log.warn(:worker, "recap_on_leave_failed", error: e.class.name, msg: e.message)
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
# Memory used before any turn saved it lives only in the Engine.
|
|
622
|
+
def empty_session?
|
|
623
|
+
SessionManager.discard_empty? && Array(@engine.used_memory_names).empty? &&
|
|
624
|
+
SessionManager.empty_session?(@session_id, state_dir: @state_dir, default_model: @default_model)
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
def log_idle_exit
|
|
628
|
+
Log.info(:worker, "idle_exit", idle_s: @idle_exit.idle_seconds.round)
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
def stopped_on_disk?
|
|
632
|
+
SessionManager.stopped_on_disk?(@session_id, state_dir: @state_dir)
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
end
|