samagotchi 0.59.0 → 0.61.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/README.md +3 -2
  4. data/bin/chi +23 -2
  5. data/docs/architecture.md +11 -6
  6. data/docs/cli.md +9 -3
  7. data/docs/configuration.md +62 -12
  8. data/docs/hooks.md +82 -5
  9. data/docs/internals/engine.md +50 -13
  10. data/docs/internals/gemma4-contract.md +1 -0
  11. data/docs/internals/prompt-caching.md +6 -3
  12. data/docs/memory.md +1 -1
  13. data/docs/plugins.md +57 -4
  14. data/docs/sessions.md +7 -3
  15. data/docs/sub-agent.md +2 -2
  16. data/docs/testing.md +4 -2
  17. data/lib/samagotchi/answer_display.rb +4 -3
  18. data/lib/samagotchi/answer_refs.rb +210 -0
  19. data/lib/samagotchi/bootstrap_command.rb +2 -1
  20. data/lib/samagotchi/bridge/card_store.rb +6 -1
  21. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  22. data/lib/samagotchi/bundles/agents-md/manifest.yml +11 -0
  23. data/lib/samagotchi/bundles/agents-md/plugin.rb +198 -0
  24. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
  26. data/lib/samagotchi/bundles/core/manifest.yml +2 -1
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  28. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  29. data/lib/samagotchi/config.rb +33 -5
  30. data/lib/samagotchi/continue_offer.rb +5 -2
  31. data/lib/samagotchi/engine.rb +101 -23
  32. data/lib/samagotchi/hooks/registry.rb +3 -1
  33. data/lib/samagotchi/interrupted_turn.rb +39 -0
  34. data/lib/samagotchi/iteration_limit.rb +3 -2
  35. data/lib/samagotchi/kernel_loop.rb +40 -12
  36. data/lib/samagotchi/llm/backend.rb +6 -1
  37. data/lib/samagotchi/llm/chat_loop.rb +16 -8
  38. data/lib/samagotchi/llm/model_result.rb +11 -2
  39. data/lib/samagotchi/llm/native_backend.rb +3 -2
  40. data/lib/samagotchi/log_subscriber.rb +8 -4
  41. data/lib/samagotchi/plugin/context.rb +16 -3
  42. data/lib/samagotchi/prompt_files.rb +127 -0
  43. data/lib/samagotchi/ref.rb +141 -0
  44. data/lib/samagotchi/reply_wait.rb +6 -2
  45. data/lib/samagotchi/session.rb +53 -5
  46. data/lib/samagotchi/session_manager.rb +64 -6
  47. data/lib/samagotchi/session_metrics.rb +8 -2
  48. data/lib/samagotchi/session_setup.rb +11 -5
  49. data/lib/samagotchi/system_prompt.rb +71 -15
  50. data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -9
  51. data/lib/samagotchi/terminal_ui/formatting.rb +14 -0
  52. data/lib/samagotchi/terminal_ui.rb +21 -5
  53. data/lib/samagotchi/turn_checkpoint.rb +128 -0
  54. data/lib/samagotchi/turn_flow.rb +22 -3
  55. data/lib/samagotchi/version.rb +1 -1
  56. data/lib/samagotchi/web/app.rb +5 -0
  57. data/lib/samagotchi/web/public/answer_refs.js +217 -0
  58. data/lib/samagotchi/web/public/app.js +15 -18
  59. data/lib/samagotchi/web/public/favicon.svg +1 -0
  60. data/lib/samagotchi/web/public/index.html +6 -3
  61. data/lib/samagotchi/web/public/ref_menu.js +9 -1
  62. data/lib/samagotchi/web/public/ref_scan.js +5 -3
  63. data/lib/samagotchi/web/public/refs.js +129 -15
  64. data/lib/samagotchi/web/public/stream_handlers.js +5 -3
  65. data/lib/samagotchi/web/public/turn_view.js +17 -7
  66. data/lib/samagotchi/worker.rb +169 -21
  67. data/lib/samagotchi/worker_signals.rb +81 -0
  68. metadata +11 -1
@@ -21,6 +21,7 @@
21
21
  // chrome (CSS :has), so a plain answer streams like today's bubble, and is
