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
|
@@ -44,14 +44,24 @@ module Samagotchi
|
|
|
44
44
|
def run
|
|
45
45
|
provenance = Provenance.new(name: @name)
|
|
46
46
|
bundle = provenance.record
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
54
|
-
hooks = bundle
|
|
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
|
-
|
|
166
|
-
|
|
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
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
|
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
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# the
|
|
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
|
|
24
|
-
#
|
|
25
|
-
#
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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:
|
|
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 "
|
|
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
|
-
|
|
187
|
-
|
|
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,
|
|
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
|