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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -1
  3. data/bin/chi +3 -1
  4. data/docs/architecture.md +7 -7
  5. data/docs/cli.md +57 -2
  6. data/docs/configuration.md +9 -0
  7. data/docs/context.md +7 -1
  8. data/docs/guardrails.md +24 -3
  9. data/docs/hooks.md +3 -0
  10. data/docs/internals/attached-context.md +4 -2
  11. data/docs/internals/bridge.md +8 -0
  12. data/docs/internals/bundles.md +9 -7
  13. data/docs/internals/engine.md +7 -1
  14. data/docs/memory.md +46 -14
  15. data/docs/scheduling.md +241 -0
  16. data/docs/sessions.md +28 -2
  17. data/lib/samagotchi/bridge.rb +37 -4
  18. data/lib/samagotchi/broadcast/scope_card.rb +2 -1
  19. data/lib/samagotchi/bundle_command.rb +39 -1
  20. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  22. data/lib/samagotchi/client_id.rb +17 -3
  23. data/lib/samagotchi/command_intake.rb +56 -0
  24. data/lib/samagotchi/config.rb +12 -0
  25. data/lib/samagotchi/context_absorber.rb +6 -3
  26. data/lib/samagotchi/context_candidate_choice.rb +8 -5
  27. data/lib/samagotchi/context_candidates.rb +11 -8
  28. data/lib/samagotchi/context_command.rb +9 -3
  29. data/lib/samagotchi/context_move.rb +74 -0
  30. data/lib/samagotchi/context_poller.rb +5 -2
  31. data/lib/samagotchi/context_sources.rb +6 -4
  32. data/lib/samagotchi/engine/events.rb +13 -2
  33. data/lib/samagotchi/engine/guardrails.rb +3 -4
  34. data/lib/samagotchi/engine.rb +1 -0
  35. data/lib/samagotchi/engine_peers.rb +1 -0
  36. data/lib/samagotchi/events.rb +71 -0
  37. data/lib/samagotchi/guardrails/approvals.rb +27 -3
  38. data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
  39. data/lib/samagotchi/hook_routes.rb +0 -1
  40. data/lib/samagotchi/memory_bundle/asset_installer.rb +106 -104
  41. data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
  42. data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
  43. data/lib/samagotchi/memory_bundle/file_actions.rb +50 -0
  44. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  45. data/lib/samagotchi/memory_bundle/installer.rb +14 -11
  46. data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
  47. data/lib/samagotchi/memory_bundle/shipped_update.rb +22 -15
  48. data/lib/samagotchi/memory_bundle/status.rb +22 -3
  49. data/lib/samagotchi/memory_bundle/uninstaller.rb +12 -4
  50. data/lib/samagotchi/memory_orphans.rb +14 -4
  51. data/lib/samagotchi/memory_paths.rb +29 -6
  52. data/lib/samagotchi/memory_usage.rb +23 -12
  53. data/lib/samagotchi/plugin/attached_context.rb +3 -1
  54. data/lib/samagotchi/plugin/sessions.rb +4 -1
  55. data/lib/samagotchi/project_command.rb +58 -20
  56. data/lib/samagotchi/project_id.rb +102 -0
  57. data/lib/samagotchi/project_id_clash.rb +62 -0
  58. data/lib/samagotchi/project_init.rb +214 -10
  59. data/lib/samagotchi/project_scope.rb +11 -2
  60. data/lib/samagotchi/run_options.rb +1 -0
  61. data/lib/samagotchi/schedule_command.rb +252 -0
  62. data/lib/samagotchi/schedules.rb +1008 -0
  63. data/lib/samagotchi/session.rb +17 -2
  64. data/lib/samagotchi/session_access/contract.rb +1 -1
  65. data/lib/samagotchi/session_access/local.rb +28 -5
  66. data/lib/samagotchi/session_access/remote.rb +2 -1
  67. data/lib/samagotchi/session_access.rb +4 -1
  68. data/lib/samagotchi/session_commands.rb +7 -3
  69. data/lib/samagotchi/session_continue.rb +10 -6
  70. data/lib/samagotchi/session_manager/spawn.rb +34 -13
  71. data/lib/samagotchi/session_manager/worker_command.rb +11 -23
  72. data/lib/samagotchi/state_format.rb +118 -0
  73. data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
  74. data/lib/samagotchi/tool_declarations.rb +43 -0
  75. data/lib/samagotchi/tools/builtin_calls.rb +2 -0
  76. data/lib/samagotchi/tools/builtins.rb +17 -0
  77. data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
  78. data/lib/samagotchi/tools/context_read.rb +7 -2
  79. data/lib/samagotchi/tools/delegate.rb +10 -2
  80. data/lib/samagotchi/tools/list_schedules.rb +43 -0
  81. data/lib/samagotchi/tools/schedule_task.rb +39 -0
  82. data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
  83. data/lib/samagotchi/update_command.rb +2 -2
  84. data/lib/samagotchi/version.rb +1 -1
  85. data/lib/samagotchi/web/app.rb +5 -4
  86. data/lib/samagotchi/web/public/chains.js +32 -8
  87. data/lib/samagotchi/web/public/index.html +4 -1
  88. data/lib/samagotchi/web/public/info_bar.js +4 -3
  89. data/lib/samagotchi/web/public/turn_events.js +4 -0
  90. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  91. data/lib/samagotchi/web/session_hub.rb +20 -0
  92. data/lib/samagotchi/worker.rb +42 -12
  93. data/lib/samagotchi/worker_boot.rb +3 -4
  94. data/lib/samagotchi/worker_inbound.rb +44 -2
  95. data/lib/samagotchi/worker_link.rb +5 -0
  96. data/lib/samagotchi/worker_wakes.rb +9 -1
  97. metadata +12 -1
