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
@@ -1,19 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../worker_link"
4
+ require_relative "../starting_marker"
4
5
 
5
6
  module Samagotchi
6
7
  class SessionManager
7
- # How long after a spawn a resume trusts that worker to take the
8
- # session, while it runs (and the session wasn't stopped since).
8
+ # How long a worker's starter waits for it to take the session
9
+ # (WorkerBoot.detach_from_spawner): the starting marker naming the
10
+ # starter holds that long at most.
9
11
  WORKER_START_GRACE = WorkerLink::START_LIMIT
10
12
 
11
- @spawns = {}
12
- @spawns_mutex = Mutex.new
13
+ # The starters this process spawned (their Process.detach waiters), for
14
+ # chi web's restart to adopt (#live_spawn_pids). Decides nothing: the
15
+ # starting marker is what holds a second spawn off.
16
+ @starters = []
17
+ @starters_mutex = Mutex.new
13
18
 
14
19
  # Spawn: deciding to start a session's worker (a new session, a resume, a
15
- # delegate's report waking its parent) and the spawns this process
16
- # remembers. Calls WorkerCommandPart's spawn_options and worker_command and
20
+ # delegate's report waking its parent) and the starters this process
21
+ # spawned. Calls WorkerCommandPart's spawn_options and worker_command and
17
22
  # the facade's ownership checks. Meant to be loaded through samagotchi/session_manager.
18
23
  module SpawnPart
19
24
  # Spawn a new background session that processes the given prompt (or,
@@ -22,9 +27,6 @@ module Samagotchi
22
27
  # Returns the session object with its ID. Every worker always starts its
23
28
  # per-session Bridge (the single live client transport), so external
24
29
  # clients can reach it once the sidecar is published.
25
- # @param memories [Array<String>] --memory: preloaded into the worker's prompt
26
- # @param muted_memories [Array<String>] --mute: hidden from the session
27
- # (both are session fields, so a respawn keeps them)
28
30
  # @param parent_id [String, nil] the session that delegates this one (the
29
31
  # `delegate` tool) or was forked from; a session field too
30
32
  # @param messages [Array<Hash>] a conversation to start from (a plugin's
@@ -35,9 +37,9 @@ module Samagotchi
35
37
  # @param title [String, nil] the session's own name (Session#title; a
36
38
  # continue's link inherits its previous link's), saved before the
37
39
  # worker starts: the worker owns the file from then on
38
- # @param setup [SessionSetup] the session's own settings (chi
39
- # --llm-context; a fork and a continue copy their session's; a delegate
40
- # child the toolkit its delegate call named); without a toolkit the
40
+ # @param setup [SessionSetup] the session's own settings (chi --memory,
41
+ # --mute, --llm-context; a fork and a continue copy their session's;
42
+ # a delegate child the memory and toolkit its delegate call named); without a toolkit the
41
43
  # session is stored on config's toolkit.default, a delegate child on
42
44
  # toolkit.child (Toolkit.configured)
43
45
  # @param continues [String, nil] the session this one continues (a
@@ -54,13 +56,20 @@ module Samagotchi
54
56
  # @param scratch [Boolean] a `chi scratch` session (its terminal's
55
57
  # AttachLauncher): its worker runs a scratch Engine and deletes it
56
58
  # once its last client is gone
59
+ # @param client_id [String, nil] who sent +prompt+ (a ClientId); required
60
+ # even when nil is intentional for a session with no initial prompt
61
+ # @param session_id [String, nil] an id reserved by a durable operation
57
62
  # @return [Session] with #seed_images_dropped: refs whose file was gone,
58
63
  # and #model_warning: an id its host's saved list doesn't have
59
- def spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
60
- memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
61
- 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)
64
+ def spawn_session(prompt:, client_id:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
65
+ parent_id: nil, messages: [], images_from: nil, preview: nil, title: nil, delegate: false,
66
+ setup: SessionSetup.new, continues: nil, note: nil, note_source: "chi", before_spawn: nil,
67
+ review: nil, verify_fresh: nil, scratch: false, session_id: nil)
63
68
  sd = state_dir || Session.default_state_dir
