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,1049 @@
|
|
|
1
|
+
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "time"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
require "rbconfig"
|
|
9
|
+
|
|
10
|
+
require_relative "session"
|
|
11
|
+
require_relative "turn_note"
|
|
12
|
+
require_relative "owner_lock"
|
|
13
|
+
require_relative "bridge_client"
|
|
14
|
+
require_relative "log"
|
|
15
|
+
require_relative "log_path"
|
|
16
|
+
require_relative "installed_gem"
|
|
17
|
+
require_relative "recap_store"
|
|
18
|
+
require_relative "image_store"
|
|
19
|
+
require_relative "terminal_ui"
|
|
20
|
+
|
|
21
|
+
module Samagotchi
|
|
22
|
+
# SessionManager coordinates background session processes.
|
|
23
|
+
#
|
|
24
|
+
# Each session runs in its own forked Ruby process, communicating via
|
|
25
|
+
# file-based IPC in the session directory.
|
|
26
|
+
#
|
|
27
|
+
# The session itself (messages + metadata) is always saved by Session at
|
|
28
|
+
# <sessions dir>/<session_id>.json, for foreground chats too. A sibling
|
|
29
|
+
# directory holds the owner lock (any owner, TUI included) and, for
|
|
30
|
+
# background workers, their IPC files:
|
|
31
|
+
# ~/.local/state/samagotchi/sessions/
|
|
32
|
+
# ├── <session_id>.json # the session (Session#save)
|
|
33
|
+
# └── <session_id>/
|
|
34
|
+
# ├── owner.lock # flock held by the session's one owner (OwnerLock)
|
|
35
|
+
# ├── input/ # clients (web/terminal UI) write messages here
|
|
36
|
+
# │ └── <timestamp>.json # one file per user message: {prompt, client_id,
|
|
37
|
+
# │ # enqueued_id} (plain <timestamp>.txt for old workers)
|
|
38
|
+
# ├── notes/ # context notes: background text the worker adds to
|
|
39
|
+
# │ └── <ts>-<rand>.json # the conversation between turns, never a turn
|
|
40
|
+
# │ # ({text, source, from_session?, from_cwd?, created_at})
|
|
41
|
+
# ├── output/ # agent writes responses here
|
|
42
|
+
# │ └── <timestamp>.txt # one file per agent response
|
|
43
|
+
# ├── pid # PID of the owner, written by the owner itself
|
|
44
|
+
# └── bridge.json # Bridge sidecar (how clients reach the worker)
|
|
45
|
+
# Needed only at call time (run_session_loop); worker.rb requires this file.
|
|
46
|
+
autoload :Worker, File.expand_path("worker", __dir__)
|
|
47
|
+
|
|
48
|
+
class SessionManager
|
|
49
|
+
INPUT_DIR = "input"
|
|
50
|
+
# Context notes live apart from input/, so nothing that reads input/
|
|
51
|
+
# (the mid-turn drain, the idle-exit hold, the Waker) ever sees one.
|
|
52
|
+
NOTES_DIR = "notes"
|
|
53
|
+
NOTE_MAX_BYTES = 16 * 1024
|
|
54
|
+
OUTPUT_DIR = "output"
|
|
55
|
+
PID_FILE = "pid"
|
|
56
|
+
# Input-file format this worker reads, advertised in the Bridge sidecar:
|
|
57
|
+
# 2 JSON with the sender's ids (and plain text)
|
|
58
|
+
# 3 images: refs too
|
|
59
|
+
# A worker that doesn't advertise one reads only .txt, and such workers
|
|
60
|
+
# never exit.
|
|
61
|
+
INPUT_FORMAT = 3
|
|
62
|
+
STRUCTURED_INPUT_FORMAT = 2
|
|
63
|
+
IMAGES_INPUT_FORMAT = 3
|
|
64
|
+
|
|
65
|
+
# A turn with images for a worker older than IMAGES_INPUT_FORMAT, which
|
|
66
|
+
# would drop them.
|
|
67
|
+
class ImagesUnsupported < StandardError
|
|
68
|
+
def initialize(msg = "this session's worker predates images: restart it (/exit, then resume)") = super
|
|
69
|
+
end
|
|
70
|
+
# Origin of the synthetic turn queued when reminders are due.
|
|
71
|
+
REMINDER_CLIENT_ID = "system:reminder"
|
|
72
|
+
|
|
73
|
+
# Raised when the interactive TUI owns the session: it runs its own Engine
|
|
74
|
+
# and reads no input files, so a worker must not be spawned or signalled.
|
|
75
|
+
class OwnedByTUI < StandardError
|
|
76
|
+
def initialize(session_id)
|
|
77
|
+
super("session #{session_id} is owned by an interactive TUI")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A note (or a `chi send` message) that can't go in: empty, or over
|
|
82
|
+
# NOTE_MAX_BYTES.
|
|
83
|
+
class NoteRejected < ArgumentError; end
|
|
84
|
+
|
|
85
|
+
# A delete that would pull the session from under its live worker.
|
|
86
|
+
# reason: :worker_running (not asked to stop it) or :still_stopping
|
|
87
|
+
# (stopped, but the worker outlived the wait).
|
|
88
|
+
class DeleteRefused < StandardError
|
|
89
|
+
attr_reader :session_id, :reason
|
|
90
|
+
|
|
91
|
+
def initialize(session_id, reason)
|
|
92
|
+
@session_id = session_id
|
|
93
|
+
@reason = reason
|
|
94
|
+
super(reason == :still_stopping ? "session #{session_id}'s worker is still shutting down" : "session #{session_id}'s worker is running")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Spawn a new background session that processes the given prompt (or,
|
|
99
|
+
# with none, waits idle for input).
|
|
100
|
+
#
|
|
101
|
+
# Returns the session object with its ID. Every worker always starts its
|
|
102
|
+
# per-session Bridge (the single live client transport), so external
|
|
103
|
+
# clients can reach it once the sidecar is published.
|
|
104
|
+
# @param memories [Array<String>] --memory: preloaded into the worker's prompt
|
|
105
|
+
# @param muted_memories [Array<String>] --mute: hidden from the session
|
|
106
|
+
# (both are session fields, so a respawn keeps them)
|
|
107
|
+
# @param parent_id [String, nil] the session that delegates this one (the
|
|
108
|
+
# `delegate` tool) or was forked from; a session field too
|
|
109
|
+
# @param messages [Array<Hash>] a conversation to start from (a plugin's
|
|
110
|
+
# ctx.sessions.fork); its image refs are copied from +images_from+
|
|
111
|
+
# @param images_from [String, nil] the session dir the seed's images are in
|
|
112
|
+
# @param title [String, nil] what the lists show before the first turn
|
|
113
|
+
# (the prompt's preview by default, else the seed's first user message)
|
|
114
|
+
# @return [Session] with #seed_images_dropped: refs whose file was gone
|
|
115
|
+
def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
|
|
116
|
+
memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
|
|
117
|
+
title: nil)
|
|
118
|
+
sd = state_dir || Session.default_state_dir
|
|
119
|
+
session = Session.new_session(
|
|
120
|
+
mode: mode,
|
|
121
|
+
model_name: model_name || Samagotchi::ModelProfile.required_model_name,
|
|
122
|
+
working_directory: working_directory || Dir.pwd,
|
|
123
|
+
preloaded_memory_names: memories,
|
|
124
|
+
muted_memory_names: muted_memories,
|
|
125
|
+
parent_id: parent_id,
|
|
126
|
+
messages: messages
|
|
127
|
+
)
|
|
128
|
+
# With no prompt there is no first turn to run (an attaching UI sends
|
|
129
|
+
# the prompts), so the session starts idle.
|
|
130
|
+
session.status = prompt.to_s.strip.empty? ? Session::STATUS_IDLE : Session::STATUS_RUNNING
|
|
131
|
+
session.last_prompt = prompt
|
|
132
|
+
# The worker takes last_prompt and clears it, and messages are saved at
|
|
133
|
+
# the turn's end: until then this is the only preview a list has.
|
|
134
|
+
session.first_preview = Session.preview_of(title.to_s.strip.empty? ? prompt : title)
|
|
135
|
+
session_dir = Session.session_dir(session.id, state_dir: sd)
|
|
136
|
+
unless session.messages.empty?
|
|
137
|
+
session.messages, dropped = ImageStore.copy_refs(session.messages, from: images_from, to: session_dir)
|
|
138
|
+
session.seed_images_dropped = dropped
|
|
139
|
+
end
|
|
140
|
+
setup_session_directory(session_dir, session, state_dir: sd)
|
|
141
|
+
spawn_worker_for_session(session, state_dir: sd)
|
|
142
|
+
session
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Build the opts hash passed to Process.spawn for a forked worker. The
|
|
146
|
+
# child inherits this process's ENV; opts[:env] adds to it (merged, not
|
|
147
|
+
# replaced) the values a worker can't read from its own config: the
|
|
148
|
+
# hosts, default model and log settings as this `chi` resolved them
|
|
149
|
+
# (CLI flags included).
|
|
150
|
+
private_class_method def self.spawn_options(session)
|
|
151
|
+
# Own process group: workers outlive `chi web`, and a Ctrl-C in its
|
|
152
|
+
# terminal must not reach them.
|
|
153
|
+
opts = { out: File::NULL, err: File::NULL, pgroup: true }
|
|
154
|
+
# The worker's tools (and `!cmd`) run in the session's directory, not
|
|
155
|
+
# in the cwd of whoever woke it (`chi web`, another terminal).
|
|
156
|
+
dir = session.working_directory.to_s
|
|
157
|
+
if !dir.empty? && File.directory?(dir)
|
|
158
|
+
opts[:chdir] = dir
|
|
159
|
+
else
|
|
160
|
+
Log.warn(:worker, "cwd_gone", sid: session.id, dir: dir, cwd: Dir.pwd)
|
|
161
|
+
end
|
|
162
|
+
child_env = {}
|
|
163
|
+
# Propagate hosts config for multi-host routing
|
|
164
|
+
begin
|
|
165
|
+
require_relative "config"
|
|
166
|
+
hosts_json = ConfigFile.hosts_json_for_env
|
|
167
|
+
child_env["SAMAGOTCHI_HOSTS_JSON"] = hosts_json if hosts_json && !hosts_json.strip.empty?
|
|
168
|
+
rescue StandardError
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
# Also propagate current default model (may be host-qualified)
|
|
172
|
+
child_env["SAMAGOTCHI_DEFAULT_MODEL"] = ENV["SAMAGOTCHI_DEFAULT_MODEL"] if ENV["SAMAGOTCHI_DEFAULT_MODEL"]
|
|
173
|
+
# A worker gets no CLI args: pass on an idle exit set by any layer.
|
|
174
|
+
idle_exit = config_idle_exit_minutes
|
|
175
|
+
child_env["SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES"] = idle_exit.to_s unless idle_exit.nil?
|
|
176
|
+
# And the spawner's debug log, absolute: a relative log.file would
|
|
177
|
+
# otherwise land in the worker's (the session's) directory.
|
|
178
|
+
log_path = begin LogPath.resolve rescue nil end
|
|
179
|
+
if log_path
|
|
180
|
+
child_env["SAMAGOTCHI_LOG_FILE"] = log_path
|
|
181
|
+
else
|
|
182
|
+
child_env["SAMAGOTCHI_LOG_DISABLE"] = "true"
|
|
183
|
+
end
|
|
184
|
+
# And its level (a --log-level flag isn't in the worker's own config).
|
|
185
|
+
level = begin Config.get("log.level") rescue nil end
|
|
186
|
+
child_env["SAMAGOTCHI_LOG_LEVEL"] = level.to_s if level
|
|
187
|
+
opts[:env] = child_env unless child_env.empty?
|
|
188
|
+
opts
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# List all sessions, reading status from persisted session.json files.
|
|
192
|
+
# +project_root+: only that project's sessions (nil: every session).
|
|
193
|
+
def self.list_sessions(state_dir: nil, sort: "updated_at", order: "desc", limit: nil, offset: 0, project_root: nil)
|
|
194
|
+
Session.list(state_dir: state_dir || Session.default_state_dir, sort: sort, order: order, limit: limit,
|
|
195
|
+
offset: offset, project_root: project_root)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Prune sessions per retention policy. Delegates to Session.prune with live-worker guard.
|
|
199
|
+
SUMMARY_DESC_LIMIT = 60
|
|
200
|
+
|
|
201
|
+
# Short summaries of sessions, newest first: the picker behind
|
|
202
|
+
# `chi sessions list --live --format json|tsv` (chi note from a script)
|
|
203
|
+
# and the agent's list_sessions tool; both pass the current project as
|
|
204
|
+
# +project_root+ unless asked for every project.
|
|
205
|
+
# @param live [Boolean] only sessions a worker owns now (the owner lock,
|
|
206
|
+
# not the saved status, which a dead worker leaves at "running"); a
|
|
207
|
+
# REPL-owned session is left out: it can't take notes
|
|
208
|
+
# @param cwd [String, nil] only sessions in this folder or below it
|
|
209
|
+
# @param project_root [String, nil] only this project's sessions
|
|
210
|
+
# (Session#project_root)
|
|
211
|
+
# @param limit [Integer, nil] taken after the filters
|
|
212
|
+
# @param include_tests [Boolean] false leaves out test runs
|
|
213
|
+
# @param exclude [String, nil] a session id to leave out (the asker)
|
|
214
|
+
# @return [Array<Hash>] {id:, short_id:, desc:, preview:, cwd:, project:,
|
|
215
|
+
# updated_at:, status:, live:, busy:, owner:, recap:, parent_id:,
|
|
216
|
+
# parent_short_id:}; busy = live with
|
|
217
|
+
# a turn running, recap = the saved recap's first sentence, project =
|
|
218
|
+
# Session#project_root
|
|
219
|
+
def self.session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
|
|
220
|
+
project_root: nil)
|
|
221
|
+
sd = state_dir || Session.default_state_dir
|
|
222
|
+
root = cwd && folder_path(cwd)
|
|
223
|
+
roots = {}
|
|
224
|
+
summaries = Session.list(state_dir: sd, sort: "updated_at", order: "desc", project_root: project_root).lazy
|
|
225
|
+
.reject { |s| (!include_tests && s.test_run) || s.id == exclude }
|
|
226
|
+
.select { |s| root.nil? || in_folder?(s.working_directory, root) }
|
|
227
|
+
.filter_map do |s|
|
|
228
|
+
owner = session_owner(s.id, state_dir: sd)&.fetch("kind", nil)
|
|
229
|
+
owned = owner == "worker"
|
|
230
|
+
next if live && !owned
|
|
231
|
+
|
|
232
|
+
{ id: s.id, short_id: s.id[0, 8], desc: summary_desc(s), preview: summary_preview(s), cwd: s.working_directory,
|
|
233
|
+
project: s.project_root(cache: roots), updated_at: s.updated_at, status: s.status, live: owned, busy: owned && s.status == Session::STATUS_RUNNING,
|
|
234
|
+
owner: owner, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
|
|
235
|
+
parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8) }
|
|
236
|
+
end
|
|
237
|
+
(limit ? summaries.first(limit) : summaries.to_a)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# The sessions delegated by +parent_id+ (the `delegate` tool), newest
|
|
241
|
+
# first, as .session_summaries rows. A running one (busy) counts against
|
|
242
|
+
# session.max_children.
|
|
243
|
+
def self.children_of(parent_id, state_dir: nil)
|
|
244
|
+
return [] if parent_id.to_s.empty?
|
|
245
|
+
|
|
246
|
+
session_summaries(state_dir: state_dir, include_tests: true).select { |s| s[:parent_id] == parent_id.to_s }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
SUMMARY_PREVIEW_LIMIT = 120
|
|
250
|
+
|
|
251
|
+
# "<cwd basename> · <last prompt, or the first preview>", one line.
|
|
252
|
+
private_class_method def self.summary_desc(session)
|
|
253
|
+
desc = [File.basename(session.working_directory.to_s), summary_text(session)].reject(&:empty?).join(" · ")
|
|
254
|
+
cut(desc, SUMMARY_DESC_LIMIT)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
private_class_method def self.summary_preview(session)
|
|
258
|
+
cut(summary_text(session), SUMMARY_PREVIEW_LIMIT)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
private_class_method def self.summary_text(session)
|
|
262
|
+
one_line(session.last_prompt.to_s.strip.empty? ? session.first_preview.to_s : session.last_prompt.to_s)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# A prompt as one line for a session list: whitespace collapsed, and a
|
|
266
|
+
# quoted or annotated message (ContextQuote, the web's annotations)
|
|
267
|
+
# without its "> " markers, so the words show.
|
|
268
|
+
def self.one_line(text)
|
|
269
|
+
text.to_s.gsub(/^[ \t]*(?:>[ \t]?)+/, "").gsub(/\s+/, " ").strip
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
private_class_method def self.cut(text, limit)
|
|
273
|
+
text.length > limit ? "#{text[0, limit - 1]}…" : text
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
private_class_method def self.folder_path(path)
|
|
277
|
+
File.realpath(path)
|
|
278
|
+
rescue SystemCallError
|
|
279
|
+
File.expand_path(path)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
private_class_method def self.in_folder?(dir, root)
|
|
283
|
+
dir = dir.to_s.chomp("/")
|
|
284
|
+
root = root.chomp("/")
|
|
285
|
+
dir == root || dir.start_with?("#{root}/")
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def self.prune_sessions(state_dir: nil, days: nil, max_count: nil, keep_status: nil, dry_run: false, test_only: false,
|
|
289
|
+
any_age: false)
|
|
290
|
+
sd = state_dir || Session.default_state_dir
|
|
291
|
+
days = resolve_retention_days(days)
|
|
292
|
+
max_count = resolve_retention_max_count(max_count)
|
|
293
|
+
keep_status = resolve_retention_keep_status(keep_status)
|
|
294
|
+
discard = discard_empty?
|
|
295
|
+
default_model = discard ? (begin ModelProfile.required_model_name(nil) rescue nil end) : nil
|
|
296
|
+
result = Session.prune(
|
|
297
|
+
state_dir: sd,
|
|
298
|
+
days: days,
|
|
299
|
+
max_count: max_count,
|
|
300
|
+
keep_status: keep_status,
|
|
301
|
+
dry_run: dry_run,
|
|
302
|
+
test_only: test_only,
|
|
303
|
+
any_age: any_age,
|
|
304
|
+
alive_check: ->(sid) { worker_alive_for_session?(sid, state_dir: sd) },
|
|
305
|
+
empty_check: discard ? ->(sid) { left_empty?(sid, state_dir: sd, default_model: default_model) } : nil
|
|
306
|
+
)
|
|
307
|
+
result[:deleted].concat(prune_orphan_dirs(sd, dry_run: dry_run)) if discard && !test_only
|
|
308
|
+
result
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# How long a session may sit empty before the sweep takes it: its
|
|
312
|
+
# worker (or a REPL) deletes it as it leaves, so the sweep only catches
|
|
313
|
+
# those killed first (a reboot, kill -9).
|
|
314
|
+
EMPTY_GRACE_SECONDS = 3600
|
|
315
|
+
|
|
316
|
+
private_class_method def self.left_empty?(session_id, state_dir:, default_model:)
|
|
317
|
+
path = File.join(state_dir, "#{session_id}#{Session::FILE_EXT}")
|
|
318
|
+
Time.now - File.mtime(path) > EMPTY_GRACE_SECONDS &&
|
|
319
|
+
empty_session?(session_id, state_dir: state_dir, default_model: default_model)
|
|
320
|
+
rescue SystemCallError
|
|
321
|
+
false
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Directories with no session file and nothing but the skeleton, nobody
|
|
325
|
+
# owning them: a REPL killed before its first save, or a worker woken
|
|
326
|
+
# just as its session was discarded.
|
|
327
|
+
# @return [Array<String>] their ids
|
|
328
|
+
private_class_method def self.prune_orphan_dirs(state_dir, dry_run:)
|
|
329
|
+
return [] unless Dir.exist?(state_dir)
|
|
330
|
+
|
|
331
|
+
Dir.children(state_dir).filter_map do |name|
|
|
332
|
+
dir = File.join(state_dir, name)
|
|
333
|
+
next unless name.match?(/\A[\w-]+\z/) && File.directory?(dir)
|
|
334
|
+
next if File.exist?(File.join(state_dir, "#{name}#{Session::FILE_EXT}"))
|
|
335
|
+
next unless Time.now - File.mtime(dir) > EMPTY_GRACE_SECONDS && empty_session_dir?(dir)
|
|
336
|
+
next if session_owner(name, state_dir: state_dir)
|
|
337
|
+
|
|
338
|
+
FileUtils.rm_rf(dir) unless dry_run
|
|
339
|
+
name
|
|
340
|
+
rescue SystemCallError
|
|
341
|
+
nil
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Lazy sweep guard: runs prune at most once per RETENTION_SWEEP_INTERVAL_HOURS.
|
|
346
|
+
RETENTION_MARKER = ".last_retention"
|
|
347
|
+
RETENTION_SWEEP_INTERVAL_HOURS = 24
|
|
348
|
+
|
|
349
|
+
def self.retention_sweep_if_due(state_dir: nil)
|
|
350
|
+
sd = state_dir || Session.default_state_dir
|
|
351
|
+
return unless Dir.exist?(sd)
|
|
352
|
+
|
|
353
|
+
cfg_interval = begin Samagotchi::Config.get("session.sweep_interval_hours") rescue nil end
|
|
354
|
+
interval = if cfg_interval && cfg_interval.to_i.positive?
|
|
355
|
+
cfg_interval.to_i * 3600
|
|
356
|
+
else
|
|
357
|
+
(ENV.fetch("SAMAGOTCHI_SESSION_SWEEP_INTERVAL_HOURS", RETENTION_SWEEP_INTERVAL_HOURS.to_s).to_i * 3600)
|
|
358
|
+
end
|
|
359
|
+
marker = File.join(sd, RETENTION_MARKER)
|
|
360
|
+
if File.exist?(marker)
|
|
361
|
+
age = Time.now - File.mtime(marker)
|
|
362
|
+
return if age < interval
|
|
363
|
+
end
|
|
364
|
+
result = prune_sessions(state_dir: sd)
|
|
365
|
+
FileUtils.touch(marker)
|
|
366
|
+
if result[:deleted].any?
|
|
367
|
+
Log.info(:worker, "retention_pruned", echo: "[retention] pruned #{result[:deleted].size} sessions (kept #{result[:kept].size})", deleted: result[:deleted].size, kept: result[:kept].size)
|
|
368
|
+
end
|
|
369
|
+
result
|
|
370
|
+
rescue StandardError => e
|
|
371
|
+
Log.warn(:worker, "retention_failed", echo: "[retention] sweep failed: #{e.class}: #{e.message}", error: e.class.name)
|
|
372
|
+
nil
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
private_class_method def self.resolve_retention_days(val)
|
|
376
|
+
return val.to_i if !val.nil? && val.to_s.strip != ""
|
|
377
|
+
cfg = begin Samagotchi::Config.get("session.retention_days") rescue nil end
|
|
378
|
+
return cfg.to_i if cfg && !cfg.to_s.strip.empty?
|
|
379
|
+
env = ENV["SAMAGOTCHI_SESSION_RETENTION_DAYS"]
|
|
380
|
+
return env.to_i if env && !env.strip.empty?
|
|
381
|
+
Session::DEFAULT_RETENTION_DAYS
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
private_class_method def self.resolve_retention_max_count(val)
|
|
385
|
+
return val.to_i if !val.nil? && val.to_s.strip != ""
|
|
386
|
+
cfg = begin Samagotchi::Config.get("session.max_count") rescue nil end
|
|
387
|
+
return cfg.to_i if cfg && !cfg.to_s.strip.empty?
|
|
388
|
+
env = ENV["SAMAGOTCHI_SESSION_MAX_COUNT"]
|
|
389
|
+
return env.to_i if env && !env.strip.empty?
|
|
390
|
+
Session::DEFAULT_MAX_COUNT
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
private_class_method def self.resolve_retention_keep_status(val)
|
|
394
|
+
raw = if !val.nil? && val.to_s.strip != ""
|
|
395
|
+
val.to_s
|
|
396
|
+
else
|
|
397
|
+
cfg = begin Samagotchi::Config.get("session.keep_status") rescue nil end
|
|
398
|
+
cfg && !cfg.to_s.strip.empty? ? cfg.to_s : (ENV["SAMAGOTCHI_SESSION_KEEP_STATUS"] || ENV["SAMAGOTCHI_SESSION_RETENTION_KEEP_STATUS"])
|
|
399
|
+
end
|
|
400
|
+
return Session::DEFAULT_KEEP_STATUS if raw.nil? || raw.strip.empty?
|
|
401
|
+
raw.split(",").map(&:strip).reject(&:empty?)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Ensure an existing session has a live worker process.
|
|
405
|
+
# Returns the loaded session after state reconciliation.
|
|
406
|
+
# @raise [OwnedByTUI] when the interactive TUI owns the session
|
|
407
|
+
def self.resume_session(session_id, state_dir: nil)
|
|
408
|
+
sd = state_dir || Session.default_state_dir
|
|
409
|
+
session = Session.load(session_id, state_dir: sd)
|
|
410
|
+
owner = session_owner(session.id, state_dir: sd)
|
|
411
|
+
raise OwnedByTUI, session.id if owner && owner["kind"] == "tui"
|
|
412
|
+
return session if owner
|
|
413
|
+
|
|
414
|
+
# status is turn state, not liveness: a new worker runs no turn yet,
|
|
415
|
+
# and must not find the session stopped (it would exit).
|
|
416
|
+
session.status = Session::STATUS_IDLE
|
|
417
|
+
session.save(state_dir: sd)
|
|
418
|
+
spawn_worker_for_session(session, state_dir: sd)
|
|
419
|
+
session
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Read any new output files from a session directory.
|
|
423
|
+
# Optionally filters to only files newer than `since_time`.
|
|
424
|
+
def self.read_responses(session_id, since_time: nil, state_dir: nil)
|
|
425
|
+
session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
426
|
+
output_path = File.join(session_dir, OUTPUT_DIR)
|
|
427
|
+
responses = []
|
|
428
|
+
return responses unless Dir.exist?(output_path)
|
|
429
|
+
|
|
430
|
+
Dir.glob(File.join(output_path, "*.txt")).sort.each do |f|
|
|
431
|
+
if since_time.nil? || File.mtime(f) > since_time
|
|
432
|
+
responses << File.read(f)
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
responses
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Stop a session by sending TERM to its process.
|
|
439
|
+
# With +wait+ (seconds), also wait for the owner to let go of the session,
|
|
440
|
+
# so a resume right after spawns a fresh worker instead of finding the
|
|
441
|
+
# dying one.
|
|
442
|
+
# A turn still running is canceled first (STOP_CANCEL_WAIT), as the
|
|
443
|
+
# web's Cancel does: its Engine saves the prompt, which the TERM alone
|
|
444
|
+
# would lose with the worker.
|
|
445
|
+
# @return [Boolean, nil] with +wait+: whether the owner was gone in time
|
|
446
|
+
# @raise [OwnedByTUI] when the interactive TUI owns the session
|
|
447
|
+
def self.stop_session(session_id, state_dir: nil, wait: nil)
|
|
448
|
+
sd = state_dir || Session.default_state_dir
|
|
449
|
+
owner = session_owner(session_id, state_dir: sd)
|
|
450
|
+
raise OwnedByTUI, session_id if owner && owner["kind"] == "tui"
|
|
451
|
+
|
|
452
|
+
cancel_running_turn(session_id, state_dir: sd)
|
|
453
|
+
# Mark first: a worker that has not taken the lock yet sees it and exits.
|
|
454
|
+
Session.mark_stopped(session_id, state_dir: sd)
|
|
455
|
+
pid = owner && owner["pid"].to_i
|
|
456
|
+
begin
|
|
457
|
+
Process.kill("TERM", pid) if pid && pid > 0
|
|
458
|
+
rescue Errno::ESRCH
|
|
459
|
+
nil # already exited
|
|
460
|
+
end
|
|
461
|
+
wait_for_owner_release(session_id, timeout: wait, state_dir: sd) if wait
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
STOP_CANCEL_WAIT = 3.0
|
|
465
|
+
|
|
466
|
+
# Cancel the session's running turn over its Bridge and wait (up to
|
|
467
|
+
# STOP_CANCEL_WAIT) for the worker to save it. Best effort: without a
|
|
468
|
+
# live Bridge, or when it doesn't answer, the stop goes on as before.
|
|
469
|
+
private_class_method def self.cancel_running_turn(session_id, state_dir:)
|
|
470
|
+
return unless Session.load(session_id, state_dir: state_dir).status == Session::STATUS_RUNNING
|
|
471
|
+
|
|
472
|
+
client = BridgeClient.discover(session_id, session_dir: Session.session_dir(session_id, state_dir: state_dir))
|
|
473
|
+
return unless client && client.cancel(reason: "user").status == 202
|
|
474
|
+
|
|
475
|
+
BridgeClient.poll(STOP_CANCEL_WAIT) do
|
|
476
|
+
Session.load(session_id, state_dir: state_dir).status != Session::STATUS_RUNNING
|
|
477
|
+
end
|
|
478
|
+
rescue StandardError => e
|
|
479
|
+
Log.info(:worker, "stop_cancel_failed", sid: session_id, error: e.class.name, msg: e.message)
|
|
480
|
+
nil
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# What a session's directory holds before anything happened in it. Any
|
|
484
|
+
# other entry (or a file in one of the EMPTY_DIRS) is something the
|
|
485
|
+
# session keeps.
|
|
486
|
+
EMPTY_SKELETON_FILES = %w[pid owner.lock bridge.json analytics.json].freeze
|
|
487
|
+
EMPTY_DIRS = [INPUT_DIR, NOTES_DIR, "images"].freeze
|
|
488
|
+
EMPTY_SKELETON_DIRS = (EMPTY_DIRS + [OUTPUT_DIR]).freeze
|
|
489
|
+
|
|
490
|
+
# session.keep_empty off: sessions left with nothing in them are deleted
|
|
491
|
+
# (the worker as it exits, the TUI at /exit, the retention sweep).
|
|
492
|
+
def self.discard_empty?
|
|
493
|
+
Samagotchi::Config.get("session.keep_empty") != true
|
|
494
|
+
rescue StandardError
|
|
495
|
+
false
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# A session nothing happened in: no conversation, no turn tried (a failed
|
|
499
|
+
# one leaves no messages but a last_prompt and analytics turns), nothing
|
|
500
|
+
# queued or attached, and the model and mode a new session gets. A
|
|
501
|
+
# session prepared for later (/model, --model, a note, an image, memory)
|
|
502
|
+
# is not empty. Anything unreadable or unknown counts as not empty.
|
|
503
|
+
# @param default_model [String, nil] what a new session starts on
|
|
504
|
+
def self.empty_session?(session_id, state_dir: nil, default_model: nil)
|
|
505
|
+
sd = state_dir || Session.default_state_dir
|
|
506
|
+
session = Session.load(session_id, state_dir: sd)
|
|
507
|
+
return false unless no_conversation?(session.messages) && session.pending_question.nil? && session.used_memory_names.empty?
|
|
508
|
+
return false unless session.last_prompt.to_s.strip.empty? && session.first_preview.to_s.strip.empty?
|
|
509
|
+
return false unless session.mode.to_s == "assist" && !default_model.nil? && session.model_name.to_s == default_model.to_s
|
|
510
|
+
|
|
511
|
+
empty_session_dir?(Session.session_dir(session_id, state_dir: sd))
|
|
512
|
+
rescue ArgumentError, SystemCallError, JSON::ParserError
|
|
513
|
+
false
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Only the system prompt (the REPL seeds one, a saved session keeps
|
|
517
|
+
# it); a context note is a system message too, but with its kind.
|
|
518
|
+
# A turn note (the tail system message after a failed, cancelled or empty
|
|
519
|
+
# turn) is not a conversation either: a session whose only turn failed
|
|
520
|
+
# before any answer is still empty.
|
|
521
|
+
def self.no_conversation?(messages)
|
|
522
|
+
Array(messages).all? do |msg|
|
|
523
|
+
(msg[:role] || msg["role"]).to_s == "system" &&
|
|
524
|
+
((msg[:kind] || msg["kind"]).nil? || TurnNote.note?(msg))
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# @return [Boolean] whether a session's directory holds only the skeleton
|
|
529
|
+
def self.empty_session_dir?(dir)
|
|
530
|
+
return true unless Dir.exist?(dir)
|
|
531
|
+
|
|
532
|
+
Dir.children(dir).all? do |name|
|
|
533
|
+
path = File.join(dir, name)
|
|
534
|
+
if EMPTY_SKELETON_DIRS.include?(name)
|
|
535
|
+
File.directory?(path) && (!EMPTY_DIRS.include?(name) || Dir.children(path).empty?)
|
|
536
|
+
elsif name == "analytics.json"
|
|
537
|
+
JSON.parse(File.read(path))["turns"].to_i.zero?
|
|
538
|
+
else
|
|
539
|
+
EMPTY_SKELETON_FILES.include?(name)
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# Delete one session: its <id>.json and the whole <id>/ directory
|
|
545
|
+
# (history sidecars, input, output, notes, images). The CLI, the TUI's
|
|
546
|
+
# /exit --delete and the web all come here.
|
|
547
|
+
# @param id_or_prefix [String] a session id or a unique prefix of one
|
|
548
|
+
# @param stop [Boolean] stop a live worker first (waits +wait+ seconds)
|
|
549
|
+
# @return [Hash] {id:, removed: [paths], stopped: whether a worker was stopped}
|
|
550
|
+
# @raise [ArgumentError] unknown id (Session::AmbiguousId for a prefix of several)
|
|
551
|
+
# @raise [OwnedByTUI] a chi REPL owns it (never stopped from here)
|
|
552
|
+
# @raise [DeleteRefused] a worker owns it and +stop+ is false, or it
|
|
553
|
+
# outlived the wait
|
|
554
|
+
def self.delete_session(id_or_prefix, state_dir: nil, stop: false, wait: 10)
|
|
555
|
+
sd = state_dir || Session.default_state_dir
|
|
556
|
+
given = id_or_prefix.to_s
|
|
557
|
+
# Only a plain id: anything else could name a path outside the state dir.
|
|
558
|
+
raise ArgumentError, "no session #{given}" unless given.match?(/\A[\w-]+\z/)
|
|
559
|
+
|
|
560
|
+
id = Session.resolve_id(given, state_dir: sd)
|
|
561
|
+
path = File.join(sd, "#{id}#{Session::FILE_EXT}")
|
|
562
|
+
dir = Session.session_dir(id, state_dir: sd)
|
|
563
|
+
raise ArgumentError, "no session #{given}" unless File.exist?(path) || Dir.exist?(dir)
|
|
564
|
+
|
|
565
|
+
owner = session_owner(id, state_dir: sd)
|
|
566
|
+
if owner
|
|
567
|
+
raise OwnedByTUI, id if owner["kind"] == "tui"
|
|
568
|
+
raise DeleteRefused.new(id, :worker_running) unless stop
|
|
569
|
+
raise DeleteRefused.new(id, :still_stopping) unless stop_session(id, state_dir: sd, wait: wait)
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
removed = []
|
|
573
|
+
if File.exist?(path)
|
|
574
|
+
FileUtils.rm_f(path)
|
|
575
|
+
removed << path
|
|
576
|
+
end
|
|
577
|
+
if Dir.exist?(dir)
|
|
578
|
+
FileUtils.rm_rf(dir)
|
|
579
|
+
removed << dir
|
|
580
|
+
end
|
|
581
|
+
{ id: id, removed: removed, stopped: !owner.nil? }
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# @return [Boolean] whether the session had no owner within +timeout+ seconds
|
|
585
|
+
def self.wait_for_owner_release(session_id, timeout:, state_dir:)
|
|
586
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout.to_f
|
|
587
|
+
while session_owner(session_id, state_dir: state_dir)
|
|
588
|
+
return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
589
|
+
|
|
590
|
+
sleep(OwnerLock::RETRY_INTERVAL)
|
|
591
|
+
end
|
|
592
|
+
true
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
# Wait for a session to reach a terminal state (completed, error, stopped).
|
|
596
|
+
# Returns true if the session finished, false if the timeout elapsed.
|
|
597
|
+
def self.wait_for_session(session_id, timeout: 30, state_dir: nil)
|
|
598
|
+
sd = state_dir || Session.default_state_dir
|
|
599
|
+
elapsed = 0
|
|
600
|
+
while elapsed < timeout
|
|
601
|
+
session = Session.load(session_id, state_dir: sd)
|
|
602
|
+
return true if %w[completed error stopped].include?(session.status)
|
|
603
|
+
|
|
604
|
+
sleep(0.5)
|
|
605
|
+
elapsed += 0.5
|
|
606
|
+
end
|
|
607
|
+
false
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
# Run the session loop inside the forked process.
|
|
611
|
+
# This is the entry point called by Process.spawn.
|
|
612
|
+
#
|
|
613
|
+
# Every worker starts its per-session Bridge (the single live client
|
|
614
|
+
# transport) on a port bound to 127.0.0.1 before the loop and stops it on
|
|
615
|
+
# exit. If the bridge fails to start the worker degrades: turns still flow
|
|
616
|
+
# through the input-dir loop, but there is no live SSE or in-process
|
|
617
|
+
# cancel/answer.
|
|
618
|
+
#
|
|
619
|
+
# The worker first takes the session's OwnerLock; when another owner holds
|
|
620
|
+
# it (a racing resume spawned two workers, or the TUI has the session) it
|
|
621
|
+
# exits quietly.
|
|
622
|
+
#
|
|
623
|
+
# A worker nobody uses returns once session.idle_exit_minutes have passed
|
|
624
|
+
# (see WorkerIdleExit), and one a client asked to exit (Bridge POST /exit)
|
|
625
|
+
# returns as soon as nothing keeps it. The next send wakes a new one.
|
|
626
|
+
# @param idle_exit_minutes [Numeric, nil] nil: session.idle_exit_minutes
|
|
627
|
+
# @param poll_interval [Numeric, nil] seconds between the loop's fallback
|
|
628
|
+
# ticks (nil: Worker::FALLBACK_TICK_SECONDS); queued turns wake it at once
|
|
629
|
+
# @return [Symbol] :idle_exit or :exit_requested
|
|
630
|
+
def self.run_session_loop(session_id, state_dir: nil, owner_wait: OwnerLock::DEFAULT_WAIT,
|
|
631
|
+
idle_exit_minutes: nil, poll_interval: nil)
|
|
632
|
+
sd = state_dir || Session.default_state_dir
|
|
633
|
+
# The spawner passed the file and level through ENV; a worker's stderr
|
|
634
|
+
# is /dev/null, so warnings only reach the file.
|
|
635
|
+
Log.configure(stderr: false)
|
|
636
|
+
Log.session_id = session_id
|
|
637
|
+
session_dir = Session.session_dir(session_id, state_dir: sd)
|
|
638
|
+
# Kept in a class ivar so the lock's File lives as long as the worker.
|
|
639
|
+
@owner_lock = OwnerLock.acquire(session_dir, kind: "worker", wait: owner_wait)
|
|
640
|
+
exit(0) unless @owner_lock
|
|
641
|
+
Log.info(:worker, "start", cwd: Dir.pwd)
|
|
642
|
+
worker = Worker.new(session_id: session_id, state_dir: sd, session_dir: session_dir,
|
|
643
|
+
idle_exit_minutes: idle_exit_minutes, poll_interval: poll_interval)
|
|
644
|
+
result = begin
|
|
645
|
+
File.write(File.join(session_dir, PID_FILE), Process.pid.to_s)
|
|
646
|
+
worker.run
|
|
647
|
+
rescue StandardError, ScriptError => e
|
|
648
|
+
# The worker's stderr is /dev/null: the log is the only trace.
|
|
649
|
+
Log.exception(:worker, "crashed", e)
|
|
650
|
+
raise
|
|
651
|
+
ensure
|
|
652
|
+
@owner_lock.release
|
|
653
|
+
end
|
|
654
|
+
Log.info(:worker, "stop", reason: result)
|
|
655
|
+
# Only after the release: a writer that still saw this worker as the
|
|
656
|
+
# owner may have queued input since the last check. Either it finds no
|
|
657
|
+
# owner after its write and wakes one, or this finds its input.
|
|
658
|
+
if %i[idle_exit exit_requested].include?(result) && !find_new_input_files(session_dir).empty?
|
|
659
|
+
resume_session(session_id, state_dir: sd)
|
|
660
|
+
elsif worker.discard?
|
|
661
|
+
discard_left_session(session_id, state_dir: sd, default_model: worker.default_model)
|
|
662
|
+
end
|
|
663
|
+
result
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
# Delete a session its worker left empty. Checked again now the lock is
|
|
667
|
+
# free: a note or input may have come in since the worker looked, and a
|
|
668
|
+
# worker woken meanwhile owns it (delete_session refuses). A `chi send`
|
|
669
|
+
# landing between this check and the delete fails with "no session"
|
|
670
|
+
# (a window of an empty session's last moments, left as is).
|
|
671
|
+
private_class_method def self.discard_left_session(session_id, state_dir:, default_model:)
|
|
672
|
+
return unless empty_session?(session_id, state_dir: state_dir, default_model: default_model)
|
|
673
|
+
|
|
674
|
+
delete_session(session_id, state_dir: state_dir)
|
|
675
|
+
Log.info(:worker, "discarded_empty", sid: session_id)
|
|
676
|
+
rescue DeleteRefused, OwnedByTUI, ArgumentError, SystemCallError => e
|
|
677
|
+
Log.info(:worker, "kept_session", sid: session_id, error: e.class.name, msg: e.message)
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def self.config_idle_exit_minutes
|
|
681
|
+
Samagotchi::Config.get("session.idle_exit_minutes")
|
|
682
|
+
rescue StandardError
|
|
683
|
+
nil
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
private_class_method def self.setup_session_directory(session_dir, session, state_dir:)
|
|
687
|
+
FileUtils.mkdir_p(session_dir)
|
|
688
|
+
FileUtils.mkdir_p(File.join(session_dir, INPUT_DIR))
|
|
689
|
+
FileUtils.mkdir_p(File.join(session_dir, OUTPUT_DIR))
|
|
690
|
+
session.save(state_dir: state_dir)
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
# The worker's command line: this chi's lib/ and ruby. Run from an
|
|
694
|
+
# installed gem, the worker activates that gem first, so its dependencies
|
|
695
|
+
# resolve as the gemspec pins them (reline ~> 0.6.3) rather than to the
|
|
696
|
+
# newest installed version. A source checkout (bin/chi, bundle exec)
|
|
697
|
+
# keeps the plain -I lib.
|
|
698
|
+
def self.worker_command(session_id, state_dir:, gem_spec: InstalledGem.spec)
|
|
699
|
+
boot = "require 'samagotchi/session_manager'; " \
|
|
700
|
+
"Samagotchi::SessionManager.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})"
|
|
701
|
+
boot = "gem 'samagotchi', '= #{gem_spec.version}'; #{boot}" if gem_spec
|
|
702
|
+
[RbConfig.ruby, "-I", File.expand_path("..", __dir__), "-e", boot]
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
private_class_method def self.spawn_worker_for_session(session, state_dir:)
|
|
706
|
+
session_dir = Session.session_dir(session.id, state_dir: state_dir)
|
|
707
|
+
FileUtils.mkdir_p(session_dir)
|
|
708
|
+
FileUtils.mkdir_p(File.join(session_dir, INPUT_DIR))
|
|
709
|
+
FileUtils.mkdir_p(File.join(session_dir, OUTPUT_DIR))
|
|
710
|
+
|
|
711
|
+
opts = spawn_options(session)
|
|
712
|
+
env = opts.delete(:env)
|
|
713
|
+
command = worker_command(session.id, state_dir: state_dir)
|
|
714
|
+
# The worker writes the pid file itself once it owns the session.
|
|
715
|
+
pid = env ? Process.spawn(env, *command, **opts) : Process.spawn(*command, **opts)
|
|
716
|
+
Log.info(:worker, "spawn", sid: session.id, child_pid: pid)
|
|
717
|
+
pid
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
# Start the in-process Bridge transport for this worker. The bridge is
|
|
721
|
+
# the single live client transport, so every worker starts it. Bridge
|
|
722
|
+
# creation happens *before* the loop so the capture observer is in place
|
|
723
|
+
# for the whole session; the caller stops the returned instance on exit
|
|
724
|
+
# (see Worker#run's ensure).
|
|
725
|
+
#
|
|
726
|
+
# @return [Samagotchi::Bridge, nil] nil when the transport failed to start
|
|
727
|
+
# (the worker degrades: turns still flow through the input-dir loop, but
|
|
728
|
+
# there is no live SSE or in-process cancel/answer).
|
|
729
|
+
# @param on_input [#call, nil] called after the Bridge queues a turn
|
|
730
|
+
def self.start_bridge(engine:, state_dir:, session_id:, on_input: nil, on_command: nil, on_exit_request: nil,
|
|
731
|
+
exit_discards: nil)
|
|
732
|
+
require_relative "bridge"
|
|
733
|
+
Samagotchi::Bridge.new(
|
|
734
|
+
engine: engine, state_dir: state_dir, session_id: session_id, input_format: INPUT_FORMAT,
|
|
735
|
+
on_input: on_input, on_command: on_command, on_exit_request: on_exit_request, exit_discards: exit_discards
|
|
736
|
+
).start
|
|
737
|
+
rescue StandardError => e
|
|
738
|
+
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)
|
|
739
|
+
nil
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
# Write a user turn into a session's input directory via the same file IPC
|
|
745
|
+
# the worker polls. Reused by the bridge's POST surface so a turn is
|
|
746
|
+
# fire-and-forget and never calls run_turn across the thread/process
|
|
747
|
+
# boundary. The file is JSON carrying the sender's ids, unless the
|
|
748
|
+
# session's live worker predates structured input (plain text then).
|
|
749
|
+
# @param client_id [String, nil] the sending UI
|
|
750
|
+
# @param enqueued_id [String, nil] the id its ACK / :turn_enqueued carry
|
|
751
|
+
# @param images [Array<Hash>] image refs ({file:, name:}) in the
|
|
752
|
+
# session's images/ (raises ImagesUnsupported for an older worker)
|
|
753
|
+
# @return [String, false] the input file's path, or false.
|
|
754
|
+
def self.write_turn_input(session_id, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, state_dir: nil,
|
|
755
|
+
images: [])
|
|
756
|
+
sd = state_dir || Session.default_state_dir
|
|
757
|
+
session_dir = Session.session_dir(session_id, state_dir: sd)
|
|
758
|
+
images = Array(images)
|
|
759
|
+
raise ImagesUnsupported if !images.empty? && !images_input?(session_dir)
|
|
760
|
+
|
|
761
|
+
write_input_file(session_dir, prompt, client_id, enqueued_id, no_interrupt, images)
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
# How long a turn waits for the Bridge of a worker a resume just spawned.
|
|
765
|
+
TURN_BRIDGE_WAIT = 5.0
|
|
766
|
+
|
|
767
|
+
# Hand a user message to the session the way a UI does, for the web
|
|
768
|
+
# composer and `chi send` alike: wakes the worker when none runs, posts
|
|
769
|
+
# through its Bridge so every live UI sees :turn_enqueued, and falls back
|
|
770
|
+
# to the input file when the Bridge is gone (a worker closing it on idle
|
|
771
|
+
# exit). Race-safe against a TUI taking the session, or the worker
|
|
772
|
+
# exiting, between the resume and the write.
|
|
773
|
+
# @param images [Array<Hash>] refs ({file:, name:}) already in images/
|
|
774
|
+
# @param manager [#resume_session, #write_turn_input] this class, or a
|
|
775
|
+
# stand-in (the web's specs); #session_owner is optional
|
|
776
|
+
# @param bridge [#call, nil] returns the BridgeClient or nil; defaults to
|
|
777
|
+
# the session's sidecar, waiting TURN_BRIDGE_WAIT for a new worker's
|
|
778
|
+
# @return [Hash] {status: :accepted, ack: Hash} (the Bridge's reply, or
|
|
779
|
+
# {status:, enqueued_id:, session_id:} for a file), {status: :refused,
|
|
780
|
+
# code:, ack:} when the Bridge refused the images, {status: :timeout,
|
|
781
|
+
# ack:} when it took the request but never answered (no file then), or
|
|
782
|
+
# {status: :failed} when the input file couldn't be written
|
|
783
|
+
# @raise [OwnedByTUI] a chi REPL owns the session
|
|
784
|
+
# @raise [ImagesUnsupported] images for a worker that predates them
|
|
785
|
+
# @raise [ArgumentError] no such session
|
|
786
|
+
def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], state_dir: nil, manager: self, bridge: nil)
|
|
787
|
+
session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
788
|
+
bridge ||= -> { BridgeClient.wait_for(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT) }
|
|
789
|
+
manager.resume_session(session_id, state_dir: state_dir) if manager.respond_to?(:resume_session)
|
|
790
|
+
# A worker that predates images would drop them (its Bridge ignores
|
|
791
|
+
# them): refuse, so the sender keeps the chips and the text.
|
|
792
|
+
raise ImagesUnsupported if images.any? && !images_input?(session_dir)
|
|
793
|
+
|
|
794
|
+
if (client = bridge.call)
|
|
795
|
+
begin
|
|
796
|
+
options = { prompt: prompt, client_id: client_id }
|
|
797
|
+
options[:images] = images unless images.empty?
|
|
798
|
+
reply = client.post_turn(**options)
|
|
799
|
+
ack = reply.json
|
|
800
|
+
return { status: :accepted, ack: ack } if reply.status == 202 && ack.is_a?(Hash)
|
|
801
|
+
if ack.is_a?(Hash) && %w[bad_images images_unsupported].include?(ack["error"])
|
|
802
|
+
return { status: :refused, code: reply.status, ack: ack }
|
|
803
|
+
end
|
|
804
|
+
# Read after its deadline and dropped: a file would run it after all.
|
|
805
|
+
return turn_timeout if ack.is_a?(Hash) && ack["error"] == "deadline_passed"
|
|
806
|
+
rescue Errno::ETIMEDOUT
|
|
807
|
+
# The Bridge drops a turn it reads after the request's deadline
|
|
808
|
+
# (BridgeClient#post_turn), so a worker that wakes later won't run
|
|
809
|
+
# it; a file would.
|
|
810
|
+
return turn_timeout
|
|
811
|
+
rescue SystemCallError, IOError
|
|
812
|
+
nil # the worker closed its Bridge on the way out: queue the file
|
|
813
|
+
end
|
|
814
|
+
end
|
|
815
|
+
enqueued_id = SecureRandom.uuid
|
|
816
|
+
input = { prompt: prompt, client_id: client_id, enqueued_id: enqueued_id, state_dir: state_dir }
|
|
817
|
+
input[:images] = images unless images.empty?
|
|
818
|
+
path = manager.write_turn_input(session_id, **input)
|
|
819
|
+
return { status: :failed } unless path
|
|
820
|
+
|
|
821
|
+
owner = delivery_owner(manager, session_id, state_dir)
|
|
822
|
+
# A TUI that took the session between the resume and the write never
|
|
823
|
+
# reads input files; a later worker would replay this one.
|
|
824
|
+
if owner&.fetch("kind", nil) == "tui"
|
|
825
|
+
FileUtils.rm_f(path) if path.is_a?(String)
|
|
826
|
+
raise OwnedByTUI, session_id
|
|
827
|
+
end
|
|
828
|
+
# A worker that idle-exited since the resume never reads it either:
|
|
829
|
+
# wake a new one. (The exiting worker also looks for input it left.)
|
|
830
|
+
if owner.nil? && manager.respond_to?(:session_owner) && manager.respond_to?(:resume_session)
|
|
831
|
+
manager.resume_session(session_id, state_dir: state_dir)
|
|
832
|
+
end
|
|
833
|
+
{ status: :accepted, ack: { status: "accepted", enqueued_id: enqueued_id, session_id: session_id } }
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
private_class_method def self.turn_timeout
|
|
837
|
+
{ status: :timeout, ack: { "error" => "worker_timeout",
|
|
838
|
+
"detail" => "the session's worker did not answer, so the message was not sent" } }
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
private_class_method def self.delivery_owner(manager, session_id, state_dir)
|
|
842
|
+
return nil unless manager.respond_to?(:session_owner)
|
|
843
|
+
|
|
844
|
+
manager.session_owner(session_id, state_dir: state_dir)
|
|
845
|
+
rescue StandardError
|
|
846
|
+
nil
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
private_class_method def self.write_input_file(session_dir, prompt, client_id, enqueued_id, no_interrupt, images)
|
|
850
|
+
input_dir = File.join(session_dir, INPUT_DIR)
|
|
851
|
+
FileUtils.mkdir_p(input_dir)
|
|
852
|
+
|
|
853
|
+
timestamp = Time.now.strftime("%Y%m%d%H%M%S%9N")
|
|
854
|
+
if structured_input?(session_dir)
|
|
855
|
+
path = File.join(input_dir, "#{timestamp}.json")
|
|
856
|
+
record = { "prompt" => prompt.to_s, "client_id" => client_id, "enqueued_id" => enqueued_id,
|
|
857
|
+
"no_interrupt" => (no_interrupt ? true : nil),
|
|
858
|
+
"images" => (images.empty? ? nil : images.map { |image| image.transform_keys(&:to_s) }) }.compact
|
|
859
|
+
write_atomic(path, JSON.generate(record))
|
|
860
|
+
else
|
|
861
|
+
path = File.join(input_dir, "#{timestamp}.txt")
|
|
862
|
+
write_atomic(path, prompt.to_s)
|
|
863
|
+
end
|
|
864
|
+
path
|
|
865
|
+
rescue StandardError
|
|
866
|
+
false
|
|
867
|
+
end
|
|
868
|
+
|
|
869
|
+
# Queue a context note for a session: background text its worker adds
|
|
870
|
+
# to the conversation between turns. It never starts a turn.
|
|
871
|
+
# @param source [String] where it came from ("cli", "slack", "session")
|
|
872
|
+
# @param from_session [String, nil] the sending session, for a peer's note
|
|
873
|
+
# @return [String] the note file's path
|
|
874
|
+
# @raise [NoteRejected] for an empty note or one over 16 KiB (never cut)
|
|
875
|
+
def self.write_note(session_id, text:, source: "cli", from_session: nil, from_cwd: nil, state_dir: nil)
|
|
876
|
+
body = checked_text(text)
|
|
877
|
+
sd = state_dir || Session.default_state_dir
|
|
878
|
+
notes_dir = File.join(Session.session_dir(session_id, state_dir: sd), NOTES_DIR)
|
|
879
|
+
FileUtils.mkdir_p(notes_dir)
|
|
880
|
+
|
|
881
|
+
# The random part keeps two writers in one nanosecond apart; the
|
|
882
|
+
# timestamp keeps the names in arrival order.
|
|
883
|
+
name = "#{Time.now.strftime("%Y%m%d%H%M%S%9N")}-#{SecureRandom.hex(3)}.json"
|
|
884
|
+
path = File.join(notes_dir, name)
|
|
885
|
+
record = { "text" => body, "source" => source.to_s, "from_session" => from_session,
|
|
886
|
+
"from_cwd" => from_cwd, "created_at" => Time.now.iso8601 }.compact
|
|
887
|
+
write_atomic(path, JSON.generate(record))
|
|
888
|
+
path
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
# The same empty and 16 KiB checks for a note and a sent message.
|
|
892
|
+
# @param noun [String] what the errors call the text
|
|
893
|
+
# @return [String] the stripped text
|
|
894
|
+
# @raise [NoteRejected]
|
|
895
|
+
def self.checked_text(text, noun: "note")
|
|
896
|
+
body = text.to_s.strip
|
|
897
|
+
raise NoteRejected, "the #{noun} is empty" if body.empty?
|
|
898
|
+
if body.bytesize > NOTE_MAX_BYTES
|
|
899
|
+
raise NoteRejected, "the #{noun} is #{body.bytesize} bytes; the limit is 16 KiB (#{NOTE_MAX_BYTES} bytes)"
|
|
900
|
+
end
|
|
901
|
+
|
|
902
|
+
body
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
# Queued notes, oldest first, plus any a crashed worker claimed and left
|
|
906
|
+
# (the absorber skips a note id the conversation already holds).
|
|
907
|
+
def self.find_new_note_files(session_dir)
|
|
908
|
+
notes_dir = File.join(session_dir, NOTES_DIR)
|
|
909
|
+
return [] unless Dir.exist?(notes_dir)
|
|
910
|
+
|
|
911
|
+
Dir.glob(File.join(notes_dir, "*.{json,json.processing}")).sort_by { |p| File.basename(p) }
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
# @return [String, nil] the claimed path, or nil when another claimed it
|
|
915
|
+
def self.claim_note_file(note_file)
|
|
916
|
+
return note_file if note_file.end_with?(".processing")
|
|
917
|
+
|
|
918
|
+
claim_input_file(note_file)
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
# @return [Hash, nil] {note_id:, text:, source:, from_session:, from_cwd:,
|
|
922
|
+
# created_at:}, or nil for a file that holds no usable note
|
|
923
|
+
def self.read_note(note_file)
|
|
924
|
+
data = JSON.parse(File.read(note_file))
|
|
925
|
+
text = data["text"].to_s.strip
|
|
926
|
+
return nil if text.empty?
|
|
927
|
+
|
|
928
|
+
{ note_id: File.basename(note_file).sub(/\.json(\.processing)?\z/, ""), text: text,
|
|
929
|
+
source: data["source"].to_s.empty? ? "cli" : data["source"].to_s,
|
|
930
|
+
from_session: data["from_session"], from_cwd: data["from_cwd"], created_at: data["created_at"] }
|
|
931
|
+
rescue JSON::ParserError, SystemCallError, NoMethodError, TypeError
|
|
932
|
+
nil
|
|
933
|
+
end
|
|
934
|
+
|
|
935
|
+
def self.write_output(session_dir, response)
|
|
936
|
+
output_dir = File.join(session_dir, OUTPUT_DIR)
|
|
937
|
+
FileUtils.mkdir_p(output_dir)
|
|
938
|
+
timestamp = Time.now.strftime("%Y%m%d%H%M%S%9N")
|
|
939
|
+
write_atomic(File.join(output_dir, "#{timestamp}.txt"), response.to_s)
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
def self.find_new_input_files(session_dir)
|
|
943
|
+
input_dir = File.join(session_dir, INPUT_DIR)
|
|
944
|
+
return [] unless Dir.exist?(input_dir)
|
|
945
|
+
|
|
946
|
+
Dir.glob(File.join(input_dir, "*.{txt,json}"))
|
|
947
|
+
end
|
|
948
|
+
|
|
949
|
+
# A worker's sidecar advertises the input format it reads; no sidecar
|
|
950
|
+
# means no worker yet, and the next one (this code) reads JSON.
|
|
951
|
+
private_class_method def self.structured_input?(session_dir)
|
|
952
|
+
worker_input_format(session_dir) >= STRUCTURED_INPUT_FORMAT
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
# Whether the session's worker (or the next one) reads images: refs.
|
|
956
|
+
def self.images_input?(session_dir)
|
|
957
|
+
worker_input_format(session_dir) >= IMAGES_INPUT_FORMAT
|
|
958
|
+
end
|
|
959
|
+
|
|
960
|
+
# The input format the session's worker advertises; no sidecar means no
|
|
961
|
+
# worker yet, and the next one (this code) reads INPUT_FORMAT.
|
|
962
|
+
private_class_method def self.worker_input_format(session_dir)
|
|
963
|
+
sidecar = File.join(session_dir, "bridge.json")
|
|
964
|
+
return INPUT_FORMAT unless File.file?(sidecar)
|
|
965
|
+
|
|
966
|
+
JSON.parse(File.read(sidecar))["input_format"].to_i
|
|
967
|
+
rescue JSON::ParserError, SystemCallError
|
|
968
|
+
INPUT_FORMAT
|
|
969
|
+
end
|
|
970
|
+
|
|
971
|
+
# @return [Array(String, Hash|nil, Boolean, Array<Hash>)] a claimed input
|
|
972
|
+
# file's prompt, origin ({client_id:, enqueued_id:}, nil for plain
|
|
973
|
+
# text), whether its turn runs with the raised iteration limit
|
|
974
|
+
# (--no-interrupt), and its image refs ({file:, name:})
|
|
975
|
+
def self.read_input(claimed_file)
|
|
976
|
+
raw = File.read(claimed_file).to_s
|
|
977
|
+
return [raw, nil, false, []] unless claimed_file.end_with?(".json.processing")
|
|
978
|
+
|
|
979
|
+
data = JSON.parse(raw)
|
|
980
|
+
origin = { client_id: data["client_id"], enqueued_id: data["enqueued_id"] }.compact
|
|
981
|
+
images = Array(data["images"]).select { |image| image.is_a?(Hash) }.map { |image| image.transform_keys(&:to_sym) }
|
|
982
|
+
[data["prompt"].to_s, origin.empty? ? nil : origin, data["no_interrupt"] == true, images]
|
|
983
|
+
rescue JSON::ParserError
|
|
984
|
+
[nil, nil, false, []]
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
# Whether an unclaimed input file carries images (a mid-turn drain
|
|
988
|
+
# leaves it for its own turn).
|
|
989
|
+
def self.input_has_images?(input_file)
|
|
990
|
+
return false unless input_file.end_with?(".json")
|
|
991
|
+
|
|
992
|
+
data = JSON.parse(File.read(input_file))
|
|
993
|
+
data.is_a?(Hash) && Array(data["images"]).any?
|
|
994
|
+
rescue JSON::ParserError, SystemCallError
|
|
995
|
+
false
|
|
996
|
+
end
|
|
997
|
+
|
|
998
|
+
def self.claim_input_file(input_file)
|
|
999
|
+
processing_path = "#{input_file}.processing"
|
|
1000
|
+
File.rename(input_file, processing_path)
|
|
1001
|
+
processing_path
|
|
1002
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
1003
|
+
nil
|
|
1004
|
+
end
|
|
1005
|
+
|
|
1006
|
+
def self.stopped_on_disk?(session_id, state_dir:)
|
|
1007
|
+
Session.load(session_id, state_dir: state_dir).status == Session::STATUS_STOPPED
|
|
1008
|
+
rescue ArgumentError
|
|
1009
|
+
false
|
|
1010
|
+
end
|
|
1011
|
+
|
|
1012
|
+
private_class_method def self.write_atomic(path, content)
|
|
1013
|
+
temp_path = "#{path}.tmp"
|
|
1014
|
+
File.write(temp_path, content)
|
|
1015
|
+
File.rename(temp_path, path)
|
|
1016
|
+
end
|
|
1017
|
+
|
|
1018
|
+
# The session's live owner: the OwnerLock holder, or a live pid-only
|
|
1019
|
+
# worker started before the lock existed (such workers never exit).
|
|
1020
|
+
# @return [Hash, nil] {"pid", "kind" ("worker"/"tui"), ...} or nil
|
|
1021
|
+
def self.session_owner(session_id, state_dir: nil)
|
|
1022
|
+
session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
1023
|
+
return OwnerLock.owner(session_dir) if OwnerLock.lock_file?(session_dir)
|
|
1024
|
+
|
|
1025
|
+
pid = legacy_worker_pid(session_dir)
|
|
1026
|
+
pid && { "pid" => pid, "kind" => "worker" }
|
|
1027
|
+
end
|
|
1028
|
+
|
|
1029
|
+
private_class_method def self.worker_alive_for_session?(session_id, state_dir:)
|
|
1030
|
+
!session_owner(session_id, state_dir: state_dir).nil?
|
|
1031
|
+
end
|
|
1032
|
+
|
|
1033
|
+
private_class_method def self.legacy_worker_pid(session_dir)
|
|
1034
|
+
pid_file = File.join(session_dir, PID_FILE)
|
|
1035
|
+
return nil unless File.exist?(pid_file)
|
|
1036
|
+
|
|
1037
|
+
pid = File.read(pid_file).strip.to_i
|
|
1038
|
+
return nil if pid <= 0
|
|
1039
|
+
|
|
1040
|
+
Process.kill(0, pid)
|
|
1041
|
+
pid
|
|
1042
|
+
rescue Errno::EPERM
|
|
1043
|
+
pid
|
|
1044
|
+
rescue Errno::ESRCH
|
|
1045
|
+
nil
|
|
1046
|
+
end
|
|
1047
|
+
end
|
|
1048
|
+
end
|
|
1049
|
+
|