@@ -166,6 +166,11 @@ module Samagotchi
166
166
  # deleted folder no longer leads to its repository. nil outside a repo,
167
167
  # and in files written before the field existed (see #project_root).
168
168
  SessionField.plain("project_root", nil),
169
+ # The project's id (ProjectId: a marked project's `id:`), saved beside
170
+ # project_root at creation, else nil: its memories and project context
171
+ # sources are keyed by it (MemoryPaths.key_for), so a session resumed
172
+ # after its folder moved finds them under the id, not its dead path.
173
+ SessionField.plain("project_id", nil, optional: true),
169
174
  # Memories the session was started with (--memory) and memories hidden
170
175
  # from it (--mute): the worker rebuilds the same prompt on a respawn.
171
176
  # Names as given; the engine normalizes them.
@@ -271,6 +276,14 @@ module Samagotchi
271
276
  # chi web's create) can name it (`--wait --turn T ID`). nil for a
272
277
  # session created idle, and for one saved before the field existed.
273
278
  SessionField.plain("first_turn_id", nil, optional: true),
279
+ # Who sent that prompt when no person did (a ClientId: a delegating
280
+ # parent's delegate:<parent8>, a schedule's chain link
281
+ # system:schedule:<id>): the first turn's origin, so the command
282
+ # intake treats it as theirs (CommandIntake) and last_turn names
283
+ # them. nil for a person's prompt (chi send --new, the web, a TUI, a
284
+ # continue) and an idle session. Set once, by spawn_session; a
285
+ # continue and a fork don't carry it.
286
+ SessionField.plain("first_turn_client_id", nil, optional: true),
274
287
  # A Verify fresh child's requester, rules and claim (VerifyFreshOrigin),
275
288
  # nil for any other session. Set once, at spawn (VerifyFresh.start).
276
289
  # Autoloaded on the first one (the bundle test kit's load budget).
@@ -290,7 +303,7 @@ module Samagotchi
290
303
  attr_accessor :id, :metadata_version, :mode, :model_name, :model_typed, :working_directory, :messages,
291
304
  :created_at, :updated_at, :status, :last_prompt, :first_preview, :title, :test_run,
292
305
  :used_memory_names, :preloaded_memory_names, :muted_memory_names, :parent_id, :scratch,
293
- :continues, :first_turn_id
306
+ :continues, :first_turn_id, :first_turn_client_id, :project_id
294
307
  attr_writer :project_root, :delegate
295
308
  attr_reader :appended_prompt_files, :written_by
296
309
 
@@ -572,6 +585,7 @@ module Samagotchi
572
585
  review: nil, now: Time.now)
573
586
  own = setup.to_h
574
587
  own = own.merge(toolkit_origin: own[:toolkit_origin] || "chosen")
588
+ project = ProjectScope.project_for(working_directory)
575
589
  Toolkit.check_new!(own[:toolkit] || Toolkit.configured(delegate: delegate))
576
590
  now = now.iso8601(3)
577
591
  resolved_test = if test_run.nil?
@@ -591,7 +605,8 @@ module Samagotchi
591
605
  status: STATUS_IDLE,
592
606
  first_preview: "",
593
607
  test_run: resolved_test,
594
- project_root: ProjectScope.root_for(working_directory),
608
+ project_root: project&.root,
609
+ project_id: project&.id,
595
610
  preloaded_memory_names: preloaded_memory_names,
596
611
  muted_memory_names: muted_memory_names,
597
612
  parent_id: parent_id,
@@ -26,7 +26,7 @@ module Samagotchi
26
26
  # bin/chi subcommands that act on this machine: never sessions. chi
27
27
  # remote (this machine's list of remotes) runs here under a remote too
28
28
  # (Dispatch); the others are refused there.
29
- MACHINE = %w[bootstrap update models desktop memory bundle guardrails toolkit judgments remote home project].freeze
29
+ MACHINE = %w[bootstrap update models desktop memory bundle guardrails toolkit judgments remote home project schedule].freeze
30
30
 
31
31
  # "command", "command sub" or either with a flag (its long name) →
32
32
  # SUPPORTED or LOCAL_ONLY. A LOCAL_ONLY command or subcommand lists
@@ -11,6 +11,7 @@ require_relative "../parent_report"
11
11
  require_relative "../note_delivery"
12
12
  require_relative "../image_store"
13
13
  require_relative "../context_candidates"
14
+ require_relative "../worker_inbound"
14
15
  require_relative "../context_sources"
15
16
  require_relative "../archive_store"
16
17
  require_relative "../session_chain"
