samagotchi 0.102.0 → 0.104.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -1
  3. data/bin/chi +60 -191
  4. data/docs/architecture.md +67 -1
  5. data/docs/cli.md +190 -9
  6. data/docs/configuration.md +7 -1
  7. data/docs/guardrails.md +4 -2
  8. data/docs/internals/context-telemetry.md +6 -2
  9. data/docs/internals/engine.md +5 -2
  10. data/docs/internals/llm-context-forget.md +11 -1
  11. data/docs/internals/participants.md +137 -0
  12. data/docs/internals/remote-sessions.md +429 -0
  13. data/docs/internals/working-set.md +95 -11
  14. data/docs/plugins.md +33 -3
  15. data/docs/sessions.md +16 -7
  16. data/docs/sub-agent.md +16 -0
  17. data/docs/testing.md +68 -4
  18. data/lib/samagotchi/answer_command.rb +27 -25
  19. data/lib/samagotchi/bridge.rb +31 -7
  20. data/lib/samagotchi/bridge_client/event_stream.rb +86 -12
  21. data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
  22. data/lib/samagotchi/bridge_client.rb +26 -39
  23. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
  25. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
  27. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
  28. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  29. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  30. data/lib/samagotchi/chi_invocation.rb +40 -0
  31. data/lib/samagotchi/cli/command.rb +3 -2
  32. data/lib/samagotchi/cli/flags.rb +5 -0
  33. data/lib/samagotchi/cli/parent_wait.rb +22 -13
  34. data/lib/samagotchi/context_command.rb +80 -73
  35. data/lib/samagotchi/context_status.rb +15 -2
  36. data/lib/samagotchi/engine.rb +28 -14
  37. data/lib/samagotchi/guardrail_wiring.rb +7 -1
  38. data/lib/samagotchi/idle_recap.rb +8 -2
  39. data/lib/samagotchi/idle_scheduler.rb +34 -9
  40. data/lib/samagotchi/kernel_loop.rb +21 -2
  41. data/lib/samagotchi/launch_mode.rb +26 -0
  42. data/lib/samagotchi/llm/chat_loop.rb +8 -1
  43. data/lib/samagotchi/llm_context_forget.rb +13 -6
  44. data/lib/samagotchi/note_command.rb +8 -7
  45. data/lib/samagotchi/owner_lock.rb +65 -5
  46. data/lib/samagotchi/parent_report.rb +55 -43
  47. data/lib/samagotchi/participants.rb +122 -0
  48. data/lib/samagotchi/remote_attach_command.rb +119 -0
  49. data/lib/samagotchi/remote_command.rb +170 -0
  50. data/lib/samagotchi/remotes.rb +130 -0
  51. data/lib/samagotchi/run_options.rb +245 -0
  52. data/lib/samagotchi/send_command/busy.rb +58 -0
  53. data/lib/samagotchi/send_command/parent_gate.rb +23 -10
  54. data/lib/samagotchi/send_command/wait_target.rb +16 -3
  55. data/lib/samagotchi/send_command.rb +173 -108
  56. data/lib/samagotchi/session.rb +38 -9
  57. data/lib/samagotchi/session_access/contract.rb +167 -0
  58. data/lib/samagotchi/session_access/dispatch.rb +152 -0
  59. data/lib/samagotchi/session_access/http_transport.rb +109 -0
  60. data/lib/samagotchi/session_access/local.rb +400 -0
  61. data/lib/samagotchi/session_access/remote.rb +734 -0
  62. data/lib/samagotchi/session_access/web_bridge_client.rb +236 -0
  63. data/lib/samagotchi/session_access.rb +223 -0
  64. data/lib/samagotchi/session_archive_command.rb +9 -5
  65. data/lib/samagotchi/session_commands.rb +20 -15
  66. data/lib/samagotchi/session_continue.rb +1 -1
  67. data/lib/samagotchi/session_delete_command.rb +8 -4
  68. data/lib/samagotchi/session_inbox.rb +18 -9
  69. data/lib/samagotchi/session_manager.rb +27 -6
  70. data/lib/samagotchi/session_setup.rb +11 -6
  71. data/lib/samagotchi/sessions_command.rb +36 -14
  72. data/lib/samagotchi/share_command.rb +174 -0
  73. data/lib/samagotchi/terminal_ui/attach_launcher.rb +66 -17
  74. data/lib/samagotchi/terminal_ui/attach_link.rb +114 -0
  75. data/lib/samagotchi/terminal_ui/attached_loop.rb +155 -87
  76. data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
  77. data/lib/samagotchi/terminal_ui/local_link.rb +135 -0
  78. data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
  79. data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
  80. data/lib/samagotchi/terminal_ui/version_lines.rb +28 -3
  81. data/lib/samagotchi/terminal_ui.rb +6 -3
  82. data/lib/samagotchi/testing/waiting.rb +92 -9
  83. data/lib/samagotchi/title_command.rb +9 -11
  84. data/lib/samagotchi/tools/task_runtime.rb +17 -3
  85. data/lib/samagotchi/version.rb +1 -1
  86. data/lib/samagotchi/web/app.rb +530 -126
  87. data/lib/samagotchi/web/grants.rb +302 -0
  88. data/lib/samagotchi/web/participant_access.rb +286 -0
  89. data/lib/samagotchi/web/principal.rb +29 -0
  90. data/lib/samagotchi/web/public/data.js +3 -2
  91. data/lib/samagotchi/web/public/index.html +20 -4
  92. data/lib/samagotchi/web/public/stage_frame.js +7 -2
  93. data/lib/samagotchi/web/public/stage_view.js +3 -2
  94. data/lib/samagotchi/web/public/stream_handlers.js +3 -1
  95. data/lib/samagotchi/web/public/working_set.js +159 -28
  96. data/lib/samagotchi/web/public/working_set_rail.js +39 -12
  97. data/lib/samagotchi/web/public/working_set_view.js +91 -33
  98. data/lib/samagotchi/web/server.rb +3 -1
  99. data/lib/samagotchi/web/session_access_routes.rb +459 -0
  100. data/lib/samagotchi/worker.rb +26 -5
  101. data/lib/samagotchi/worker_sidecar.rb +12 -5
  102. data/lib/samagotchi/working_set.rb +88 -14
  103. data/lib/samagotchi/working_set_command.rb +133 -30
  104. data/lib/samagotchi/working_set_pin.rb +34 -0
  105. data/lib/samagotchi/working_set_steer.rb +191 -27
  106. metadata +28 -1