22
22
  // removed at the end: the answer bubble takes its place.
23
23
  import { html, nothing, render } from "./vendor/lit-html.js";
24
+ import { setAnswerRefs } from "./answer_refs.js";
24
25
  import { leavesBlock } from "./card.js";
25
26
  import { copySourceAttr } from "./copy.js";
26
27
  import { escapeHtml, failedTurnText, messageBodyHtml, noteHtml, normalize, sourceLabel, steerRowHtml } from "./format.js";
@@ -779,10 +780,12 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
779
780
  : "";
780
781
  };
781
782
  // The timing line after the bubble, not in it: where the live view
782
- // leaves it at the turn's end.
783
- const answerHtml = (m, timing) => {
783
+ // leaves it at the turn's end. +at+: the message's index, for its refs
784
+ // (data-answer-at, gone before the nodes are returned).
785
+ const answerHtml = (at, timing) => {
786
+ const m = items[at];
784
787
  const { body, renderedMarkdown } = messageBodyHtml(m);
785
- return `<div class="bubble output${renderedMarkdown ? " markdown" : ""}"${copySourceAttr(m, renderedMarkdown)}>${body}</div>${timing}`;
788
+ return `<div class="bubble output${renderedMarkdown ? " markdown" : ""}" data-answer-at="${at}"${copySourceAttr(m, renderedMarkdown)}>${body}</div>${timing}`;
786
789
  };
787
790
  // A user's line, labelled when chi brought it in (a delegate report). A
788
791
  // `!cmd`'s saved message (the worker's "!(<command>)\n<output>") renders
@@ -799,14 +802,14 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
799
802
  const j = items.findIndex((m, k) => k > i && m?.role === "assistant" && String(m.content ?? "").trim());
800
803
  if (j < 0 || j === group.answer || items.slice(i + 1, j).some((m) => m?.role === "user" && !m.merged)) return "";
801
804
  const { body, renderedMarkdown } = messageBodyHtml(items[j]);
802
- return `<div class="bubble output report-reply${renderedMarkdown ? " markdown" : ""}"${copySourceAttr(items[j], renderedMarkdown)}>${body}</div>`;
805
+ return `<div class="bubble output report-reply${renderedMarkdown ? " markdown" : ""}" data-answer-at="${j}"${copySourceAttr(items[j], renderedMarkdown)}>${body}</div>`;
803
806
  };
804
807
  const steerHtml = (i, bubble) => steerRowHtml({ source: items[i].source, text: items[i].content }, { bubble });
805
808
  const parts = [];
806
809
  for (const group of turnGroups(items, timing)) {
807
810
  if (group.kind === "note") { parts.push(`<div class="bubble note">${noteHtml(items[group.i])}</div>`); continue; }
808
811
  if (group.kind === "steer") { parts.push(steerHtml(group.i, true)); continue; }
809
- if (group.kind === "answer") { parts.push(answerHtml(items[group.answer], "")); continue; }
812
+ if (group.kind === "answer") { parts.push(answerHtml(group.answer, "")); continue; }
810
813
  const prompt = items[group.user];
811
814
  // A context wake turn starts with its note.
812
815
  parts.push(prompt.role === "note" ? `<div class="bubble note">${noteHtml(prompt)}</div>` : userHtml(prompt));
@@ -846,11 +849,18 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
846
849
  // A turn with no answer (canceled, or running: no record yet) shows its
847
850
  // timing after its prompt and steps, where the live view leaves it.
848
851
  else if (group.answer === null) parts.push(timingHtml(group));
849
- else parts.push(answerHtml(items[group.answer], timingHtml(group)));
852
+ else parts.push(answerHtml(group.answer, timingHtml(group)));
850
853
  // A canceled turn ends with its cancel line, a failed one whose steps
851
854
  // stayed with its failure line, as the live view left them.
852
855
  parts.push(cancelLineHtml(group.record, escapeHtml));
853
856
  parts.push(failedLineHtml(group.record));
854
857
  }
