samagotchi 0.32.0 → 0.33.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/docs/context.md +3 -1
  4. data/docs/hooks.md +6 -3
  5. data/docs/plugins.md +2 -1
  6. data/docs/releasing.md +2 -1
  7. data/lib/samagotchi/answer_command.rb +2 -1
  8. data/lib/samagotchi/archive_store.rb +1 -15
  9. data/lib/samagotchi/bridge.rb +8 -3
  10. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  11. data/lib/samagotchi/child_reports.rb +31 -10
  12. data/lib/samagotchi/client_id.rb +52 -0
  13. data/lib/samagotchi/context_absorber.rb +2 -1
  14. data/lib/samagotchi/context_fetch.rb +14 -45
  15. data/lib/samagotchi/context_sources.rb +20 -10
  16. data/lib/samagotchi/engine.rb +10 -7
  17. data/lib/samagotchi/guardrails/parent_approvals.rb +0 -2
  18. data/lib/samagotchi/memory_bundle/builder.rb +22 -1
  19. data/lib/samagotchi/memory_bundle/manifest.rb +3 -1
  20. data/lib/samagotchi/plugin/sessions.rb +2 -1
  21. data/lib/samagotchi/process_group.rb +160 -0
  22. data/lib/samagotchi/prompt_warmup.rb +20 -1
  23. data/lib/samagotchi/question_desk.rb +2 -1
  24. data/lib/samagotchi/relay_verifier.rb +2 -4
  25. data/lib/samagotchi/reply_wait.rb +9 -4
  26. data/lib/samagotchi/send_command.rb +2 -1
  27. data/lib/samagotchi/session_manager.rb +2 -4
  28. data/lib/samagotchi/steer.rb +27 -19
  29. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -4
  30. data/lib/samagotchi/terminal_ui/formatting.rb +21 -12
  31. data/lib/samagotchi/terminal_ui.rb +2 -1
  32. data/lib/samagotchi/tools/delegate.rb +2 -2
  33. data/lib/samagotchi/tools/execute.rb +10 -81
  34. data/lib/samagotchi/tools/task_runtime.rb +25 -38
  35. data/lib/samagotchi/version.rb +1 -1
  36. data/lib/samagotchi/web/app.rb +2 -1
  37. data/lib/samagotchi/web/public/app.js +19 -20
  38. data/lib/samagotchi/web/public/data.js +3 -0
  39. data/lib/samagotchi/web/public/question_card.js +3 -4
  40. data/lib/samagotchi/web/public/shown_texts.js +13 -0
  41. data/lib/samagotchi/web/public/stable_html.js +18 -0
  42. data/lib/samagotchi/web/public/turn_events.js +9 -2
  43. data/lib/samagotchi/web/public/turn_view.js +1 -1
  44. data/lib/samagotchi/worker.rb +33 -11
  45. metadata +5 -1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require_relative "../process_group"
4
5
  require_relative "output_guardrails"
5
6
  require_relative "shell"
6
7
 
@@ -95,7 +96,7 @@ module Samagotchi
95
96
 
