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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +75 -1
- data/README.md +4 -1
- data/docs/context.md +8 -1
- data/docs/guardrails.md +31 -1
- data/docs/hooks.md +7 -4
- data/docs/memory.md +17 -1
- data/docs/plugins.md +79 -3
- data/docs/releasing.md +3 -2
- data/docs/sessions.md +9 -3
- data/lib/samagotchi/answer_command.rb +2 -1
- data/lib/samagotchi/archive_store.rb +1 -15
- data/lib/samagotchi/bridge.rb +8 -3
- data/lib/samagotchi/bundles/coordinator/manifest.yml +14 -0
- data/lib/samagotchi/bundles/coordinator/plugin.rb +114 -0
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +60 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +10 -11
- data/lib/samagotchi/bundles/system/delegated.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/bundles/system/self_map.md +4 -2
- data/lib/samagotchi/child_reports.rb +31 -10
- data/lib/samagotchi/children_status.rb +157 -0
- data/lib/samagotchi/client_id.rb +52 -0
- data/lib/samagotchi/context_absorber.rb +2 -1
- data/lib/samagotchi/context_fetch.rb +14 -45
- data/lib/samagotchi/context_sources.rb +20 -10
- data/lib/samagotchi/cut_policy.rb +58 -0
- data/lib/samagotchi/engine.rb +24 -108
- data/lib/samagotchi/engine_peers.rb +30 -0
- data/lib/samagotchi/guardrail_wiring.rb +27 -2
- data/lib/samagotchi/guardrails/approvals.rb +7 -0
- data/lib/samagotchi/guardrails/child_boundary.rb +103 -0
- data/lib/samagotchi/guardrails/context.rb +32 -0
- data/lib/samagotchi/guardrails/parent_approvals.rb +0 -2
- data/lib/samagotchi/guardrails/shared_git.rb +124 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/kernel_loop.rb +9 -22
- data/lib/samagotchi/llm/chat_loop.rb +11 -22
- data/lib/samagotchi/memory_bundle/builder.rb +35 -1
- data/lib/samagotchi/memory_bundle/installer.rb +6 -2
- data/lib/samagotchi/memory_bundle/manifest.rb +28 -5
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/memory_paths.rb +10 -0
- data/lib/samagotchi/plugin/api.rb +2 -2
- data/lib/samagotchi/plugin/sessions.rb +47 -2
- data/lib/samagotchi/process_group.rb +157 -0
- data/lib/samagotchi/prompt_warmup.rb +20 -1
- data/lib/samagotchi/question_desk.rb +2 -1
- data/lib/samagotchi/relay_verifier.rb +2 -4
- data/lib/samagotchi/reply_wait.rb +9 -4
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session_manager.rb +2 -4
- data/lib/samagotchi/steer.rb +27 -19
- data/lib/samagotchi/steer_cut.rb +125 -0
- data/lib/samagotchi/system_prompt.rb +20 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -4
- data/lib/samagotchi/terminal_ui/formatting.rb +21 -12
- data/lib/samagotchi/terminal_ui.rb +2 -1
- data/lib/samagotchi/tool_declarations.rb +9 -5
- data/lib/samagotchi/tools/builtins.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +105 -16
- data/lib/samagotchi/tools/delegate_wait.rb +55 -13
- data/lib/samagotchi/tools/execute.rb +10 -81
- data/lib/samagotchi/tools/task_runtime.rb +25 -38
- data/lib/samagotchi/turn_note.rb +9 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -1
- data/lib/samagotchi/web/public/app.js +19 -20
- data/lib/samagotchi/web/public/data.js +3 -0
- data/lib/samagotchi/web/public/question_card.js +3 -4
- data/lib/samagotchi/web/public/shown_texts.js +13 -0
- data/lib/samagotchi/web/public/stable_html.js +18 -0
- data/lib/samagotchi/web/public/turn_events.js +9 -2
- data/lib/samagotchi/web/public/turn_view.js +1 -1
- data/lib/samagotchi/worker.rb +40 -13
- 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
|
|
117
|
-
#
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
#
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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[
|
|
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:
|
|
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.
|
|
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: "
|
|
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],
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
"
|
|
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}.
|
|
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:
|
|
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: "#{
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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}
|
|
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 = [
|
|
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(
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
#
|
|
176
|
-
|
|
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
|
-
|
|
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
|