855
- return fragmentOf(parts);
858
+ const out = fragmentOf(parts);
859
+ // Each answer bubble knows its message's refs (answer_refs.js; the
860
+ // page pairs them after the render), the index stays out of the DOM.
861
+ for (const bubble of out.querySelectorAll("[data-answer-at]")) {
862
+ setAnswerRefs(bubble, items[Number(bubble.dataset.answerAt)]?.display_refs);
863
+ bubble.removeAttribute("data-answer-at");
864
+ }
865
+ return out;
856
866
  }
@@ -12,10 +12,12 @@ require_relative "steer"
12
12
  require_relative "worker_idle_exit"
13
13
  require_relative "worker_wakes"
14
14
  require_relative "worker_inbound"
15
+ require_relative "worker_signals"
15
16
  require_relative "session_manager"
16
17
  require_relative "archive_store"
17
18
  require_relative "log"
18
19
  require_relative "turn_flow"
20
+ require_relative "turn_checkpoint"
19
21
  require_relative "llm/errors"
20
22
  require_relative "continue_offer"
21
23
  require_relative "iteration_limit"
@@ -52,6 +54,13 @@ module Samagotchi
52
54
  # /continue) run on the loop between turns, before any queued prompt. One
53
55
  # taken while a turn runs (at an iteration boundary, or right after the
54
56
  # turn) is refused as busy.
57
+ #
58
+ # A TERM, HUP or INT (WorkerSignals, trapped in a spawned worker only)
59
+ # stops the running turn as a Stop does (reason :signal), the turn's
60
+ # work is saved as after any Stop, and the worker leaves (:signal) taking
61
+ # no new work. A turn that hasn't ended SIGNAL_GRACE seconds after the
62
+ # signal is cut: the session is saved as it is and the signal raised in
63
+ # the loop's thread, as Ruby's default would (the teardown still runs).
55
64
  class Worker
56
65
  FALLBACK_TICK_SECONDS = 5
57
66
  # A worker just started runs no wake turn for delegate reports this
@@ -64,6 +73,18 @@ module Samagotchi
64
73
  BUSY_OUTPUT = "busy: wait for the turn to end"
65
74
  # !rollback mid-turn would mean "the turn I'm in".
66
75
  ROLLBACK_BUSY_OUTPUT = "busy: Ctrl-C the turn first, then !rollback"
76
+ # How long a signalled worker's turn has to end (it is canceled at once,
77
+ # and again every SIGNAL_RETRY until it ends: a signal that lands just
78
+ # before the turn begins finds none to cancel).
79
+ SIGNAL_GRACE = 3.0
80
+ # A second signal, or a TERM after `chi sessions stop` marked the
81
+ # session stopped (that stop already canceled over the Bridge and waits
82
+ # only seconds for the lock): this much.
83
+ SIGNAL_SHORT_GRACE = 0.5
84
+ SIGNAL_RETRY = 0.1
85
+ # After the grace ran out and the signal was raised: the process ends
86
+ # this much later at the latest (a teardown that hangs).
87
+ SIGNAL_HARD_EXIT = 10.0
67
88
 
68
89
  # Wakes the worker loop. Whoever queues work writes it first and wakes
69
90
  # after, and #wait drains every wake before the loop looks for work: a
@@ -90,7 +111,12 @@ module Samagotchi
90
111
 
91
112
  # @param idle_exit_minutes [Numeric, nil] nil: session.idle_exit_minutes
92
113
  # @param poll_interval [Numeric, nil] seconds between fallback ticks
93
- def initialize(session_id:, state_dir:, session_dir:, idle_exit_minutes: nil, poll_interval: nil)
114
+ # @param signals [WorkerSignals, WorkerSignals::None] the process's
115
+ # trapped signals (SessionManager.run_session_loop); none by default
116
+ # @param signal_grace [Array(Numeric, Numeric, Numeric)] the grace, the
117
+ # short grace and the hard exit's delay (specs shorten them)
118
+ def initialize(session_id:, state_dir:, session_dir:, idle_exit_minutes: nil, poll_interval: nil,
119
+ signals: WorkerSignals::NONE, signal_grace: [SIGNAL_GRACE, SIGNAL_SHORT_GRACE, SIGNAL_HARD_EXIT])
94
120
  @session_id = session_id
95
121
  @state_dir = state_dir
96
122
  @session_dir = session_dir