@@ -30,6 +30,7 @@ require_relative "tool_ids"
30
30
  require_relative "llm_context_view"
31
31
  require_relative "llm_context_apply"
32
32
  require_relative "llm_context_forget"
33
+ require_relative "working_set"
33
34
  require_relative "llm_context_notice"
34
35
  require_relative "answer_display"
35
36
  require_relative "llm/model_result"
@@ -146,6 +147,11 @@ module Samagotchi
146
147
  # write; the Engine's own (it hands it to a kernel it was given).
147
148
  attr_accessor :reminder_store
148
149
 
150
+ # @return [Float, nil] the last counted request's chars per token
151
+ # (ContextStatus#counted_chars_per_token), for the next turn's first
152
+ # estimate (no dip at a turn's start)
153
+ attr_accessor :counted_chars_per_token
154
+
149
155
  # @return [ModelProfile] the active prompt profile
150
156
  attr_reader :profile
151
157
 
@@ -178,6 +184,10 @@ module Samagotchi
178
184
  # a plugin tool's row refs resolve their files against (ToolRunner,
179
185
  # RowRefs); the Engine sets it. nil: the process's own (Dir.pwd).
180
186
  attr_accessor :session_cwd
187
+ # The session's working-set pins (a callable → Array<WorkingSetPin>),
188
+ # which forget_outputs refuses (LLMContextForget); the Engine sets it.
189
+ # nil: none.
190
+ attr_accessor :working_set_pins
181
191
  # The session's folder (a callable → String or nil), where ToolRunner
182
192
  # saves a cut output whole (ToolOutputFiles); the Engine sets it. nil:
183
193
  # none (a bare kernel), the cut says the output wasn't saved.
@@ -304,7 +314,8 @@ module Samagotchi
304
314
 
305
315
  def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:, reasoning_since: nil)
306
316
  conversation = prepare_conversation(messages, keep_thought_from: ReasoningHistory.run_start(messages, reasoning_since))
307
- context = ContextStatus.new(conversation: conversation, llm_context: @turn_settings.llm_context)
317
+ context = ContextStatus.new(conversation: conversation, llm_context: @turn_settings.llm_context,
318
+ counted_chars_per_token: @counted_chars_per_token)
308
319
  emit = ->(event) { emit_stream_event(on_stream_event, event) }
309
320
  @llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: context, emit: emit)
