samagotchi 0.160.0 → 0.161.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 +86 -1
- data/docs/architecture.md +7 -3
- data/docs/cli.md +6 -4
- data/docs/context.md +10 -1
- data/docs/guardrails.md +12 -3
- data/docs/internals/bridge.md +21 -6
- data/docs/internals/bundles.md +24 -5
- data/docs/internals/objectives.md +8 -2
- data/docs/internals/remote-sessions.md +2 -1
- data/docs/memory.md +4 -2
- data/docs/plugins.md +13 -4
- data/docs/releasing.md +7 -4
- data/docs/scheduling.md +37 -5
- data/docs/sessions.md +14 -11
- data/docs/testing.md +9 -1
- data/lib/samagotchi/bridge/card_store.rb +31 -3
- data/lib/samagotchi/bridge/ring_buffer.rb +11 -1
- data/lib/samagotchi/bridge/sse_writer.rb +8 -5
- data/lib/samagotchi/bridge.rb +92 -42
- data/lib/samagotchi/bridge_client.rb +9 -6
- data/lib/samagotchi/broadcast_command.rb +24 -8
- data/lib/samagotchi/bundle_command.rb +56 -6
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +29 -23
- data/lib/samagotchi/client_id.rb +2 -0
- data/lib/samagotchi/compat.rb +32 -0
- data/lib/samagotchi/context_candidates_log.rb +10 -1
- data/lib/samagotchi/context_command.rb +1 -1
- data/lib/samagotchi/context_move.rb +6 -1
- data/lib/samagotchi/engine/events.rb +12 -0
- data/lib/samagotchi/engine/plugins.rb +1 -1
- data/lib/samagotchi/engine/steering.rb +2 -2
- data/lib/samagotchi/guardrails/child_boundary.rb +37 -4
- data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
- data/lib/samagotchi/guardrails/targets.rb +25 -6
- data/lib/samagotchi/live_versions.rb +7 -2
- data/lib/samagotchi/memory_bundle/asset_installer.rb +59 -12
- data/lib/samagotchi/memory_bundle/file_state.rb +11 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +7 -4
- data/lib/samagotchi/memory_bundle/installer.rb +79 -12
- data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
- data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
- data/lib/samagotchi/memory_bundle/status.rb +1 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +27 -13
- data/lib/samagotchi/memory_orphans.rb +40 -9
- data/lib/samagotchi/note_command.rb +9 -1
- data/lib/samagotchi/note_delivery.rb +34 -24
- data/lib/samagotchi/note_move.rb +205 -0
- data/lib/samagotchi/objectives_command.rb +3 -1
- data/lib/samagotchi/owner_lock.rb +7 -1
- data/lib/samagotchi/project_init.rb +3 -3
- data/lib/samagotchi/review_start.rb +5 -3
- data/lib/samagotchi/schedule_command.rb +12 -0
- data/lib/samagotchi/schedules.rb +286 -48
- data/lib/samagotchi/send_command.rb +2 -15
- data/lib/samagotchi/session.rb +42 -5
- data/lib/samagotchi/session_access/local.rb +45 -17
- data/lib/samagotchi/session_access/remote.rb +11 -4
- data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
- data/lib/samagotchi/session_access.rb +16 -3
- data/lib/samagotchi/session_continue.rb +155 -21
- data/lib/samagotchi/session_delete_command.rb +3 -1
- data/lib/samagotchi/session_inbox.rb +41 -1
- data/lib/samagotchi/session_inbox_delivery.rb +254 -0
- data/lib/samagotchi/session_manager/delivery.rb +66 -12
- data/lib/samagotchi/session_manager/emptiness.rb +7 -2
- data/lib/samagotchi/session_manager/lifecycle.rb +2 -1
- data/lib/samagotchi/session_manager/listing.rb +4 -1
- data/lib/samagotchi/session_manager/removal.rb +130 -26
- data/lib/samagotchi/session_manager/spawn.rb +164 -104
- data/lib/samagotchi/session_manager.rb +1 -0
- data/lib/samagotchi/session_retention.rb +53 -23
- data/lib/samagotchi/session_setup.rb +27 -7
- data/lib/samagotchi/starting_marker.rb +93 -24
- data/lib/samagotchi/state_format.rb +18 -7
- data/lib/samagotchi/system_one/runs.rb +6 -1
- data/lib/samagotchi/system_one/runs_store.rb +18 -14
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +7 -4
- data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +5 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +4 -2
- data/lib/samagotchi/terminal_ui.rb +2 -3
- data/lib/samagotchi/tools/delegate.rb +9 -4
- data/lib/samagotchi/tools/schedule_task.rb +5 -3
- data/lib/samagotchi/tools/send_note.rb +10 -4
- data/lib/samagotchi/turn_state.rb +108 -4
- data/lib/samagotchi/update_command.rb +7 -2
- data/lib/samagotchi/verify_fresh.rb +27 -9
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +1 -1
- data/lib/samagotchi/web/public/app.js +2 -7
- data/lib/samagotchi/web/public/chains.js +4 -4
- data/lib/samagotchi/web/public/data.js +18 -2
- data/lib/samagotchi/web/public/info_bar_model.js +1 -1
- data/lib/samagotchi/web/public/session_fields.js +3 -2
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/update_notice.js +2 -2
- data/lib/samagotchi/web/session_access_routes.rb +12 -8
- data/lib/samagotchi/web/session_control.rb +9 -3
- data/lib/samagotchi/web/session_create.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +50 -5
- data/lib/samagotchi/web/session_summary.rb +5 -2
- data/lib/samagotchi/worker.rb +45 -7
- data/lib/samagotchi/worker_boot.rb +22 -11
- data/lib/samagotchi/worker_inbound.rb +41 -8
- data/lib/samagotchi/worker_link.rb +4 -1
- data/lib/samagotchi/worker_presence.rb +106 -0
- data/lib/samagotchi/worker_sidecar.rb +3 -4
- metadata +5 -1
|
@@ -249,13 +249,20 @@ module Samagotchi
|
|
|
249
249
|
# +decisions+, prints a line per recipient and the summary, and logs it.
|
|
250
250
|
def deliver_all(broadcast, decisions, cards)
|
|
251
251
|
ok = true
|
|
252
|
+
# Two links of one chain are one session: the note goes once (Head).
|
|
253
|
+
got = {}
|
|
252
254
|
lines = decisions.map do |d|
|
|
253
255
|
next [d, "skipped", d.verdict.reason] unless d.deliver?
|
|
254
256
|
|
|
257
|
+
head = SessionInbox::Head.resolve(:note, d.recipient.id, state_dir: @state_dir)
|
|
258
|
+
next [d, "skipped", "#{head.id[0, 8]}, its chain's newest link, got it already"] if got[head.id]
|
|
259
|
+
|
|
255
260
|
result = NoteDelivery.deliver(d.recipient.id, text: body(broadcast.text, d.verdict), source: SOURCE,
|
|
256
|
-
state_dir: @state_dir)
|
|
261
|
+
state_dir: @state_dir, resolved: head)
|
|
257
262
|
next [d, "skipped", OwnerLock::TUI_BUSY] unless result.delivered?
|
|
258
263
|
|
|
264
|
+
got[result.id] = true
|
|
265
|
+
|
|
259
266
|
[d, "delivered", "#{d.verdict.reason}#{"; waits for its next start" if result.status == :waits}"]
|
|
260
267
|
rescue SessionInbox::NoteRejected, SystemCallError => e
|
|
261
268
|
ok = false
|
|
@@ -333,7 +340,9 @@ module Samagotchi
|
|
|
333
340
|
return 1
|
|
334
341
|
end
|
|
335
342
|
ids = sessions.map { |given| resolve_session(given) }
|
|
336
|
-
|
|
343
|
+
# Two links of one chain are one session: the note goes once (Head).
|
|
344
|
+
results = ids.compact.map { |id| SessionInbox::Head.resolve(:note, id, state_dir: @state_dir) }
|
|
345
|
+
.uniq(&:id).map { |head| deliver_anyway(record, head) }
|
|
337
346
|
unless results.empty?
|
|
338
347
|
begin
|
|
339
348
|
@log.append_correction(id: record.id, at: @now, sessions: results)
|
|
@@ -359,22 +368,29 @@ module Samagotchi
|
|
|
359
368
|
nil
|
|
360
369
|
end
|
|
361
370
|
|
|
362
|
-
# @param
|
|
371
|
+
# @param head [SessionInbox::Resolved] the session named (+asked+, a full
|
|
372
|
+
# id: #resolve_session) and the link that gets it (a continued one's
|
|
373
|
+
# chain's newest)
|
|
363
374
|
# @return [Broadcast::TriageLog::Delivery]
|
|
364
|
-
def deliver_anyway(record,
|
|
365
|
-
|
|
375
|
+
def deliver_anyway(record, head)
|
|
376
|
+
id = head.asked
|
|
377
|
+
if record.delivered?(id) || record.delivered?(head.id)
|
|
366
378
|
@stdout.puts(line_for(id, "skipped", "it got #{record.id} already"))
|
|
367
379
|
return Broadcast::TriageLog::Delivery.new(session: id, result: "had it")
|
|
368
380
|
end
|
|
369
381
|
|
|
370
|
-
result = NoteDelivery.deliver(id, text: passed_on_body(record), source: SOURCE, state_dir: @state_dir
|
|
382
|
+
result = NoteDelivery.deliver(id, text: passed_on_body(record), source: SOURCE, state_dir: @state_dir,
|
|
383
|
+
resolved: head)
|
|
371
384
|
unless result.delivered?
|
|
372
385
|
@stdout.puts(line_for(id, "skipped", OwnerLock::TUI_BUSY))
|
|
373
386
|
return Broadcast::TriageLog::Delivery.new(session: id, result: "refused")
|
|
374
387
|
end
|
|
375
388
|
|
|
376
|
-
|
|
377
|
-
|
|
389
|
+
how = "by hand#{"; to #{result.id[0, 8]}, its chain's newest link" if result.continued?}" \
|
|
390
|
+
"#{"; waits for its next start" if result.status == :waits}"
|
|
391
|
+
@stdout.puts(line_for(id, "delivered", how))
|
|
392
|
+
# Logged under the link that has it, so the next deliver finds it.
|
|
393
|
+
Broadcast::TriageLog::Delivery.new(session: result.id, result: "delivered")
|
|
378
394
|
rescue SessionInbox::NoteRejected, SystemCallError => e
|
|
379
395
|
@stdout.puts(line_for(id, "failed", e.message))
|
|
380
396
|
Broadcast::TriageLog::Delivery.new(session: id, result: "failed")
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "cli/flags"
|
|
4
4
|
require_relative "cli/exit"
|
|
5
|
+
require_relative "live_versions"
|
|
5
6
|
require_relative "memory_bundle"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
@@ -17,6 +18,7 @@ module Samagotchi
|
|
|
17
18
|
Source is the path/URL to the bundle (dir/zip/tar.gz/git).
|
|
18
19
|
--force overwrites existing entries (one that differs is copied to the trash first); default skips them.
|
|
19
20
|
A bundle whose requires_chi this chi doesn't meet is refused; --force installs it anyway.
|
|
21
|
+
So is a memory, plugin or script whose bytes disagree with its manifest sha256; --force installs it too.
|
|
20
22
|
|
|
21
23
|
upgrade <source|name> [--scope system|project] [--force] [--dry-run] [--agent|--no-agent]
|
|
22
24
|
Upgrade a bundle (3-way: a file you didn't edit updates; one you edited takes a text
|
|
@@ -24,6 +26,7 @@ module Samagotchi
|
|
|
24
26
|
A bare NAME upgrades the bundle installed under that name from the source it came from.
|
|
25
27
|
--dry-run shows what would change. --agent launches interactive session on conflict.
|
|
26
28
|
A bundle whose requires_chi this chi doesn't meet is refused; --force installs it anyway.
|
|
29
|
+
So is a file whose bytes disagree with its manifest sha256; --force installs it too.
|
|
27
30
|
|
|
28
31
|
uninstall <bundle> [--scope system|project] [--force]
|
|
29
32
|
Remove a bundle and its index entries (--force moves a locally edited file to the trash).
|
|
@@ -73,8 +76,8 @@ module Samagotchi
|
|
|
73
76
|
Install a memory bundle from a directory, zip, tar archive, or git URL,
|
|
74
77
|
or a bundle shipped with chi by name (see: chi bundle list).
|
|
75
78
|
--force overwrites existing entries (one that differs is copied to the trash first); default skips them.
|
|
76
|
-
It also installs a bundle whose requires_chi this chi doesn't meet
|
|
77
|
-
refused, and the bundle is not written anywhere.
|
|
79
|
+
It also installs a bundle whose requires_chi this chi doesn't meet and a file whose bytes disagree with
|
|
80
|
+
its manifest sha256: without --force both are refused, and the bundle is not written anywhere.
|
|
78
81
|
install --dev PATH
|
|
79
82
|
Install the bundle in the local directory PATH as a link to it: its plugin, rules and scripts
|
|
80
83
|
are PATH's own files (an edit applies at the next worker or chi web restart, with no re-install) and
|
|
@@ -83,8 +86,9 @@ module Samagotchi
|
|
|
83
86
|
TEXT
|
|
84
87
|
UPGRADE_HELP = <<~TEXT
|
|
85
88
|
Usage: chi bundle upgrade <source|name> [--scope system|project] [--force] [--dry-run] [--agent|--no-agent]
|
|
86
|
-
--force takes the bundle's file over an edited one,
|
|
87
|
-
doesn't meet (without it
|
|
89
|
+
--force takes the bundle's file over an edited one, installs a bundle whose requires_chi this chi
|
|
90
|
+
doesn't meet, and installs a file whose bytes disagree with its manifest sha256 (without it each is
|
|
91
|
+
refused, nothing written).
|
|
88
92
|
TEXT
|
|
89
93
|
UNINSTALL_HELP = "Usage: chi bundle uninstall <bundle> [--scope system|project] [--force]"
|
|
90
94
|
BUILD_HELP = <<~TEXT
|
|
@@ -246,7 +250,12 @@ module Samagotchi
|
|
|
246
250
|
end
|
|
247
251
|
end
|
|
248
252
|
installer = installer_for(expanded_source, bundle_name, prepared: prepared, scope: scope, force: force, upgrade: true, dry_run: dry_run)
|
|
249
|
-
|
|
253
|
+
# The record and the manifest as they were/are, for the hint below: it
|
|
254
|
+
# reads which plugin the run touched.
|
|
255
|
+
before = Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).record
|
|
256
|
+
manifest = nil
|
|
257
|
+
status = run_installer(installer, failed: "Upgrade") do |run_manifest|
|
|
258
|
+
manifest = run_manifest
|
|
250
259
|
print_plugin(manifest, plugin: !dry_run)
|
|
251
260
|
next dry_run_done if dry_run
|
|
252
261
|
|
|
@@ -256,6 +265,8 @@ module Samagotchi
|
|
|
256
265
|
|
|
257
266
|
print_provenance(bundle_name, manifest)
|
|
258
267
|
end
|
|
268
|
+
restart_hint(before, manifest, installer) if !dry_run && status != 1
|
|
269
|
+
status
|
|
259
270
|
end
|
|
260
271
|
|
|
261
272
|
# The source normalized once (a git clone, a zip or tar extract) and the
|
|
@@ -300,7 +311,10 @@ module Samagotchi
|
|
|
300
311
|
end
|
|
301
312
|
|
|
302
313
|
def installer_for(source, name, scope:, force:, **)
|
|
303
|
-
|
|
314
|
+
# A bundle the user installs: a memory, plugin or script whose bytes
|
|
315
|
+
# disagree with its manifest sha is refused, not warned (--force past it).
|
|
316
|
+
Samagotchi::MemoryBundle::Installer.new(source: source, name: name, scope: scope, force: force, strict: true,
|
|
317
|
+
refuse_mismatch: true, **)
|
|
304
318
|
end
|
|
305
319
|
|
|
306
320
|
# Runs +installer+, prints its summary, then yields the manifest.
|
|
@@ -340,6 +354,41 @@ module Samagotchi
|
|
|
340
354
|
0
|
|
341
355
|
end
|
|
342
356
|
|
|
357
|
+
# A run wrote or took away a file (Installer#results' words).
|
|
358
|
+
CHANGED_WORDS = %i[installed updated merged removed restored].freeze
|
|
359
|
+
|
|
360
|
+
# Only a bundle's plugin stays in a worker's memory: its rules reload by
|
|
361
|
+
# their stamp (GuardrailWiring#rules_stamp), its scripts run from the files
|
|
362
|
+
# the install replaced, and chi no longer loads class-file hooks. So an
|
|
363
|
+
# upgrade or uninstall that changed the plugin -- or brought one in --
|
|
364
|
+
# leaves the old code in every session already up: one line, after the
|
|
365
|
+
# result, with how many sessions and the command for each. Nothing when the
|
|
366
|
+
# plugin is unchanged or there never was one, nothing with no worker that
|
|
367
|
+
# can be restarted.
|
|
368
|
+
# @param before [InstalledBundle, nil] the bundle's record as it was before
|
|
369
|
+
# the run (an uninstall deletes it, so take it first)
|
|
370
|
+
# @param manifest [MemoryBundle::Manifest, nil] the manifest this run
|
|
371
|
+
# installed; nil for an uninstall
|
|
372
|
+
# @param installer [Installer, nil] the run, for what it wrote to the plugin
|
|
373
|
+
def restart_hint(before, manifest = nil, installer = nil)
|
|
374
|
+
return if before.nil? || before.error?
|
|
375
|
+
|
|
376
|
+
plugin_files = [before.plugin&.file, manifest&.plugin&.dig(:file)].compact.uniq
|
|
377
|
+
changed = if installer
|
|
378
|
+
plugin_files.any? { |file| CHANGED_WORDS.include?(installer.results[file]&.word) }
|
|
379
|
+
else
|
|
380
|
+
!before.plugin.nil?
|
|
381
|
+
end
|
|
382
|
+
return unless changed
|
|
383
|
+
|
|
384
|
+
ids = Samagotchi::LiveVersions.workers.select(&:restart).map { |worker| worker.session_id.to_s[0, 8] }
|
|
385
|
+
return if ids.empty?
|
|
386
|
+
|
|
387
|
+
commands = ids.map { |id| "chi sessions restart #{id}" }
|
|
388
|
+
@stdout.puts "#{ids.size} session#{"s" unless ids.size == 1} " \
|
|
389
|
+
"#{ids.size == 1 ? "runs" : "run"} the old code: #{commands.join(", ")}"
|
|
390
|
+
end
|
|
391
|
+
|
|
343
392
|
# An upgrade that kept edited files: launch an agent to merge them
|
|
344
393
|
# (--agent, or yes on a terminal), or say how to take the bundle's.
|
|
345
394
|
# An upgrade that kept edited files exits 2, as a usage error does
|
|
@@ -427,6 +476,7 @@ module Samagotchi
|
|
|
427
476
|
@stdout.puts uninstaller.trash_copy_line if uninstaller.trash_copy_line
|
|
428
477
|
@stdout.puts "Removed: #{uninstaller.removed_files.join(", ")}" unless uninstaller.removed_files.empty?
|
|
429
478
|
uninstaller.warnings.each { |w| @stdout.puts w }
|
|
479
|
+
restart_hint(record)
|
|
430
480
|
0
|
|
431
481
|
rescue Samagotchi::MemoryBundle::Uninstaller::UninstallError => e
|
|
432
482
|
@stderr.puts "Uninstall failed: #{e.message}"; 1
|
|
@@ -52,12 +52,15 @@ module Samagotchi
|
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
# Ring +child+'s parent; best effort (a failure is logged, never
|
|
55
|
-
# raised)
|
|
56
|
-
#
|
|
55
|
+
# raised): a ring delivery (SessionInbox.deliver :ring). The ring file
|
|
56
|
+
# goes to the parent parent.json names; its WakePolicy decides whether
|
|
57
|
+
# a worker starts (a parent with none, idle, in wake mode), and for an
|
|
58
|
+
# archived parent with a later link, that link (the one whose
|
|
59
|
+
# ChildReports reads the ring). A start runs on a thread.
|
|
57
60
|
# @param child [Session] a delegate child
|
|
58
61
|
# @param why [String] "turn_end", "question" or "crash"
|
|
59
|
-
# @param wake [#call, nil] (parent id)
|
|
60
|
-
# nil: SessionManager.
|
|
62
|
+
# @param wake [#call, nil] (parent id) starts the worker the policy
|
|
63
|
+
# decided on; nil: SessionManager.start_for_inbox on a thread
|
|
61
64
|
# @return [String, nil] the ring file, nil when none was written
|
|
62
65
|
# The parent is read fresh: a continue may have moved the child to the
|
|
63
66
|
# chain's new link (Session::PARENT_FILE) since its worker loaded it.
|
|
@@ -70,33 +73,36 @@ module Samagotchi
|
|
|
70
73
|
|
|
71
74
|
parent_id = Session.parent_override(child.id, state_dir: state_dir) || child.parent_id
|
|
72
75
|
return nil unless parent_id
|
|
73
|
-
# A deleted parent gets no orphan dir.
|
|
74
|
-
return nil unless Session.exist?(parent_id, state_dir: state_dir)
|
|
75
76
|
|
|
76
|
-
|
|
77
|
-
path =
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
# A deleted parent gets no orphan dir: the delivery refuses it (:gone).
|
|
78
|
+
path = nil
|
|
79
|
+
SessionInbox.deliver(:ring, parent_id, state_dir: state_dir, wake: mode == "wake",
|
|
80
|
+
start: starter(state_dir, wake)) do |at|
|
|
81
|
+
path = SessionInbox.write_ring(Session.session_dir(at, state_dir: state_dir), child_id: child.id, why: why)
|
|
82
|
+
Log.info(:worker, "delegate_rang", parent: at[0, 8], why: why)
|
|
83
|
+
true
|
|
84
|
+
end
|
|
80
85
|
path
|
|
81
86
|
rescue StandardError => e
|
|
82
87
|
Log.warn(:worker, "delegate_ring_failed", parent: child&.parent_id.to_s[0, 8], error: e.class.name, msg: e.message)
|
|
83
88
|
nil
|
|
84
89
|
end
|
|
85
90
|
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
91
|
+
# The ring delivery's start: +wake+, or SessionManager.start_for_inbox
|
|
92
|
+
# on a thread (#await_wakes joins it), so the child's turn doesn't wait
|
|
93
|
+
# for a spawn.
|
|
94
|
+
def starter(state_dir, wake)
|
|
95
|
+
return ->(id) { wake.call(id).then { :woken } } if wake
|
|
96
|
+
|
|
97
|
+
lambda do |id|
|
|
98
|
+
thread = Thread.new do
|
|
99
|
+
SessionManager.start_for_inbox(id, state_dir: state_dir, event: :delegate_woke_parent)
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
Log.warn(:worker, "delegate_wake_failed", parent: id[0, 8], error: e.class.name, msg: e.message)
|
|
102
|
+
end
|
|
103
|
+
@wakes_mutex.synchronize { @wakes = @wakes.select(&:alive?) << thread }
|
|
104
|
+
:woken
|
|
97
105
|
end
|
|
98
|
-
@wakes_mutex.synchronize { @wakes = @wakes.select(&:alive?) << thread }
|
|
99
|
-
thread
|
|
100
106
|
end
|
|
101
107
|
|
|
102
108
|
# Waits up to +timeout+ for the parent wakes this process started: a
|
data/lib/samagotchi/client_id.rb
CHANGED
|
@@ -9,6 +9,8 @@ module Samagotchi
|
|
|
9
9
|
# A web tab (web:<random>, per tab); web:restart, the page's Restart.
|
|
10
10
|
WEB_PREFIX = "web:"
|
|
11
11
|
WEB_RESTART = "web:restart"
|
|
12
|
+
# A web page's first prompt when creating a session.
|
|
13
|
+
WEB_CREATE = "web:create"
|
|
12
14
|
# An attached chi TUI: tui:<pid>.
|
|
13
15
|
TUI_PREFIX = "tui:"
|
|
14
16
|
# chi send's turns and commands.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubygems/version"
|
|
4
|
+
require_relative "version"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# The compatibility decision for state and bridge data from a chi version.
|
|
8
|
+
module Compat
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def verdict(worker_version:, version: VERSION, worker_protocol: nil, protocol: nil)
|
|
12
|
+
return :refused if protocol && worker_protocol.to_i > protocol
|
|
13
|
+
|
|
14
|
+
worker_version = worker_version.to_s
|
|
15
|
+
version = version.to_s
|
|
16
|
+
return :restart_suggested if worker_version.empty? || version.empty?
|
|
17
|
+
return :restart_suggested unless Gem::Version.correct?(worker_version) && Gem::Version.correct?(version)
|
|
18
|
+
|
|
19
|
+
worker = Gem::Version.new(worker_version)
|
|
20
|
+
current = Gem::Version.new(version)
|
|
21
|
+
return :restart_suggested if worker < previous_minor(current)
|
|
22
|
+
|
|
23
|
+
:ok
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def previous_minor(version)
|
|
27
|
+
major, minor = version.segments.first(2)
|
|
28
|
+
Gem::Version.new("#{major}.#{[(minor || 0) - 1, 0].max}.0")
|
|
29
|
+
end
|
|
30
|
+
private_class_method :previous_minor
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -65,7 +65,16 @@ module Samagotchi
|
|
|
65
65
|
# its prompts. Never raises.
|
|
66
66
|
# @return [Integer] how many lines were dropped
|
|
67
67
|
def purge_session(session_id, state_dir: nil)
|
|
68
|
-
|
|
68
|
+
purge_sessions([session_id], state_dir: state_dir)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Many sessions' lines, pruned in one pass through the log. Never raises.
|
|
72
|
+
# @return [Integer] how many lines were dropped
|
|
73
|
+
def purge_sessions(session_ids, state_dir: nil)
|
|
74
|
+
ids = Array(session_ids).map(&:to_s).reject(&:empty?).to_h { |id| [id, true] }
|
|
75
|
+
return 0 if ids.empty?
|
|
76
|
+
|
|
77
|
+
log_for(state_dir).prune { |line| !ids[line["session_id"]] }
|
|
69
78
|
rescue StandardError => e
|
|
70
79
|
Log.warn(:context, "context_candidates_log_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
71
80
|
0
|
|
@@ -239,7 +239,7 @@ module Samagotchi
|
|
|
239
239
|
end
|
|
240
240
|
|
|
241
241
|
@stdout.puts("#{target.label} #{name}: #{pushed.revision == pushed.before ? "unchanged" : "new text"} " \
|
|
242
|
-
"(#{pushed.revision[0, 12]})")
|
|
242
|
+
"(#{pushed.revision[0, 12]})#{" · #{pushed.wake_line}" if pushed.wake_line}")
|
|
243
243
|
true
|
|
244
244
|
end
|
|
245
245
|
end
|
|
@@ -70,13 +70,18 @@ module Samagotchi
|
|
|
70
70
|
reserve = CARRY_MORE_TRUNCATION.bytesize
|
|
71
71
|
text_limit = [CARRY_SOURCE_MAX_BYTES, remaining - heading.bytesize - reserve].min
|
|
72
72
|
if text_limit < CARRY_SOURCE_TRUNCATION.bytesize
|
|
73
|
+
# Not even room for a trimmed source: this one is dropped, so the
|
|
74
|
+
# note says text is missing even when it was the last.
|
|
73
75
|
truncated = true
|
|
74
76
|
break
|
|
75
77
|
end
|
|
76
78
|
|
|
77
79
|
bounded, source_truncated = bound_source_text(text, text_limit)
|
|
78
80
|
note << heading << bounded
|
|
79
|
-
|
|
81
|
+
# Only a cut by the note's own budget ends the note: a source trimmed to
|
|
82
|
+
# CARRY_SOURCE_MAX_BYTES still leaves room for the next one, and one that
|
|
83
|
+
# fits the smaller room left is carried whole.
|
|
84
|
+
if source_truncated && text_limit < CARRY_SOURCE_MAX_BYTES
|
|
80
85
|
truncated = more
|
|
81
86
|
break
|
|
82
87
|
end
|
|
@@ -51,6 +51,18 @@ module Samagotchi
|
|
|
51
51
|
@session_observer.synchronize(&)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
def reserve_enqueued_id(id) = @turn_state.reserve_enqueued_id(id)
|
|
55
|
+
|
|
56
|
+
def claim_enqueued_id(id) = @turn_state.claim_enqueued_id(id)
|
|
57
|
+
|
|
58
|
+
def finish_enqueued_id(id) = @turn_state.finish_enqueued_id(id)
|
|
59
|
+
|
|
60
|
+
def forget_enqueued_id(id) = @turn_state.forget_enqueued_id(id)
|
|
61
|
+
|
|
62
|
+
def drop_enqueued_id(id) = @turn_state.drop_enqueued_id(id)
|
|
63
|
+
|
|
64
|
+
def enqueued_id?(id) = @turn_state.enqueued_id?(id)
|
|
65
|
+
|
|
54
66
|
# The working set's :working_set_updated, announced between turns
|
|
55
67
|
# (after a command changed what the next request sends), when it
|
|
56
68
|
# differs from the last one emitted (#emit_working_set). Under the
|
|
@@ -166,7 +166,7 @@ module Samagotchi
|
|
|
166
166
|
end,
|
|
167
167
|
state_dir: -> { session_state_dir },
|
|
168
168
|
scratch: -> { @scratch },
|
|
169
|
-
setup: -> { SessionSetup.of(@session) },
|
|
169
|
+
setup: -> { SessionSetup.of(@session, forked: true) },
|
|
170
170
|
# The files whose text the built system prompt holds; [] until a
|
|
171
171
|
# build and after a rebuild that drops them (SystemPrompt#prompt_files
|
|
172
172
|
# returns [] itself: the || keeps a nil builder safe).
|
|
@@ -126,8 +126,8 @@ module Samagotchi
|
|
|
126
126
|
# Cancel the currently running turn, if any.
|
|
127
127
|
# @param reason [Symbol] cancellation reason
|
|
128
128
|
# @return [Boolean] whether a cancellation was triggered
|
|
129
|
-
def cancel_current_turn!(reason = :manual)
|
|
130
|
-
@turn_state.cancel!(reason)
|
|
129
|
+
def cancel_current_turn!(reason = :manual, turn_id: nil)
|
|
130
|
+
@turn_state.cancel!(reason, turn_id: turn_id)
|
|
131
131
|
end
|
|
132
132
|
end
|
|
133
133
|
end
|
|
@@ -17,14 +17,20 @@ module Samagotchi
|
|
|
17
17
|
# (a repo or rule answer would hold in every worktree, for every child),
|
|
18
18
|
# when:
|
|
19
19
|
# write, edit: a path is outside root (Outside: tmp dirs and memory
|
|
20
|
-
# files never are);
|
|
20
|
+
# files never are); so is a path a plugin tool's targets: declare
|
|
21
|
+
# the call writes (writes: true), the same declaration
|
|
22
|
+
# ProtectedPaths reads — a call is judged by what its targets say it
|
|
23
|
+
# acts on, not by its tool's name;
|
|
21
24
|
# execute, task_create: mutating git runs in a dir outside root
|
|
22
25
|
# (ShellGitDirs), or a command that isn't read-only starts outside
|
|
23
26
|
# root (its cwd:, else Dir.pwd) or names a path in the repository's
|
|
24
27
|
# other checkouts (the main one and sibling worktrees) or its
|
|
25
28
|
# common git dir; or it runs git that changes what all checkouts
|
|
26
29
|
# share, from anywhere (SharedGit: config, update-ref, worktree add,
|
|
27
|
-
# stash clear, tags, another branch's delete).
|
|
30
|
+
# stash clear, tags, another branch's delete). A plugin tool's
|
|
31
|
+
# declared command: is checked for what chi can read for sure only:
|
|
32
|
+
# SharedGit, and whether the call's cwd: is outside root (its text
|
|
33
|
+
# may not be shell; Targets#rule_command).
|
|
28
34
|
# Paths elsewhere in a command (/usr/bin, ~/.gem) don't count, and
|
|
29
35
|
# read-only commands never ask, nor do ones that only write into a tmp
|
|
30
36
|
# dir (> / >>, cp's destination, tee; Targets#read_only_but_tmp_writes?). Paths resolve under chi's env and the
|
|
@@ -67,12 +73,39 @@ module Samagotchi
|
|
|
67
73
|
|
|
68
74
|
def crosses?(targets, root)
|
|
69
75
|
case targets.tool
|
|
70
|
-
when "write", "edit" then
|
|
76
|
+
when "write", "edit" then paths_cross?(targets, root)
|
|
71
77
|
when *SHELL_TOOLS then shell_crosses?(targets, root)
|
|
72
|
-
else
|
|
78
|
+
else
|
|
79
|
+
# chi's other own tools (read, memory_write, …) are unchanged:
|
|
80
|
+
# memory_write carries writes: for ProtectedPaths, but its paths
|
|
81
|
+
# are memory files (Outside exempts them) and its `..` entry names
|
|
82
|
+
# asked for nothing here before.
|
|
83
|
+
return false if Targets::CORE_TOOLS.include?(targets.tool)
|
|
84
|
+
|
|
85
|
+
plugin_crosses?(targets, root)
|
|
73
86
|
end
|
|
74
87
|
end
|
|
75
88
|
|
|
89
|
+
def paths_cross?(targets, root)
|
|
90
|
+
targets.paths.any? { |path| Outside.outside?(path, root: root) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A plugin tool, by what its targets: say it acts on (Targets#writes?,
|
|
94
|
+
# #command, #cwd), not by its name: paths its targets say it writes
|
|
95
|
+
# like a write's, and a declared command like a shell's — but for the
|
|
96
|
+
# command only what chi can read for sure: git that changes what every
|
|
97
|
+
# checkout shares (SharedGit), and where the call runs (its cwd:). A
|
|
98
|
+
# plugin's command text may not be shell (Targets#rule_command), so
|
|
99
|
+
# the shell half's git-dir, cd and redirect reads are left out; a
|
|
100
|
+
# plugin that wants more can bring a rule of its own.
|
|
101
|
+
def plugin_crosses?(targets, root)
|
|
102
|
+
return true if targets.writes? && paths_cross?(targets, root)
|
|
103
|
+
return :shared if targets.command && SharedGit.changes?(targets.command, own_branch: @branch.call)
|
|
104
|
+
return true if targets.command && targets.cwd && Outside.outside?(targets.cwd, root: root)
|
|
105
|
+
|
|
106
|
+
false
|
|
107
|
+
end
|
|
108
|
+
|
|
76
109
|
def shell_crosses?(targets, root)
|
|
77
110
|
return true if targets.git_dirs.any? { |dir| dir.is_a?(String) && Outside.outside?(dir, root: root) }
|
|
78
111
|
return :shared if SharedGit.changes?(targets.command, own_branch: @branch.call)
|
|
@@ -4,7 +4,8 @@ require_relative "path_resolve"
|
|
|
4
4
|
|
|
5
5
|
module Samagotchi
|
|
6
6
|
module Guardrails
|
|
7
|
-
# Core checks on what
|
|
7
|
+
# Core checks on what a call writes (Targets#writes?: write, edit,
|
|
8
|
+
# memory_write, and a plugin tool whose targets: say writes: true),
|
|
8
9
|
# after the hooks and before the rules. Paths are compared with
|
|
9
10
|
# symlinks resolved (on the longest existing parent, `..` taken
|
|
10
11
|
# physically, a dangling symlink followed: PathResolve.reals, the
|
|
@@ -14,7 +15,8 @@ module Samagotchi
|
|
|
14
15
|
# hooks, rules, manifests), attached context (a source's
|
|
15
16
|
# command runs later, outside the gate; chi context writes
|
|
16
17
|
# it) and System 1's saved runs (a label there may train a
|
|
17
|
-
# model); nothing legitimate writes there through
|
|
18
|
+
# model); nothing legitimate writes there through a tool
|
|
19
|
+
# that says it writes.
|
|
18
20
|
# ask (once / session): config.yml, home.json (the chi home: the
|
|
19
21
|
# Ruby every chi launch boots), the chi shim (ChiShim: the
|
|
20
22
|
# `chi` every tool runs) and the plain hooks dir. The
|
|
@@ -24,7 +26,6 @@ module Samagotchi
|
|
|
24
26
|
# execute can still write all of them (the guardrails bundle adds a
|
|
25
27
|
# text match for that).
|
|
26
28
|
class ProtectedPaths
|
|
27
|
-
FILE_TOOLS = %w[write edit memory_write].freeze
|
|
28
29
|
SOURCE = "core"
|
|
29
30
|
|
|
30
31
|
# @param store_dir [String] <state dir>/guardrails
|
|
@@ -50,10 +51,12 @@ module Samagotchi
|
|
|
50
51
|
].select { |_, root, _| root }
|
|
51
52
|
end
|
|
52
53
|
|
|
53
|
-
# Vote on +verdict+ (its targets)
|
|
54
|
+
# Vote on +verdict+ (its targets): a call whose targets say it writes
|
|
55
|
+
# (Targets#writes?: write, edit, memory_write, or a plugin tool's
|
|
56
|
+
# targets: writes: true) is judged on its paths.
|
|
54
57
|
def check(verdict)
|
|
55
58
|
targets = verdict.targets
|
|
56
|
-
return verdict unless targets
|
|
59
|
+
return verdict unless targets&.writes?
|
|
57
60
|
|
|
58
61
|
paths = targets.paths.flat_map { |p| PathResolve.reals(p) }
|
|
59
62
|
@deny.each do |rule, root, reason|
|
|
@@ -90,6 +90,15 @@ module Samagotchi
|
|
|
90
90
|
# reads it)
|
|
91
91
|
attr_reader :label
|
|
92
92
|
|
|
93
|
+
# @return [Boolean] whether the call writes the paths its targets name:
|
|
94
|
+
# chi's own write, edit and memory_write, and a plugin tool whose
|
|
95
|
+
# targets: say writes: true (a literal true only). The checks that
|
|
96
|
+
# guard a write (ProtectedPaths, ChildBoundary) ask this rather than
|
|
97
|
+
# the tool's name, so a plugin tool that acts like one is judged
|
|
98
|
+
# like one.
|
|
99
|
+
attr_reader :writes
|
|
100
|
+
alias writes? writes
|
|
101
|
+
|
|
93
102
|
# @return [Boolean] a memory_write call that removes a memory (remove:
|
|
94
103
|
# true), for a rule's memory: remove
|
|
95
104
|
attr_reader :memory_remove
|
|
@@ -126,6 +135,7 @@ module Samagotchi
|
|
|
126
135
|
args = nil
|
|
127
136
|
acts_as = nil
|
|
128
137
|
label = nil
|
|
138
|
+
writes = false
|
|
129
139
|
env = {}
|
|
130
140
|
case tool
|
|
131
141
|
when *SHELL_TOOLS
|
|
@@ -134,14 +144,17 @@ module Samagotchi
|
|
|
134
144
|
given = call[:cwd].to_s.strip
|
|
135
145
|
cwd = File.expand_path(given, base) unless given.empty?
|
|
136
146
|
when "write", "edit"
|
|
147
|
+
writes = true
|
|
137
148
|
paths.concat(absolutes(call[:path], base))
|
|
138
149
|
when "read"
|
|
139
150
|
paths.concat(absolutes(call[:content], base))
|
|
140
151
|
when "memory_write"
|
|
152
|
+
writes = true
|
|
141
153
|
paths << memory_path(call, model_key)
|
|
142
154
|
else
|
|
143
155
|
given = plugin_targets(call, registry, surface)
|
|
144
156
|
command = given[:command]
|
|
157
|
+
writes = given[:writes]
|
|
145
158
|
given_cwd = given[:cwd].to_s.strip
|
|
146
159
|
cwd = File.expand_path(given_cwd, base) unless given_cwd.empty?
|
|
147
160
|
paths.concat(given[:paths].flat_map { |path| absolutes(path, cwd) })
|
|
@@ -154,25 +167,29 @@ module Samagotchi
|
|
|
154
167
|
memory_overlay = tool == "memory_write" && !memory_remove && call[:current_model_only].to_s.strip.downcase == "true"
|
|
155
168
|
memory = tool == "memory_write" ? memory_facts(call) : nil
|
|
156
169
|
new(tool: tool, command: command, paths: paths.compact, cwd: cwd, env: env, repo_root: context.repo_root(cwd),
|
|
157
|
-
repo: context.repo(cwd), args: args, acts_as: acts_as, label: label,
|
|
170
|
+
repo: context.repo(cwd), args: args, acts_as: acts_as, label: label, writes: writes,
|
|
171
|
+
memory_remove: memory_remove,
|
|
158
172
|
memory_overlay: memory_overlay, memory: memory,
|
|
159
173
|
model_name: model_name, model_key: model_key, session_root: context.repo_root(base) || base,
|
|
160
174
|
chi_dirs: -> { chi_dirs(context, base) })
|
|
161
175
|
end
|
|
162
176
|
|
|
163
177
|
# What a plugin tool's targets: callable says the call acts on:
|
|
164
|
-
# {command:, paths:, cwd:, acts_as:, args:, label:}, each
|
|
178
|
+
# {command:, paths:, writes:, cwd:, acts_as:, args:, label:}, each
|
|
179
|
+
# optional; a callable
|
|
165
180
|
# that raises or gives something else counts as nothing (and is
|
|
166
181
|
# logged). args: counts when it is a Hash; acts_as: is dropped (and
|
|
167
182
|
# logged) when it names a core tool or another bundle's tool, so a
|
|
168
183
|
# call can't pass for one and match (or reuse the approvals of) its
|
|
169
|
-
# rules. label: counts when it is one short line (.label_of).
|
|
184
|
+
# rules. label: counts when it is one short line (.label_of). writes:
|
|
185
|
+
# counts only for a literal true: the tool says its paths are written,
|
|
186
|
+
# and the checks that guard write/edit judge them (.writes?). A
|
|
170
187
|
# missing surface (Tools::MissingSurface) is raised, never taken as
|
|
171
188
|
# nothing: call_tool's acts_as would go missing, and with it the
|
|
172
189
|
# mcp_* rules on the call (fail-open).
|
|
173
190
|
def self.plugin_targets(call, registry, surface = nil)
|
|
174
191
|
entry = entry_of(call[:name], registry, surface)
|
|
175
|
-
none = { command: nil, paths: [], cwd: nil, acts_as: nil, args: nil, label: nil }
|
|
192
|
+
none = { command: nil, paths: [], writes: false, cwd: nil, acts_as: nil, args: nil, label: nil }
|
|
176
193
|
return none unless entry && !entry.core? && entry.targets
|
|
177
194
|
|
|
178
195
|
given = entry.targets_of(call, surface)
|
|
@@ -180,7 +197,8 @@ module Samagotchi
|
|
|
180
197
|
|
|
181
198
|
given = given.transform_keys(&:to_sym)
|
|
182
199
|
command = given[:command].to_s
|
|
183
|
-
{ command: command.empty? ? nil : command, paths: Array(given[:paths]).map(&:to_s),
|
|
200
|
+
{ command: command.empty? ? nil : command, paths: Array(given[:paths]).map(&:to_s), writes: given[:writes] == true,
|
|
201
|
+
cwd: given[:cwd],
|
|
184
202
|
acts_as: acts_as_of(given[:acts_as], entry, registry, surface), args: given[:args].is_a?(Hash) ? given[:args] : nil,
|
|
185
203
|
label: label_of(given[:label], entry) }
|
|
186
204
|
rescue Tools::MissingSurface
|
|
@@ -318,10 +336,11 @@ module Samagotchi
|
|
|
318
336
|
# @param env [Hash{String=>String}] the call's env: (.env_of)
|
|
319
337
|
def initialize(tool:, command:, paths:, cwd:, repo_root:, env: {}, repo: nil, args: nil, acts_as: nil,
|
|
320
338
|
label: nil, model_name: nil, model_key: nil, session_root: nil, chi_dirs: nil,
|
|
321
|
-
memory_remove: false, memory_overlay: false, memory: nil)
|
|
339
|
+
memory_remove: false, memory_overlay: false, memory: nil, writes: false)
|
|
322
340
|
@env = env
|
|
323
341
|
@acts_as = acts_as
|
|
324
342
|
@label = label
|
|
343
|
+
@writes = writes
|
|
325
344
|
@memory_remove = memory_remove
|
|
326
345
|
@memory_overlay = memory_overlay
|
|
327
346
|
@memory = memory
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "rubygems/version"
|
|
3
4
|
require_relative "config"
|
|
4
5
|
require_relative "session"
|
|
5
6
|
require_relative "version"
|
|
@@ -38,9 +39,13 @@ module Samagotchi
|
|
|
38
39
|
end
|
|
39
40
|
end
|
|
40
41
|
|
|
41
|
-
# The live workers
|
|
42
|
+
# The live workers older than the target chi (unknown versions count).
|
|
43
|
+
# Compat's N-1 verdict is for hints; these callers are adopting the new code.
|
|
42
44
|
def stale_workers(version = VERSION, state_dir: Session.default_state_dir)
|
|
43
|
-
|
|
45
|
+
target = Gem::Version.new(version)
|
|
46
|
+
workers(state_dir: state_dir).select do |worker|
|
|
47
|
+
worker.version.nil? || !Gem::Version.correct?(worker.version) || Gem::Version.new(worker.version) < target
|
|
48
|
+
end
|
|
44
49
|
end
|
|
45
50
|
|
|
46
51
|
# The /api/info of a chi web on host:port, or nil when nothing (or not
|