samagotchi 0.32.0 → 0.34.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -1
  3. data/README.md +4 -1
  4. data/docs/context.md +8 -1
  5. data/docs/guardrails.md +31 -1
  6. data/docs/hooks.md +7 -4
  7. data/docs/memory.md +17 -1
  8. data/docs/plugins.md +79 -3
  9. data/docs/releasing.md +3 -2
  10. data/docs/sessions.md +9 -3
  11. data/lib/samagotchi/answer_command.rb +2 -1
  12. data/lib/samagotchi/archive_store.rb +1 -15
  13. data/lib/samagotchi/bridge.rb +8 -3
  14. data/lib/samagotchi/bundles/coordinator/manifest.yml +14 -0
  15. data/lib/samagotchi/bundles/coordinator/plugin.rb +114 -0
  16. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +60 -0
  17. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  18. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  19. data/lib/samagotchi/bundles/mcp/plugin.rb +10 -11
  20. data/lib/samagotchi/bundles/system/delegated.md +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  22. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  23. data/lib/samagotchi/bundles/system/self_map.md +4 -2
  24. data/lib/samagotchi/child_reports.rb +31 -10
  25. data/lib/samagotchi/children_status.rb +157 -0
  26. data/lib/samagotchi/client_id.rb +52 -0
  27. data/lib/samagotchi/context_absorber.rb +2 -1
  28. data/lib/samagotchi/context_fetch.rb +14 -45
  29. data/lib/samagotchi/context_sources.rb +20 -10
  30. data/lib/samagotchi/cut_policy.rb +58 -0
  31. data/lib/samagotchi/engine.rb +24 -108
  32. data/lib/samagotchi/engine_peers.rb +30 -0
  33. data/lib/samagotchi/guardrail_wiring.rb +27 -2
  34. data/lib/samagotchi/guardrails/approvals.rb +7 -0
  35. data/lib/samagotchi/guardrails/child_boundary.rb +103 -0
  36. data/lib/samagotchi/guardrails/context.rb +32 -0
  37. data/lib/samagotchi/guardrails/parent_approvals.rb +0 -2
  38. data/lib/samagotchi/guardrails/shared_git.rb +124 -0
  39. data/lib/samagotchi/guardrails.rb +1 -0
  40. data/lib/samagotchi/kernel_loop.rb +9 -22
  41. data/lib/samagotchi/llm/chat_loop.rb +11 -22
  42. data/lib/samagotchi/memory_bundle/builder.rb +35 -1
  43. data/lib/samagotchi/memory_bundle/installer.rb +6 -2
  44. data/lib/samagotchi/memory_bundle/manifest.rb +28 -5
  45. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  46. data/lib/samagotchi/memory_paths.rb +10 -0
  47. data/lib/samagotchi/plugin/api.rb +2 -2
  48. data/lib/samagotchi/plugin/sessions.rb +47 -2
  49. data/lib/samagotchi/process_group.rb +157 -0
  50. data/lib/samagotchi/prompt_warmup.rb +20 -1
  51. data/lib/samagotchi/question_desk.rb +2 -1
  52. data/lib/samagotchi/relay_verifier.rb +2 -4
  53. data/lib/samagotchi/reply_wait.rb +9 -4
  54. data/lib/samagotchi/send_command.rb +2 -1
  55. data/lib/samagotchi/session_manager.rb +2 -4
  56. data/lib/samagotchi/steer.rb +27 -19
  57. data/lib/samagotchi/steer_cut.rb +125 -0
  58. data/lib/samagotchi/system_prompt.rb +20 -8
  59. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -4
  60. data/lib/samagotchi/terminal_ui/formatting.rb +21 -12
  61. data/lib/samagotchi/terminal_ui.rb +2 -1
  62. data/lib/samagotchi/tool_declarations.rb +9 -5
  63. data/lib/samagotchi/tools/builtins.rb +2 -2
  64. data/lib/samagotchi/tools/delegate.rb +105 -16
  65. data/lib/samagotchi/tools/delegate_wait.rb +55 -13
  66. data/lib/samagotchi/tools/execute.rb +10 -81
  67. data/lib/samagotchi/tools/task_runtime.rb +25 -38
  68. data/lib/samagotchi/turn_note.rb +9 -1
  69. data/lib/samagotchi/version.rb +1 -1
  70. data/lib/samagotchi/web/app.rb +2 -1
  71. data/lib/samagotchi/web/public/app.js +19 -20
  72. data/lib/samagotchi/web/public/data.js +3 -0
  73. data/lib/samagotchi/web/public/question_card.js +3 -4
  74. data/lib/samagotchi/web/public/shown_texts.js +13 -0
  75. data/lib/samagotchi/web/public/stable_html.js +18 -0
  76. data/lib/samagotchi/web/public/turn_events.js +9 -2
  77. data/lib/samagotchi/web/public/turn_view.js +1 -1
  78. data/lib/samagotchi/worker.rb +40 -13
  79. metadata +14 -1
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../client_id"
3
4
  require_relative "../served_model"
