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
@@ -44,14 +44,24 @@ module Samagotchi
44
44
  def run
45
45
  provenance = Provenance.new(name: @name)
46
46
  bundle = provenance.record
47
- raise UninstallError.not_installed(@name) unless bundle
48
-
49
- # The scope from the flag, else the record's
50
- target_scope = @scope.to_s.strip.empty? ? bundle.effective_scope : @scope
51
- target_dir = MemoryPaths.scope_dir(target_scope) or raise UninstallError, "invalid scope: #{target_scope}"
47
+ if bundle
48
+ target_scope = @scope.to_s.strip.empty? ? bundle.effective_scope : @scope
49
+ target_dir = MemoryPaths.scope_dir(target_scope) or raise UninstallError, "invalid scope: #{target_scope}"
50
+ else
51
+ target_scope = @scope.to_s.strip.empty? ? provenance.pending_scope : @scope
52
+ pending_target = provenance.pending_target_dir
53
+ raise UninstallError.not_installed(@name) unless target_scope && pending_target
54
+
55
+ target_dir = MemoryPaths.scope_dir(target_scope) or raise UninstallError, "invalid scope: #{target_scope}"
56
+ if @scope.to_s.strip.empty?
57
+ target_dir = pending_target
58
+ elsif File.expand_path(target_dir) != pending_target
59
+ raise UninstallError.not_installed(@name)
60
+ end
61
+ end
52
62
 
53
- files = bundle.files
54
- hooks = bundle.hooks
63
+ files = provenance.files_with_pending(bundle, target_dir: target_dir)
64
+ hooks = bundle&.hooks || {}
55
65
  if files.empty? && hooks.empty?
56
66
  # No file list, just remove provenance (rules, a plugin and scripts
57
67
  # live there too)
@@ -98,7 +108,7 @@ module Samagotchi
98
108
 
99
109
  @warnings << "Kept #{file_key_str}: bundle #{others.join(", ")} has it too"
100
110
  unless ModelOverlay.bundle_overlay?(file_key_str, bundle_files: files.keys.map(&:to_s), target_dir: target_dir)
101
- retag(target_scope, target_path, file_key_str, others.first)
111
+ retag(target_scope, target_path, file_key_str, others.first, target_dir: target_dir)
102
112
  end
103
113
  next
104
114
  end
@@ -108,8 +118,8 @@ module Samagotchi
108
118
  end
109
119
  # Remove index line (not fatal: a warning says so)
110
120
  begin
111
- IndexUpdater.remove_index(target_scope, file_key_str.delete_suffix(".md"))
112
- IndexUpdater.remove_index(target_scope, file_key_str) # legacy "name.md" line
121
+ IndexUpdater.remove_index(target_scope, file_key_str.delete_suffix(".md"), target_dir: target_dir)
122
+ IndexUpdater.remove_index(target_scope, file_key_str, target_dir: target_dir) # legacy "name.md" line
113
123
  rescue StandardError => e
114
124
  @warnings << "index.md: line for #{file_key_str.delete_suffix(".md")} not removed (#{e.message})"
115
125
  end
@@ -151,6 +161,8 @@ module Samagotchi
151
161
  # scripts by their source-less AssetState and ASSET_UNINSTALL's
152
162
  # save_first; an older chi's hooks/ by Trash#copy_edits.
153
163
  def save_edits(provenance, bundle)
164
+ return unless bundle
165
+
154
166
  @trash.copy_edits(provenance.hooks_dir, "hooks", bundle.hooks.transform_values(&:sha256))
155
167
  { rule: [provenance.guardrails_dir, bundle.guardrails], plugin: [provenance.plugin_dir, bundle.plugin_shas],
156
168
  script: [provenance.scripts_dir, bundle.scripts] }.each do |kind_of_file, (dir, recorded)|
@@ -161,9 +173,11 @@ module Samagotchi
161
173
  end
162
174
 
163
175
  # A kept shared file's index line names the bundle that still has it
