samagotchi 0.158.0 → 0.160.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 +60 -1
- data/bin/chi +3 -1
- data/docs/architecture.md +7 -7
- data/docs/cli.md +57 -2
- data/docs/configuration.md +9 -0
- data/docs/context.md +7 -1
- data/docs/guardrails.md +24 -3
- data/docs/hooks.md +3 -0
- data/docs/internals/attached-context.md +4 -2
- data/docs/internals/bridge.md +8 -0
- data/docs/internals/bundles.md +9 -7
- data/docs/internals/engine.md +7 -1
- data/docs/memory.md +46 -14
- data/docs/scheduling.md +241 -0
- data/docs/sessions.md +28 -2
- data/lib/samagotchi/bridge.rb +37 -4
- data/lib/samagotchi/broadcast/scope_card.rb +2 -1
- data/lib/samagotchi/bundle_command.rb +39 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
- data/lib/samagotchi/client_id.rb +17 -3
- data/lib/samagotchi/command_intake.rb +56 -0
- data/lib/samagotchi/config.rb +12 -0
- data/lib/samagotchi/context_absorber.rb +6 -3
- data/lib/samagotchi/context_candidate_choice.rb +8 -5
- data/lib/samagotchi/context_candidates.rb +11 -8
- data/lib/samagotchi/context_command.rb +9 -3
- data/lib/samagotchi/context_move.rb +74 -0
- data/lib/samagotchi/context_poller.rb +5 -2
- data/lib/samagotchi/context_sources.rb +6 -4
- data/lib/samagotchi/engine/events.rb +13 -2
- data/lib/samagotchi/engine/guardrails.rb +3 -4
- data/lib/samagotchi/engine.rb +1 -0
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/events.rb +71 -0
- data/lib/samagotchi/guardrails/approvals.rb +27 -3
- data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
- data/lib/samagotchi/hook_routes.rb +0 -1
- data/lib/samagotchi/memory_bundle/asset_installer.rb +106 -104
- data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
- data/lib/samagotchi/memory_bundle/file_actions.rb +50 -0
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installer.rb +14 -11
- data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +22 -15
- data/lib/samagotchi/memory_bundle/status.rb +22 -3
- data/lib/samagotchi/memory_bundle/uninstaller.rb +12 -4
- data/lib/samagotchi/memory_orphans.rb +14 -4
- data/lib/samagotchi/memory_paths.rb +29 -6
- data/lib/samagotchi/memory_usage.rb +23 -12
- data/lib/samagotchi/plugin/attached_context.rb +3 -1
- data/lib/samagotchi/plugin/sessions.rb +4 -1
- data/lib/samagotchi/project_command.rb +58 -20
- data/lib/samagotchi/project_id.rb +102 -0
- data/lib/samagotchi/project_id_clash.rb +62 -0
- data/lib/samagotchi/project_init.rb +214 -10
- data/lib/samagotchi/project_scope.rb +11 -2
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/schedule_command.rb +252 -0
- data/lib/samagotchi/schedules.rb +1008 -0
- data/lib/samagotchi/session.rb +17 -2
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +28 -5
- data/lib/samagotchi/session_access/remote.rb +2 -1
- data/lib/samagotchi/session_access.rb +4 -1
- data/lib/samagotchi/session_commands.rb +7 -3
- data/lib/samagotchi/session_continue.rb +10 -6
- data/lib/samagotchi/session_manager/spawn.rb +34 -13
- data/lib/samagotchi/session_manager/worker_command.rb +11 -23
- data/lib/samagotchi/state_format.rb +118 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
- data/lib/samagotchi/tool_declarations.rb +43 -0
- data/lib/samagotchi/tools/builtin_calls.rb +2 -0
- data/lib/samagotchi/tools/builtins.rb +17 -0
- data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
- data/lib/samagotchi/tools/context_read.rb +7 -2
- data/lib/samagotchi/tools/delegate.rb +10 -2
- data/lib/samagotchi/tools/list_schedules.rb +43 -0
- data/lib/samagotchi/tools/schedule_task.rb +39 -0
- data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
- data/lib/samagotchi/update_command.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +5 -4
- data/lib/samagotchi/web/public/chains.js +32 -8
- data/lib/samagotchi/web/public/index.html +4 -1
- data/lib/samagotchi/web/public/info_bar.js +4 -3
- data/lib/samagotchi/web/public/turn_events.js +4 -0
- data/lib/samagotchi/web/session_access_routes.rb +3 -3
- data/lib/samagotchi/web/session_hub.rb +20 -0
- data/lib/samagotchi/worker.rb +42 -12
- data/lib/samagotchi/worker_boot.rb +3 -4
- data/lib/samagotchi/worker_inbound.rb +44 -2
- data/lib/samagotchi/worker_link.rb +5 -0
- data/lib/samagotchi/worker_wakes.rb +9 -1
- metadata +12 -1
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Tools
|
|
5
|
+
# Cancel a durable schedule of this session's chain by its id, a unique
|
|
6
|
+
# prefix of it, or its title (docs/scheduling.md). A schedule of another
|
|
7
|
+
# session's chain is refused unless the call says `all: true`: `chi
|
|
8
|
+
# schedule cancel` is the machine-wide way.
|
|
9
|
+
class CancelSchedule
|
|
10
|
+
NAME = "cancel_schedule"
|
|
11
|
+
|
|
12
|
+
def self.name = NAME
|
|
13
|
+
|
|
14
|
+
def self.call(content, all: false, peers: nil)
|
|
15
|
+
# Required here, not at the top of the file: the kit does not pay
|
|
16
|
+
# for the session lifecycle until a schedule is actually used.
|
|
17
|
+
require_relative "../schedules"
|
|
18
|
+
name = content.to_s.strip
|
|
19
|
+
return "Error: cancel_schedule needs the schedule's id or title" if name.empty?
|
|
20
|
+
|
|
21
|
+
want_all = all == true || all.to_s.strip.casecmp?("true")
|
|
22
|
+
schedules = Schedules.new(state_dir: peers&.state_dir)
|
|
23
|
+
entry = schedules.lookup(name)
|
|
24
|
+
return "No schedule named #{name}." if entry.nil?
|
|
25
|
+
|
|
26
|
+
if want_all || peers&.session_id.nil?
|
|
27
|
+
schedules.cancel(name)
|
|
28
|
+
return "Cancelled #{entry.id} (#{entry.title || entry.recurrence_words})."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
unless schedules.chain_of(peers.session_id).any? { |id| id == entry.anchor || id == entry.head }
|
|
32
|
+
return "Error: #{entry.id} belongs to another session's chain. " \
|
|
33
|
+
"chi schedule cancel #{entry.id} cancels it, or say all: true if you meant it."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
schedules.cancel(name)
|
|
37
|
+
"Cancelled #{entry.id} (#{entry.title || entry.recurrence_words})."
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
"Error: #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -51,8 +51,13 @@ module Samagotchi
|
|
|
51
51
|
@own = ContextSources.session_location(peers.session_id, state_dir: @state_dir)
|
|
52
52
|
# The Engine's PeerView knows the session's project; a plain Peers
|
|
53
53
|
# (specs, a bare kernel) has only its folder.
|
|
54
|
-
root = peers.respond_to?(:project_root)
|
|
55
|
-
|
|
54
|
+
root, id = if peers.respond_to?(:project_root)
|
|
55
|
+
[peers.project_root, peers.project_id]
|
|
56
|
+
else
|
|
57
|
+
found = ProjectScope.project_for(peers.cwd)
|
|
58
|
+
[found&.root, found&.id]
|
|
59
|
+
end
|
|
60
|
+
@attached = ContextSources.attached(peers.session_id, project_root: root, project_id: id, state_dir: @state_dir)
|
|
56
61
|
@max_chars = [max_chars.to_i, MIN_CHARS].max
|
|
57
62
|
@now = now
|
|
58
63
|
# The peer bundles' declared sources (PeerSources), through the
|
|
@@ -13,6 +13,10 @@ require_relative "../child_reports"
|
|
|
13
13
|
require_relative "../worker_link"
|
|
14
14
|
|
|
15
15
|
module Samagotchi
|
|
16
|
+
# Loaded on the first task (a leaf; the bundle test kit's load budget,
|
|
17
|
+
# spec/samagotchi/testing/sandbox_spec.rb).
|
|
18
|
+
autoload :CommandIntake, File.expand_path("../command_intake", __dir__)
|
|
19
|
+
|
|
16
20
|
# Loaded on first use: this autoload keeps SessionManager and its ~80
|
|
17
21
|
# files unloaded until the delegate/session path needs them.
|
|
18
22
|
autoload :SessionManager, File.expand_path("../session_manager", __dir__)
|
|
@@ -53,6 +57,9 @@ module Samagotchi
|
|
|
53
57
|
|
|
54
58
|
task = content.to_s.strip
|
|
55
59
|
return "Error: give the task, the child's first message" if task.empty?
|
|
60
|
+
# The child's intake takes no shell line from its parent: say so
|
|
61
|
+
# now, so the model rewrites the task (a /word one is the child's text).
|
|
62
|
+
return "Error: #{CommandIntake::REFUSED}" if CommandIntake.shell_line?(task)
|
|
56
63
|
|
|
57
64
|
sd = peers.state_dir || Session.default_state_dir
|
|
58
65
|
parent = Session.load(peers.session_id, state_dir: sd)
|
|
@@ -172,7 +179,8 @@ module Samagotchi
|
|
|
172
179
|
parent_answers: parent.parent_answers)
|
|
173
180
|
child = SessionManager.spawn_session(prompt: task, working_directory: folder,
|
|
174
181
|
model_name: child_model(model, parent), memories: CHILD_MEMORIES,
|
|
175
|
-
parent_id: parent.id, delegate: true, setup: setup, state_dir: state_dir
|
|
182
|
+
parent_id: parent.id, delegate: true, setup: setup, state_dir: state_dir,
|
|
183
|
+
client_id: ClientId.delegate(parent.id))
|
|
176
184
|
DelegateWait.mark_started(parent.id, child, state_dir: state_dir)
|
|
177
185
|
# The line for a task that names a worktree while the child runs in
|
|
178
186
|
# the parent's folder (no cwd: given): the child still starts.
|
|
@@ -358,7 +366,7 @@ module Samagotchi
|
|
|
358
366
|
|
|
359
367
|
# Only a reply after this delivery counts as the follow-up's.
|
|
360
368
|
DelegateWait.mark_seen(parent.id, child, state_dir: state_dir)
|
|
361
|
-
delivered = SessionManager.deliver_turn(id, prompt: task, client_id:
|
|
369
|
+
delivered = SessionManager.deliver_turn(id, prompt: task, client_id: ClientId.delegate(parent.id),
|
|
362
370
|
state_dir: state_dir)
|
|
363
371
|
case delivered.status
|
|
364
372
|
when :accepted
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Tools
|
|
5
|
+
# The durable schedules of this session's chain (docs/scheduling.md): id,
|
|
6
|
+
# mode, recurrence, next due and target. Another session's schedules are
|
|
7
|
+
# out of reach unless the call says `all: true`; `chi schedule list` is the
|
|
8
|
+
# machine-wide view. Schedules is required inside the call, so the testing
|
|
9
|
+
# kit does not pay for the session lifecycle at load time.
|
|
10
|
+
class ListSchedules
|
|
11
|
+
NAME = "list_schedules"
|
|
12
|
+
|
|
13
|
+
def self.name = NAME
|
|
14
|
+
|
|
15
|
+
def self.call(_content, include_disabled: false, all: false, peers: nil)
|
|
16
|
+
# Required here, not at the top of the file: the kit does not pay
|
|
17
|
+
# for the session lifecycle until a schedule is actually used.
|
|
18
|
+
require_relative "../schedules"
|
|
19
|
+
wanted = include_disabled == true || include_disabled.to_s.strip.casecmp?("true")
|
|
20
|
+
want_all = all == true || all.to_s.strip.casecmp?("true")
|
|
21
|
+
schedules = Samagotchi::Schedules.new(state_dir: peers&.state_dir)
|
|
22
|
+
|
|
23
|
+
scoped = !want_all && peers&.session_id
|
|
24
|
+
entries = scoped ? schedules.for_chain(peers.session_id, include_disabled: wanted) : schedules.entries(include_disabled: wanted)
|
|
25
|
+
|
|
26
|
+
if entries.empty?
|
|
27
|
+
scoped ? "No schedules in this session's chain. `chi schedule list` shows every schedule on this machine." : "No schedules."
|
|
28
|
+
else
|
|
29
|
+
lines = entries.map do |entry|
|
|
30
|
+
flags = []
|
|
31
|
+
flags << "skips #{entry.skips} of #{entry.max_skips}" if entry.skips.positive?
|
|
32
|
+
flags << "disabled after #{entry.failures} failures" unless entry.enabled
|
|
33
|
+
"#{entry.id} #{entry.target_words} #{entry.recurrence_words} next #{entry.next_due} #{flags.join(", ")}".rstrip
|
|
34
|
+
end
|
|
35
|
+
lines << "These are this session's chain only; `chi schedule list` shows every schedule on this machine." if scoped
|
|
36
|
+
lines.join("\n")
|
|
37
|
+
end
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
"Error: #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Tools
|
|
5
|
+
# Register a durable schedule for this session (docs/scheduling.md): a
|
|
6
|
+
# prompt that runs on a clock, out of the worker's memory, until it is
|
|
7
|
+
# cancelled. The default mode is `chain`: each firing is the next link of
|
|
8
|
+
# this session's chain, so a long-running routine keeps a fresh context.
|
|
9
|
+
# Unlike register_reminder, a schedule needs no live worker — a tick run by
|
|
10
|
+
# cron (or chi web's hub) wakes one — and it never holds an idle worker up.
|
|
11
|
+
class ScheduleTask
|
|
12
|
+
NAME = "schedule_task"
|
|
13
|
+
|
|
14
|
+
def self.name = NAME
|
|
15
|
+
|
|
16
|
+
def self.call(content, title: nil, prompt: nil, every: nil, weekday: nil, time: nil, date: nil,
|
|
17
|
+
mode: nil, keep_links: nil, peers: nil)
|
|
18
|
+
# Required here, not at the top of the file: the kit does not pay
|
|
19
|
+
# for the session lifecycle until a schedule is actually used.
|
|
20
|
+
require_relative "../schedules"
|
|
21
|
+
return "Error: this session's id is not known here" unless peers&.session_id
|
|
22
|
+
|
|
23
|
+
prompt = content.to_s if prompt.to_s.strip.empty?
|
|
24
|
+
schedules = Schedules.new(state_dir: peers.state_dir)
|
|
25
|
+
entry = schedules.add(
|
|
26
|
+
target: peers.session_id, prompt: prompt, title: title,
|
|
27
|
+
recurrence: Schedules.recurrence(every: every, weekday: weekday, time: time, date: date),
|
|
28
|
+
mode: mode.nil? || mode.to_s.strip.empty? ? Schedules::CHAIN : mode.to_s.strip,
|
|
29
|
+
keep_links: keep_links
|
|
30
|
+
)
|
|
31
|
+
"Scheduled #{entry.id}: #{entry.target_words} · #{entry.recurrence_words} · next #{entry.next_due}. " \
|
|
32
|
+
"It fires outside this worker (chi schedule tick, run by cron or chi web's hub); " \
|
|
33
|
+
"chi schedule list and cancel_schedule show and stop it."
|
|
34
|
+
rescue Schedules::Usage, Session::NotFound, Session::AmbiguousId => e
|
|
35
|
+
"Error: #{e.message}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -89,7 +89,8 @@ module Samagotchi
|
|
|
89
89
|
def pass(session, texts, phase:, state_dir:, recorder:)
|
|
90
90
|
return if session.id.nil? || session.scratch
|
|
91
91
|
|
|
92
|
-
rows = ContextCandidates.with_status(session.id, project_root: session.project_root,
|
|
92
|
+
rows = ContextCandidates.with_status(session.id, project_root: session.project_root,
|
|
93
|
+
project_id: session.project_id, state_dir: state_dir)
|
|
93
94
|
return if rows.empty?
|
|
94
95
|
|
|
95
96
|
log = ContextCandidatesLog.log_for(state_dir)
|
|
@@ -134,6 +135,7 @@ module Samagotchi
|
|
|
134
135
|
def choice(session, log, settings, state_dir)
|
|
135
136
|
session_id = session.id
|
|
136
137
|
project_root = session.project_root
|
|
138
|
+
project_id = session.project_id
|
|
137
139
|
show = lambda do |**card|
|
|
138
140
|
current = @current_session.call
|
|
139
141
|
next nil unless current.nil? || current.id == session_id
|
|
@@ -146,6 +148,7 @@ module Samagotchi
|
|
|
146
148
|
end
|
|
147
149
|
lambda do |job, fields|
|
|
148
150
|
ContextCandidateChoice.after_judged(job: job, fields: fields, settings: settings, project_root: project_root,
|
|
151
|
+
project_id: project_id,
|
|
149
152
|
state_dir: state_dir, log: log, show_card: show)
|
|
150
153
|
end
|
|
151
154
|
end
|
|
@@ -156,12 +159,9 @@ module Samagotchi
|
|
|
156
159
|
def parent_attached(session, state_dir)
|
|
157
160
|
return nil unless session.delegate? && session.parent_id
|
|
158
161
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
session.project_root
|
|
163
|
-
end
|
|
164
|
-
ContextSources.attached(session.parent_id, project_root: parent_root, state_dir: state_dir)
|
|
162
|
+
parent = Session.exist?(session.parent_id, state_dir: state_dir) ? Session.load(session.parent_id, state_dir: state_dir) : session
|
|
163
|
+
ContextSources.attached(session.parent_id, project_root: parent.project_root, project_id: parent.project_id,
|
|
164
|
+
state_dir: state_dir)
|
|
165
165
|
.map { |attached| attached.source.title || attached.name }
|
|
166
166
|
rescue StandardError => e
|
|
167
167
|
Log.warn(:context, "context_parent_attached_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
@@ -356,7 +356,7 @@ module Samagotchi
|
|
|
356
356
|
notes = file_notes(row, update.name, update.to || update.from, update.merged, update.behind, update.trash_copy, earlier: earlier)
|
|
357
357
|
case update.status
|
|
358
358
|
when :would_update, :updated
|
|
359
|
-
notes = [adds(update.adds), *notes, replaced(update.replaced), *update.kept_own, update.note].compact
|
|
359
|
+
notes = [adds(update.added_files), adds(update.adds), *notes, replaced(update.replaced), *update.kept_own, update.note].compact
|
|
360
360
|
done(row, :update, notes.empty? ? nil : notes.join("; "))
|
|
361
361
|
when :would_merge, :merged, :up_to_date then same_version(row, [*notes, update.note, update.manifest].compact, changed: Array(update.merged).any?)
|
|
362
362
|
when :skipped then skipped(row, [update.note, *notes].join("; "))
|
|
@@ -522,7 +522,7 @@ module Samagotchi
|
|
|
522
522
|
row
|
|
523
523
|
end
|
|
524
524
|
|
|
525
|
-
#
|
|
525
|
+
# New memory files or profile members: "+ file".
|
|
526
526
|
def adds(members)
|
|
527
527
|
members.nil? || members.empty? ? nil : "+ #{members.join(", ")}"
|
|
528
528
|
end
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -1030,7 +1030,7 @@ module Samagotchi
|
|
|
1030
1030
|
|
|
1031
1031
|
# One normal form for the decision, the mkdir and the realpath (a `..` after a symlink would differ).
|
|
1032
1032
|
dir = File.expand_path(dir)
|
|
1033
|
-
outcome = ProjectInit.call(dir, home: home)
|
|
1033
|
+
outcome = ProjectInit.call(dir, home: home, state_dir: default_state_dir)
|
|
1034
1034
|
return error_response(422, outcome.reason.to_s, outcome.message) if outcome.kind == :refused
|
|
1035
1035
|
|
|
1036
1036
|
if outcome.kind == :git
|
|
@@ -1455,7 +1455,8 @@ module Samagotchi
|
|
|
1455
1455
|
body = { session_id: id, sources: rows, can_add_url: ContextProviders.any? }
|
|
1456
1456
|
# The candidates too, for chi context ls (no UI shows them yet).
|
|
1457
1457
|
if full
|
|
1458
|
-
body[:candidates] = ContextCandidates.with_status(id, project_root: session.project_root,
|
|
1458
|
+
body[:candidates] = ContextCandidates.with_status(id, project_root: session.project_root, project_id: session.project_id,
|
|
1459
|
+
state_dir: default_state_dir)
|
|
1459
1460
|
.map { |candidate, status| candidate.to_h.merge("name" => candidate.name, "status" => status) }
|
|
1460
1461
|
end
|
|
1461
1462
|
json_response(200, body)
|
|
@@ -1573,8 +1574,8 @@ module Samagotchi
|
|
|
1573
1574
|
end
|
|
1574
1575
|
|
|
1575
1576
|
def attached_context(session, shadowed: false)
|
|
1576
|
-
ContextSources.attached(session.id, project_root: session.project_root,
|
|
1577
|
-
shadowed: shadowed)
|
|
1577
|
+
ContextSources.attached(session.id, project_root: session.project_root, project_id: session.project_id,
|
|
1578
|
+
state_dir: default_state_dir, shadowed: shadowed)
|
|
1578
1579
|
end
|
|
1579
1580
|
|
|
1580
1581
|
# @return [ContextSources::Attached, nil]
|
|
@@ -128,10 +128,21 @@ export function chainLinks(list, id, nexts = nextsOf(list), byId = new Map(list.
|
|
|
128
128
|
return links;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// Whether the chain's oldest link the page holds still names a link it does
|
|
132
|
+
// not: a schedule's prune (docs/scheduling.md, keep_links) or a delete. The
|
|
133
|
+
// walk stops there, so every count comes from the surviving root, and the page
|
|
134
|
+
// says "earlier links pruned" rather than linking to a session that is gone.
|
|
135
|
+
export function linksPruned(links, byId) {
|
|
136
|
+
const root = links[0];
|
|
137
|
+
return !!root?.continues && !byId.has(root.continues);
|
|
138
|
+
}
|
|
139
|
+
|
|
131
140
|
// The session page's neighbours of +id+: {prev, next}, each {id, session}
|
|
132
|
-
// or null
|
|
133
|
-
//
|
|
134
|
-
//
|
|
141
|
+
// or null, and +pruned+: the session's own `continues` names a link the list
|
|
142
|
+
// does not hold (a schedule pruned it, or it was deleted). +continues+: the
|
|
143
|
+
// open session's own `continues`, when the list may not have caught up with it.
|
|
144
|
+
// A previous link the list doesn't hold is null: its link would open a
|
|
145
|
+
// not-found page.
|
|
135
146
|
export function chainNeighbours(list, id, { continues = null } = {}) {
|
|
136
147
|
const self = list.find((s) => s.id === id);
|
|
137
148
|
const prevId = self?.continues || continues || null;
|
|
@@ -139,7 +150,7 @@ export function chainNeighbours(list, id, { continues = null } = {}) {
|
|
|
139
150
|
const prev = prevSession ? { id: prevId, session: prevSession } : null;
|
|
140
151
|
const nextSession = nextsOf(list).get(id) || null;
|
|
141
152
|
const next = nextSession ? { id: nextSession.id, session: nextSession } : null;
|
|
142
|
-
return { prev, next };
|
|
153
|
+
return { prev, next, pruned: !!prevId && !prevSession };
|
|
143
154
|
}
|
|
144
155
|
|
|
145
156
|
const pad = (n) => String(n).padStart(2, "0");
|
|
@@ -188,14 +199,22 @@ export function foldChains(fams, all, { fold = true } = {}) {
|
|
|
188
199
|
let changed = false;
|
|
189
200
|
for (const f of fams) {
|
|
190
201
|
const links = chainLinks(all, f.head.id, nexts, byId);
|
|
202
|
+
const pruned = linksPruned(links, byId);
|
|
191
203
|
if (links.length < 2) {
|
|
192
|
-
|
|
204
|
+
// A chain pruned to one link is still a chain: its card carries the chip
|
|
205
|
+
// and the popover says what is behind it.
|
|
206
|
+
if (pruned) {
|
|
207
|
+
changed = true;
|
|
208
|
+
out.push({ ...f, chain: { links, at: 0, folded: fold, pruned } });
|
|
209
|
+
} else {
|
|
210
|
+
out.push(f);
|
|
211
|
+
}
|
|
193
212
|
continue;
|
|
194
213
|
}
|
|
195
214
|
changed = true;
|
|
196
215
|
const at = links.findIndex((s) => s.id === f.head.id);
|
|
197
216
|
if (fold && links.slice(at + 1).some((s) => heads.has(s.id))) continue;
|
|
198
|
-
out.push({ ...f, chain: { links, at, folded: fold } });
|
|
217
|
+
out.push({ ...f, chain: { links, at, folded: fold, pruned } });
|
|
199
218
|
}
|
|
200
219
|
return changed ? out : fams;
|
|
201
220
|
}
|
|
@@ -233,7 +252,8 @@ export function chainChipTemplate(f, { open = false } = {}) {
|
|
|
233
252
|
const text = n === links.length ? `#${n}` : `#${n} of ${links.length}`;
|
|
234
253
|
const earlier = at;
|
|
235
254
|
const later = links.length - 1 - at;
|
|
236
|
-
const parts = [earlier ? `${earlier} earlier ${earlier === 1 ? "link" : "links"}` : "", later ? `${later} later` : ""
|
|
255
|
+
const parts = [earlier ? `${earlier} earlier ${earlier === 1 ? "link" : "links"}` : "", later ? `${later} later` : "",
|
|
256
|
+
f.chain.pruned ? "earlier links pruned" : ""].filter(Boolean);
|
|
237
257
|
const title = `Session chain: link ${n} of ${links.length} (${parts.join(", ")}); open the list`;
|
|
238
258
|
const liveT = live ? html`<span class="cc-live"> · <span class="live-dot" aria-hidden="true"></span>${live} live</span>` : nothing;
|
|
239
259
|
const waitingT = waiting ? html` · <span class="cc-waiting">${waiting} waiting</span>` : nothing;
|
|
@@ -275,7 +295,11 @@ export function chainRowsTemplate(f, { activeId = null, now = Date.now() } = {})
|
|
|
275
295
|
date ? html`<span class=${date.length > 10 ? "cr-date timed" : "cr-date"}>${date}</span>` : nothing}<span class="fr-preview">${rowTitle(s, headTitle, byId)}</span>${
|
|
276
296
|
rowBadgesTemplate(waiting, stopped, s.status)}${s.archived ? archivedBadge : nothing}</button></div>`;
|
|
277
297
|
});
|
|
278
|
-
|
|
298
|
+
const prunedRow = f?.chain?.pruned
|
|
299
|
+
? html`<div class="chain-row chain-pruned" role="listitem"><span class="cr-n" aria-hidden="true">↩</span>
|
|
300
|
+
<span class="fr-preview">earlier links pruned</span></div>`
|
|
301
|
+
: nothing;
|
|
302
|
+
return html`<div class="family-rows chain-rows" role="list">${rows}${prunedRow}</div>`;
|
|
279
303
|
}
|
|
280
304
|
|
|
281
305
|
// What the page does with a refused continue (POST /api/sessions
|
|
@@ -1384,7 +1384,7 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
|
|
|
1384
1384
|
/* One run: the groups' chips are the meta's. */
|
|
1385
1385
|
#infoBar .meta .bar-group{display:contents}
|
|
1386
1386
|
#infoBar .meta .bar-sep{display:none}
|
|
1387
|
-
#infoBar .meta :is(.tools-chip,.notes-chip,.mem-chip,.ws-chip,.chain-link,.parent,.children-chip,.participants-chip,.dir,.ctx,.speed,.session-time,.worker-badge){display:none}
|
|
1387
|
+
#infoBar .meta :is(.tools-chip,.notes-chip,.mem-chip,.ws-chip,.chain-link,.chain-pruned,.parent,.children-chip,.participants-chip,.dir,.ctx,.speed,.session-time,.worker-badge){display:none}
|
|
1388
1388
|
#infoBar .info-actions{display:none}
|
|
1389
1389
|
#infoBar .meta .status+.model{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
1390
1390
|
/* ⚙ alone, the start page's dot when the session set its own (its values in the tooltip and accessible name). */
|
|
@@ -1577,6 +1577,9 @@ body.no-glass :is(#familyPop,#chainPop){background:var(--bg-panel);-webkit-backd
|
|
|
1577
1577
|
.chain-row .cr-date{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);white-space:nowrap;font-variant-numeric:tabular-nums}
|
|
1578
1578
|
/* A row with its time (chainStamp): every row's date as wide, so the previews line up. */
|
|
1579
1579
|
.chain-rows:has(.cr-date.timed) .cr-date{min-width:16ch}
|
|
1580
|
+
/* A chain pruned by a schedule (docs/scheduling.md): the popover's last line and the info bar say the earlier links are gone. */
|
|
1581
|
+
.chain-row.chain-pruned,.chain-row.chain-pruned .cr-n{color:var(--text-muted)}
|
|
1582
|
+
#infoBar .meta .chain-pruned{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);white-space:nowrap}
|
|
1580
1583
|
/* The session page's links to the previous and the next link of its chain. */
|
|
1581
1584
|
#infoBar .meta a.chain-link{text-decoration:none;font-family:ui-monospace,monospace;white-space:nowrap;color:var(--accent)}
|
|
1582
1585
|
@media(hover:hover){#infoBar .meta a.chain-link:hover{background:var(--accent-bg)}}
|
|
@@ -98,10 +98,11 @@ export function infoBarHtml(bar) {
|
|
|
98
98
|
if (!bar) return nothing;
|
|
99
99
|
const memories = bar.memories || [];
|
|
100
100
|
const setup = html`<button type="button" class="id" title=${`Session id ${bar.id}: click to copy it`}>${String(bar.id).slice(0, 8)}</button><span class=${bar.status ? `status ${bar.status}` : "status"}><span>${bar.status || ""}</span></span><span class=${`model${bar.model.mismatch ? " served-mismatch" : ""}`} title=${bar.model.title}>${bar.model.text}</span>${settingsChip(bar.settings)}${toolkitChip(bar.toolkit)}${bar.notes ? html`<span class="model notes-chip" title=${bar.notes.title}>${bar.notes.text}</span>` : nothing}${bar.workingSet ? workingSetChipTemplate(bar.workingSet.chip) : memories.length ? html`<span class="model mem-chip" title=${memChipTitle(memories)}>${memChipText(memories)}</span>` : nothing}`;
|
|
101
|
-
const related = !!(bar.chain?.prev || bar.chain?.next || bar.parent || bar.kids || bar.participants);
|
|
102
|
-
//
|
|
101
|
+
const related = !!(bar.chain?.prev || bar.chain?.next || bar.chain?.pruned || bar.parent || bar.kids || bar.participants);
|
|
102
|
+
// A chain pruned by a schedule (docs/scheduling.md): the link before this one
|
|
103
|
+
// is gone, so there is nothing to link to — the bar says so instead.
|
|
103
104
|
const relations = related
|
|
104
|
-
? html`<span class="bar-sep" aria-hidden="true"></span><span class="bar-group relations">${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>↑ ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${participantsChip(bar.participants)}</span>`
|
|
105
|
+
? html`<span class="bar-sep" aria-hidden="true"></span><span class="bar-group relations">${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.pruned && !bar.chain?.prev ? html`<span class="model chain-pruned" title="The links before this one were pruned by a schedule">↩ earlier links pruned</span>` : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>↑ ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${participantsChip(bar.participants)}</span>`
|
|
105
106
|
: nothing;
|
|
106
107
|
const telemetry = html`<span class="dir" title=${bar.dir || ""}><bdi>${bar.dir || ""}</bdi></span><span class="model ctx" title=${bar.ctx.tip}>${bar.ctx.text}</span>${bar.speed ? html`<span class=${`model speed${bar.speed.empty ? " empty" : ""}`} title=${bar.speed.tip}>${bar.speed.text}</span>` : nothing}<span class=${`model session-time${bar.clock ? "" : " empty"}`} title=${bar.clock ? "This session's time: its turns, and the running one" : "This session's time: no turn yet"}>${bar.clock || CLOCK_NONE}</span>${workerBadgeTemplate(bar.worker)}`;
|
|
107
108
|
return html`<div class="meta"><span class="bar-group setup">${setup}</span>${relations}<span class="bar-sep" aria-hidden="true"></span><span class="bar-group telemetry">${telemetry}</span></div>`;
|
|
@@ -133,6 +133,9 @@ const CONTEXT_CLIENT = "context:";
|
|
|
133
133
|
// A turn chi ran because a background task the session started finished
|
|
134
134
|
// unseen: task:<task id> (the Worker's finish wake).
|
|
135
135
|
const TASK_CLIENT = "task:";
|
|
136
|
+
// A durable schedule's firing (chi schedule, the schedule tools):
|
|
137
|
+
// system:schedule:<schedule id>.
|
|
138
|
+
const SCHEDULE_CLIENT = "system:schedule:";
|
|
136
139
|
|
|
137
140
|
// A task id as people read it: the random part a task_create id ends with
|
|
138
141
|
// (it starts with the time), else its first 8 chars (TaskRecord.short_id).
|
|
@@ -149,6 +152,7 @@ export function clientLabel(clientId) {
|
|
|
149
152
|
const id = String(clientId);
|
|
150
153
|
if (id.startsWith(CONTEXT_CLIENT) && id.length > CONTEXT_CLIENT.length) return `context ${id.slice(CONTEXT_CLIENT.length)} changed`;
|
|
151
154
|
if (id.startsWith(TASK_CLIENT) && id.length > TASK_CLIENT.length) return `task ${shortTaskId(id.slice(TASK_CLIENT.length))} finished`;
|
|
155
|
+
if (id.startsWith(SCHEDULE_CLIENT)) return "schedule";
|
|
152
156
|
const colon = id.indexOf(":");
|
|
153
157
|
return CLIENT_LABELS[id] || (colon >= 0 && CLIENT_LABELS[id.slice(0, colon + 1)]) || AUTOMATIC_LABEL;
|
|
154
158
|
}
|
|
@@ -101,7 +101,7 @@ module Samagotchi
|
|
|
101
101
|
brief: true,
|
|
102
102
|
session: { id: session.id, status: session.status, title: session.title,
|
|
103
103
|
display_title: session.display_title, project_root: session.project_root,
|
|
104
|
-
working_directory: session.working_directory, model_name: session.model_name,
|
|
104
|
+
project_id: session.project_id, working_directory: session.working_directory, model_name: session.model_name,
|
|
105
105
|
model_typed: session.model_typed, parent_answers: session.parent_answers,
|
|
106
106
|
owner: owner&.kind },
|
|
107
107
|
pending_question: session.pending_question&.to_file
|
|
@@ -492,8 +492,8 @@ module Samagotchi
|
|
|
492
492
|
# @raise [ArgumentError] no such session
|
|
493
493
|
def context_target(id)
|
|
494
494
|
session = session_access.show(id)
|
|
495
|
-
ContextCommand::Target.new(session_id: id, project_root: session.project_root,
|
|
496
|
-
label: id[0, 8])
|
|
495
|
+
ContextCommand::Target.new(session_id: id, project_root: session.project_root, project_id: session.project_id,
|
|
496
|
+
cwd: session.working_directory, label: id[0, 8])
|
|
497
497
|
end
|
|
498
498
|
end
|
|
499
499
|
end
|
|
@@ -6,6 +6,7 @@ require "time"
|
|
|
6
6
|
|
|
7
7
|
require_relative "../session"
|
|
8
8
|
require_relative "../session_manager"
|
|
9
|
+
require_relative "../schedules"
|
|
9
10
|
require_relative "../bridge_client"
|
|
10
11
|
require_relative "../worker_link"
|
|
11
12
|
require_relative "../config"
|
|
@@ -105,6 +106,7 @@ module Samagotchi
|
|
|
105
106
|
@dir_mtime = nil # the state dir's mtime at the last glob
|
|
106
107
|
@root_cache = {} # folder → project root, for sessions saved before project_root
|
|
107
108
|
@next_full_probe = nil
|
|
109
|
+
@next_schedule_tick = nil
|
|
108
110
|
@wakeup = Queue.new # stop wakes the tick thread out of its sleep
|
|
109
111
|
@thread = nil
|
|
110
112
|
@stopped = false
|
|
@@ -148,6 +150,8 @@ module Samagotchi
|
|
|
148
150
|
check_installed
|
|
149
151
|
end
|
|
150
152
|
end
|
|
153
|
+
# Outside the projection's lock: a firing can wait for a recap.
|
|
154
|
+
schedule_tick
|
|
151
155
|
nil
|
|
152
156
|
end
|
|
153
157
|
|
|
@@ -314,6 +318,22 @@ module Samagotchi
|
|
|
314
318
|
nil
|
|
315
319
|
end
|
|
316
320
|
|
|
321
|
+
# chi web can be the scheduler when the user opts in (schedule.auto_tick):
|
|
322
|
+
# the same tick cron runs, gated by schedule.tick_interval_minutes and
|
|
323
|
+
# sharing schedules.lock, so cron and the hub never both fire.
|
|
324
|
+
def schedule_tick
|
|
325
|
+
return unless Config.get("schedule.auto_tick")
|
|
326
|
+
|
|
327
|
+
now = @now.call
|
|
328
|
+
return if @next_schedule_tick && now < @next_schedule_tick
|
|
329
|
+
|
|
330
|
+
@next_schedule_tick = now + (Samagotchi::Schedules.tick_interval_minutes * 60)
|
|
331
|
+
Samagotchi::Schedules.new(state_dir: @state_dir).tick
|
|
332
|
+
rescue StandardError => e
|
|
333
|
+
Log.warn(:web, "schedule_tick_failed", error: e.class.name, msg: e.message)
|
|
334
|
+
nil
|
|
335
|
+
end
|
|
336
|
+
|
|
317
337
|
# A change (the first look too, unless it finds nothing) is a `chi`
|
|
318
338
|
# event: chi web's terminal line and the open tabs hear it.
|
|
319
339
|
def check_installed
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "fileutils"
|
|
|
4
4
|
require "securerandom"
|
|
5
5
|
|
|
6
6
|
require_relative "client_id"
|
|
7
|
+
require_relative "command_intake"
|
|
7
8
|
require_relative "config"
|
|
8
9
|
require_relative "engine"
|
|
9
10
|
require_relative "guardrails/parent_policy"
|
|
@@ -338,7 +339,7 @@ module Samagotchi
|
|
|
338
339
|
predecessor: @session.continues)
|
|
339
340
|
# Its attached context's notes (ContextSources).
|
|
340
341
|
@context_absorber = ContextAbsorber.new(session_id: @session_id, state_dir: @state_dir,
|
|
341
|
-
project_root: @session.project_root,
|
|
342
|
+
project_root: @session.project_root, project_id: @session.project_id,
|
|
342
343
|
peer_sources: @engine.peer_sources, peer_turns: -> { @wakes.peer_turns })
|
|
343
344
|
# What comes in between turns, and how each kind is saved.
|
|
344
345
|
@inbound = WorkerInbound.new(session: @session, state_dir: @state_dir, session_dir: @session_dir, engine: @engine,
|
|
@@ -374,7 +375,9 @@ module Samagotchi
|
|
|
374
375
|
# chi web's Stop asks about these before TERMed-ing
|
|
375
376
|
# the worker (its own holds routes).
|
|
376
377
|
on_holds: -> { @idle_exit&.stoppable_holds },
|
|
377
|
-
on_holds_stop: -> { @hold_stop&.stop_all }
|
|
378
|
+
on_holds_stop: -> { @hold_stop&.stop_all },
|
|
379
|
+
# `chi schedule tick` asks this before firing.
|
|
380
|
+
on_activity: method(:activity_snapshot))
|
|
378
381
|
# What failed to load and what plugins showed as they loaded, now that
|
|
379
382
|
# a UI can be there (a late one gets them in the snapshot).
|
|
380
383
|
@engine.announce_load_events!
|
|
@@ -410,12 +413,12 @@ module Samagotchi
|
|
|
410
413
|
# there is no live SSE or in-process cancel/answer).
|
|
411
414
|
# @param on_input [#call, nil] called after the Bridge queues a turn
|
|
412
415
|
def start_bridge(engine:, state_dir:, session_id:, on_input: nil, on_command: nil, on_exit_request: nil,
|
|
413
|
-
exit_discards: nil, exit_stopped: nil, on_holds: nil, on_holds_stop: nil)
|
|
416
|
+
exit_discards: nil, exit_stopped: nil, on_holds: nil, on_holds_stop: nil, on_activity: nil)
|
|
414
417
|
require_relative "bridge"
|
|
415
418
|
Samagotchi::Bridge.new(
|
|
416
419
|
engine: engine, state_dir: state_dir, session_id: session_id, input_format: SessionInbox::INPUT_FORMAT,
|
|
417
420
|
on_input: on_input, on_command: on_command, on_exit_request: on_exit_request, exit_discards: exit_discards,
|
|
418
|
-
exit_stopped: exit_stopped, on_holds: on_holds, on_holds_stop: on_holds_stop
|
|
421
|
+
exit_stopped: exit_stopped, on_holds: on_holds, on_holds_stop: on_holds_stop, on_activity: on_activity
|
|
419
422
|
).start
|
|
420
423
|
rescue StandardError => e
|
|
421
424
|
Log.error(:bridge, "start_failed", echo: "Bridge: failed to start for session #{session_id}: #{e.class}: #{e.message}", sid: session_id, error: e.class.name)
|
|
@@ -426,6 +429,22 @@ module Samagotchi
|
|
|
426
429
|
Degrade.call(:config, "idle_exit_minutes_unreadable") { Samagotchi::Config.get("session.idle_exit_minutes") }
|
|
427
430
|
end
|
|
428
431
|
|
|
432
|
+
# What this worker is busy with right now, for GET /activity: the same
|
|
433
|
+
# signals WorkerIdleExit#hold reads. A schedule never holds a worker up,
|
|
434
|
+
# so +reminders+ and +tasks_holding+ are reported for the log but are not a
|
|
435
|
+
# reason for a tick to skip a firing.
|
|
436
|
+
# @return [Hash]
|
|
437
|
+
def activity_snapshot
|
|
438
|
+
{
|
|
439
|
+
turn_running: @engine.turn_running?,
|
|
440
|
+
input_pending: input_pending?,
|
|
441
|
+
awaiting_continue: @turn_flow.awaiting_continue?,
|
|
442
|
+
open_streams: @bridge&.open_streams.to_i,
|
|
443
|
+
reminders: @engine.reminder_store&.any? ? 1 : 0,
|
|
444
|
+
tasks_holding: Degrade.call(:worker, "activity_tasks_unreadable") { @task_wakes&.watch&.holding&.size.to_i }
|
|
445
|
+
}
|
|
446
|
+
end
|
|
447
|
+
|
|
429
448
|
# The delegate children of this session that are running a turn or have
|
|
430
449
|
# a live worker. ChildrenStatus.of scans the session files; this runs on
|
|
431
450
|
# an exit request, not every pass.
|
|
@@ -450,8 +469,8 @@ module Samagotchi
|
|
|
450
469
|
# Attached context's commands, in the background; a new text wakes
|
|
451
470
|
# the loop to absorb it. Not activity: the idle exit stops it.
|
|
452
471
|
@context_poller = ContextPoller.new(session_id: @session_id, state_dir: @state_dir,
|
|
453
|
-
project_root: @session.project_root,
|
|
454
|
-
on_change: -> { @waker.wake }).start
|
|
472
|
+
project_root: @session.project_root, project_id: @session.project_id,
|
|
473
|
+
cwd: @session.working_directory, on_change: -> { @waker.wake }).start
|
|
455
474
|
@scratch_watch = start_scratch_watch if @session.scratch
|
|
456
475
|
end
|
|
457
476
|
|
|
@@ -741,14 +760,15 @@ module Samagotchi
|
|
|
741
760
|
end
|
|
742
761
|
|
|
743
762
|
# The first prompt (spawn_session's): a delegate child's task, which is
|
|
744
|
-
# its parent's
|
|
745
|
-
#
|
|
763
|
+
# its parent's. Its turn carries the id spawn_session gave it, so a
|
|
764
|
+
# wait for it (--new --wait) names it, and its sender
|
|
765
|
+
# (WorkerInbound#initial_origin).
|
|
746
766
|
# A Verify fresh child's is a Verify turn on its claim (its verdict is
|
|
747
767
|
# recorded and forwarded to the parent).
|
|
748
768
|
def run_initial_prompt(prompt)
|
|
749
769
|
@initial_turn = true
|
|
750
770
|
fresh = @session.verify_fresh
|
|
751
|
-
run_prompt(WorkerInbound::Prompt.new(text: prompt, origin:
|
|
771
|
+
run_prompt(WorkerInbound::Prompt.new(text: prompt, origin: @inbound.initial_origin,
|
|
752
772
|
anchors: { verify: fresh&.anchor }))
|
|
753
773
|
ensure
|
|
754
774
|
@initial_turn = false
|
|
@@ -1033,9 +1053,9 @@ module Samagotchi
|
|
|
1033
1053
|
kept: "Its note stays for the user's next message.", mark: task_wake)
|
|
1034
1054
|
end
|
|
1035
1055
|
|
|
1036
|
-
# The idle loop's sleep: the fallback tick, or less while reports
|
|
1037
|
-
# out the start grace.
|
|
1038
|
-
def idle_wait = @wakes.idle_wait(@poll_interval) { wake_state == :due }
|
|
1056
|
+
# The idle loop's sleep: the fallback tick, or less while reports or
|
|
1057
|
+
# attached-context wakes wait out the start grace.
|
|
1058
|
+
def idle_wait = @wakes.idle_wait(@poll_interval) { wake_state == :due || @inbound.context_wake_waiting? }
|
|
1039
1059
|
|
|
1040
1060
|
# The wake budget is spent: the parent's UIs hear once that reports
|
|
1041
1061
|
# wait for the next message.
|
|
@@ -1197,6 +1217,8 @@ module Samagotchi
|
|
|
1197
1217
|
# the cards and notices it showed, held: they follow its command_ran,
|
|
1198
1218
|
# as its output
|
|
1199
1219
|
def run_command_line(command)
|
|
1220
|
+
return [shell_refused(command), []] if CommandIntake.refused?(command.line, command.client_id)
|
|
1221
|
+
|
|
1200
1222
|
@engine.holding_announcements do
|
|
1201
1223
|
command_result(command) do
|
|
1202
1224
|
@commands.run(command.line, confirm: confirm?(command), as_parent: as_parent?(command), user_ui: user_ui?(command))
|
|
@@ -1204,6 +1226,14 @@ module Samagotchi
|
|
|
1204
1226
|
end
|
|
1205
1227
|
end
|
|
1206
1228
|
|
|
1229
|
+
# A shell line from a sender that may not run one (CommandIntake: a
|
|
1230
|
+
# delegate's task, a schedule's prompt, a plugin's message): not run,
|
|
1231
|
+
# its command_ran an error with the one refusal text.
|
|
1232
|
+
def shell_refused(command)
|
|
1233
|
+
Log.warn(:worker, "shell_line_refused", client_id: command.client_id)
|
|
1234
|
+
SessionCommands::Result.new(status: :error, output: CommandIntake::REFUSED, changed: [])
|
|
1235
|
+
end
|
|
1236
|
+
|
|
1207
1237
|
# Whether +command+ asks before a costly /thinking or /reasoning-history
|
|
1208
1238
|
# change (CacheConfirm): only a person at a web tab or a TUI sees the
|
|
1209
1239
|
# question; chi send, a delegate, a plugin and a card's action apply it.
|