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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -1
  3. data/docs/architecture.md +7 -3
  4. data/docs/cli.md +6 -4
  5. data/docs/context.md +10 -1
  6. data/docs/guardrails.md +12 -3
  7. data/docs/internals/bridge.md +21 -6
  8. data/docs/internals/bundles.md +24 -5
  9. data/docs/internals/objectives.md +8 -2
  10. data/docs/internals/remote-sessions.md +2 -1
  11. data/docs/memory.md +4 -2
  12. data/docs/plugins.md +13 -4
  13. data/docs/releasing.md +7 -4
  14. data/docs/scheduling.md +37 -5
  15. data/docs/sessions.md +14 -11
  16. data/docs/testing.md +9 -1
  17. data/lib/samagotchi/bridge/card_store.rb +31 -3
  18. data/lib/samagotchi/bridge/ring_buffer.rb +11 -1
  19. data/lib/samagotchi/bridge/sse_writer.rb +8 -5
  20. data/lib/samagotchi/bridge.rb +92 -42
  21. data/lib/samagotchi/bridge_client.rb +9 -6
  22. data/lib/samagotchi/broadcast_command.rb +24 -8
  23. data/lib/samagotchi/bundle_command.rb +56 -6
  24. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  25. data/lib/samagotchi/child_reports.rb +29 -23
  26. data/lib/samagotchi/client_id.rb +2 -0
  27. data/lib/samagotchi/compat.rb +32 -0
  28. data/lib/samagotchi/context_candidates_log.rb +10 -1
  29. data/lib/samagotchi/context_command.rb +1 -1
  30. data/lib/samagotchi/context_move.rb +6 -1
  31. data/lib/samagotchi/engine/events.rb +12 -0
  32. data/lib/samagotchi/engine/plugins.rb +1 -1
  33. data/lib/samagotchi/engine/steering.rb +2 -2
  34. data/lib/samagotchi/guardrails/child_boundary.rb +37 -4
  35. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  36. data/lib/samagotchi/guardrails/targets.rb +25 -6
  37. data/lib/samagotchi/live_versions.rb +7 -2
  38. data/lib/samagotchi/memory_bundle/asset_installer.rb +59 -12
  39. data/lib/samagotchi/memory_bundle/file_state.rb +11 -0
  40. data/lib/samagotchi/memory_bundle/index_updater.rb +7 -4
  41. data/lib/samagotchi/memory_bundle/installer.rb +79 -12
  42. data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
  43. data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
  44. data/lib/samagotchi/memory_bundle/status.rb +1 -1
  45. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
  46. data/lib/samagotchi/memory_bundle/uninstaller.rb +27 -13
  47. data/lib/samagotchi/memory_orphans.rb +40 -9
  48. data/lib/samagotchi/note_command.rb +9 -1
  49. data/lib/samagotchi/note_delivery.rb +34 -24
  50. data/lib/samagotchi/note_move.rb +205 -0
  51. data/lib/samagotchi/objectives_command.rb +3 -1
  52. data/lib/samagotchi/owner_lock.rb +7 -1
  53. data/lib/samagotchi/project_init.rb +3 -3
  54. data/lib/samagotchi/review_start.rb +5 -3
  55. data/lib/samagotchi/schedule_command.rb +12 -0
  56. data/lib/samagotchi/schedules.rb +286 -48
  57. data/lib/samagotchi/send_command.rb +2 -15
  58. data/lib/samagotchi/session.rb +42 -5
  59. data/lib/samagotchi/session_access/local.rb +45 -17
  60. data/lib/samagotchi/session_access/remote.rb +11 -4
  61. data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
  62. data/lib/samagotchi/session_access.rb +16 -3
  63. data/lib/samagotchi/session_continue.rb +155 -21
  64. data/lib/samagotchi/session_delete_command.rb +3 -1
  65. data/lib/samagotchi/session_inbox.rb +41 -1
  66. data/lib/samagotchi/session_inbox_delivery.rb +254 -0
  67. data/lib/samagotchi/session_manager/delivery.rb +66 -12
  68. data/lib/samagotchi/session_manager/emptiness.rb +7 -2
  69. data/lib/samagotchi/session_manager/lifecycle.rb +2 -1
  70. data/lib/samagotchi/session_manager/listing.rb +4 -1
  71. data/lib/samagotchi/session_manager/removal.rb +130 -26
  72. data/lib/samagotchi/session_manager/spawn.rb +164 -104
  73. data/lib/samagotchi/session_manager.rb +1 -0
  74. data/lib/samagotchi/session_retention.rb +53 -23
  75. data/lib/samagotchi/session_setup.rb +27 -7
  76. data/lib/samagotchi/starting_marker.rb +93 -24
  77. data/lib/samagotchi/state_format.rb +18 -7
  78. data/lib/samagotchi/system_one/runs.rb +6 -1
  79. data/lib/samagotchi/system_one/runs_store.rb +18 -14
  80. data/lib/samagotchi/terminal_ui/attach_launcher.rb +7 -4
  81. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +5 -2
  82. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -0
  83. data/lib/samagotchi/terminal_ui/version_lines.rb +4 -2
  84. data/lib/samagotchi/terminal_ui.rb +2 -3
  85. data/lib/samagotchi/tools/delegate.rb +9 -4
  86. data/lib/samagotchi/tools/schedule_task.rb +5 -3
  87. data/lib/samagotchi/tools/send_note.rb +10 -4
  88. data/lib/samagotchi/turn_state.rb +108 -4
  89. data/lib/samagotchi/update_command.rb +7 -2
  90. data/lib/samagotchi/verify_fresh.rb +27 -9
  91. data/lib/samagotchi/version.rb +1 -1
  92. data/lib/samagotchi/web/app.rb +1 -1
  93. data/lib/samagotchi/web/public/app.js +2 -7
  94. data/lib/samagotchi/web/public/chains.js +4 -4
  95. data/lib/samagotchi/web/public/data.js +18 -2
  96. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  97. data/lib/samagotchi/web/public/session_fields.js +3 -2
  98. data/lib/samagotchi/web/public/session_state.js +2 -0
  99. data/lib/samagotchi/web/public/update_notice.js +2 -2
  100. data/lib/samagotchi/web/session_access_routes.rb +12 -8
  101. data/lib/samagotchi/web/session_control.rb +9 -3
  102. data/lib/samagotchi/web/session_create.rb +8 -4
  103. data/lib/samagotchi/web/session_hub.rb +50 -5
  104. data/lib/samagotchi/web/session_summary.rb +5 -2
  105. data/lib/samagotchi/worker.rb +45 -7
  106. data/lib/samagotchi/worker_boot.rb +22 -11
  107. data/lib/samagotchi/worker_inbound.rb +41 -8
  108. data/lib/samagotchi/worker_link.rb +4 -1
  109. data/lib/samagotchi/worker_presence.rb +106 -0
  110. data/lib/samagotchi/worker_sidecar.rb +3 -4
  111. 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