310
321
  Turn.new(
@@ -604,6 +615,7 @@ module Samagotchi
604
615
  response = generation.response.to_s
605
616
  turn.context.generation_done(generation.usage, prompt_chars: request.prompt.length, image_tokens: request.image_tokens,
606
617
  window: request.window)
618
+ @counted_chars_per_token = turn.context.counted_chars_per_token || @counted_chars_per_token
607
619
  emit(turn, type: :generation_completed, iteration: turn.iteration, content_length: response.length,
608
620
  thinking_chars: thinking_chars(response, generation.streamed_thinking),
609
621
  served_model: generation.served_model, requested_model: turn.model_name,
@@ -945,8 +957,15 @@ module Samagotchi
945
957
  end
946
958
 
947
959
  apply = -> { apply_llm_context!(turn.conversation, llm_context, context: turn.context, emit: turn.emit) }
948
- LLMContextForget.call(turn, call, llm_context: llm_context, apply: apply)
960
+ LLMContextForget.call(turn, call, llm_context: llm_context, apply: apply, pinned: method(:pinned_ids))
961
+ end
962
+
963
+ # The ids of +conversation+'s outputs the session's pins hold.
964
+ def pinned_ids(conversation)
965
+ pins = Array(@working_set_pins&.call)
966
+ WorkingSet.pinned_ids(conversation, pins: pins, cwd: @session_cwd&.call)
949
967
  end
968
+ private :pinned_ids
950
969
 
951
970
  # Before each request of both loops (+moment+ :request; the warm-up
952
971
  # too, on its own copy, as the next turn's first request) and at the
@@ -11,6 +11,18 @@ module Samagotchi
11
11
  # reach the worker: a /model command, and no_interrupt on each posted
12
12
  # turn; --memory and --mute are session fields the worker reads.)
13
13
  REPL_ONLY = { verbose: "--verbose" }.freeze
14
+ # What an attached run can't be combined with (the worker runs its turns).
15
+ ATTACHED_CONFLICTS = { non_interactive: "--non-interactive", verbose: "--verbose" }.freeze
16
+ # Opening a `chi scratch` session a killed REPL left behind would turn it
17
+ # into a kept one.
18
+ SCRATCH_LEFTOVER = "that's a leftover scratch session; it is deleted at the next sweep (chi sessions clean)"
19
+ # A worker spawned or resumed for an attach whose Bridge never came (%s:
20
+ # the session id).
21
+ BRIDGE_LATE = "the worker for session %s did not start its Bridge in time"
22
+ # An attach (or a resume) that found the session's worker leaving, its
23
+ # Bridge closed and its recap on leave still being written: it waits for
24
+ # the worker to let go (OwnerLock.await_release), then starts a new one.
25
+ RECAP_WAIT = "(waiting for the session to finish its recap…)"
14
26
 
15
27
  module_function
16
28
 
@@ -30,5 +42,19 @@ module Samagotchi
30
42
 
31
43
  [:attached, nil]
32
44
  end
45
+
46
+ # Why an attached run's flags don't go together, or nil when they do.
47
+ # @param options [Hash] bin/chi's parsed options
48
+ # @return [String, nil]
49
+ def attached_conflict(options)
50
+ conflict = ATTACHED_CONFLICTS.find { |key, _flag| options[key] }
51
+ if options[:attach] && options[:shared]
52
+ "use either --attach ID or --shared"
53
+ elsif options[:attach] && options[:resume]
54
+ "--attach takes the session id; --resume goes with --shared"
55
+ elsif conflict
56
+ "--attach/--shared can't be combined with #{conflict.last}"
57
+ end
58
+ end
33
59
  end
34
60
  end
@@ -63,6 +63,11 @@ module Samagotchi
63
63
  # The session every request is for; Engine sets it with the adapter.
64
64
  # Goes out as a Session-Id header (OpenAIChat#chat).
65
65
  attr_accessor :session_id
66
+ # The last counted request's chars per token
67
+ # (ContextStatus#counted_chars_per_token), carried to the next turn's
68
+ # tracker: its first estimate counts the tool schemas the chars leave
69
+ # out, so the ctx value doesn't dip at each turn's start.
70
+ attr_accessor :counted_chars_per_token
66
71
 
67
72
  # @param kernel [KernelLoop] tool dispatch, thought stripping, hooks
68
73
  # @param adapter [OpenAIChat, nil] the host to talk to; Engine sets it
@@ -585,7 +590,8 @@ module Samagotchi
585
590
  @recovery_repeated = false
586
591
  # The names the last request declared deferred.
587
592
  @deferred = []
588
- @context = ContextStatus.new(conversation: conversation, llm_context: loop.llm_context)
593
+ @context = ContextStatus.new(conversation: conversation, llm_context: loop.llm_context,
594
+ counted_chars_per_token: loop.counted_chars_per_token)
589
595
  loop.llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: @context,
590
596
  emit: method(:emit))
591
597
  end
@@ -976,6 +982,7 @@ module Samagotchi
976
982
  "completion_tokens" => usage.completion_tokens } })
977
983
  @context.generation_done(counts, prompt_chars: @request_chars.to_i, image_tokens: @request_image_tokens.to_i,
978
984
  window: window)
985
+ @loop.counted_chars_per_token = @context.counted_chars_per_token || @loop.counted_chars_per_token
979
986
  end
980
987
 
981
988
  # The model's answer at debug level, as the native loop dumps its
@@ -36,7 +36,10 @@ module Samagotchi
36
36
  # to free anything (SMALL), an output already stubbed or forgotten, an
37
37
  # id nothing in context has;
38
38
  # - an output the user dropped (WorkingSetSteer): neither forgotten nor
39
- # restored by the model, in WorkingSetSteer::Refused's words.
39
+ # restored by the model, in WorkingSetSteer::Refused's words;
40
+ # - an output of an item the user pinned (+pinned+: WorkingSet.pinned_ids
41
+ # of the session's pins), in WorkingSetSteer::Refused's words too (a
42
+ # restore of one is fine: it brings the output back).
40
43
  # A read is never restored: reading the file again is its restore, and
41
44
  # the file may have changed.
42
45
  module LLMContextForget
@@ -74,8 +77,10 @@ module Samagotchi
74
77
  # call (both loops add the model's entry before its calls run).
