samagotchi 0.128.0 → 0.129.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -1
  3. data/docs/architecture.md +12 -5
  4. data/docs/cli.md +2 -1
  5. data/docs/guardrails.md +150 -25
  6. data/docs/internals/participants.md +17 -3
  7. data/docs/internals/web-push.md +4 -1
  8. data/docs/sessions.md +1 -1
  9. data/lib/samagotchi/bridge_client.rb +8 -2
  10. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  11. data/lib/samagotchi/guardrail_wiring.rb +6 -5
  12. data/lib/samagotchi/guardrails/child_boundary.rb +23 -13
  13. data/lib/samagotchi/guardrails/outside.rb +54 -5
  14. data/lib/samagotchi/guardrails/pair_boundary.rb +78 -26
  15. data/lib/samagotchi/guardrails/pair_command.rb +261 -66
  16. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -1
  17. data/lib/samagotchi/guardrails/protected_paths.rb +98 -5
  18. data/lib/samagotchi/guardrails/read_only_shell.rb +191 -8
  19. data/lib/samagotchi/guardrails/review_paths.rb +22 -8
  20. data/lib/samagotchi/guardrails/rm_targets.rb +4 -3
  21. data/lib/samagotchi/guardrails/scratch_writes.rb +3 -1
  22. data/lib/samagotchi/guardrails/shell_git_dirs.rb +67 -35
  23. data/lib/samagotchi/guardrails/shell_lex.rb +1 -1
  24. data/lib/samagotchi/guardrails/shell_paths.rb +73 -25
  25. data/lib/samagotchi/guardrails/targets.rb +128 -24
  26. data/lib/samagotchi/session_manager.rb +9 -15
  27. data/lib/samagotchi/system_one/self_protect.rb +108 -16
  28. data/lib/samagotchi/terminal_ui/exit_lines.rb +2 -1
  29. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  30. data/lib/samagotchi/version.rb +1 -1
  31. data/lib/samagotchi/web/app.rb +9 -7
  32. data/lib/samagotchi/web/public/app.js +8 -1
  33. data/lib/samagotchi/web/public/participant.js +2 -0
  34. data/lib/samagotchi/web/public/verdict_fresh.js +12 -5
  35. data/lib/samagotchi/worker.rb +82 -32
  36. data/lib/samagotchi/worker_idle_exit.rb +17 -0
  37. data/lib/samagotchi/worker_link.rb +13 -5
  38. metadata +1 -1
@@ -57,7 +57,7 @@ module Samagotchi
57
57
  id = @session_id
58
58
  return [:gone, "stopped"] if Session.stopped_marker?(id, state_dir: @state_dir)
59
59
 
60
- link = WorkerLink.resolve(session_dir, unlink: true, host: @host)
60
+ link = WorkerLink.resolve(session_dir, unlink: true)
61
61
  if link.is_a?(WorkerLink::Skew)
62
62
  @failure = link.message(id)
63
63
  return [:gone, "worker_newer"]
@@ -79,7 +79,7 @@ module Samagotchi
79
79
  candidate = if found&.port == port
80
80
  found
81
81
  else
82
- link = WorkerLink.resolve(session_dir, unlink: true, host: @host)
82
+ link = WorkerLink.resolve(session_dir, unlink: true)
83
83
  link.live? && link.port == port ? link.client(@session_id, host: @host) : nil
84
84
  end
85
85
  @client = candidate if candidate
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.128.0"
4
+ VERSION = "0.129.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -118,6 +118,9 @@ module Samagotchi
118
118
  # Seconds GET /api/models waits for the hosts' lists before answering
119
119
  # with what it has (the listing goes on and fills the registry's cache).
120
120
  MODELS_WAIT_TIMEOUT = 4.0
121
+ # The one refusal for an update operation (an install, a workers
122
+ # restart, a chi web restart) asked while another one runs: 409 update_running.
123
+ UPDATE_RUNNING = "a chi update operation is already running; try again when it ends"
121
124
  EVENTS_POLL = 1.0
122
125
 
123
126
  # A callable the event loops ask whether the server still runs;
@@ -702,7 +705,7 @@ module Samagotchi
702
705
  false
703
706
  end
704
707
  end
705
- return error_response(409, "update_running", "an update is already running") if already_running
708
+ return error_response(409, "update_running", UPDATE_RUNNING) if already_running
706
709
 
707
710
  @hub.publish_update(state: "running", rows: [], operation: "install", version: version)
708
711
  Thread.new { perform_update(version) }
@@ -774,7 +777,7 @@ module Samagotchi
774
777
  false
775
778
  end
776
779
  end
777
- return error_response(409, "update_running", "a chi update operation is already running") if already_running
780
+ return error_response(409, "update_running", UPDATE_RUNNING) if already_running
778
781
 