96
97
  Open3.popen3(env || {}, *Shell.argv(command), chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
97
98
  stdin.close
98
- readers = [OutputReader.new(stdout), OutputReader.new(stderr)]
99
+ readers = [stdout, stderr].map { |io| ProcessGroup::PipeReader.new(io, chunk: READ_CHUNK_BYTES) }
99
100
 
100
101
  begin
101
102
  until wait_thr.join(WAIT_SLICE_SEC)
@@ -132,7 +133,7 @@ module Samagotchi
132
133
  stopped = stop_process_group(wait_thr.pid, readers)
133
134
  held_open = (stopped ? :stopped : :detached) if outcome == :grace && !stopping
134
135
  end
135
- readers.each(&:finish)
136
+ readers.each { |reader| reader.finish(poll: STOP_POLL_INTERVAL_SEC, grace: STOP_GRACE_SEC) }
136
137
  stdout_text, stderr_text = readers.map(&:text)
137
138
  end
138
139
  end
@@ -162,95 +163,23 @@ module Samagotchi
162
163
  # has members holding the output: TERM the group, then KILL it. A process
163
164
  # that left the group (setsid) is out of reach; the pipes get closed.
164
165
  # True once the output was released, false if something still holds it.
166
+ # Only the group: the leader's pid is reaped, so it may name another process.
165
167
  def self.stop_process_group(pgid, readers)
166
- signal_group(pgid, "TERM")
167
- return true if await_readers(readers, until_time: monotonic_time + STOP_GRACE_SEC, deadline: nil,
168
- cancelled: -> { false }) == :done
168
+ released = -> { readers.all?(&:done?) }
169
+ wait = ->(seconds) { readers.find { |r| !r.done? }&.wait(seconds) }
170
+ return true if ProcessGroup.stop(pgid, grace: STOP_GRACE_SEC, poll: STOP_POLL_INTERVAL_SEC, stopped: released, wait: wait)
169
171
 
170
- signal_group(pgid, "KILL")
171
172
  await_readers(readers, until_time: monotonic_time + STOP_GRACE_SEC, deadline: nil, cancelled: -> { false }) == :done
172
173
  end
173
174
  private_class_method :stop_process_group
174
175
 
175
- # Only the group: the leader's pid is reaped, so it may name another process.
176
- def self.signal_group(pgid, signal)
177
- Process.kill(signal, -pgid)
178
- rescue Errno::ESRCH, Errno::EPERM
179
- nil
180
- end
181
- private_class_method :signal_group
182
-
183
- # Reads a pipe in chunks on its own thread, so the output read so far
184
- # survives the pipe being closed under it.
185
- class OutputReader
186
- def initialize(io)
187
- @io = io
188
- @buffer = String.new(encoding: Encoding::BINARY)
189
- @thread = Thread.new do
190
- Thread.current.report_on_exception = false
191
- loop { @buffer << io.readpartial(READ_CHUNK_BYTES) }
192
- rescue IOError # EOFError is one
193
- nil
194
- end
195
- end
196
-
197
- def done? = !@thread.alive?
198
-
199
- def wait(seconds) = @thread.join(seconds)
200
-
201
- # Closes the pipe (a reader still blocked gets IOError) and ends the
202
- # thread; the text is safe to read afterwards.
203
- def finish
204
- @thread.join(STOP_POLL_INTERVAL_SEC) unless done?
205
- close_quietly
206
- @thread.kill unless @thread.join(STOP_GRACE_SEC)
207
- end
208
-
209
- def text
210
- @buffer.dup.force_encoding(@io.external_encoding || Encoding.default_external)
211
- end
212
-
213
- private
214
-
215
- def close_quietly
216
- @io.close unless @io.closed?
217
- rescue IOError
218
- nil
219
- end
220
- end
221
- private_constant :OutputReader
222
-
176
+ # The shell still runs: TERM its group (or the shell alone, if it left
177
+ # the group), then KILL it once the grace is over.
223
178
  def self.terminate_process_tree(pid)
224
- signal_process(pid, "TERM")
225
-
226
- deadline = monotonic_time + STOP_GRACE_SEC
227
- sleep(STOP_POLL_INTERVAL_SEC) while process_alive?(pid) && monotonic_time < deadline
228
-
229
- signal_process(pid, "KILL") if process_alive?(pid)
179
+ ProcessGroup.stop(pid, grace: STOP_GRACE_SEC, poll: STOP_POLL_INTERVAL_SEC, leader: true)
230
180
  end
231
181
  private_class_method :terminate_process_tree
232
182
 
233
- def self.signal_process(pid, signal)
234
- Process.kill(signal, -pid)
235
- rescue Errno::ESRCH, Errno::EPERM
236
- begin
237
- Process.kill(signal, pid)
238
- rescue Errno::ESRCH, Errno::EPERM
239
- nil
240
- end
241
- end
242
- private_class_method :signal_process
243
-
244
- def self.process_alive?(pid)
245
- Process.kill(0, pid)
246
- true
247
- rescue Errno::ESRCH
248
- false
249
- rescue Errno::EPERM
250
- true
251
- end
252
- private_class_method :process_alive?
253
-
254
183
  def self.monotonic_time
255
184
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
256
185
  end
@@ -6,6 +6,7 @@ require "securerandom"
6
6
  require "shellwords"
7
7
  require "time"
8
8
  require_relative "../atomic_file"
9
+ require_relative "../process_group"
9
10
  require_relative "shell"
10
11
 
11
12
  module Samagotchi
@@ -24,6 +25,9 @@ module Samagotchi
24
25
  MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL].freeze
25
26
  # task_wait and task_get add it to a task the user stopped (the web's
26
27
  # stop-task button), so the model doesn't rerun the job.
28
+ # A record whose pid isn't a process group chi started (missing, 0, 1,
29
+ # not a number, or a pid another process reuses): nothing is signalled.
30
+ NOT_CHIS_PROCESS = "the task's process is gone or isn't the one chi started"
27
31
  USER_STOP_NOTE = "note: the user stopped this task; don't restart it unless they ask."