4
5
  require_relative "../image_store"
5
6
  require_relative "../turn_note"
@@ -113,21 +114,29 @@ module Samagotchi
113
114
  " #{paint("+#{get.call(:added).to_i}", 32)} #{paint("\u2212#{get.call(:removed).to_i}", 31)}"
114
115
  end
115
116
 
116
- # Prompt labels by the sender's client_id prefix (turn_events.js
117
- # CLIENT_LABELS; spec/shared/labels_matrix.json).
118
- CLIENT_LABELS = { "web" => "web", "tui" => "tui", "system" => "reminder", "delegate" => "delegate",
119
- "child" => "delegate report" }.freeze
120
-
121
- # Who sent a line, by its client_id: CLIENT_LABELS by prefix, and a
122
- # turn an attached context source started (context:<name>) as
123
- # "context <name> changed"; +fallback+ for anything else.
117
+ # Prompt labels by the sender's client_id: a whole id, or a prefix
118
+ # (ending in ":") it starts with (turn_events.js CLIENT_LABELS;
119
+ # spec/shared/labels_matrix.json).
120
+ CLIENT_LABELS = { ClientId::WEB_PREFIX => "web", ClientId::TUI_PREFIX => "tui", ClientId::SYSTEM_PREFIX => "reminder",
121
+ ClientId::DELEGATE_PREFIX => "delegate", ClientId::CHILD_PREFIX => "delegate report",
122
+ ClientId::CLI_SEND => "chi send", ClientId::CLI_ANSWER => "chi answer",
123
+ ClientId::PLUGIN => "plugin" }.freeze
124
+ # A client id chi doesn't know: not the user (ClientId.human?).
125
+ AUTOMATIC_LABEL = "automatic"
126
+
127
+ # Who sent a line, by its client_id: CLIENT_LABELS by the whole id or
128
+ # its prefix, a turn an attached context source started
129
+ # (context:<name>) as "context <name> changed", AUTOMATIC_LABEL for
130
+ # an id chi doesn't know; +fallback+ for none (the user's own).
124
131
  def client_label(client_id, fallback = "user")
125
132
  return fallback unless client_id
126
133
 
127
- prefix, rest = client_id.to_s.split(":", 2)
128
- return "context #{rest} changed" if prefix == Steer::CONTEXT_CLIENT_PREFIX.delete_suffix(":") && !rest.to_s.empty?
134
+ id = client_id.to_s
135
+ name = id.delete_prefix(ClientId::CONTEXT_PREFIX)
136
+ return "context #{name} changed" if id.start_with?(ClientId::CONTEXT_PREFIX) && !name.empty?
129
137
 
130
- CLIENT_LABELS.fetch(prefix, fallback)
138
+ prefix = id[/\A[^:]*:/]
139
+ CLIENT_LABELS[id] || (prefix && CLIENT_LABELS[prefix]) || AUTOMATIC_LABEL
131
140
  end
132
141
 
133
142
  # "web> <prompt>": a prompt, labelled by who sent it; nil: the label alone.
@@ -142,7 +151,7 @@ module Samagotchi
142
151
  status = report[/^status: (.*)$/, 1]
143
152
  rest = report.split("\n").drop(2).reject { |line| line.strip.empty? || line == "---" }.first.to_s
144
153
  rest = "#{rest[0, STEER_PREVIEW - 1]}…" if rest.length > STEER_PREVIEW
145
- "#{paint("#{CLIENT_LABELS["child"]}>", 35)} #{[id, status].compact.join(" ")}#{": #{rest}" unless rest.empty?}"
154
+ "#{paint("#{CLIENT_LABELS[ClientId::CHILD_PREFIX]}>", 35)} #{[id, status].compact.join(" ")}#{": #{rest}" unless rest.empty?}"
146
155
  end
147
156
 
148
157
  # The last prompt as a join shows it: a delegate report (a wake
@@ -5,6 +5,7 @@ require "fileutils"
5
5
  require "io/console"
6
6
  require "reline"
7
7
 
8
+ require_relative "client_id"
8
9
  require_relative "iteration_limit"
9
10
  require_relative "model_profile"
10
11
  require_relative "turn_note"
@@ -1281,7 +1282,7 @@ module Samagotchi
1281
1282
  end
1282
1283
 
1283
1284
  begin
1284
- parent = parent_answers? ? { client_id: Guardrails::ParentApprovals::CLIENT_ID } : {}
1285
+ parent = parent_answers? ? { client_id: ClientId::CLI_ANSWER } : {}
1285
1286
  @engine.answer_question(id: prompt.id, selected: answer.selected, freeform: answer.freeform, **parent)
1286
1287
  close_question_widget(prompt, prompt.answer_text(answer))
1287
1288
  return true
@@ -351,7 +351,7 @@ module Samagotchi
351
351
  },