@@ -116,6 +142,20 @@ module Samagotchi
116
142
  @asked_parent = false
117
143
  @owes_parent = false
118
144
  @initial_turn = false
145
+ @signals = signals
146
+ @signal_grace, @signal_short_grace, @signal_hard_exit = signal_grace
147
+ # Set as the loop starts, and as it returns (or raises): a signal's
148
+ # grace runs out only in between.
149
+ @loop_started = false
150
+ @left_loop = false
151
+ @signal_mutex = Mutex.new
152
+ @grace_expired = false
153
+ # The running turn has ended (its end event) and its after_turn hooks
154
+ # run: a signal's grace waits for them (#signalled).
155
+ @turn_ending = false
156
+ # The worker's own saves and a signal's (#signal_grace_expired), one
157
+ # at a time.
158
+ @save_mutex = Mutex.new
119
159
  end
120
160
 
121
161
  # Whether the session was empty as the worker left it, so the caller
@@ -135,30 +175,46 @@ module Samagotchi
135
175
  # @return [Symbol] :idle_exit, :exit_requested when a client asked it
136
176
  # to exit (Bridge POST /exit), :restart when it asked for a restart
137
177
  # (the caller starts a new worker), :stopped when the session was stopped
138
- # (chi stop), :crashed when the loop raised (the session is marked
139
- # errored); SessionManager.run_session_loop turns it into the exit
178
+ # (chi stop), :signal when a signal stopped it (WorkerSignals),
179
+ # :crashed when the loop raised (the session is marked errored);
180
+ # SessionManager.run_session_loop turns it into the exit
140
181
  def run
182
+ # The thread a signal's expired grace raises in (the main thread in a
183
+ # worker process).
184
+ @run_thread = Thread.current
185
+ @signals.on_signal { |name, count| signalled(name, count) }
141
186
  start
142
187
  serve
143
- begin
144
- # A session stopped before this worker took the lock (e.g. a stop
145
- # right after create) must not run its initial prompt.
146
- return :stopped if stopped_on_disk?
147
-
148
- start_background
149
- loop do
150
- leaving = pass
151
- return leaving if leaving
152
- end
188
+ # That raise lands in the loop only (#signal_grace_expired arms once
189
+ # it started, and never after it left): the teardown always runs
190
+ # whole, a raise already on its way waits until it is done.
191
+ Thread.handle_interrupt(SignalException => :never) do
192
+ Thread.handle_interrupt(SignalException => :immediate) { run_loop }
153
193
  rescue StandardError => e
154
194
  crashed(e)
155
195
  ensure
196
+ @signal_mutex.synchronize { @left_loop = true }
156
197
  teardown
157
198
  end
158
199
  end
159
200
 
160
201
  private
161
202
 
203
+ # @return [Symbol] what #run returns
204
+ def run_loop
205
+ @signal_mutex.synchronize { @loop_started = true }
206
+ # A session stopped before this worker took the lock (e.g. a stop
207
+ # right after create) must not run its initial prompt.
208
+ return :stopped if stopped_on_disk?
209
+ return signal_leave if signalled?
210
+
211
+ start_background
212
+ loop do
213
+ leaving = pass
214
+ return leaving if leaving
215
+ end
216
+ end
217
+
162
218
  # The session, its Engine and what runs its turns, before anyone can
163
219
  # reach it.
164
220
  def start
@@ -171,7 +227,10 @@ module Samagotchi
171
227
  ChildMove.adopt(@session_id, state_dir: @state_dir)
172
228
  @session = Session.load(@session_id, state_dir: @state_dir)
173
229
  drop_dead_question
230
+ # The running turn, on disk at its iteration boundaries.
231
+ @checkpoint = TurnCheckpoint.new(session: @session, state_dir: @state_dir)
174
232
  @engine = build_engine
233
+ settle_interrupted_turn
175
234
  # Before the Bridge serves anything: a UI joining a resumed worker's
176
235
  # stream gets the session's history and status in its snapshot, not
177
236
  # an empty session until the first turn.
@@ -200,8 +259,12 @@ module Samagotchi
200
259
  if Events::TURN_END.include?(event[:type])
201
260
  @turn_end_seq = event[:event_seq]