28
32
 
29
33
  module_function
@@ -54,13 +58,12 @@ module Samagotchi
54
58
 
55
59
  wrapped_command = wrapped_shell_command(normalized_command, exit_code_path)
56
60
  # Non-login, the shell Execute uses too (Shell).
57
- pid = Process.spawn(
61
+ pid = ProcessGroup.spawn(
58
62
  spawn_env,
59
63
  *Shell.argv(wrapped_command),
60
64
  chdir: resolved_cwd,
61
65
  out: output_io,
62
- err: output_io,
63
- pgroup: true
66
+ err: output_io
64
67
  )
65
68
  Process.detach(pid)
66
69
 
@@ -185,26 +188,15 @@ module Samagotchi
185
188
  refreshed = refresh_record(record)
186
189
  return [refreshed, nil] unless refreshed["status"] == "running"
187
190
 
191
+ pid = refreshed["pid"]
192
+ # Checked again just before the signal: never a group chi didn't start.
193
+ return [mark_not_chis(refreshed), nil] unless ProcessGroup.leader?(pid)
194
+
188
195
  refreshed["stop_requested_by"] = by.to_s
189
196
  write_record(refreshed)
190
- pid = refreshed["pid"].to_i
191
-
192
- begin
193
- Process.kill("TERM", -pid)
194
- rescue Errno::ESRCH
195
- # Process already exited; status refresh below handles final state.
196
- end
197
197
 
198
- deadline = monotonic_time + STOP_GRACE_SEC
199
- sleep(STOP_POLL_INTERVAL_SEC) while process_alive?(pid) && monotonic_time < deadline
200
-
201
- if process_alive?(pid)
202
- begin
203
- Process.kill("KILL", -pid)
204
- rescue Errno::ESRCH
205
- # Process exited between checks.
206
- end
207
- end
198
+ # A group that isn't ours (EPERM) is an error, not a stop.
199
+ ProcessGroup.stop(pid, grace: STOP_GRACE_SEC, poll: STOP_POLL_INTERVAL_SEC, quiet: [Errno::ESRCH])
208
200
 
209
201
  updated = mark_stopped(refresh_record(refreshed))
210
202
  write_record(updated)
@@ -217,10 +209,18 @@ module Samagotchi
217
209
  def refresh_record(record)
218
210
  return record unless record["status"] == "running"
219
211
 
220
- pid = record["pid"].to_i
221
- return mark_finished_without_exit_code(record) unless process_alive?(pid)
212
+ pid = record["pid"]
213
+ return record if ProcessGroup.leader?(pid)
214
+ # Gone: its exit code says how it ended.
215
+ return mark_finished_without_exit_code(record) if ProcessGroup.signalable?(pid) && !ProcessGroup.alive?(pid)
222
216
 
223
- record
217
+ mark_not_chis(record)
218
+ end
219
+
220
+ # A running record whose pid isn't a group chi started (NOT_CHIS_PROCESS)
221
+ # ends as failed, unless its exit code or a stop on disk says more.
222
+ def mark_not_chis(record)
223
+ mark_finished_without_exit_code(record, missing_reason: NOT_CHIS_PROCESS)
224
224
  end
225
225
 
226
226
  # A session id (a uuid) is never a task's: task ids are a timestamp
@@ -338,16 +338,7 @@ module Samagotchi
338
338
  filtered.join(" ")
339
339
  end
340
340
 
341
- def process_alive?(pid)
342
- Process.kill(0, pid)
343
- true
344
- rescue Errno::ESRCH
345
- false
346
- rescue Errno::EPERM
347
- true
348
- end
349
-
350
- def mark_finished_without_exit_code(record)
341
+ def mark_finished_without_exit_code(record, missing_reason: "process_ended_without_exit_code")
351
342
  record = current_record(record)
352
343
  return record unless record["status"] == "running"
353
344
 
@@ -359,7 +350,7 @@ module Samagotchi
359
350
  elsif exit_code.nil?
360
351
  record["status"] = "failed"
361
352
  record["exit_code"] = nil
362
- record["stop_reason"] ||= "process_ended_without_exit_code"
353
+ record["stop_reason"] ||= missing_reason
363
354
  elsif exit_code.zero?
364
355
  record["status"] = "completed"
365
356
  record["exit_code"] = 0
@@ -401,10 +392,6 @@ module Samagotchi
401
392
  def timestamp
402
393
  Time.now.utc.iso8601
403
394
  end
404
-
405
- def monotonic_time
406
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
407
- end
408
395
  end
409
396
  end
410
397
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.32.0"
4
+ VERSION = "0.33.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -9,6 +9,7 @@ require "uri"
9
9
  require "rack"
10
10
  require "rack/request"
11
11
 
12
+ require_relative "../client_id"
12
13
  require_relative "../answer_tail"
13
14
  require_relative "../bridge_client"
14
15
  require_relative "../bridge/bounded_queue"
@@ -1069,7 +1070,7 @@ module Samagotchi
1069
1070
  # {error: "held" | "not_running" | "unsupported" | "failed", reason, detail} in words; 409
1070
1071
  # owned_by_tui; 404.
1071
1072
  def handle_restart(_req, id)
1072
- result = @manager.restart_session(id, state_dir: @state_dir, client_id: "web:restart")
1073
+ result = @manager.restart_session(id, state_dir: @state_dir, client_id: ClientId::WEB_RESTART)
1073
1074
  @hub&.touch(id)
1074
1075
  json_response(200, { status: "restarted", session_id: id, from_version: result.from_version,
1075
1076
  version: result.version })
@@ -21,6 +21,8 @@ import {
21
21
  addContext,
22
22
  } from "./data.js";
23
23
  import { createContextChips } from "./context_chips.js";
24
+ import { renderStable } from "./stable_html.js";
25
+ import { createShownTexts } from "./shown_texts.js";
24
26
  import { MODEL_KEY } from "./model_pick.js";
25
27
  import { emptySessionState } from "./session_state.js";
26
28
  import { createModelPicker } from "./model_picker.js";
@@ -63,7 +65,7 @@ const infoBarEl = $("#infoBar");
63
65
  const firstPeekEl = $("#firstPeek");
64
66
  const infoTextEl = $("#infoText");
65
67
  // The open session's attached context, in its own row of the session bar
66
- // (updateInfoBar rewrites #infoText every tick; the chips keep their popover).
68
+ // (updateInfoBar redraws #infoText every tick; the chips keep their popover).
67
69
  const contextChips = createContextChips({
68
70
  el: $("#contextChips"),
69
71
  api: { list: listContext, show: getContext, detach: detachContext, add: addContext },
@@ -87,7 +89,8 @@ let eventsControl = null;
87
89
  let resyncsInFlight = 0;
88
90
  let resyncAgain = false;
89
91
  let allSessions = [];
90
- let seenContent = new Set();
92
+ // The running turn's texts on the page (the turn output dedupe).
93
+ const shownTexts = createShownTexts();
91
94
  // The page's view of a turn (web.view, or ?view=stage|turn): the running
92
95
  // turn pinned above the composer (the stage, stage_view.js), or one block
93
96
  // per turn with its steps in the history (turn_view.js).
@@ -98,7 +101,7 @@ const viewArgs = {
98
101
  historyEl,
99
102
  appendToHistory: (el) => appendToHistory(el),
100
103
  isNearBottom: () => isNearBottom(),
101
- sawText: (k) => seenContent.add(k),
104
+ sawText: (k) => shownTexts.saw(k),
102
105
  sessionId: () => selected,
103
106
  // The stop-task button on a running task_wait, for a worker that has
104
107
  // the route (its sidecar features).
@@ -950,11 +953,7 @@ function applyFinalMarkdown(messages) {
950
953
  }
951
954
 
952
955
  function seedSeenFromDom() {
953
- seenContent = new Set();
954
- findInTurnRoots(".bubble").forEach((b) => {
955
- const k = normalize(b.textContent);
956
- if (k) seenContent.add(k);
957
- });
956
+ shownTexts.seed(findInTurnRoots(".bubble").map((b) => normalize(b.textContent)));
958
957
  }
959
958
 
960
959
  // Sticky-follow scroll: only auto-scroll to the bottom while the user is
@@ -974,7 +973,7 @@ function atHistoryEnd() {
974
973
  function appendChunk(text) {
975
974
  if (!text) return;
976
975
  const k = normalize(text);
977
- if (!k || seenContent.has(k)) return;
976
+ if (!k || shownTexts.shows(k)) return;
978
977
  const follow = isNearBottom();
979
978
  const hint = historyEl.querySelector(".hint");
980
979
  if (hint) hint.remove();
@@ -1464,7 +1463,7 @@ function updateInfoBar() {
1464
1463
  }
1465
1464
  if (!selected) {
1466
1465
  // The start page hides the info bar (the storage path is on #/sessions).
1467
- infoTextEl.innerHTML = "";
1466
+ renderStable(infoTextEl, "");
1468
1467
  infoTextEl.title = "";
1469
1468
  if (stopBtn) stopBtn.style.display = "none";
1470
1469
  return;
@@ -1488,9 +1487,11 @@ function updateInfoBar() {
1488
1487
  const attachCmd = `chi --attach ${selected}`;
1489
1488
  const copied = Date.now() - attachCopiedAt < 2000;
1490
1489
  const copyHtml = `<button type="button" class="copy-attach" title="Copy &quot;${escapeHtml(attachCmd)}&quot; to attach a terminal">${copied ? "copied ✓" : "copy chi --attach"}</button>`;
1491
- const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}"><bdi>${escapeHtml(current.dir||"")}</bdi></span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model">${escapeHtml(timingText)}</span>` : ""}${workerBadgeHtml()}</div>`;
1490
+ const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}"><bdi>${escapeHtml(current.dir||"")}</bdi></span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model session-time"></span>` : ""}${workerBadgeHtml()}</div>`;
1492
1491
  const previewHtml = `<div class="preview-line"><span class="preview">${escapeHtml(current.firstPreview || "—")}</span> · <span class="mem" title="${escapeHtml(current.usedMemories.join(", "))}">mem: ${escapeHtml(memText)}</span></div>`;
1493
- infoTextEl.innerHTML = metaHtml + previewHtml;
1492
+ // The session clock ticks every second while a turn runs: only its text
1493
+ // changes, the bar's other nodes (a tooltip, a button mid-press) stay.
1494
+ renderStable(infoTextEl, metaHtml + previewHtml, { ".session-time": timingText });
1494
1495
  infoTextEl.title = memoriesTooltip(current.usedMemories, current.preloadedMemories, current.mutedMemories);
1495
1496
  }
1496
1497
 
@@ -1759,6 +1760,7 @@ const streamHandlers = {
1759
1760
  // for a queued prompt among its rows.
1760
1761
  turnDom.handOff?.({ immediate: true });
1761
1762
  markRecapStale();
1763
+ shownTexts.turnStarted();
1762
1764
  const promptBubble = applyPromptOps(data);
1763
1765
  setTurnRunning(true);
1764
1766
  setLiveStatus("running");
@@ -2071,12 +2073,9 @@ async function handleSendTurn() {
2071
2073
  // for its ack is never taken back (4.13), so drop it here. The prompt
2072
2074
  // is still in the composer, as the echo was removed.
2073
2075
  dropEarlyRestores(earlyRestores);
2074
- // The chips stay (uploaded ones keep their ref) for another try.
2075
- if (/bad_image|too_large/.test(e.message)) {
2076
- alert(e.message);
2077
- return;
2078
- }
2079
- if (/owned_by_tui|409/.test(e.message)) {
2076
+ // The chips stay (uploaded ones keep their ref) for another try: an
2077
+ // image refused (bad_image, too_large, bad_images) says why below.
2078
+ if (e.code === "owned_by_tui") {
2080
2079
  current.owner = "tui";
2081
2080
  updateComposerMode();
2082
2081
  return;
@@ -2107,7 +2106,7 @@ async function handleSendCommand(line) {
2107
2106
  // The command woke the worker: follow its stream if none is open.
2108
2107
  followWokenWorker();
2109
2108
  } catch (e) {
2110
- if (/owned_by_tui|409/.test(e.message)) {
2109
+ if (e.code === "owned_by_tui") {
2111
2110
  current.owner = "tui";
2112
2111
  updateComposerMode();
2113
2112
  return;
@@ -2168,7 +2167,7 @@ async function handleCancel() {
2168
2167
  await cancelTurn(selected);
2169
2168
  } catch (e) {
2170
2169
  // The turn ended meanwhile: nothing left to cancel.
2171
- if (!/not_running|409/.test(e.message)) alert(e.message);
2170
+ if (e.code !== "not_running") alert(e.message);
2172
2171
  } finally {
2173
2172
  cancelBtn.disabled = false;
2174
2173
  }
@@ -8,6 +8,9 @@ export function api(path, opts = {}) {
8
8
  if (!r.ok) {
9
9
  const error = new Error(`${j.detail || j.error || r.statusText} (${r.status})`);
10
10
  error.status = r.status;
11
+ // The server's error code ({error: "owned_by_tui", detail}): what
12
+ // the page matches on, never the message's words.
13
+ error.code = j.error || null;
11
14
  throw error;
12
15
  }
13
16
  return j;
@@ -163,14 +163,13 @@ export function approvalAllowed(pq, answer) {
163
163
  }
164
164
 
165
165
  // What the card says when an answer or a dismissal didn't go through. 503
166
- // (the server found no live bridge) reads as the session not running; any
167
- // other failure — the worker's own (the server maps it to a 502
166
+ // not_live (the server found no live bridge) reads as the session not
167
+ // running; any other failure — the worker's own (the server maps it to a 502
168
168
  // bridge_error) or the connection's — shows what it said, so a worker that
169
169
  // failed the request is not mistaken for one that is gone.
170
170
  export function answerErrorText(error) {
171
- const status = error?.status;
172
171
  const message = String(error?.message ?? error ?? "");
173
- if (status === 503 || /not_live/.test(message)) return "Session is not running — restart it to answer.";
172
+ if (error?.status === 503 || error?.code === "not_live") return "Session is not running — restart it to answer.";
174
173
  return message;
175
174
  }
176
175
 
@@ -0,0 +1,13 @@
1
+ // The texts the running turn already shows (its generations as they close,
2
+ // the bubbles a render drew): turn_completed's answer gets a bubble of its
3
+ // own only when it isn't one of them (it wasn't streamed). A new turn starts
4
+ // over, so an earlier turn's "Done." doesn't hide this one's.
5
+ export function createShownTexts() {
6
+ let seen = new Set();
7
+ return {
8
+ turnStarted() { seen = new Set(); },
9
+ seed(texts) { seen = new Set(texts.filter(Boolean)); },
10
+ saw(text) { if (text) seen.add(text); },
11
+ shows(text) { return seen.has(text); },
12
+ };
13
+ }
@@ -0,0 +1,18 @@
1
+ // A block redrawn every tick (the info bar, once a second while a turn
2
+ // runs) keeps its nodes: +html+ is written only when it changed, so a
3
+ // shown tooltip stays up and a press on one of its buttons isn't lost to a
4
+ // rewrite between mousedown and mouseup. +live+ ({selector: text}) names
5
+ // the parts that change every tick (a running clock): +html+ carries them
6
+ // empty and only their text is set, so a tick alone rewrites nothing else.
7
+ const shown = new WeakMap();
8
+
9
+ export function renderStable(el, html, live = {}) {
10
+ if (shown.get(el) !== html) {
11
+ el.innerHTML = html;
12
+ shown.set(el, html);
13
+ }
14
+ for (const [selector, text] of Object.entries(live)) {
15
+ const part = el.querySelector(selector);
16
+ if (part && part.textContent !== text) part.textContent = text;
17
+ }
18
+ }
@@ -93,7 +93,13 @@ export function isOwn(clientId, myId) {
93
93
 
94
94
  // As the TUI labels them (Formatting::CLIENT_LABELS); this file's label
95
95
  // helpers and the TUI's agree per spec/shared/labels_matrix.json.
96
- const CLIENT_LABELS = Object.freeze({ tui: "tui", web: "web", system: "reminder", delegate: "delegate", child: "delegate report" });
96
+ // By the whole id or its prefix (up to and with its first ":"); an id
97
+ // chi doesn't know is "automatic", not the user (ClientId.human?).
98
+ const CLIENT_LABELS = Object.freeze({
99
+ "tui:": "tui", "web:": "web", "system:": "reminder", "delegate:": "delegate", "child:": "delegate report",
100
+ "cli:send": "chi send", "cli:answer": "chi answer", plugin: "plugin",
101
+ });
102
+ const AUTOMATIC_LABEL = "automatic";
97
103
 
98
104
  // A delegate child's report comes in as client child:<id8> (ChildReports).
99
105
  const REPORT_CLIENT = "child:";
@@ -110,7 +116,8 @@ export function clientLabel(clientId) {
110
116
  if (!clientId) return null;
111
117
  const id = String(clientId);
112
118
  if (id.startsWith(CONTEXT_CLIENT) && id.length > CONTEXT_CLIENT.length) return `context ${id.slice(CONTEXT_CLIENT.length)} changed`;
113
- return CLIENT_LABELS[id.split(":", 1)[0]] || "user";
119
+ const colon = id.indexOf(":");
120
+ return CLIENT_LABELS[id] || (colon >= 0 && CLIENT_LABELS[id.slice(0, colon + 1)]) || AUTOMATIC_LABEL;
114
121
  }
115
122
 
116
123
  // The note for reminders a turn got (a reminder turn has no prompt).
@@ -390,7 +390,7 @@ export function createTurnView({
390
390
  }
391
391
  writeClosedLabel(entry, gen);
392
392
  if (!entry.toggled) entry.el.open = false;
393
- // Every generation's text goes into seenContent (the turn output dedupe).
393
+ // Every generation's text goes into the shown texts (the turn output dedupe).
394
394
  const k = normalize(gen.text);
395
395
  if (k) sawText(k);
396
396
  }
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require "securerandom"
5
5
  require "time"
6
6
 
7
+ require_relative "client_id"
7
8
  require_relative "config"
8
9
  require_relative "events"
9
10
  require_relative "session"
@@ -287,6 +288,9 @@ module Samagotchi
287
288
  @context_poller&.stop
288
289
  @engine&.shutdown
289
290
  @bridge&.stop
291
+ # The process exits next: a parent wake it rang for (a crash's
292
+ # ring comes just above) must spawn the parent's worker first.
293
+ ChildRing.await_wakes
290
294
  end
291
295
  end
292
296
 
@@ -425,7 +429,7 @@ module Samagotchi
425
429
  @context_absorber.commit(waking ? woken(batch, waking) : batch)
426
430
  return false unless waking
427
431
 
428
- run_context_wake_turn(waking.name, turn_id)
432
+ run_context_wake_turn(waking, turn_id)
429
433
  true
430
434
  rescue SystemCallError, IOError => e
431
435
  Log.exception(:worker, "context_failed", e)
@@ -473,17 +477,20 @@ module Samagotchi
473
477
  # turn from context:<name> whose first message is the wake note already
474
478
  # in the conversation (named by +turn_id+), on the same rules as a
475
479
  # delegate report's wake turn: it counts toward session.max_wakes, and
476
- # a failure goes back to before the turn (the note stays: it was saved
477
- # before the checkpoint) and pauses wakes until a human's input.
478
- def run_context_wake_turn(name, turn_id)
480
+ # a failure goes back to before the turn (the note stays, as a plain
481
+ # one: #unmark_wake_note) and pauses wakes until a human's input.
482
+ # +waking+: the ContextAbsorber::Delivery that woke it.
483
+ def run_context_wake_turn(waking, turn_id)
484
+ name = waking.name
479
485
  @wakes_in_a_row += 1
480
486
  Log.info(:worker, "context_wake_turn", name: name, in_a_row: @wakes_in_a_row)
481
487
  @turn_flow.before_prompt_turn
482
- run_engine_turn(nil, continue: true, origin: { client_id: "#{Steer::CONTEXT_CLIENT_PREFIX}#{name}" },
488
+ run_engine_turn(nil, continue: true, origin: { client_id: "#{ClientId::CONTEXT_PREFIX}#{name}" },
483
489
  id: turn_id, max_iterations: IterationLimit.for) do |result, error|
484
490
  if error
485
491
  note = TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message)
486
492
  @turn_flow.prompt_turn_failed(note: note)
493
+ unmark_wake_note(turn_id, waking.note)
487
494
  @wakes_paused = true
488
495
  else
489
496
  @continue_offer.after_turn(result)
@@ -491,6 +498,18 @@ module Samagotchi
491
498
  end
492
499
  end
493
500
 
501
+ # A failed wake turn kept nothing but its note: the update goes back to
502
+ # a plain note (no turn start, the background wording), so a reload
503
+ # draws no empty turn for it.
504
+ def unmark_wake_note(turn_id, note)
505
+ messages = @engine.messages_checkpoint
506
+ index = messages.index { |m| m[:turn_start] && m[:turn_id] == turn_id }
507
+ return unless index
508
+
509
+ messages[index] = ContextNote.message(**note)
510
+ @engine.rollback_to(messages)
511
+ end
512
+
494
513
  # Whether the session has had a turn: a user or assistant message.
495
514
  def conversation_started?
496
515
  Array(@session.messages).any? { |m| %w[user assistant model].include?((m[:role] || m["role"]).to_s) }
@@ -551,11 +570,11 @@ module Samagotchi
551
570
  @engine.clear_due_reminder_names!
552
571
  return false unless @engine.reminders_due?
553
572
 
554
- @continue_offer.drop({ client_id: SessionManager::REMINDER_CLIENT_ID })
573
+ @continue_offer.drop({ client_id: ClientId::REMINDER })
555
574
  # A failure has no prompt to hand back (the Engine announced
556
575
  # :turn_failed). The offer went before the turn
557
576
  # (ContinueOffer#drop); either way the rollback window closes.
558
- run_engine_turn(nil, continue: true, origin: { client_id: SessionManager::REMINDER_CLIENT_ID },
577
+ run_engine_turn(nil, continue: true, origin: { client_id: ClientId::REMINDER },
559
578
  max_iterations: IterationLimit.for) { @turn_flow.after_reminder_turn }
560
579
  true
561
580
  end
@@ -759,8 +778,11 @@ module Samagotchi
759
778
  rescue StandardError => e
760
779
  error = e
761
780
  # Still running: no turn of the Engine's ended (it never began).
781
+ # It ends here, failed, as an Engine turn would record it: a wait
782
+ # (ReplyWait) tells a turn's end by last_turn.
762
783
  if @session.status == Session::STATUS_RUNNING
763
784
  @session.status = Session::STATUS_IDLE
785
+ @engine.record_last_turn(@session, "failed", 0, turn_args[:origin], { type: :turn_failed })
764
786
  Log.warn(:worker, "turn_not_begun", error: e.class.name, msg: e.message)
765
787
  end
766
788
  ensure
@@ -800,12 +822,12 @@ module Samagotchi
800
822
  # Whether the turn starting now is a delegate child's parent's: its
801
823
  # task (the initial prompt), a follow-up, a continue the parent
802
824
  # answered, a reminder, anything but a human typing into the child
803
- # (ArchiveStore.user_input?), and any turn after a question the parent
825
+ # (ClientId.human?), and any turn after a question the parent
804
826
  # was rung about.
805
827
  def reports_to_parent?(origin)
806
828
  return false unless @session.delegate?
807
829
 
808
- @initial_turn || @owes_parent || !ArchiveStore.user_input?(origin&.dig(:client_id))
830
+ @initial_turn || @owes_parent || !ClientId.human?(origin&.dig(:client_id))
809
831
  end
810
832
 
811
833
  # The Engine published a question (after the question desk saved it).
@@ -1023,11 +1045,11 @@ module Samagotchi
1023
1045
  Log.info(:worker, "idle_exit", idle_s: @idle_exit.idle_seconds.round)
1024
1046
  end
1025
1047
 
1026
- # A human's input (ArchiveStore.user_input?) un-archives the session;
1048
+ # A human's input (ClientId.human?) un-archives the session;
1027
1049
  # a delegate's, a plugin's or a reminder's doesn't.
1028
1050
  # It also resets the delegate-report wakes (budget and pause).
1029
1051
  def user_input(client_id)
1030
- return unless ArchiveStore.user_input?(client_id)
1052
+ return unless ClientId.human?(client_id)
1031
1053
 
1032
1054
  ArchiveStore.user_input(@session_id, state_dir: @state_dir)
1033
1055
  @wakes_in_a_row = 0
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.32.0
4
+ version: 0.33.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -203,6 +203,7 @@ files:
203
203
  - lib/samagotchi/cli/flags.rb
204
204
  - lib/samagotchi/cli/parent_wait.rb
205
205
  - lib/samagotchi/client.rb
206
+ - lib/samagotchi/client_id.rb
206
207
  - lib/samagotchi/command_steps.rb
207
208
  - lib/samagotchi/commands/registry.rb
208
209
  - lib/samagotchi/config.rb
@@ -334,6 +335,7 @@ files:
334
335
  - lib/samagotchi/plugin/tool_result.rb
335
336
  - lib/samagotchi/plugin_session_state.rb
336
337
  - lib/samagotchi/plugin_tasks.rb
338
+ - lib/samagotchi/process_group.rb
337
339
  - lib/samagotchi/project_scope.rb
338
340
  - lib/samagotchi/prompt.rb
339
341
  - lib/samagotchi/prompt_history.rb
@@ -483,6 +485,8 @@ files:
483
485
  - lib/samagotchi/web/public/sentences.js
484
486
  - lib/samagotchi/web/public/session_state.js
485
487
  - lib/samagotchi/web/public/sessions_list.js
488
+ - lib/samagotchi/web/public/shown_texts.js
489
+ - lib/samagotchi/web/public/stable_html.js
486
490
  - lib/samagotchi/web/public/stage_model.js
487
491
  - lib/samagotchi/web/public/stage_view.js
488
492
  - lib/samagotchi/web/public/strip.js