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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +89 -1
- data/README.md +3 -2
- data/bin/chi +23 -2
- data/docs/architecture.md +11 -6
- data/docs/cli.md +9 -3
- data/docs/configuration.md +62 -12
- data/docs/hooks.md +82 -5
- data/docs/internals/engine.md +50 -13
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/internals/prompt-caching.md +6 -3
- data/docs/memory.md +1 -1
- data/docs/plugins.md +57 -4
- data/docs/sessions.md +7 -3
- data/docs/sub-agent.md +2 -2
- data/docs/testing.md +4 -2
- data/lib/samagotchi/answer_display.rb +4 -3
- data/lib/samagotchi/answer_refs.rb +210 -0
- data/lib/samagotchi/bootstrap_command.rb +2 -1
- data/lib/samagotchi/bridge/card_store.rb +6 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
- data/lib/samagotchi/bundles/agents-md/manifest.yml +11 -0
- data/lib/samagotchi/bundles/agents-md/plugin.rb +198 -0
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
- data/lib/samagotchi/bundles/core/manifest.yml +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/config.rb +33 -5
- data/lib/samagotchi/continue_offer.rb +5 -2
- data/lib/samagotchi/engine.rb +101 -23
- data/lib/samagotchi/hooks/registry.rb +3 -1
- data/lib/samagotchi/interrupted_turn.rb +39 -0
- data/lib/samagotchi/iteration_limit.rb +3 -2
- data/lib/samagotchi/kernel_loop.rb +40 -12
- data/lib/samagotchi/llm/backend.rb +6 -1
- data/lib/samagotchi/llm/chat_loop.rb +16 -8
- data/lib/samagotchi/llm/model_result.rb +11 -2
- data/lib/samagotchi/llm/native_backend.rb +3 -2
- data/lib/samagotchi/log_subscriber.rb +8 -4
- data/lib/samagotchi/plugin/context.rb +16 -3
- data/lib/samagotchi/prompt_files.rb +127 -0
- data/lib/samagotchi/ref.rb +141 -0
- data/lib/samagotchi/reply_wait.rb +6 -2
- data/lib/samagotchi/session.rb +53 -5
- data/lib/samagotchi/session_manager.rb +64 -6
- data/lib/samagotchi/session_metrics.rb +8 -2
- data/lib/samagotchi/session_setup.rb +11 -5
- data/lib/samagotchi/system_prompt.rb +71 -15
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -9
- data/lib/samagotchi/terminal_ui/formatting.rb +14 -0
- data/lib/samagotchi/terminal_ui.rb +21 -5
- data/lib/samagotchi/turn_checkpoint.rb +128 -0
- data/lib/samagotchi/turn_flow.rb +22 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +5 -0
- data/lib/samagotchi/web/public/answer_refs.js +217 -0
- data/lib/samagotchi/web/public/app.js +15 -18
- data/lib/samagotchi/web/public/favicon.svg +1 -0
- data/lib/samagotchi/web/public/index.html +6 -3
- data/lib/samagotchi/web/public/ref_menu.js +9 -1
- data/lib/samagotchi/web/public/ref_scan.js +5 -3
- data/lib/samagotchi/web/public/refs.js +129 -15
- data/lib/samagotchi/web/public/stream_handlers.js +5 -3
- data/lib/samagotchi/web/public/turn_view.js +17 -7
- data/lib/samagotchi/worker.rb +169 -21
- data/lib/samagotchi/worker_signals.rb +81 -0
- 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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
}
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -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
|
-
|
|
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), :
|
|
139
|
-
#
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
|
1130
|
-
#
|
|
1131
|
-
#
|
|
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)
|
|
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.
|
|
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
|