75
78
  # @param apply [#call] → LLMContextApply::Outcome: the apply rule at a
76
79
  # request, on the turn's conversation (KernelLoop#apply_llm_context!)
80
+ # @param pinned [#call, nil] conversation → the ids the user's pins
81
+ # hold (WorkingSet.pinned_ids), asked only when a forget is asked
77
82
  # @return [String] the tool result
78
- def call(turn, call, llm_context:, apply:, root: Dir.pwd, now: Time.now.utc.iso8601(3))
83
+ def call(turn, call, llm_context:, apply:, root: Dir.pwd, pinned: nil, now: Time.now.utc.iso8601(3))
79
84
  request = Tools::ForgetOutputs.parse(call)
80
85
  conversation = turn.conversation
81
86
  return restore(turn, request.restore, llm_context) if request.restore? && request.ids.empty?
@@ -85,7 +90,8 @@ module Samagotchi
85
90
  "what's ruled out, NEXT), since it is all that stays of them"
86
91
  end
87
92
 
88
- forgotten, refused = forget(conversation, request, llm_context.protect_steps, root, now)
93
+ pinned_ids = pinned ? pinned.call(conversation) : Set.new
94
+ forgotten, refused = forget(conversation, request, llm_context.protect_steps, root, now, pinned_ids)
89
95
  lines = if forgotten.empty?
90
96
  ["nothing forgotten"]
91
97
  else
@@ -99,7 +105,7 @@ module Samagotchi
99
105
  # Saves a staged forget on each output +request+ names that may go.
100
106
  # @return [Array(Array<String>, Hash{String => String})] the ids
101
107
  # forgotten, and the refused ones with why
102
- def forget(conversation, request, protect_steps, root, now)
108
+ def forget(conversation, request, protect_steps, root, now, pinned_ids)
103
109
  outputs = outputs(conversation)
104
110
  last = LLMContextStale.step_count(conversation)
105
111
  # The steps before the call's own: protected_ids counts that one too.
@@ -107,7 +113,7 @@ module Samagotchi
107
113
  root: root)
108
114
  refused = {}
109
115
  forgotten = request.ids.select do |id|
110
- why = refusal(conversation, outputs[id], request.keep[id], last, protect_steps, kept_reads)
116
+ why = refusal(conversation, outputs[id], request.keep[id], last, protect_steps, kept_reads, pinned_ids)
111
117
  refused[id] = why if why
112
118
  why.nil?
113
119
  end
@@ -124,11 +130,12 @@ module Samagotchi
124
130
  # Why +output+ can't go (a String for the result), nil when it can.
125
131
  # +last+: the call's own step; the protect_steps steps before it are
126
132
  # the model's latest outputs.
127
- def refusal(conversation, output, keep, last, protect_steps, kept_reads)
133
+ def refusal(conversation, output, keep, last, protect_steps, kept_reads, pinned_ids)
128
134
  return "no output with that id in context (ids look like t42)" unless output
129
135
 
130
136
  held = LLMContextEdit.on(conversation[output.index])[output.ref.id]
131
137
  return WorkingSetSteer::Refused.text(:dropped) if held&.kind == LLMContextEdit::DROP
138
+ return WorkingSetSteer::Refused.text(:pinned_by_user) if pinned_ids.include?(output.ref.id)
132
139
  return "already forgotten or stubbed" if held
133
140
  if protect_steps.positive? && output.step >= last - protect_steps
134
141
  return "from your last #{protect_steps} step#{"s" unless protect_steps == 1}; you may still need it, forget it later"
@@ -6,6 +6,7 @@ require_relative "note_delivery"
6
6
  require_relative "session_manager"
7
7
  require_relative "cli/command"
8
8
  require_relative "cli/flags"
9
+ require_relative "session_access/local"
9
10
 
10
11
  module Samagotchi
11
12
  # `chi note`: push text into sessions as a context note (background the
@@ -33,12 +34,15 @@ module Samagotchi
33
34
  end
34
35
 
35
36
  # @param argv [Array<String>] the arguments after "note"
36
- def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil)
37
+ # @param access [SessionAccess::Local, nil] where the sessions are;
38
+ # default this machine's, in +state_dir+
39
+ def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil, access: nil)
37
40
  @argv = argv.dup
38
41
  @stdin = stdin
39
42
  @stdout = stdout
40
43
  @stderr = stderr
41
44
  @state_dir = state_dir || Session.default_state_dir
45
+ @access = access || SessionAccess::Local.new(state_dir: @state_dir)
42
46
  end
43
47
 
44
48
  # @return [Integer] exit status: 0 all queued, 1 any refused or failed,
@@ -93,8 +97,7 @@ module Samagotchi
93
97
 
94
98
  # --all: every live session. @return [Integer] exit status
95
99
  def deliver_to_live(text, source)
96
- ids = SessionManager.session_summaries(live: true, include_tests: Session.test_session_env?,
97
- state_dir: @state_dir).map { |s| s[:id] }
100
+ ids = @access.summaries(live: true, include_tests: Session.test_session_env?).map { |s| s[:id] }
98
101
  if ids.empty?
