samagotchi 0.91.0 → 0.93.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -1
  3. data/README.md +5 -2
  4. data/bin/chi +8 -8
  5. data/docs/architecture.md +20 -16
  6. data/docs/bundle-authoring.md +2 -1
  7. data/docs/cli.md +5 -5
  8. data/docs/configuration.md +3 -1
  9. data/docs/context.md +5 -3
  10. data/docs/guardrails.md +4 -5
  11. data/docs/hooks.md +3 -3
  12. data/docs/internals/background-tasks.md +2 -2
  13. data/docs/internals/bundles.md +4 -4
  14. data/docs/internals/engine.md +27 -8
  15. data/docs/internals/tool-guardrails.md +10 -2
  16. data/docs/memory.md +6 -0
  17. data/docs/plugins.md +30 -13
  18. data/docs/releasing.md +3 -0
  19. data/docs/sessions.md +4 -4
  20. data/docs/testing.md +2 -1
  21. data/lib/samagotchi/bootstrap/probe.rb +2 -2
  22. data/lib/samagotchi/bridge.rb +8 -9
  23. data/lib/samagotchi/bundle_command/check.rb +43 -12
  24. data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
  25. data/lib/samagotchi/bundle_command/test_run.rb +12 -0
  26. data/lib/samagotchi/bundle_command/try/command.rb +164 -0
  27. data/lib/samagotchi/bundle_command/try/render.rb +80 -0
  28. data/lib/samagotchi/bundle_command/try/run.rb +35 -17
  29. data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
  30. data/lib/samagotchi/bundle_command/try/trace.rb +20 -26
  31. data/lib/samagotchi/bundle_command.rb +11 -213
  32. data/lib/samagotchi/bundle_session_settings.rb +82 -45
  33. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  34. data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
  35. data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
  36. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  37. data/lib/samagotchi/child_env.rb +50 -0
  38. data/lib/samagotchi/child_reports.rb +12 -14
  39. data/lib/samagotchi/client_id.rb +18 -0
  40. data/lib/samagotchi/config/restart_watch.rb +112 -0
  41. data/lib/samagotchi/config.rb +18 -59
  42. data/lib/samagotchi/engine.rb +26 -26
  43. data/lib/samagotchi/guardrails/data_readers.rb +34 -30
  44. data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
  45. data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
  46. data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
  47. data/lib/samagotchi/hook_question.rb +21 -0
  48. data/lib/samagotchi/hook_routes.rb +6 -6
  49. data/lib/samagotchi/hooks/registry.rb +7 -5
  50. data/lib/samagotchi/host_registry.rb +1 -1
  51. data/lib/samagotchi/kernel_loop.rb +14 -3
  52. data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
  53. data/lib/samagotchi/llm/chat_loop.rb +3 -1
  54. data/lib/samagotchi/llm/errors.rb +4 -4
  55. data/lib/samagotchi/memory_bundle/builder.rb +3 -2
  56. data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
  57. data/lib/samagotchi/memory_bundle/installer.rb +1 -0
  58. data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
  59. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  60. data/lib/samagotchi/parent_answers_command.rb +6 -6
  61. data/lib/samagotchi/parent_report.rb +12 -0
  62. data/lib/samagotchi/plugin/context.rb +6 -5
  63. data/lib/samagotchi/plugin/http_transport.rb +14 -3
  64. data/lib/samagotchi/plugin/sessions.rb +2 -3
  65. data/lib/samagotchi/question_desk.rb +12 -9
  66. data/lib/samagotchi/reply_wait/poll.rb +267 -0
  67. data/lib/samagotchi/reply_wait.rb +51 -190
  68. data/lib/samagotchi/sandbox_layout.rb +66 -0
  69. data/lib/samagotchi/send_command/parent_gate.rb +83 -0
  70. data/lib/samagotchi/send_command/wait_target.rb +55 -0
  71. data/lib/samagotchi/send_command.rb +34 -103
  72. data/lib/samagotchi/session.rb +10 -9
  73. data/lib/samagotchi/session_inbox.rb +31 -8
  74. data/lib/samagotchi/session_manager.rb +60 -35
  75. data/lib/samagotchi/session_metrics.rb +22 -3
  76. data/lib/samagotchi/sessions_command.rb +4 -4
  77. data/lib/samagotchi/task_watch.rb +64 -21
  78. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  79. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  80. data/lib/samagotchi/testing/bundle.rb +33 -20
  81. data/lib/samagotchi/testing/host.rb +2 -8
  82. data/lib/samagotchi/testing/install.rb +26 -17
  83. data/lib/samagotchi/testing/recorder.rb +6 -9
  84. data/lib/samagotchi/testing/sandbox.rb +45 -37
  85. data/lib/samagotchi/testing/waiting.rb +5 -7
  86. data/lib/samagotchi/tool_response.rb +28 -6
  87. data/lib/samagotchi/tools/delegate.rb +10 -10
  88. data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
  89. data/lib/samagotchi/tools/delegate_relay.rb +31 -16
  90. data/lib/samagotchi/tools/delegate_wait.rb +4 -4
  91. data/lib/samagotchi/tools/task_runtime.rb +2 -2
  92. data/lib/samagotchi/turn_checkpoint.rb +23 -10
  93. data/lib/samagotchi/version.rb +1 -1
  94. data/lib/samagotchi/web/app.rb +46 -63
  95. data/lib/samagotchi/web/public/app.js +130 -554
  96. data/lib/samagotchi/web/public/cards_place.js +4 -1
  97. data/lib/samagotchi/web/public/images.js +6 -0
  98. data/lib/samagotchi/web/public/index.html +1 -1
  99. data/lib/samagotchi/web/public/model_pick.js +10 -0
  100. data/lib/samagotchi/web/public/send_turn.js +173 -0
  101. data/lib/samagotchi/web/public/session_actions.js +203 -0
  102. data/lib/samagotchi/web/public/start_page.js +236 -0
  103. data/lib/samagotchi/web/public/toasts.js +17 -9
  104. data/lib/samagotchi/web/public/unqueue.js +20 -4
  105. data/lib/samagotchi/worker.rb +77 -185
  106. data/lib/samagotchi/worker_idle_exit.rb +20 -5
  107. data/lib/samagotchi/worker_inbound.rb +5 -5
  108. data/lib/samagotchi/worker_task_wakes.rb +160 -0
  109. data/lib/samagotchi/worker_wakes.rb +0 -3
  110. metadata +15 -1
