samagotchi 0.128.0 → 0.130.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +126 -1
  3. data/docs/architecture.md +13 -6
  4. data/docs/cli.md +20 -10
  5. data/docs/configuration.md +7 -2
  6. data/docs/guardrails.md +202 -33
  7. data/docs/internals/background-tasks.md +11 -0
  8. data/docs/internals/engine.md +4 -3
  9. data/docs/internals/participants.md +29 -6
  10. data/docs/internals/remote-sessions.md +5 -4
  11. data/docs/internals/web-push.md +4 -1
  12. data/docs/sessions.md +2 -2
  13. data/lib/samagotchi/bridge_client.rb +8 -2
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/engine.rb +20 -4
  16. data/lib/samagotchi/engine_peers.rb +3 -1
  17. data/lib/samagotchi/guardrail_wiring.rb +6 -5
  18. data/lib/samagotchi/guardrails/chi_dirs.rb +64 -0
  19. data/lib/samagotchi/guardrails/child_boundary.rb +26 -16
  20. data/lib/samagotchi/guardrails/outside.rb +68 -17
  21. data/lib/samagotchi/guardrails/pair_boundary.rb +89 -26
  22. data/lib/samagotchi/guardrails/pair_command.rb +327 -72
  23. data/lib/samagotchi/guardrails/parent_approvals.rb +8 -10
  24. data/lib/samagotchi/guardrails/path_resolve.rb +231 -0
  25. data/lib/samagotchi/guardrails/protected_paths.rb +18 -24
  26. data/lib/samagotchi/guardrails/read_only_shell.rb +191 -8
  27. data/lib/samagotchi/guardrails/review_paths.rb +30 -14
  28. data/lib/samagotchi/guardrails/rm_targets.rb +7 -6
  29. data/lib/samagotchi/guardrails/rules.rb +23 -5
  30. data/lib/samagotchi/guardrails/scratch_writes.rb +4 -1
  31. data/lib/samagotchi/guardrails/shell_git_dirs.rb +75 -38
  32. data/lib/samagotchi/guardrails/shell_lex.rb +1 -1
  33. data/lib/samagotchi/guardrails/shell_paths.rb +85 -48
  34. data/lib/samagotchi/guardrails/targets.rb +131 -29
  35. data/lib/samagotchi/remote_command.rb +13 -3
  36. data/lib/samagotchi/remotes.rb +3 -2
  37. data/lib/samagotchi/review_start.rb +3 -2
  38. data/lib/samagotchi/session_inbox.rb +17 -0
  39. data/lib/samagotchi/session_manager.rb +9 -15
  40. data/lib/samagotchi/system_one/self_protect.rb +127 -20
  41. data/lib/samagotchi/terminal_ui/exit_lines.rb +2 -1
  42. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  43. data/lib/samagotchi/terminal_ui.rb +2 -0
  44. data/lib/samagotchi/tool_declarations.rb +1 -1
  45. data/lib/samagotchi/tools/builtins.rb +10 -1
  46. data/lib/samagotchi/tools/peers.rb +4 -0
  47. data/lib/samagotchi/tools/task_wait.rb +26 -1
  48. data/lib/samagotchi/turn_state.rb +23 -4
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +32 -25
  51. data/lib/samagotchi/web/proxy_gate.rb +8 -6
  52. data/lib/samagotchi/web/public/app.js +8 -1
  53. data/lib/samagotchi/web/public/participant.js +2 -0
  54. data/lib/samagotchi/web/public/verdict_fresh.js +12 -5
  55. data/lib/samagotchi/web/server.rb +29 -4
  56. data/lib/samagotchi/worker.rb +91 -33
  57. data/lib/samagotchi/worker_idle_exit.rb +17 -0
  58. data/lib/samagotchi/worker_link.rb +13 -5
  59. metadata +3 -1
@@ -168,6 +168,10 @@ module Samagotchi
168
168
  @loop_started = false
169
169
  @left_loop = false
170
170
  @signal_mutex = Mutex.new
171
+ # The exit being taken or taken (:exit, :delete, :restart), claimed
172
+ # under its own lock before #exit_request's slow stop-all (#claim_exit).
173
+ @exit_claim = nil
174
+ @exit_claim_mutex = Mutex.new
171
175
  @grace_expired = false
172
176
  # The running turn has ended (its end event) and its after_turn hooks
173
177
  # run: a signal's grace waits for them (#signalled).
@@ -1241,7 +1245,8 @@ module Samagotchi
1241
1245
  # status, its rings and every save from here on say so.