352
352
  {
353
353
  name: "delegate",
354
- description: "Hand a task to a child chi session that runs in parallel in this folder and returns only its final reply (its trace stays out of this context). The child is a normal session: it shows in the lists as a child of this one and the user can attach to it. With session, send a follow-up to one of this session's children instead. A tool call the child needs approved goes to your user as one of your own approvals; you only see how it was answered, as a line in the result. Children keep running if this turn is canceled. With wait false, chi brings each child's reply to you by itself when the child ends its turn (a delegate report, also when you are idle): don't poll it with delegate_result. To retry or redirect a child, use delegate with its session (task_* tools are for background commands, not sessions). A child stays until stopped: once its work is done, stop it with execute `chi sessions stop ID` (a follow-up with session wakes it again).",
354
+ description: "Hand a task to a child chi session that runs in parallel in this folder, or in cwd, and returns only its final reply (its trace stays out of this context). The child is a normal session: it shows in the lists as a child of this one and the user can attach to it. With session, send a follow-up to one of this session's children instead. A tool call the child needs approved goes to your user as one of your own approvals; you only see how it was answered, as a line in the result. Children keep running if this turn is canceled. Use wait false unless this turn can't go on without the reply: the call returns at once, you end your turn or keep working with your user, and chi brings the child's reply to you by itself as a delegate report when the child ends its turn, starting a turn for it if you are idle. After wait false don't call delegate_result for that child, not to check on it and not before ending your turn. To retry or redirect a child, use delegate with its session (task_* tools are for background commands, not sessions). A child stays until stopped: once its work is done, stop it with execute `chi sessions stop ID` (a follow-up with session wakes it again).",
355
355
  parameters: {
356
356
  type: "object",
357
357
  properties: {
@@ -367,13 +367,17 @@ module Samagotchi
367
367
  type: "string",
368
368
  description: "A child's id or prefix: send the task there as a follow-up instead of starting a new child"
369
369
  },
370
+ cwd: {
371
+ type: "string",
372
+ description: "The new child's working folder: a worktree or subfolder of this session's repository (create a worktree with execute `git worktree add ../<repo>-<name> -b <branch>` first). Default: this session's folder"
373
+ },
370
374
  wait: {
371
375
  type: "boolean",
372
- description: "Wait for the reply (default true); false returns at once so several children can run side by side"
376
+ description: "true (default): this call waits for the reply; pick it only when this turn needs the reply to go on. false: returns at once and the reply comes later as a delegate report (also when you are idle); pick it for work that takes minutes, several children side by side, or to keep talking with your user"
373
377
  },
374
378
  timeout: {
375
379
  type: "integer",
376
- description: "Maximum seconds to wait (default: 600; 0 checks once without waiting); a timed-out child keeps running"
380
+ description: "Maximum seconds to wait (default: 600; 0 checks once without waiting); a timed-out child keeps running and its reply still comes as a delegate report"
377
381
  }
378
382
  },
379
383
  required: ["task"]
@@ -381,7 +385,7 @@ module Samagotchi
381
385
  },
382
386
  {
383
387
  name: "delegate_result",
384
- description: "Wait for a delegated child session's next reply: the child named, or this session's newest running child (name the child when a delegate report is about it). A delegate report already carries the reply: don't call this for it. Returns early if the child waits for a question the user must answer. A tool call the child needs approved goes to your user while this waits; the result says how it was answered.",
388
+ description: "Wait for a delegated child session's next reply, only when this turn can't go on without it: the child named, or this session's newest running child (name the child when a delegate report is about it). After delegate with wait false you don't need this: chi brings each child's reply by itself as a delegate report, also when you are idle, so end your turn or keep working instead of waiting here. A delegate report already carries the reply: don't call this for it. Returns early if the child waits for a question the user must answer. A tool call the child needs approved goes to your user while this waits; the result says how it was answered.",
385
389
  parameters: {
386
390
  type: "object",
387
391
  properties: {
@@ -391,7 +395,7 @@ module Samagotchi
391
395
  },
392
396
  timeout: {
393
397
  type: "integer",
394
- description: "Maximum seconds to wait (default: 600; 0 checks once without waiting)"
398
+ description: "Maximum seconds to wait (default: 600; 0 checks once without waiting); on a timeout the child keeps running and its reply comes as a delegate report"
395
399
  }
396
400
  }
397
401
  }
@@ -98,8 +98,8 @@ module Samagotchi
98
98
  ContextRead.call(call[:content], offset: call[:offset], limit: call[:limit], peers: kctx.peers)
99
99
  end,
100
100
  Delegate::NAME => lambda do |call, kctx|
101
- Delegate.call(call[:content], model: call[:model], session: call[:session], wait: call[:wait],
102
- timeout: call[:timeout], peers: kctx.peers)
101
+ Delegate.call(call[:content], model: call[:model], session: call[:session], cwd: call[:cwd],
102
+ wait: call[:wait], timeout: call[:timeout], peers: kctx.peers)
103
103
  end,
104
104
  DelegateResult::NAME => lambda do |call, kctx|
105
105
  DelegateResult.call(call[:content], session: call[:session], timeout: call[:timeout], peers: kctx.peers)
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../client_id"
3
4
  require_relative "../session"