164
- # (a model overlay has none: the caller skips it).
165
- def retag(scope, path, file_key, bundle)
166
- IndexUpdater.update_index(scope, file_key.delete_suffix(".md"), File.size(path), source: bundle)
176
+ # (a model overlay has none: the caller skips it). +target_dir+ is the
177
+ # folder the file lives in, which for an install interrupted before its
178
+ # record is the journal's target dir, not the current folder's.
179
+ def retag(scope, path, file_key, bundle, target_dir:)
180
+ IndexUpdater.update_index(scope, file_key.delete_suffix(".md"), File.size(path), source: bundle, target_dir: target_dir)
167
181
  rescue StandardError => e
168
182
  @warnings << "index.md: line for #{file_key.delete_suffix(".md")} not updated (#{e.message})"
169
183
  end
@@ -19,12 +19,28 @@ module Samagotchi
19
19
  # memory_read miss (Tools::MemoryRead); never in the system prompt.
20
20
  module MemoryOrphans
21
21
  # @!attribute computed [String] the folder chi reads (this project's key)
22
- # @!attribute candidates [Array<String>] same-basename folders, other hashes
22
+ # @!attribute candidates [Array<String>] same-basename folders keyed by
23
+ # another path's hash or by a project id
23
24
  # @!attribute computed_exists [Boolean] whether +computed+ is a folder (empty of memories)
24
25
  Found = Data.define(:computed, :candidates, :computed_exists)
25
26
 
27
+ # A project keyed by its path is <name>_<8 hex>; a marked project's folder is
28
+ # its id, <name>-<8 hex>.
29
+ PATH_HASH = /_\h{8}\z/
30
+
26
31
  module_function
27
32
 
33
+ # A candidate folder's key, in words: an earlier path's hash, or a chi project
34
+ # id (a folder marked by `chi project init`, left behind when `git init` makes
35
+ # it a repository). +candidate+ is a folder path.
36
+ def key_note(candidate)
37
+ hash_keyed?(candidate) ? "another path's hash" : "project id #{File.basename(candidate)}"
38
+ end
39
+
40
+ # Internal: is this candidate keyed by its path rather than by a project id?
41
+ def hash_keyed?(candidate) = File.basename(candidate).match?(PATH_HASH)
42
+ private_class_method :hash_keyed?
43
+
28
44
  # The orphan candidates for +cwd+'s project, or nil: only inside a
29
45
  # project, a git repository or a marked folder (outside both the root is
30
46
  # cwd, and every src/ or tmp/ would match), and only while the computed folder holds no *.md (a
@@ -67,9 +83,18 @@ module Samagotchi
67
83
  found = find(env: env, cwd: cwd)
68
84
  return unless found
69
85
 
70
- name = File.basename(found.computed).sub(/_\h{8}\z/, "")
71
- "a folder with the same project name (#{name}) exists with another path's hash; ask the user whether its " \
72
- "memories belong to this project before using them."
86
+ # The project's name is its root folder's basename: splitting the key would
87
+ # print a project id as the name when this project is keyed by one.
88
+ name = File.basename(MemoryPaths.project_root(cwd))
89
+ if found.candidates.size == 1
90
+ candidate = found.candidates.first
91
+ "a folder with the same project name (#{name}) exists under #{key_note(candidate)}; ask the user " \
92
+ "whether its memories belong to this project before using them."
93
+ else
94
+ list = found.candidates.map { |candidate| "#{candidate} (#{key_note(candidate)})" }.join(", ")
95
+ "folders with the same project name (#{name}) exist under other keys (#{list}); ask the user whether their " \
96
+ "memories belong to this project before using them."
97
+ end
73
98
  end
74
99
 
75
100
  def text(found)
@@ -82,13 +107,19 @@ module Samagotchi
82
107
  else
83
108
  "`mv #{candidate} #{computed}`"
84
109
  end
85
- "#{lead}, but #{candidate} has the same project name with another path's hash (memories moved from " \
86
- "another machine or path?). A name match can't prove it's this project; if it is, #{fix}."
110
+ why = if hash_keyed?(candidate)
111
+ "has the same project name with #{key_note(candidate)} (memories moved from another machine or " \
112
+ "path?)"
113
+ else
114
+ "has the same project name keyed by a project id (#{File.basename(candidate)}): the folder " \
115
+ "chi project init marked, keyed by its id instead of its path"
116
+ end
117
+ "#{lead}, but #{candidate} #{why}. A name match can't prove it's this project; if it is, #{fix}."
87
118
  else
