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.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -1
  3. data/bin/chi +60 -191
  4. data/docs/architecture.md +67 -1
  5. data/docs/cli.md +190 -9
  6. data/docs/configuration.md +7 -1
  7. data/docs/guardrails.md +4 -2
  8. data/docs/internals/context-telemetry.md +6 -2
  9. data/docs/internals/engine.md +5 -2
  10. data/docs/internals/llm-context-forget.md +11 -1
  11. data/docs/internals/participants.md +137 -0
  12. data/docs/internals/remote-sessions.md +429 -0
  13. data/docs/internals/working-set.md +95 -11
  14. data/docs/plugins.md +33 -3
  15. data/docs/sessions.md +16 -7
  16. data/docs/sub-agent.md +16 -0
  17. data/docs/testing.md +68 -4
  18. data/lib/samagotchi/answer_command.rb +27 -25
  19. data/lib/samagotchi/bridge.rb +31 -7
  20. data/lib/samagotchi/bridge_client/event_stream.rb +86 -12
  21. data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
  22. data/lib/samagotchi/bridge_client.rb +26 -39
  23. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
  25. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
  27. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
  28. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  29. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  30. data/lib/samagotchi/chi_invocation.rb +40 -0
  31. data/lib/samagotchi/cli/command.rb +3 -2
  32. data/lib/samagotchi/cli/flags.rb +5 -0
  33. data/lib/samagotchi/cli/parent_wait.rb +22 -13
  34. data/lib/samagotchi/context_command.rb +80 -73
  35. data/lib/samagotchi/context_status.rb +15 -2
  36. data/lib/samagotchi/engine.rb +28 -14
  37. data/lib/samagotchi/guardrail_wiring.rb +7 -1
  38. data/lib/samagotchi/idle_recap.rb +8 -2
  39. data/lib/samagotchi/idle_scheduler.rb +34 -9
  40. data/lib/samagotchi/kernel_loop.rb +21 -2
  41. data/lib/samagotchi/launch_mode.rb +26 -0
  42. data/lib/samagotchi/llm/chat_loop.rb +8 -1
  43. data/lib/samagotchi/llm_context_forget.rb +13 -6
  44. data/lib/samagotchi/note_command.rb +8 -7
  45. data/lib/samagotchi/owner_lock.rb +65 -5
  46. data/lib/samagotchi/parent_report.rb +55 -43
  47. data/lib/samagotchi/participants.rb +122 -0
  48. data/lib/samagotchi/remote_attach_command.rb +119 -0
  49. data/lib/samagotchi/remote_command.rb +170 -0
  50. data/lib/samagotchi/remotes.rb +130 -0
  51. data/lib/samagotchi/run_options.rb +245 -0
  52. data/lib/samagotchi/send_command/busy.rb +58 -0
  53. data/lib/samagotchi/send_command/parent_gate.rb +23 -10
  54. data/lib/samagotchi/send_command/wait_target.rb +16 -3
  55. data/lib/samagotchi/send_command.rb +173 -108
  56. data/lib/samagotchi/session.rb +38 -9
  57. data/lib/samagotchi/session_access/contract.rb +167 -0
  58. data/lib/samagotchi/session_access/dispatch.rb +152 -0
  59. data/lib/samagotchi/session_access/http_transport.rb +109 -0
  60. data/lib/samagotchi/session_access/local.rb +400 -0
  61. data/lib/samagotchi/session_access/remote.rb +734 -0
  62. data/lib/samagotchi/session_access/web_bridge_client.rb +236 -0
  63. data/lib/samagotchi/session_access.rb +223 -0
  64. data/lib/samagotchi/session_archive_command.rb +9 -5
  65. data/lib/samagotchi/session_commands.rb +20 -15
  66. data/lib/samagotchi/session_continue.rb +1 -1
  67. data/lib/samagotchi/session_delete_command.rb +8 -4
  68. data/lib/samagotchi/session_inbox.rb +18 -9
  69. data/lib/samagotchi/session_manager.rb +27 -6
  70. data/lib/samagotchi/session_setup.rb +11 -6
  71. data/lib/samagotchi/sessions_command.rb +36 -14
  72. data/lib/samagotchi/share_command.rb +174 -0
  73. data/lib/samagotchi/terminal_ui/attach_launcher.rb +66 -17
  74. data/lib/samagotchi/terminal_ui/attach_link.rb +114 -0
  75. data/lib/samagotchi/terminal_ui/attached_loop.rb +155 -87
  76. data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
  77. data/lib/samagotchi/terminal_ui/local_link.rb +135 -0
  78. data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
  79. data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
  80. data/lib/samagotchi/terminal_ui/version_lines.rb +28 -3
  81. data/lib/samagotchi/terminal_ui.rb +6 -3
  82. data/lib/samagotchi/testing/waiting.rb +92 -9
  83. data/lib/samagotchi/title_command.rb +9 -11
  84. data/lib/samagotchi/tools/task_runtime.rb +17 -3
  85. data/lib/samagotchi/version.rb +1 -1
  86. data/lib/samagotchi/web/app.rb +530 -126
  87. data/lib/samagotchi/web/grants.rb +302 -0
  88. data/lib/samagotchi/web/participant_access.rb +286 -0
  89. data/lib/samagotchi/web/principal.rb +29 -0
  90. data/lib/samagotchi/web/public/data.js +3 -2
  91. data/lib/samagotchi/web/public/index.html +20 -4
  92. data/lib/samagotchi/web/public/stage_frame.js +7 -2
  93. data/lib/samagotchi/web/public/stage_view.js +3 -2
  94. data/lib/samagotchi/web/public/stream_handlers.js +3 -1
  95. data/lib/samagotchi/web/public/working_set.js +159 -28
  96. data/lib/samagotchi/web/public/working_set_rail.js +39 -12
  97. data/lib/samagotchi/web/public/working_set_view.js +91 -33
  98. data/lib/samagotchi/web/server.rb +3 -1
  99. data/lib/samagotchi/web/session_access_routes.rb +459 -0
  100. data/lib/samagotchi/worker.rb +26 -5
  101. data/lib/samagotchi/worker_sidecar.rb +12 -5
  102. data/lib/samagotchi/working_set.rb +88 -14
  103. data/lib/samagotchi/working_set_command.rb +133 -30
  104. data/lib/samagotchi/working_set_pin.rb +34 -0
  105. data/lib/samagotchi/working_set_steer.rb +191 -27
  106. 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
@@ -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
- @engine.stop_idle
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
- @bridge&.stop if leaving
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
- @engine.stop_idle
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). The Bridge writes it on start
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: []) = super
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 it names one.
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