1242
1246
  @session.parent_id = Session.parent_override(@session_id, state_dir: @state_dir) || @session.parent_id
1243
1247
  save_session
1244
- result = @engine.run_turn(@session, prompt, pending_input: pending_input_drain, **turn_args)
1248
+ result = @engine.run_turn(@session, prompt, pending_input: pending_input_drain, input_waiting: method(:input_waiting?),
1249
+ **turn_args)
1245
1250
  rescue StandardError => e
1246
1251
  error = e
1247
1252
  # Still running: no turn of the Engine's ended (it never began).
@@ -1464,6 +1469,13 @@ module Samagotchi
1464
1469
  end
1465
1470
  end
1466
1471
 
1472
+ # Whether a person's message waits that the next boundary merges
1473
+ # (SessionInbox.steer_waiting?): a running task_wait ends on it. Never
1474
+ # in a participant's turn, whose drain merges none.
1475
+ def input_waiting?
1476
+ !@participant_turn && SessionInbox.steer_waiting?(@session_dir)
1477
+ end
1478
+
1467
1479
  # A participant's author fields (Engine#author_fields), {} for the owner.
1468
1480
  def turn_author_fields(origin)
1469
1481
  origin.participant? ? @engine.author_fields(origin.author, session: @session) : {}
@@ -1583,41 +1595,89 @@ module Samagotchi
1583
1595
  def exit_request(client_id, delete: false, restart: false, stop: false, ask: true)
1584
1596
  # The Bridge serves before the idle-exit policy exists.
1585
1597
  return WorkerIdleExit::Hold.new(reason: :starting) unless @idle_exit
1586
- # One exit is already taken: the first one decides how the worker
1587
- # leaves (a later stop-all can't turn a restart, whose reminders go to
1588
- # the next worker, into a plain exit). Held at the leave, it is
1589
- # forgotten and the next request counts again.
1590
- return nil if @exit_requested
1591
1598
 
1592
- if restart
1593
- hold = @idle_exit.hold_for_restart
1594
- return WorkerIdleExit::Hold.new(reason: hold) if hold
1595
- else
1596
- # /exit names the tasks it waits for (D3), from the one look that found them.
1597
- # The ask covers the delegate children too, but only for a plain /exit: an
1598
- # exit that deletes the session, or one a UI won't ask about, ignores them.
1599
- hold = @idle_exit.request_hold(requester: client_id, hint: TASK_STOP_HINT, ask: ask && !delete)
1600
- if hold
1601
- # A hold the user can't stop here: no ask, today's refusal.
1602
- return hold unless hold.ask?
1603
- # The ask's 409: the list, for the terminal to ask with.
1604
- return hold unless stop
1605
-
1606
- @stopped_holds = @hold_stop.stop_all
1607
- # An unstoppable hold may have arrived while stopping (a child stopped
1608
- # here is no longer counted).
1609
- recheck = @idle_exit.request_hold(requester: client_id, hint: TASK_STOP_HINT)
1610
- return recheck if recheck
1611
- end
1599
+ kind = if restart then :restart
1600
+ elsif delete then :delete
1601
+ else :exit
1602
+ end
1603
+ # One exit at a time, and the first one taken decides how the worker
1604
+ # leaves (a later stop-all can't turn a restart, whose reminders go to
1605
+ # the next worker, into a plain exit): claimed before the slow
1606
+ # stop-all, so a request of another kind meanwhile is refused (409),
1607
+ # not told it went through. Held at the leave, it is forgotten and the
1608
+ # next request counts again.
1609
+ refused = claim_exit(kind)
1610
+ return refused.value if refused
1611
+
1612
+ hold = exit_hold(client_id, kind: kind, stop: stop, ask: ask)
1613
+ if hold
1614
+ forget_exit
1615
+ return hold
1612
1616
  end
1613
1617
 
1614
1618
  @stopped_holds = nil unless stop
1615
- @exit_requested = true
1616
1619
  @exit_requested_by = client_id
1617
- @exit_deletes = delete && !restart
1618
- @exit_restart = restart
1620
+ @exit_deletes = kind == :delete
1621
+ @exit_restart = kind == :restart
1622
+ @exit_requested = true
1619
1623
  @waker.wake
1620
1624
  nil
1625
+ rescue StandardError
1626
+ # A look or a stop that raised lets the claim go (the Bridge answers 500).
1627
+ forget_exit unless @exit_requested
1628
+ raise
1629
+ end
1630
+
1631
+ # One answer for an exit request that doesn't get the claim, boxed so a
1632
+ # nil (accepted) is told apart from "claimed".
1633
+ ExitRefused = Data.define(:value)
1634
+
1635
+ # @return [ExitRefused, nil] nil when this request now holds the claim;
1636
+ # else its answer: nil (accepted: an exit of the same kind is already
1637
+ # taken) or WorkerIdleExit.leaving (one of another kind is, or is still
1638
+ # being taken)
1639
+ def claim_exit(kind)
1640
+ @exit_claim_mutex.synchronize do
1641
+ next ExitRefused.new(value: nil) if @exit_claim == kind && @exit_requested
1642
+ next ExitRefused.new(value: WorkerIdleExit.leaving(@exit_claim)) if @exit_claim
1643
+
1644
+ @exit_claim = kind
1645
+ nil
1646
+ end
1647
+ end
1648
+
1649
+ # The taken exit is let go: held at the leave, or its hand-off failed.
1650
+ def forget_exit
1651
+ @exit_claim_mutex.synchronize do
1652
+ @exit_requested = nil
1653
+ @exit_restart = false
1654
+ @exit_claim = nil
1655
+ end
1656
+ end
1657
+
1658
+ # What keeps the worker up for this request, nil when nothing does; a
1659
+ # stop request stops what the user can stop first (#exit_request).
1660
+ # @return [WorkerIdleExit::Hold, nil]
1661
+ def exit_hold(client_id, kind:, stop:, ask:)
1662
+ if kind == :restart
1663
+ hold = @idle_exit.hold_for_restart
1664
+ return hold && WorkerIdleExit::Hold.new(reason: hold)
1665
+ end
1666
+
1667
+ # /exit names the tasks it waits for (D3), from the one look that found them.
1668
+ # The ask covers the delegate children too, but only for a plain /exit: an
1669
+ # exit that deletes the session, or one a UI won't ask about, ignores them.
1670
+ hold = @idle_exit.request_hold(requester: client_id, hint: TASK_STOP_HINT, ask: ask && kind != :delete)
1671
+ return nil unless hold
1672
+ # A hold the user can't stop here: no ask, today's refusal.
1673
+ return hold unless hold.ask?
1674
+ # The ask's 409: the list, for the terminal to ask with.
1675
+ return hold unless stop
1676
+
1677
+ @stopped_holds = @hold_stop.stop_all
1678
+ # An unstoppable hold may have arrived while stopping (a child stopped
1679
+ # here is no longer counted).
1680
+ @idle_exit.request_hold(requester: client_id, hint: TASK_STOP_HINT)
1621
1681
  end
1622
1682
 
1623
1683
  # #leave_idle for an exit a client asked for: check again with the event
@@ -1636,8 +1696,7 @@ module Samagotchi
1636
1696
  @idle_exit.hold_for_request(requester: @exit_requested_by, streams: false)
1637
1697
  end
1638
1698
  if hold
1639
- @exit_requested = nil
1640
- @exit_restart = false
1699
+ forget_exit
1641
1700
  Log.info(:worker, "exit_held", reason: hold)
1642
1701
  next false
1643
1702
  end
@@ -1666,8 +1725,7 @@ module Samagotchi
1666
1725
  end
1667
1726
  true
1668
1727
  rescue StandardError => e
1669
- @exit_requested = nil
1670
- @exit_restart = false
1728
+ forget_exit
1671
1729
  Log.warn(:worker, "reminders_carry_failed", error: e.class.name, msg: e.message)
1672
1730
  false
1673
1731
  end
@@ -39,6 +39,23 @@ module Samagotchi
39
39
  def ask? = !holds.empty?
40
40
  end
41
41
 
42
+ # The 409 reason for an exit request when another client's exit, of
43
+ # another kind, is already taken (Worker#exit_request): the first one
44
+ # decides how the worker leaves, so a restart asked after a plain /exit
45
+ # is refused rather than told "restarting".
46
+ LEAVING = { exit: :leaving_exit, delete: :leaving_delete, restart: :leaving_restart }.freeze
47
+ # What each of those says, for every client's refusal line
48
+ # (TerminalUI::ExitLines, SessionManager::RestartRefused).
49
+ LEAVING_WORDS = {
50
+ leaving_exit: "the worker is already leaving as a plain exit",
51
+ leaving_delete: "the worker is already leaving as an exit that deletes the session",
52
+ leaving_restart: "the worker is already leaving as a restart"
53
+ }.freeze
54
+
55
+ # @param kind [Symbol] the exit already taken (:exit, :delete or :restart)
56
+ # @return [Hold] the refusal for a request of another kind
57
+ def self.leaving(kind) = Hold.new(reason: LEAVING.fetch(kind))
58
+
42
59
  # One kind of hold the exit ask offers to stop: +kind+ (:reminders,
43
60
  # :background_tasks or :delegates) and its +items+ (#HoldItem). The 409's
44
61
  # +holds+ is this in JSON: `{kind:, items: [{id:, label:, detail:}]}`.
@@ -121,7 +121,7 @@ module Samagotchi
121
121
  # @param owner [OwnerLock::Owner, nil, :read] the lock's holder as the
122
122
  # caller already read it (nil: free); :read reads it here
123
123
  # @return [Live, Starting, Leaving, None, Stale, Skew]
124
- def resolve(session_dir, unlink:, owner: :read, host: BridgeClient::HOST)
124
+ def resolve(session_dir, unlink:, owner: :read)
125
125
  owner = OwnerLock.owner(session_dir) if owner == :read
126
126
  stamp = file_stamp(session_dir)
127
127
  sidecar = WorkerSidecar.read(session_dir)
@@ -136,7 +136,7 @@ module Samagotchi
136
136
 
137
137
  # @return [BridgeClient, nil] a client for the session's Live worker
138
138
  def client(session_id, session_dir:, unlink: true, host: BridgeClient::HOST, read_timeout: BridgeClient::READ_TIMEOUT)
139
- link = resolve(session_dir, unlink: unlink, host: host)
139
+ link = resolve(session_dir, unlink: unlink)
140
140
  link.live? ? link.client(session_id, host: host, read_timeout: read_timeout) : nil
141
141
  end
142
142
 
@@ -152,11 +152,11 @@ module Samagotchi
152
152
  # @return [Live, Starting, Leaving, None, Stale, Skew] the outcome it
153
153
  # stopped at, else the last one seen
154
154
  def await(_session_id, session_dir:, timeout:, unlink: true, interval: BridgeClient::SPAWN_POLL_INTERVAL,
155
- until: nil, give_up: nil, host: BridgeClient::HOST)
155
+ until: nil, give_up: nil)
156
156
  wanted = binding.local_variable_get(:until) # a keyword: no plain read
157
157
  last = nil
158
158
  found = BridgeClient.poll(timeout, interval: interval) do
159
- last = resolve(session_dir, unlink: unlink, host: host)
159
+ last = resolve(session_dir, unlink: unlink)
160
160
  last if (last.live? && (wanted.nil? || wanted.call(last))) || give_up&.call(last)
161
161
  end
162
162
  found || last
@@ -189,7 +189,15 @@ module Samagotchi
189
189
  def unowned(session_dir, sidecar, owner, stamp, unlink)
190
190
  return None.new(owner: owner) unless sidecar
191
191
 
192
- unlink_unchanged(session_dir, stamp) if unlink
192
+ if unlink
193
+ # A worker that took the lock and wrote its sidecar since the lock
194
+ # was read (before the stamp): its fresh file is the one stamped, so
195
+ # read the lock again just before the unlink, and take that worker.
196
+ fresh = OwnerLock.owner(session_dir)
197
+ return resolve(session_dir, unlink: false, owner: fresh) if fresh&.worker?
198
+
199
+ unlink_unchanged(session_dir, stamp)
200
+ end
193
201
  Stale.new(sidecar: sidecar, reason: :lock_free, owner: owner)
194
202
  end
195
203
  private_class_method :unowned
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.128.0
4
+ version: 0.130.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -342,6 +342,7 @@ files:
342
342
  - lib/samagotchi/guardrails.rb
343
343
  - lib/samagotchi/guardrails/approval.rb
344
344
  - lib/samagotchi/guardrails/approvals.rb
345
+ - lib/samagotchi/guardrails/chi_dirs.rb
345
346
  - lib/samagotchi/guardrails/child_boundary.rb
346
347
  - lib/samagotchi/guardrails/context.rb
347
348
  - lib/samagotchi/guardrails/data_readers.rb
@@ -354,6 +355,7 @@ files:
354
355
  - lib/samagotchi/guardrails/parent_approvals.rb
355
356
  - lib/samagotchi/guardrails/parent_levels.rb
356
357
  - lib/samagotchi/guardrails/parent_policy.rb
358
+ - lib/samagotchi/guardrails/path_resolve.rb
357
359
  - lib/samagotchi/guardrails/protected_paths.rb
358
360
  - lib/samagotchi/guardrails/read_only_shell.rb
359
361
  - lib/samagotchi/guardrails/review_boundary.rb