@@ -28,20 +28,42 @@ module Samagotchi
28
28
 
29
29
  # Runs +calls+ in order (ToolRunner#run each), yielding each run and its
30
30
  # index as it finishes. +emit+ takes the dispatch events, the runner's
31
- # own go to +on_stream_event+.
31
+ # own go to +on_stream_event+. An Interrupt (Ctrl-C) mid-batch yields an
32
+ # #interrupted run for every call left (the one it cut short too), so
33
+ # the loop's conversation answers each call, then goes on up: the turn
34
+ # keeps its progress (Engine#ctrl_c_messages).
32
35
  # @return [Array<ToolRun>] the runs
33
36
  def run_batch(runner, calls, iteration:, emit:, on_stream_event:, cap:)
37
+ # Before anything can be interrupted: the rescue counts it.
38
+ runs = []
34
39
  emit.call({ type: :tool_dispatch_started, iteration: iteration, call_count: calls.length })
35
- runs = calls.each_with_index.map do |call, index|
36
- run = runner.run(call, iteration: iteration, call_index: index + 1, call_count: calls.length,
37
- on_stream_event: on_stream_event, max_tool_output_chars: cap)
38
- yield run, index if block_given?
39
- run
40
+ calls.each_with_index do |call, index|
41
+ runs << runner.run(call, iteration: iteration, call_index: index + 1, call_count: calls.length,
42
+ on_stream_event: on_stream_event, max_tool_output_chars: cap)
43
+ yield runs.last, index if block_given?
40
44
  end
41
45
  emit.call({ type: :tool_dispatch_completed, iteration: iteration, call_count: calls.length })
42
46
  runs