88
119
  fix = found.computed_exists ? "move its files into #{computed}" : "`mv <that folder> #{computed}`"
89
- "#{lead}, but these have the same project name with another path's hash (memories moved from another " \
90
- "machine or path?): #{found.candidates.join(", ")}. A name match can't prove one is this project; if one " \
91
- "is, #{fix}."
120
+ list = found.candidates.map { |candidate| "#{candidate} (#{key_note(candidate)})" }.join(", ")
121
+ "#{lead}, but these have the same project name (memories moved from another machine or path?): " \
122
+ "#{list}. A name match can't prove one is this project; if one is, #{fix}."
92
123
  end
93
124
  end
94
125
  end
@@ -20,6 +20,7 @@ module Samagotchi
20
20
  Usage: chi note [--source NAME] [-m TEXT] (ID|PREFIX)... | --all
21
21
  Adds TEXT (or stdin) to each session as a context note: background
22
22
  the model sees on its next turn, not a prompt. It starts no turn.
23
+ A session that was continued: the newest link of its chain gets it.
23
24
  --source NAME where it came from, shown to the model (default: cli)
24
25
  -m TEXT the note; without it, stdin is read
25
26
  --all every session a worker runs now, in every project
@@ -116,10 +117,17 @@ module Samagotchi
116
117
  # @return [Boolean] whether the note was queued
117
118
  def deliver(id, text, source)
118
119
  result = @access.note(id, text: text, source: source)
119
- @stdout.puts("#{id[0, 8]} #{outcome(result)}")
120
+ @stdout.puts("#{where(id, result)} #{outcome(result)}")
120
121
  result.delivered?
121
122
  end
122
123
 
124
+ # The id, or for a session that was continued the link that took it.
125
+ def where(id, result)
126
+ return id[0, 8] unless result.continued?
127
+
128
+ "#{id[0, 8]} → #{result.id[0, 8]} (continued)"
129
+ end
130
+
123
131
  def outcome(result)
124
132
  case result.status
125
133
  when :refused then "refused: #{result.refusal}"
@@ -5,31 +5,35 @@ require_relative "owner_lock"
5
5
  require_relative "session_inbox"
6
6
 
7
7
  module Samagotchi
8
- # Loaded on first use: this autoload keeps SessionManager and its ~80
9
- # files unloaded until the delegate/session path needs them.
10
- autoload :SessionManager, File.expand_path("session_manager", __dir__)
11
-
12
8
  # The one rule for putting a context note into a session, shared by
13
- # `chi note`, the agent's send_note and `chi broadcast`: a session a
14
- # one-turn chi run owns, or a chi scratch session, refuses it; any other
15
- # gets the note in its notes/ folder, which a live worker adds within
16
- # seconds and a session with no worker at its next start. Callers word
17
- # the outcome (#refusal for a refused one).
9
+ # `chi note`, the agent's send_note, `chi broadcast` and /objectives: a
10
+ # note delivery (SessionInbox.deliver :note). A note to a link that was
11
+ # continued goes to its chain's newest link (SessionInbox::Head); a
12
+ # session a one-turn chi run owns, or a chi scratch session, refuses it;
13
+ # any other gets the note in its notes/ folder, which a live worker adds
14
+ # within seconds and a session with no worker at its next start (a note
15
+ # never starts one). Callers word the outcome (#refusal for a refused one).
18
16
  module NoteDelivery
19
17
  # Why a one-turn chi run's session takes no note.
20
18
  ONE_SHOT_REFUSAL = "it is #{OwnerLock::TUI_BUSY}; send the note once it ends"
21
19
 
22
20
  # The outcome for one session.
23
- # @!attribute status [Symbol] :queued (a worker runs it), :waits (no
24
- # worker: it waits for the next start) or :refused (a one-turn chi run
25
- # owns it, or it is a chi scratch session: +scratch+)
21
+ # @!attribute id [String] the session that has the note: +asked+, or
22
+ # the newest link of its chain
23
+ # @!attribute status [Symbol] :queued (a worker runs it or is starting),
24
+ # :waits (no worker: it waits for the next start) or :refused (a
25
+ # one-turn chi run owns it, or it is a chi scratch session: +scratch+)
26
26
  # @!attribute queued [Integer] notes waiting in its notes/ folder