@@ -24,6 +25,7 @@ require_relative "../participants"
24
25
  require_relative "../config"
25
26
  require_relative "../live_versions"
26
27
  require_relative "../grants"
28
+ require_relative "../log"
27
29
  require_relative "../web/info_probe"
28
30
  require_relative "../web/public_url"
29
31
  require_relative "../web/link_codes"
@@ -314,6 +316,16 @@ module Samagotchi
314
316
  before, written = attached.location.locked(name) do
315
317
  [attached.location.snapshot(name).revision, attached.location.record_text(name, fetched)]
316
318
  end
319
+ if fetched.wake && before != written.revision && !target.project? && attached.location.session? &&
320
+ attached.source.scope == "session"
321
+ begin
322
+ subscription = attached.location.subscription(name)
323
+ recent_wake = context_wake_recent?(subscription)
324
+ SessionManager.wake_for_context(target.session_id, **dir) unless recent_wake || Config.get("context.wake") == false
325
+ rescue StandardError => e
326
+ Log.exception(:context, "context_push_wake_failed", e, sid: target.session_id, source: name)
327
+ end
328
+ end
317
329
  ContextPush.new(status: :pushed, before: before, revision: written.revision)
318
330
  end
319
331
 
@@ -341,7 +353,8 @@ module Samagotchi
341
353
  def context_candidates(target)
342
354
  return [] if target.project?
343
355
 
344
- ContextCandidates.with_status(target.session_id, project_root: target.project_root, state_dir: @state_dir)
356
+ ContextCandidates.with_status(target.session_id, project_root: target.project_root, project_id: target.project_id,
357
+ state_dir: @state_dir)
345
358
  .map { |candidate, status| ContextCandidate.new(candidate: candidate, status: status) }
346
359
  end
347
360
 
@@ -359,7 +372,9 @@ module Samagotchi
359
372
  ContextSources.check_name!(name)
360
373
  return :removed if context_location(target).remove(name)
361
374
 
362
- project = !target.project? && ContextSources.project_location_for(target.project_root, state_dir: @state_dir)
375
+ project = !target.project? && ContextSources.project_location_for(target.project_root,
376
+ project_id: target.project_id,
377
+ state_dir: @state_dir)
363
378
  project&.source(name) ? :project : :missing
364
379
  end
365
380
 
@@ -382,7 +397,7 @@ module Samagotchi
382
397
  # live in.
383
398
  def context_location(target)
384
399
  if target.project?
385
- ContextSources.project_location_for(target.project_root, state_dir: @state_dir)
400
+ ContextSources.project_location_for(target.project_root, project_id: target.project_id, state_dir: @state_dir)
386
401
  else
387
402
  ContextSources.session_location(target.session_id, state_dir: @state_dir)
388
403
  end
@@ -513,9 +528,17 @@ module Samagotchi
513
528
  def state_dir! = @state_dir || Session.default_state_dir
514
529
  def session_dir(id) = Session.session_dir(id, state_dir: state_dir!)
515
530
 
531
+ def context_wake_recent?(subscription)
532
+ return false unless subscription.wakes_at
533
+
534
+ Time.now - Time.iso8601(subscription.wakes_at) < WorkerInbound::CONTEXT_WAKE_WINDOW
535
+ rescue ArgumentError
536
+ false
537
+ end
538
+
516
539
  def attached_to(target, shadowed: false)
517
- ContextSources.attached(target.session_id, project_root: target.project_root, state_dir: @state_dir,
518
- shadowed: shadowed)
540
+ ContextSources.attached(target.session_id, project_root: target.project_root, project_id: target.project_id,
541
+ state_dir: @state_dir, shadowed: shadowed)
519
542
  end
520
543
 
521
544
  # The block's post to the session's live worker, again on a 408 (the
@@ -809,7 +809,8 @@ module Samagotchi
809
809
  owner = json["owner"] && OwnerLock::Owner.new(pid: nil, kind: json["owner"].to_s, started_at: nil)
810
810
  SessionView.new(id: json["id"], status: json["status"], title: json["title"],
811
811
  display_title: json["display_title"] || json["title"] || json["first_preview"].to_s,
812
- project_root: json["project_root"], working_directory: json["working_directory"],
812
+ project_root: json["project_root"], project_id: json["project_id"],
813
+ working_directory: json["working_directory"],
813
814
  model_name: json["model_name"], model_typed: json["model_typed"],
814
815
  parent_answers: json["parent_answers"], owner: owner,
815
816
  pending_question: PendingQuestion.from_file(pending))
@@ -166,7 +166,10 @@ module Samagotchi
166
166
  # @!attribute owner [OwnerLock::Owner, nil] its worker or one-turn chi run
167
167
  # @!attribute pending_question [PendingQuestion, nil] the file's
168
168
  SessionView = Data.define(:id, :status, :title, :display_title, :project_root, :working_directory, :model_name,
169
- :model_typed, :parent_answers, :owner, :pending_question)
169
+ :model_typed, :parent_answers, :owner, :pending_question, :project_id) do
170
+ def initialize(id:, status:, title:, display_title:, project_root:, working_directory:, model_name:,
171
+ model_typed:, parent_answers:, owner:, pending_question:, project_id: nil) = super
172
+ end
170
173
 