47
+ rescue Interrupt
48
+ calls.each_with_index.drop(runs.size).each { |call, index| yield interrupted(call[:name]), index } if block_given?
49
+ raise
43
50
  end
44
51
 
52
+ # The result of a call an Interrupt cut short (or never started): the
53
+ # model reads that it may have partly run.
54
+ def interrupted(name)
55
+ text = interrupted_text(name)
56
+ ToolRun.new(output: text, capped_output: text)
57
+ end
58
+
59
+ INTERRUPTED = "Interrupted (Ctrl-C) before it returned: it may have partly run."
60
+
61
+ def interrupted_text(name) = "[#{name}] #{INTERRUPTED}"
62
+
63
+ # Whether +content+ is an #interrupted result, whatever tool it names
64
+ # (an aliased call's saved name is the model's, the text the tool's).
65
+ def interrupted_text?(content) = content.to_s.match?(/\A\[[^\]\s]+\] #{Regexp.escape(INTERRUPTED)}\z/)
66
+
45
67
  # The runs' activity, for the turn's tool list (none for a call whose
46
68
  # dispatcher raised).
47
69
  def activities(runs)
@@ -234,11 +234,11 @@ module Samagotchi
234
234
  DelegateWait.mark_seen(parent.id, child, state_dir: state_dir)
235
235
  delivered = SessionManager.deliver_turn(id, prompt: task, client_id: "#{ClientId::DELEGATE_PREFIX}#{parent.id[0, 8]}",
236
236
  state_dir: state_dir)
237
- case delivered[:status]
237
+ case delivered.status
238
238
  when :accepted
239
- DelegateWait.mark_turn(parent.id, id, follow_up_turn(id, delivered[:ack], state_dir: state_dir), state_dir: state_dir)
239
+ DelegateWait.mark_turn(parent.id, id, follow_up_turn(id, delivered.enqueued_id, state_dir: state_dir), state_dir: state_dir)
240
240
  id
241
- when :refused then "Error: session #{id[0, 8]} refused the message (#{delivered.dig(:ack, "error")})"
241
+ when :refused then "Error: session #{id[0, 8]} refused the message (#{delivered.error})"
242
242
  when :timeout then "Error: the worker of session #{id[0, 8]} did not answer in time, so the message was not sent"
243
243
  else "Error: the message to session #{id[0, 8]} could not be written"
244
244
  end
@@ -251,24 +251,24 @@ module Samagotchi
251
251
 
252
252
  # The child's turn a delivered follow-up joins, for the wait
253
253
  # (DelegateCursor#turn): the running turn's (its checkpoint mark), the
254
- # follow-up merging into it; else the ack's enqueued_id, the follow-up
255
- # running as a turn of its own. Only from a worker that names
256
- # turn_ids; nil otherwise, or when the running turn has no id (a
257
- # delegate's first turn): the wait takes the next reply.
254
+ # follow-up merging into it; else the delivery's +enqueued_id+, the
255
+ # follow-up running as a turn of its own. Only
256
+ # from a worker that names turn_ids; nil otherwise, or when the
257
+ # running turn has no id (a delegate's first turn): the wait takes the
258
+ # next reply.
258
259
  # A follow-up steered into a turn that ended before it merged runs as
259
260
  # its own turn, yet the wait is on the ended one (LOW, as before).
260
261
  # One the child's idle look missed being merged into a turn starting
261
262
  # then never runs as its own: ReplyWait falls back to that turn's
262
263
  # reply (merged_elsewhere?).
263
264
  # @return [String, nil]
264
- def self.follow_up_turn(id, ack, state_dir:)
265
+ def self.follow_up_turn(id, enqueued_id, state_dir:)
265
266
  return nil unless ReplyWait.turn_ids?(Session.session_dir(id, state_dir: state_dir))
266
267
 
267
268
  child = Session.load(id, state_dir: state_dir)
268
269
  return child.interrupted_turn&.enqueued_id if child.status == Session::STATUS_RUNNING
269
270
 
270
- enqueued_id = ack.is_a?(Hash) ? (ack["enqueued_id"] || ack[:enqueued_id]) : nil
271
- enqueued_id.to_s.empty? ? nil : enqueued_id.to_s
271
+ enqueued_id
272
272
  rescue ArgumentError