27
27
  # (:waits only; 0 otherwise)
28
- Result = Data.define(:id, :status, :queued, :scratch) do
29
- def initialize(id:, status:, queued:, scratch: false) = super
28
+ # @!attribute asked [String] the id the caller named (+id+ by default)
29
+ Result = Data.define(:id, :status, :queued, :scratch, :asked) do
30
+ def initialize(id:, status:, queued:, scratch: false, asked: nil) = super(id:, status:, queued:, scratch:, asked: asked || id)
30
31
 
31
32
  def delivered? = status != :refused
32
33
 
34
+ # The note went on to a later link of +asked+'s chain.
35
+ def continued? = asked != id
36
+
33
37
  # Why a refused note was refused, in its condition's one wording.
34
38
  def refusal = scratch ? Session::SCRATCH_REFUSED : ONE_SHOT_REFUSAL
35
39
  end
@@ -38,19 +42,25 @@ module Samagotchi
38
42
 
39
43
  # @param id [String] a full session id (resolved by the caller)
40
44
  # @param objectives [Hash, nil] an objectives note's payload
45
+ # @param pin [Boolean] keep it in +id+, never its chain's newest link
46
+ # (an /objectives list is the conversation of +id+'s own)
47
+ # @param resolved [SessionInbox::Resolved, nil] the link the caller
48
+ # resolved already (SessionInbox::Head.resolve(:note, id)): no second walk
41
49
  # @return [Result]
42
50
  # @raise [SessionInbox::NoteRejected] an empty note or one over 16 KiB
43
- def deliver(id, text:, source:, state_dir:, from_session: nil, from_cwd: nil, objectives: nil)
44
- owner = SessionManager.session_owner(id, state_dir: state_dir)
45
- return Result.new(id: id, status: :refused, queued: 0) if owner&.tui?
46
- # Its terminal's alone, live or left behind.
47
- return Result.new(id: id, status: :refused, queued: 0, scratch: true) if Session.load(id, state_dir: state_dir).scratch
48
-
49
- path = SessionInbox.write_note(id, text: text, source: source, from_session: from_session, from_cwd: from_cwd,
50
- objectives: objectives, state_dir: state_dir)
51
- return Result.new(id: id, status: :queued, queued: 0) if owner
51
+ # @raise [Session::NotFound] no such session
52
+ def deliver(id, text:, source:, state_dir:, from_session: nil, from_cwd: nil, objectives: nil, pin: false,
53
+ resolved: nil)
54
+ body = SessionInbox.checked_text(text)
55
+ delivered = SessionInbox.deliver(:note, id, state_dir: state_dir, pin: pin, resolved: resolved) do |at|
56
+ SessionInbox.write_note(at, text: body, source: source, from_session: from_session, from_cwd: from_cwd,
57
+ objectives: objectives, state_dir: state_dir)
58
+ false
59
+ end
60
+ raise Session::NotFound, id if delivered.wake == :gone
52
61
 
53
- Result.new(id: id, status: :waits, queued: SessionInbox.find_new_note_files(File.dirname(path, 2)).size)
62
+ Result.new(id: delivered.id, asked: delivered.asked, status: delivered.status, queued: delivered.queued || 0,
63
+ scratch: delivered.refused? && delivered.wake == :scratch)
54
64
  end
55
65
  end
56
66
  end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+ require_relative "archive_store"