69
+ if session_id && (!Session.valid_id?(session_id) || Session.exist?(session_id, state_dir: sd))
70
+ raise ArgumentError, "invalid or existing session id #{session_id.inspect}"
71
+ end
72
+
64
73
  # The resolved ref is stored (a resumed session keeps its model when an
65
74
  # alias is retargeted), with the name as typed beside it. A wrong host
66
75
  # refuses here, before anything is saved or spawned; a model id the
@@ -75,8 +84,6 @@ module Samagotchi
75
84
  model_name: ref,
76
85
  model_typed: typed == ref ? nil : typed,
77
86
  working_directory: working_directory || Dir.pwd,
78
- preloaded_memory_names: memories,
79
- muted_memory_names: muted_memories,
80
87
  parent_id: parent_id,
81
88
  messages: messages,
82
89
  delegate: delegate,
@@ -85,6 +92,7 @@ module Samagotchi
85
92
  review: review,
86
93
  scratch: scratch
87
94
  )
95
+ session.id = session_id if session_id
88
96
  session.toolkit = Toolkit.configured(delegate: delegate) if session.toolkit.nil?
89
97
  session.toolkit_origin = setup.toolkit_origin || "default"
90
98
  # Saved before the worker starts: its first turn is already the requester's.
@@ -98,92 +106,117 @@ module Samagotchi
98
106
  # for it can name it (chi send --new --wait, chi web's create): the
99
107
  # worker runs it with that origin, and its reply file and last_turn
100
108
  # carry the id.
101
- session.first_turn_id = SecureRandom.uuid unless prompt.to_s.strip.empty?
109
+ unless prompt.to_s.strip.empty?
110
+ session.first_turn_id = SecureRandom.uuid
111
+ session.first_turn_client_id = client_id unless ClientId.human?(client_id)
112
+ end
102
113
  # The worker takes last_prompt and clears it, and messages are saved at
103
114
  # the turn's end: until then this is the only preview a list has.
104
115
  session.first_preview = Session.preview_of(preview.to_s.strip.empty? ? prompt : preview)
105
116
  session.title = title
106
117
  session_dir = Session.session_dir(session.id, state_dir: sd)
107
- unless session.messages.empty?
108
- session.messages, dropped = ImageStore.copy_refs(session.messages, from: images_from, to: session_dir)
109
- session.seed_images_dropped = dropped
118
+ # Starting before its file exists: the file is never there unowned and
119
+ # unmarked (a sweep would take a scratch session from under its
120
+ # worker). spawn_worker_for_session hands it to the starter.
121
+ StartingMarker.mark(session.id, state_dir: sd)
122
+ begin
123
+ unless session.messages.empty?
124
+ session.messages, dropped = ImageStore.copy_refs(session.messages, from: images_from, to: session_dir)
125
+ session.seed_images_dropped = dropped
126
+ end
127
+ setup_session_directory(session_dir, session, state_dir: sd)
128
+ SessionInbox.write_note(session.id, text: note, source: note_source, state_dir: sd) if note
129
+ before_spawn&.call(session)
130
+ spawn_worker_for_session(session, state_dir: sd)
131
+ rescue StandardError
132
+ # Nothing is starting: a caller's cleanup (a continue's failed link) may delete it.
133
+ StartingMarker.clear(session.id, state_dir: sd)
134
+ raise
110
135
  end
111
- setup_session_directory(session_dir, session, state_dir: sd)
112
- SessionInbox.write_note(session.id, text: note, source: note_source, state_dir: sd) if note
113
- before_spawn&.call(session)
114
- spawn_worker_for_session(session, state_dir: sd)
115
136
  session
116
137
  end
117
138
 
118
139
  # Ensure an existing session has a live worker process.