273
273
  nil
274
274
  end
@@ -4,6 +4,7 @@ require "json"
4
4
  require "fileutils"
5
5
  require_relative "../atomic_file"
6
6
  require_relative "../session"
7
+ require_relative "../reply_wait"
7
8
 
8
9
  module Samagotchi
9
10
  module Tools
@@ -14,32 +15,54 @@ module Samagotchi
14
15
  # of the child's turn the last follow-up went to (Delegate.follow_up),
15
16
  # whose own reply a wait takes (ReplyWait's turn:); nil once that turn
16
17
  # was reported, or when unknown (a new child, an older worker).
17
- # +report_claim+: a report reserved until the parent's turn is settled.
18
+ # +report_claim+: a report reserved until the parent's turn is settled
19
+ # (ReportClaim).
18
20
  DelegateCursor = Data.define(:reply_file, :last_turn, :question_id, :messages, :turn, :report_claim) do
19
21
  def initialize(reply_file: nil, last_turn: nil, question_id: nil, messages: nil, turn: nil, report_claim: nil) = super
20
22
 
21
23
  def self.empty = new
22
24
 
23
- # @param baseline [Hash, nil] ReplyWait.baseline_of's
25
+ # The cursor with no report reserved: what it is once the reserving
26
+ # turn is settled, and how two cursors compare apart from a claim.
27
+ def without_claim = with(report_claim: nil)
28
+
29
+ # @param baseline [ReplyWait::Baseline, nil] ReplyWait.baseline_of's
24
30
  def with_baseline(baseline)
25
- b = baseline || {}
26
- with(messages: b[:messages], question_id: b[:question_id], last_turn: b[:last_turn])
31
+ with(messages: baseline&.messages, question_id: baseline&.question_id, last_turn: baseline&.last_turn)
27
32
  end
28
33
 
29
- # @return [Hash, nil] ReplyWait's baseline:, nil when none was taken
34
+ # @return [ReplyWait::Baseline, nil] ReplyWait's baseline:, nil when
35
+ # none was taken
30
36
  def baseline
31
37
  return nil if messages.nil?
32
38
 
33
- { messages: messages, question_id: question_id, last_turn: last_turn }
39
+ ReplyWait::Baseline.new(messages: messages, question_id: question_id, last_turn: last_turn)
34
40
  end
35
41
 
36
- def to_json_hash = to_h.transform_keys(&:to_s).compact
42
+ def to_json_hash = to_h.merge(report_claim: report_claim&.to_json_hash).transform_keys(&:to_s).compact
37
43
 
38
44
  def self.from_json_hash(data)
39
45
  return empty unless data.is_a?(Hash)
40
46
 
41
47
  new(reply_file: data["reply_file"], last_turn: data["last_turn"], question_id: data["question_id"],
42
- messages: data["messages"], turn: data["turn"], report_claim: data["report_claim"])
48
+ messages: data["messages"], turn: data["turn"], report_claim: ReportClaim.from_json_hash(data["report_claim"]))
49
+ end
50
+ end
51
+
52
+ # A delegate report reserved in the parent's cursor (ChildReports) until
53
+ # the parent's turn that took it is kept or rolled back: +id+ the
54
+ # claim's id (the taking turn's Report#claim_id), +file+ the reply file
55
+ # reported (nil for a question), +cursor+ the DelegateCursor the report
56
+ # moves the parent to once kept (its own claim empty). In delegates.json
57
+ # it is {"id", "file", "cursor"}, the file's key kept even when nil.
58
+ ReportClaim = Data.define(:id, :file, :cursor) do
59
+ def to_json_hash = { "id" => id, "file" => file, "cursor" => cursor.to_json_hash }
60
+
61
+ # @return [ReportClaim, nil] nil for no claim (or not a Hash)
62
+ def self.from_json_hash(data)
63
+ return nil unless data.is_a?(Hash)
64
+
65
+ new(id: data["id"], file: data["file"], cursor: DelegateCursor.from_json_hash(data["cursor"]))
43
66
  end