7
+ require_relative "context_move"
8
+ require_relative "log"
9
+ require_relative "objectives"
10
+ require_relative "session"
11
+ require_relative "session_chain"
12
+ require_relative "session_inbox"
13
+
14
+ module Samagotchi
15
+ # A session chain's notes/ across a continue. The continue moves the
16
+ # previous link's waiting notes to the new link (.move, the last step
17
+ # of its before_spawn); a continue that fails gives them back (.give_back);
18
+ # the new link's worker adopts a note written to the previous link after
19
+ # the move (.adoption: a writer that resolved the id just before the new
20
+ # link was saved, or an older chi that knows no chain head). A note keeps
21
+ # its file name everywhere: its id, and its place in arrival order.
22
+ #
23
+ # Objectives lists stay where they are: an /objectives note is its own
24
+ # session's (pinned), and the continue's Objectives.carry already took the
25
+ # previous link's newest one into the new link's list.
26
+ module NoteMove
27
+ # The notes a continue writes into its new link itself (the chain note,
28
+ # ChildMove's, the attached context's, the objectives' carry): a failed
29
+ # continue deletes them with the link.
30
+ CONTINUE_SOURCES = [SessionChain::NOTE_SOURCE, ContextMove::NOTE_SOURCE, Objectives::SOURCE].freeze
31
+
32
+ module_function
33
+
34
+ # Moves +from+'s notes into +to+'s notes/, but not its objectives lists
35
+ # nor the notes a continue into +from+ wrote there (CONTINUE_SOURCES: a
36
+ # link continued before it ever started holds its own chain note, which
37
+ # the new link's own replaces). The rename is the claim: one a worker or
38
+ # another move took meanwhile is skipped. A claimed one
39
+ # (*.json.processing: an old worker took it and never absorbed it, or
40
+ # absorbed it and died before the delete) moves too, un-claimed, logged
41
+ # (notes_moved_claimed): the link is archived, so left there it would be
42
+ # lost, and a note seen twice beats a lost one.
43
+ # @return [Array<String>] the file names moved (un-claimed), oldest first
44
+ def move(from:, to:, state_dir:)
45
+ files = Dir.glob(File.join(notes_dir(from, state_dir), "*.{json,json.processing}"))
46
+ .sort_by { |file| unclaimed_name(file) }
47
+ .select { |file| (data = read(file)) && !objectives?(data) && !CONTINUE_SOURCES.include?(data["source"]) }
48
+ moved = rename(files, notes_dir(to, state_dir))
49
+ claimed = files.count { |file| file.end_with?(".processing") && moved.include?(unclaimed_name(file)) }
50
+ Log.info(:worker, "notes_moved_claimed", from: from[0, 8], to: to[0, 8], count: claimed) if claimed.positive?
51
+ Log.info(:worker, "notes_moved", from: from[0, 8], to: to[0, 8], count: moved.size) unless moved.empty?
52
+ moved
53
+ end
54
+
55
+ # A failed continue's undo, before its link +from+ is deleted: back to
56
+ # +to+ go the notes it moved (+moved+) and any other sent to the link
57
+ # meanwhile, never one the continue wrote itself (CONTINUE_SOURCES).
58
+ # @return [Array<String>] the file names given back
59
+ def give_back(from:, to:, moved:, state_dir:)
60
+ files = unclaimed(notes_dir(from, state_dir)).select do |file|
61
+ next true if moved.include?(File.basename(file))
62
+
63
+ data = read(file)
64
+ data && !objectives?(data) && !CONTINUE_SOURCES.include?(data["source"])
65
+ end
66
+ rename(files, notes_dir(to, state_dir)).tap do |back|
67
+ Log.info(:worker, "notes_given_back", from: from[0, 8], to: to[0, 8], count: back.size) unless back.empty?
68
+ end
69
+ end
70
+
71
+ # How far back along the chain a worker adopts (Adoption): the links
72
+ # continued into it, newest first.
73
+ ADOPT_MAX_LINKS = 8
74
+
75
+ # What a worker for +session+ adopts from the links before it, nil when
76
+ # there is nothing to adopt from: a first link, a predecessor not
77
+ # archived (checked first: no listing then), or one whose next link
78
+ # (SessionChain.next_of) is another. Else one listing walks back: each
79
+ # archived link whose next link is the one after it, from the newest, up
80
+ # to ADOPT_MAX_LINKS, stopping at one not archived or gone; each is
81
+ # adopted from by the creation of the link that continued it.
82
+ # @param session [Session]
83
+ # @return [Adoption, nil]
84
+ def adoption(session, state_dir:)
85
+ from = session.continues
86
+ return nil unless Session.valid_id?(from) && archived?(from, state_dir)
87
+
88
+ sessions = Session.list(state_dir: state_dir, sort: "created_at", order: "asc", include_archived: true)
89
+ byid = sessions.to_h { |s| [s.id, s] }
90
+ nexts = sessions.each_with_object({}) { |s, map| map[s.continues] ||= s.id if s.continues }
91
+ sources = []
92
+ child = session
93
+ while Session.valid_id?(from) && sources.size < ADOPT_MAX_LINKS && sources.none? { |src| src.from == from }
94
+ since = time_of(child.created_at)
95
+ break unless since && nexts[from] == child.id && archived?(from, state_dir)
96
+
97
+ sources << Source.new(from: from, since: since.to_i)
98
+ child = byid[from] or break
99
+ from = child.continues
100
+ end
101
+ sources.empty? ? nil : Adoption.new(to: session.id, sources: sources, state_dir: state_dir)
102
+ end
103
+
104
+ # A link adopted from (#adoption) and from when (whole seconds: the
105
+ # creation of the link that continued it).
106
+ Source = Data.define(:from, :since)
107
+
108
+ # A worker's adoption of the notes the links before it get after their
109
+ # continue: #call on each pass takes them while each link is archived
110
+ # (back in the lists, its notes are its own, and the walk stops there).
111
+ # Only notes from the second the next link was made on (a note's stamp
112
+ # has whole seconds): older ones are from before the continue (a
113
+ # continue made before notes moved left them), and would come days late,
114
+ # out of order with the conversation. They, objectives lists and claimed
115
+ # ones stay, each logged once.
116
+ class Adoption
117
+ def initialize(to:, sources:, state_dir:)
118
+ @to = to
119
+ @sources = sources
120
+ @state_dir = state_dir
121
+ @to_dir = NoteMove.notes_dir(to, state_dir)
122
+ @left = Set.new
123
+ end
124
+
125
+ # @return [Array<String>] the file names adopted
126
+ def call
127
+ adopted = []
128
+ @sources.each do |source|
129
+ break unless NoteMove.archived?(source.from, @state_dir)
130
+
131
+ adopted.concat(adopt(source))
132
+ end
133
+ adopted
134
+ end
135
+
136
+ private
137
+
138
+ def adopt(source)
139
+ files = NoteMove.unclaimed(NoteMove.notes_dir(source.from, @state_dir)).reject { |file| @left.include?(file) }
140
+ return [] if files.empty?
141
+
142
+ take, leave = files.partition { |file| adoptable?(NoteMove.read(file), source.since) }
143
+ log_left(source, leave)
144
+ NoteMove.rename(take, @to_dir).tap do |adopted|
145
+ Log.info(:worker, "notes_adopted", from: source.from[0, 8], sid: @to[0, 8], count: adopted.size) unless adopted.empty?
146
+ end
147
+ end
148
+
149
+ def adoptable?(data, since)
150
+ return false if data.nil? || NoteMove.objectives?(data)
151
+
152
+ (time = NoteMove.time_of(data["created_at"])) && time.to_i >= since
153
+ end
154
+
155
+ def log_left(source, files)
156
+ return if files.empty?
157
+
158
+ @left.merge(files)
159
+ Log.info(:worker, "notes_left_old", from: source.from[0, 8], sid: @to[0, 8], count: files.size)
160
+ end
161
+ end
162
+
163
+ def archived?(id, state_dir) = ArchiveStore.archived?(Session.session_dir(id, state_dir: state_dir))
164
+
165
+ # @return [Time, nil]
166
+ def time_of(value)
167
+ Time.iso8601(value.to_s)
168
+ rescue ArgumentError
169
+ nil
170
+ end
171
+
172
+ def notes_dir(id, state_dir) = File.join(Session.session_dir(id, state_dir: state_dir), SessionInbox::NOTES_DIR)
173
+
174
+ # The unclaimed note files, oldest first.
175
+ def unclaimed(dir) = Dir.glob(File.join(dir, "*.json")).sort_by { |file| File.basename(file) }
176
+
177
+ # @return [Hash, nil] the note file's JSON, nil when it can't be read
178
+ def read(file)
179
+ data = JSON.parse(File.read(file))
180
+ data.is_a?(Hash) ? data : nil
181
+ rescue JSON::ParserError, SystemCallError
182
+ nil
183
+ end
184
+
185
+ def objectives?(data) = data.is_a?(Hash) && (data["objectives"].is_a?(Hash) || data["source"] == Objectives::SOURCE)
186
+
187
+ # A note file's name without a claim's ".processing".
188
+ def unclaimed_name(file) = File.basename(file).delete_suffix(".processing")
189
+
190
+ # @return [Array<String>] the names renamed into +dir+, un-claimed (one
191
+ # taken meanwhile is skipped)
192
+ def rename(files, dir)
193
+ return [] if files.empty?
194
+
195
+ FileUtils.mkdir_p(dir)
196
+ files.filter_map do |file|
197
+ name = unclaimed_name(file)
198
+ File.rename(file, File.join(dir, name))
199
+ name
200
+ rescue Errno::ENOENT
201
+ nil
202
+ end
203
+ end
204
+ end
205
+ end
@@ -97,8 +97,10 @@ module Samagotchi
97
97
  end