171
174
  # What chi send --new created (Remote; Local gives the Session): its
172
175
  # id, a model warning, the session it continues, the delegates it took
@@ -473,7 +473,8 @@ module Samagotchi
473
473
 
474
474
  peers = Tools::Peers.new(session_id: session.id, cwd: session.working_directory, state_dir: @engine.peer_state_dir)
475
475
  listing = Tools::ContextRead.call("", peers: peers)
476
- candidates = ContextCandidates.listing(session.id, project_root: session.project_root, state_dir: @engine.peer_state_dir)
476
+ candidates = ContextCandidates.listing(session.id, project_root: session.project_root, project_id: session.project_id,
477
+ state_dir: @engine.peer_state_dir)
477
478
  candidates ? "#{listing}\n\n#{candidates}" : listing
478
479
  end
479
480
 
@@ -618,9 +619,12 @@ module Samagotchi
618
619
 
619
620
  def approval_line(entry)
620
621
  what = entry["key"] ? entry["key"].tr("\n", " ") : "any call rule #{entry["rule"]} asks about"
621
- where = if entry["scope"] == "session" then "session #{entry["session_id"].to_s[0, 8]}"
622
+ where = if entry["scope"] == "session"
623
+ place = entry["repo_root"] ? " in #{entry["repo_root"]}" : ""
624
+ "session #{entry["session_id"].to_s[0, 8]}#{place}#{" with its env:" if entry["env"]}"
622
625
  elsif entry["repo"] then "in repo #{entry["repo"]}"
623
- else "in #{entry["repo_root"]}"
626
+ else
627
+ "in #{entry["repo_root"]}"
624
628
  end
625
629
  rule = entry["rule"] ? " (rule #{entry["rule"]}, #{entry["source"]})" : ""
626
630
  "#{entry["scope"]}: #{what} — #{where}#{rule}, #{entry["created_at"]}"
@@ -91,6 +91,9 @@ module Samagotchi
91
91
  # @param prompt [String, nil] its first message (nil: it starts idle)
92
92
  # @param preview [String, nil] what the lists show before the first turn;
93
93
  # by default the prompt, else the previous link's preview
94
+ # @param client_id [String, nil] who sends +prompt+ when no person does
95
+ # (a schedule's chain firing: ClientId.schedule), the first turn's
96
+ # origin (spawn_session's client_id:)
94
97
  # @return [Session] the new link (#model_warning as spawn_session's,
95
98
  # #moved_children: the delegates it took over, #moved_context: the
96
99
  # attached context it took (ContextMove), #opener:
@@ -99,7 +102,8 @@ module Samagotchi
99
102
  # @raise [Refused] continued already, delegates open that can't
100
103
  # move, or its folder is gone
101
104
  # @raise [ArchiveRefused, OwnedByTUI] as archive_session
102
- def self.run(id_or_ref, prompt: nil, preview: nil, model: nil, state_dir: nil, recap_wait: RECAP_WAIT, archive_wait: 5)
105
+ def self.run(id_or_ref, prompt: nil, preview: nil, model: nil, state_dir: nil, recap_wait: RECAP_WAIT, archive_wait: 5,
106
+ client_id: nil)
103
107
  sd = state_dir || Session.default_state_dir
104
108
  TRIES.times do |try|
105
109
  id = SessionChain.resolve(id_or_ref, state_dir: sd)
@@ -108,7 +112,7 @@ module Samagotchi
108
112
  next :moved if follow && SessionChain.next_of(id, state_dir: sd)
109
113
 
110
114
  run_locked(id, prompt: prompt, preview: preview, model: model, state_dir: sd, recap_wait: recap_wait,
111
- archive_wait: archive_wait)
115
+ archive_wait: archive_wait, client_id: client_id)
112
116
  end
113
117
  return result unless result == :moved
114
118
  end
@@ -123,7 +127,7 @@ module Samagotchi
123
127
  end
124
128
  end
125
129
 
126
- private_class_method def self.run_locked(id, prompt:, preview:, model:, state_dir:, recap_wait:, archive_wait:)
130
+ private_class_method def self.run_locked(id, prompt:, preview:, model:, state_dir:, recap_wait:, archive_wait:, client_id:)
127
131
  previous = Session.load(id, state_dir: state_dir)
128
132
  checked = check!(previous, state_dir, model: model)
129
133
  plan = checked.plan
@@ -140,7 +144,7 @@ module Samagotchi
140
144
  archived = archive.archived - already
141
145
  begin
142
146
  spawn_continuation(previous, prompt: prompt, preview: preview, choice: checked.choice, state_dir: state_dir,
143
- move: plan.move)
147
+ move: plan.move, client_id: client_id)
144
148
  rescue StandardError
145
149
  # Nothing started: the delegates go back, what this archived goes
146
150
  # back to the lists, and a new link saved before its worker failed
@@ -201,7 +205,7 @@ module Samagotchi
201
205
 
202
206
  # The new link, holding off any worker but its own (StartingMarker.fresh?)
203
207
  # while +move+ goes to it.