99
102
  error_line("chi note: no live sessions (chi sessions list --live --scope=all)")
100
103
  return 1
@@ -104,9 +107,7 @@ module Samagotchi
104
107
  end
105
108
 
106
109
  def resolve(given)
107
- id = Session.resolve_id(given, state_dir: @state_dir)
108
- Session.load(id, state_dir: @state_dir)
109
- id
110
+ @access.resolve(given)
110
111
  rescue ArgumentError => e
111
112
  message = e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"
112
113
  error_line("chi note: #{message}")
@@ -115,7 +116,7 @@ module Samagotchi
115
116
 
116
117
  # @return [Boolean] whether the note was queued
117
118
  def deliver(id, text, source)
118
- result = NoteDelivery.deliver(id, text: text, source: source, state_dir: @state_dir)
119
+ result = @access.note(id, text: text, source: source)
119
120
  @stdout.puts("#{id[0, 8]} #{outcome(result)}")
120
121
  result.delivered?
121
122
  end
@@ -19,17 +19,37 @@ module Samagotchi
19
19
  DEFAULT_WAIT = 2.0
20
20
  RETRY_INTERVAL = 0.05
21
21
  OWNER_READ_ATTEMPTS = 10
22
+ # Seconds .await_release waits past a leaving worker's own budget: its
23
+ # last save, the task watch's absorb, its exit.
24
+ LEAVE_MARGIN = 5.0
25
+ # Seconds between .await_release's looks at the lock.
26
+ RELEASE_POLL = 0.1
22
27
 
23
28
  # Who holds the lock, as .owner reads it from the lock file. Its fields
24
29
  # may be nil for a moment right after acquisition.
25
- # kind: "worker" or "tui"; pid as written (an Integer).
26
- Owner = Data.define(:pid, :kind, :started_at) do
27
- def initialize(pid: nil, kind: nil, started_at: nil) = super
30
+ # kind: "worker" or "tui"; pid as written (an Integer). leaving_at (an
31
+ # ISO 8601 time) and leave_wait (seconds): set once a worker is leaving
32
+ # (#mark_leaving), its Bridge closed, the lock held for its last work
33
+ # (the recap on leave) for up to leave_wait.
34
+ Owner = Data.define(:pid, :kind, :started_at, :leaving_at, :leave_wait) do
35
+ def initialize(pid: nil, kind: nil, started_at: nil, leaving_at: nil, leave_wait: nil) = super
28
36
 
29
37
  # The interactive TUI's: it reads no input files and takes no notes.
30
38
  def tui? = kind == "tui"
31
39
 
32
40
  def worker? = kind == "worker"
41
+
42
+ # A worker that closed its Bridge and releases the lock once its
43
+ # last work is done.
44
+ def leaving? = worker? && !leaving_at.nil?
45
+
46
+ # Seconds of its leave_wait still to run at +now+ (0 when past).
47
+ def leave_left(now = Time.now)
48
+ started = Time.iso8601(leaving_at.to_s)
49
+ [leave_wait.to_f - (now - started), 0.0].max
50
+ rescue ArgumentError
51
+ leave_wait.to_f
52
+ end
33
53
  end
34
54
 
35
55
  # Take the lock, retrying for up to +wait+ seconds (another process's
@@ -71,12 +91,35 @@ module Samagotchi
71
91
 
72
92
  sleep(RETRY_INTERVAL / 5)
73
93
  end
74
- Owner.new(pid: data["pid"], kind: data["kind"], started_at: data["started_at"])
94
+ Owner.new(pid: data["pid"], kind: data["kind"], started_at: data["started_at"],
95
+ leaving_at: data["leaving_at"], leave_wait: data["leave_wait"])
75
96
  end
76
97
  rescue Errno::ENOENT
77
98
  nil
78
99
  end
79
100
 
101
+ # Wait for a leaving worker (Owner#leaving?) to release the lock: up to
102
+ # its leave_wait still to run plus LEAVE_MARGIN, looking every
103
+ # RELEASE_POLL seconds. An owner that isn't leaving (a live worker, a
104
+ # stuck one, a chi REPL) or no owner returns at once. A Ctrl-C (Interrupt)
105
+ # stops the wait as it would any sleep.
106
+ # @param on_wait [#call, nil] called once with the Owner before waiting
107
+ # @return [Symbol] :not_leaving, :released, or :timeout (still held)
108
+ def self.await_release(session_dir, on_wait: nil, margin: LEAVE_MARGIN, clock: method(:monotonic_now),
109
+ sleeper: method(:sleep), now: -> { Time.now })
110
+ holder = owner(session_dir)
111
+ return :not_leaving unless holder&.leaving?
112
+
113
+ on_wait&.call(holder)
114
+ deadline = clock.call + holder.leave_left(now.call) + margin
115
+ loop do
116
+ return :released unless owner(session_dir)
117
+ return :timeout if clock.call >= deadline
118
+
119
+ sleeper.call(RELEASE_POLL)
120
+ end
121
+ end
122
+
80
123
  def self.path(session_dir)