98
98
 
99
99
  def deliver(list, session)
100
+ # Pinned: the list is this session's own, never its chain's newest link's.
100
101
  result = NoteDelivery.deliver(session.id, text: list.text, source: Objectives::SOURCE,
101
- state_dir: @engine.session_state_dir, objectives: list.to_payload)
102
+ state_dir: @engine.session_state_dir, objectives: list.to_payload,
103
+ pin: true)
102
104
  return ["refused: #{result.refusal}", :error] unless result.delivered?
103
105
 
104
106
  @engine.announce_objectives(session)
@@ -23,6 +23,8 @@ module Samagotchi
23
23
  # web's continueRefusal reads "<short> (why)" out of a refusal.
24
24
  TUI_BUSY = "in use by a one-turn chi --non-interactive run or an older chi's terminal"
25
25
  DEFAULT_WAIT = 2.0
26
+ # The kind a deleter holds the lock as while a session's files go.
27
+ DELETING = "deleting"
26
28
  RETRY_INTERVAL = 0.05
27
29
  OWNER_READ_ATTEMPTS = 10
28
30
  # Seconds .await_release waits past a leaving worker's own budget: its
@@ -33,7 +35,7 @@ module Samagotchi
33
35
 
34
36
  # Who holds the lock, as .owner reads it from the lock file. Its fields