204
- private_class_method def self.spawn_continuation(previous, prompt:, preview:, choice:, state_dir:, move:)
208
+ private_class_method def self.spawn_continuation(previous, prompt:, preview:, choice:, state_dir:, move:, client_id: nil)
205
209
  recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
206
210
  start = prompt.to_s.strip.empty? ? nil : prompt
207
211
  preview = previous.first_preview if preview.to_s.strip.empty? && start.nil?
@@ -218,7 +222,7 @@ module Samagotchi
218
222
  prompt: start, preview: preview, title: previous.title, working_directory: previous.working_directory,
219
223
  model_name: choice.model, setup: SessionSetup.of(previous), review: previous.review,
220
224
  continues: previous.id, note: SessionChain.note_text(previous, recap: recap, state_dir: state_dir),
221
- note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn
225
+ note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn, client_id: client_id
222
226
  )
223
227
  link.moved_children = move
224
228
  link.moved_context = moved_context
@@ -54,12 +54,16 @@ module Samagotchi
54
54
  # @param scratch [Boolean] a `chi scratch` session (its terminal's
55
55
  # AttachLauncher): its worker runs a scratch Engine and deletes it
56
56
  # once its last client is gone
57
+ # @param client_id [String, nil] who sent +prompt+ when no person did
58
+ # (a ClientId: a delegating parent, a schedule's chain link): its
59
+ # first turn's origin (Session#first_turn_client_id)
57
60
  # @return [Session] with #seed_images_dropped: refs whose file was gone,
58
61
  # and #model_warning: an id its host's saved list doesn't have
59
62
  def spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
60
63
  memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
61
64
  preview: nil, title: nil, delegate: false, setup: SessionSetup.new, continues: nil, note: nil,
62
- note_source: "chi", before_spawn: nil, review: nil, verify_fresh: nil, scratch: false)
65
+ note_source: "chi", before_spawn: nil, review: nil, verify_fresh: nil, scratch: false,
66
+ client_id: nil)
63
67
  sd = state_dir || Session.default_state_dir
64
68
  # The resolved ref is stored (a resumed session keeps its model when an
65
69
  # alias is retargeted), with the name as typed beside it. A wrong host
@@ -98,7 +102,10 @@ module Samagotchi
98
102
  # for it can name it (chi send --new --wait, chi web's create): the
99
103
  # worker runs it with that origin, and its reply file and last_turn
100
104
  # carry the id.
101
- session.first_turn_id = SecureRandom.uuid unless prompt.to_s.strip.empty?
105
+ unless prompt.to_s.strip.empty?
106
+ session.first_turn_id = SecureRandom.uuid
107
+ session.first_turn_client_id = client_id
108
+ end
102
109
  # The worker takes last_prompt and clears it, and messages are saved at
103
110
  # the turn's end: until then this is the only preview a list has.
104
111
  session.first_preview = Session.preview_of(preview.to_s.strip.empty? ? prompt : preview)
@@ -155,23 +162,37 @@ module Samagotchi
155
162
  # @return [Symbol] :woken, or why not (:gone, :stopped, :archived,
156
163
  # :scratch, :owned, :starting)
157
164
  def wake_for_report(session_id, state_dir: nil, forward: true)
158
- sd = state_dir || Session.default_state_dir
159
- return :gone unless Session.exist?(session_id, state_dir: sd)
160
- return :owned if session_owner(session_id, state_dir: sd)
161
- return :stopped if Session.stopped_marker?(session_id, state_dir: sd)
165
+ wake_existing_session(session_id, state_dir: state_dir || Session.default_state_dir, forward: forward,
166
+ event: :delegate_woke_parent)
167
+ end
162
168
 
163
- if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: sd))
164
- following = forward && SessionChain.next_of(session_id, state_dir: sd)
165
- return following ? wake_for_report(following, state_dir: sd, forward: false) : :archived
169
+ # A pushed context source asked for a wake: start an idle-exited
170
+ # session, but never clear a stop or forward an archived link.
171
+ # @return [Symbol] :woken, or why not (:gone, :stopped, :archived,
172
+ # :scratch, :owned, :starting)
173
+ def wake_for_context(session_id, state_dir: nil)
174
+ wake_existing_session(session_id, state_dir: state_dir || Session.default_state_dir, forward: false,
175
+ event: :context_woke_session)
176
+ end
177
+
178
+ private def wake_existing_session(session_id, state_dir:, forward:, event:)
179
+ return :gone unless Session.exist?(session_id, state_dir: state_dir)
180
+ return :owned if session_owner(session_id, state_dir: state_dir)
181
+ return :stopped if Session.stopped_marker?(session_id, state_dir: state_dir)
182
+
183
+ if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: state_dir))
184
+ following = forward && SessionChain.next_of(session_id, state_dir: state_dir)
185
+ return following ? wake_existing_session(following, state_dir: state_dir, forward: false, event: event) : :archived
166
186
  end
167
- return :starting if StartingMarker.fresh?(session_id, state_dir: sd)
187
+ return :starting if StartingMarker.fresh?(session_id, state_dir: state_dir)
168
188
 
169
- session = Session.load(session_id, state_dir: sd)
189
+ session = Session.load(session_id, state_dir: state_dir)
170
190
  return :stopped if session.status == Session::STATUS_STOPPED