119
140
  # Returns the loaded session after state reconciliation.
120
141
  # @raise [OwnedByTUI] when the interactive TUI owns the session
142
+ # @yieldparam outcome [Symbol] what it did, when a block is given:
143
+ # :woken (it spawned the worker), :live (a worker holds the session,
144
+ # or took it since the first read) or :starting (another start is
145
+ # pending, or won the claim)
121
146
  def resume_session(session_id, state_dir: nil)
147
+ session, outcome = resume(session_id, state_dir: state_dir)
148
+ yield outcome if block_given?
149
+ session
150
+ end
151
+
152
+ # resume_session's work, with its outcome.
153
+ # @return [Array(Session, Symbol)]
154
+ private def resume(session_id, state_dir:)
122
155
  sd = state_dir || Session.default_state_dir
123
156
  session = Session.load(session_id, state_dir: sd)
124
157
  # Its terminal's worker came from spawn_session; nothing wakes another.
125
158
  raise ScratchSession, session.id if session.scratch
126
- return session if refuse_tui!(session.id, state_dir: sd)
127
159
 
128
- # status is turn state, not liveness: a new worker runs no turn yet,
129
- # and must not find the session stopped (it would exit): neither the
130
- # marker nor a status field from before it.
131
- # One this process just spawned may not hold the lock yet (a slow
132
- # start, deliver_turn's second resume): it would only lose it.
133
- return session if still_starting?(session.id, state_dir: sd)
134
- # A continue moving delegates to it spawns its worker once they are
135
- # moved; one spawned now (a moved child's ring) would race the move.
136
- return session if StartingMarker.fresh?(session.id, state_dir: sd)
160
+ presence = WorkerPresence.read(session.id, state_dir: sd, owner: session_owner(session.id, state_dir: sd))
161
+ raise OwnedByTUI, session.id if presence.tui_owned?
162
+ return [session, :live] if presence.owned?
137
163
 
138
- Session.clear_stopped(session.id, state_dir: sd)
139
- session.status = Session::STATUS_IDLE
140
- session.save(state_dir: sd)
141
- spawn_worker_for_session(session, state_dir: sd)
142
- session
143
- end
164
+ # A worker being started for it, by this process or another (chi web,
165
+ # a `chi send`, a child's ring; a continue moving delegates to its new
166
+ # link), may not hold the lock yet: a second one would only lose it,
167
+ # or race the move (#resume_held_off?).
168
+ return [session, :starting] if presence.resume_held_off?
144
169
 
145
- # A delegate child rang a parent that has no worker (ChildRing): start
146
- # one, which runs a turn for the report. Not for a parent that is gone,
147
- # stopped, archived, a scratch session, a one-turn run's, or a
148
- # continue's new link still taking its delegates (its own spawn comes):
149
- # the ring waits on disk for whoever runs it next (#resume_session
150
- # itself would clear the stop). A stop landing between the check and
151
- # the resume is a small window, left as is. An archived parent with a
152
- # later link in its chain wakes that link instead: a ring a moved child
153
- # wrote just as the continue moved it reaches the new link that way
154
- # (ChildReports reads its predecessor's rings).
155
- # @return [Symbol] :woken, or why not (:gone, :stopped, :archived,
156
- # :scratch, :owned, :starting)
157
- def wake_for_report(session_id, state_dir: nil, forward: true)
158
- wake_existing_session(session_id, state_dir: state_dir || Session.default_state_dir, forward: forward,
159
- event: :delegate_woke_parent)
170
+ claimed = claim_start(session.id, state_dir: sd, stopped: presence.stop_marked?)
171
+ return [session, claimed] unless claimed == :claimed
172
+
173
+ # The new worker must not find the session stopped (it would exit).
174
+ # Nothing else is written: the file is the worker's, which turns a
175
+ # status from before it idle under the lock (Worker#settle_stale_status).
176
+ begin
177
+ Session.clear_stopped(session.id, state_dir: sd)
178
+ spawn_worker_for_session(session, state_dir: sd)
179
+ rescue StandardError
180
+ # Its claim holds nothing now (a command or the options failed
181
+ # before Process.spawn): no 60 s hold-off.
182
+ StartingMarker.clear(session.id, state_dir: sd)
183
+ raise
184
+ end
185
+ [session, :woken]
160
186
  end