4
5
  require_relative "../config"
5
6
  require_relative "../model_profile"
7
+ require_relative "../memory_paths"
6
8
  require_relative "peers"
7
9
  require_relative "delegate_wait"
8
10
  require_relative "../child_reports"
@@ -14,14 +16,14 @@ module Samagotchi
14
16
 
15
17
  module Tools
16
18
  # Hand a task to a child session: an ordinary chi session in its own
17
- # worker, started in this session's folder with the task as its first
19
+ # worker, started in this session's folder (or cwd:, a worktree or
20
+ # subfolder of the same repository) with the task as its first
18
21
  # user message and the `delegated` system memory preloaded. It shows in
19
22
  # every list as a child of this one and the user can attach to it. With
20
23
  # session:, a follow-up to a child that exists. Only the child's final
21
24
  # reply comes back (DelegateWait), never its trace.
22
25
  class Delegate
23
26
  NAME = "delegate"
24
- CLIENT_PREFIX = "delegate"
25
27
  MAX_CHILDREN_KEY = "session.max_children"
26
28
  MAX_CHILDREN_DEFAULT = 4
27
29
  # A child spawned this many seconds ago counts as running even before
@@ -38,10 +40,11 @@ module Samagotchi
38
40
  # @param content [String] the task (the child's user message, verbatim)
39
41
  # @param model [String, nil] the child's model (an alias works); the parent's by default
40
42
  # @param session [String, nil] a child's id or prefix: send the task there instead
43
+ # @param cwd [String, nil] the new child's folder (child_cwd checks it); the parent's by default
41
44
  # @param wait [Boolean, String, nil] wait for the reply (default true)
42
45
  # @param timeout [Integer, String, nil] seconds to wait (default DelegateWait::TIMEOUT_DEFAULT)
43
46
  # @param peers [Peers, nil]
44
- def self.call(content, model: nil, session: nil, wait: nil, timeout: nil, peers: nil)
47
+ def self.call(content, model: nil, session: nil, cwd: nil, wait: nil, timeout: nil, peers: nil)
45
48
  return "Error: this session's id is not known here" unless peers&.session_id
46
49
 
47
50
  task = content.to_s.strip
@@ -52,8 +55,11 @@ module Samagotchi
52
55
  wait = parse_wait(wait)
53
56
  timeout = parse_timeout(timeout)
54
57
 
58
+ cwd = cwd.to_s.strip
55
59
  child_id, warning = if session.to_s.strip.empty?
56
- start_child(task, parent: parent, model: model, state_dir: sd)
60
+ start_child(task, parent: parent, model: model, cwd: cwd, state_dir: sd, peers: peers)
61
+ elsif !cwd.empty?
62
+ "Error: cwd starts a new child; a follow-up with session keeps the child's folder"
57
63
  else
58
64
  follow_up(task, session: session.to_s.strip, parent: parent, state_dir: sd)
59
65
  end
@@ -63,35 +69,58 @@ module Samagotchi
63
69
  return note + DelegateWait.call(child_id, peers: peers, timeout: timeout) if wait
64
70
 
65
71
  started = session.to_s.strip.empty? ? "Started a delegate session" : "Sent the follow-up to delegate #{child_id[0, 8]}"
66
- "#{note}session: #{child_id}\nstatus: running\n#{started}; #{running_hint} " \
72
+ "#{note}session: #{child_id}\nstatus: running\n#{started}; #{running_hint(peers)} " \
67
73
  "It shows in chi sessions list and the web as a child of this session; the user can attach to it."
68
74
  rescue ArgumentError => e
69
75
  "Error: #{e.message}"
70
76
  end
71
77
 
72
- # How the reply of a child left running comes back.
73
- def self.running_hint
74
- return "delegate_result waits for its reply." if ChildRing.mode == "off"
75
-
76
- "chi brings its reply to you by itself when it ends its turn (a delegate report); don't poll with delegate_result."
78
+ # How the reply of a child left running comes back: a report only
79
+ # reaches a parent that can get one (DelegateWait.reports_mode).
80
+ def self.running_hint(peers)
81
+ case DelegateWait.reports_mode(peers)
82
+ when "off" then "delegate_result waits for its reply."
83
+ when "queue"
84
+ "chi adds its reply to your next turn by itself (a delegate report; you aren't woken while idle); " \
85
+ "don't poll with delegate_result."
86
+ else
87
+ "chi brings its reply to you by itself when it ends its turn (a delegate report), starting a turn for it " \
88
+ "if you are idle. Don't wait for it with delegate_result, also not to collect several children's replies: " \
89
+ "tell your user what started and end your turn, or keep working."
90
+ end
77
91
  end
78
92
  private_class_method :running_hint
79
93
 
94
+ # What to do when session.max_children are running: a parent that
95
+ # gets reports ends its turn (a report frees a slot); one that
96
+ # doesn't (DelegateWait.reports_mode off) waits with delegate_result.
97
+ def self.full_hint(peers)
98
+ if DelegateWait.reports_mode(peers) == "off"
99
+ "delegate_result waits for one; `chi sessions stop ID` stops one."
100
+ else
101
+ "End your turn: a delegate report frees a slot when a child finishes; or stop one with `chi sessions stop ID`."
102
+ end
103
+ end
104
+ private_class_method :full_hint
105
+
80
106
  # @return [String] the new child's id, or an Error: line
81
- def self.start_child(task, parent:, model:, state_dir:)
107
+ def self.start_child(task, parent:, model:, cwd:, state_dir:, peers:)
82
108
  if parent.parent_id
83
109
  return "Error: this session is a delegate of #{parent.parent_id}; delegated sessions don't delegate further"
84
110
  end
85
111
 
112
+ folder = child_cwd(cwd, parent)
113
+ return folder if folder.start_with?("Error:")
114
+
86
115
  running = running_children(parent.id, state_dir: state_dir)
87
116
  max = max_children
88
117
  if running.size >= max
89
118
  ids = running.map { |s| s[:short_id] }.join(", ")
90
119
  return "Error: #{running.size} delegate#{"s" if running.size != 1} of this session #{running.size == 1 ? "is" : "are"} running " \
91
- "(the most is #{max}, #{MAX_CHILDREN_KEY}): #{ids}. delegate_result waits for one; `chi sessions stop ID` stops one."
120
+ "(the most is #{max}, #{MAX_CHILDREN_KEY}): #{ids}. #{full_hint(peers)}"
92
121
  end
93
122
 