171
191
  return :scratch if session.scratch
172
192
 
173
- resume_session(session_id, state_dir: sd)
174
- Log.info(:worker, "delegate_woke_parent", parent: session_id[0, 8])
193
+ resume_session(session_id, state_dir: state_dir)
194
+ details = event == :delegate_woke_parent ? { parent: session_id[0, 8] } : { sid: session_id[0, 8] }
195
+ Log.info(:worker, event, **details)
175
196
  :woken
176
197
  rescue OwnedByTUI
177
198
  :owned
@@ -3,10 +3,10 @@
3
3
  module Samagotchi
4
4
  class SessionManager
5
5
  # CROSS-VERSION CONTRACT: this boot string, the entry it names
6
- # (run_session_loop(id, state_dir:), the forwarder below), the env keys
7
- # spawn_options sets and the input/ file
8
- # format are read by a NEWER chi than the one writing them. Change them
9
- # only additively (spec/worker_contract_spec.rb).
6
+ # (WorkerBoot.run_session_loop(id, state_dir:)), the env keys
7
+ # spawn_options sets and the input/ file format are read by a NEWER chi
8
+ # than the one writing them. Change them only additively
9
+ # (spec/worker_contract_spec.rb).
10
10
  BOOT_FALLBACK_ENV = "SAMAGOTCHI_BOOT_FALLBACK"
11
11
  # "1" in a worker spawn_options started: it traps signals (WorkerSignals).
12
12
  # WorkerBoot.run_session_loop deletes it from ENV as it reads it.
@@ -28,9 +28,9 @@ module Samagotchi
28
28
 
29
29
  # WorkerCommand: the worker's command line and env (CROSS-VERSION
30
30
  # CONTRACT: spec/worker_contract_spec.rb); the worker process itself is
31
- # WorkerBoot (worker_boot.rb), which only the forwarder (run_session_loop)
32
- # loads, as a worker boots. Calls no other part. Meant to be loaded
33
- # through samagotchi/session_manager.
31
+ # WorkerBoot (worker_boot.rb), which the boot command loads directly.
32
+ # Calls no other part. Meant to be loaded through
33
+ # samagotchi/session_manager.
34
34
  module WorkerCommandPart
35
35
  # Build the opts hash passed to Process.spawn for a forked worker. The
36
36
  # child inherits this process's ENV and reads config.yml itself (as it
@@ -94,18 +94,6 @@ module Samagotchi
94
94
  opts
95
95
  end
96
96
 
97
- # The worker's entry, the one every boot string names (CROSS-VERSION
98
- # CONTRACT: spec/worker_contract_spec.rb): it only forwards to
99
- # WorkerBoot.run_session_loop, which runs the worker process. Looked up
100
- # by name on purpose: a constant reference here would put SessionManager
101
- # back into one cycle with Worker, Engine and Bridge (the class map).
102
- # REMOVE one release after the boot string switches (see
103
- # ~/.claude/plans/arch-worker-boot.md).
104
- def run_session_loop(session_id, state_dir: nil, **)
105
- require_relative "../worker_boot"
106
- Samagotchi.const_get(:WorkerBoot).run_session_loop(session_id, state_dir: state_dir, **)
107
- end
108
-
109
97
  # The worker's command line. Run from an installed gem, the worker
110
98
  # activates the newest installed samagotchi (`>= 0.a`, prereleases
111
99
  # count), whoever spawns it: an old chi web, chi send or --attach then
@@ -118,8 +106,8 @@ module Samagotchi
118
106
  # there) keeps the plain -I lib: a worktree's bin/chi spawns its own code.
119
107
  def worker_command(session_id, state_dir:, gem_spec: InstalledGem.spec,
120
108
  home: ChiHome.current(gem_spec: gem_spec))
121
- boot = "require 'samagotchi/session_manager'; " \
122
- "Samagotchi::SessionManager.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})"
109
+ boot = "require 'samagotchi/worker_boot'; " \
110
+ "Samagotchi::WorkerBoot.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})"
123
111
  return [RbConfig.ruby, "-I", File.expand_path("../..", __dir__), "-e", boot] unless gem_spec
124
112
  return home_worker_command(session_id, state_dir: state_dir, gem_spec: gem_spec, home: home) if home
125
113
 
@@ -146,9 +134,9 @@ module Samagotchi
146
134
  else
147
135
  "gem 'samagotchi', '>= 0.a'"
148
136
  end
149
- home.boot_argv(run: "begin; #{activate}; require 'samagotchi/session_manager'; " \
137
+ home.boot_argv(run: "begin; #{activate}; require 'samagotchi/worker_boot'; " \
150
138
  "#{HOME_BOOT_FAILED.sub("SID", session_id)}; end; " \
151
- "Samagotchi::SessionManager.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})")
139
+ "Samagotchi::WorkerBoot.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})")
152
140
  end
153
141
  end