161
187
 
162
- # A pushed context source asked for a wake: start an idle-exited
163
- # session, but never clear a stop or forward an archived link.
188
+ # A ring waits in a parent's children/ (a failed continue's previous
189
+ # link got rings back): wake it as the ring's own delivery would
190
+ # (SessionInbox.wake :ring, its WakePolicy). Not for a parent that is
191
+ # gone, stopped (the stop marker; the file's status never decides),
192
+ # archived, a scratch session, a one-turn run's or owned, or a
193
+ # continue's new link still taking its delegates (its own spawn
194
+ # comes): the ring waits on disk for whoever runs it next. A stop
195
+ # landing between the check and the resume is a small window, left as
196
+ # is. An archived parent with a later link wakes that link (one hop:
197
+ # ChildReports reads its predecessor's rings, no further).
164
198
  # @return [Symbol] :woken, or why not (:gone, :stopped, :archived,
165
199
  # :scratch, :owned, :starting)
166
- def wake_for_context(session_id, state_dir: nil)
167
- wake_existing_session(session_id, state_dir: state_dir || Session.default_state_dir, forward: false,
168
- event: :context_woke_session)
200
+ def wake_for_report(session_id, state_dir: nil)
201
+ sd = state_dir || Session.default_state_dir
202
+ outcome = SessionInbox.wake(:ring, session_id, state_dir: sd, owner: ->(id) { session_owner(id, state_dir: sd) }).wake
203
+ outcome == :live ? :owned : outcome
169
204
  end
170
205
 
171
- private def wake_existing_session(session_id, state_dir:, forward:, event:)
172
- return :gone unless Session.exist?(session_id, state_dir: state_dir)
173
- return :owned if session_owner(session_id, state_dir: state_dir)
174
- return :stopped if Session.stopped_marker?(session_id, state_dir: state_dir)
175
-
176
- if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: state_dir))
177
- following = forward && SessionChain.next_of(session_id, state_dir: state_dir)
178
- return following ? wake_existing_session(following, state_dir: state_dir, forward: false, event: event) : :archived
179
- end
180
- return :starting if StartingMarker.fresh?(session_id, state_dir: state_dir)
181
-
182
- session = Session.load(session_id, state_dir: state_dir)
183
- return :stopped if session.status == Session::STATUS_STOPPED
184
- return :scratch if session.scratch
206
+ # Start a worker for a delivery whose WakePolicy decided :start
207
+ # (SessionInbox.deliver): resume_session, which keeps its own checks
208
+ # (a claim on the starting marker: of starts racing, one spawns),
209
+ # logged as +event+.
210
+ # @param event [Symbol] :delegate_woke_parent (a ring) or :context_woke_session (a push)
211
+ # @return [Symbol] :woken (this start spawned it), :starting (another
212
+ # start won the claim, or is pending), :live (a worker took it since)
213
+ # or :owned (a one-turn chi run or its terminal's scratch session did)
214
+ def start_for_inbox(session_id, state_dir:, event:)
215
+ # A stand-in resume_session that yields nothing counts as a start.
216
+ outcome = :woken
217
+ resume_session(session_id, state_dir: state_dir) { |done| outcome = done }
218
+ return outcome unless outcome == :woken
185
219
 
186
- resume_session(session_id, state_dir: state_dir)
187
220
  details = event == :delegate_woke_parent ? { parent: session_id[0, 8] } : { sid: session_id[0, 8] }
188
221
  Log.info(:worker, event, **details)
189
222
  :woken