94
- child = SessionManager.spawn_session(prompt: task, working_directory: parent.working_directory,
123
+ child = SessionManager.spawn_session(prompt: task, working_directory: folder,
95
124
  model_name: child_model(model, parent), memories: CHILD_MEMORIES,
96
125
  parent_id: parent.id, delegate: true, state_dir: state_dir)
97
126
  DelegateWait.mark_started(parent.id, child, state_dir: state_dir)
@@ -99,6 +128,64 @@ module Samagotchi
99
128
  end
100
129
  private_class_method :start_child
101
130
 
131
+ # The new child's folder: the parent's, or +cwd+ (relative to the
132
+ # parent's) when it is a folder of the parent's repository, in any of
133
+ # its worktrees (one project_root, from the common git dir). Outside
134
+ # git only a subfolder of the parent's folder. Nothing is created
135
+ # here: the model makes a worktree with execute, where guardrails see it.
136
+ # @return [String] an absolute folder, or an Error: line
137
+ def self.child_cwd(cwd, parent)
138
+ base = parent.working_directory
139
+ return base if cwd.empty?
140
+
141
+ folder = File.expand_path(cwd, base)
142
+ return "Error: cwd #{folder} is not a folder (create the worktree first)" unless File.directory?(folder)
143
+
144
+ folder = File.realpath(folder)
145
+ unless same_repository?(folder, base)
146
+ return "Error: cwd must be a folder of this session's repository (a worktree or subfolder of it), not #{folder}"
147
+ end
148
+ if MemoryPaths.in_repo?(base) && !work_tree?(folder)
149
+ return "Error: cwd #{folder} is not in a work tree of this session's repository (a git dir, or a bare " \
150
+ "repository's folder): give a worktree's folder or a subfolder of one"
151
+ end
152
+
153
+ folder
154
+ end
155
+ private_class_method :child_cwd
156
+
157
+ # A folder git works in: under a checkout's top (work_tree_root), not
158
+ # inside the git dir (.git, .git/objects, a bare layout's .bare), and
159
+ # one `git rev-parse` says is in a work tree (it says false in a bare
160
+ # layout's container too). Read-only; IO.popen, so a spec's
161
+ # Process.spawn stub doesn't catch it.
162
+ def self.work_tree?(folder)
163
+ return false unless MemoryPaths.work_tree_root(folder)
164
+
165
+ git_dir = MemoryPaths.git_dir(folder)
166
+ return false if git_dir && within?(folder, File.realpath(git_dir))
167
+
168
+ out = IO.popen(["git", "-C", folder, "rev-parse", "--is-inside-work-tree"], err: File::NULL, &:read)
169
+ out.strip == "true"
170
+ rescue SystemCallError
171
+ false
172
+ end
173
+ private_class_method :work_tree?
174
+
175
+ def self.within?(path, dir) = path == dir || path.start_with?("#{dir}/")
176
+ private_class_method :within?
177
+
178
+ def self.same_repository?(folder, base)
179
+ if MemoryPaths.in_repo?(base)
180
+ MemoryPaths.project_root(folder) == MemoryPaths.project_root(base)
181
+ else
182
+ within?(folder, File.realpath(base))
183
+ end
184
+ rescue SystemCallError
185
+ false
186
+ end
187
+ private_class_method :same_repository?
188
+
102
189
  # @return [String] the child's id, or an Error: line
103
190
  def self.follow_up(task, session:, parent:, state_dir:)
104
191
  id = Session.resolve_id(session, state_dir: state_dir)
@@ -113,7 +200,7 @@ module Samagotchi
113
200
 
114
201
  # Only a reply after this delivery counts as the follow-up's.
115
202
  DelegateWait.mark_seen(parent.id, child, state_dir: state_dir)
116
- delivered = SessionManager.deliver_turn(id, prompt: task, client_id: "#{CLIENT_PREFIX}:#{parent.id[0, 8]}",
203
+ delivered = SessionManager.deliver_turn(id, prompt: task, client_id: "#{ClientId::DELEGATE_PREFIX}#{parent.id[0, 8]}",
117
204
  state_dir: state_dir)
118
205
  case delivered[:status]
119
206
  when :accepted then id
@@ -147,7 +234,10 @@ module Samagotchi
147
234
  SessionManager.children_of(parent_id, state_dir: state_dir).select { |s| running?(s) }
148
235
  end
149
236
 
150
- # busy (a worker runs its turn), or running with a worker on its way.
237
+ # busy (a worker runs its turn), or running with a worker on its way
238
+ # (ChildrenStatus asks it too).
239
+ # @param summary [Hash] a children_of row, or the same keys: busy,
240
+ # status, live, updated_at
151
241
  def self.running?(summary)
152
242
  return true if summary[:busy]
153
243
  return false unless summary[:status] == Session::STATUS_RUNNING && !summary[:live]
@@ -156,7 +246,6 @@ module Samagotchi
156
246
  rescue ArgumentError
157
247
  false
158
248
  end
159
- private_class_method :running?
160
249
 
161
250
  def self.max_children
162
251
  value = Integer(Config.get(MAX_CHILDREN_KEY), exception: false)
@@ -9,6 +9,10 @@ require_relative "delegate_relay"
9
9
  require_relative "delegate_cursor"
10
10
 
11
11
  module Samagotchi
12
+ # Loaded on first use: child_reports requires this file (a circular
13
+ # require otherwise).
14
+ autoload :ChildRing, File.expand_path("../child_reports", __dir__)
15
+
12
16
  module Tools
13
17
  # Waiting for a delegated session's next reply, shared by delegate and
14
18
  # delegate_result: ReplyWait in the tools' words, with the status words
@@ -58,6 +62,7 @@ module Samagotchi
58
62
  key = [peers.session_id, child_id]
59
63
  cancelled = -> { peers.cancelled? }
60
64
  relay = peers.respond_to?(:relay) ? peers.relay : nil
65
+ reports = reports_mode(peers)
61
66
  others = relay && others_for(peers.session_id, child_id, relay, sd)
62
67
  cursor = DelegateCursors.get(peers.session_id, child_id, state_dir: sd)
63
68
  baseline = cursor.baseline
@@ -72,12 +77,12 @@ module Samagotchi
72
77
  unless wait.status == :waiting_for_answer && DelegateRelay.relayable?(relay, wait.question)
73
78
  outcomes.concat(relayed_outcomes.delete(key) || [])
74
79
  advance(peers.session_id, child_id, wait, state_dir: sd)
75
- return with_outcomes(finish(wait, child_id, timeout: timeout), outcomes)
80
+ return with_outcomes(finish(wait, child_id, timeout: timeout, reports: reports), outcomes)
76
81
  end
77
82
 
78
83
  outcome = DelegateRelay.call(child_id, wait.question, relay: relay, state_dir: sd, more: others&.count.to_i)
79
84
  outcomes << outcome.line
80
- return with_outcomes(canceled_result(child_id), outcomes) if outcome.stopped
85
+ return with_outcomes(canceled_result(child_id, reports: reports), outcomes) if outcome.stopped
81
86
 
82
87
  # Wait on with the child as it is now: a turn that ends with no
83
88
  # reply after the relay still ends the wait, and the relayed
@@ -104,8 +109,26 @@ module Samagotchi
104
109
  text.sub(/\A(session: [^\n]*\nstatus: [^\n]*\n)/) { "#{::Regexp.last_match(1)}#{outcomes.join("\n")}\n" }
105
110
  end
106
111
 
107
- def canceled_result(child_id)
108
- result(child_id, "running", "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
112
+ # How a child's reply reaches this parent when no wait takes it:
113
+ # "wake" (a delegate report, a turn of its own when idle), "queue" (a
114
+ # report at its next step or turn, no wake) or "off" (only
115
+ # delegate_result). Only a worker's session reads its children's rings
116
+ # (its Engine gives a relay); a REPL, a -p run or a scratch session
117
+ # never gets a report.
118
+ # @param peers [Peers]
119
+ # @return [String] "wake", "queue" or "off"
120
+ def reports_mode(peers)
121
+ return "off" unless peers.respond_to?(:relay) && peers.relay
122
+
123
+ ChildRing.mode
124
+ end
125
+
126
+ def canceled_result(child_id, reports: "off")
127
+ if reports == "off"
128
+ return result(child_id, "running", "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
129
+ end
130
+
131
+ result(child_id, "running", "wait canceled; the child keeps running; chi brings its reply as a delegate report")
109
132
  end
110
133
 
111
134
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -135,8 +158,10 @@ module Samagotchi
135
158
  # @param wait [ReplyWait::Result]
136
159
  # @param report [Boolean] a delegate report's text (ChildReports), not
137
160
  # the tool's: a question's last line says chi brings the next reply
161
+ # @param reports [String] #reports_mode: what a timeout, a cancel or
162
+ # a question tells the model about the reply to come
138
163
  # @return [String]
139
- def finish(wait, child_id, timeout:, report: false)
164
+ def finish(wait, child_id, timeout:, report: false, reports: "off")
140
165
  status = ParentReport.status(wait)
141
166
  case wait.status
142
167
  when :done
@@ -146,16 +171,16 @@ module Samagotchi
146
171
  when :stopped
147
172
  result(child_id, status, "the child was stopped (chi sessions stop); delegate with session: #{child_id} starts it again with a message")
148
173
  when :waiting_for_answer
149
- result(child_id, status, waiting_text(child_id, wait.question, report: report))
174
+ result(child_id, status, waiting_text(child_id, wait.question, report: report, reports: reports))
150
175
  when :canceled
151
- canceled_result(child_id)
176
+ canceled_result(child_id, reports: reports)
152
177
  when :no_reply
153
178
  result(child_id, status, "#{no_reply_text(wait, child_id)}; its session shows what happened")
154
179
  when :worker_gone
155
180
  result(child_id, status,
156
181
  "the child's worker is gone (it stopped or crashed); delegate with session: #{child_id} starts it again with a message")
157
182
  else
158
- timeout_result(child_id, timeout)
183
+ timeout_result(child_id, timeout, reports: reports)
159
184
  end
160
185
  end
161
186
 
@@ -191,10 +216,24 @@ module Samagotchi
191
216
  "session: #{child_id}\nstatus: #{status}\n#{text}"
192
217
  end
193
218
 
194
- def timeout_result(child_id, timeout)
219
+ # The cursor didn't move (#advance), so the reply still comes as a
220
+ # delegate report when this parent can get one.
221
+ def timeout_result(child_id, timeout, reports: "off")
222
+ head = "no reply yet after #{timeout.to_i} s; the child keeps running."
223
+ if reports == "off"
224
+ return result(child_id, "running",
225
+ "#{head} delegate_result #{child_id} waits again; chi --attach #{child_id} shows it.")
226
+ end
227
+
228
+ comes = if reports == "queue"
229
+ "chi adds its reply to your turn at its next step, or to your next turn, as a delegate report " \
230
+ "(you are not woken while idle)"
231
+ else
232
+ "chi brings its reply here by itself as a delegate report when it ends its turn (also after you end yours)"
233
+ end
195
234
  result(child_id, "running",
196
- "no reply yet after #{timeout.to_i} s; the child keeps running. delegate_result #{child_id} waits again; " \
197
- "chi --attach #{child_id} shows it.")
235
+ "#{head} #{comes}, so carry on or end your turn. Only if this turn can't go on without it: " \
236
+ "delegate_result #{child_id} waits again. chi --attach #{child_id} shows it.")
198
237
  end
199
238
 
200
239
  # The whole question, as `chi send --wait` prints it (ParentReport):
@@ -205,7 +244,8 @@ module Samagotchi
205
244
  # report back.
206
245
  # A report's last line differs (+report+): "waits again" made models
207
246
  # call delegate_result right after answering Continue.
208
- def waiting_text(child_id, pending, report: false)
247
+ # The tool's last line follows #reports_mode.
248
+ def waiting_text(child_id, pending, report: false, reports: "off")
209
249
  text = "Child #{child_id} is #{ParentReport.question_text(pending, session_id: child_id)}"
210
250
  if ParentReport.continue?(pending)
211
251
  text += "The child ran out of steps before it answered. Decide: continue it (run the chi answer command " \
@@ -213,8 +253,10 @@ module Samagotchi
213
253
  "session: #{child_id} (that drops the question); or stop it and report back to your user.\n"
214
254
  end
215
255
  return "#{text}#{REPORT_NEXT_REPLY}" if report
256
+ return "#{text}delegate_result #{child_id} waits again once it is answered." if reports == "off"
216
257
 
217
- "#{text}delegate_result #{child_id} waits again once it is answered."
258
+ "#{text}Once it is answered, chi brings the child's next reply here as a delegate report; " \
259
+ "delegate_result #{child_id} waits for it only if this turn needs it."
218
260
  end
219
261
 
220
262
  def cut(text)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require_relative "../process_group"
4
5
  require_relative "output_guardrails"
5
6
  require_relative "shell"
6
7
 
@@ -95,7 +96,7 @@ module Samagotchi
95
96
 
96
97
  Open3.popen3(env || {}, *Shell.argv(command), chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
97
98
  stdin.close
98
- readers = [OutputReader.new(stdout), OutputReader.new(stderr)]
99
+ readers = [stdout, stderr].map { |io| ProcessGroup::PipeReader.new(io, chunk: READ_CHUNK_BYTES) }
99
100
 
100
101
  begin
101
102
  until wait_thr.join(WAIT_SLICE_SEC)
@@ -132,7 +133,7 @@ module Samagotchi
132
133
  stopped = stop_process_group(wait_thr.pid, readers)
133
134
  held_open = (stopped ? :stopped : :detached) if outcome == :grace && !stopping
134
135
  end
135
- readers.each(&:finish)
136
+ readers.each { |reader| reader.finish(poll: STOP_POLL_INTERVAL_SEC, grace: STOP_GRACE_SEC) }
136
137
  stdout_text, stderr_text = readers.map(&:text)
137
138
  end
138
139
  end
@@ -162,95 +163,23 @@ module Samagotchi
162
163
  # has members holding the output: TERM the group, then KILL it. A process
163
164
  # that left the group (setsid) is out of reach; the pipes get closed.
164
165
  # True once the output was released, false if something still holds it.
166
+ # Only the group: the leader's pid is reaped, so it may name another process.
165
167
  def self.stop_process_group(pgid, readers)
166
- signal_group(pgid, "TERM")
167
- return true if await_readers(readers, until_time: monotonic_time + STOP_GRACE_SEC, deadline: nil,
168
- cancelled: -> { false }) == :done
168
+ released = -> { readers.all?(&:done?) }
169
+ wait = ->(seconds) { readers.find { |r| !r.done? }&.wait(seconds) }
170
+ return true if ProcessGroup.stop(pgid, grace: STOP_GRACE_SEC, poll: STOP_POLL_INTERVAL_SEC, stopped: released, wait: wait)
169
171
 
170
- signal_group(pgid, "KILL")
171
172
  await_readers(readers, until_time: monotonic_time + STOP_GRACE_SEC, deadline: nil, cancelled: -> { false }) == :done
172
173
  end
173
174
  private_class_method :stop_process_group
174
175
 
175
- # Only the group: the leader's pid is reaped, so it may name another process.
176
- def self.signal_group(pgid, signal)
177
- Process.kill(signal, -pgid)
178
- rescue Errno::ESRCH, Errno::EPERM
179
- nil
180
- end
181
- private_class_method :signal_group
182
-
183
- # Reads a pipe in chunks on its own thread, so the output read so far
184
- # survives the pipe being closed under it.
185
- class OutputReader
186
- def initialize(io)
187
- @io = io
188
- @buffer = String.new(encoding: Encoding::BINARY)
189
- @thread = Thread.new do
190
- Thread.current.report_on_exception = false
191
- loop { @buffer << io.readpartial(READ_CHUNK_BYTES) }
192
- rescue IOError # EOFError is one
193
- nil
194
- end
195
- end
196
-
197
- def done? = !@thread.alive?
198
-
199
- def wait(seconds) = @thread.join(seconds)
200
-
201
- # Closes the pipe (a reader still blocked gets IOError) and ends the
202
- # thread; the text is safe to read afterwards.
203
- def finish
204
- @thread.join(STOP_POLL_INTERVAL_SEC) unless done?
205
- close_quietly
206
- @thread.kill unless @thread.join(STOP_GRACE_SEC)
207
- end
208
-
209
- def text
210
- @buffer.dup.force_encoding(@io.external_encoding || Encoding.default_external)
211
- end
212
-
213
- private
214
-
215
- def close_quietly
216
- @io.close unless @io.closed?
217
- rescue IOError
218
- nil
219
- end
220
- end
221
- private_constant :OutputReader
222
-
176
+ # The shell still runs: TERM its group (or the shell alone, if it left
177
+ # the group), then KILL it once the grace is over.
223
178
  def self.terminate_process_tree(pid)
224
- signal_process(pid, "TERM")
225
-
226
- deadline = monotonic_time + STOP_GRACE_SEC
227
- sleep(STOP_POLL_INTERVAL_SEC) while process_alive?(pid) && monotonic_time < deadline
228
-
229
- signal_process(pid, "KILL") if process_alive?(pid)
179
+ ProcessGroup.stop(pid, grace: STOP_GRACE_SEC, poll: STOP_POLL_INTERVAL_SEC, leader: true)
230
180
  end
231
181
  private_class_method :terminate_process_tree
232
182
 
233
- def self.signal_process(pid, signal)
234
- Process.kill(signal, -pid)
235
- rescue Errno::ESRCH, Errno::EPERM
236
- begin
237
- Process.kill(signal, pid)
238
- rescue Errno::ESRCH, Errno::EPERM
239
- nil
240
- end
241
- end
242
- private_class_method :signal_process
243
-
244
- def self.process_alive?(pid)
245
- Process.kill(0, pid)
246
- true
247
- rescue Errno::ESRCH
248
- false
249
- rescue Errno::EPERM
250
- true
251
- end
252
- private_class_method :process_alive?
253
-
254
183
  def self.monotonic_time
255
184
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
256
185
  end