154
142
 
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # A versioned JSON state-file schema. Missing members must be required or
5
+ # declare the format version and default that introduced them.
6
+ class StateFormat
7
+ REQUIRED = Object.new.freeze
8
+
9
+ Member = Data.define(:name, :introduced_in, :default, :persisted) do
10
+ def required? = introduced_in.nil?
11
+ def defaulted? = !required?
12
+ def persisted? = persisted
13
+ end
14
+
15
+ Record = Data.define(:attributes, :extra, :raw, :missing) do
16
+ def readable? = missing.empty?
17
+
18
+ def to_h
19
+ return raw unless readable?
20
+
21
+ extra.transform_keys(&:to_s).merge(attributes.transform_keys(&:to_s))
22
+ end
23
+ end
24
+
25
+ Document = Data.define(:version, :extra, :entries)
26
+
27
+ class InvalidDocument < StandardError; end
28
+
29
+ def self.required(name)
30
+ Member.new(name: name.to_sym, introduced_in: nil, default: REQUIRED, persisted: true)
31
+ end
32
+
33
+ def self.added(name, in_version:, default:)
34
+ Member.new(name: name.to_sym, introduced_in: in_version, default: default, persisted: true)
35
+ end
36
+
37
+ def self.extra(name, in_version:)
38
+ Member.new(name: name.to_sym, introduced_in: in_version, default: ->(_attributes) { {} }, persisted: false)
39
+ end
40
+
41
+ attr_reader :members
42
+
43
+ def initialize(version:, members:)
44
+ @version = version
45
+ @members = members.freeze
46
+ @extra_member = members.find { |member| !member.persisted? }
47
+ raise ArgumentError, "a state format can declare only one extra member" if members.count { |member| !member.persisted? } > 1
48
+ raise ArgumentError, "duplicate state format members" unless members.map(&:name).uniq == members.map(&:name)
49
+
50
+ members.each do |member|
51
+ required = member.required? && member.default.equal?(REQUIRED)
52
+ defaulted = member.defaulted? && member.introduced_in.is_a?(Integer) &&
53
+ member.introduced_in.positive? && member.introduced_in <= version &&
54
+ !member.default.equal?(REQUIRED)
55
+ next if required || defaulted
56
+
57
+ raise ArgumentError, "#{member.name} must be required or have an introduction version and default"
58
+ end
59
+ end
60
+
61
+ def read(data, collection:)
62
+ raise InvalidDocument, "the state file must contain a JSON object" unless data.is_a?(Hash)
63
+
64
+ collection = collection.to_s
65
+ entries = Array(data[collection]).map { |raw| read_record(raw) }
66
+ known = %w[version updated_at] + [collection]
67
+ stored_version = data["version"]
68
+ stored_version = 0 unless stored_version.is_a?(Integer) && stored_version >= 0
69
+
70
+ Document.new(version: [@version, stored_version].max,
71
+ extra: data.reject { |key, _value| known.include?(key.to_s) },
72
+ entries: entries)
73
+ end
74
+
75
+ def write(document, collection:, updated_at:)
76
+ document.extra.transform_keys(&:to_s).merge(
77
+ "version" => [document.version, @version].max,
78
+ "updated_at" => updated_at,
79
+ collection.to_s => document.entries.map(&:to_h)
80
+ )
81
+ end
82
+
83
+ private
84
+
85
+ def read_record(raw)
86
+ unless raw.is_a?(Hash)
87
+ missing = members.select(&:required?).map(&:name)
88
+ return Record.new(attributes: {}, extra: {}, raw: raw, missing: missing)
89
+ end
90
+
91
+ attributes = {}
92
+ missing = []
93
+ members.each do |member|
94
+ next unless member.persisted?
95
+
96
+ key = member.name.to_s
97
+ if raw.key?(key)
98
+ attributes[member.name] = raw[key]
99
+ elsif member.required?
100
+ missing << member.name
101
+ else
102
+ attributes[member.name] = default_value(member.default, attributes)
103
+ end
104
+ end
105
+
106
+ known = members.select(&:persisted?).map { |member| member.name.to_s }
107
+ extra = raw.reject { |key, _value| known.include?(key.to_s) }
108
+ extra = default_value(@extra_member.default, attributes).merge(extra) if @extra_member
109
+ Record.new(attributes: attributes, extra: extra, raw: raw, missing: missing)
110
+ end
111
+
112
+ def default_value(default, attributes)
113
+ value = default.respond_to?(:call) ? default.call(attributes) : default
114
+ value = value.dup if value.is_a?(Hash) || value.is_a?(Array)
115
+ value
116
+ end
117
+ end
118
+ end
@@ -242,6 +242,8 @@ module Samagotchi
242
242
  when "task"
243
243
  task = id.delete_prefix(ClientId::TASK_PREFIX)
244
244
  return "task #{Tools::TaskRecord.short_id(task)} finished" unless task.empty?
245
+ when "schedule"
246
+ return "schedule"
245
247
  end
246
248
 
247
249
  prefix = id[/\A[^:]*:/]
@@ -303,6 +303,49 @@ module Samagotchi
303
303
  properties: {}
304
304
  }
305
305
  },