779
782
  @hub.publish_update(state: "running", rows: [], operation: "workers")
780
783
  Thread.new { perform_worker_restarts }
@@ -834,7 +837,7 @@ module Samagotchi
834
837
  @update_running = true # held for good once restarting: the exec ends this process
835
838
  false
836
839
  end
837
- return error_response(409, "update_running", "a chi update operation is running; restart chi web when it ends") if busy
840
+ return error_response(409, "update_running", UPDATE_RUNNING) if busy
838
841
 
839
842
  case @restart_web.call
840
843
  when true then return json_response(202, { state: "restarting" })
@@ -1721,8 +1724,7 @@ module Samagotchi
1721
1724
  outcome.is_a?(WorkerLink::Leaving) || outcome.is_a?(WorkerLink::Stale) ||
1722
1725
  outcome.is_a?(WorkerLink::Skew) ||
1723
1726
  (outcome.is_a?(WorkerLink::Starting) && outcome.stuck)
1724
- end,
1725
- host: DEFAULT_HOST)
1727
+ end)
1726
1728
  end
1727
1729
 
1728
1730
  def stream_link_error(id, link)
@@ -1741,7 +1743,7 @@ module Samagotchi
1741
1743
  def stream_open_error(id, outcome)
1742
1744
  return error_response(503, "not_live", WorkerLink.unanswered(id)) if outcome.is_a?(BridgeClient::StreamOpen::Unreachable)
1743
1745
  return error_response(503, "not_live", WorkerLink.not_live(id)) if outcome.reason == :unknown_session
1744
- return error_response(502, "bridge_secret", BridgeSecret.refused(id)) if outcome.reason == :bridge_secret
1746
+ return error_response(502, BridgeSecret::ERROR, BridgeSecret.refused(id)) if outcome.reason == :bridge_secret
1745
1747
 
1746
1748
  error_response(502, "bridge_error", outcome.detail || "the session's worker answered #{outcome.status}")
1747
1749
  end
@@ -1752,7 +1754,7 @@ module Samagotchi
1752
1754
  # @return [WorkerLink::Live, WorkerLink::None, WorkerLink::Stale]
1753
1755
  def worker_link(session_id)
1754
1756
  dir = @session_class.session_dir(session_id, state_dir: default_state_dir)
1755
- WorkerLink.resolve(dir, unlink: true, host: DEFAULT_HOST)
1757
+ WorkerLink.resolve(dir, unlink: true)
1756
1758
  rescue StandardError
1757
1759
  WorkerLink::None.new
1758
1760
  end
@@ -60,7 +60,7 @@ import { createSystemOneWrongAction } from "./system_one_wrong.js";
60
60
  import { appendQuote } from "./annotations.js";
61
61
  import { applyComposerText, applyRowTemplate, fetchApply } from "./review_apply.js";
62
62
  import { CHECK_NOT_ON_PAGE, nameVerdictCards, ownerCheckHash } from "./verdict_card.js";
63
- import { freshURL, placeVerdictWithFresh, settleRunning } from "./verdict_fresh.js";
63
+ import { freshURL, placeVerdictWithFresh, redrawFresh, settleRunning } from "./verdict_fresh.js";
64
64
  import { authorBadge, nameUnnamedBadges } from "./participant_names.js";
65
65
  import { parsePresets } from "./annotate_presets.js";
66
66
  import { createAnnotateBar } from "./annotate_bar.js";