@@ -191,37 +224,46 @@ module Samagotchi
191
224
  :owned
192
225
  end
193
226
 
194
- # A worker this process spawned for the session within
195
- # WORKER_START_GRACE is still running, and no stop came since.
196
- private def still_starting?(session_id, state_dir:)
197
- return false if Session.stopped_marker?(session_id, state_dir: state_dir)
198
-
199
- at, waiter = @spawns_mutex.synchronize { @spawns[[state_dir, session_id]] }
200
- return false unless at && waiter.respond_to?(:alive?) && waiter.alive?
227
+ # Take the start for resume_session: the marker claimed (of resumes
228
+ # racing from any processes, one claims it), then the owner read
229
+ # again: a worker that took the lock since the first read cleared an
230
+ # earlier start's marker, and keeps the session. After a stop, a start
231
+ # marked before it is taken over (a stop cancels the hold-off), one a
232
+ # resume made after it holds (two sends after a stop start one worker).
233
+ # @return [Symbol] :claimed (this resume spawns), :starting (another
234
+ # start holds the claim) or :live (a worker took the lock since)
235
+ private def claim_start(session_id, state_dir:, stopped:)
236
+ over_before = stopped ? stopped_at(session_id, state_dir) : nil
237
+ return :starting unless StartingMarker.claim(session_id, state_dir: state_dir, over_before: over_before)
238
+ return :claimed unless session_owner(session_id, state_dir: state_dir)
201
239
 
202
- Process.clock_gettime(Process::CLOCK_MONOTONIC) - at < WORKER_START_GRACE
240
+ StartingMarker.clear(session_id, state_dir: state_dir)
241
+ :live
203
242
  end
204
243
 
205
- private def note_spawn(session_id, state_dir:, waiter:)
206
- @spawns_mutex.synchronize do
207
- @spawns.delete_if { |_, (_, w)| !(w.respond_to?(:alive?) && w.alive?) }
208
- @spawns[[state_dir, session_id]] = [Process.clock_gettime(Process::CLOCK_MONOTONIC), waiter]
244
+ # When the stop marker was written (Session.stopped_at); nil when it
245
+ # is gone (another resume cleared it: its start came after the stop,
246
+ # and holds).
247
+ private def stopped_at(session_id, state_dir) = Session.stopped_at(session_id, state_dir: state_dir)
248
+
249
+ private def note_starter(waiter)
250
+ @starters_mutex.synchronize do
251
+ @starters.select! { |w| w.respond_to?(:alive?) && w.alive? }
252
+ @starters << waiter
209
253
  end
210
254
  end
211
255
 
212
- # Forget the spawns #still_starting? remembers (specs).
256
+ # Forget the starters this process spawned (specs).
213
257
  def forget_spawns
214
- @spawns_mutex.synchronize { @spawns.clear }
258
+ @starters_mutex.synchronize { @starters.clear }
215
259
  end
216
260
 
217
261
  # Starter processes are children of chi web, unlike the detached workers
218
262
  # they launch; the web restart adopts these pids before replacing itself.
219
263
  def live_spawn_pids
220
- @spawns_mutex.synchronize do
221
- @spawns.delete_if { |_, (_, waiter)| !(waiter.respond_to?(:alive?) && waiter.alive?) }
222
- @spawns.values.filter_map do |_, waiter|
223
- waiter.pid if waiter.respond_to?(:pid) && waiter.alive?
224
- end.uniq.sort
264
+ @starters_mutex.synchronize do
265
+ @starters.select! { |waiter| waiter.respond_to?(:alive?) && waiter.alive? }
266
+ @starters.filter_map { |waiter| waiter.pid if waiter.respond_to?(:pid) }.uniq.sort
225
267
  end
226
268
  end
227
269
 
@@ -241,14 +283,39 @@ module Samagotchi
241
283
  opts = spawn_options(session)
242
284
  env = opts.delete(:env)