44
67
  end
45
68
 
@@ -77,6 +100,7 @@ module Samagotchi
77
100
  end
78
101
 
79
102
  # Reserve a report only if the cursor read to build it is still current.
103
+ # @param claim [ReportClaim]
80
104
  # @return [DelegateCursor, nil] the reserved cursor, or nil if another path moved it
81
105
  def claim_report(parent_id, child_id, expected:, claim:, state_dir:)
82
106
  locked(parent_id, state_dir: state_dir) do
@@ -85,7 +85,7 @@ module Samagotchi
85
85
 
86
86
  fields = Relay.card(child, question, relay_id: relay_id, more: more)
87
87
  answer = relay.open_question(fields, watch: watch(child_id, qid, state_dir))
88
- settle(answer, fields, client: client, desk: desk, relay_id: relay_id, qid: qid)
88
+ settle(answer, fields, label: label(question), client: client, desk: desk, relay_id: relay_id, qid: qid)
89
89
  ensure
90
90
  # A relay that failed before it settled closes, so the desk (which
91
91
  # prunes only settled ones) keeps none open; a settled one stays.
@@ -108,12 +108,11 @@ module Samagotchi
108
108
  end
109
109
 
110
110
  # Record the parent's answer, tell the child, and say what came of it.
111
- def settle(answer, fields, client:, desk:, relay_id:, qid:)
112
- what = fields[:kind] == HOOK_KIND ? hook_what(fields) : what_text(fields[:approval])
113
- hook = fields[:kind] == HOOK_KIND
111
+ # @param label [String] the outcome line's start (#label)
112
+ def settle(answer, fields, label:, client:, desk:, relay_id:, qid:)
114
113
  if answer.is_a?(Hash) && answer[:error] == "cancelled"
115
114
  desk.close(relay_id)
116
- return closed(answer[:reason].to_s, what, client: client, relay_id: relay_id, qid: qid, hook: hook)
115
+ return closed(answer[:reason].to_s, label, client: client, relay_id: relay_id, qid: qid)
117
116
  end
118
117
 
119
118
  dismissed = !answer.is_a?(QuestionAnswer)
@@ -121,18 +120,18 @@ module Samagotchi
121
120
  desk.record(relay_id, selected_indices: dismissed ? [] : answer.selected_indices,
122
121
  freeform: dismissed ? nil : answer.freeform, dismissed: dismissed, by: by)
123
122
  response = post(client, "answered", relay_id, qid)
124
- Outcome.new(line: line(what, delivered(response, answer, fields, dismissed), hook: hook), stopped: false)
123
+ Outcome.new(line: line(label, delivered(response, answer, fields, dismissed)), stopped: false)
125
124
  end
126
125
 
127
- def closed(reason, what, client:, relay_id:, qid:, hook: false)
126
+ def closed(reason, label, client:, relay_id:, qid:)
128
127
  case reason
129
- when "answered_on_child" then Outcome.new(line: line(what, "answered on the child", hook: hook), stopped: false)
128
+ when "answered_on_child" then Outcome.new(line: line(label, "answered on the child"), stopped: false)
130
129
  when "child_gone"
131
- Outcome.new(line: line(what, "not delivered (the child's worker is gone)", hook: hook), stopped: false)
130
+ Outcome.new(line: line(label, "not delivered (the child's worker is gone)"), stopped: false)
132
131
  else
133
132
  # A Stop: the child's question stays open there.
134
133
  post(client, "closed", relay_id, qid, reason: "stopped")
135
- Outcome.new(line: line(what, "still open (your turn was stopped)", hook: hook), stopped: true)
134
+ Outcome.new(line: line(label, "still open (your turn was stopped)"), stopped: true)
136
135
  end
137
136
  end
138
137
 
@@ -172,14 +171,30 @@ module Samagotchi
172
171
  why.empty? ? "denied" : "denied (#{why.inspect})"
173
172
  end
174
173
 
175
- def line(what, outcome, hook: false)
176
- "#{hook ? "hook question" : "approval"} relayed to your user: #{what} → #{outcome}"
174
+ # One relayed question's outcome line: #label → +outcome+.
175
+ def line(label, outcome) = "#{label} → #{outcome}"
176
+
177
+ # What an outcome line starts with: "hook question relayed to your
178
+ # user: Cost cap", "approval relayed to your user: execute: git push".
179
+ # @param question [PendingQuestion] the child's
180
+ def label(question)
181
+ "#{question.kind.to_s == HOOK_KIND ? "hook question" : "approval"} relayed to your user: #{what(question)}"
182
+ end
183
+
184
+ # What a relayed question is about, as its card has it (Relay.card): a
185
+ # hook's header as the asking hook gave it (kept down a chain: a relay
186
+ # of a relay), else its hook; an approval's call (#what_text).
187
+ # @param question [PendingQuestion] the child's
188
+ def what(question)
189
+ return what_text(symbolized(question.approval)) unless question.kind.to_s == HOOK_KIND
190
+
191
+ inner = symbolized(question.relay, nil)
192
+ header = (inner ? inner[:header] : question.header).to_s
193
+ header.empty? ? question.hook.to_s : header
177
194
  end
178
195
 
179
- # A hook card's header as the child asked it (Cost cap), or its hook.
180
- def hook_what(fields)
181
- header = fields.dig(:relay, :header).to_s
182
- header.empty? ? fields[:hook].to_s : header
196
+ def symbolized(value, none = {})
197
+ value.is_a?(Hash) ? Relay.deep_symbolize(value) : none
183
198
  end
184
199
 
185
200
  # execute: git push origin main
@@ -69,7 +69,7 @@ module Samagotchi
69
69
  cursor = DelegateCursors.get(peers.session_id, child_id, state_dir: sd)
70
70
  report_claim = cursor.report_claim
71
71
  if report_claim
72
- cursor = DelegateCursor.from_json_hash(report_claim["cursor"]).with(report_claim: report_claim)
72
+ cursor = report_claim.cursor.with(report_claim: report_claim)
73
73
  end
74
74
 
75
75
  baseline = cursor.baseline
@@ -88,10 +88,10 @@ module Samagotchi
88
88
  outcomes.concat(relayed_outcomes.delete(key) || [])
89
89
  movement = advance(peers.session_id, child_id, wait, state_dir: sd)
90
90
  if movement&.already_reported
91
- file = movement.cursor.report_claim&.fetch("file", nil) || wait.file
91
+ file = movement.cursor.report_claim&.file || wait.file
92
92
  return already_reported_result(child_id, file, state_dir: sd)
93
93
  end
94
- return already_reported_result(child_id, report_claim["file"], state_dir: sd) if report_claim && wait.status == :timeout
94
+ return already_reported_result(child_id, report_claim.file, state_dir: sd) if report_claim && wait.status == :timeout
95
95
 
96
96
  text = with_outcomes(finish(wait, child_id, timeout: timeout, reports: reports), outcomes)
97
97
  return RefText.wrap(text, reply_path(child_id, wait, state_dir: sd))
@@ -179,7 +179,7 @@ module Samagotchi
179
179
 
180
180
  already_reported = false
181
181
  cursor = DelegateCursors.update(parent_id, child_id, state_dir: state_dir) do |current|
182
- if (current.report_claim && current.report_claim["file"] == wait.file) ||
182
+ if (current.report_claim && current.report_claim.file == wait.file) ||
183
183
  (wait.status == :done && current.reply_file == wait.file)
184
184
  already_reported = true
185
185
  next current
@@ -6,6 +6,7 @@ require "securerandom"
6
6
  require "shellwords"
7
7
  require "time"
8
8
  require_relative "../atomic_file"
9
+ require_relative "../child_env"
9
10
  require_relative "../log"
10
11
  require_relative "../process_group"
11
12
  require_relative "shell"
@@ -29,8 +30,7 @@ module Samagotchi
29
30
  OUTPUT_TAIL_READ_BYTES = 16 * 1024
30
31
  SANITIZED_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION].freeze
31
32
  # Set by chi (Builtins.parent_env); the model's env can't change it.