@@ -590,8 +590,13 @@ function copyUpdateCommand(command) {
590
590
  function followSelectedSession(sess) {
591
591
  current.owner = sess.owner || null;
592
592
  current.workerVersion = sess.worker_version || null;
593
+ const verifyWas = verifyWorkerUnavailable(current.workerFeatures, current.bridgeUp);
593
594
  current.workerFeatures = Array.isArray(sess.worker_features) ? sess.worker_features : [];
594
595
  current.bridgeUp = sess.bridge_up === true;
596
+ // The Verify gate flipped (a restart brought the feature): the cards' Verify fresh part follows.
597
+ if (verifyWas !== verifyWorkerUnavailable(current.workerFeatures, current.bridgeUp)) {
598
+ redrawFresh(historyEl, verifyFreshState());
599
+ }
595
600
  const streaming = !!(streamControl && streamControl.live);
596
601
  if (!streaming) current.status = sess.status;
597
602
  updateInfoBar();
@@ -1356,6 +1361,7 @@ function placeVerdict(record) {
1356
1361
 
1357
1362
  // The owner's Verify fresh on an unclear verdict's card (verdict_fresh.js):
1358
1363
  // always theirs to start; its running check links to the child's session.
1364
+ const verifyFreshErrors = {};
1359
1365
  function verifyFreshState() {
1360
1366
  const id = selected;
1361
1367
  const unavailable = verifyWorkerUnavailable(current.workerFeatures, current.bridgeUp);
@@ -1363,6 +1369,7 @@ function verifyFreshState() {
1363
1369
  return {
1364
1370
  verdicts: current.verdicts || [],
1365
1371
  running: current.verifyFresh,
1372
+ errors: verifyFreshErrors,
1366
1373
  canStart: !!id && !unavailable,
1367
1374
  unavailableReason: unavailable ? VERIFY_UNAVAILABLE : null,
1368
1375
  start: async (of) => {
@@ -434,12 +434,14 @@ function renderThread() {
434
434
  // participant starts one while their grant could run a turn now
435
435
  // (can_comment); the owner looking through the participant view never
436
436
  // does, and no one here gets a link to the child's session.
437
+ const freshErrors = {};
437
438
  function verifyFreshState() {
438
439
  if (!Array.isArray(data.fresh_running)) data.fresh_running = [];
439
440
  const unavailable = data.session?.verify_unavailable === true;
440
441
  return {
441
442
  verdicts: data.verdicts || [],
442
443
  running: data.fresh_running,
444
+ errors: freshErrors,
443
445
  canStart: !unavailable && !ownerView && !ended && !!data.viewer?.can_comment,
444
446
  unavailableReason: unavailable ? VERIFY_UNAVAILABLE : null,
445
447
  start: async (of) => {
@@ -29,7 +29,8 @@ export function settleRunning(running, record) {
29
29
  // Places a session verdict as the pages draw them: an original verdict's
30
30
  // card (verdict_card.js) with its Verify fresh part, or a fresh verdict on
31
31
  // its original's card. +fresh+: {verdicts (all of the session's), running
32
- // (the page's own array; a start adds to it), canStart, start(of) → Promise
32
+ // (the page's own array; a start adds to it), errors (the page's own
33
+ // object, check id → the last start error, so a redraw shows it again), canStart, start(of) → Promise
33
34
  // of {ok, child_id, error}, childHref(id) → a link to the child or null}.
34
35
  export function placeVerdictWithFresh(root, record, opts = {}, fresh = {}) {
35
36
  if (record?.fresh) {
@@ -43,6 +44,11 @@ export function placeVerdictWithFresh(root, record, opts = {}, fresh = {}) {
43
44
  return card;
44
45
  }
45
46
 
47
+ // Every verdict card under +root+ drawn again from +fresh+ (the gate changed).
48
+ export function redrawFresh(root, fresh = {}) {
49
+ for (const card of root?.querySelectorAll?.(".verify-verdict") || []) renderFresh(card, fresh);
50
+ }
51
+
46
52
  // The card's Verify fresh part, drawn again from +fresh+: the fresh
47
53
  // verdicts, the running check, else the button (an unclear verdict, and a
48
54
  // viewer who could run a turn now).
@@ -100,10 +106,11 @@ export function renderFresh(card, fresh = {}) {
100
106
  part.append(button);
101
107
  }
102
108
 
103
- if (card.dataset.freshError) {
109
+ const error = (fresh.errors ||= {})[of];
110
+ if (error) {
104
111
  const line = doc.createElement("p");
105
112
  line.className = "verify-fresh-error";
106
- line.textContent = card.dataset.freshError;
113
+ line.textContent = error;
107
114
  part.append(line);
108
115
  }
109
116
  if (part.childNodes.length) card.append(part);
@@ -120,12 +127,12 @@ async function startFresh(card, button, fresh) {
120
127
  result = { ok: false, error: error?.message };
121
128
  }
122
129
  if (result?.ok) {
123
- delete card.dataset.freshError;
130
+ if (fresh.errors) delete fresh.errors[of];
124
131
  if (fresh.running && !fresh.running.some((entry) => runningOf(entry) === of)) {
125
132
  fresh.running.push(result.child_id ? { of, child_id: result.child_id } : of);
126
133
  }
127
134
  } else {
128
- card.dataset.freshError = result?.error || "Couldn't start a fresh check.";
135
+ (fresh.errors ||= {})[of] = result?.error || "Couldn't start a fresh check.";
129
136
  }
130
137
  renderFresh(card, fresh);
131
138
  }
@@ -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).
@@ -1583,41 +1587,89 @@ module Samagotchi
1583
1587
  def exit_request(client_id, delete: false, restart: false, stop: false, ask: true)
1584
1588
  # The Bridge serves before the idle-exit policy exists.
1585
1589
  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
1590
 
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
1591
+ kind = if restart then :restart
1592
+ elsif delete then :delete
1593
+ else :exit
1594
+ end
1595
+ # One exit at a time, and the first one taken decides how the worker
1596
+ # leaves (a later stop-all can't turn a restart, whose reminders go to
1597
+ # the next worker, into a plain exit): claimed before the slow
1598
+ # stop-all, so a request of another kind meanwhile is refused (409),
1599
+ # not told it went through. Held at the leave, it is forgotten and the
1600
+ # next request counts again.
1601
+ refused = claim_exit(kind)
1602
+ return refused.value if refused
1603
+
1604
+ hold = exit_hold(client_id, kind: kind, stop: stop, ask: ask)
1605
+ if hold
1606
+ forget_exit
1607
+ return hold
1612
1608
  end
1613
1609
 
1614
1610
  @stopped_holds = nil unless stop
1615
- @exit_requested = true
1616
1611
  @exit_requested_by = client_id
1617
- @exit_deletes = delete && !restart
1618
- @exit_restart = restart
1612
+ @exit_deletes = kind == :delete
1613
+ @exit_restart = kind == :restart
1614
+ @exit_requested = true
1619
1615
  @waker.wake
1620
1616
  nil
1617
+ rescue StandardError
1618
+ # A look or a stop that raised lets the claim go (the Bridge answers 500).
1619
+ forget_exit unless @exit_requested
1620
+ raise
1621
+ end
1622
+
1623
+ # One answer for an exit request that doesn't get the claim, boxed so a
1624
+ # nil (accepted) is told apart from "claimed".
1625
+ ExitRefused = Data.define(:value)
1626
+
1627
+ # @return [ExitRefused, nil] nil when this request now holds the claim;
1628
+ # else its answer: nil (accepted: an exit of the same kind is already
1629
+ # taken) or WorkerIdleExit.leaving (one of another kind is, or is still
1630
+ # being taken)
1631
+ def claim_exit(kind)
1632
+ @exit_claim_mutex.synchronize do
1633
+ next ExitRefused.new(value: nil) if @exit_claim == kind && @exit_requested
1634
+ next ExitRefused.new(value: WorkerIdleExit.leaving(@exit_claim)) if @exit_claim
1635
+
1636
+ @exit_claim = kind
1637
+ nil
1638
+ end
1639
+ end
1640
+
1641
+ # The taken exit is let go: held at the leave, or its hand-off failed.
1642
+ def forget_exit
1643
+ @exit_claim_mutex.synchronize do
1644
+ @exit_requested = nil
1645
+ @exit_restart = false
1646
+ @exit_claim = nil
1647
+ end
1648
+ end
1649
+
1650
+ # What keeps the worker up for this request, nil when nothing does; a
1651
+ # stop request stops what the user can stop first (#exit_request).
1652
+ # @return [WorkerIdleExit::Hold, nil]
1653
+ def exit_hold(client_id, kind:, stop:, ask:)
1654
+ if kind == :restart
1655
+ hold = @idle_exit.hold_for_restart
1656
+ return hold && WorkerIdleExit::Hold.new(reason: hold)
1657
+ end
1658
+
1659
+ # /exit names the tasks it waits for (D3), from the one look that found them.
1660
+ # The ask covers the delegate children too, but only for a plain /exit: an
1661
+ # exit that deletes the session, or one a UI won't ask about, ignores them.
1662
+ hold = @idle_exit.request_hold(requester: client_id, hint: TASK_STOP_HINT, ask: ask && kind != :delete)
1663
+ return nil unless hold
1664
+ # A hold the user can't stop here: no ask, today's refusal.
1665
+ return hold unless hold.ask?
1666
+ # The ask's 409: the list, for the terminal to ask with.
1667
+ return hold unless stop
1668
+
1669
+ @stopped_holds = @hold_stop.stop_all
1670
+ # An unstoppable hold may have arrived while stopping (a child stopped
1671
+ # here is no longer counted).
1672
+ @idle_exit.request_hold(requester: client_id, hint: TASK_STOP_HINT)
1621
1673
  end
1622
1674
 
1623
1675
  # #leave_idle for an exit a client asked for: check again with the event
@@ -1636,8 +1688,7 @@ module Samagotchi
1636
1688
  @idle_exit.hold_for_request(requester: @exit_requested_by, streams: false)
1637
1689
  end
1638
1690
  if hold
1639
- @exit_requested = nil
1640
- @exit_restart = false
1691
+ forget_exit
1641
1692
  Log.info(:worker, "exit_held", reason: hold)
1642
1693
  next false
1643
1694
  end
@@ -1666,8 +1717,7 @@ module Samagotchi
1666
1717
  end
1667
1718
  true
1668
1719
  rescue StandardError => e
1669
- @exit_requested = nil
1670
- @exit_restart = false
1720
+ forget_exit
1671
1721
  Log.warn(:worker, "reminders_carry_failed", error: e.class.name, msg: e.message)
1672
1722
  false
1673
1723
  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.129.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov