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.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +125 -1
  3. data/docs/architecture.md +12 -9
  4. data/docs/cli.md +7 -5
  5. data/docs/context.md +10 -1
  6. data/docs/guardrails.md +33 -5
  7. data/docs/internals/attached-context.md +4 -2
  8. data/docs/internals/bridge.md +28 -6
  9. data/docs/internals/bundles.md +24 -5
  10. data/docs/internals/engine.md +7 -1
  11. data/docs/internals/objectives.md +8 -2
  12. data/docs/internals/remote-sessions.md +2 -1
  13. data/docs/memory.md +43 -13
  14. data/docs/plugins.md +13 -4
  15. data/docs/releasing.md +7 -4
  16. data/docs/scheduling.md +53 -8
  17. data/docs/sessions.md +17 -12
  18. data/docs/testing.md +9 -1
  19. data/lib/samagotchi/bridge/card_store.rb +31 -3
  20. data/lib/samagotchi/bridge/ring_buffer.rb +11 -1
  21. data/lib/samagotchi/bridge/sse_writer.rb +8 -5
  22. data/lib/samagotchi/bridge.rb +103 -44
  23. data/lib/samagotchi/bridge_client.rb +9 -6
  24. data/lib/samagotchi/broadcast/scope_card.rb +2 -1
  25. data/lib/samagotchi/broadcast_command.rb +24 -8
  26. data/lib/samagotchi/bundle_command.rb +57 -6
  27. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  29. data/lib/samagotchi/child_reports.rb +29 -23
  30. data/lib/samagotchi/client_id.rb +6 -0
  31. data/lib/samagotchi/command_intake.rb +56 -0
  32. data/lib/samagotchi/compat.rb +32 -0
  33. data/lib/samagotchi/context_absorber.rb +6 -3
  34. data/lib/samagotchi/context_candidate_choice.rb +8 -5
  35. data/lib/samagotchi/context_candidates.rb +11 -8
  36. data/lib/samagotchi/context_candidates_log.rb +10 -1
  37. data/lib/samagotchi/context_command.rb +10 -4
  38. data/lib/samagotchi/context_move.rb +6 -1
  39. data/lib/samagotchi/context_poller.rb +5 -2
  40. data/lib/samagotchi/context_sources.rb +6 -4
  41. data/lib/samagotchi/engine/events.rb +25 -2
  42. data/lib/samagotchi/engine/guardrails.rb +3 -4
  43. data/lib/samagotchi/engine/plugins.rb +1 -1
  44. data/lib/samagotchi/engine/steering.rb +2 -2
  45. data/lib/samagotchi/engine.rb +1 -0
  46. data/lib/samagotchi/engine_peers.rb +1 -0
  47. data/lib/samagotchi/events.rb +71 -0
  48. data/lib/samagotchi/guardrails/approvals.rb +27 -3
  49. data/lib/samagotchi/guardrails/child_boundary.rb +37 -4
  50. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  51. data/lib/samagotchi/guardrails/targets.rb +25 -6
  52. data/lib/samagotchi/hook_routes.rb +0 -1
  53. data/lib/samagotchi/live_versions.rb +7 -2
  54. data/lib/samagotchi/memory_bundle/asset_installer.rb +86 -18
  55. data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
  56. data/lib/samagotchi/memory_bundle/file_actions.rb +3 -4
  57. data/lib/samagotchi/memory_bundle/file_state.rb +11 -0
  58. data/lib/samagotchi/memory_bundle/index_updater.rb +7 -4
  59. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  60. data/lib/samagotchi/memory_bundle/installer.rb +93 -23
  61. data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
  62. data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
  63. data/lib/samagotchi/memory_bundle/shipped_update.rb +7 -4
  64. data/lib/samagotchi/memory_bundle/status.rb +3 -3
  65. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
  66. data/lib/samagotchi/memory_bundle/uninstaller.rb +29 -13
  67. data/lib/samagotchi/memory_orphans.rb +54 -13
  68. data/lib/samagotchi/memory_paths.rb +29 -6
  69. data/lib/samagotchi/memory_usage.rb +23 -12
  70. data/lib/samagotchi/note_command.rb +9 -1
  71. data/lib/samagotchi/note_delivery.rb +34 -24
  72. data/lib/samagotchi/note_move.rb +205 -0
  73. data/lib/samagotchi/objectives_command.rb +3 -1
  74. data/lib/samagotchi/owner_lock.rb +7 -1
  75. data/lib/samagotchi/plugin/attached_context.rb +3 -1
  76. data/lib/samagotchi/plugin/sessions.rb +4 -1
  77. data/lib/samagotchi/project_command.rb +58 -20
  78. data/lib/samagotchi/project_id.rb +102 -0
  79. data/lib/samagotchi/project_id_clash.rb +62 -0
  80. data/lib/samagotchi/project_init.rb +214 -10
  81. data/lib/samagotchi/project_scope.rb +11 -2
  82. data/lib/samagotchi/review_start.rb +5 -3
  83. data/lib/samagotchi/schedule_command.rb +12 -0
  84. data/lib/samagotchi/schedules.rb +374 -76
  85. data/lib/samagotchi/send_command.rb +2 -15
  86. data/lib/samagotchi/session.rb +59 -7
  87. data/lib/samagotchi/session_access/local.rb +53 -22
  88. data/lib/samagotchi/session_access/remote.rb +13 -5
  89. data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
  90. data/lib/samagotchi/session_access.rb +20 -4
  91. data/lib/samagotchi/session_commands.rb +7 -3
  92. data/lib/samagotchi/session_continue.rb +162 -24
  93. data/lib/samagotchi/session_delete_command.rb +3 -1
  94. data/lib/samagotchi/session_inbox.rb +41 -1
  95. data/lib/samagotchi/session_inbox_delivery.rb +254 -0
  96. data/lib/samagotchi/session_manager/delivery.rb +66 -12
  97. data/lib/samagotchi/session_manager/emptiness.rb +7 -2
  98. data/lib/samagotchi/session_manager/lifecycle.rb +2 -1
  99. data/lib/samagotchi/session_manager/listing.rb +4 -1
  100. data/lib/samagotchi/session_manager/removal.rb +130 -26
  101. data/lib/samagotchi/session_manager/spawn.rb +167 -100
  102. data/lib/samagotchi/session_manager/worker_command.rb +7 -19
  103. data/lib/samagotchi/session_manager.rb +1 -0
  104. data/lib/samagotchi/session_retention.rb +53 -23
  105. data/lib/samagotchi/session_setup.rb +27 -7
  106. data/lib/samagotchi/starting_marker.rb +93 -24
  107. data/lib/samagotchi/state_format.rb +129 -0
  108. data/lib/samagotchi/system_one/runs.rb +6 -1
  109. data/lib/samagotchi/system_one/runs_store.rb +18 -14
  110. data/lib/samagotchi/terminal_ui/attach_launcher.rb +7 -4
  111. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +5 -2
  112. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -0
  113. data/lib/samagotchi/terminal_ui/version_lines.rb +4 -2
  114. data/lib/samagotchi/terminal_ui.rb +2 -3
  115. data/lib/samagotchi/tools/context_read.rb +7 -2
  116. data/lib/samagotchi/tools/delegate.rb +19 -6
  117. data/lib/samagotchi/tools/schedule_task.rb +5 -3
  118. data/lib/samagotchi/tools/send_note.rb +10 -4
  119. data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
  120. data/lib/samagotchi/turn_state.rb +108 -4
  121. data/lib/samagotchi/update_command.rb +9 -4
  122. data/lib/samagotchi/verify_fresh.rb +27 -9
  123. data/lib/samagotchi/version.rb +1 -1
  124. data/lib/samagotchi/web/app.rb +6 -5
  125. data/lib/samagotchi/web/public/app.js +2 -7
  126. data/lib/samagotchi/web/public/chains.js +4 -4
  127. data/lib/samagotchi/web/public/data.js +18 -2
  128. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  129. data/lib/samagotchi/web/public/session_fields.js +3 -2
  130. data/lib/samagotchi/web/public/session_state.js +2 -0
  131. data/lib/samagotchi/web/public/update_notice.js +2 -2
  132. data/lib/samagotchi/web/session_access_routes.rb +15 -11
  133. data/lib/samagotchi/web/session_control.rb +9 -3
  134. data/lib/samagotchi/web/session_create.rb +8 -4
  135. data/lib/samagotchi/web/session_hub.rb +50 -5
  136. data/lib/samagotchi/web/session_summary.rb +5 -2
  137. data/lib/samagotchi/worker.rb +61 -11
  138. data/lib/samagotchi/worker_boot.rb +22 -11
  139. data/lib/samagotchi/worker_inbound.rb +62 -10
  140. data/lib/samagotchi/worker_link.rb +4 -1
  141. data/lib/samagotchi/worker_presence.rb +106 -0
  142. data/lib/samagotchi/worker_sidecar.rb +3 -4
  143. 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