32
- MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL SAMAGOTCHI_SESSION_THINKING
33
- SAMAGOTCHI_SESSION_REASONING_HISTORY].freeze
33
+ MARKER_ENV_KEYS = ChildEnv::SESSION_MARKERS
34
34
  # task_wait and task_get add it to a task the user stopped (the web's
35
35
  # stop-task button), so the model doesn't rerun the job.
36
36
  # A record whose pid isn't a process group chi started (missing, 0, 1,
@@ -91,16 +91,25 @@ module Samagotchi
91
91
  end
92
92
  private_class_method :settled_record
93
93
 
94
+ # A command's status (its command_ran's) as a settled outcome; any
95
+ # other status (busy, dropped) is a cancel for that reason.
96
+ COMMAND_OUTCOMES = { "ok" => "completed", "error" => "failed" }.freeze
97
+ # Its record's origin: no turn ran (ParentReport says "the command").
98
+ COMMAND_ORIGIN = "command"
99
+
94
100
  # A file-delivered command ends its sender's wait without running a
95
- # turn. Keep a terminal outcome under the same per-input settled path.
96
- def self.record_command_outcome(session_dir, enqueued_id)
101
+ # turn. Keep a terminal outcome under the same per-input settled path:
102
+ # +status+ is its command_ran's ("ok", "error", "busy", "dropped" at a
103
+ # teardown), so the wait reads completed, failed, or canceled with that
104
+ # reason.
105
+ def self.record_command_outcome(session_dir, enqueued_id, status:)
97
106
  id = enqueued_id.to_s
98
107
  return if id.empty? || !SessionInbox::ENQUEUED_ID_NAME.match?(id)
99
108
 
100
- dir = File.join(session_dir, SessionInbox::INPUT_DIR, SETTLED_DIR)
101
- FileUtils.mkdir_p(dir)
102
- record = LastTurn.new(outcome: "completed", ended_at: Time.now.iso8601(3), enqueued_id: id)
103
- AtomicFile.write(File.join(dir, id), JSON.generate(record.to_file))
109
+ outcome = COMMAND_OUTCOMES.fetch(status.to_s, "canceled")
110
+ record = LastTurn.new(outcome: outcome, cancel_reason: (status.to_s if outcome == "canceled"),
111
+ ended_at: Time.now.iso8601(3), origin: COMMAND_ORIGIN, enqueued_id: id)
112
+ write_settled(session_dir, id, record)
104
113
  rescue SystemCallError, IOError => e
105
114
  Log.warn(:worker, "command_outcome_write_failed", error: e.class.name, msg: e.message, enqueued_id: id)
106
115
  end
@@ -109,14 +118,18 @@ module Samagotchi
109
118
  id = mark.enqueued_id.to_s
110
119
  return unless SessionInbox::ENQUEUED_ID_NAME.match?(id)
111
120
 
112
- dir = File.join(session_dir, SessionInbox::INPUT_DIR, SETTLED_DIR)
113
- FileUtils.mkdir_p(dir)
114
- record = settled_record(id, ended_at: Time.now.iso8601(3), seconds: seconds)
115
- AtomicFile.write(File.join(dir, id), JSON.generate(record.to_file))
121
+ write_settled(session_dir, id, settled_record(id, ended_at: Time.now.iso8601(3), seconds: seconds))
116
122
  rescue SystemCallError => e
117
123
  # The settle goes on; a wait for that turn runs to its timeout.
118
124
  Log.warn(:worker, "settled_turn_write_failed", error: e.class.name, msg: e.message)
119
125
  end
126
+
127
+ def self.write_settled(session_dir, id, record)
128
+ dir = File.join(session_dir, SessionInbox::INPUT_DIR, SETTLED_DIR)
129
+ FileUtils.mkdir_p(dir)
130
+ AtomicFile.write(File.join(dir, id), JSON.generate(record.to_file))
131
+ end
132
+ private_class_method :write_settled
120
133
  private_class_method :record_settled
121
134
 
122
135
  # @param session [Session] the worker's session (saved as a whole)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.91.0"
4
+ VERSION = "0.93.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end