243
285
  command = worker_command(session.id, state_dir: state_dir)
244
- pid = env ? Process.spawn(env, *command, **opts) : Process.spawn(*command, **opts)
245
- # Reaped when it exits: a long-lived spawner (chi web) keeps no zombies.
286
+ # Starting from here (WorkerPresence#starting?), for as long as this
287
+ # process runs, until the spawn below names its starter.
288
+ StartingMarker.mark(session.id, state_dir: state_dir)
289
+ begin
290
+ pid = env ? Process.spawn(env, *command, **opts) : Process.spawn(*command, **opts)
291
+ rescue StandardError
292
+ StartingMarker.clear(session.id, state_dir: state_dir)
293
+ raise
294
+ end
246
295
  # +pid+ is the worker's starter, which exits once the worker it forked
247
- # holds the lock (WorkerBoot.detach_from_spawner): the lock's pid is the worker's.
248
- note_spawn(session.id, state_dir: state_dir, waiter: Process.detach(pid))
296
+ # holds the lock (WorkerBoot.detach_from_spawner): the lock's pid is
297
+ # the worker's, and the marker holds exactly while the starter runs
298
+ # (most spawners exit right after this). The worker removes it once it
299
+ # holds the lock (Worker#start).
300
+ if starter_running?(pid)
301
+ StartingMarker.mark(session.id, state_dir: state_dir, pid: pid)
302
+ # Reaped when it exits: a long-lived spawner (chi web) keeps no zombies.
303
+ note_starter(Process.detach(pid))
304
+ else
305
+ # It failed to exec or boot: nothing is starting.
306
+ StartingMarker.clear(session.id, state_dir: state_dir)
307
+ end
249
308
  Log.info(:worker, "spawn", sid: session.id, child_pid: pid)
250
309
  pid
251
310
  end
311
+
312
+ # Whether the starter just spawned still runs (reaped here if it
313
+ # already exited). Not a child of this process: nothing to wait for.
314
+ private def starter_running?(pid)
315
+ Process.waitpid(pid, Process::WNOHANG).nil?
316
+ rescue Errno::ECHILD
317
+ false
318
+ end
252
319
  end
253
320
 
254
321
  extend SpawnPart
@@ -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
- # (WorkerBoot.run_session_loop(id, state_dir:)), the legacy forwarder
7
- # below, the env keys 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). The boot command loads it directly; the
32
- # forwarder (run_session_loop) remains for older boot strings. Calls no
33
- # other part. Meant to be loaded 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 legacy entry for boot strings written by older chi
98
- # (CROSS-VERSION CONTRACT: spec/worker_contract_spec.rb): it 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 in the next release: boot strings name WorkerBoot since this
103
- # one (see ~/.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
@@ -59,6 +59,7 @@ require_relative "context_candidates_log"
59
59
  require_relative "session_chain"
60
60
  require_relative "child_move"
61
61
  require_relative "starting_marker"
62
+ require_relative "worker_presence"
62
63
  require_relative "tools/task_runtime"
63
64
 
64
65
  module Samagotchi
@@ -4,6 +4,8 @@ require "fileutils"
4
4
  require "time"
5
5
  require_relative "session"
6
6
  require_relative "archive_store"
7
+ require_relative "worker_presence"
8
+ require_relative "starting_marker"
7
9
  require_relative "config"
8
10
  require_relative "log"
9
11
  require_relative "model_profile"
@@ -18,8 +20,8 @@ module Samagotchi
18
20
  # (session.retention_days), count (session.max_count, counting only the
19
21
  # sessions that stay), statuses kept (session.keep_status), sessions left
20
22
  # empty (SessionManager.discardable?, an hour on), leftover scratch
21
- # sessions (once past SCRATCH_START_GRACE) and skeleton-only directories; a session with a live owner
22
- # always stays.
23
+ # sessions and skeleton-only directories; a session with a live owner, or
24
+ # whose worker is being started (StartingMarker), always stays.
23
25
  module SessionRetention