81
124
  File.join(session_dir, FILE)
82
125
  end
@@ -99,11 +142,28 @@ module Samagotchi
99
142
  def initialize(file, kind:)
100
143
  @file = file
101
144
  @kind = kind.to_s
145
+ @record = { "pid" => Process.pid, "kind" => @kind, "started_at" => Time.now.iso8601(3) }
146
+ write_record
147
+ end
148
+
149
+ # The holder is leaving: its Bridge is closed and it releases the lock
150
+ # within +wait+ seconds (its recap on leave's timeout), which
151
+ # .await_release reads. .owner retries the moment the file is empty.
152
+ # @param wait [Numeric]
153
+ def mark_leaving(wait:)
154
+ return if @file.closed?
155
+
156
+ @record = @record.merge("leaving_at" => Time.now.iso8601(3), "leave_wait" => wait.to_f)
157
+ write_record
158
+ end
159
+
160
+ def write_record
102
161
  @file.truncate(0)
103
162
  @file.rewind
104
- @file.write(JSON.generate("pid" => Process.pid, "kind" => @kind, "started_at" => Time.now.iso8601(3)))
163
+ @file.write(JSON.generate(@record))
105
164
  @file.flush
106
165
  end
166
+ private :write_record
107
167
 
108
168
  # Release the lock (process exit releases it too).
109
169
  def release
@@ -3,6 +3,7 @@
3
3
  require "delegate"
4
4
  require "json"
5
5
 
6
+ require_relative "chi_invocation"
6
7
  require_relative "cli/exit"
7
8
  require_relative "pending_question"
8
9
  require_relative "session"
@@ -81,7 +82,9 @@ module Samagotchi
81
82
  # @return [Hash] the --format json object
82
83
  # @param strategy [Guardrails::ParentPolicy::Strategy, nil] what a parent
83
84
  # may pick on the waited-for session (#strategy_for); nil: config.yml's
84
- def json(result, session_id:, timeout: nil, extra: {}, strategy: nil)
85
+ # @param via [ChiInvocation] how the printed commands are spelled (this
86
+ # machine's chi, or chi --remote NAME); each function here takes it
87
+ def json(result, session_id:, timeout: nil, extra: {}, strategy: nil, via: ChiInvocation::HERE)
85
88
  report = { status: status(result), session_id: session_id }
86
89
  report[:turn_id] = result.turn_id unless result.turn_id.to_s.empty?
87
90
  case report[:status]
@@ -89,10 +92,10 @@ module Samagotchi
89
92
  when "question"
90
93
  question = question(result.question, strategy: strategy)
91
94
  report[:question] = question
92
- report[:answer_with] = answer_with(session_id, question)
95
+ report[:answer_with] = answer_with(session_id, question, via: via)
93
96
  else
94
97
  report.merge!(stop_facts(result))
95
- report[:detail] = detail(result, session_id: session_id, timeout: timeout, strategy: strategy)
98
+ report[:detail] = detail(result, session_id: session_id, timeout: timeout, strategy: strategy, via: via)
96
99
  end
97
100
  report.merge(extra)
98
101
  end
@@ -110,8 +113,8 @@ module Samagotchi
110
113
  end
111
114
 
112
115
  # @return [String] the JSON object as one line
113
- def json_line(result, session_id:, timeout: nil, extra: {}, strategy: nil)
114
- JSON.generate(json(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy))
116
+ def json_line(result, session_id:, timeout: nil, extra: {}, strategy: nil, via: ChiInvocation::HERE)
117
+ JSON.generate(json(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy, via: via))
115
118
  end
116
119
 
117
120
  # Print a wait's end the way the commands do: the JSON object on
@@ -120,17 +123,19 @@ module Samagotchi
120
123
  # @param command [String] "chi send", "chi answer"
121
124
  # @param extra [Hash] more keys for the JSON object (#json)
122
125
  # @return [Integer] the exit status
123
- def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil, extra: {}, strategy: nil)
126
+ def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil, extra: {}, strategy: nil,
127
+ via: ChiInvocation::HERE)
124
128
  if json
125
- stdout.puts(json_line(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy))
129
+ stdout.puts(json_line(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy, via: via))
126
130
  elsif result.status == :done
127
131
  stdout.puts(result.text)
128
132
  else
129
133
  stdout.flush
130
134
  text = if result.status == :waiting_for_answer
131
- question_text(result.question, session_id: session_id, strategy: strategy)
135
+ question_text(result.question, session_id: session_id, strategy: strategy, via: via)
132
136
  else
133
- "#{detail(result, session_id: session_id, timeout: timeout, turn_id: result.turn_id, strategy: strategy)}\n"
137
+ "#{detail(result, session_id: session_id, timeout: timeout, turn_id: result.turn_id, strategy: strategy,
138
+ via: via)}\n"
134
139
  end
135
140
  stderr.print("#{command}: #{text}")
136
141
  end
@@ -177,16 +182,22 @@ module Samagotchi
177
182
  # lists an Allow once a parent may give). A step-limit question's
178
183
  # continues the turn, or stops it where a parent may not continue (its