35
37
  # may be nil for a moment right after acquisition.
36
- # kind: "worker" or "tui"; pid as written (an Integer). leaving_at (an
38
+ # kind: "worker", "tui" or "deleting" (Owner#deleting?); pid as written (an Integer). leaving_at (an
37
39
  # ISO 8601 time) and leave_wait (seconds): set once a worker is leaving
38
40
  # (#mark_leaving), its Bridge closed, the lock held for its last work
39
41
  # (the recap on leave) for up to leave_wait.
@@ -45,6 +47,10 @@ module Samagotchi
45
47
 
46
48
  def worker? = kind == "worker"
47
49
 
50
+ # A deleter removing the session's files (SessionManager's
51
+ # remove_session_files): no worker, nothing to signal.
52
+ def deleting? = kind == DELETING
53
+
48
54
  # A worker that closed its Bridge and releases the lock once its
49
55
  # last work is done.
50
56
  def leaving? = worker? && !leaving_at.nil?
@@ -183,9 +183,9 @@ module Samagotchi
183
183
  list.each { |place| lines.concat(move_folder(place.path(path_key), place.path(id))) }
184
184
  write_id(root, id)
185
185
  if orphans
186
- lines << "memories from an earlier path: #{orphans.candidates.map { |c| Paths.tilde(c) }.join(", ")} (this " \
187
- "folder's name with another path's hash; if they are this project's, move their files into " \
188
- "#{Paths.tilde(list.first.path(id))})"
186
+ keys = orphans.candidates.map { |candidate| "#{Paths.tilde(candidate)} (#{MemoryOrphans.key_note(candidate)})" }
187
+ lines << "memories with this folder's name under another key: #{keys.join(", ")}; if they are this " \
188
+ "project's, move their files into #{Paths.tilde(list.first.path(id))}"
189
189
  end
190
190
  Outcome.new(kind: :adopted, reason: nil, root: root, top: root, nested_in: nil, message: lines.join("\n"), id: id)