24
26
  # The session.retention_days / max_count / keep_status defaults.
25
27
  DEFAULT_RETENTION_DAYS = 14
@@ -53,13 +55,19 @@ module Samagotchi
53
55
  dry_run: dry_run,
54
56
  test_only: test_only,
55
57
  any_age: any_age,
56
- alive_check: ->(sid) { !SessionManager.session_owner(sid, state_dir: sd).nil? },
58
+ alive_check: ->(sid) { owned?(sid, sd) },
57
59
  empty_check: discard ? ->(sid) { left_empty?(sid, state_dir: sd, default_model: default_model) } : nil
58
60
  )
59
61
  result[:deleted].concat(prune_orphan_dirs(sd, dry_run: dry_run)) if discard && !test_only
60
62
  result
61
63
  end
62
64
 
65
+ # Someone holds the session's owner lock (a worker, a one-turn run).
66
+ private_class_method def self.owned?(session_id, state_dir)
67
+ WorkerPresence.read(session_id, state_dir: state_dir,
68
+ owner: SessionManager.session_owner(session_id, state_dir: state_dir)).owned?
69
+ end
70
+
63
71
  private_class_method def self.left_empty?(session_id, state_dir:, default_model:)
64
72
  path = Session.session_file(session_id, state_dir: state_dir)
65
73
  Time.now - File.mtime(path) > EMPTY_GRACE_SECONDS &&
@@ -80,7 +88,7 @@ module Samagotchi
80
88
  next unless Session.valid_id?(name) && File.directory?(dir)
81
89
  next if File.exist?(File.join(state_dir, "#{name}#{SessionPaths::FILE_EXT}"))
82
90
  next unless Time.now - File.mtime(dir) > EMPTY_GRACE_SECONDS && SessionManager.empty_session_dir?(dir)
83
- next if SessionManager.session_owner(name, state_dir: state_dir)
91
+ next if owned?(name, state_dir) || starting?(name, state_dir)
84
92
 
85
93
  unless dry_run
86
94
  FileUtils.rm_rf(dir)
@@ -93,14 +101,18 @@ module Samagotchi
93
101
  end
94
102
  end
95
103
 
96
- # How long after its save an unowned scratch session may still be
97
- # waiting for the worker just spawned for it to take its owner lock.
98
- SCRATCH_START_GRACE = SessionManager::WORKER_START_GRACE
104
+ # A worker is being started for it (every spawn marks it, a new
105
+ # session's before its file is saved): no owner yet doesn't mean nobody
106
+ # runs it.
107
+ private_class_method def self.starting?(session_id, state_dir)
108
+ StartingMarker.fresh?(session_id, state_dir: state_dir)
109
+ end
99
110
 
100
- private_class_method def self.scratch_starting?(path, now)
101
- now - File.mtime(path) < SCRATCH_START_GRACE
102
- rescue SystemCallError
103
- false
111
+ # Being started or owned now (+alive_check+, when given, reads the owner).
112
+ private_class_method def self.held?(session_id, state_dir, alive_check)
113
+ starting?(session_id, state_dir) || alive_check&.call(session_id) == true
114
+ rescue StandardError
115
+ true
104
116
  end
105
117
 
106
118
  # Lazy sweep guard: runs prune at most once per SWEEP_INTERVAL_HOURS
@@ -172,9 +184,11 @@ module Samagotchi
172
184
  # count (`chi sessions clean` with no --days: test runs are throwaway).
173
185
  #
174
186
  # A scratch session nobody owns (its terminal and worker were killed) goes whatever its
175
- # age, its status and the count; +test_only+ takes it too. One saved
176
- # within SCRATCH_START_GRACE (its file's mtime, against +now+) stays: its
177
- # worker may not have taken the owner lock yet.
187
+ # age, its status and the count; +test_only+ takes it too.
188
+ #
189
+ # A session whose worker is being started (StartingMarker, fresh) stays,
190
+ # whatever it is, and takes no count slot: its worker may not have taken
191
+ # the owner lock yet.
178
192
  #