202
261
  @turn_end_type = event[:type]
262
+ @turn_ending = true
263
+ end
264
+ if event[:type] == :turn_started
265
+ @turn_id = event[:turn_id]
266
+ @turn_ending = false
203
267
  end
204
- @turn_id = event[:turn_id] if event[:type] == :turn_started
205
268
  ring_question(event) if event[:type] == :question_requested
206
269
  })
207
270
  # This session's own delegate children's news (rings in children/).
@@ -268,6 +331,9 @@ module Samagotchi
268
331
  # next pass looks again; with none, #idle_pass.
269
332
  # @return [Symbol, nil] what #run returns as the worker leaves, nil to go on
270
333
  def pass
334
+ # A signalled worker takes no new work.
335
+ return signal_leave if signalled?
336
+
271
337
  # Check if the session was externally marked as stopped. Not
272
338
  # stopped_on_disk?, which reads a vanished file as "not stopped":
273
339
  # a session file deleted under a running worker crashes it here
@@ -316,8 +382,8 @@ module Samagotchi
316
382
  # @return [nil]
317
383
  def run_input_files(input_files)
318
384
  input_files.sort.each do |input_file|
319
- # A stop between two queued turns leaves the rest queued.
320
- break if stopped_on_disk?
385
+ # A stop (or a signal) between two queued turns leaves the rest queued.
386
+ break if signalled? || stopped_on_disk?
321
387
 
322
388
  # Commands that came before this file (D1: a queued /model X sent
323
389
  # before this prompt, a command an earlier file carried) run first.
@@ -367,6 +433,7 @@ module Samagotchi
367
433
  # (re)spawn.
368
434
  memories: @session.preloaded_memory_names,
369
435
  muted_memories: @session.muted_memory_names,