191
191
  end
@@ -80,7 +80,8 @@ module Samagotchi
80
80
  setup = setup.with(toolkit_origin: "chosen")
81
81
  SessionManager.spawn_session(prompt: nil, preview: preview(subjects), working_directory: dir, model_name: model,
82
82
  setup: setup, review: SessionReview.new(subjects: subjects),
83
- note: note(subjects: subjects), note_source: NOTE_SOURCE, state_dir: state_dir)
83
+ note: note(subjects: subjects), note_source: NOTE_SOURCE, state_dir: state_dir,
84
+ client_id: nil)
84
85
  end
85
86
 
86
87
  # A review fork of +from+ (an id, a prefix, or last:<id>): a new session
@@ -106,9 +107,10 @@ module Samagotchi
106
107
  recap = RecapStore.read(Session.session_dir(source.id, state_dir: sd))
107
108
  session = SessionManager.spawn_session(
108
109
  prompt: nil, preview: preview(subjects, source: source), working_directory: dir, model_name: choice.model,
109
- setup: SessionSetup.of(source).with(toolkit_origin: "chosen"),
110
+ setup: SessionSetup.of(source).with(preloaded_memory_names: [], toolkit_origin: "chosen"),
110
111
  review: SessionReview.new(subjects: subjects, from: source.id),
111
- note: note(subjects: subjects, source: source, recap: recap, seed: seed), note_source: NOTE_SOURCE, state_dir: sd
112
+ note: note(subjects: subjects, source: source, recap: recap, seed: seed), note_source: NOTE_SOURCE,
113
+ state_dir: sd, client_id: nil
112
114
  )
113
115
  warnings = [choice.warning, session.model_warning].compact
114
116
  session.model_warning = warnings.join("; ") unless warnings.empty?
@@ -137,6 +137,7 @@ module Samagotchi
137
137
  end
138
138
 
139
139
  @stdout.puts("#{entry.id} added: #{entry.target_words} · #{entry.recurrence_words} · next #{clock(entry.next_due)}")
140
+ print_tick_warning
140
141
  CLI::Exit::OK
141
142
  end
142
143
 
@@ -144,13 +145,16 @@ module Samagotchi
144
145
  entries = @schedules.entries(include_disabled: @opts[:include_disabled] ? true : false)
145
146
  if @opts[:format] == "json"
146
147
  @stdout.puts(JSON.pretty_generate(entries.map { |entry| entry_line_json(entry) }))
148
+ print_tick_warning(@stderr)
147
149
  return CLI::Exit::OK
148
150
  end
149
151
  if entries.empty?
150
152
  @stdout.puts("no schedules")
153
+ print_tick_warning
151
154
  return CLI::Exit::OK
152
155
  end
153
156
  entries.each { |entry| @stdout.puts(entry_line(entry)) }
157
+ print_tick_warning
154
158
  CLI::Exit::OK
155
159
  end
156
160
 
@@ -194,6 +198,8 @@ module Samagotchi
194
198
  @stdout.puts("#{outcome.id} #{word} #{outcome.detail}")
195
199
  end
196
200
  CLI::Exit::OK
201
+ rescue Schedules::Corrupt => e
202
+ failed("#{e.message.lines.first.chomp}; left as is")
197
203
  end
198
204
 
199
205
  def act
@@ -231,6 +237,7 @@ module Samagotchi
231
237
  note = ""
232
238
  note += " skips #{entry.skips} of #{entry.max_skips} (#{skip_window(entry)})" if entry.skips.positive?
233
239
  note += " disabled after #{entry.failures} failures" unless entry.enabled
240
+ note += " last: #{entry.last_result}" if entry.last_result
234
241
  "#{entry.id} #{entry.mode} #{entry.recurrence_words} #{clock(entry.next_due)} #{entry.head[0, 8]}#{note}"
235
242
  end
236
243
 
@@ -248,5 +255,10 @@ module Samagotchi
248
255
  "skip_window" => skip_window(entry)
249
256
  )
250
257
  end
258
+
259
+ def print_tick_warning(stream = @stdout)
260
+ warning = @schedules.tick_warning
261
+ stream.puts(warning) if warning
262
+ end
251
263
  end
252
264
  end