ace-herdr 0.1.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 +7 -0
- data/.ace-defaults/herdr/config.yml +26 -0
- data/.ace-defaults/herdr/tabs/agent.yml +11 -0
- data/.ace-defaults/herdr/workspaces/development.yml +23 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE +21 -0
- data/README.md +54 -0
- data/Rakefile +12 -0
- data/docs/usage.md +306 -0
- data/exe/ace-herdr +17 -0
- data/lib/ace/herdr/atoms/answer_digest.rb +18 -0
- data/lib/ace/herdr/cli/commands/capture.rb +55 -0
- data/lib/ace/herdr/cli/commands/close.rb +53 -0
- data/lib/ace/herdr/cli/commands/deliver.rb +67 -0
- data/lib/ace/herdr/cli/commands/dispatch.rb +62 -0
- data/lib/ace/herdr/cli/commands/list.rb +71 -0
- data/lib/ace/herdr/cli/commands/list_presets.rb +47 -0
- data/lib/ace/herdr/cli/commands/send.rb +103 -0
- data/lib/ace/herdr/cli/commands/support.rb +68 -0
- data/lib/ace/herdr/cli/commands/tab.rb +54 -0
- data/lib/ace/herdr/cli/commands/tidy.rb +65 -0
- data/lib/ace/herdr/cli/commands/wait.rb +88 -0
- data/lib/ace/herdr/cli/commands/workspace.rb +52 -0
- data/lib/ace/herdr/cli.rb +98 -0
- data/lib/ace/herdr/errors.rb +51 -0
- data/lib/ace/herdr/models/delivery_record.rb +109 -0
- data/lib/ace/herdr/models/dispatch_outcome.rb +29 -0
- data/lib/ace/herdr/molecules/delivery_record_store.rb +150 -0
- data/lib/ace/herdr/molecules/herdr_executor.rb +264 -0
- data/lib/ace/herdr/molecules/pane_tidy_probe.rb +80 -0
- data/lib/ace/herdr/molecules/preset_loader.rb +70 -0
- data/lib/ace/herdr/molecules/preset_resolver.rb +88 -0
- data/lib/ace/herdr/organisms/control_surface.rb +575 -0
- data/lib/ace/herdr/organisms/deliverer.rb +358 -0
- data/lib/ace/herdr/organisms/dispatcher.rb +130 -0
- data/lib/ace/herdr/organisms/tidy.rb +188 -0
- data/lib/ace/herdr/version.rb +7 -0
- data/lib/ace/herdr.rb +103 -0
- metadata +224 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require "time"
|
|
5
|
+
require "ace/hitl"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Herdr
|
|
9
|
+
module Organisms
|
|
10
|
+
# Push delivery per the ace-hitl provider delivery contract
|
|
11
|
+
# (spec 8wm.t.vrz §1.2, task 8wm.t.vs0):
|
|
12
|
+
# deliver(ref, answer) -> Ace::Hitl::Providers::DeliverResult
|
|
13
|
+
# with state :delivered, :retryable or :failed.
|
|
14
|
+
#
|
|
15
|
+
# Guarantees:
|
|
16
|
+
# - Idempotent per event id: a delivered record short-circuits
|
|
17
|
+
# identical content; different content or a different destination
|
|
18
|
+
# for the same id fails closed.
|
|
19
|
+
# - The answer is never lost: the record carries the full answer and
|
|
20
|
+
# is persisted before the first herdr contact (0600, atomic) and
|
|
21
|
+
# around every attempt; a crashed run can be resumed with #resume.
|
|
22
|
+
# - Concurrent deliveries of one event serialize on a per-event lock.
|
|
23
|
+
# - An ambiguous crash window (prompt submitted, outcome not yet
|
|
24
|
+
# persisted) is reported as :failed instead of silently resending.
|
|
25
|
+
# - A pane without an agent is bootstrapped (herdr agent start) with
|
|
26
|
+
# the reverse address exported into the pane shell, then gated on
|
|
27
|
+
# readiness before the prompt is submitted.
|
|
28
|
+
# - Transient failures (probe or prompt) persist their history and
|
|
29
|
+
# return :retryable within retry limits; terminal failures return
|
|
30
|
+
# :failed with the error persisted in the record.
|
|
31
|
+
class Deliverer
|
|
32
|
+
READY_STATES = %w[idle].freeze
|
|
33
|
+
|
|
34
|
+
# executor: seam responding to the Molecules::HerdrExecutor API
|
|
35
|
+
# clock: sleeper for backoff, injectable for tests (default Kernel.sleep)
|
|
36
|
+
def initialize(executor:, deliveries_dir:, max_attempts:, backoff_seconds:,
|
|
37
|
+
default_agent_kind:, agent_start_timeout_ms:, readiness_timeout_ms:, clock: nil)
|
|
38
|
+
@executor = executor
|
|
39
|
+
@deliveries_dir = deliveries_dir
|
|
40
|
+
@max_attempts = max_attempts
|
|
41
|
+
@backoff_seconds = backoff_seconds
|
|
42
|
+
@default_agent_kind = default_agent_kind
|
|
43
|
+
@agent_start_timeout_ms = agent_start_timeout_ms
|
|
44
|
+
@readiness_timeout_ms = readiness_timeout_ms
|
|
45
|
+
@clock = clock || ->(seconds) { Kernel.sleep(seconds) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Build a Deliverer from the merged config cascade hash (string keys)
|
|
49
|
+
def self.from_config(executor:, deliveries_dir:, config: {})
|
|
50
|
+
delivery = config["delivery"] || {}
|
|
51
|
+
timeouts = config["timeouts"] || {}
|
|
52
|
+
new(
|
|
53
|
+
executor: executor,
|
|
54
|
+
deliveries_dir: deliveries_dir,
|
|
55
|
+
max_attempts: delivery["max_attempts"] || 3,
|
|
56
|
+
backoff_seconds: delivery["backoff_seconds"] || [1, 2, 4],
|
|
57
|
+
default_agent_kind: config["default_agent_kind"] || "pi",
|
|
58
|
+
agent_start_timeout_ms: (timeouts["agent_start"] || 60) * 1000,
|
|
59
|
+
readiness_timeout_ms: (timeouts["wait"] || 30) * 1000
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param ref [Ace::Hitl::Providers::Ref, Hash] reverse address; a Hash
|
|
64
|
+
# with "session"/"pane" keys (persisted event fields) is coerced.
|
|
65
|
+
# @param answer [String] content pushed to the pane
|
|
66
|
+
# @param event_id [String, nil] idempotency key; derived from the
|
|
67
|
+
# ref and content digest when omitted
|
|
68
|
+
# @param kind [String, nil] agent kind for bootstrap
|
|
69
|
+
# (default: config default_agent_kind)
|
|
70
|
+
# @param label [String, nil] agent name used when bootstrapping
|
|
71
|
+
# (default: the event id)
|
|
72
|
+
# @return [Ace::Hitl::Providers::DeliverResult]
|
|
73
|
+
# @raise [Ace::Hitl::Providers::InvalidRefError] invalid reverse address
|
|
74
|
+
# @raise [ValidationError] event id, destination or content conflict
|
|
75
|
+
def deliver(ref, answer, event_id: nil, kind: nil, label: nil)
|
|
76
|
+
ref = coerce_ref(ref)
|
|
77
|
+
raise ValidationError, "answer is required" if answer.to_s.empty?
|
|
78
|
+
|
|
79
|
+
digest = Ace::Herdr::Atoms::AnswerDigest.call(answer)
|
|
80
|
+
event_id = normalize_event_id(event_id, ref, digest)
|
|
81
|
+
|
|
82
|
+
Molecules::DeliveryRecordStore.with_lock(@deliveries_dir, event_id) do
|
|
83
|
+
record = Molecules::DeliveryRecordStore.load(@deliveries_dir, event_id)
|
|
84
|
+
validate_existing_record(record, ref, digest) if record
|
|
85
|
+
record ||= Models::DeliveryRecord.new(
|
|
86
|
+
event_id: event_id, session: ref.session, pane: ref.pane,
|
|
87
|
+
answer_digest: digest, answer: answer
|
|
88
|
+
)
|
|
89
|
+
deliver_locked(ref, answer, record, kind, label)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Re-deliver from the persisted record after a crash. The answer and
|
|
94
|
+
# destination come from the record; no fresh content is needed.
|
|
95
|
+
# @return [Ace::Hitl::Providers::DeliverResult]
|
|
96
|
+
# @raise [ValidationError] no recoverable record for the event id
|
|
97
|
+
def resume(event_id, kind: nil, label: nil)
|
|
98
|
+
validate_event_id!(event_id)
|
|
99
|
+
Molecules::DeliveryRecordStore.with_lock(@deliveries_dir, event_id) do
|
|
100
|
+
record = Molecules::DeliveryRecordStore.load(@deliveries_dir, event_id)
|
|
101
|
+
if record.nil? || record.answer.to_s.empty?
|
|
102
|
+
raise ValidationError,
|
|
103
|
+
"no recoverable answer stored for event #{event_id.inspect}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
ref = Ace::Hitl::Providers::Ref.new(session: record.session, pane: record.pane)
|
|
107
|
+
deliver_locked(ref, record.answer, record, kind, label)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def deliver_locked(ref, answer, record, kind, label)
|
|
114
|
+
if record.delivered?
|
|
115
|
+
return DeliverResult(ref: ref, state: :delivered)
|
|
116
|
+
end
|
|
117
|
+
if record.state == "failed"
|
|
118
|
+
# Terminal: retrying would need a new event id, never a resend
|
|
119
|
+
return DeliverResult(ref: ref, state: :failed)
|
|
120
|
+
end
|
|
121
|
+
if record.ambiguous_submission?
|
|
122
|
+
# The previous run may have already pushed this answer; resending
|
|
123
|
+
# could duplicate it, so report instead of acting.
|
|
124
|
+
record = record.append_event(
|
|
125
|
+
state: "failed",
|
|
126
|
+
detail: {action: "reconcile", outcome: "ambiguous",
|
|
127
|
+
error: "previous run crashed after submitting; resolve manually"},
|
|
128
|
+
timestamp: now
|
|
129
|
+
)
|
|
130
|
+
persist(record)
|
|
131
|
+
return DeliverResult(ref: ref, state: :failed)
|
|
132
|
+
end
|
|
133
|
+
persist(record) if record.attempts.zero? && record.history.empty?
|
|
134
|
+
|
|
135
|
+
deliver_with_bootstrap(ref, answer, record, kind, label)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def deliver_with_bootstrap(ref, answer, record, kind, label)
|
|
139
|
+
outcome = probe_agent(ref, record, kind, label)
|
|
140
|
+
return outcome.result if outcome.terminal?
|
|
141
|
+
|
|
142
|
+
push_prompt(ref, answer, outcome.record)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Probe the pane and bootstrap a missing agent. Returns a terminal
|
|
146
|
+
# outcome carrying the final DeliverResult, or the record to prompt.
|
|
147
|
+
# Transient probe failures retry within the configured limits.
|
|
148
|
+
def probe_agent(ref, record, kind, label)
|
|
149
|
+
attempt = 0
|
|
150
|
+
loop do
|
|
151
|
+
@executor.agent_get(ref.pane)
|
|
152
|
+
return Outcome.present(record)
|
|
153
|
+
rescue PaneNotFoundError => e
|
|
154
|
+
return Outcome.terminal(terminal(ref, record, e, action: "probe"))
|
|
155
|
+
rescue AgentNotFoundError
|
|
156
|
+
return bootstrap_and_wait(ref, record, kind, label)
|
|
157
|
+
rescue ExecutorError => e
|
|
158
|
+
attempt += 1
|
|
159
|
+
exhausted = attempt >= @max_attempts
|
|
160
|
+
record = record.append_event(
|
|
161
|
+
state: exhausted ? "retryable" : "pending",
|
|
162
|
+
detail: {action: "probe", attempt: attempt, outcome: e.class.name,
|
|
163
|
+
error: e.message},
|
|
164
|
+
timestamp: now
|
|
165
|
+
)
|
|
166
|
+
persist(record)
|
|
167
|
+
if exhausted
|
|
168
|
+
return Outcome.terminal(DeliverResult(ref: ref, state: :retryable))
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
@clock.call(backoff_for(attempt))
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Bootstrap a missing agent, then gate on readiness. Returns a
|
|
176
|
+
# terminal outcome when bootstrapping or readiness terminates the
|
|
177
|
+
# delivery; otherwise the record to prompt. Bootstrap failures are
|
|
178
|
+
# terminal: an immediate retry cannot heal a broken pane.
|
|
179
|
+
def bootstrap_and_wait(ref, record, kind, label)
|
|
180
|
+
export = "export HERDR_SESSION=#{Shellwords.escape(ref.session)} " \
|
|
181
|
+
"HERDR_PANE=#{Shellwords.escape(ref.pane)}"
|
|
182
|
+
begin
|
|
183
|
+
@executor.pane_run(ref.pane, export)
|
|
184
|
+
@executor.agent_start(
|
|
185
|
+
name: label || record.event_id, kind: kind || @default_agent_kind,
|
|
186
|
+
pane: ref.pane, timeout_ms: @agent_start_timeout_ms
|
|
187
|
+
)
|
|
188
|
+
rescue ExecutorError => e
|
|
189
|
+
return Outcome.terminal(terminal(ref, record, e, action: "bootstrap"))
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
record = record.append_event(
|
|
193
|
+
detail: {action: "bootstrap", outcome: "agent started"},
|
|
194
|
+
timestamp: now
|
|
195
|
+
)
|
|
196
|
+
persist(record)
|
|
197
|
+
|
|
198
|
+
begin
|
|
199
|
+
@executor.agent_wait(
|
|
200
|
+
pane: ref.pane, until_states: READY_STATES,
|
|
201
|
+
timeout_ms: @readiness_timeout_ms
|
|
202
|
+
)
|
|
203
|
+
rescue ExecutorError => e
|
|
204
|
+
# Started but not yet ready: do not prompt; safe to retry later
|
|
205
|
+
record = record.append_event(
|
|
206
|
+
state: "retryable",
|
|
207
|
+
detail: {action: "readiness", outcome: "timeout", error: e.message},
|
|
208
|
+
timestamp: now
|
|
209
|
+
)
|
|
210
|
+
persist(record)
|
|
211
|
+
return Outcome.terminal(DeliverResult(ref: ref, state: :retryable))
|
|
212
|
+
end
|
|
213
|
+
Outcome.present(record)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Prompt with retry limits and fixed deterministic backoff. Each
|
|
217
|
+
# attempt is written ahead (outcome "submitting") so an interrupted
|
|
218
|
+
# run never silently duplicates the submission.
|
|
219
|
+
def push_prompt(ref, answer, record)
|
|
220
|
+
attempt = 0
|
|
221
|
+
loop do
|
|
222
|
+
attempt += 1
|
|
223
|
+
record = record.append_event(
|
|
224
|
+
detail: {action: "prompt", outcome: "submitting"},
|
|
225
|
+
timestamp: now
|
|
226
|
+
)
|
|
227
|
+
persist(record)
|
|
228
|
+
begin
|
|
229
|
+
@executor.agent_prompt(pane: ref.pane, text: answer)
|
|
230
|
+
record = record.record_attempt(
|
|
231
|
+
state: "delivered",
|
|
232
|
+
detail: {action: "prompt", outcome: "delivered"},
|
|
233
|
+
timestamp: now
|
|
234
|
+
)
|
|
235
|
+
persist(record)
|
|
236
|
+
return DeliverResult(ref: ref, state: :delivered)
|
|
237
|
+
rescue ExecutorError => e
|
|
238
|
+
terminal = !e.retryable?
|
|
239
|
+
exhausted = !terminal && attempt >= @max_attempts
|
|
240
|
+
state = if terminal
|
|
241
|
+
"failed"
|
|
242
|
+
elsif exhausted
|
|
243
|
+
"retryable"
|
|
244
|
+
else
|
|
245
|
+
"pending"
|
|
246
|
+
end
|
|
247
|
+
record = record.record_attempt(
|
|
248
|
+
state: state,
|
|
249
|
+
detail: {action: "prompt", outcome: e.class.name, error: e.message},
|
|
250
|
+
timestamp: now
|
|
251
|
+
)
|
|
252
|
+
persist(record)
|
|
253
|
+
if terminal
|
|
254
|
+
return DeliverResult(ref: ref, state: :failed)
|
|
255
|
+
elsif exhausted
|
|
256
|
+
return DeliverResult(ref: ref, state: :retryable)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
@clock.call(backoff_for(attempt))
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Fail closed when an existing record conflicts with this delivery
|
|
265
|
+
def validate_existing_record(record, ref, digest)
|
|
266
|
+
if record.answer_digest != digest || record.session != ref.session ||
|
|
267
|
+
record.pane != ref.pane
|
|
268
|
+
raise ValidationError,
|
|
269
|
+
"event #{record.event_id} was already used for a different " \
|
|
270
|
+
"answer or destination (idempotency conflict; fail closed)"
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def terminal(ref, record, error, action:)
|
|
275
|
+
record = record.record_attempt(
|
|
276
|
+
state: "failed",
|
|
277
|
+
detail: {action: action, outcome: error.class.name, error: error.message},
|
|
278
|
+
timestamp: now
|
|
279
|
+
)
|
|
280
|
+
persist(record)
|
|
281
|
+
DeliverResult(ref: ref, state: :failed)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def persist(record)
|
|
285
|
+
Molecules::DeliveryRecordStore.save(record, @deliveries_dir)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def backoff_for(attempt)
|
|
289
|
+
@backoff_seconds[[attempt - 1, @backoff_seconds.length - 1].min].to_f
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def coerce_ref(ref)
|
|
293
|
+
session, pane =
|
|
294
|
+
if ref.is_a?(Ace::Hitl::Providers::Ref)
|
|
295
|
+
[ref.session, ref.pane]
|
|
296
|
+
elsif ref.is_a?(Hash)
|
|
297
|
+
[ref["session"], ref["pane"]]
|
|
298
|
+
else
|
|
299
|
+
raise ValidationError, "ref must be an Ace::Hitl::Providers::Ref or a Hash"
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
Ace::Hitl::Providers::Ref.new(
|
|
303
|
+
session: Ace::Hitl::Providers::Ref.validate!(session, "ref session"),
|
|
304
|
+
pane: Ace::Hitl::Providers::Ref.validate!(pane, "ref pane")
|
|
305
|
+
)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def normalize_event_id(event_id, ref, digest)
|
|
309
|
+
return validate_event_id!(event_id) if event_id
|
|
310
|
+
|
|
311
|
+
seed = Ace::Herdr::Atoms::AnswerDigest.call("ref:#{ref.session}/#{ref.pane}:#{digest}")
|
|
312
|
+
"ans-#{seed[0, 24]}"
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Event ids become record file names and lock names; only tokens may
|
|
316
|
+
# pass (also blocks path traversal via resume)
|
|
317
|
+
def validate_event_id!(event_id)
|
|
318
|
+
token = event_id.to_s
|
|
319
|
+
unless token.match?(Ace::Hitl::Providers::Ref::TOKEN_PATTERN)
|
|
320
|
+
raise ValidationError,
|
|
321
|
+
"event id may only contain letters, digits, '.', '_', ':', '-' " \
|
|
322
|
+
"(got #{token.inspect})"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
token
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def now
|
|
329
|
+
Time.now.utc.iso8601
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Contract result type from ace-hitl (spec 8wm.t.vrz §1.2)
|
|
333
|
+
def DeliverResult(ref:, state:)
|
|
334
|
+
Ace::Hitl::Providers::DeliverResult.new(ref: ref, state: state)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Probe/bootstrap phase outcome for deliver_with_bootstrap
|
|
338
|
+
class Outcome
|
|
339
|
+
attr_reader :record, :result
|
|
340
|
+
|
|
341
|
+
def initialize(record:, result: nil, terminal: false, bootstrapped: false)
|
|
342
|
+
@record = record
|
|
343
|
+
@result = result
|
|
344
|
+
@terminal = terminal
|
|
345
|
+
@bootstrapped = bootstrapped
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def self.present(record) = new(record: record)
|
|
349
|
+
def self.bootstrapped(record) = new(record: record, bootstrapped: true)
|
|
350
|
+
def self.terminal(result) = new(record: nil, result: result, terminal: true)
|
|
351
|
+
|
|
352
|
+
def terminal? = @terminal
|
|
353
|
+
def bootstrapped? = @bootstrapped
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require "ace/hitl"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Herdr
|
|
8
|
+
module Organisms
|
|
9
|
+
# One-command subagent dispatch (spec 8wm.t.vs0 scope extension):
|
|
10
|
+
# tab + herdr agent start + prompt with deterministic defaults —
|
|
11
|
+
# same workspace as the caller, label = agent name, prompt from
|
|
12
|
+
# file or stdin. Zero-token: no LLM decisions here.
|
|
13
|
+
class Dispatcher
|
|
14
|
+
PANE_KEYS = %w[pane_id paneId].freeze
|
|
15
|
+
WORKSPACE_KEYS = %w[workspace_id workspaceId].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(executor:, default_agent_kind:, agent_start_timeout_ms:)
|
|
18
|
+
@executor = executor
|
|
19
|
+
@default_agent_kind = default_agent_kind
|
|
20
|
+
@agent_start_timeout_ms = agent_start_timeout_ms
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.from_config(executor:, config: {})
|
|
24
|
+
timeouts = config["timeouts"] || {}
|
|
25
|
+
new(
|
|
26
|
+
executor: executor,
|
|
27
|
+
default_agent_kind: config["default_agent_kind"] || "pi",
|
|
28
|
+
agent_start_timeout_ms: (timeouts["agent_start"] || 60) * 1000
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param label [String] agent name and pane label (typically the task id)
|
|
33
|
+
# @param prompt [String] initial prompt submitted after readiness
|
|
34
|
+
# @param kind [String, nil] agent kind (default: config default_agent_kind)
|
|
35
|
+
# @param workspace_id [String, nil] target herdr workspace
|
|
36
|
+
# (default: caller's workspace via env or pane current)
|
|
37
|
+
# @param pane [String, nil] existing pane to start the agent in;
|
|
38
|
+
# when omitted a new tab is created in the workspace
|
|
39
|
+
# @param cwd [String, nil] working directory for a created tab
|
|
40
|
+
# @return [Models::DispatchOutcome]
|
|
41
|
+
# @raise [TargetResolutionError] workspace/pane cannot be resolved
|
|
42
|
+
# @raise [ExecutorError] herdr command failures
|
|
43
|
+
def dispatch(label:, prompt:, kind: nil, workspace_id: nil, pane: nil, cwd: nil)
|
|
44
|
+
kind ||= @default_agent_kind
|
|
45
|
+
tab_created = false
|
|
46
|
+
|
|
47
|
+
workspace_id = resolve_workspace(workspace_id)
|
|
48
|
+
validate_token!(workspace_id, "workspace")
|
|
49
|
+
unless pane
|
|
50
|
+
pane = create_tab(workspace_id, label, cwd)
|
|
51
|
+
tab_created = true
|
|
52
|
+
end
|
|
53
|
+
validate_token!(pane, "pane")
|
|
54
|
+
|
|
55
|
+
@executor.pane_run(
|
|
56
|
+
pane,
|
|
57
|
+
"export HERDR_SESSION=#{Shellwords.escape(workspace_id)} " \
|
|
58
|
+
"HERDR_PANE=#{Shellwords.escape(pane)}"
|
|
59
|
+
)
|
|
60
|
+
@executor.agent_start(
|
|
61
|
+
name: label, kind: kind, pane: pane, timeout_ms: @agent_start_timeout_ms
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
prompted = false
|
|
65
|
+
unless prompt.empty?
|
|
66
|
+
@executor.agent_prompt(pane: pane, text: prompt)
|
|
67
|
+
prompted = true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
Models::DispatchOutcome.new(
|
|
71
|
+
workspace_id: workspace_id, pane: pane, agent_name: label,
|
|
72
|
+
kind: kind, tab_created: tab_created, prompted: prompted
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Caller's workspace wins by default: explicit flag, then the herdr
|
|
79
|
+
# environment of the calling pane, then the pane current projection
|
|
80
|
+
def resolve_workspace(workspace_id)
|
|
81
|
+
return workspace_id if workspace_id
|
|
82
|
+
|
|
83
|
+
env = ENV["HERDR_WORKSPACE_ID"].to_s
|
|
84
|
+
return env unless env.empty?
|
|
85
|
+
|
|
86
|
+
json = @executor.pane_current.parsed_json
|
|
87
|
+
from_json(json, WORKSPACE_KEYS) ||
|
|
88
|
+
raise(TargetResolutionError,
|
|
89
|
+
"cannot resolve the caller's herdr workspace " \
|
|
90
|
+
"(pass --workspace, run inside a herdr pane, or start herdr)")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def create_tab(workspace_id, label, cwd)
|
|
94
|
+
result = @executor.tab_create(workspace_id: workspace_id, label: label, cwd: cwd)
|
|
95
|
+
from_json(result.parsed_json, PANE_KEYS) ||
|
|
96
|
+
raise(TargetResolutionError,
|
|
97
|
+
"could not read the new pane id from herdr tab create output " \
|
|
98
|
+
"(pass --pane with an existing pane instead)")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Values reach a pane shell via the export line; only tokens may pass
|
|
102
|
+
def validate_token!(value, what)
|
|
103
|
+
return value if value.is_a?(String) && value.match?(Ace::Hitl::Providers::Ref::TOKEN_PATTERN)
|
|
104
|
+
|
|
105
|
+
raise ValidationError,
|
|
106
|
+
"#{what} contains invalid characters " \
|
|
107
|
+
"(allowed: letters, digits, '.', '_', ':', '-')"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def from_json(json, keys)
|
|
111
|
+
case json
|
|
112
|
+
when Hash
|
|
113
|
+
keys.each { |k| return json[k] if json[k].is_a?(String) && !json[k].empty? }
|
|
114
|
+
json.values.each do |value|
|
|
115
|
+
found = from_json(value, keys)
|
|
116
|
+
return found if found
|
|
117
|
+
end
|
|
118
|
+
nil
|
|
119
|
+
when Array
|
|
120
|
+
json.each do |value|
|
|
121
|
+
found = from_json(value, keys)
|
|
122
|
+
return found if found
|
|
123
|
+
end
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Herdr
|
|
7
|
+
module Organisms
|
|
8
|
+
# Owner of tidy behavior (spec 8wq.t.1w0): dry-run-first cleanup of
|
|
9
|
+
# finished agent panes and old delivered delivery records. Safety rule:
|
|
10
|
+
# only positive evidence closes — a pane is closed solely on observed
|
|
11
|
+
# `done` agent state or a dead pane process, confirmed by a fresh probe
|
|
12
|
+
# immediately before the rename->close mutation (a candidate that
|
|
13
|
+
# revived in between is excluded, never mutated). Delivered records
|
|
14
|
+
# older than the retention threshold are archived only when a reload
|
|
15
|
+
# under the per-event lock still proves them eligible; failed,
|
|
16
|
+
# retryable, pending, and unreadable records are never touched.
|
|
17
|
+
class Tidy
|
|
18
|
+
DEFAULT_RETENTION_DAYS = 7
|
|
19
|
+
CLOSE_LABEL = "done"
|
|
20
|
+
DAY_SECONDS = 86_400
|
|
21
|
+
ELIGIBLE_EVIDENCE = %i[agent_done process_exited].freeze
|
|
22
|
+
|
|
23
|
+
attr_reader :retention_days
|
|
24
|
+
|
|
25
|
+
# @param executor [Molecules::HerdrExecutor] the exclusive native seam
|
|
26
|
+
# @param record_store [Molecules::DeliveryRecordStore] record access
|
|
27
|
+
# @param deliveries_dir [String] delivery record directory
|
|
28
|
+
# @param retention_days [Integer] archive delivered records strictly
|
|
29
|
+
# older than this many days (age = record updated_at)
|
|
30
|
+
# @param clock [#now] time source for the retention cutoff
|
|
31
|
+
def initialize(executor:, record_store: Molecules::DeliveryRecordStore,
|
|
32
|
+
deliveries_dir:, retention_days: DEFAULT_RETENTION_DAYS, clock: Time)
|
|
33
|
+
@executor = executor
|
|
34
|
+
@record_store = record_store
|
|
35
|
+
@deliveries_dir = deliveries_dir
|
|
36
|
+
@retention_days =
|
|
37
|
+
begin
|
|
38
|
+
Integer(retention_days)
|
|
39
|
+
rescue ArgumentError, TypeError
|
|
40
|
+
raise ValidationError, "tidy.delivered_retention_days must be a non-negative integer"
|
|
41
|
+
end
|
|
42
|
+
raise ValidationError, "tidy.delivered_retention_days must be a non-negative integer" if @retention_days.negative?
|
|
43
|
+
|
|
44
|
+
@clock = clock
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Classify cleanup candidates (and everything preserved) and, with
|
|
48
|
+
# apply:, perform the mutations. Returns the deterministic report
|
|
49
|
+
# hash the CLI renders as one JSON line.
|
|
50
|
+
def run(apply: false)
|
|
51
|
+
report = {
|
|
52
|
+
apply: apply,
|
|
53
|
+
retention_days: @retention_days,
|
|
54
|
+
panes: {candidates: [], preserved: [], closed: [], excluded: []},
|
|
55
|
+
deliveries: {candidates: [], protected: [], archived: [], preserved: []}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# Discovery probes everything before any mutation; a probe failure
|
|
59
|
+
# here (unavailable runtime) aborts with no partial report.
|
|
60
|
+
classify_panes!(report)
|
|
61
|
+
classify_deliveries!(report)
|
|
62
|
+
close_panes!(report) if apply
|
|
63
|
+
archive_deliveries!(report) if apply
|
|
64
|
+
report
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# --- discovery ---------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def classify_panes!(report)
|
|
72
|
+
pane_ids = pane_rows.map { |row| row["pane_id"] }
|
|
73
|
+
|
|
74
|
+
pane_ids.each do |pane_id|
|
|
75
|
+
evidence = Molecules::PaneTidyProbe.pane_evidence(@executor, pane_id)
|
|
76
|
+
if ELIGIBLE_EVIDENCE.include?(evidence)
|
|
77
|
+
report[:panes][:candidates] << {id: pane_id, evidence: evidence.to_s}
|
|
78
|
+
else
|
|
79
|
+
report[:panes][:preserved] << {id: pane_id, reason: evidence.to_s}
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
report[:panes][:candidates].sort_by! { |entry| entry[:id].to_s }
|
|
84
|
+
report[:panes][:preserved].sort_by! { |entry| entry[:id].to_s }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def classify_deliveries!(report)
|
|
88
|
+
@record_store.list_records(@deliveries_dir).each do |entry|
|
|
89
|
+
event_id = entry[:event_id]
|
|
90
|
+
record = entry[:record]
|
|
91
|
+
if record.nil?
|
|
92
|
+
report[:deliveries][:preserved] << {event_id: event_id, reason: "unreadable"}
|
|
93
|
+
elsif record.state != "delivered"
|
|
94
|
+
report[:deliveries][:protected] << {event_id: event_id, state: record.state}
|
|
95
|
+
elsif (updated_at = parse_updated_at(record))
|
|
96
|
+
entry = {event_id: event_id, updated_at: record.updated_at}
|
|
97
|
+
if updated_at < cutoff
|
|
98
|
+
report[:deliveries][:candidates] << entry
|
|
99
|
+
else
|
|
100
|
+
report[:deliveries][:protected] << {event_id: event_id, state: record.state}
|
|
101
|
+
end
|
|
102
|
+
else
|
|
103
|
+
report[:deliveries][:preserved] << {event_id: event_id, reason: "invalid_updated_at"}
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
sort_delivery_entries!(report)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# --- apply ---------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
# Re-probe every candidate immediately before mutating: close only on
|
|
113
|
+
# still-positive evidence, rename -> close (vs0 close semantics)
|
|
114
|
+
def close_panes!(report)
|
|
115
|
+
report[:panes][:candidates].each do |candidate|
|
|
116
|
+
pane_id = candidate[:id]
|
|
117
|
+
evidence = Molecules::PaneTidyProbe.pane_evidence(@executor, pane_id)
|
|
118
|
+
unless ELIGIBLE_EVIDENCE.include?(evidence)
|
|
119
|
+
report[:panes][:excluded] << {id: pane_id, reason: evidence.to_s}
|
|
120
|
+
next
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
@executor.pane_rename(pane_id, CLOSE_LABEL)
|
|
124
|
+
@executor.pane_close(pane_id)
|
|
125
|
+
report[:panes][:closed] << {id: pane_id}
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# For each candidate: reload, re-check eligibility, and archive —
|
|
130
|
+
# all inside the per-event lock, so a record changed by another
|
|
131
|
+
# writer is re-evaluated, never archived on a stale snapshot. A
|
|
132
|
+
# vanished or unreadable record stays in place and is reported.
|
|
133
|
+
def archive_deliveries!(report)
|
|
134
|
+
report[:deliveries][:candidates].each do |candidate|
|
|
135
|
+
event_id = candidate[:event_id]
|
|
136
|
+
outcome = nil
|
|
137
|
+
@record_store.with_lock(@deliveries_dir, event_id) do
|
|
138
|
+
fresh = @record_store.load_revalidated(@deliveries_dir, event_id)
|
|
139
|
+
if fresh == :unreadable
|
|
140
|
+
outcome = {reason: "unreadable"}
|
|
141
|
+
elsif fresh&.delivered? && older_than_retention?(fresh)
|
|
142
|
+
outcome = {archive_path: @record_store.archive(@deliveries_dir, event_id)}
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
if outcome&.key?(:archive_path)
|
|
146
|
+
report[:deliveries][:archived] << {event_id: event_id, archive_path: outcome[:archive_path]}
|
|
147
|
+
else
|
|
148
|
+
reason = outcome && outcome[:reason]
|
|
149
|
+
report[:deliveries][:preserved] << {event_id: event_id, reason: reason || "changed"}
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# --- shared helpers ------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
# Native list response nests rows under result.panes; anything else
|
|
157
|
+
# is an explicit empty state, never an error
|
|
158
|
+
def pane_rows
|
|
159
|
+
parsed = @executor.pane_list.parsed_json
|
|
160
|
+
rows = parsed.is_a?(Hash) && parsed["result"].is_a?(Hash) ? parsed["result"]["panes"] : nil
|
|
161
|
+
rows.is_a?(Array) ? rows : []
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def cutoff
|
|
165
|
+
@clock.now - @retention_days * DAY_SECONDS
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def older_than_retention?(record)
|
|
169
|
+
updated_at = parse_updated_at(record)
|
|
170
|
+
updated_at && updated_at < cutoff
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# RFC 3339 / ISO 8601 only; anything else fails closed (nil)
|
|
174
|
+
def parse_updated_at(record)
|
|
175
|
+
Time.iso8601(record.updated_at)
|
|
176
|
+
rescue ArgumentError, TypeError
|
|
177
|
+
nil
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def sort_delivery_entries!(report)
|
|
181
|
+
%i[candidates protected archived preserved].each do |bucket|
|
|
182
|
+
report[:deliveries][bucket].sort_by! { |entry| entry[:event_id].to_s }
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|