- return "Error: session #{id[0, 8]}: #{result.refusal}" if result.scratch
32
- return "Error: session #{id[0, 8]} is #{OwnerLock::TUI_BUSY}, which can't take notes" unless result.delivered?
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
- "Queued a note for session #{id[0, 8]}: #{where}. It does not start a turn there."
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, state_dir: state_dir)
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
- parent_root = if Session.exist?(session.parent_id, state_dir: state_dir)
160
- Session.load(session.parent_id, state_dir: state_dir).project_root
161
- else
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 (outside the lock).
183
- # @return [Boolean] whether a cancellation was triggered
184
- def cancel!(reason)
185
- ctrl = controller
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) if read_by_someone?
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
- MemoryBundle::Provenance.new(name: update.name).clear_merge_notes(earlier, files: row.merged) if read_by_someone? && update.status != :failed
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
- # A profile's new members: "+ btw".
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, memories: [Session::DELEGATE_MEMORY],
90
- parent_id: parent.id, delegate: true, verify_fresh: origin, state_dir: state_dir)
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 && Session.exist?(child.parent_id, state_dir: state_dir)
174
+ return 0 unless fresh&.of && child.parent_id
169
175
 
170
- dir = File.join(Session.session_dir(child.parent_id, state_dir: state_dir), INBOX)
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" => fresh.of, "child_id" => child.id)
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.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.159.0"
4
+ VERSION = "0.161.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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, state_dir: default_state_dir)
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, state_dir: default_state_dir,
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
- cancelTurn,
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
- try {
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 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.
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
- ...opts,
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 (!newerVersion(newest, workerVersion)) return null;
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
- # queued}; 409 owned_by_tui (status refused: a one-turn chi run has it); 400
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
- error = result.scratch ? SessionManager::ScratchSession.new(id) : SessionManager::OwnedByTUI.new(id)
450
- return refusal_response(409, "owned_by_tui", error, session_id: id, status: "refused", queued: 0,
451
- scratch: result.scratch || nil)
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}; 404
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, cwd: session.working_directory,
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
- request = ->(client) { client.cancel(reason: reason) }
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
- # No active turn to cancel.
310
- when 409 then json_response(409, { error: "not_running", detail: "no active turn to cancel", session_id: id })
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, state_dir: @state_dir, **folder)
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), nil]
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)]