- results = ids.compact.uniq.map { |id| deliver_anyway(record, id) }
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 id [String] a full session id (#resolve_session)
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, id)
365
- if record.delivered?(id)
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
- @stdout.puts(line_for(id, "delivered", "by hand#{"; waits for its next start" if result.status == :waits}"))
377
- Broadcast::TriageLog::Delivery.new(session: id, result: "delivered")
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: without --force that install is
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, and installs a bundle whose requires_chi this chi
87
- doesn't meet (without it such a bundle is refused).
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
- run_installer(installer, failed: "Upgrade") do |manifest|
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
- Samagotchi::MemoryBundle::Installer.new(source: source, name: name, scope: scope, force: force, strict: true, **)
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
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.160.0
3
+ version: 0.161.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -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). A parent with no worker is woken on a thread
56
- # (SessionManager.wake_for_report decides whether it may be).
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) wakes a parent with no worker;
60
- # nil: SessionManager.wake_for_report on a thread
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
- parent_dir = Session.session_dir(parent_id, state_dir: state_dir)
77
- path = SessionInbox.write_ring(parent_dir, child_id: child.id, why: why)
78
- Log.info(:worker, "delegate_rang", parent: parent_id[0, 8], why: why)
79
- wake_parent(parent_id, state_dir: state_dir, wake: wake) if mode == "wake"
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
- # A live parent worker sees the ring on its next pass; one with no
87
- # worker is woken.
88
- def wake_parent(parent_id, state_dir:, wake:)
89
- return if SessionManager.session_owner(parent_id, state_dir: state_dir)
90
-
91
- return wake.call(parent_id) if wake
92
-
93
- thread = Thread.new do
94
- SessionManager.wake_for_report(parent_id, state_dir: state_dir)
95
- rescue StandardError => e
96
- Log.warn(:worker, "delegate_wake_failed", parent: parent_id[0, 8], error: e.class.name, msg: e.message)
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
@@ -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
- log_for(state_dir).prune { |line| line["session_id"] != session_id.to_s }
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
- if source_truncated
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 targets.paths.any? { |path| Outside.outside?(path, root: root) }
76
+ when "write", "edit" then paths_cross?(targets, root)
71
77
  when *SHELL_TOOLS then shell_crosses?(targets, root)
72
- else false
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 the file tools (write, edit, memory_write) touch,
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 the file tools.
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 && FILE_TOOLS.include?(targets.tool)
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, memory_remove: memory_remove,
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 optional; a callable
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). A
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), cwd: given[:cwd],
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 on another version than +version+ (unknown counts).
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
- workers(state_dir: state_dir).reject { |w| w.version == version }
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