samagotchi 0.121.0 → 0.122.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 +110 -1
- data/bin/chi +7 -3
- data/docs/architecture.md +37 -11
- data/docs/bundle-authoring.md +2 -1
- data/docs/cli.md +11 -4
- data/docs/configuration.md +36 -7
- data/docs/guardrails.md +63 -19
- data/docs/internals/anthropic.md +1 -1
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/engine.md +2 -0
- data/docs/internals/participants.md +16 -8
- data/docs/internals/remote-sessions.md +15 -11
- data/docs/internals/web-push.md +22 -11
- data/docs/memory.md +33 -0
- data/docs/plugins.md +8 -3
- data/docs/sessions.md +21 -0
- data/docs/testing.md +41 -1
- data/lib/samagotchi/answer_command.rb +1 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bridge.rb +12 -11
- data/lib/samagotchi/bridge_client/event_stream.rb +14 -26
- data/lib/samagotchi/bridge_client/stream_open.rb +85 -0
- data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
- data/lib/samagotchi/bridge_client.rb +52 -65
- data/lib/samagotchi/bridge_secret.rb +9 -7
- data/lib/samagotchi/bundle_command/check.rb +27 -2
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +20 -6
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
- data/lib/samagotchi/child_move.rb +5 -7
- data/lib/samagotchi/children_status.rb +2 -1
- data/lib/samagotchi/config.rb +5 -0
- data/lib/samagotchi/engine.rb +107 -1
- data/lib/samagotchi/event_fanout.rb +0 -1
- data/lib/samagotchi/guardrail_wiring.rb +24 -6
- data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
- data/lib/samagotchi/guardrails_command.rb +41 -6
- data/lib/samagotchi/image_store.rb +6 -3
- data/lib/samagotchi/interrupted_turn.rb +6 -3
- data/lib/samagotchi/live_versions.rb +6 -7
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/memory_bundle/profile.rb +1 -1
- data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
- data/lib/samagotchi/memory_orphans.rb +11 -0
- data/lib/samagotchi/participant_mode.rb +11 -0
- data/lib/samagotchi/plugin/api.rb +14 -8
- data/lib/samagotchi/plugin/sessions.rb +13 -4
- data/lib/samagotchi/process_group.rb +13 -0
- data/lib/samagotchi/push_command.rb +1 -1
- data/lib/samagotchi/relay_verifier.rb +2 -3
- data/lib/samagotchi/relay_watcher.rb +2 -2
- data/lib/samagotchi/remote_attach_command.rb +1 -1
- data/lib/samagotchi/reply_wait/poll.rb +8 -2
- data/lib/samagotchi/reply_wait.rb +4 -3
- data/lib/samagotchi/review_command.rb +1 -1
- data/lib/samagotchi/review_start.rb +6 -2
- data/lib/samagotchi/run_options.rb +2 -0
- data/lib/samagotchi/session.rb +18 -1
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +18 -8
- data/lib/samagotchi/session_access/remote.rb +37 -12
- data/lib/samagotchi/session_access/web_bridge_client.rb +38 -19
- data/lib/samagotchi/session_access.rb +21 -9
- data/lib/samagotchi/session_continue.rb +2 -3
- data/lib/samagotchi/session_manager.rb +133 -29
- data/lib/samagotchi/session_setup.rb +8 -4
- data/lib/samagotchi/sessions_command.rb +10 -5
- data/lib/samagotchi/steer.rb +7 -6
- data/lib/samagotchi/system_one/client.rb +12 -6
- data/lib/samagotchi/system_one/fake_backend.rb +0 -2
- data/lib/samagotchi/system_one/log.rb +44 -9
- data/lib/samagotchi/system_one/self_protect.rb +39 -43
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
- data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
- data/lib/samagotchi/terminal_ui.rb +2 -1
- data/lib/samagotchi/tool_runner.rb +2 -1
- data/lib/samagotchi/toolkit.rb +95 -10
- data/lib/samagotchi/toolkit_binding.rb +115 -7
- data/lib/samagotchi/toolkit_command.rb +15 -3
- data/lib/samagotchi/toolkit_report_command.rb +318 -0
- data/lib/samagotchi/toolkit_suggest.rb +211 -0
- data/lib/samagotchi/tools/delegate.rb +1 -1
- data/lib/samagotchi/tools/delegate_relay.rb +9 -7
- data/lib/samagotchi/tools/memory.rb +1 -1
- data/lib/samagotchi/turn_checkpoint.rb +15 -9
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +127 -69
- data/lib/samagotchi/web/participant_access.rb +30 -8
- data/lib/samagotchi/web/participant_review.rb +8 -5
- data/lib/samagotchi/web/participant_view.rb +7 -3
- data/lib/samagotchi/web/participants_admin.rb +13 -0
- data/lib/samagotchi/web/principal.rb +3 -4
- data/lib/samagotchi/web/public/app.js +1 -1
- data/lib/samagotchi/web/public/bubbles.js +3 -2
- data/lib/samagotchi/web/public/notifications.js +21 -1
- data/lib/samagotchi/web/public/participant.html +3 -2
- data/lib/samagotchi/web/public/participant.js +25 -10
- data/lib/samagotchi/web/public/participants_panel.js +46 -11
- data/lib/samagotchi/web/public/push.js +4 -3
- data/lib/samagotchi/web/public/push_devices.js +17 -3
- data/lib/samagotchi/web/public/review_history.js +14 -2
- data/lib/samagotchi/web/public/review_start.js +1 -3
- data/lib/samagotchi/web/public/session_actions.js +14 -2
- data/lib/samagotchi/web/public/storage.js +16 -0
- data/lib/samagotchi/web/public/turn_events.js +2 -1
- data/lib/samagotchi/web/public/turn_html.js +1 -1
- data/lib/samagotchi/web/push/client.rb +5 -0
- data/lib/samagotchi/web/push/store.rb +16 -3
- data/lib/samagotchi/web/push_routes.rb +3 -2
- data/lib/samagotchi/web/server.rb +2 -2
- data/lib/samagotchi/web/session_access_routes.rb +28 -20
- data/lib/samagotchi/web/session_control.rb +4 -2
- data/lib/samagotchi/web/session_create.rb +4 -4
- data/lib/samagotchi/web/session_hub.rb +6 -4
- data/lib/samagotchi/web/session_summary.rb +8 -16
- data/lib/samagotchi/web/start_dir.rb +10 -5
- data/lib/samagotchi/worker.rb +17 -3
- data/lib/samagotchi/worker_link.rb +214 -0
- data/lib/samagotchi/worker_sidecar.rb +15 -37
- metadata +6 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -41,6 +41,8 @@ require_relative "llm/openai_chat"
|
|
|
41
41
|
require_relative "llm/turn_settings"
|
|
42
42
|
require_relative "session"
|
|
43
43
|
require_relative "toolkit_binding"
|
|
44
|
+
require_relative "participant_mode"
|
|
45
|
+
require_relative "toolkit_suggest"
|
|
44
46
|
require_relative "tool_loads"
|
|
45
47
|
require_relative "answer_tail"
|
|
46
48
|
require_relative "archive_store"
|
|
@@ -156,6 +158,7 @@ module Samagotchi
|
|
|
156
158
|
@no_interrupt = no_interrupt
|
|
157
159
|
@chat_backend = nil
|
|
158
160
|
@chat_backend_mutex = Mutex.new
|
|
161
|
+
@toolkit_audit_thread = nil
|
|
159
162
|
@host_registry = host_registry || HostRegistry.new
|
|
160
163
|
# A model given here (a worker's session model) is the one it runs,
|
|
161
164
|
# so its host is checked; the config default is only checked once it
|
|
@@ -1082,6 +1085,10 @@ module Samagotchi
|
|
|
1082
1085
|
|
|
1083
1086
|
# How long #shutdown waits for the running anytime commands, in all.
|
|
1084
1087
|
SHUTDOWN_JOIN_SECONDS = 3.0
|
|
1088
|
+
# How long #shutdown waits for a running toolkit audit ask (a one-shot
|
|
1089
|
+
# -p --non-interactive exits right after its turn); a slower ask goes
|
|
1090
|
+
# unlogged.
|
|
1091
|
+
TOOLKIT_AUDIT_JOIN_SECONDS = 1.0
|
|
1085
1092
|
|
|
1086
1093
|
# The REPL or the session's worker is leaving (/exit, an idle exit, a
|
|
1087
1094
|
# crash, TERM): stop the idle jobs, give the running anytime commands
|
|
@@ -1107,6 +1114,7 @@ module Samagotchi
|
|
|
1107
1114
|
|
|
1108
1115
|
thread.join([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max)
|
|
1109
1116
|
end
|
|
1117
|
+
@toolkit_audit_thread&.join(TOOLKIT_AUDIT_JOIN_SECONDS)
|
|
1110
1118
|
left = threads.count(&:alive?)
|
|
1111
1119
|
Log.warn(:plugins, "anytime_commands_left", count: left) if left.positive?
|
|
1112
1120
|
@plugin_tasks.kill_leftovers
|
|
@@ -1516,6 +1524,13 @@ module Samagotchi
|
|
|
1516
1524
|
Steer.author_fields(author, @participants.name_for(author, session: session))
|
|
1517
1525
|
end
|
|
1518
1526
|
|
|
1527
|
+
# A participant's opening message keeps the rules it was delivered under
|
|
1528
|
+
# (ParticipantMode), stamped before any request carries it, so its header
|
|
1529
|
+
# never changes when the session's mode does.
|
|
1530
|
+
def participant_mode_fields(author, session: @session)
|
|
1531
|
+
Steer.participant_author?(author) ? { participant_mode: ParticipantMode.of(session).to_s } : {}
|
|
1532
|
+
end
|
|
1533
|
+
|
|
1519
1534
|
# Why a turn stopped (TurnEndings#stop_facts).
|
|
1520
1535
|
def stop_facts(event) = @turn_endings.stop_facts(event)
|
|
1521
1536
|
private :stop_facts
|
|
@@ -2128,6 +2143,8 @@ module Samagotchi
|
|
|
2128
2143
|
emit_event(turn.on_event, turn.tag(turn_started))
|
|
2129
2144
|
raise image_error if image_error
|
|
2130
2145
|
|
|
2146
|
+
start_toolkit_suggestion_audit(turn)
|
|
2147
|
+
|
|
2131
2148
|
# The window and the profile probes run after run_turn's probe-cancel
|
|
2132
2149
|
# swap, so a Stop cuts them; a failure ends the turn as turn_failed.
|
|
2133
2150
|
# The cache itself was dropped at the previous turn's end, so this
|
|
@@ -2289,7 +2306,7 @@ module Samagotchi
|
|
|
2289
2306
|
|
|
2290
2307
|
unless turn.continue
|
|
2291
2308
|
user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin.author),
|
|
2292
|
-
MessageTime::KEY => turn.at }
|
|
2309
|
+
**participant_mode_fields(turn.origin.author, session: session), MessageTime::KEY => turn.at }
|
|
2293
2310
|
user_message[:images] = image_refs unless image_refs.empty?
|
|
2294
2311
|
turn.messages << user_message
|
|
2295
2312
|
session.last_prompt = turn.prompt
|
|
@@ -2712,6 +2729,95 @@ module Samagotchi
|
|
|
2712
2729
|
def apply_toolkit!(session, on_event = nil) = @toolkit_binding.apply!(session, on_event)
|
|
2713
2730
|
private :apply_toolkit!
|
|
2714
2731
|
|
|
2732
|
+
# Start an audit-only classifier after :turn_started. All session and
|
|
2733
|
+
# tool-registry reads happen here; the thread receives only this snapshot.
|
|
2734
|
+
def start_toolkit_suggestion_audit(turn)
|
|
2735
|
+
mode = Config.get("toolkit.suggest.mode").to_s
|
|
2736
|
+
return if mode == "off"
|
|
2737
|
+
|
|
2738
|
+
session = turn.session
|
|
2739
|
+
default = Toolkit.of(session.toolkit)
|
|
2740
|
+
path = ToolkitSuggest.log_path(session_state_dir)
|
|
2741
|
+
log = SystemOne::Log.new(dir: File.dirname(path), file: File.basename(path),
|
|
2742
|
+
warn: lambda { |reason|
|
|
2743
|
+
Log.warn(:toolkit, "toolkit_suggest_log_failed",
|
|
2744
|
+
echo: "[samagotchi:toolkit] #{reason}")
|
|
2745
|
+
})
|
|
2746
|
+
# The predicate checks the session first; the kits (memory kits are
|
|
2747
|
+
# read from disk) and the log (read whole) only when it gets there.
|
|
2748
|
+
kits = nil
|
|
2749
|
+
candidates = nil
|
|
2750
|
+
return unless ToolkitSuggest.eligible?(
|
|
2751
|
+
session: session, prompt: turn.prompt, author: turn.origin.author,
|
|
2752
|
+
candidate_count: lambda {
|
|
2753
|
+
kits = @toolkit_binding.kits
|
|
2754
|
+
candidates = ToolkitSuggest.candidates(kits, default_name: default.name)
|
|
2755
|
+
candidates.size
|
|
2756
|
+
},
|
|
2757
|
+
last_suggestion: -> { ToolkitSuggest.last_suggestion(log, session.id) },
|
|
2758
|
+
defer_turns: Config.get("toolkit.suggest.defer_turns"),
|
|
2759
|
+
defer_tokens: Config.get("toolkit.suggest.defer_tokens")
|
|
2760
|
+
)
|
|
2761
|
+
|
|
2762
|
+
# One ask at a time: an eligible turn while the last one still runs
|
|
2763
|
+
# goes unasked, and says so.
|
|
2764
|
+
if @toolkit_audit_thread&.alive?
|
|
2765
|
+
Log.info(:toolkit, "toolkit_suggest_in_flight_skipped", session_id: session.id)
|
|
2766
|
+
return
|
|
2767
|
+
end
|
|
2768
|
+
|
|
2769
|
+
client = SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit)
|
|
2770
|
+
return unless client
|
|
2771
|
+
|
|
2772
|
+
if toolkit_judge_shares_local_host?(client.host)
|
|
2773
|
+
Log.info(:toolkit, "toolkit_suggest_shared_host_skipped", session_id: session.id, host: client.host)
|
|
2774
|
+
return
|
|
2775
|
+
end
|
|
2776
|
+
|
|
2777
|
+
# Rules, not tool lists: this runs before await_init_tasks and
|
|
2778
|
+
# apply_staged_tools!, so a first turn's plugin/MCP tools aren't
|
|
2779
|
+
# registered yet; the report expands the rules (ToolkitSuggest.rules).
|
|
2780
|
+
snapshots = candidates.map do |kit|
|
|
2781
|
+
ToolkitSuggest::Candidate.new(name: kit.name.dup.freeze, description: kit.description.dup.freeze,
|
|
2782
|
+
rules: ToolkitSuggest.rules(Toolkit.resolve_kit(kit.name, kits)))
|
|
2783
|
+
end.freeze
|
|
2784
|
+
default_snapshot = ToolkitSuggest::Candidate.new(name: default.name.dup.freeze, description: nil,
|
|
2785
|
+
rules: ToolkitSuggest.rules(default.resolve(kits)))
|
|
2786
|
+
core_tools = @tools.entries(toolkit: nil).select(&:core?).map { |entry| entry.name.dup.freeze }.freeze
|
|
2787
|
+
state = ToolkitSuggest.question(
|
|
2788
|
+
default: default, kits: kits, project: File.basename(session.project_root.to_s),
|
|
2789
|
+
prompt: turn.prompt
|
|
2790
|
+
).last
|
|
2791
|
+
snapshot = ToolkitSuggest::Snapshot.new(
|
|
2792
|
+
session_id: session.id.dup.freeze, turn_number: ToolkitSuggest.turn_number(session.messages),
|
|
2793
|
+
default: default_snapshot, candidates: snapshots, core_tools: core_tools,
|
|
2794
|
+
state: { project: state[:project].dup.freeze, prompt: state[:prompt].dup.freeze }.freeze
|
|
2795
|
+
)
|
|
2796
|
+
threshold = Config.get("toolkit.suggest.threshold")
|
|
2797
|
+
@toolkit_audit_thread = Thread.new do
|
|
2798
|
+
ToolkitSuggest.ask(client: client, log: log, snapshot: snapshot, threshold: threshold)
|
|
2799
|
+
rescue StandardError => e
|
|
2800
|
+
ToolkitSuggest.warn(e)
|
|
2801
|
+
end
|
|
2802
|
+
rescue StandardError => e
|
|
2803
|
+
ToolkitSuggest.warn(e)
|
|
2804
|
+
nil
|
|
2805
|
+
end
|
|
2806
|
+
private :start_toolkit_suggestion_audit
|
|
2807
|
+
|
|
2808
|
+
# D7: System 1's host is the session's model server, and a local one
|
|
2809
|
+
# (a single-slot Splash or llama.cpp): the same name, or two hosts:
|
|
2810
|
+
# entries for one server root (scheme, host, port).
|
|
2811
|
+
def toolkit_judge_shares_local_host?(judge_name)
|
|
2812
|
+
model_host, = @host_registry.host_for_model(effective_model_name)
|
|
2813
|
+
return false if model_host.nil? || model_host.remote?
|
|
2814
|
+
return true if judge_name.to_s.strip.casecmp?(model_host.name.to_s)
|
|
2815
|
+
|
|
2816
|
+
judge = @host_registry.find_entry(judge_name)
|
|
2817
|
+
!judge.nil? && judge.root_url.downcase == model_host.root_url.downcase
|
|
2818
|
+
end
|
|
2819
|
+
private :toolkit_judge_shares_local_host?
|
|
2820
|
+
|
|
2715
2821
|
# The session's toolkit (see #initialize for when).
|
|
2716
2822
|
def build_toolkit_binding
|
|
2717
2823
|
ToolkitBinding.new(
|
|
@@ -56,7 +56,6 @@ module Samagotchi
|
|
|
56
56
|
@announce_or_hold = announce_or_hold
|
|
57
57
|
@working_set_emitted = nil
|
|
58
58
|
end
|
|
59
|
-
# rubocop:enable Metrics/ParameterLists
|
|
60
59
|
|
|
61
60
|
# Always return a handler so raw kernel loop events reach the persistent
|
|
62
61
|
# SessionObserver (and thus the analytics collector) even when there is no
|
|
@@ -15,6 +15,7 @@ require_relative "edit_preview"
|
|
|
15
15
|
require_relative "system_one"
|
|
16
16
|
require_relative "tool_activity"
|
|
17
17
|
require_relative "turn_origin"
|
|
18
|
+
require_relative "participant_mode"
|
|
18
19
|
|
|
19
20
|
module Samagotchi
|
|
20
21
|
# An Engine's tool guardrails: who can approve (the interface), the YAML
|
|
@@ -126,12 +127,12 @@ module Samagotchi
|
|
|
126
127
|
# The gate's core checks, in order. A delegate child's boundary comes
|
|
127
128
|
# before the rules, so its ask keeps the child-boundary rule id (and
|
|
128
129
|
# its once/session scopes) when a strict rule asks too. The review
|
|
129
|
-
# boundary comes before both: a participant's turn is denied
|
|
130
|
-
# may not do, never asked about it
|
|
131
|
-
# can turn it into a question).
|
|
130
|
+
# boundary comes before both: a participant's review turn is denied
|
|
131
|
+
# what it may not do, never asked about it.
|
|
132
132
|
def checks
|
|
133
133
|
rules = self.rules
|
|
134
|
-
|
|
134
|
+
boundary = ParticipantMode.of(@session_lookup.call) == :review ? review_boundary : nil
|
|
135
|
+
[@failures, boundary, rules.hook_asks, protected_paths,
|
|
135
136
|
(@scratch_writes ||= Guardrails::ScratchWrites.new if @scratch), child_boundary, rules].compact
|
|
136
137
|
end
|
|
137
138
|
|
|
@@ -271,7 +272,14 @@ module Samagotchi
|
|
|
271
272
|
# System 1: the ask is judged before the user sees it, and in `on` mode
|
|
272
273
|
# one that passes every gate is allowed once without reaching them
|
|
273
274
|
# (docs/guardrails.md, "System 1").
|
|
274
|
-
judgment =
|
|
275
|
+
judgment = begin
|
|
276
|
+
system_one_judge(payload)
|
|
277
|
+
rescue LLM::RequestCancelled
|
|
278
|
+
return turn_stopped(verdict)
|
|
279
|
+
end
|
|
280
|
+
# A turn stopped while the judge was asked opens no card.
|
|
281
|
+
return turn_stopped(verdict) if @cancelled_lookup.call
|
|
282
|
+
|
|
275
283
|
if judgment && judgment[:auto]
|
|
276
284
|
verdict.judgment = { id: judgment[:id], p: judgment[:p] }
|
|
277
285
|
verdict.settle!(:allow, decided_by: "system one")
|
|
@@ -286,6 +294,12 @@ module Samagotchi
|
|
|
286
294
|
|
|
287
295
|
private
|
|
288
296
|
|
|
297
|
+
# The deny for a call whose turn was stopped before its ask reached the
|
|
298
|
+
# user (as Guardrails::Gate words one stopped before the vote).
|
|
299
|
+
def turn_stopped(verdict)
|
|
300
|
+
verdict.settle!(:deny, decided_by: "core", note: "The turn was stopped before the user was asked.")
|
|
301
|
+
end
|
|
302
|
+
|
|
289
303
|
# The judgments log (SystemOne::Log), beside the approvals store; nil
|
|
290
304
|
# while there is no store dir.
|
|
291
305
|
def system_one_log
|
|
@@ -319,6 +333,8 @@ module Samagotchi
|
|
|
319
333
|
entries: entries),
|
|
320
334
|
questions: { SystemOne::ApprovalQuestion::ID => SystemOne::ApprovalQuestion.question },
|
|
321
335
|
cancel_controller: @cancel_controller.call)
|
|
336
|
+
rescue LLM::RequestCancelled
|
|
337
|
+
raise
|
|
322
338
|
rescue StandardError => e
|
|
323
339
|
# The client already turns every failure it knows into a nil; this is
|
|
324
340
|
# the unexpected one. Either way the ask goes to the user.
|
|
@@ -338,6 +354,8 @@ module Samagotchi
|
|
|
338
354
|
latency_ms: latency_ms, decision: decision, origin: "live")
|
|
339
355
|
# A judgment the log cannot hold is no judgment: the user is asked.
|
|
340
356
|
{ id: id, p: p, auto: auto && !id.nil? }
|
|
357
|
+
rescue LLM::RequestCancelled
|
|
358
|
+
raise
|
|
341
359
|
rescue StandardError => e
|
|
342
360
|
# The approval never fails because of System 1.
|
|
343
361
|
Samagotchi::Log.warn(:guardrails, "system_one_judgment_failed", error: "#{e.class}: #{e.message}")
|
|
@@ -408,7 +426,7 @@ module Samagotchi
|
|
|
408
426
|
"deny"
|
|
409
427
|
end
|
|
410
428
|
end
|
|
411
|
-
system_one_log.label(id, label)
|
|
429
|
+
system_one_log.label(id, label, question_id: (answer.id if answer.is_a?(Samagotchi::QuestionAnswer)))
|
|
412
430
|
rescue StandardError => e
|
|
413
431
|
Samagotchi::Log.warn(:guardrails, "system_one_label_failed", error: "#{e.class}: #{e.message}")
|
|
414
432
|
nil
|
|
@@ -69,6 +69,15 @@ module Samagotchi
|
|
|
69
69
|
# memory-remove rule's ask, which a parent may answer.
|
|
70
70
|
return false if fetch(facts, :rule).to_s == MEMORY_REMOVE_RULE
|
|
71
71
|
|
|
72
|
+
chi_files?(facts)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Whether an approval's paths or command name chi's own dirs (#chi_dirs)
|
|
76
|
+
# or CHI_TEXT, whatever its rule: the part of #protected_facts? that
|
|
77
|
+
# System 1's SystemOne::SelfProtect shares.
|
|
78
|
+
def chi_files?(facts)
|
|
79
|
+
return false unless facts.is_a?(Hash)
|
|
80
|
+
|
|
72
81
|
dirs = chi_dirs
|
|
73
82
|
paths = Array(fetch(facts, :paths)).map(&:to_s).reject(&:empty?)
|
|
74
83
|
return true if paths.any? { |path| dirs.any? { |dir| ProtectedPaths.within?(ProtectedPaths.real(path), dir) } }
|
|
@@ -61,6 +61,7 @@ module Samagotchi
|
|
|
61
61
|
# uses.
|
|
62
62
|
SKIP_WORDS = {
|
|
63
63
|
already_replayed: "already replayed",
|
|
64
|
+
judged_live: "already judged live",
|
|
64
65
|
answered_by_parent: "answered by a parent agent",
|
|
65
66
|
parent_cannot_allow: "answered by a parent that could not allow",
|
|
66
67
|
unanswered: "unanswered",
|
|
@@ -73,6 +74,36 @@ module Samagotchi
|
|
|
73
74
|
# and when the user was asked.
|
|
74
75
|
Ask = Data.define(:question_id, :session_id, :approval, :label, :asked_at)
|
|
75
76
|
|
|
77
|
+
# What the judgments log already holds, so no ask is judged twice: the
|
|
78
|
+
# question ids its judgments name, with their origin (a replay's own, a
|
|
79
|
+
# live one's through its label), and, for live judgments with none (an ask that was
|
|
80
|
+
# cancelled, or a line older than the id), how many each (session, call
|
|
81
|
+
# digest) has. A card with no id match takes one of those (#take_live).
|
|
82
|
+
Judged = Data.define(:question_ids, :live_calls) do
|
|
83
|
+
# @param entries [Array<Hash>] SystemOne::Log#entries
|
|
84
|
+
def self.of(entries)
|
|
85
|
+
ids = entries.filter_map { |entry| [entry["question_id"], entry["origin"]] if entry["question_id"] }.to_h
|
|
86
|
+
live = Hash.new(0)
|
|
87
|
+
entries.each do |entry|
|
|
88
|
+
next unless entry["origin"] == "live" && entry["question_id"].nil?
|
|
89
|
+
|
|
90
|
+
live[[entry["session_id"], entry["call_digest"]]] += 1
|
|
91
|
+
end
|
|
92
|
+
new(question_ids: ids, live_calls: live)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Whether a live judgment without a question id is this card's: true
|
|
96
|
+
# (and that judgment counted off) while one with its session and call
|
|
97
|
+
# digest is left.
|
|
98
|
+
def take_live(session_id, approval)
|
|
99
|
+
key = [session_id, SystemOne::ApprovalQuestion.call_fields(approval)["call_digest"]]
|
|
100
|
+
return false unless live_calls[key].positive?
|
|
101
|
+
|
|
102
|
+
live_calls[key] -= 1
|
|
103
|
+
true
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
76
107
|
# @param argv [Array<String>] the arguments after "guardrails"
|
|
77
108
|
# @param client [SystemOne::Client, nil] the judgment client; nil: the
|
|
78
109
|
# one config.yml describes (the seam the specs pass a fake through)
|
|
@@ -122,7 +153,7 @@ module Samagotchi
|
|
|
122
153
|
|
|
123
154
|
sessions_dir = @sessions_dir || Session.default_state_dir
|
|
124
155
|
log = @log || SystemOne::Log.new(dir: Guardrails::Approvals.dir_for(sessions_dir))
|
|
125
|
-
|
|
156
|
+
judged = Judged.of(File.exist?(log.path) ? log.entries : [])
|
|
126
157
|
@dry_run = options[:dry_run] ? true : false
|
|
127
158
|
|
|
128
159
|
asks = []
|
|
@@ -140,7 +171,7 @@ module Samagotchi
|
|
|
140
171
|
end
|
|
141
172
|
|
|
142
173
|
entries.each do |entry|
|
|
143
|
-
ask = ask_of(entry, name,
|
|
174
|
+
ask = ask_of(entry, name, judged, sessions_dir)
|
|
144
175
|
next if ask.nil?
|
|
145
176
|
|
|
146
177
|
ask.is_a?(Symbol) ? skipped[ask] += 1 : asks << ask
|
|
@@ -168,9 +199,10 @@ module Samagotchi
|
|
|
168
199
|
end
|
|
169
200
|
|
|
170
201
|
# One card entry as an ask to judge: an Ask, a skip reason (:already_replayed,
|
|
171
|
-
# :answered_by_parent, :parent_cannot_allow, :unanswered,
|
|
172
|
-
# :no_question_id), or nil for an entry that is not a
|
|
173
|
-
|
|
202
|
+
# :judged_live, :answered_by_parent, :parent_cannot_allow, :unanswered,
|
|
203
|
+
# :no_approval, :no_question_id), or nil for an entry that is not a
|
|
204
|
+
# question at all.
|
|
205
|
+
def ask_of(entry, session_id, judged, sessions_dir)
|
|
174
206
|
return nil unless entry.is_a?(Hash) && entry[:type] == "question"
|
|
175
207
|
|
|
176
208
|
pending = entry[:pending_question]
|
|
@@ -178,7 +210,10 @@ module Samagotchi
|
|
|
178
210
|
|
|
179
211
|
question_id = pending[:id].to_s
|
|
180
212
|
return :no_question_id if question_id.empty?
|
|
181
|
-
|
|
213
|
+
|
|
214
|
+
origin = judged.question_ids[question_id]
|
|
215
|
+
return origin == "replay" ? :already_replayed : :judged_live if origin
|
|
216
|
+
return :judged_live if judged.take_live(session_id, pending[:approval])
|
|
182
217
|
|
|
183
218
|
answer = entry[:answer]
|
|
184
219
|
label = if entry[:cancelled]
|
|
@@ -201,6 +201,9 @@ module Samagotchi
|
|
|
201
201
|
# a request is sent.
|
|
202
202
|
module ImageStore
|
|
203
203
|
class Error < StandardError
|
|
204
|
+
# A ref to a file this session doesn't hold as an image: one wording
|
|
205
|
+
# for a turn's refs (#check_refs) and a web upload's (#ref_for).
|
|
206
|
+
def self.unknown_image(file) = new("unknown image #{file.to_s[0, 80]}")
|
|
204
207
|
end
|
|
205
208
|
|
|
206
209
|
DIR = "images"
|
|
@@ -278,7 +281,7 @@ module Samagotchi
|
|
|
278
281
|
|
|
279
282
|
ref = symbolize(image)
|
|
280
283
|
return "images are refs to uploaded files, not paths" if ref.key?(:path)
|
|
281
|
-
return
|
|
284
|
+
return Error.unknown_image(ref[:file]).message unless valid_ref?(session_dir, ref)
|
|
282
285
|
|
|
283
286
|
{ file: ref[:file].to_s, name: File.basename(ref[:name].to_s)[0, 120] }
|
|
284
287
|
end
|
|
@@ -321,11 +324,11 @@ module Samagotchi
|
|
|
321
324
|
# A ref for an image already stored in this session (a web upload),
|
|
322
325
|
# rebuilt from the file itself: only its name comes from the caller.
|
|
323
326
|
def self.ref_for(session_dir, file:, name: nil, source: "user")
|
|
324
|
-
raise Error
|
|
327
|
+
raise Error.unknown_image(file) unless valid_ref?(session_dir, { file: file })
|
|
325
328
|
|
|
326
329
|
bytes = File.binread(File.join(session_dir.to_s, file.to_s))
|
|
327
330
|
info = ImageHeader.read(bytes)
|
|
328
|
-
raise Error
|
|
331
|
+
raise Error.unknown_image(file) unless info && MIME.key?(info.format) && info.width
|
|
329
332
|
|
|
330
333
|
name = File.basename(name.to_s).gsub(/[[:cntrl:]]/, "")[0, 120]
|
|
331
334
|
{ file: file.to_s, mime: MIME.fetch(info.format), width: info.width, height: info.height,
|
|
@@ -13,9 +13,12 @@ module Samagotchi
|
|
|
13
13
|
# runs (its ack's id, LastTurn#enqueued_id once it ends; a Continue turn
|
|
14
14
|
# carries its offer turn's), so any process that loads the session can
|
|
15
15
|
# tell which turn is running (ReplyWait.own_turn); nil when it ran none,
|
|
16
|
-
# or a worker from before it wrote one.
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
# or a worker from before it wrote one. author: a participant's turn's
|
|
17
|
+
# "p:<id>" (nil for the owner's), which its settled record keeps, so a
|
|
18
|
+
# chi web restarted since still knows whose wait it is
|
|
19
|
+
# (ParticipantAccess#turn_author).
|
|
20
|
+
InterruptedTurn = Data.define(:id, :started_at, :at, :iteration, :enqueued_id, :author) do
|
|
21
|
+
def initialize(id: nil, started_at: nil, at: nil, iteration: nil, enqueued_id: nil, author: nil) = super
|
|
19
22
|
|
|
20
23
|
# A saved record (string or symbol keys), an InterruptedTurn as it is;
|
|
21
24
|
# nil for anything else.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "session"
|
|
4
4
|
require_relative "version"
|
|
5
|
-
require_relative "
|
|
5
|
+
require_relative "worker_link"
|
|
6
6
|
require_relative "web/info_probe"
|
|
7
7
|
|
|
8
8
|
module Samagotchi
|
|
@@ -21,13 +21,12 @@ module Samagotchi
|
|
|
21
21
|
|
|
22
22
|
# @return [Array<Worker>] the workers whose Bridge answers, by session id
|
|
23
23
|
def workers(state_dir: Session.default_state_dir)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
next unless sidecar
|
|
24
|
+
WorkerLink.each_dir(state_dir).filter_map do |session_dir|
|
|
25
|
+
link = WorkerLink.resolve(session_dir, unlink: false)
|
|
26
|
+
next unless link.live?
|
|
28
27
|
|
|
29
|
-
Worker.new(session_id: File.basename(session_dir), version:
|
|
30
|
-
restart:
|
|
28
|
+
Worker.new(session_id: File.basename(session_dir), version: link.version,
|
|
29
|
+
restart: link.features.include?("restart"))
|
|
31
30
|
end
|
|
32
31
|
end
|
|
33
32
|
|
data/lib/samagotchi/log_line.rb
CHANGED
|
@@ -16,7 +16,7 @@ module Samagotchi
|
|
|
16
16
|
# (debug dumps) are indented by four spaces and belong to the record above.
|
|
17
17
|
module LogLine
|
|
18
18
|
LEVELS = %w[DEBUG INFO WARN ERROR].freeze
|
|
19
|
-
TAGS = %w[turn http worker bridge web attached repl idle recap hooks plugins guardrails config memory model context broadcast].freeze
|
|
19
|
+
TAGS = %w[turn http worker bridge web attached repl idle recap hooks plugins guardrails config memory model context broadcast toolkit].freeze
|
|
20
20
|
INDENT = " "
|
|
21
21
|
|
|
22
22
|
Record = Struct.new(:ts, :level, :tag, :pid, :sid, :event, :fields, :payload, keyword_init: true)
|
|
@@ -152,7 +152,7 @@ module Samagotchi
|
|
|
152
152
|
# @raise [Uninstaller::UninstallError] when +name+ isn't installed
|
|
153
153
|
def uninstall(name, shipped_dir: SourceNormalizer::SHIPPED_DIR, force: false)
|
|
154
154
|
bundle = Provenance.new(name: name).record
|
|
155
|
-
raise Uninstaller::UninstallError
|
|
155
|
+
raise Uninstaller::UninstallError.not_installed(name) unless bundle
|
|
156
156
|
|
|
157
157
|
shipped = File.join(shipped_dir, name)
|
|
158
158
|
manifest = File.file?(File.join(shipped, "manifest.yml")) ? Manifest.read(dir: shipped) : nil
|
|
@@ -12,7 +12,11 @@ require_relative "../model_overlay"
|
|
|
12
12
|
module Samagotchi
|
|
13
13
|
module MemoryBundle
|
|
14
14
|
class Uninstaller
|
|
15
|
-
class UninstallError < StandardError
|
|
15
|
+
class UninstallError < StandardError
|
|
16
|
+
# The bundle +name+ has no install record: one wording for the
|
|
17
|
+
# uninstall of a bundle and of a profile (Profile#uninstall).
|
|
18
|
+
def self.not_installed(name) = new("Bundle '#{name}' is not installed")
|
|
19
|
+
end
|
|
16
20
|
|
|
17
21
|
# removed_files: the bundle's own hooks/ and plugin/ files (deleted);
|
|
18
22
|
# trashed_files: its memory files, moved to trash_dir (Trash), nil
|
|
@@ -34,7 +38,7 @@ module Samagotchi
|
|
|
34
38
|
def run
|
|
35
39
|
provenance = Provenance.new(name: @name)
|
|
36
40
|
bundle = provenance.record
|
|
37
|
-
raise UninstallError
|
|
41
|
+
raise UninstallError.not_installed(@name) unless bundle
|
|
38
42
|
|
|
39
43
|
# The scope from the flag, else the record's
|
|
40
44
|
target_scope = @scope.to_s.strip.empty? ? bundle.effective_scope : @scope
|
|
@@ -51,6 +51,17 @@ module Samagotchi
|
|
|
51
51
|
found && text(found)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# A model-facing hint: report the name collision without giving the model
|
|
55
|
+
# a command that could move another live clone's memories.
|
|
56
|
+
def model_message(env: ENV, cwd: Dir.pwd)
|
|
57
|
+
found = find(env: env, cwd: cwd)
|
|
58
|
+
return unless found
|
|
59
|
+
|
|
60
|
+
name = File.basename(found.computed).sub(/_\h{8}\z/, "")
|
|
61
|
+
"a folder with the same project name (#{name}) exists with another path's hash; ask the user whether its " \
|
|
62
|
+
"memories belong to this project before using them."
|
|
63
|
+
end
|
|
64
|
+
|
|
54
65
|
def text(found)
|
|
55
66
|
computed = found.computed
|
|
56
67
|
lead = "no project memories in #{computed}"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# The rules for a participant's turn. P0 keeps every participant in review
|
|
5
|
+
# mode; this is the single resolver for the later mode split.
|
|
6
|
+
module ParticipantMode
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def of(_session) = :review
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -24,9 +24,11 @@ module Samagotchi
|
|
|
24
24
|
# A call not listed gets no floor check: it is in the first tag (v0.2.0:
|
|
25
25
|
# chi.command/tool/on/service/init, ctx.settings, ctx.sessions,
|
|
26
26
|
# ctx.ask_model, ctx.notify, ctx.card, ctx.messages…). A member of
|
|
27
|
-
# one (ctx.sessions.children) is listed when it came later
|
|
28
|
-
# no "?" (API_CALL drops it). A
|
|
29
|
-
#
|
|
27
|
+
# one (ctx.sessions.children) is listed when it came later; fork, send
|
|
28
|
+
# and read are in v0.2.0. Keys have no "?" (API_CALL drops it). A
|
|
29
|
+
# keyword added later is "call(keyword:)", the call's receiver as the
|
|
30
|
+
# plugin writes it (Check#keyword_calls: `ctx.sessions.send(…,
|
|
31
|
+
# delivery:)` is seen, a `sessions = ctx.sessions` alias isn't).
|
|
30
32
|
# Events aren't listed: chi.on takes any symbol and an older chi never
|
|
31
33
|
# fires one it doesn't know (harmless; chi bundle check warns on it),
|
|
32
34
|
# and a manifest hook's events: list (BundleHook) needs the bundle's
|
|
@@ -42,7 +44,8 @@ module Samagotchi
|
|
|
42
44
|
"chi.tool_source" => "0.67.0", "chi.peer_source" => "0.67.0", "ctx.http" => "0.80.0",
|
|
43
45
|
"ctx.cost" => "0.83.0", "ctx.sessions.children" => "0.34.0", "ctx.load_tools" => "0.99.0",
|
|
44
46
|
"ctx.usage" => "0.100.0", "ctx.sessions.stop" => "0.34.0", "ctx.turn_author" => "0.106.0",
|
|
45
|
-
"ctx.sessions.review" => "0.107.0"
|
|
47
|
+
"ctx.sessions.review" => "0.107.0", "ctx.sessions.send(delivery:)" => "0.64.0",
|
|
48
|
+
"chi.toolkit(prompt:)" => "0.72.0", "ctx.ask_model(model:)" => "0.80.0", "chi.toolkit(groups:)" => "0.121.0"
|
|
46
49
|
}.freeze
|
|
47
50
|
|
|
48
51
|
# @param bundle [String]
|
|
@@ -309,17 +312,20 @@ module Samagotchi
|
|
|
309
312
|
# a bundle has) with +add+ declared and +remove+ left out, and
|
|
310
313
|
# +prompt+: text the sessions on it get in their system prompt (nil:
|
|
311
314
|
# its base's, "" none; at most Toolkit::PROMPT_MAX characters);
|
|
312
|
-
# +groups+: MCP source group names/globs (nil inherits its base)
|
|
315
|
+
# +groups+: MCP source group names/globs (nil inherits its base);
|
|
316
|
+
# +description+: optional classifier text, not included in prompts
|
|
317
|
+
# (at most Toolkit::DESCRIPTION_MAX characters).
|
|
313
318
|
# A
|
|
314
319
|
# name chi (full, lean, child) or another bundle has is a load error,
|
|
315
320
|
# and so is a prompt that isn't text or is too long; a base that
|
|
316
321
|
# doesn't resolve leaves the sessions on it with every tool
|
|
317
322
|
# (Toolkit#resolve).
|
|
318
|
-
def toolkit(name, base:, add: [], remove: [], prompt: nil, groups: nil)
|
|
323
|
+
def toolkit(name, base:, add: [], remove: [], prompt: nil, groups: nil, description: nil)
|
|
319
324
|
name = name.to_s
|
|
320
325
|
raise ArgumentError, "toolkit name #{name.inspect} must be a-z, 0-9, _ and -" unless name.match?(TOOLKIT_NAME)
|
|
321
326
|
|
|
322
|
-
problem = Toolkit.prompt_error(prompt)
|
|
327
|
+
problem = Toolkit.prompt_error(prompt) || Toolkit.description_error(description) ||
|
|
328
|
+
(Toolkit.groups_error(groups) unless groups == :all)
|
|
323
329
|
raise ArgumentError, "toolkit #{name}: #{problem}" if problem
|
|
324
330
|
raise ArgumentError, "this chi has no toolkits (plugins: false)" unless @registries.toolkits
|
|
325
331
|
if Toolkit::BUILTIN_KITS.key?(name)
|
|
@@ -330,7 +336,7 @@ module Samagotchi
|
|
|
330
336
|
raise ArgumentError, "toolkit #{name} is declared twice" if @toolkits.any? { |kit| kit.name == name }
|
|
331
337
|
|
|
332
338
|
@toolkits << Toolkit::Kit.new(name: name, base: base, add: add, remove: remove, prompt: prompt,
|
|
333
|
-
groups: groups, source: @bundle)
|
|
339
|
+
groups: groups, source: @bundle, description: description)
|
|
334
340
|
nil
|
|
335
341
|
end
|
|
336
342
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require_relative "../client_id"
|
|
4
4
|
require_relative "../delivery"
|
|
5
5
|
require_relative "../session"
|
|
6
|
-
require_relative "../
|
|
6
|
+
require_relative "../worker_link"
|
|
7
7
|
require_relative "../bridge/turn_accumulator"
|
|
8
8
|
require_relative "../tools/delegate"
|
|
9
9
|
require_relative "../children_status"
|
|
@@ -27,6 +27,11 @@ module Samagotchi
|
|
|
27
27
|
# A fork, send, read or stop that couldn't be done; the message says why.
|
|
28
28
|
class Error < StandardError; end
|
|
29
29
|
|
|
30
|
+
# What starting a session can be refused by, answered as Error with
|
|
31
|
+
# chi's own message: a model that is gone, a toolkit the config refuses,
|
|
32
|
+
# a bad argument. #review adds its own: a subject it won't take.
|
|
33
|
+
REFUSED = [ModelProfile::MissingModel, Toolkit::Refused, ArgumentError].freeze
|
|
34
|
+
|
|
30
35
|
# @param host [Host] session_id, cwd, model_name and state_dir
|
|
31
36
|
def initialize(host)
|
|
32
37
|
@host = host
|
|
@@ -44,7 +49,9 @@ module Samagotchi
|
|
|
44
49
|
# @param prompt [String, nil] the child's first turn
|
|
45
50
|
# @return [String] the child's id
|
|
46
51
|
# @raise [Error] no session yet, a scratch session (the child would
|
|
47
|
-
# outlive it),
|
|
52
|
+
# outlive it), too many children running, or the model or toolkit the
|
|
53
|
+
# child starts on being refused (a config kit with an invalid groups:,
|
|
54
|
+
# a model that is gone)
|
|
48
55
|
def fork(messages:, title: nil, prompt: nil)
|
|
49
56
|
parent_id = @host.session_id.call or raise Error, "this session has no id yet"
|
|
50
57
|
raise Error, "a scratch session starts no other sessions: they would outlive it" if @host.scratch&.call
|
|
@@ -60,6 +67,8 @@ module Samagotchi
|
|
|
60
67
|
setup: @host.setup&.call || SessionSetup.new
|
|
61
68
|
)
|
|
62
69
|
child.id
|
|
70
|
+
rescue *REFUSED => e
|
|
71
|
+
raise Error, e.message
|
|
63
72
|
end
|
|
64
73
|
|
|
65
74
|
# A review of this session (ReviewStart.fork, chi review --from): a new
|
|
@@ -81,7 +90,7 @@ module Samagotchi
|
|
|
81
90
|
|
|
82
91
|
ReviewStart.fork(from: source, subjects: Array(subjects), seed: seed, state_dir: state_dir,
|
|
83
92
|
recap_wait: recap_wait).id
|
|
84
|
-
rescue ReviewStart::Refused
|
|
93
|
+
rescue *REFUSED, ReviewStart::Refused => e
|
|
85
94
|
raise Error, e.message
|
|
86
95
|
end
|
|
87
96
|
|
|
@@ -212,7 +221,7 @@ module Samagotchi
|
|
|
212
221
|
end
|
|
213
222
|
|
|
214
223
|
def live_snapshot(sid, state_dir)
|
|
215
|
-
|
|
224
|
+
WorkerLink.client(sid, session_dir: Session.session_dir(sid, state_dir: state_dir))&.get_json("snapshot")
|
|
216
225
|
rescue StandardError
|
|
217
226
|
nil
|
|
218
227
|
end
|
|
@@ -87,6 +87,19 @@ module Samagotchi
|
|
|
87
87
|
nil
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
+
# Whether +pid+ is stopped (SIGSTOP, Ctrl-Z; t: under a tracer): ps's
|
|
91
|
+
# state starts with T or t on macOS and Linux alike. Such a process
|
|
92
|
+
# takes no signal but KILL and answers no socket until it is continued.
|
|
93
|
+
# false when it isn't running or ps can't say.
|
|
94
|
+
def stopped?(pid)
|
|
95
|
+
return false unless signalable?(pid)
|
|
96
|
+
|
|
97
|
+
out, status = Open3.capture2("ps", "-o", "state=", "-p", pid.to_s, err: File::NULL)
|
|
98
|
+
status.success? && out.lstrip.start_with?("T", "t")
|
|
99
|
+
rescue SystemCallError, IOError
|
|
100
|
+
false
|
|
101
|
+
end
|
|
102
|
+
|
|
90
103
|
# Field 22 (starttime); the command name (field 2) may hold spaces and
|
|
91
104
|
# parentheses, so the fields are counted after its last ")".
|
|
92
105
|
def proc_start_time(stat) = stat[/\A.*\)\s(.*)\z/m, 1]&.split&.[](19)
|
|
@@ -135,7 +135,7 @@ module Samagotchi
|
|
|
135
135
|
# route does). @return [Boolean] delivered
|
|
136
136
|
def test(sub)
|
|
137
137
|
result = client_for(sub).deliver(sub, Web::Push::TEST_PAYLOAD, ttl: Web::Push::TEST_TTL, urgency: "high")
|
|
138
|
-
words =
|
|
138
|
+
words = [result.status, result.code, ("(#{result.ms} ms)" if result.ms)].reject { |part| part.to_s.empty? }.join(" ")
|
|
139
139
|
words += ": #{result.error}" if result.error && !result.ok?
|
|
140
140
|
if result.status == :gone
|
|
141
141
|
@store.remove(sub.id)
|