179
193
  # +max_count+ counts the sessions that stay: one deleted whatever its
180
194
  # place (scratch, left empty, expired, +any_age+) takes no slot.
@@ -186,6 +200,9 @@ module Samagotchi
186
200
  def self.apply(state_dir: Session.default_state_dir, days: DEFAULT_RETENTION_DAYS, max_count: DEFAULT_MAX_COUNT,
187
201
  keep_status: DEFAULT_KEEP_STATUS, dry_run: false, test_only: false, alive_check: nil,
188
202
  empty_check: nil, any_age: false, now: Time.now)
203
+ require_relative "schedules"
204
+ schedules = Schedules.new(state_dir: state_dir)
205
+ scheduled_targets = schedules.protected_session_ids
189
206
  keep_status = Array(keep_status).map(&:to_s)
190
207
  # Fetch all sessions sorted newest-first for count logic
191
208
  all = Session.list(state_dir: state_dir, sort: "updated_at", order: "desc")
@@ -200,6 +217,7 @@ module Samagotchi
200
217
  deleted = []
201
218
  kept = []
202
219
  skipped = []
220
+ purge_ids = []
203
221
  passed_over = 0
204
222
 
205
223
  all.each_with_index do |session, idx|
@@ -210,6 +228,11 @@ module Samagotchi
210
228
  next
211
229
  end
212
230
 
231
+ if scheduled_targets.include?(session.id)
232
+ kept << session.id
233
+ next
234
+ end
235
+
213
236
  # Protected by keep_status
214
237
  if keep_status.include?(session.status.to_s) && !session.scratch
215
238
  kept << session.id
@@ -228,10 +251,9 @@ module Samagotchi
228
251
  end
229
252
  end
230
253
 
231
- # A scratch session saved within SCRATCH_START_GRACE: `chi scratch`
232
- # saves it, then spawns the worker that takes its owner lock, so no
233
- # owner yet doesn't mean a leftover. Kept, it takes no count slot.
234
- if session.scratch && scratch_starting?(path, now)
254
+ # Being started (a resume, a new or scratch session): its worker
255
+ # takes the owner lock in a moment. Kept, it takes no count slot.
256
+ if starting?(session.id, state_dir)
235
257
  kept << session.id
236
258
  passed_over += 1
237
259
  next
@@ -277,20 +299,28 @@ module Samagotchi
277
299
  # Eligible for deletion
278
300
  if dry_run
279
301
  deleted << session.id
302
+ # Looked at again right before its files go (the list was read
303
+ # once, at the start): resumed or taken by a worker since, it stays.
304
+ elsif held?(session.id, state_dir, alive_check)
305
+ kept << session.id
306
+ elsif scheduled_targets.include?(session.id)
307
+ kept << session.id
280
308
  else
281
309
  begin
282
- FileUtils.rm_f(path)
283
- sidecar = File.join(state_dir, session.id)
284
- FileUtils.rm_rf(sidecar)
285
- PluginSessionState.remove(session.id)
286
- ContextSources.remove_session(session.id, state_dir: state_dir)
310
+ # Under the owner lock, the start read again (as a delete).
311
+ SessionManager.remove_files(session.id, path, File.join(state_dir, session.id), state_dir: state_dir,
312
+ purge_runs: false)
287
313
  deleted << session.id
314
+ purge_ids << session.id
315
+ rescue SessionManager::DeleteRefused
316
+ kept << session.id
288
317
  rescue StandardError
289
318
  skipped << session.id
290
319
  end
291
320
  end
292
321
  end
293
322
 
323
+ SessionManager.purge_removal_stores(purge_ids, state_dir: state_dir)
294
324
  { deleted: deleted, kept: kept, skipped: skipped }
295
325
  end
296
326
  end