samagotchi 0.102.0 → 0.104.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +93 -1
- data/bin/chi +60 -191
- data/docs/architecture.md +67 -1
- data/docs/cli.md +190 -9
- data/docs/configuration.md +7 -1
- data/docs/guardrails.md +4 -2
- data/docs/internals/context-telemetry.md +6 -2
- data/docs/internals/engine.md +5 -2
- data/docs/internals/llm-context-forget.md +11 -1
- data/docs/internals/participants.md +137 -0
- data/docs/internals/remote-sessions.md +429 -0
- data/docs/internals/working-set.md +95 -11
- data/docs/plugins.md +33 -3
- data/docs/sessions.md +16 -7
- data/docs/sub-agent.md +16 -0
- data/docs/testing.md +68 -4
- data/lib/samagotchi/answer_command.rb +27 -25
- data/lib/samagotchi/bridge.rb +31 -7
- data/lib/samagotchi/bridge_client/event_stream.rb +86 -12
- data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
- data/lib/samagotchi/bridge_client.rb +26 -39
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_invocation.rb +40 -0
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/flags.rb +5 -0
- data/lib/samagotchi/cli/parent_wait.rb +22 -13
- data/lib/samagotchi/context_command.rb +80 -73
- data/lib/samagotchi/context_status.rb +15 -2
- data/lib/samagotchi/engine.rb +28 -14
- data/lib/samagotchi/guardrail_wiring.rb +7 -1
- data/lib/samagotchi/idle_recap.rb +8 -2
- data/lib/samagotchi/idle_scheduler.rb +34 -9
- data/lib/samagotchi/kernel_loop.rb +21 -2
- data/lib/samagotchi/launch_mode.rb +26 -0
- data/lib/samagotchi/llm/chat_loop.rb +8 -1
- data/lib/samagotchi/llm_context_forget.rb +13 -6
- data/lib/samagotchi/note_command.rb +8 -7
- data/lib/samagotchi/owner_lock.rb +65 -5
- data/lib/samagotchi/parent_report.rb +55 -43
- data/lib/samagotchi/participants.rb +122 -0
- data/lib/samagotchi/remote_attach_command.rb +119 -0
- data/lib/samagotchi/remote_command.rb +170 -0
- data/lib/samagotchi/remotes.rb +130 -0
- data/lib/samagotchi/run_options.rb +245 -0
- data/lib/samagotchi/send_command/busy.rb +58 -0
- data/lib/samagotchi/send_command/parent_gate.rb +23 -10
- data/lib/samagotchi/send_command/wait_target.rb +16 -3
- data/lib/samagotchi/send_command.rb +173 -108
- data/lib/samagotchi/session.rb +38 -9
- data/lib/samagotchi/session_access/contract.rb +167 -0
- data/lib/samagotchi/session_access/dispatch.rb +152 -0
- data/lib/samagotchi/session_access/http_transport.rb +109 -0
- data/lib/samagotchi/session_access/local.rb +400 -0
- data/lib/samagotchi/session_access/remote.rb +734 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +236 -0
- data/lib/samagotchi/session_access.rb +223 -0
- data/lib/samagotchi/session_archive_command.rb +9 -5
- data/lib/samagotchi/session_commands.rb +20 -15
- data/lib/samagotchi/session_continue.rb +1 -1
- data/lib/samagotchi/session_delete_command.rb +8 -4
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +27 -6
- data/lib/samagotchi/session_setup.rb +11 -6
- data/lib/samagotchi/sessions_command.rb +36 -14
- data/lib/samagotchi/share_command.rb +174 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +66 -17
- data/lib/samagotchi/terminal_ui/attach_link.rb +114 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +155 -87
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +135 -0
- data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
- data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +28 -3
- data/lib/samagotchi/terminal_ui.rb +6 -3
- data/lib/samagotchi/testing/waiting.rb +92 -9
- data/lib/samagotchi/title_command.rb +9 -11
- data/lib/samagotchi/tools/task_runtime.rb +17 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +530 -126
- data/lib/samagotchi/web/grants.rb +302 -0
- data/lib/samagotchi/web/participant_access.rb +286 -0
- data/lib/samagotchi/web/principal.rb +29 -0
- data/lib/samagotchi/web/public/data.js +3 -2
- data/lib/samagotchi/web/public/index.html +20 -4
- data/lib/samagotchi/web/public/stage_frame.js +7 -2
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/stream_handlers.js +3 -1
- data/lib/samagotchi/web/public/working_set.js +159 -28
- data/lib/samagotchi/web/public/working_set_rail.js +39 -12
- data/lib/samagotchi/web/public/working_set_view.js +91 -33
- data/lib/samagotchi/web/server.rb +3 -1
- data/lib/samagotchi/web/session_access_routes.rb +459 -0
- data/lib/samagotchi/worker.rb +26 -5
- data/lib/samagotchi/worker_sidecar.rb +12 -5
- data/lib/samagotchi/working_set.rb +88 -14
- data/lib/samagotchi/working_set_command.rb +133 -30
- data/lib/samagotchi/working_set_pin.rb +34 -0
- data/lib/samagotchi/working_set_steer.rb +191 -27
- metadata +28 -1
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../session"
|
|
4
|
+
require_relative "../session_access/local"
|
|
5
|
+
require_relative "../session_inbox"
|
|
6
|
+
require_relative "../reply_wait"
|
|
7
|
+
require_relative "../pending_question"
|
|
8
|
+
require_relative "../context_sources"
|
|
9
|
+
require_relative "../context_command"
|
|
10
|
+
require_relative "../send_command"
|
|
11
|
+
require_relative "../launch_mode"
|
|
12
|
+
require_relative "../model_profile"
|
|
13
|
+
require_relative "../client_id"
|
|
14
|
+
require_relative "../children_status"
|
|
15
|
+
require_relative "../session_metrics"
|
|
16
|
+
|
|
17
|
+
module Samagotchi
|
|
18
|
+
module Web
|
|
19
|
+
# The routes chi's session commands need from another machine's chi web
|
|
20
|
+
# (chi --remote NAME …, SessionAccess::Remote), one per
|
|
21
|
+
# SessionAccess::Local operation that the page's own routes don't cover:
|
|
22
|
+
# a prefix resolved, the CLI's list rows, a wait's pre-send state and
|
|
23
|
+
# the wait itself, a note, a push into a context source. Each calls
|
|
24
|
+
# SessionAccess::Local, so a Remote maps 1:1 onto them. Included in
|
|
25
|
+
# App: its ROUTES name the handlers, and its helpers (json_response,
|
|
26
|
+
# parse_json, …) answer. See docs/architecture.md (chi web's routes for
|
|
27
|
+
# the session commands).
|
|
28
|
+
module SessionAccessRoutes
|
|
29
|
+
# A wait route answers within this many seconds (status timeout); the
|
|
30
|
+
# client asks again until its own --timeout.
|
|
31
|
+
WAIT_MAX_SECONDS = 30.0
|
|
32
|
+
# GET /api/info's feature: this chi web has these routes (a remote
|
|
33
|
+
# client checks it before its first session command).
|
|
34
|
+
FEATURE = "session_commands"
|
|
35
|
+
# GET /api/info's feature: this chi web has the routes a remote
|
|
36
|
+
# attached TUI needs (wake, worker; chi --remote NAME --attach).
|
|
37
|
+
ATTACH_FEATURE = "remote_attach"
|
|
38
|
+
# The longest POST …/wake waits for a worker's Bridge
|
|
39
|
+
# (TerminalUI::AttachLauncher::BRIDGE_WAIT, an attach's here).
|
|
40
|
+
WAKE_MAX_WAIT = 10.0
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# This server's sessions, as a session command reaches them here.
|
|
45
|
+
def session_access = @session_access ||= SessionAccess::Local.new(state_dir: @state_dir)
|
|
46
|
+
|
|
47
|
+
# A refusal an exception carries: its stable +code+, +detail+ (what
|
|
48
|
+
# the page shows; the message by default), and for a remote client
|
|
49
|
+
# the exception's own message and the fields its class is built from
|
|
50
|
+
# (session_id, reason, …), so it can raise the same class.
|
|
51
|
+
def refusal_response(status, code, error, detail: error.message, **fields)
|
|
52
|
+
json_response(status, { error: code, detail: detail, message: error.message, **fields }.compact)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The body as a Hash ({} when there is none), or nil when it isn't one.
|
|
56
|
+
def body_hash(req)
|
|
57
|
+
raw = request_body(req)
|
|
58
|
+
return {} if raw.to_s.strip.empty?
|
|
59
|
+
|
|
60
|
+
parsed = parse_json(raw)
|
|
61
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def truthy_param?(value) = %w[1 true yes].include?(value.to_s.strip.downcase)
|
|
65
|
+
|
|
66
|
+
# GET /api/sessions/resolve?id=PREFIX: the full id of the session an id
|
|
67
|
+
# or a unique prefix names (SessionAccess::Local#resolve). 200 {id};
|
|
68
|
+
# 404 not_found; 400 ambiguous_id with ids (the sessions it names).
|
|
69
|
+
def handle_resolve(req)
|
|
70
|
+
given = req.params["id"].to_s
|
|
71
|
+
return error_response(404, "not_found", "no session #{given[0, 80]}") unless Session.valid_id?(given)
|
|
72
|
+
|
|
73
|
+
json_response(200, { id: session_access.resolve(given) })
|
|
74
|
+
rescue Session::AmbiguousId => e
|
|
75
|
+
refusal_response(400, "ambiguous_id", e, ids: e.ids)
|
|
76
|
+
rescue ArgumentError
|
|
77
|
+
error_response(404, "not_found", "no session #{given[0, 80]}")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# GET /api/sessions/:id?brief=1: the session as a session command
|
|
81
|
+
# reads it (SessionAccess::Local#show): the file's status (not the
|
|
82
|
+
# displayed one), its names, folder, model and parent_answers, its
|
|
83
|
+
# owner's kind, and the file's pending question, never its messages.
|
|
84
|
+
# A chi web from before ?brief answers the whole read, which has the
|
|
85
|
+
# session part too.
|
|
86
|
+
def handle_brief_read(id)
|
|
87
|
+
session = @session_class.load(id, state_dir: default_state_dir)
|
|
88
|
+
owner = session_access.owner(id)
|
|
89
|
+
json_response(200, {
|
|
90
|
+
brief: true,
|
|
91
|
+
session: { id: session.id, status: session.status, title: session.title,
|
|
92
|
+
display_title: session.display_title, project_root: session.project_root,
|
|
93
|
+
working_directory: session.working_directory, model_name: session.model_name,
|
|
94
|
+
model_typed: session.model_typed, parent_answers: session.parent_answers,
|
|
95
|
+
owner: owner&.kind },
|
|
96
|
+
pending_question: session.pending_question&.to_file
|
|
97
|
+
})
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# GET /api/sessions/summaries: the rows chi sessions list prints
|
|
101
|
+
# (SessionManager.session_summaries: status, live, busy, owner, recap,
|
|
102
|
+
# ctx_pct, waiting, …), filtered as its flags filter. Params: dir (a
|
|
103
|
+
# folder: its project's sessions; none: every project's), cwd (in that
|
|
104
|
+
# folder or below), live=1, archived=1, tests=0 (leave test runs out),
|
|
105
|
+
# sort, order, limit. An array, newest first by default.
|
|
106
|
+
def handle_summaries(req)
|
|
107
|
+
dir, error = scope_dir(req.params["dir"])
|
|
108
|
+
return error if error
|
|
109
|
+
|
|
110
|
+
cwd = req.params["cwd"].to_s
|
|
111
|
+
cwd = nil if cwd.strip.empty?
|
|
112
|
+
return error_response(400, "invalid_cwd", "cwd is an absolute path: #{cwd[0, 200]}") if cwd && !cwd.start_with?("/")
|
|
113
|
+
|
|
114
|
+
rows = session_access.summaries(
|
|
115
|
+
live: truthy_param?(req.params["live"]), cwd: cwd, limit: sanitize_limit(req.params["limit"]),
|
|
116
|
+
include_tests: !%w[0 false no].include?(req.params["tests"].to_s.strip.downcase),
|
|
117
|
+
project_root: dir && ProjectScope.root_for(dir), include_archived: truthy_param?(req.params["archived"]),
|
|
118
|
+
sort: sanitize_sort(req.params["sort"]), order: sanitize_order(req.params["order"])
|
|
119
|
+
)
|
|
120
|
+
json_response(200, rows)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# POST /api/sessions/:id/wait/prepare: what chi send --wait and chi
|
|
124
|
+
# answer read before anything is sent (SessionAccess::Local#
|
|
125
|
+
# prepare_wait). Body (all optional): question_id (the question the
|
|
126
|
+
# baseline counts as reported; absent: the one pending now, null:
|
|
127
|
+
# none), turn (--turn: known_turn says whether it can be waited for),
|
|
128
|
+
# created (a session just created: no reply, no question), busy (chi
|
|
129
|
+
# send --wait with a message: refuse a running turn, 409 busy or
|
|
130
|
+
# waiting in SendCommand::Busy's words, with its fields: question,
|
|
131
|
+
# parent_strategy; queue: true, --queue, refuses only a waiting
|
|
132
|
+
# question). 200 {session_id, cursor, baseline:
|
|
133
|
+
# {messages, question_id, last_turn}, own_turn, known_turn, running,
|
|
134
|
+
# live, settled, last_turn_id}.
|
|
135
|
+
def handle_wait_prepare(req, id)
|
|
136
|
+
body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
|
|
137
|
+
turn = body["turn"]
|
|
138
|
+
return error_response(400, "invalid_turn", "turn is a turn id") unless turn.nil? || turn.is_a?(String)
|
|
139
|
+
|
|
140
|
+
session = session_access.show(id)
|
|
141
|
+
if body["busy"] == true
|
|
142
|
+
owner = session_access.owner(id) if session.status == Session::STATUS_RUNNING
|
|
143
|
+
busy = SendCommand::Busy.for(session, owner: owner, queue: body["queue"] == true)
|
|
144
|
+
return json_response(409, busy_json(busy)) if busy
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
question_id = body.key?("question_id") ? body["question_id"]&.to_s : :pending
|
|
148
|
+
start = session_access.prepare_wait(id, session: session, question_id: question_id, turn: turn,
|
|
149
|
+
created: body["created"] == true)
|
|
150
|
+
json_response(200, wait_start_json(id, start))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# A Busy refusal: its words here, and its fields, which a chi --remote
|
|
154
|
+
# client words with its own commands (SessionAccess::Remote#busy).
|
|
155
|
+
def busy_json(busy)
|
|
156
|
+
{ error: busy.error, detail: busy.message, message: busy.message, session_id: busy.session_id,
|
|
157
|
+
question: busy.question&.to_file, parent_strategy: (strategy_json(busy.strategy) if busy.strategy) }.compact
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def strategy_json(strategy) = { name: strategy.name, allowed: strategy.allowed, origin: strategy.origin.to_s }
|
|
161
|
+
|
|
162
|
+
def wait_start_json(id, start)
|
|
163
|
+
{ session_id: id, cursor: start.cursor, baseline: start.baseline.to_h, own_turn: start.own_turn,
|
|
164
|
+
known_turn: start.known_turn, running: start.running, live: start.live, settled: start.settled,
|
|
165
|
+
last_turn_id: start.last_turn_id }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# POST /api/sessions/:id/wait: ReplyWait for at most WAIT_MAX_SECONDS
|
|
169
|
+
# (SessionAccess::Local#wait_reply). Body: cursor, baseline
|
|
170
|
+
# ({messages, question_id, last_turn}, as the prepare route gave it),
|
|
171
|
+
# turn, queued, ended_before, owner_grace (seconds, null: wait for
|
|
172
|
+
# whatever wakes a worker), timeout (seconds, capped). 200: the
|
|
173
|
+
# ReplyWait::Result's fields (status: done, waiting_for_answer,
|
|
174
|
+
# no_reply, error, stopped, timeout or worker_gone; text, file,
|
|
175
|
+
# question, outcome, …; never the Session) and parent_strategy, what
|
|
176
|
+
# a parent may pick on the session's questions here
|
|
177
|
+
# (ParentReport.strategy_for). A timeout status: ask again. 503
|
|
178
|
+
# server_stopping when chi web stops mid-wait.
|
|
179
|
+
def handle_wait(req, id)
|
|
180
|
+
body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
|
|
181
|
+
options, error = wait_options(body)
|
|
182
|
+
return error if error
|
|
183
|
+
|
|
184
|
+
session_access.show(id)
|
|
185
|
+
result = session_access.wait_reply(id, **options, poll_interval: @wait_poll_interval,
|
|
186
|
+
cancelled: -> { !@server_running.call })
|
|
187
|
+
return error_response(503, "server_stopping", "chi web is stopping; wait again") if result.status == :canceled
|
|
188
|
+
|
|
189
|
+
json_response(200, wait_result_json(result, session_access.parent_strategy(id)))
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# The wait's ReplyWait arguments from the body: [options, nil] or
|
|
193
|
+
# [nil, a 400 response].
|
|
194
|
+
def wait_options(body)
|
|
195
|
+
strings = %w[cursor turn].to_h { |key| [key, body[key]] }
|
|
196
|
+
if strings.any? { |_, value| !value.nil? && !value.is_a?(String) }
|
|
197
|
+
return [nil, error_response(400, "invalid_wait", "cursor and turn are strings")]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
baseline, error = wait_baseline(body["baseline"])
|
|
201
|
+
return [nil, error] if error
|
|
202
|
+
|
|
203
|
+
numbers = %w[timeout owner_grace].to_h { |key| [key, body[key]] }
|
|
204
|
+
if numbers.any? { |_, value| !value.nil? && !(value.is_a?(Numeric) && value.positive?) }
|
|
205
|
+
return [nil, error_response(400, "invalid_wait", "timeout and owner_grace are positive seconds")]
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
timeout = [numbers["timeout"] || WAIT_MAX_SECONDS, WAIT_MAX_SECONDS].min
|
|
209
|
+
[{ cursor: strings["cursor"], turn: strings["turn"], baseline: baseline, timeout: timeout,
|
|
210
|
+
owner_grace: numbers["owner_grace"], queued: body["queued"] == true, ended_before: body["ended_before"] == true }, nil]
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def wait_baseline(raw)
|
|
214
|
+
return [nil, nil] if raw.nil?
|
|
215
|
+
|
|
216
|
+
fields = { "messages" => Integer, "question_id" => String, "last_turn" => String }
|
|
217
|
+
unless raw.is_a?(Hash) && raw.all? { |key, value| fields.key?(key) && (value.nil? || value.is_a?(fields[key])) }
|
|
218
|
+
return [nil, error_response(400, "invalid_wait", "baseline is {messages, question_id, last_turn}")]
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
[ReplyWait::Baseline.new(**raw.transform_keys(&:to_sym)), nil]
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
RESULT_FIELDS = %i[text file outcome limit error_kind retryable cancel_reason stopped_by kept_steps turn_id origin].freeze
|
|
225
|
+
|
|
226
|
+
def wait_result_json(result, strategy)
|
|
227
|
+
json = { status: result.status.to_s }
|
|
228
|
+
RESULT_FIELDS.each { |key| json[key] = result[key] }
|
|
229
|
+
json[:text] = json[:text]&.dup&.force_encoding(Encoding::UTF_8)&.scrub
|
|
230
|
+
json[:question] = result.question&.to_file
|
|
231
|
+
json[:parent_strategy] = strategy_json(strategy)
|
|
232
|
+
json
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# POST /api/sessions/:id/wake {wait}: the session's worker for a remote
|
|
236
|
+
# attach (SessionAccess::Local#wake): the live one, else resumed and
|
|
237
|
+
# waited for up to +wait+ seconds (WAKE_MAX_WAIT, also the most). 200
|
|
238
|
+
# {session_id, woke, worker: {epoch, started_at, version, features},
|
|
239
|
+
# model_name, default_model (this machine's config, for the TUI's
|
|
240
|
+
# status row), parent_strategy, installed (the newest chi here)}. A
|
|
241
|
+
# worker still leaving is waited for first (Local#wake: its recap's
|
|
242
|
+
# timeout and a margin, on top of +wait+); 409
|
|
243
|
+
# owned_by_tui (a chi REPL has it), 409 scratch (a leftover scratch
|
|
244
|
+
# session, as chi --attach here says); 504 worker_timeout (its Bridge
|
|
245
|
+
# didn't come in time); 404 not_found.
|
|
246
|
+
def handle_wake(req, id)
|
|
247
|
+
body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
|
|
248
|
+
wait = body.fetch("wait", WAKE_MAX_WAIT)
|
|
249
|
+
return error_response(400, "invalid_wait", "wait is positive seconds") unless wait.is_a?(Numeric) && wait.positive?
|
|
250
|
+
|
|
251
|
+
woken = session_access.wake(id, wait: [wait, WAKE_MAX_WAIT].min)
|
|
252
|
+
unless woken.sidecar
|
|
253
|
+
late = format(LaunchMode::BRIDGE_LATE, id)
|
|
254
|
+
return json_response(504, { error: "worker_timeout", detail: late, message: late, session_id: id })
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
json_response(200, { session_id: id, woke: woken.woke, worker: worker_json(woken.sidecar),
|
|
258
|
+
model_name: session_access.show(id).model_name,
|
|
259
|
+
default_model: ModelProfile.default_model_name,
|
|
260
|
+
parent_strategy: strategy_json(session_access.parent_strategy(id)),
|
|
261
|
+
installed: @hub&.installed })
|
|
262
|
+
rescue SessionManager::OwnedByTUI => e
|
|
263
|
+
owned_by_tui(e)
|
|
264
|
+
rescue SessionAccess::Refused => e
|
|
265
|
+
refusal_response(409, e.code, e, session_id: id)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# GET /api/sessions/:id/worker: the session's worker now, never
|
|
269
|
+
# started (SessionAccess::Local#worker): a remote attach's
|
|
270
|
+
# rediscovery and its features after a restart, and before its wake
|
|
271
|
+
# whether the worker is leaving (its recap on leave: the wake waits
|
|
272
|
+
# for it, so the client says so). 200 {session_id, live, stopped,
|
|
273
|
+
# owner: worker|tui|null, leaving, leave_seconds (its leave's budget
|
|
274
|
+
# left, nil unless leaving), worker: {epoch, started_at, version,
|
|
275
|
+
# features}|null, default_model (this machine's config, as the
|
|
276
|
+
# wake's), parent_strategy}; 404 not_found.
|
|
277
|
+
def handle_worker(_req, id)
|
|
278
|
+
state = session_access.worker(id)
|
|
279
|
+
leaving = state.leaving?
|
|
280
|
+
json_response(200, { session_id: id, live: state.live?, stopped: state.stopped, owner: state.owner&.kind,
|
|
281
|
+
leaving: leaving, leave_seconds: (state.owner.leave_left.round(1) if leaving),
|
|
282
|
+
worker: state.sidecar && worker_json(state.sidecar),
|
|
283
|
+
default_model: ModelProfile.default_model_name,
|
|
284
|
+
parent_strategy: strategy_json(session_access.parent_strategy(id)) })
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# A live worker as a remote client knows it: its epoch is its
|
|
288
|
+
# identity (the one in its event ids).
|
|
289
|
+
def worker_json(sidecar)
|
|
290
|
+
{ epoch: sidecar.epoch, started_at: sidecar.started_at, version: sidecar.version, features: sidecar.features }
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# POST /api/sessions/:id/exit {client_id, delete}: a remote attached
|
|
294
|
+
# TUI's /exit (and the worker's half of /exit --delete and /archive),
|
|
295
|
+
# relayed to the live worker's exit (BridgeClient#request_exit; never
|
|
296
|
+
# wakes one). client_id is passed on only when it is an attached TUI's
|
|
297
|
+
# (tui:…): any other is dropped, and then every stream counts as a UI,
|
|
298
|
+
# which only keeps the worker up. 200 the Bridge's {status: exiting,
|
|
299
|
+
# session_id, discard}; 409 held with its {status, reason, tasks,
|
|
300
|
+
# hint}; 503 not_live, 501 not_supported (an older worker), 504
|
|
301
|
+
# worker_timeout.
|
|
302
|
+
def handle_exit(req, id)
|
|
303
|
+
body = body_hash(req) or return refusal(400, "invalid_json", "invalid JSON body")
|
|
304
|
+
@session_class.load(id, state_dir: default_state_dir)
|
|
305
|
+
client_id = body["client_id"] if body["client_id"].is_a?(String) && body["client_id"].start_with?(ClientId::TUI_PREFIX)
|
|
306
|
+
delete = body["delete"] == true
|
|
307
|
+
|
|
308
|
+
request = ->(client) { client.request_exit(client_id: client_id, delete: delete) }
|
|
309
|
+
with_message(relay(id, bridge_client(id), request, what: "the worker was not asked to exit", cant: "exit from here") do |reply|
|
|
310
|
+
case reply.status
|
|
311
|
+
when 200 then json_response(200, reply.json || { status: "exiting", session_id: id })
|
|
312
|
+
when 409
|
|
313
|
+
held = reply.json || {}
|
|
314
|
+
words = "the session's worker stays up (#{held["reason"] || "held"})"
|
|
315
|
+
json_response(409, held.merge("error" => "held", "detail" => words, "message" => words, "session_id" => id))
|
|
316
|
+
end
|
|
317
|
+
end)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# POST /api/sessions/:id/recap: the saved recap, and a new one asked for
|
|
321
|
+
# (BridgeClient#request_recap; it arrives on the stream as
|
|
322
|
+
# :recap_ready). 200 the Bridge's {enabled, saved, request,
|
|
323
|
+
# min_user_turns, session_off}; 503, 501, 504 as every relay.
|
|
324
|
+
def handle_recap(_req, id)
|
|
325
|
+
@session_class.load(id, state_dir: default_state_dir)
|
|
326
|
+
request = lambda(&:request_recap)
|
|
327
|
+
with_message(relay(id, bridge_client(id), request, what: "no recap was read", cant: "recap") do |reply|
|
|
328
|
+
json_response(200, reply.json || {}) if reply.status == 200
|
|
329
|
+
end)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# GET /api/sessions/:id/stats: the live worker's metrics for /stats
|
|
333
|
+
# (its stats route; a worker from before it answers only its state's
|
|
334
|
+
# metrics). 200 {session_id, metrics}; 503 not_live, 504
|
|
335
|
+
# worker_timeout, 502 bridge_error.
|
|
336
|
+
def handle_stats(_req, id)
|
|
337
|
+
@session_class.load(id, state_dir: default_state_dir)
|
|
338
|
+
client = bridge_client(id) or return with_message(not_live(id))
|
|
339
|
+
|
|
340
|
+
status, json = client.get("stats")
|
|
341
|
+
metrics = json["metrics"] if status == 200 && json.is_a?(Hash)
|
|
342
|
+
if status == 404 && !(json.is_a?(Hash) && json["error"] == "unknown_session")
|
|
343
|
+
status, json = client.get("state")
|
|
344
|
+
metrics = json.dig("session_state_snapshot", "metrics") if status == 200 && json.is_a?(Hash)
|
|
345
|
+
end
|
|
346
|
+
return json_response(200, { session_id: id, metrics: metrics }) if status == 200
|
|
347
|
+
return with_message(worker_timeout("the stats were not read")) if status.nil?
|
|
348
|
+
return with_message(not_live(id)) if status == 404
|
|
349
|
+
|
|
350
|
+
with_message(error_response(502, "bridge_error", "the stats were not read: the session's worker answered #{status}"))
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# GET /api/sessions/:id/children/counts: the session's delegates'
|
|
354
|
+
# counts (ChildrenStatus.counts, from their files here), a remote
|
|
355
|
+
# TUI's status row segment. 200 {running, waiting, unreported}; 404.
|
|
356
|
+
def handle_children_counts(_req, id)
|
|
357
|
+
@session_class.load(id, state_dir: default_state_dir)
|
|
358
|
+
json_response(200, ChildrenStatus.counts(id, state_dir: default_state_dir).to_h)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# A turn id as a session's prompts carry it (a uuid; any word-ish id).
|
|
362
|
+
TURN_ID = /\A[\w.:-]{1,128}\z/
|
|
363
|
+
|
|
364
|
+
# GET /api/sessions/:id/turns/:turn_id/records: a turn's saved tool
|
|
365
|
+
# records and turn record (SessionMetrics, from analytics.json here),
|
|
366
|
+
# as saved: what a remote TUI's join draws the steps' durations and a
|
|
367
|
+
# failed turn's line from. 200 {tool_records: […], turn_record: {…} |
|
|
368
|
+
# null}; 400 invalid_turn_id; 404 not_found.
|
|
369
|
+
def handle_turn_records(_req, id, turn_id)
|
|
370
|
+
return refusal(400, "invalid_turn_id", "not a turn id: #{turn_id.to_s[0, 80]}") unless TURN_ID.match?(turn_id)
|
|
371
|
+
|
|
372
|
+
@session_class.load(id, state_dir: default_state_dir)
|
|
373
|
+
dir = Session.session_dir(id, state_dir: default_state_dir)
|
|
374
|
+
json_response(200, { tool_records: SessionMetrics.saved_tool_records(dir, turn_id),
|
|
375
|
+
turn_record: SessionMetrics.saved_turn_record(dir, turn_id) })
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# {error, detail, message}: the words a remote client raises with.
|
|
379
|
+
def refusal(status, code, words, **fields)
|
|
380
|
+
json_response(status, { error: code, detail: words, message: words, **fields })
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# A relay's error answer (#relay's {error, detail}) with its message
|
|
384
|
+
# too, as a remote attach's routes answer every refusal.
|
|
385
|
+
def with_message(response)
|
|
386
|
+
status, _headers, chunks = response
|
|
387
|
+
return response if status < 400
|
|
388
|
+
|
|
389
|
+
body = JSON.parse(chunks.join)
|
|
390
|
+
return response unless body.is_a?(Hash) && body["error"] && !body.key?("message")
|
|
391
|
+
|
|
392
|
+
json_response(status, body.merge("message" => body["detail"]))
|
|
393
|
+
rescue JSON::ParserError
|
|
394
|
+
response
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# POST /api/sessions/:id/notes {text, source}: a context note for the
|
|
398
|
+
# session (NoteDelivery, as chi note; source: where it came from,
|
|
399
|
+
# "cli" by default, never "broadcast"). 200 {status: "queued" (a
|
|
400
|
+
# worker adds it) or "waits" (the next start does), session_id,
|
|
401
|
+
# queued}; 409 owned_by_tui (status refused: a chi REPL has it); 400
|
|
402
|
+
# note_rejected (empty, or over 16 KiB).
|
|
403
|
+
def handle_notes(req, id)
|
|
404
|
+
body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
|
|
405
|
+
text = body["text"]
|
|
406
|
+
source = body.fetch("source", "cli")
|
|
407
|
+
return error_response(400, "missing_fields", "text is required") unless text.is_a?(String)
|
|
408
|
+
return error_response(400, "invalid_source", "source is a word") unless source.is_a?(String) && !source.strip.empty?
|
|
409
|
+
return error_response(400, "invalid_source", "source broadcast is chi broadcast's own") if source.strip == "broadcast"
|
|
410
|
+
|
|
411
|
+
session_access.show(id)
|
|
412
|
+
result = session_access.note(id, text: text, source: source.strip)
|
|
413
|
+
if result.status == :refused
|
|
414
|
+
error = SessionManager::OwnedByTUI.new(id)
|
|
415
|
+
return refusal_response(409, "owned_by_tui", error, session_id: id, status: "refused", queued: 0)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
json_response(200, { status: result.status.to_s, session_id: id, queued: result.queued })
|
|
419
|
+
rescue SessionInbox::NoteRejected => e
|
|
420
|
+
error_response(400, "note_rejected", e.message)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# POST /api/sessions/:id/context/:name/push {text, summary, wake,
|
|
424
|
+
# hint}: new text for the session's --push source (chi context push;
|
|
425
|
+
# the fields as a source's JSON output gives them, checked the same
|
|
426
|
+
# way). 200 {status: "pushed", name, before, revision, changed}; 404
|
|
427
|
+
# not_found (no such source); 409 not_push (a command source); 400
|
|
428
|
+
# invalid_text.
|
|
429
|
+
def handle_context_push(req, id, name)
|
|
430
|
+
body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
|
|
431
|
+
return error_response(400, "missing_fields", "text is required") unless body["text"].is_a?(String)
|
|
432
|
+
|
|
433
|
+
fetched = begin
|
|
434
|
+
ContextSources.parse_output(JSON.generate(body.slice("text", "summary", "wake", "hint")))
|
|
435
|
+
rescue ContextSources::Invalid => e
|
|
436
|
+
return error_response(400, "invalid_text", e.message)
|
|
437
|
+
end
|
|
438
|
+
pushed = session_access.context_push(context_target(id), name, fetched)
|
|
439
|
+
case pushed.status
|
|
440
|
+
when :missing then error_response(404, "not_found", "no source #{name[0, 40]}")
|
|
441
|
+
when :not_push then error_response(409, "not_push", SessionAccess::ContextPush.not_push_message(name))
|
|
442
|
+
else json_response(200, { status: "pushed", name: name, before: pushed.before, revision: pushed.revision,
|
|
443
|
+
changed: pushed.before != pushed.revision })
|
|
444
|
+
end
|
|
445
|
+
rescue ContextSources::Invalid
|
|
446
|
+
error_response(404, "not_found", "no source #{name[0, 40]}")
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# The session as chi context's Target (SessionAccess::Local's context
|
|
450
|
+
# calls take one).
|
|
451
|
+
# @raise [ArgumentError] no such session
|
|
452
|
+
def context_target(id)
|
|
453
|
+
session = session_access.show(id)
|
|
454
|
+
ContextCommand::Target.new(session_id: id, project_root: session.project_root, cwd: session.working_directory,
|
|
455
|
+
label: id[0, 8])
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
end
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -122,9 +122,14 @@ module Samagotchi
|
|
|
122
122
|
# trapped signals (SessionManager.run_session_loop); none by default
|
|
123
123
|
# @param signal_grace [Array(Numeric, Numeric, Numeric)] the grace, the
|
|
124
124
|
# short grace and the hard exit's delay (specs shorten them)
|
|
125
|
+
# @param owner_lock [OwnerLock, nil] the session's, which this process
|
|
126
|
+
# holds (SessionManager.run_session_loop): marked leaving as the Bridge
|
|
127
|
+
# stops, so an attach waits for the recap on leave (OwnerLock.await_release)
|
|
125
128
|
def initialize(session_id:, state_dir:, session_dir:, idle_exit_minutes: nil, poll_interval: nil,
|
|
126
|
-
signals: WorkerSignals::NONE, signal_grace: [SIGNAL_GRACE, SIGNAL_SHORT_GRACE, SIGNAL_HARD_EXIT]
|
|
129
|
+
signals: WorkerSignals::NONE, signal_grace: [SIGNAL_GRACE, SIGNAL_SHORT_GRACE, SIGNAL_HARD_EXIT],
|
|
130
|
+
owner_lock: nil)
|
|
127
131
|
@session_id = session_id
|
|
132
|
+
@owner_lock = owner_lock
|
|
128
133
|
@state_dir = state_dir
|
|
129
134
|
@session_dir = session_dir
|
|
130
135
|
@idle_exit_minutes = idle_exit_minutes
|
|
@@ -1245,7 +1250,8 @@ module Samagotchi
|
|
|
1245
1250
|
next false unless @idle_exit.due?
|
|
1246
1251
|
|
|
1247
1252
|
@bridge&.close_intake
|
|
1248
|
-
|
|
1253
|
+
# Not joined: a recap tick's emit waits for the event log held here.
|
|
1254
|
+
@engine.stop_idle(join: false)
|
|
1249
1255
|
log_idle_exit
|
|
1250
1256
|
true
|
|
1251
1257
|
end
|
|
@@ -1255,14 +1261,28 @@ module Samagotchi
|
|
|
1255
1261
|
# The Bridge stops once the block (which closed its intake with the
|
|
1256
1262
|
# event log held) answers true, outside the log: Bridge#stop waits for
|
|
1257
1263
|
# the requests it is answering, and one waiting on the log would hold
|
|
1258
|
-
# it for the whole grace before being killed.
|
|
1264
|
+
# it for the whole grace before being killed. The owner lock says it is
|
|
1265
|
+
# leaving first, so a client that finds no Bridge from here on waits
|
|
1266
|
+
# for the lock (OwnerLock.await_release) instead of giving up.
|
|
1259
1267
|
# @return [Boolean] what the block answered
|
|
1260
1268
|
def stop_bridge_after
|
|
1261
1269
|
leaving = yield
|
|
1262
|
-
|
|
1270
|
+
if leaving
|
|
1271
|
+
mark_leaving
|
|
1272
|
+
@bridge&.stop
|
|
1273
|
+
end
|
|
1263
1274
|
leaving
|
|
1264
1275
|
end
|
|
1265
1276
|
|
|
1277
|
+
# How long the lock stays held after the Bridge: the recap on leave's
|
|
1278
|
+
# timeout (#left), none for a restart (#left writes none). A delete
|
|
1279
|
+
# writes none either, but the bound only has to be an upper one.
|
|
1280
|
+
def mark_leaving
|
|
1281
|
+
@owner_lock&.mark_leaving(wait: @exit_restart ? 0 : @engine.recap_timeout.to_f)
|
|
1282
|
+
rescue StandardError => e
|
|
1283
|
+
Log.warn(:worker, "mark_leaving_failed", error: e.class.name, msg: e.message)
|
|
1284
|
+
end
|
|
1285
|
+
|
|
1266
1286
|
# Bridge POST /exit, on a Bridge thread with the event log held.
|
|
1267
1287
|
# +delete+: the session is deleted after (/exit --delete), so no recap.
|
|
1268
1288
|
# +restart+: a new worker takes over (on the newest chi installed), so
|
|
@@ -1313,7 +1333,8 @@ module Samagotchi
|
|
|
1313
1333
|
end
|
|
1314
1334
|
|
|
1315
1335
|
@bridge&.close_intake
|
|
1316
|
-
|
|
1336
|
+
# Not joined: a recap tick's emit waits for the event log held here.
|
|
1337
|
+
@engine.stop_idle(join: false)
|
|
1317
1338
|
Log.info(:worker, @exit_restart ? "restart_requested" : "exit_requested", by: @exit_requested_by || "a client")
|
|
1318
1339
|
true
|
|
1319
1340
|
end
|
|
@@ -10,10 +10,14 @@ module Samagotchi
|
|
|
10
10
|
# Bridge (port, bind), when it started (a new worker behind the same
|
|
11
11
|
# "worker" owner), the chi it runs (chi update, the web's stale-worker
|
|
12
12
|
# badge), the input-file format it reads (SessionInbox::INPUT_FORMAT) and
|
|
13
|
-
# what else it can do (Bridge::FEATURES)
|
|
13
|
+
# what else it can do (Bridge::FEATURES), and its Bridge's epoch (the
|
|
14
|
+
# one worker identity: the epoch in every event id, so a remote client
|
|
15
|
+
# tells a restarted worker apart; nil from a worker before it). The
|
|
16
|
+
# Bridge writes it on start
|
|
14
17
|
# and removes it on stop; one a dead worker left stays until a client's
|
|
15
18
|
# probe finds its port closed (.live with unlink: true).
|
|
16
|
-
class WorkerSidecar < Data.define(:port, :bind, :session_id, :started_at, :version, :input_format, :features
|
|
19
|
+
class WorkerSidecar < Data.define(:port, :bind, :session_id, :started_at, :version, :input_format, :features,
|
|
20
|
+
:epoch)
|
|
17
21
|
FILE = "bridge.json"
|
|
18
22
|
# Seconds a liveness probe waits for the port to take a connect.
|
|
19
23
|
PROBE_TIMEOUT = 0.2
|
|
@@ -29,7 +33,7 @@ module Samagotchi
|
|
|
29
33
|
port = data["port"]
|
|
30
34
|
new(port: port.respond_to?(:to_i) ? port.to_i : 0, bind: data["bind"], session_id: data["session_id"],
|
|
31
35
|
started_at: data["started_at"], version: data["version"], input_format: data["input_format"],
|
|
32
|
-
features: Array(data["features"]).map(&:to_s))
|
|
36
|
+
features: Array(data["features"]).map(&:to_s), epoch: data["epoch"]&.to_s)
|
|
33
37
|
rescue JSON::ParserError, SystemCallError
|
|
34
38
|
nil
|
|
35
39
|
end
|
|
@@ -63,13 +67,16 @@ module Samagotchi
|
|
|
63
67
|
# @return [Integer, nil] .live's port
|
|
64
68
|
def self.live_port(session_dir, unlink:, host: "127.0.0.1") = live(session_dir, unlink: unlink, host: host)&.port
|
|
65
69
|
|
|
66
|
-
def initialize(port:, bind: nil, session_id: nil, started_at: nil, version: nil, input_format: nil, features: []
|
|
70
|
+
def initialize(port:, bind: nil, session_id: nil, started_at: nil, version: nil, input_format: nil, features: [],
|
|
71
|
+
epoch: nil) = super
|
|
67
72
|
|
|
68
|
-
# Written whole or not at all; input_format only when
|
|
73
|
+
# Written whole or not at all; input_format and epoch only when they
|
|
74
|
+
# name one.
|
|
69
75
|
def write(session_dir)
|
|
70
76
|
FileUtils.mkdir_p(session_dir)
|
|
71
77
|
record = to_h.transform_keys(&:to_s)
|
|
72
78
|
record.delete("input_format") if input_format.nil?
|
|
79
|
+
record.delete("epoch") if epoch.nil?
|
|
73
80
|
AtomicFile.write(self.class.path(session_dir), "#{JSON.pretty_generate(record)}\n")
|
|
74
81
|
end
|
|
75
82
|
end
|