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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -1
- data/docs/context.md +3 -1
- data/docs/hooks.md +6 -3
- data/docs/plugins.md +2 -1
- data/docs/releasing.md +2 -1
- data/lib/samagotchi/answer_command.rb +2 -1
- data/lib/samagotchi/archive_store.rb +1 -15
- data/lib/samagotchi/bridge.rb +8 -3
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +31 -10
- data/lib/samagotchi/client_id.rb +52 -0
- data/lib/samagotchi/context_absorber.rb +2 -1
- data/lib/samagotchi/context_fetch.rb +14 -45
- data/lib/samagotchi/context_sources.rb +20 -10
- data/lib/samagotchi/engine.rb +10 -7
- data/lib/samagotchi/guardrails/parent_approvals.rb +0 -2
- data/lib/samagotchi/memory_bundle/builder.rb +22 -1
- data/lib/samagotchi/memory_bundle/manifest.rb +3 -1
- data/lib/samagotchi/plugin/sessions.rb +2 -1
- data/lib/samagotchi/process_group.rb +160 -0
- data/lib/samagotchi/prompt_warmup.rb +20 -1
- data/lib/samagotchi/question_desk.rb +2 -1
- data/lib/samagotchi/relay_verifier.rb +2 -4
- data/lib/samagotchi/reply_wait.rb +9 -4
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session_manager.rb +2 -4
- data/lib/samagotchi/steer.rb +27 -19
- data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -4
- data/lib/samagotchi/terminal_ui/formatting.rb +21 -12
- data/lib/samagotchi/terminal_ui.rb +2 -1
- data/lib/samagotchi/tools/delegate.rb +2 -2
- data/lib/samagotchi/tools/execute.rb +10 -81
- data/lib/samagotchi/tools/task_runtime.rb +25 -38
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -1
- data/lib/samagotchi/web/public/app.js +19 -20
- data/lib/samagotchi/web/public/data.js +3 -0
- data/lib/samagotchi/web/public/question_card.js +3 -4
- data/lib/samagotchi/web/public/shown_texts.js +13 -0
- data/lib/samagotchi/web/public/stable_html.js +18 -0
- data/lib/samagotchi/web/public/turn_events.js +9 -2
- data/lib/samagotchi/web/public/turn_view.js +1 -1
- data/lib/samagotchi/worker.rb +33 -11
- 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 = [
|
|
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(
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
#
|
|
176
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
199
|
-
|
|
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"]
|
|
221
|
-
return
|
|
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
|
|
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"] ||=
|
|
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
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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 ||
|
|
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
|
|
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 "${escapeHtml(attachCmd)}" 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"
|
|
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
|
-
|
|
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
|
-
|
|
2076
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -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
|
|
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
|
|
477
|
-
#
|
|
478
|
-
|
|
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: "#{
|
|
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:
|
|
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:
|
|
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
|
-
# (
|
|
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 || !
|
|
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 (
|
|
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
|
|
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.
|
|
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
|