306
+ {
307
+ name: "schedule_task",
308
+ description: "Register a durable schedule for this session: a prompt that runs on a clock outside the worker " \
309
+ "(chi schedule tick, run by cron, systemd or chi web's hub). Unlike register_reminder it needs no " \
310
+ "live worker and never holds one up. Give exactly one of every, weekday+time or date.",
311
+ parameters: {
312
+ type: "object",
313
+ properties: {
314
+ title: { type: "string", description: "A short name for the schedule" },
315
+ prompt: { type: "string", description: "What each firing does (non-empty, at most 16 KiB)" },
316
+ every: { type: "string", description: "Interval: \"30m\", \"2h\", \"1d\" (UTC math: drifts an hour across a DST change)" },
317
+ weekday: { type: "integer", description: "1=Mon … 7=Sun, used with time" },
318
+ time: { type: "string", description: "HH:MM in the machine's local zone (with weekday)" },
319
+ date: { type: "string", description: "One local moment: 2026-11-20T09:00" },
320
+ mode: { type: "string", description: "chain (default): the next link of this session's chain. same: a queued turn in this session" },
321
+ keep_links: { type: "integer", description: "Chain links to keep (default 5; 0 keeps all)" }
322
+ },
323
+ required: ["prompt"]
324
+ }
325
+ },
326
+ {
327
+ name: "list_schedules",
328
+ description: "List the durable schedules of this chain (all: true for every schedule on this machine): id, mode, recurrence, next due and target.",
329
+ parameters: {
330
+ type: "object",
331
+ properties: {
332
+ include_disabled: { type: "boolean", description: "Include the disabled schedules and the fired one-offs" },
333
+ all: { type: "boolean", description: "Every schedule on this machine, not just this chain" }
334
+ }
335
+ }
336
+ },
337
+ {
338
+ name: "cancel_schedule",
339
+ description: "Cancel a durable schedule of this chain by its id, a unique prefix of it, or its title (all: true for one from another chain).",
340
+ parameters: {
341
+ type: "object",
342
+ properties: {
343
+ name: { type: "string", description: "The schedule's id or title" },
344
+ all: { type: "boolean", description: "Allow cancelling a schedule of another session's chain" }
345
+ },
346
+ required: ["name"]
347
+ }
348
+ },
306
349
  {
307
350
  name: "list_sessions",
308
351
  description: "List the other chi sessions of this project (newest first, up to 20): id, whether a worker runs it, its folder and last prompt; other projects: cwd \"/\". Use it to find the session send_note should go to.",
@@ -47,6 +47,8 @@ module Samagotchi
47
47
  "web_fetch" => { content: "url", fallback: :prefix_or_raw },
48
48
  "register_reminder" => { content: "name", fallback: :prefix_or_raw },
49
49
  "cancel_reminder" => { content: "name", fallback: :prefix_or_raw },
50
+ "schedule_task" => { content: "prompt", fallback: :prefix_or_raw },
51
+ "cancel_schedule" => { content: "name", fallback: :prefix_or_raw },
50
52
  "list_sessions" => { fallback: :prefix, fallback_key: "cwd" },
51
53
  "send_note" => { content: "text" },
52
54
  "context_read" => { content: "name", fallback: :prefix_or_raw },
@@ -18,6 +18,9 @@ require_relative "web_fetch"
18
18
  require_relative "register_reminder"
19
19
  require_relative "cancel_reminder"
20
20
  require_relative "list_reminders"
21
+ require_relative "schedule_task"
22
+ require_relative "list_schedules"
23
+ require_relative "cancel_schedule"
21
24
  require_relative "list_sessions"
22
25
  require_relative "send_note"
23
26
  require_relative "context_read"
@@ -54,6 +57,9 @@ module Samagotchi
54
57
  RegisterReminder,
55
58
  CancelReminder,
56
59
  ListReminders,
60
+ ScheduleTask,
61
+ ListSchedules,
62
+ CancelSchedule,
57
63
  ListSessions,
58
64
  SendNote,
59
65
  ContextRead,
@@ -103,6 +109,17 @@ module Samagotchi
103
109
  end,
104
110
  CancelReminder::NAME => ->(call, kctx) { CancelReminder.call(call[:content], reminder_store: kctx.reminder_store) },
105
111
  ListReminders::NAME => ->(call, kctx) { ListReminders.call(call[:content], reminder_store: kctx.reminder_store) },
112
+ ScheduleTask::NAME => lambda do |call, kctx|
113
+ ScheduleTask.call(call[:content], title: call[:title], prompt: call[:prompt], every: call[:every],
114
+ weekday: call[:weekday], time: call[:time], date: call[:date], mode: call[:mode],
115
+ keep_links: call[:keep_links], peers: kctx.peers)
116
+ end,
117
+ ListSchedules::NAME => lambda do |call, kctx|
118
+ ListSchedules.call(call[:content], include_disabled: call[:include_disabled], all: call[:all], peers: kctx.peers)
119
+ end,
120
+ CancelSchedule::NAME => lambda do |call, kctx|
121
+ CancelSchedule.call(call[:content], all: call[:all], peers: kctx.peers)
122
+ end,
106
123
  ListSessions::NAME => ->(call, kctx) { ListSessions.call(call[:content], peers: kctx.peers, cwd: call[:cwd]) },
107
124
  SendNote::NAME => ->(call, kctx) { SendNote.call(call[:content], session: call[:session], peers: kctx.peers) },
108
125
  ContextRead::NAME => lambda do |call, kctx|