179
184
  # other form is in #question_text).
180
- def answer_with(session_id, question)
181
- return "chi answer #{session_id} --question #{question[:id]} --option Deny --text WHY" if approval?(question)
182
- return stop_command(session_id, question) if continue?(question) && !may_continue?(question)
183
- return "chi answer #{session_id} --question #{question[:id]} --option Continue" if continue?(question)
185
+ def answer_with(session_id, question, via: ChiInvocation::HERE)
186
+ return answer_command(session_id, question, "Deny --text WHY", via: via) if approval?(question)
187
+ return stop_command(session_id, question, via: via) if continue?(question) && !may_continue?(question)
188
+ return answer_command(session_id, question, "Continue", via: via) if continue?(question)
184
189
 
185
190
  # The one option a parent may pick (cost-cap's Stop under hard).
186
191
  only = Array(question[:allowed_options])
187
- return "chi answer #{session_id} --question #{question[:id]} --option #{option_arg(only.first)}" if only.size == 1
192
+ return answer_command(session_id, question, option_arg(only.first), via: via) if only.size == 1
188
193
 
189
- "chi answer #{session_id} --question #{question[:id]} --option N"
194
+ answer_command(session_id, question, "N", via: via)
195
+ end
196
+
197
+ # chi answer ID --question QID --option +option+ (its words after
198
+ # --option: a label, maybe --text).
199
+ def answer_command(session_id, question, option, via: ChiInvocation::HERE)
200
+ via.command("answer #{session_id} --question #{question[:id]} --option #{option}")
190
201
  end
191
202
 
192
203
  # What a parent does with an approval it may not allow.
@@ -239,17 +250,18 @@ module Samagotchi
239
250
  question.fetch(:allowed_options, question[:options]).include?(Guardrails::ParentPolicy::CONTINUE)
240
251
  end
241
252
 
242
- def stop_command(session_id, question)
243
- "chi answer #{session_id} --question #{question[:id]} --option Stop --text WHY"
253
+ def stop_command(session_id, question, via: ChiInvocation::HERE)
254
+ answer_command(session_id, question, "Stop --text WHY", via: via)
244
255
  end
245
256
 
246
257
  def option_arg(label) = label.match?(/\A[\w.-]+\z/) ? label : label.inspect
247
258
 
248
259
  # The other way: the user answers it in chi (the web's bell and badge
249
260
  # say it waits), and a wait with no message waits past it.
250
- def leave_open(session_id)
251
- "or leave it open: tell your user it waits in chi web (session #{session_id[0, 8]}); " \
252
- "chi send --wait --format json #{session_id} waits until they answer"
261
+ def leave_open(session_id, via: ChiInvocation::HERE)
262
+ web = via.remote? ? via.web : "chi web"
263
+ "or leave it open: tell your user it waits in #{web} (session #{session_id[0, 8]}); " \
264
+ "#{via.command("send --wait --format json #{session_id}")} waits until they answer"
253
265
  end
254
266
 
255
267
  # @param question [PendingQuestion, Hash, nil] a pending question, or a
@@ -267,13 +279,13 @@ module Samagotchi
267
279
  end
268
280
 
269
281
  # What a message does to a step-limit question.
270
- def message_drops(session_id)
271
- "a message instead (chi send #{session_id} -m …) drops it and starts a new turn"
282
+ def message_drops(session_id, via: ChiInvocation::HERE)
283
+ "a message instead (#{via.command("send #{session_id} -m …")}) drops it and starts a new turn"
272
284
  end
273
285
 
274
286
  # What a wait that ended without an answer says, one line.
275
- def detail(result, session_id:, timeout: nil, turn_id: nil, strategy: nil)
276
- attach = "chi --attach #{session_id}"
287
+ def detail(result, session_id:, timeout: nil, turn_id: nil, strategy: nil, via: ChiInvocation::HERE)
288
+ shows = via.shows(session_id)
277
289
  case result.status
278
290
  when :waiting_for_answer
279
291
  q = question(result.question, strategy: strategy)
@@ -281,20 +293,20 @@ module Samagotchi
281
293
  "waiting for an approval: #{first_line(q[:text])}; #{approval_advice(q, strategy: strategy)}"
282
294
  elsif continue?(q) && !may_continue?(q)
283
295
  "waiting at the step limit: #{first_line(q[:text])}; a parent may only stop it here " \
284
- "(#{policy(strategy).label}): #{stop_command(session_id, q)}, or tell your user it waits"
296
+ "(#{policy(strategy).label}): #{stop_command(session_id, q, via: via)}, or tell your user it waits"
285
297
  elsif continue?(q)
286
298
  "waiting at the step limit: #{first_line(q[:text])}; " \
287
- "answer Continue or Stop: chi answer #{session_id} --question #{q[:id]} --option Continue"
299
+ "answer Continue or Stop: #{answer_command(session_id, q, "Continue", via: via)}"
288
300
  else
289
- "waiting for an answer: #{first_line(q[:text])}; open it: #{attach} or the web"
301
+ "waiting for an answer: #{first_line(q[:text])}; open it: #{via.open_it(session_id)}"
290
302
  end