436
+ appended_prompt_files: @session.appended_prompt_files,
370
437
  reminders: {
371
438
  callback: lambda { |due_names|
372
439
  # A reminder is due: the loop runs a reminder turn for it once
@@ -384,6 +451,17 @@ module Samagotchi
384
451
  engine
385
452
  end
386
453
 
454
+ # A turn an earlier worker died in (TurnCheckpoint's mark in the file):
455
+ # ended now as canceled, with its note, and saved, before the Bridge
456
+ # serves the session (TurnCheckpoint.settle).
457
+ def settle_interrupted_turn
458
+ mark = TurnCheckpoint.settle(@session)
459
+ return unless mark
460
+
461
+ Log.info(:worker, "interrupted_turn_settled", turn_id: mark.id, iteration: mark.iteration, at: mark.at)
462
+ save_or_log(:interrupted_turn) { @session.save(state_dir: @state_dir) }
463
+ end
464
+
387
465
  # A question saved by a worker that died while it waited (an
388
466
  # ask_user_question, an approval, a hook's question): the turn that
389
467
  # asked is gone, so nobody can answer it, and left in the file it
@@ -571,7 +649,8 @@ module Samagotchi
571
649
  # @return [Boolean] whether any command ran
572
650
  def run_queued_commands
573
651
  ran = false
574
- while (command = next_ready_command)
652
+ # A signalled worker runs no more (a /continue would start a turn).
653
+ while !signalled? && (command = next_ready_command)
575
654
  run_command(command)
576
655
  ran = true
577
656
  end
@@ -821,6 +900,7 @@ module Samagotchi
821
900
  @reporting_turn = reports_to_parent?(turn_args[:origin])
822
901
  restores = @turn_flow.restores
823
902
  begin
903
+ @checkpoint.begin_turn(prompt: prompt)
824
904
  @session.status = Session::STATUS_RUNNING
825
905
  # A continue that moved this session to the chain's new link wrote
826
906
  # the new parent beside it (Session::PARENT_FILE): this turn's
@@ -839,6 +919,8 @@ module Samagotchi
839
919
  Log.warn(:worker, "turn_not_begun", error: e.class.name, msg: e.message)
840
920
  end
841
921
  ensure
922
+ # Ended (or never begun): the saves write the session's own messages.
923
+ @checkpoint.end_turn
842
924
  refuse_queued_commands
843
925
  end
844
926
  yield result, error
@@ -994,6 +1076,9 @@ module Samagotchi
994
1076
  reports.map(&:line)
995
1077
  # A wake turn starts with its first line, whoever sent it.
996
1078
  lines[0] = lines[0].with(mark: mark) if mark && lines.any?
1079
+ # The turn so far, with what this boundary merges (its input files
1080
+ # are gone now), on disk at most every TurnCheckpoint::INTERVAL.
1081
+ @checkpoint.boundary(@engine.live_conversation, merging: lines, turn_id: @turn_id)
997
1082
  lines
998
1083
  end
999
1084
  end
@@ -1126,11 +1211,74 @@ module Samagotchi
1126
1211
  SessionManager.stopped_on_disk?(@session_id, state_dir: @state_dir)
1127
1212
  end
1128
1213
 
1129
- # Save the session unless it was stopped meanwhile: the stop (chi stop,
1130
- # from another process) saved its status, and this save would write
1131
- # the worker's over it.
1214
+ # Save the session, a stopped one too: a stop is the marker file
1215
+ # (Session.mark_stopped), which every load reads over the saved status,
1216
+ # so this save can't hide it, and the turn the stop canceled is kept.
1132
1217
  def save_session
1133
- @session.save(state_dir: @state_dir) unless stopped_on_disk?
1218
+ @save_mutex.synchronize { @session.save(state_dir: @state_dir) }
1219
+ end
1220
+
1221
+ def signalled? = !@signals.signal.nil?
1222
+
1223
+ # @return [Symbol] :stopped when the session was stopped too (chi
1224
+ # sessions stop TERMs its worker), else :signal
1225
+ def signal_leave
1226
+ Session.stopped_marker?(@session_id, state_dir: @state_dir) ? :stopped : :signal
1227
+ end
1228
+
1229
+ # A signal (WorkerSignals#fire), on a thread of its own: the loop wakes
1230
+ # (an idle worker leaves at once), the running turn is canceled as a
1231
+ # Stop (again every SIGNAL_RETRY, until the loop has left), and when
1232
+ # the grace runs out first, #signal_grace_expired. A turn that has
1233
+ # ended and runs its after_turn hooks (which store the answer's display
1234
+ # and refs) is no longer canceled, and gets until SIGNAL_HARD_EXIT
1235
+ # past the grace, the worker's own end of the turn (its saves, after
1236
+ # the turn stopped running) too: the loop leaves next. A grace that runs out before the loop started waits
1237
+ # for it (the loop's first look leaves). A signal after the loop left
1238
+ # (the teardown, the caller's last steps) only brings the hard exit.
1239
+ def signalled(name, count)
1240
+ Log.info(:worker, "signal", signal: name, count: count, turn_running: @engine&.turn_running? || false)
1241
+ return signal_hard_exit if @left_loop
1242
+
1243
+ @waker.wake
1244
+ short = count > 1 || Session.stopped_marker?(@session_id, state_dir: @state_dir)
1245
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1246
+ deadline = now + (short ? @signal_short_grace : @signal_grace)
1247
+ hooks_deadline = deadline + @signal_hard_exit
1248
+ ended = false
1249
+ until @left_loop
1250
+ running = @engine&.turn_running?
1251
+ ended ||= running && @turn_ending
1252
+ @engine.cancel_current_turn!(:signal) if running && !ended
1253
+ break if @loop_started && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= (ended ? hooks_deadline : deadline)
1254
+
1255
+ sleep(SIGNAL_RETRY)
1256
+ end
1257
+ signal_grace_expired(name) unless @left_loop
1258
+ rescue StandardError => e
1259
+ Log.warn(:worker, "signal_failed", signal: name, error: e.class.name, msg: e.message)
1260
+ end
1261
+
1262
+ # The process ends SIGNAL_HARD_EXIT from now at the latest.
1263
+ def signal_hard_exit
1264
+ sleep(@signal_hard_exit)
1265
+ @signals.hard_exit
1266
+ end
1267
+
1268
+ # The turn didn't end in time (a tool that ignores the cancel, slow
1269
+ # after_turn hooks): the session is saved as it is now (a turn that has
1270
+ # ended: with it), and the signal raised in the loop's thread, so
1271
+ # Worker#run's teardown and the lock's release still run. The process
1272
+ # ends SIGNAL_HARD_EXIT later at the latest. Once, whatever the signals.
1273
+ def signal_grace_expired(name)
1274
+ expired = @signal_mutex.synchronize { !@grace_expired && !@left_loop && (@grace_expired = true) }
1275
+ return unless expired
1276
+
1277
+ Log.warn(:worker, "signal_grace_expired", signal: name, turn_running: @engine&.turn_running? || false)
1278
+ save_or_log(:signal) { save_session }
1279
+ # Not once the loop has left (it may have, during the save).
1280
+ @signal_mutex.synchronize { @run_thread&.raise(SignalException.new(name)) unless @left_loop }
1281
+ signal_hard_exit
1134
1282
  end
1135
1283
  end
1136
1284
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # TERM, HUP and INT in a worker process: the running turn stops as a Stop
5
+ # does (reason :signal), is saved, and the worker leaves (Worker#run
6
+ # returns :signal). SessionManager.run_session_loop installs the traps in
7
+ # a spawned worker only (WORKER_PROCESS_ENV), never in a process that runs
8
+ # the loop in-process (the specs, which fire #fire directly).
9
+ #
10
+ # A trap handler may take no lock (Mutex#lock raises ThreadError in trap
11
+ # context) and should do no IO: the trap only records the signal and
12
+ # starts a thread, which calls the worker's handler (#on_signal).
13
+ #
14
+ # Ruby's default for these signals raised SignalException in the main
15
+ # thread, mid-turn: the turn skipped every end-of-turn path and nothing of
16
+ # it reached the session file.
17
+ class WorkerSignals
18
+ SIGNALS = %w[TERM HUP INT].freeze
19
+
20
+ # Nothing trapped and nothing fires: Worker.new's default (specs, and
21
+ # any caller that isn't a spawned worker).
22
+ class None
23
+ def on_signal(&) = nil
24
+ def signal = nil
25
+ def count = 0
26
+ def hard_exit = nil
27
+ def restore! = nil
28
+ end
29
+ NONE = None.new.freeze
30
+
31
+ # @return [String, nil] the first signal received ("TERM"), nil before one
32
+ attr_reader :signal
33
+
34
+ # @param hard_exit [#call] the last resort when the worker hasn't left
35
+ # long after the signal (exit! in a real worker)
36
+ def initialize(hard_exit: -> { Process.exit!(1) })
37
+ @signal = nil
38
+ @count = 0
39
+ @handler = nil
40
+ @hard_exit = hard_exit
41
+ @previous = {}
42
+ end
43
+
44
+ # How many signals came (a second one skips the grace).
45
+ attr_reader :count
46
+
47
+ # The worker's handler, called on a thread of its own with the signal
48
+ # name and the count, once per signal.
49
+ def on_signal(&handler)
50
+ @handler = handler
51
+ end
52
+
53
+ # Trap SIGNALS in this process (the previous handlers kept for #restore!).
54
+ # @return [self]
55
+ def install!
56
+ SIGNALS.each { |name| @previous[name] = Signal.trap(name) { fire(name) } }
57
+ self
58
+ end
59
+
60
+ # The handlers from before #install!.
61
+ def restore!
62
+ @previous.each { |name, handler| Signal.trap(name, handler || "DEFAULT") }
63
+ @previous.clear
64
+ end
65
+
66
+ # What a trap does (and what specs call): record +name+ and run the
67
+ # handler on its own thread. Safe in trap context: no lock, no IO.
68
+ # @return [Thread, nil] the handler's thread
69
+ def fire(name)
70
+ @signal ||= name
71
+ @count += 1
72
+ handler = @handler
73
+ count = @count
74
+ return nil unless handler
75
+
76
+ Thread.new { handler.call(name, count) }
77
+ end
78
+
79
+ def hard_exit = @hard_exit.call
80
+ end
81
+ end
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.59.0
4
+ version: 0.61.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -150,6 +150,7 @@ files:
150
150
  - lib/samagotchi/analytics_file.rb
151
151
  - lib/samagotchi/answer_command.rb
152
152
  - lib/samagotchi/answer_display.rb
153
+ - lib/samagotchi/answer_refs.rb
153
154
  - lib/samagotchi/answer_tail.rb
154
155
  - lib/samagotchi/archive_store.rb
155
156
  - lib/samagotchi/atomic_file.rb
@@ -178,6 +179,8 @@ files:
178
179
  - lib/samagotchi/broadcast_command.rb
179
180
  - lib/samagotchi/bundle_command.rb
180
181
  - lib/samagotchi/bundle_needs.rb
182
+ - lib/samagotchi/bundles/agents-md/manifest.yml
183
+ - lib/samagotchi/bundles/agents-md/plugin.rb
181
184
  - lib/samagotchi/bundles/btw/manifest.yml
182
185
  - lib/samagotchi/bundles/btw/plugin.rb
183
186
  - lib/samagotchi/bundles/check-in/manifest.yml
@@ -306,6 +309,7 @@ files:
306
309
  - lib/samagotchi/image_store.rb
307
310
  - lib/samagotchi/installed_gem.rb
308
311
  - lib/samagotchi/installed_versions.rb
312
+ - lib/samagotchi/interrupted_turn.rb
309
313
  - lib/samagotchi/iteration_limit.rb
310
314
  - lib/samagotchi/kernel_loop.rb
311
315
  - lib/samagotchi/last_turn.rb
@@ -392,6 +396,7 @@ files:
392
396
  - lib/samagotchi/process_group.rb
393
397
  - lib/samagotchi/project_scope.rb
394
398
  - lib/samagotchi/prompt.rb
399
+ - lib/samagotchi/prompt_files.rb
395
400
  - lib/samagotchi/prompt_history.rb
396
401
  - lib/samagotchi/prompt_literal_guard.rb
397
402
  - lib/samagotchi/prompt_note.rb
@@ -403,6 +408,7 @@ files:
403
408
  - lib/samagotchi/recap_setup.rb
404
409
  - lib/samagotchi/recap_store.rb
405
410
  - lib/samagotchi/redacted_inspect.rb
411
+ - lib/samagotchi/ref.rb
406
412
  - lib/samagotchi/relay.rb
407
413
  - lib/samagotchi/relay_desk.rb
408
414
  - lib/samagotchi/relay_verifier.rb
@@ -503,6 +509,7 @@ files:
503
509
  - lib/samagotchi/tools/tool_path.rb
504
510
  - lib/samagotchi/tools/web_fetch.rb
505
511
  - lib/samagotchi/tools/write.rb
512
+ - lib/samagotchi/turn_checkpoint.rb
506
513
  - lib/samagotchi/turn_flow.rb
507
514
  - lib/samagotchi/turn_note.rb
508
515
  - lib/samagotchi/turn_state.rb
@@ -526,6 +533,7 @@ files:
526
533
  - lib/samagotchi/web/public/annotate_bar.js
527
534
  - lib/samagotchi/web/public/annotate_presets.js
528
535
  - lib/samagotchi/web/public/annotations.js
536
+ - lib/samagotchi/web/public/answer_refs.js
529
537
  - lib/samagotchi/web/public/app.js
530
538
  - lib/samagotchi/web/public/card.js
531
539
  - lib/samagotchi/web/public/chains.js
@@ -542,6 +550,7 @@ files:
542
550
  - lib/samagotchi/web/public/data.js
543
551
  - lib/samagotchi/web/public/diff_view.js
544
552
  - lib/samagotchi/web/public/families_view.js
553
+ - lib/samagotchi/web/public/favicon.svg
545
554
  - lib/samagotchi/web/public/format.js
546
555
  - lib/samagotchi/web/public/hold.js
547
556
  - lib/samagotchi/web/public/images.js
@@ -599,6 +608,7 @@ files:
599
608
  - lib/samagotchi/worker_idle_exit.rb
600
609
  - lib/samagotchi/worker_inbound.rb
601
610
  - lib/samagotchi/worker_sidecar.rb
611
+ - lib/samagotchi/worker_signals.rb
602
612
  - lib/samagotchi/worker_wakes.rb
603
613
  - lib/samagotchi/yaml_lines.rb
604
614
  homepage: https://github.com/dm1try/samagotchi