samagotchi 0.159.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 +125 -1
- data/docs/architecture.md +12 -9
- data/docs/cli.md +7 -5
- data/docs/context.md +10 -1
- data/docs/guardrails.md +33 -5
- data/docs/internals/attached-context.md +4 -2
- data/docs/internals/bridge.md +28 -6
- data/docs/internals/bundles.md +24 -5
- data/docs/internals/engine.md +7 -1
- data/docs/internals/objectives.md +8 -2
- data/docs/internals/remote-sessions.md +2 -1
- data/docs/memory.md +43 -13
- data/docs/plugins.md +13 -4
- data/docs/releasing.md +7 -4
- data/docs/scheduling.md +53 -8
- data/docs/sessions.md +17 -12
- 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 +103 -44
- data/lib/samagotchi/bridge_client.rb +9 -6
- data/lib/samagotchi/broadcast/scope_card.rb +2 -1
- data/lib/samagotchi/broadcast_command.rb +24 -8
- data/lib/samagotchi/bundle_command.rb +57 -6
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
- data/lib/samagotchi/child_reports.rb +29 -23
- data/lib/samagotchi/client_id.rb +6 -0
- data/lib/samagotchi/command_intake.rb +56 -0
- data/lib/samagotchi/compat.rb +32 -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_candidates_log.rb +10 -1
- data/lib/samagotchi/context_command.rb +10 -4
- data/lib/samagotchi/context_move.rb +6 -1
- data/lib/samagotchi/context_poller.rb +5 -2
- data/lib/samagotchi/context_sources.rb +6 -4
- data/lib/samagotchi/engine/events.rb +25 -2
- data/lib/samagotchi/engine/guardrails.rb +3 -4
- data/lib/samagotchi/engine/plugins.rb +1 -1
- data/lib/samagotchi/engine/steering.rb +2 -2
- 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/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/hook_routes.rb +0 -1
- data/lib/samagotchi/live_versions.rb +7 -2
- data/lib/samagotchi/memory_bundle/asset_installer.rb +86 -18
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
- data/lib/samagotchi/memory_bundle/file_actions.rb +3 -4
- 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/installed_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installer.rb +93 -23
- data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
- data/lib/samagotchi/memory_bundle/shipped_update.rb +7 -4
- data/lib/samagotchi/memory_bundle/status.rb +3 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +29 -13
- data/lib/samagotchi/memory_orphans.rb +54 -13
- data/lib/samagotchi/memory_paths.rb +29 -6
- data/lib/samagotchi/memory_usage.rb +23 -12
- 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/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/review_start.rb +5 -3
- data/lib/samagotchi/schedule_command.rb +12 -0
- data/lib/samagotchi/schedules.rb +374 -76
- data/lib/samagotchi/send_command.rb +2 -15
- data/lib/samagotchi/session.rb +59 -7
- data/lib/samagotchi/session_access/local.rb +53 -22
- data/lib/samagotchi/session_access/remote.rb +13 -5
- data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
- data/lib/samagotchi/session_access.rb +20 -4
- data/lib/samagotchi/session_commands.rb +7 -3
- data/lib/samagotchi/session_continue.rb +162 -24
- 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 +167 -100
- data/lib/samagotchi/session_manager/worker_command.rb +7 -19
- 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 +129 -0
- 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/context_read.rb +7 -2
- data/lib/samagotchi/tools/delegate.rb +19 -6
- data/lib/samagotchi/tools/schedule_task.rb +5 -3
- data/lib/samagotchi/tools/send_note.rb +10 -4
- data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
- data/lib/samagotchi/turn_state.rb +108 -4
- data/lib/samagotchi/update_command.rb +9 -4
- data/lib/samagotchi/verify_fresh.rb +27 -9
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +6 -5
- 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 +15 -11
- 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 +61 -11
- data/lib/samagotchi/worker_boot.rb +22 -11
- data/lib/samagotchi/worker_inbound.rb +62 -10
- 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 +9 -1
|
@@ -26,13 +26,19 @@ module Samagotchi
|
|
|
26
26
|
Session.load(id, state_dir: peers.state_dir)
|
|
27
27
|
return "Error: #{id[0, 8]} is this session; send_note is for other sessions" if id == peers.session_id
|
|
28
28
|
|
|
29
|
+
# A link continued into this one: the note would come back here.
|
|
30
|
+
head = SessionInbox::Head.resolve(:note, id, state_dir: peers.state_dir)
|
|
31
|
+
return "Error: #{id[0, 8]} was continued into this session; send_note is for other sessions" if head.id == peers.session_id
|
|
32
|
+
|
|
29
33
|
result = NoteDelivery.deliver(id, text: content, source: "session", from_session: peers.session_id,
|
|
30
|
-
from_cwd: peers.cwd, state_dir: peers.state_dir)
|
|
31
|
-
|
|
32
|
-
return "Error: session #{
|
|
34
|
+
from_cwd: peers.cwd, state_dir: peers.state_dir, resolved: head)
|
|
35
|
+
target = result.id[0, 8]
|
|
36
|
+
return "Error: session #{target}: #{result.refusal}" if result.scratch
|
|
37
|
+
return "Error: session #{target} is #{OwnerLock::TUI_BUSY}, which can't take notes" unless result.delivered?
|
|
33
38
|
|
|
34
39
|
where = result.status == :queued ? "its worker adds it before its next turn" : "it has no worker now, so it waits for its next start"
|
|
35
|
-
|
|
40
|
+
via = result.continued? ? " (#{id[0, 8]} was continued: #{target} is its chain's newest link)" : ""
|
|
41
|
+
"Queued a note for session #{target}#{via}: #{where}. It does not start a turn there."
|
|
36
42
|
rescue Session::NotFound => e
|
|
37
43
|
"Error: #{e.message} (list_sessions shows them)"
|
|
38
44
|
rescue ArgumentError => e # AmbiguousId, SessionInbox::NoteRejected, a file that won't load
|
|
@@ -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])
|
|
@@ -15,6 +15,8 @@ module Samagotchi
|
|
|
15
15
|
# so any lock may be held around a call into it, and it is never held
|
|
16
16
|
# around anything else.
|
|
17
17
|
class TurnState
|
|
18
|
+
ENQUEUED_ID_HISTORY = 256
|
|
19
|
+
|
|
18
20
|
# @param clock [#call] monotonic seconds
|
|
19
21
|
def initialize(clock:)
|
|
20
22
|
@clock = clock
|
|
@@ -32,6 +34,80 @@ module Samagotchi
|
|
|
32
34
|
@last_context = nil
|
|
33
35
|
@steers = []
|
|
34
36
|
@carried_steers = []
|
|
37
|
+
@enqueued_ids = {}
|
|
38
|
+
@enqueued_order = []
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Reserve an id for a Bridge-enqueued input. False means this id was
|
|
42
|
+
# already accepted in this worker.
|
|
43
|
+
def reserve_enqueued_id(id)
|
|
44
|
+
id = id.to_s
|
|
45
|
+
return false if id.empty?
|
|
46
|
+
|
|
47
|
+
@lock.synchronize do
|
|
48
|
+
next false if @enqueued_ids.key?(id)
|
|
49
|
+
|
|
50
|
+
remember_enqueued_id(id, :queued)
|
|
51
|
+
true
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Claim an input file. A Bridge reservation permits its first file claim;
|
|
56
|
+
# a duplicate file with the same id is refused. Unreserved files are
|
|
57
|
+
# accepted once too.
|
|
58
|
+
def claim_enqueued_id(id)
|
|
59
|
+
id = id.to_s
|
|
60
|
+
return true if id.empty?
|
|
61
|
+
|
|
62
|
+
@lock.synchronize do
|
|
63
|
+
case @enqueued_ids[id]
|
|
64
|
+
when :queued
|
|
65
|
+
@enqueued_ids[id] = :running
|
|
66
|
+
true
|
|
67
|
+
when nil
|
|
68
|
+
remember_enqueued_id(id, :running)
|
|
69
|
+
true
|
|
70
|
+
else
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def finish_enqueued_id(id)
|
|
77
|
+
id = id.to_s
|
|
78
|
+
return if id.empty?
|
|
79
|
+
|
|
80
|
+
@lock.synchronize do
|
|
81
|
+
@enqueued_ids[id] = :ended if @enqueued_ids[id] == :running
|
|
82
|
+
prune_enqueued_ids
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def forget_enqueued_id(id)
|
|
87
|
+
id = id.to_s
|
|
88
|
+
return if id.empty?
|
|
89
|
+
|
|
90
|
+
@lock.synchronize do
|
|
91
|
+
next unless @enqueued_ids[id] == :queued
|
|
92
|
+
|
|
93
|
+
@enqueued_ids.delete(id)
|
|
94
|
+
@enqueued_order.delete(id)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def drop_enqueued_id(id)
|
|
99
|
+
id = id.to_s
|
|
100
|
+
return if id.empty?
|
|
101
|
+
|
|
102
|
+
@lock.synchronize do
|
|
103
|
+
@enqueued_ids[id] = :ended if @enqueued_ids[id] == :queued
|
|
104
|
+
prune_enqueued_ids
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def enqueued_id?(id)
|
|
109
|
+
id = id.to_s
|
|
110
|
+
!id.empty? && @lock.synchronize { @enqueued_ids.key?(id) }
|
|
35
111
|
end
|
|
36
112
|
|
|
37
113
|
# ── Activity clock ──────────────────────────────────────────────────
|
|
@@ -179,11 +255,22 @@ module Samagotchi
|
|
|
179
255
|
@lock.synchronize { !@input_waiting_warned.tap { @input_waiting_warned = true } }
|
|
180
256
|
end
|
|
181
257
|
|
|
182
|
-
# Cancel the running turn, if any (
|
|
183
|
-
#
|
|
184
|
-
|
|
185
|
-
|
|
258
|
+
# Cancel the running turn, if any (the controller is taken, and the turn
|
|
259
|
+
# id compared, under the lock; it is cancelled outside it).
|
|
260
|
+
# @param turn_id [String, nil] the turn the caller means: another turn
|
|
261
|
+
# running (a Stop that arrived as its turn ended) is left alone; nil
|
|
262
|
+
# cancels whichever runs
|
|
263
|
+
# @return [Boolean, Symbol] whether a cancellation was triggered, or
|
|
264
|
+
# :turn_mismatch when +turn_id+ is not the running turn's
|
|
265
|
+
def cancel!(reason, turn_id: nil)
|
|
266
|
+
ctrl = @lock.synchronize do
|
|
267
|
+
next nil unless @controller
|
|
268
|
+
next :mismatch if turn_id && @context&.id != turn_id
|
|
269
|
+
|
|
270
|
+
@controller
|
|
271
|
+
end
|
|
186
272
|
return false unless ctrl
|
|
273
|
+
return :turn_mismatch if ctrl == :mismatch
|
|
187
274
|
|
|
188
275
|
ctrl.cancel!(reason)
|
|
189
276
|
end
|
|
@@ -238,5 +325,22 @@ module Samagotchi
|
|
|
238
325
|
def take_steers
|
|
239
326
|
@lock.synchronize { @steers.tap { @steers = [] } }
|
|
240
327
|
end
|
|
328
|
+
|
|
329
|
+
private
|
|
330
|
+
|
|
331
|
+
def remember_enqueued_id(id, status)
|
|
332
|
+
@enqueued_ids[id] = status
|
|
333
|
+
@enqueued_order << id
|
|
334
|
+
prune_enqueued_ids
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def prune_enqueued_ids
|
|
338
|
+
while @enqueued_ids.size > ENQUEUED_ID_HISTORY
|
|
339
|
+
index = @enqueued_order.index { |id| @enqueued_ids[id] == :ended }
|
|
340
|
+
break unless index
|
|
341
|
+
|
|
342
|
+
@enqueued_ids.delete(@enqueued_order.delete_at(index))
|
|
343
|
+
end
|
|
344
|
+
end
|
|
241
345
|
end
|
|
242
346
|
end
|
|
@@ -310,7 +310,9 @@ module Samagotchi
|
|
|
310
310
|
when :skipped then skipped(row, result.error)
|
|
311
311
|
else failed(row, result.error)
|
|
312
312
|
end
|
|
313
|
-
system.clear_merge_notes(earlier, files: result.merged)
|
|
313
|
+
if read_by_someone? && (error = system.clear_merge_notes(earlier, files: result.merged)).is_a?(String)
|
|
314
|
+
row.status = [row.status, error].compact.join("; ")
|
|
315
|
+
end
|
|
314
316
|
row
|
|
315
317
|
end
|
|
316
318
|
|
|
@@ -356,14 +358,17 @@ module Samagotchi
|
|
|
356
358
|
notes = file_notes(row, update.name, update.to || update.from, update.merged, update.behind, update.trash_copy, earlier: earlier)
|
|
357
359
|
case update.status
|
|
358
360
|
when :would_update, :updated
|
|
359
|
-
notes = [adds(update.adds), *notes, replaced(update.replaced), *update.kept_own, update.note].compact
|
|
361
|
+
notes = [adds(update.added_files), adds(update.adds), *notes, replaced(update.replaced), *update.kept_own, update.note].compact
|
|
360
362
|
done(row, :update, notes.empty? ? nil : notes.join("; "))
|
|
361
363
|
when :would_merge, :merged, :up_to_date then same_version(row, [*notes, update.note, update.manifest].compact, changed: Array(update.merged).any?)
|
|
362
364
|
when :skipped then skipped(row, [update.note, *notes].join("; "))
|
|
363
365
|
else failed(row, [update.note, *notes].compact.join("; "))
|
|
364
366
|
end
|
|
365
367
|
# A failed row keeps them: the run didn't finish, and the next says what stands.
|
|
366
|
-
|
|
368
|
+
if read_by_someone? && update.status != :failed
|
|
369
|
+
error = MemoryBundle::Provenance.new(name: update.name).clear_merge_notes(earlier, files: row.merged)
|
|
370
|
+
row.status = [row.status, error].compact.join("; ") if error.is_a?(String)
|
|
371
|
+
end
|
|
367
372
|
row
|
|
368
373
|
end
|
|
369
374
|
|
|
@@ -522,7 +527,7 @@ module Samagotchi
|
|
|
522
527
|
row
|
|
523
528
|
end
|
|
524
529
|
|
|
525
|
-
#
|
|
530
|
+
# New memory files or profile members: "+ file".
|
|
526
531
|
def adds(members)
|
|
527
532
|
members.nil? || members.empty? ? nil : "+ #{members.join(", ")}"
|
|
528
533
|
end
|
|
@@ -6,6 +6,7 @@ require "time"
|
|
|
6
6
|
require_relative "atomic_file"
|
|
7
7
|
require_relative "log"
|
|
8
8
|
require_relative "session"
|
|
9
|
+
require_relative "session_inbox"
|
|
9
10
|
require_relative "session_metrics"
|
|
10
11
|
require_relative "session_row"
|
|
11
12
|
require_relative "worker_link"
|
|
@@ -86,8 +87,10 @@ module Samagotchi
|
|
|
86
87
|
refuse_busy!(parent.id, of, author, state_dir)
|
|
87
88
|
|
|
88
89
|
child = spawn.call(prompt: prompt_for(anchor.quote), working_directory: parent.working_directory,
|
|
89
|
-
model_name: parent.model_name,
|
|
90
|
-
|
|
90
|
+
model_name: parent.model_name,
|
|
91
|
+
setup: SessionSetup.new(preloaded_memory_names: [Session::DELEGATE_MEMORY]),
|
|
92
|
+
parent_id: parent.id, delegate: true, verify_fresh: origin, state_dir: state_dir,
|
|
93
|
+
client_id: ClientId.delegate(parent.id))
|
|
91
94
|
append_ledger(dir, { "child_id" => child.id, "of" => of, "author" => author, "at" => Time.now.utc.iso8601(3) })
|
|
92
95
|
child
|
|
93
96
|
end
|
|
@@ -160,28 +163,43 @@ module Samagotchi
|
|
|
160
163
|
|
|
161
164
|
# The child's new verdicts (+records+, Engine's verdict records) into
|
|
162
165
|
# its parent's INBOX, marked fresh: true with the child's id and the
|
|
163
|
-
# verdict they check again (of). Best effort.
|
|
166
|
+
# verdict they check again (of). Best effort. A session inbox delivery
|
|
167
|
+
# (SessionInbox.deliver :fresh_verdict): pinned to the parent the child
|
|
168
|
+
# names (its original verdict's card is there, a continued one's too),
|
|
169
|
+
# never a worker started for it; a parent that is gone gets nothing.
|
|
164
170
|
# @param child [Session] a Verify fresh child
|
|
165
171
|
# @return [Integer] how many were written
|
|
166
172
|
def forward(child, records, state_dir: Session.default_state_dir)
|
|
167
173
|
fresh = child&.verify_fresh
|
|
168
|
-
return 0 unless fresh&.of && child.parent_id
|
|
174
|
+
return 0 unless fresh&.of && child.parent_id
|
|
169
175
|
|
|
170
|
-
|
|
176
|
+
written = 0
|
|
177
|
+
SessionInbox.deliver(:fresh_verdict, child.parent_id, state_dir: state_dir, wake: false) do |at|
|
|
178
|
+
written = write_verdicts(Session.session_dir(at, state_dir: state_dir), child, fresh.of, records)
|
|
179
|
+
false
|
|
180
|
+
end
|
|
181
|
+
written
|
|
182
|
+
rescue StandardError => e
|
|
183
|
+
Log.warn(:worker, "fresh_forward_failed", parent: child&.parent_id.to_s[0, 8], error: e.class.name, msg: e.message)
|
|
184
|
+
0
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# +records+ into +session_dir+'s INBOX, one file per check turn.
|
|
188
|
+
# @return [Integer] how many were written
|
|
189
|
+
def write_verdicts(session_dir, child, of, records)
|
|
190
|
+
dir = File.join(session_dir, INBOX)
|
|
171
191
|
FileUtils.mkdir_p(dir)
|
|
172
192
|
Array(records).count do |record|
|
|
173
193
|
check = field(record, :check_turn_id).to_s
|
|
174
194
|
next false unless Session.valid_id?(check)
|
|
175
195
|
|
|
176
196
|
out = record.to_h { |key, value| [key.to_s, value.is_a?(Hash) ? value.transform_keys(&:to_s) : value] }
|
|
177
|
-
out.merge!("fresh" => true, "of" =>
|
|
197
|
+
out.merge!("fresh" => true, "of" => of, "child_id" => child.id)
|
|
178
198
|
AtomicFile.write(File.join(dir, "#{check}.json"), JSON.generate(out))
|
|
179
199
|
true
|
|
180
200
|
end
|
|
181
|
-
rescue StandardError => e
|
|
182
|
-
Log.warn(:worker, "fresh_forward_failed", parent: child&.parent_id.to_s[0, 8], error: e.class.name, msg: e.message)
|
|
183
|
-
0
|
|
184
201
|
end
|
|
202
|
+
private_class_method :write_verdicts
|
|
185
203
|
|
|
186
204
|
# The verdicts waiting in +session_dir+'s INBOX: [[record, file]],
|
|
187
205
|
# oldest first.
|
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]
|
|
@@ -1895,7 +1896,7 @@ module Samagotchi
|
|
|
1895
1896
|
def session_to_json(s, status: s.status, owner: nil)
|
|
1896
1897
|
SessionSummary.build(s, status: status, owner: owner,
|
|
1897
1898
|
session_dir: @session_class.session_dir(s.id, state_dir: default_state_dir),
|
|
1898
|
-
registry: host_registry)
|
|
1899
|
+
registry: host_registry, compat_version: @hub&.installed || VERSION)
|
|
1899
1900
|
end
|
|
1900
1901
|
|
|
1901
1902
|
def parse_json(str)
|
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
listSessions,
|
|
3
3
|
getSession,
|
|
4
4
|
sendTurn,
|
|
5
|
-
|
|
5
|
+
stopTurn,
|
|
6
6
|
stopSession,
|
|
7
7
|
sessionHolds,
|
|
8
8
|
stopSessionHolds,
|
|
@@ -1675,12 +1675,7 @@ function queueRefused(altKey) {
|
|
|
1675
1675
|
// The stage's Stop turn (its button stays disabled until this settles).
|
|
1676
1676
|
async function handleCancel() {
|
|
1677
1677
|
if (!store.selected) return;
|
|
1678
|
-
|
|
1679
|
-
await cancelTurn(store.selected);
|
|
1680
|
-
} catch (e) {
|
|
1681
|
-
// The turn ended meanwhile: nothing left to cancel.
|
|
1682
|
-
if (e.code !== "not_running") alert(e.message);
|
|
1683
|
-
}
|
|
1678
|
+
await stopTurn(store.selected, liveTurn.timing.turnId, { resync: () => resync(), onError: (e) => alert(e.message) });
|
|
1684
1679
|
}
|
|
1685
1680
|
|
|
1686
1681
|
// The chi logo: back to the empty start, where the composer makes a new
|
|
@@ -138,9 +138,9 @@ export function linksPruned(links, byId) {
|
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
// The session page's neighbours of +id+: {prev, next}, each {id, session}
|
|
141
|
-
// or null, and +pruned+: the session's
|
|
142
|
-
// does not hold (a schedule pruned it, or it was deleted). +continues+: the
|
|
143
|
-
//
|
|
141
|
+
// or null, and +pruned+: the listed session's `continues` names a link the list
|
|
142
|
+
// does not hold (a schedule pruned it, or it was deleted). +continues+: the open
|
|
143
|
+
// session's own `continues`, when the list may not have caught up with it.
|
|
144
144
|
// A previous link the list doesn't hold is null: its link would open a
|
|
145
145
|
// not-found page.
|
|
146
146
|
export function chainNeighbours(list, id, { continues = null } = {}) {
|
|
@@ -150,7 +150,7 @@ export function chainNeighbours(list, id, { continues = null } = {}) {
|
|
|
150
150
|
const prev = prevSession ? { id: prevId, session: prevSession } : null;
|
|
151
151
|
const nextSession = nextsOf(list).get(id) || null;
|
|
152
152
|
const next = nextSession ? { id: nextSession.id, session: nextSession } : null;
|
|
153
|
-
return { prev, next, pruned: !!prevId && !prevSession };
|
|
153
|
+
return { prev, next, pruned: !!self && !!prevId && !prevSession };
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
const pad = (n) => String(n).padStart(2, "0");
|
|
@@ -181,11 +181,15 @@ export function renderText(text, opts = {}) {
|
|
|
181
181
|
});
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
// +turnId+: the turn the Stop is for, when the page knows it; a Stop that
|
|
185
|
+
// arrives as that turn ends is refused (409 turn_mismatch), not applied to
|
|
186
|
+
// the next turn.
|
|
184
187
|
export function cancelTurn(id, opts = {}) {
|
|
188
|
+
const { turnId = null, ...rest } = opts;
|
|
185
189
|
return api(`/api/sessions/${id}/cancel`, {
|
|
186
190
|
method: "POST",
|
|
187
|
-
body: JSON.stringify({ reason: "user" }),
|
|
188
|
-
...
|
|
191
|
+
body: JSON.stringify(turnId ? { reason: "user", turn_id: turnId } : { reason: "user" }),
|
|
192
|
+
...rest,
|
|
189
193
|
});
|
|
190
194
|
}
|
|
191
195
|
|
|
@@ -216,6 +220,18 @@ export function markSystemOneWrong(id, judgmentId, opts = {}) {
|
|
|
216
220
|
});
|
|
217
221
|
}
|
|
218
222
|
|
|
223
|
+
// The stage's Stop: cancelTurn, quiet when the turn ended meanwhile. When
|
|
224
|
+
// another turn began (turn_mismatch) the page's turn id was stale, so it
|
|
225
|
+
// resyncs and the next Stop names the current turn; other errors go to onError.
|
|
226
|
+
export async function stopTurn(id, turnId, { resync, onError, ...opts } = {}) {
|
|
227
|
+
try {
|
|
228
|
+
await cancelTurn(id, { turnId, ...opts });
|
|
229
|
+
} catch (e) {
|
|
230
|
+
if (e.code === "turn_mismatch") resync?.();
|
|
231
|
+
else if (e.code !== "not_running") onError?.(e);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
219
235
|
export function stopSession(id, opts = {}) {
|
|
220
236
|
return api(`/api/sessions/${id}/stop`, { method: "POST", body: JSON.stringify({}), ...opts });
|
|
221
237
|
}
|
|
@@ -19,7 +19,7 @@ export const SPEED_NONE = "– tok/s";
|
|
|
19
19
|
// @param versions {served, installed}: the chi serving the page, the newest
|
|
20
20
|
export function workerBadgeFor(id, state, versions) {
|
|
21
21
|
if (!id || state.owner !== "worker") return null;
|
|
22
|
-
return workerBadge({ workerVersion: state.workerVersion, features: state.workerFeatures,
|
|
22
|
+
return workerBadge({ workerVersion: state.workerVersion, compatibility: state.workerCompat, features: state.workerFeatures,
|
|
23
23
|
installed: versions.installed, served: versions.served, sessionId: id });
|
|
24
24
|
}
|
|
25
25
|
|
|
@@ -88,6 +88,7 @@ export const FIELDS = {
|
|
|
88
88
|
redraws: ["featureGates", "infoBar"],
|
|
89
89
|
writers: "followSelectedSession",
|
|
90
90
|
},
|
|
91
|
+
workerCompat: { card: (sess) => sess.worker_compat || null, redraws: ["infoBar"], writers: "followSelectedSession" },
|
|
91
92
|
bridgeUp: {
|
|
92
93
|
card: (sess) => sess.bridge_up === true,
|
|
93
94
|
redraws: ["featureGates", "infoBar"],
|
|
@@ -114,10 +115,10 @@ export const FIELDS = {
|
|
|
114
115
|
// The fields a hub card brings for the open session (followSelectedSession):
|
|
115
116
|
// its owner and worker. Never its status: that goes through setStatus, so
|
|
116
117
|
// "the hub's status only while not streaming" can't be bypassed.
|
|
117
|
-
const CARD_FIELDS = ["owner", "workerVersion", "workerFeatures", "bridgeUp"];
|
|
118
|
+
const CARD_FIELDS = ["owner", "workerVersion", "workerFeatures", "workerCompat", "bridgeUp"];
|
|
118
119
|
|
|
119
120
|
// @param sess a hub card (the open session's)
|
|
120
|
-
// @return {owner, workerVersion, workerFeatures, bridgeUp}
|
|
121
|
+
// @return {owner, workerVersion, workerFeatures, workerCompat, bridgeUp}
|
|
121
122
|
export function fieldsFromCard(sess) {
|
|
122
123
|
return Object.fromEntries(CARD_FIELDS.map((key) => [key, FIELDS[key].card(sess)]));
|
|
123
124
|
}
|
|
@@ -55,6 +55,7 @@ export function emptySessionState() {
|
|
|
55
55
|
// worker_version / worker_features): the info bar's stale-worker badge.
|
|
56
56
|
workerVersion: null,
|
|
57
57
|
workerFeatures: [],
|
|
58
|
+
workerCompat: null,
|
|
58
59
|
bridgeUp: false,
|
|
59
60
|
// The LLM context strategy, apply rule and budget the next turn runs
|
|
60
61
|
// under, each with its source (the info bar's ⚙ chip), or null.
|
|
@@ -99,6 +100,7 @@ export function sessionStateFrom(data) {
|
|
|
99
100
|
// not at the session's next hub event (followSelectedSession).
|
|
100
101
|
workerVersion: s.worker_version || null,
|
|
101
102
|
workerFeatures: Array.isArray(s.worker_features) ? s.worker_features : [],
|
|
103
|
+
workerCompat: s.worker_compat || null,
|
|
102
104
|
bridgeUp: s.bridge_up === true,
|
|
103
105
|
timing: normalizeTiming(data.timing),
|
|
104
106
|
};
|
|
@@ -117,9 +117,9 @@ export function updateFrameToast(data, { wasOpen = false, snapshot = false } = {
|
|
|
117
117
|
// else null. A worker with the "restart" feature offers Restart; an older
|
|
118
118
|
// one can only be stopped (any message then starts it on the newest chi).
|
|
119
119
|
// @return {{text, title, newest, restart: boolean} | null}
|
|
120
|
-
export function workerBadge({ workerVersion, features, installed, served, sessionId = "" } = {}) {
|
|
120
|
+
export function workerBadge({ workerVersion, compatibility, features, installed, served, sessionId = "" } = {}) {
|
|
121
121
|
const newest = known(installed) ? installed : served;
|
|
122
|
-
if (!
|
|
122
|
+
if (!known(newest) || !known(workerVersion) || compatibility !== "restart_suggested") return null;
|
|
123
123
|
if (Array.isArray(features) && features.includes("restart")) {
|
|
124
124
|
return {
|
|
125
125
|
text: `chi ${workerVersion}`,
|
|
@@ -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
|
|
@@ -432,8 +432,10 @@ module Samagotchi
|
|
|
432
432
|
# POST /api/sessions/:id/notes {text, source}: a context note for the
|
|
433
433
|
# session (NoteDelivery, as chi note; source: where it came from,
|
|
434
434
|
# "cli" by default, never "broadcast"). 200 {status: "queued" (a
|
|
435
|
-
# worker adds it) or "waits" (the next start does), session_id,
|
|
436
|
-
#
|
|
435
|
+
# worker adds it) or "waits" (the next start does), session_id, id
|
|
436
|
+
# (the session that has it: session_id, or its chain's newest link
|
|
437
|
+
# when session_id was continued), queued}; 409 owned_by_tui (status refused: a one-turn chi run has it, or a
|
|
438
|
+
# scratch session: scratch; id as above); 400
|
|
437
439
|
# note_rejected (empty, or over 16 KiB).
|
|
438
440
|
def handle_notes(req, id)
|
|
439
441
|
body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
|
|
@@ -446,12 +448,13 @@ module Samagotchi
|
|
|
446
448
|
session_access.show(id)
|
|
447
449
|
result = session_access.note(id, text: text, source: source.strip)
|
|
448
450
|
if result.status == :refused
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
451
|
+
# Named by the link that refused it (its chain's newest, for a continued one).
|
|
452
|
+
error = result.scratch ? SessionManager::ScratchSession.new(result.id) : SessionManager::OwnedByTUI.new(result.id)
|
|
453
|
+
return refusal_response(409, "owned_by_tui", error, session_id: id, id: result.id, status: "refused",
|
|
454
|
+
queued: 0, scratch: result.scratch || nil)
|
|
452
455
|
end
|
|
453
456
|
|
|
454
|
-
json_response(200, { status: result.status.to_s, session_id: id, queued: result.queued })
|
|
457
|
+
json_response(200, { status: result.status.to_s, session_id: id, id: result.id, queued: result.queued })
|
|
455
458
|
rescue SessionInbox::NoteRejected => e
|
|
456
459
|
error_response(400, "note_rejected", e.message)
|
|
457
460
|
end
|
|
@@ -459,7 +462,8 @@ module Samagotchi
|
|
|
459
462
|
# POST /api/sessions/:id/context/:name/push {text, summary, wake,
|
|
460
463
|
# hint}: new text for the session's --push source (chi context push;
|
|
461
464
|
# the fields as a source's JSON output gives them, checked the same
|
|
462
|
-
# way). 200 {status: "pushed", name, before, revision, changed
|
|
465
|
+
# way). 200 {status: "pushed", name, before, revision, changed, wake:
|
|
466
|
+
# what a push that asked for a wake did, absent when it asked none}; 404
|
|
463
467
|
# context_missing (no such source); 409 not_push (a command source); 410
|
|
464
468
|
# context_continued (the session was continued, as next_id: never 409,
|
|
465
469
|
# which an older Remote reads as not_push, nor "continued", which it
|
|
@@ -481,7 +485,7 @@ module Samagotchi
|
|
|
481
485
|
json_response(410, { error: "context_continued", next_id: pushed.next_id,
|
|
482
486
|
detail: SessionAccess::ContextPush.continued_message(name, id, pushed.next_id) })
|
|
483
487
|
else json_response(200, { status: "pushed", name: name, before: pushed.before, revision: pushed.revision,
|
|
484
|
-
changed: pushed.before != pushed.revision })
|
|
488
|
+
changed: pushed.before != pushed.revision }.merge(pushed.wake ? { wake: pushed.wake } : {}))
|
|
485
489
|
end
|
|
486
490
|
rescue ContextSources::Invalid
|
|
487
491
|
error_response(404, "context_missing", "no source #{name[0, 40]}")
|
|
@@ -492,8 +496,8 @@ module Samagotchi
|
|
|
492
496
|
# @raise [ArgumentError] no such session
|
|
493
497
|
def context_target(id)
|
|
494
498
|
session = session_access.show(id)
|
|
495
|
-
ContextCommand::Target.new(session_id: id, project_root: session.project_root,
|
|
496
|
-
label: id[0, 8])
|
|
499
|
+
ContextCommand::Target.new(session_id: id, project_root: session.project_root, project_id: session.project_id,
|
|
500
|
+
cwd: session.working_directory, label: id[0, 8])
|
|
497
501
|
end
|
|
498
502
|
end
|
|
499
503
|
end
|
|
@@ -301,13 +301,19 @@ module Samagotchi
|
|
|
301
301
|
reason = (parsed["reason"] if parsed.is_a?(Hash)).to_s.strip
|
|
302
302
|
reason = "user" if reason.empty?
|
|
303
303
|
|
|
304
|
-
|
|
304
|
+
turn_id = (parsed["turn_id"] if parsed.is_a?(Hash)).to_s.strip
|
|
305
|
+
request = ->(client) { turn_id.empty? ? client.cancel(reason: reason) : client.cancel(reason: reason, turn_id: turn_id) }
|
|
305
306
|
relay(id, bridge_client(id), request, what: "the turn was not cancelled", cant: "cancel turns") do |reply|
|
|
306
307
|
case reply.status
|
|
307
308
|
when 202
|
|
308
309
|
json_response(202, { status: "cancel_requested", session_id: id, reason: reply.json&.dig("reason"), via: "bridge" })
|
|
309
|
-
|
|
310
|
-
|
|
310
|
+
when 409
|
|
311
|
+
if reply.json&.dig("error") == "turn_mismatch"
|
|
312
|
+
json_response(409, { error: "turn_mismatch", detail: "the running turn is not the one named", session_id: id })
|
|
313
|
+
else
|
|
314
|
+
# No active turn to cancel.
|
|
315
|
+
json_response(409, { error: "not_running", detail: "no active turn to cancel", session_id: id })
|
|
316
|
+
end
|
|
311
317
|
end
|
|
312
318
|
end
|
|
313
319
|
end
|
|
@@ -120,7 +120,9 @@ module Samagotchi
|
|
|
120
120
|
ReviewStart.start(subjects: review.subjects, dir: dir || start_dir, model: folder[:model_name],
|
|
121
121
|
setup: setup, state_dir: @state_dir)
|
|
122
122
|
else
|
|
123
|
-
@manager.spawn_session(prompt: idle ? nil : prompt.to_s,
|
|
123
|
+
@manager.spawn_session(prompt: idle ? nil : prompt.to_s,
|
|
124
|
+
client_id: body["client_id"] || ClientId::WEB_CREATE,
|
|
125
|
+
state_dir: @state_dir, **folder)
|
|
124
126
|
end
|
|
125
127
|
rescue ModelProfile::MissingModel => e
|
|
126
128
|
# No model configured, or one qualified with an unknown host.
|
|
@@ -169,7 +171,8 @@ module Samagotchi
|
|
|
169
171
|
return refused if refused
|
|
170
172
|
|
|
171
173
|
session, error = continue_from(given, prompt: idle ? nil : prompt.to_s, preview: preview.empty? ? nil : preview,
|
|
172
|
-
model: model.empty? ? nil : model
|
|
174
|
+
model: model.empty? ? nil : model,
|
|
175
|
+
client_id: body["client_id"] || ClientId::WEB_CREATE)
|
|
173
176
|
return error if error
|
|
174
177
|
|
|
175
178
|
port = await_worker_link(session.id).then { |link| link.live? ? link.port : nil }
|
|
@@ -331,8 +334,9 @@ module Samagotchi
|
|
|
331
334
|
# SessionContinue.run, its refusals as responses: [session,
|
|
332
335
|
# nil] or [nil, the response]. Only its own errors: what fails after
|
|
333
336
|
# the new link started is not a refusal of it.
|
|
334
|
-
def continue_from(given, prompt:, preview:, model:)
|
|
335
|
-
[SessionContinue.run(given, prompt: prompt, preview: preview, model: model, state_dir: @state_dir
|
|
337
|
+
def continue_from(given, prompt:, preview:, model:, client_id:)
|
|
338
|
+
[SessionContinue.run(given, prompt: prompt, preview: preview, model: model, state_dir: @state_dir,
|
|
339
|
+
client_id: client_id), nil]
|
|
336
340
|
rescue SessionContinue::Refused => e
|
|
337
341
|
extra = e.reason == :continued ? { next_id: e.ids.first } : {}
|
|
338
342
|
[nil, refusal_response(409, e.reason.to_s, e, session_id: e.session_id, reason: e.reason.to_s, ids: e.ids, **extra)]
|