291
- when :no_reply then "#{no_reply_line(result, session_id, strategy: strategy)}; #{attach} shows it"
292
- when :error then "the worker failed: #{result.text}; #{attach} shows what happened"
293
- when :worker_gone then "the worker is gone; #{attach} shows what happened"
303
+ when :no_reply then "#{no_reply_line(result, session_id, strategy: strategy, via: via)}; #{shows} shows it"
304
+ when :error then "the worker failed: #{result.text}; #{shows} shows what happened"
305
+ when :worker_gone then "the worker is gone; #{shows} shows what happened"
294
306
  when :stopped then "the session was stopped (chi sessions stop)"
295
307
  else
296
- wait_again = "chi send --wait --turn #{turn_id} #{session_id}" unless turn_id.to_s.empty?
297
- hint = wait_again || attach
308
+ wait_again = via.command("send --wait --turn #{turn_id} #{session_id}") unless turn_id.to_s.empty?
309
+ hint = wait_again || shows
298
310
  timeout ? "still running after #{format("%g", timeout)} s: #{hint}" : "still running: #{hint}"
299
311
  end
300
312
  end
@@ -302,7 +314,7 @@ module Samagotchi
302
314
  # The whole question for stderr: header, text, numbered options, what
303
315
  # else it takes, and the commands that answer it.
304
316
  # @return [String] lines, newline-terminated
305
- def question_text(pending, session_id:, strategy: nil)
317
+ def question_text(pending, session_id:, strategy: nil, via: ChiInvocation::HERE)
306
318
  q = question(pending, strategy: strategy)
307
319
  lines = ["waiting for an answer (#{q[:kind]}): #{q[:header] || first_line(q[:text])}"]
308
320
  text_lines = q[:text].strip.lines.map(&:rstrip)
@@ -315,31 +327,31 @@ module Samagotchi
315
327
  lines << " allowing it is up to your user: #{DENY_AND_TELL}"
316
328
  else
317
329
  lines << " #{approval_advice(q, strategy: strategy)}"
318
- allows.each { |label| lines << " allow: chi answer #{session_id} --question #{q[:id]} --option #{option_arg(label)}" }
330
+ allows.each { |label| lines << " allow: #{answer_command(session_id, q, option_arg(label), via: via)}" }
319
331
  end
320
- lines << " deny: #{answer_with(session_id, q)}"
321
- lines << " #{leave_open(session_id)}"
332
+ lines << " deny: #{answer_with(session_id, q, via: via)}"
333
+ lines << " #{leave_open(session_id, via: via)}"
322
334
  elsif continue?(q)
323
335
  lines << if may_continue?(q)
324
- " continue: #{answer_with(session_id, q)}"
336
+ " continue: #{answer_with(session_id, q, via: via)}"
325
337
  else
326
338
  " continuing it is up to your user (#{policy(strategy).label}): stop it, or tell your user it waits"
327
339
  end
328
- lines << " stop: #{stop_command(session_id, q)} (the text is optional; the model reads it)"
329
- lines << " #{message_drops(session_id)}"
340
+ lines << " stop: #{stop_command(session_id, q, via: via)} (the text is optional; the model reads it)"
341
+ lines << " #{message_drops(session_id, via: via)}"
330
342
  else
331
343
  lines << " more than one allowed: repeat --option" if q[:multi_select]
332
344
  lines << " free text allowed: --text" if q[:allow_freeform]
333
345
  if q[:allowed_options]
334
346
  lines << " a parent may pick #{q[:allowed_options].map { |o| option_arg(o) }.join(", ")}; the others are up to your user"
335
347
  end
336
- lines << " answer: #{answer_with(session_id, q)}"
337
- lines << " or open it: chi --attach #{session_id} or the web"
348
+ lines << " answer: #{answer_with(session_id, q, via: via)}"
349
+ lines << " or open it: #{via.open_it(session_id)}"
338
350
  end
339
351
  lines.map { |line| "#{line}\n" }.join
340
352
  end
341
353
 
342
- def no_reply_line(result, session_id = nil, strategy: nil)
354
+ def no_reply_line(result, session_id = nil, strategy: nil, via: ChiInvocation::HERE)
343
355
  return command_line(result) if result.origin == TurnCheckpoint::COMMAND_ORIGIN
344
356
 
345
357
  case result.outcome
@@ -354,7 +366,7 @@ module Samagotchi
354
366
  ran_out = "the turn ran out of iterations#{" (#{result.limit} steps)" if result.limit} before it answered"
355
367
  strategy = policy(strategy)
356
368
  if strategy.allow?(Guardrails::ParentPolicy::SPEND)
357
- "#{ran_out}; chi send #{session_id} -m '/continue yes' continues it, a message drops it"
369
+ "#{ran_out}; #{via.command("send #{session_id} -m '/continue yes'")} continues it, a message drops it"
358
370
  else
359
371
  # No /continue yes hint: a parent may not continue it here.
360
372
  "#{ran_out}; continuing it is up to your user (#{strategy.label}), a message drops it"