ace-herdr 0.1.0 → 0.2.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 +7 -0
- data/README.md +1 -0
- data/docs/usage.md +12 -2
- data/lib/ace/herdr/atoms/json_find.rb +46 -0
- data/lib/ace/herdr/errors.rb +3 -0
- data/lib/ace/herdr/molecules/herdr_executor.rb +37 -3
- data/lib/ace/herdr/organisms/control_surface.rb +20 -32
- data/lib/ace/herdr/organisms/runtime_adapter.rb +555 -0
- data/lib/ace/herdr/version.rb +1 -1
- data/lib/ace/herdr.rb +14 -0
- data/lib/ace/runtime/adapters/herdr.rb +6 -0
- metadata +19 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7bf428cc5ca891abf1fc4214e10301ab30e50ca66b2cc122f14512152437b0e8
|
|
4
|
+
data.tar.gz: f43847a527924ee7f2b2ba7266f47c18f741a04e012aef255cebfb121c05ad1d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 83223ba775d983ce92b32031a0e13d99d33cf9c95f2e8067816933051f0dfca77388c65241115e67eaf2d101d2c648cc72af134355a582b8b4e523bf4e2799c9
|
|
7
|
+
data.tar.gz: 5f8dc827d8cad595d2821442386247c764ac7913c6d8371f19621a7f633db824a271a0791f8556bc9359da821f3fcd8cf6358eb12769befdd4ecbc33d4a25103
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,13 @@ All notable changes to `ace-herdr` will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.2.0] - 2026-10-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `Ace::Herdr::Organisms::RuntimeAdapter` and the lazy `ace/runtime/adapters/herdr` entrypoint implement the shared ace-runtime intent contract. It preserves native agent prompt and wait semantics, polls all four lifecycle observations, and maps Herdr failures to contract errors. Native tab and pane probes support context, focus, and retained pane preparation. The packaged shared adapter contract suite runs against a Herdr-shaped fake executor.
|
|
14
|
+
|
|
8
15
|
## [0.1.0] - 2026-09-27
|
|
9
16
|
|
|
10
17
|
### Added
|
data/README.md
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
- Dispatch a subagent in one command with deterministic defaults and no LLM decisions.
|
|
33
33
|
- Wait for agent readiness or completion without scraping pane noise.
|
|
34
34
|
- Close out finished agent panes (rename/close) and return their result.
|
|
35
|
+
- Use Herdr through the shared `ace-runtime` Ruby intent API, with workspace/session and tab/window mapping handled by the adapter.
|
|
35
36
|
|
|
36
37
|
## Installation
|
|
37
38
|
|
data/docs/usage.md
CHANGED
|
@@ -3,8 +3,8 @@ doc-type: user
|
|
|
3
3
|
title: ace-herdr Usage
|
|
4
4
|
purpose: Full CLI and configuration reference for ace-herdr: push delivery, agent bootstrap, the terminal-control surface (list, send, capture, wait, presets), and tidy cleanup.
|
|
5
5
|
ace-docs:
|
|
6
|
-
last-updated: 2026-
|
|
7
|
-
last-checked: 2026-
|
|
6
|
+
last-updated: 2026-10-02
|
|
7
|
+
last-checked: 2026-10-02
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# Usage
|
|
@@ -31,6 +31,16 @@ Control commands print exactly one deterministic JSON line on stdout; `--quiet`
|
|
|
31
31
|
|
|
32
32
|
herdr *sessions* (server persistence) are intentionally not exposed; the tmux session analogue is the herdr **workspace**.
|
|
33
33
|
|
|
34
|
+
## ace-runtime adapter
|
|
35
|
+
|
|
36
|
+
Install `ace-herdr` alongside `ace-runtime`, then resolve the adapter with `Ace::Runtime.resolve("herdr")`. The contract's `session` is a Herdr **workspace**, `window` is a **tab**, and `pane` is an opaque Herdr pane ID. The adapter does not construct pane IDs. It locates the caller through `HERDR_SESSION` and `HERDR_PANE` and reads the explicit pane with `pane get`; the pane's native workspace is authoritative and a conflicting `HERDR_WORKSPACE_ID` hint is rejected instead of honored. Outside Herdr, `context` reports `in_runtime: false`; operations requiring a caller workspace raise `Ace::Runtime::RuntimeUnavailableError`.
|
|
37
|
+
|
|
38
|
+
The adapter implements `context`, `ensure_window`, `prepare_pane`, `focus`, ordered `send` and its convenience methods, `capture`, `wait_output`, `wait_agent`, `wait_lifecycle`, `close_window`, `list_windows`, and `list_panes`. `ensure_window` scopes the sanitized tab label to the caller workspace. It records the tab's root, preset, and prepared pane under `~/.ace/local/herdr/runtime-tabs/` — keyed by workspace and label, so adapter instances from any working directory or requested root share one record, create through the same lock, and serialize prepared-pane creation through it. Later instances verify an idempotent request and reject a conflicting one; a tab whose ownership cannot be proven is never closed or replaced (a failed create rolls back only tabs the same attempt created). `prepare_pane` splits a retained shell target and returns the native pane ID after verifying it exists and has a shell process.
|
|
39
|
+
|
|
40
|
+
Sends probe for a live agent. Plain panes receive raw text and keys in order; agent panes receive one self-submitting `agent prompt`, with one trailing Enter dropped and reported. Agent waits use native `agent wait` states (`idle`, `working`, `blocked`, `done`). Contract timeout values are seconds and are converted to Herdr milliseconds. Lifecycle waits observe `tab get`, focused tab state, `pane get`, and `pane process-info`; a missing pane keeps `pane-exists` waiting but immediately satisfies `pane-exited`. A pane with only its retained shell also satisfies `pane-exited` because no submitted foreground command remains. This observation does not prove assignment success or authorize cleanup.
|
|
41
|
+
|
|
42
|
+
Native `agent_blocked` becomes `SendRejectedError`; `agent_prompt_stalled` becomes `SendStalledError` and must not be automatically resent. Native wait timeouts become `WaitTimeoutError`, missing targets become `TargetNotFoundError`, and an unavailable binary or socket becomes `RuntimeUnavailableError`.
|
|
43
|
+
|
|
34
44
|
## tmux-intent ↔ herdr-command parity
|
|
35
45
|
|
|
36
46
|
Every common terminal-control intent available through `ace-tmux` is available through `ace-herdr` (herdr 0.9.1). Parity is of INTENT and FLAG VOCABULARY, not byte-format: ace-herdr keeps one-line JSON where ace-tmux renders human tables.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Herdr
|
|
5
|
+
module Atoms
|
|
6
|
+
# Shared recursive extraction from herdr's native JSON responses
|
|
7
|
+
# (ids nest under result.*; native shapes vary slightly between
|
|
8
|
+
# commands). Pure functions — no side effects.
|
|
9
|
+
module JsonFind
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Walk a path of hash keys; nil unless every hop is a Hash. A hop
|
|
13
|
+
# resolves only to a non-empty String.
|
|
14
|
+
def dig_value(json, path)
|
|
15
|
+
current = json
|
|
16
|
+
path.each do |key|
|
|
17
|
+
return nil unless current.is_a?(Hash)
|
|
18
|
+
|
|
19
|
+
current = current[key]
|
|
20
|
+
end
|
|
21
|
+
current.is_a?(String) && !current.empty? ? current : nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Depth-first search for the first non-empty String under any of
|
|
25
|
+
# the given keys (tolerant extraction of ids from native shapes).
|
|
26
|
+
def find_value(json, keys)
|
|
27
|
+
case json
|
|
28
|
+
when Hash
|
|
29
|
+
keys.each { |key| return json[key] if json[key].is_a?(String) && !json[key].empty? }
|
|
30
|
+
json.each_value do |value|
|
|
31
|
+
found = find_value(value, keys)
|
|
32
|
+
return found if found
|
|
33
|
+
end
|
|
34
|
+
nil
|
|
35
|
+
when Array
|
|
36
|
+
json.each do |value|
|
|
37
|
+
found = find_value(value, keys)
|
|
38
|
+
return found if found
|
|
39
|
+
end
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/ace/herdr/errors.rb
CHANGED
|
@@ -22,6 +22,9 @@ module Ace
|
|
|
22
22
|
end
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# Native timeout is distinct from a prompt accepted but not observed.
|
|
26
|
+
class ExecutorTimeoutError < AgentNotReadyError; end
|
|
27
|
+
|
|
25
28
|
# Target pane does not exist (pane_not_found)
|
|
26
29
|
class PaneNotFoundError < ExecutorError; end
|
|
27
30
|
|
|
@@ -60,6 +60,10 @@ module Ace
|
|
|
60
60
|
run!([@binary, "pane", "current", "--current"])
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
def pane_get(pane)
|
|
64
|
+
run!([@binary, "pane", "get", pane])
|
|
65
|
+
end
|
|
66
|
+
|
|
63
67
|
# Inspect a pane's processes (positive proof of process exit for tidy)
|
|
64
68
|
def pane_process_info(pane)
|
|
65
69
|
run!([@binary, "pane", "process-info", "--pane", pane])
|
|
@@ -73,6 +77,18 @@ module Ace
|
|
|
73
77
|
run!(cmd)
|
|
74
78
|
end
|
|
75
79
|
|
|
80
|
+
def tab_get(tab)
|
|
81
|
+
run!([@binary, "tab", "get", tab])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def tab_focus(tab)
|
|
85
|
+
run!([@binary, "tab", "focus", tab])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def api_snapshot
|
|
89
|
+
run!([@binary, "api", "snapshot"])
|
|
90
|
+
end
|
|
91
|
+
|
|
76
92
|
# --- terminal-control surface (spec 8wq.t.k84) -----------------------
|
|
77
93
|
|
|
78
94
|
# List workspaces (tmux sessions analogue)
|
|
@@ -200,6 +216,25 @@ module Ace
|
|
|
200
216
|
# code is kept in the message so CLI failures carry it.
|
|
201
217
|
def classify(result, cmd)
|
|
202
218
|
code, message = error_code(result)
|
|
219
|
+
if code
|
|
220
|
+
return classify_code(code, message, result: result, cmd: cmd)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# No structured code: first nonempty of stderr/stdout — herdr
|
|
224
|
+
# writes some failures (socket errors) to stdout with an empty
|
|
225
|
+
# stderr, and empty strings are truthy in Ruby, so `||` chaining
|
|
226
|
+
# would hide the detail.
|
|
227
|
+
detail = [result.stderr, result.stdout].reject { |value| value.to_s.empty? }.first
|
|
228
|
+
if detail.to_s.match?(/no herdr server is running|cannot connect|connection refused|socket.*unavailable/i)
|
|
229
|
+
return ExecutorUnavailableError.new(detail)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
CommandError.new(
|
|
233
|
+
"herdr command failed (exit #{result.exit_code}): #{cmd.join(" ")} #{detail}".strip
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def classify_code(code, message, result:, cmd:)
|
|
203
238
|
case code
|
|
204
239
|
when "agent_blocked" then AgentBlockedError.new(tag_code(code, message))
|
|
205
240
|
when "agent_prompt_stalled" then AgentNotReadyError.new(tag_code(code, message))
|
|
@@ -207,11 +242,10 @@ module Ace
|
|
|
207
242
|
when "agent_not_found" then AgentNotFoundError.new(tag_code(code, message))
|
|
208
243
|
when "tab_not_found" then TabNotFoundError.new(tag_code(code, message))
|
|
209
244
|
when "workspace_not_found" then WorkspaceNotFoundError.new(tag_code(code, message))
|
|
210
|
-
when "timeout" then
|
|
245
|
+
when "timeout" then ExecutorTimeoutError.new(tag_code(code, message))
|
|
211
246
|
else
|
|
212
247
|
CommandError.new(
|
|
213
|
-
"herdr command failed (exit #{result.exit_code}): #{cmd.join(" ")} "
|
|
214
|
-
"#{message || result.stderr}".strip
|
|
248
|
+
"herdr command failed (exit #{result.exit_code}): #{cmd.join(" ")}: #{tag_code(code, message)}"
|
|
215
249
|
)
|
|
216
250
|
end
|
|
217
251
|
end
|
|
@@ -146,9 +146,10 @@ module Ace
|
|
|
146
146
|
# Create a tab from a preset (tmux `window` analogue) in the given
|
|
147
147
|
# or resolved workspace; --cwd overrides the resolved tab cwd. The
|
|
148
148
|
# layout is validated before the tab is created.
|
|
149
|
-
def create_tab(preset_name, workspace_id: nil, cwd: nil)
|
|
149
|
+
def create_tab(preset_name, workspace_id: nil, cwd: nil, label: nil)
|
|
150
150
|
workspace_id = resolve_workspace_id(workspace_id)
|
|
151
151
|
resolved = resolve_tab_preset(preset_name)
|
|
152
|
+
resolved = resolved.merge("label" => label) if label
|
|
152
153
|
preflight_tab!(resolved)
|
|
153
154
|
instantiate_tab(
|
|
154
155
|
resolved, workspace_id: workspace_id,
|
|
@@ -248,13 +249,20 @@ module Ace
|
|
|
248
249
|
tab_id = dig_value(tab_json, %w[result tab tab_id]) ||
|
|
249
250
|
raise(TargetResolutionError, "could not read the new tab id from herdr tab create output")
|
|
250
251
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
252
|
+
begin
|
|
253
|
+
pane_specs = Array(tab_spec["panes"])
|
|
254
|
+
root_pane_id = dig_value(tab_json, %w[result root_pane pane_id]) ||
|
|
255
|
+
raise(TargetResolutionError, "could not read the root pane id from herdr tab create output")
|
|
256
|
+
placed = place_panes(tab_spec, pane_specs, tab_cwd, root_pane_id)
|
|
257
|
+
|
|
258
|
+
commands = run_pane_commands(placed)
|
|
259
|
+
agents = start_pane_agents(placed, workspace_id: workspace_id)
|
|
260
|
+
rescue StandardError => e
|
|
261
|
+
# Surface the created tab id and the original error class so
|
|
262
|
+
# the owner can roll back exactly this tab and keep
|
|
263
|
+
# mapping-relevant semantics (e.g. AgentBlockedError).
|
|
264
|
+
raise TabMaterializationError.new(tab_id: tab_id, message: "#{e.class}: #{e.message}")
|
|
265
|
+
end
|
|
258
266
|
|
|
259
267
|
{
|
|
260
268
|
tab: tab_id,
|
|
@@ -386,33 +394,13 @@ module Ace
|
|
|
386
394
|
end
|
|
387
395
|
|
|
388
396
|
def dig_value(json, path)
|
|
389
|
-
|
|
390
|
-
path.each do |key|
|
|
391
|
-
return nil unless current.is_a?(Hash)
|
|
392
|
-
|
|
393
|
-
current = current[key]
|
|
394
|
-
end
|
|
395
|
-
current.is_a?(String) && !current.empty? ? current : nil
|
|
397
|
+
Atoms::JsonFind.dig_value(json, path)
|
|
396
398
|
end
|
|
397
399
|
|
|
398
|
-
#
|
|
399
|
-
#
|
|
400
|
+
# Tolerant extraction of ids from native responses (native shapes
|
|
401
|
+
# nest under result.*); shared atom, same semantics as Dispatcher.
|
|
400
402
|
def find_value(json, keys)
|
|
401
|
-
|
|
402
|
-
when Hash
|
|
403
|
-
keys.each { |key| return json[key] if json[key].is_a?(String) && !json[key].empty? }
|
|
404
|
-
json.each_value do |value|
|
|
405
|
-
found = find_value(value, keys)
|
|
406
|
-
return found if found
|
|
407
|
-
end
|
|
408
|
-
nil
|
|
409
|
-
when Array
|
|
410
|
-
json.each do |value|
|
|
411
|
-
found = find_value(value, keys)
|
|
412
|
-
return found if found
|
|
413
|
-
end
|
|
414
|
-
nil
|
|
415
|
-
end
|
|
403
|
+
Atoms::JsonFind.find_value(json, keys)
|
|
416
404
|
end
|
|
417
405
|
|
|
418
406
|
# --- send: validation -------------------------------------------------
|
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/runtime"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Herdr
|
|
10
|
+
module Organisms
|
|
11
|
+
# Maps ace-runtime's terminal intents onto herdr's workspace/tab/pane API.
|
|
12
|
+
# Contract session means workspace; contract window means tab.
|
|
13
|
+
class RuntimeAdapter
|
|
14
|
+
POLL_INTERVAL = 0.02
|
|
15
|
+
|
|
16
|
+
def initialize(executor: Molecules::HerdrExecutor.new, env: ENV, clock: Process, sleeper: Kernel, surface: nil,
|
|
17
|
+
identity_dir: nil, poll_interval: POLL_INTERVAL)
|
|
18
|
+
@executor = executor
|
|
19
|
+
@env = env
|
|
20
|
+
@clock = clock
|
|
21
|
+
@sleeper = sleeper
|
|
22
|
+
@surface = surface || ControlSurface.new(executor: executor)
|
|
23
|
+
@prepared = {}
|
|
24
|
+
@identity_dir = identity_dir
|
|
25
|
+
@poll_interval = poll_interval
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def context
|
|
29
|
+
return {in_runtime: false, session: nil, window: nil, pane: nil} unless inside?
|
|
30
|
+
|
|
31
|
+
with_errors do
|
|
32
|
+
current = @executor.pane_get(@env["HERDR_PANE"]).parsed_json
|
|
33
|
+
native_workspace = find_value(current, %w[workspace_id workspaceId])
|
|
34
|
+
hint = @env["HERDR_WORKSPACE_ID"]
|
|
35
|
+
if hint && native_workspace && hint != native_workspace
|
|
36
|
+
raise Runtime::RuntimeUnavailableError,
|
|
37
|
+
"HERDR_WORKSPACE_ID '#{hint}' does not match the caller pane's workspace " \
|
|
38
|
+
"'#{native_workspace}'; clear the hint or reconnect the pane"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
in_runtime: true,
|
|
43
|
+
session: native_workspace || hint,
|
|
44
|
+
window: find_value(current, %w[tab_id tabId]),
|
|
45
|
+
pane: @env["HERDR_PANE"] || find_value(current, %w[pane_id paneId])
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def ensure_window(name:, root:, preset: nil)
|
|
51
|
+
with_errors do
|
|
52
|
+
workspace = workspace_id!
|
|
53
|
+
label = Runtime.sanitize_name(name)
|
|
54
|
+
with_identity_lock(workspace, label) do
|
|
55
|
+
path = identity_path(workspace, label)
|
|
56
|
+
existing = tabs(workspace).find { |tab| tab[:name] == label }
|
|
57
|
+
if existing
|
|
58
|
+
# Ownership must be proven by the record (or, without one,
|
|
59
|
+
# by the pane's native root for a preset-free request); a
|
|
60
|
+
# tab we cannot verify is never closed or replaced.
|
|
61
|
+
unless verifiable_identity?(existing, root: root, preset: preset, path: path)
|
|
62
|
+
raise Runtime::WindowConflictError,
|
|
63
|
+
"window '#{existing[:name]}' conflicts with root or preset"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Adopting a record-less tab: persist its provenance (and
|
|
67
|
+
# keep any prepared-pane pointer) so later instances share it.
|
|
68
|
+
if read_identity(path, existing).nil?
|
|
69
|
+
write_identity(path, id: existing[:id], root: canonical_root(root), preset: preset,
|
|
70
|
+
prepared_pane: pointer_prepared_pane(path, existing[:id]))
|
|
71
|
+
end
|
|
72
|
+
next existing[:id]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
created_id =
|
|
76
|
+
if preset
|
|
77
|
+
begin
|
|
78
|
+
created = @surface.create_tab(preset, workspace_id: workspace, cwd: File.expand_path(root), label: label)
|
|
79
|
+
rescue TabMaterializationError => e
|
|
80
|
+
# Roll back the tab this call created — exact id from
|
|
81
|
+
# the surface, never a listing guess. Best effort: a
|
|
82
|
+
# rollback failure never replaces the primary error.
|
|
83
|
+
begin
|
|
84
|
+
@executor.tab_close(e.tab_id)
|
|
85
|
+
rescue StandardError => close_error
|
|
86
|
+
warn "ace-herdr: rollback close failed for tab #{e.tab_id}: #{close_error.class}: #{close_error.message}"
|
|
87
|
+
end
|
|
88
|
+
raise
|
|
89
|
+
end
|
|
90
|
+
created.fetch(:tab)
|
|
91
|
+
else
|
|
92
|
+
parsed = @executor.tab_create(workspace_id: workspace, label: label, cwd: File.expand_path(root)).parsed_json
|
|
93
|
+
find_value(parsed, %w[tab tab_id])
|
|
94
|
+
end
|
|
95
|
+
raise TargetResolutionError, "tab create returned no tab id" if created_id.nil?
|
|
96
|
+
|
|
97
|
+
begin
|
|
98
|
+
write_identity(path, id: created_id, root: canonical_root(root), preset: preset)
|
|
99
|
+
rescue StandardError
|
|
100
|
+
begin
|
|
101
|
+
@executor.tab_close(created_id)
|
|
102
|
+
rescue StandardError => close_error
|
|
103
|
+
warn "ace-herdr: rollback close failed for tab #{created_id}: #{close_error.class}: #{close_error.message}"
|
|
104
|
+
end
|
|
105
|
+
raise
|
|
106
|
+
end
|
|
107
|
+
created_id
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def prepare_pane(window:)
|
|
113
|
+
with_errors do
|
|
114
|
+
tab = resolve_tab!(window)
|
|
115
|
+
# Hold the identity lock through discovery, split, verification,
|
|
116
|
+
# and recording so concurrent instances cannot split twice.
|
|
117
|
+
with_identity_lock(tab[:workspace], tab[:name]) do
|
|
118
|
+
cached = discover_prepared_pane(tab)
|
|
119
|
+
next cached if cached
|
|
120
|
+
|
|
121
|
+
# Split a shell pane: prefer the first pane without a live
|
|
122
|
+
# agent — pane list ordering does not contractually promise
|
|
123
|
+
# a shell root pane in foreign multi-pane tabs. Single-pane
|
|
124
|
+
# tabs skip the probe entirely.
|
|
125
|
+
tab_panes = panes(tab[:workspace]).select { |pane| pane[:tab] == tab[:id] }
|
|
126
|
+
root_pane = tab_panes.first
|
|
127
|
+
if tab_panes.length > 1
|
|
128
|
+
root_pane = tab_panes.find { |pane| !agent_pane?(pane[:pane]) } || tab_panes.first
|
|
129
|
+
end
|
|
130
|
+
raise Runtime::TargetNotFoundError, "tab '#{window}' has no pane" unless root_pane
|
|
131
|
+
|
|
132
|
+
parsed = @executor.pane_split(pane: root_pane[:pane], direction: "right").parsed_json
|
|
133
|
+
id = find_value(parsed, %w[pane pane_id])
|
|
134
|
+
raise TargetResolutionError, "pane split returned no pane id" if id.nil?
|
|
135
|
+
|
|
136
|
+
verify_retained_shell!(id)
|
|
137
|
+
record_prepared_pane(tab, id)
|
|
138
|
+
id
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def focus(window:)
|
|
144
|
+
with_errors { @executor.tab_focus(resolve_tab!(window)[:id]); true }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def send_profile
|
|
148
|
+
:agent_aware
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def send(pane:, command: nil, items: [])
|
|
152
|
+
# Reject malformed shared shapes before any delivery or mutation;
|
|
153
|
+
# the agent probe below is a native read, never a write.
|
|
154
|
+
Runtime::Atoms::SendContract.normalize!(command: command, items: items, profile: :plain_pane)
|
|
155
|
+
with_errors do
|
|
156
|
+
agent = agent_pane?(pane)
|
|
157
|
+
request = Runtime::Atoms::SendContract.normalize!(
|
|
158
|
+
command: command, items: items, profile: agent ? :agent_aware : :plain_pane
|
|
159
|
+
)
|
|
160
|
+
if agent
|
|
161
|
+
request.items.each do |item|
|
|
162
|
+
item.key?(:message) ? @executor.agent_prompt(pane: pane, text: item[:message]) :
|
|
163
|
+
@executor.agent_send_keys(pane, [item[:key]])
|
|
164
|
+
end
|
|
165
|
+
else
|
|
166
|
+
@executor.pane_run(pane, request.command) if request.command
|
|
167
|
+
request.items.each do |item|
|
|
168
|
+
item.key?(:message) ? @executor.pane_send_text(pane, item[:message]) :
|
|
169
|
+
@executor.pane_send_keys(pane, [item[:key]])
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
Runtime::Atoms::SendContract::Result.new(dropped_trailing_enter: request.dropped_trailing_enter)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def send_command(pane:, command:)
|
|
177
|
+
send(pane: pane, command: command)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def send_text(pane:, text:)
|
|
181
|
+
send(pane: pane, items: [{message: text}])
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def send_keys(pane:, keys:)
|
|
185
|
+
send(pane: pane, items: Array(keys).map { |key| {key: key} })
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def capture(pane:, lines: 40)
|
|
189
|
+
with_errors { @surface.capture(pane: pane, lines: lines) }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def wait_output(pane:, pattern:, timeout:)
|
|
193
|
+
with_errors(condition: "output", target: pane, timeout: timeout) do
|
|
194
|
+
@surface.wait_output(pane: pane, pattern: pattern, timeout_ms: milliseconds(timeout))
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def wait_agent(pane:, states:, timeout:)
|
|
199
|
+
with_errors(condition: "agent", target: pane, timeout: timeout) do
|
|
200
|
+
@executor.agent_wait(pane: pane, until_states: states, timeout_ms: milliseconds(timeout))
|
|
201
|
+
true
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def wait_lifecycle(condition:, target:, timeout:)
|
|
206
|
+
unless Runtime::Atoms::SendContract::LIFECYCLE_CONDITIONS.include?(condition)
|
|
207
|
+
raise ArgumentError, "unknown lifecycle condition '#{condition}'"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Window conditions resolve the caller workspace once per wait so
|
|
211
|
+
# poll iterations do not re-run context and tab-list subprocesses;
|
|
212
|
+
# pane conditions never need it.
|
|
213
|
+
scope = nil
|
|
214
|
+
deadline = @clock.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
215
|
+
with_errors(condition: condition, target: target, timeout: timeout) do
|
|
216
|
+
loop do
|
|
217
|
+
if condition.start_with?("window-") && scope.nil?
|
|
218
|
+
scope = workspace_id!
|
|
219
|
+
end
|
|
220
|
+
return true if lifecycle_met?(condition, target, workspace: scope)
|
|
221
|
+
raise Runtime::WaitTimeoutError.new(condition: condition, target: target, timeout: timeout) if
|
|
222
|
+
@clock.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
223
|
+
|
|
224
|
+
@sleeper.sleep(@poll_interval)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def close_window(window:)
|
|
230
|
+
with_errors { @executor.tab_close(resolve_tab!(window)[:id]); true }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def list_windows
|
|
234
|
+
with_errors { tabs(workspace_id!).map { |tab| {name: tab[:name], window: tab[:id], active: tab[:active]} } }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def list_panes(window:)
|
|
238
|
+
with_errors do
|
|
239
|
+
tab = resolve_tab!(window)
|
|
240
|
+
panes(tab[:workspace]).select { |pane| pane[:tab] == tab[:id] }
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
private
|
|
245
|
+
|
|
246
|
+
def inside?
|
|
247
|
+
!@env["HERDR_SESSION"].to_s.empty? && !@env["HERDR_PANE"].to_s.empty?
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def workspace_id!
|
|
251
|
+
raise Runtime::RuntimeUnavailableError, "not inside a herdr workspace" unless inside?
|
|
252
|
+
|
|
253
|
+
context[:session] || raise(Runtime::RuntimeUnavailableError, "herdr workspace is unavailable")
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def tabs(workspace)
|
|
257
|
+
@surface.list_tabs(workspace_id: workspace).map do |row|
|
|
258
|
+
{id: row[:id], name: row[:title], workspace: row[:workspace], active: row[:focused]}
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def panes(workspace)
|
|
263
|
+
@surface.list_panes(workspace_id: workspace).map do |row|
|
|
264
|
+
{pane: row[:id], tab: row[:tab], workspace: row[:workspace]}
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def resolve_tab!(window)
|
|
269
|
+
workspace = workspace_id!
|
|
270
|
+
tab = tabs(workspace).find { |row| row[:id] == window || row[:name] == window }
|
|
271
|
+
tab || raise(Runtime::TargetNotFoundError, "tab '#{window}' not found")
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def canonical_root(root)
|
|
275
|
+
File.realpath(root)
|
|
276
|
+
rescue Errno::ENOENT
|
|
277
|
+
File.expand_path(root)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Identity records and locks are keyed on the shared (workspace,
|
|
281
|
+
# label) identity in a stable user-level location — instances from
|
|
282
|
+
# any cwd and any requested root agree on the same record, so
|
|
283
|
+
# concurrent creators of the same label serialize and reuse is
|
|
284
|
+
# verifiable across processes.
|
|
285
|
+
def identity_path(workspace, label)
|
|
286
|
+
base = @identity_dir || File.join(Dir.home, ".ace", "local", "herdr", "runtime-tabs")
|
|
287
|
+
File.join(base, "#{Digest::SHA256.hexdigest("#{workspace}\0#{label}")}.json")
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def with_identity_lock(workspace, label, &_block)
|
|
291
|
+
path = "#{identity_path(workspace, label)}.lock"
|
|
292
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
293
|
+
File.open(path, File::RDWR | File::CREAT, 0o600) do |file|
|
|
294
|
+
file.flock(File::LOCK_EX)
|
|
295
|
+
yield
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Reuse needs the recorded identity to match the request; when no
|
|
300
|
+
# readable record exists, native pane cwd is the only remaining
|
|
301
|
+
# root evidence and a preset can never be verified.
|
|
302
|
+
def verifiable_identity?(tab, root:, preset:, path:)
|
|
303
|
+
recorded = read_identity(path, tab)
|
|
304
|
+
if recorded
|
|
305
|
+
return recorded["root"] == canonical_root(root) && recorded["preset"] == preset
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
native_root = native_pane_cwd(tab)
|
|
309
|
+
native_root && canonical_root(native_root) == canonical_root(root) && preset.nil?
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Rollback after a failed create is exact-id only (see ensure_window);
|
|
313
|
+
# listings are never consulted for ownership decisions.
|
|
314
|
+
|
|
315
|
+
def read_identity(path, tab)
|
|
316
|
+
recorded = JSON.parse(File.read(path)) if File.file?(path)
|
|
317
|
+
recorded = nil unless recorded.is_a?(Hash) && recorded["id"] == tab[:id]
|
|
318
|
+
# A pointer-only record (no root/preset provenance — written for
|
|
319
|
+
# a foreign tab's prepared pane) proves no ownership.
|
|
320
|
+
recorded = nil if recorded && !recorded.key?("root") && !recorded.key?("preset")
|
|
321
|
+
recorded
|
|
322
|
+
rescue JSON::ParserError
|
|
323
|
+
raise Runtime::WindowConflictError,
|
|
324
|
+
"window '#{tab[:name]}' has unreadable root or preset identity"
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def native_pane_cwd(tab)
|
|
328
|
+
native_pane = @surface.list_panes(workspace_id: tab[:workspace]).find { |pane| pane[:tab] == tab[:id] }
|
|
329
|
+
native_pane && native_pane[:cwd]
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Cross-instance prepared-pane reuse: the identity record carries
|
|
333
|
+
# the prepared pane id, so another adapter instance (or process)
|
|
334
|
+
# reuses the retained target instead of splitting again. Works for
|
|
335
|
+
# provenance records and pointer-only (foreign tab) records alike.
|
|
336
|
+
def discover_prepared_pane(tab)
|
|
337
|
+
cached = @prepared[tab[:id]]
|
|
338
|
+
return cached if usable_prepared_pane?(cached)
|
|
339
|
+
|
|
340
|
+
pane_id = pointer_prepared_pane(identity_path(tab[:workspace], tab[:name]), tab[:id])
|
|
341
|
+
return nil unless pane_id && usable_prepared_pane?(pane_id)
|
|
342
|
+
|
|
343
|
+
@prepared[tab[:id]] = pane_id
|
|
344
|
+
pane_id
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def pointer_prepared_pane(path, id)
|
|
348
|
+
raw = JSON.parse(File.read(path))
|
|
349
|
+
raw.is_a?(Hash) && raw["id"] == id ? raw["prepared_pane"] : nil
|
|
350
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
351
|
+
nil
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def record_prepared_pane(tab, pane_id)
|
|
355
|
+
@prepared[tab[:id]] = pane_id
|
|
356
|
+
path = identity_path(tab[:workspace], tab[:name])
|
|
357
|
+
begin
|
|
358
|
+
recorded = File.file?(path) ? JSON.parse(File.read(path)) : nil
|
|
359
|
+
rescue JSON::ParserError
|
|
360
|
+
# A corrupt record is repaired under the identity lock rather
|
|
361
|
+
# than silently dropped, so the pointer survives.
|
|
362
|
+
recorded = :corrupt
|
|
363
|
+
end
|
|
364
|
+
if recorded.is_a?(Hash) && recorded["id"] == tab[:id]
|
|
365
|
+
write_identity(path, id: recorded["id"], root: recorded["root"], preset: recorded["preset"],
|
|
366
|
+
prepared_pane: pane_id)
|
|
367
|
+
else
|
|
368
|
+
# Foreign tab (not created through ensure_window) or corrupt
|
|
369
|
+
# record: persist a pointer-only record so later instances
|
|
370
|
+
# reuse this pane instead of splitting again.
|
|
371
|
+
write_pointer_only(path, id: tab[:id], prepared_pane: pane_id)
|
|
372
|
+
end
|
|
373
|
+
rescue StandardError => e
|
|
374
|
+
# The pointer is an optimization; losing it only costs a re-split.
|
|
375
|
+
# Warn and continue — provenance writes in ensure_window stay
|
|
376
|
+
# hard-fail, this one does not.
|
|
377
|
+
warn "ace-herdr: could not persist prepared-pane record for '#{tab[:name]}': #{e.class}: #{e.message}"
|
|
378
|
+
nil
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def usable_prepared_pane?(pane_id)
|
|
382
|
+
return false unless pane_id
|
|
383
|
+
|
|
384
|
+
@executor.pane_get(pane_id)
|
|
385
|
+
info = find_hash(@executor.pane_process_info(pane_id).parsed_json, %w[result process_info])
|
|
386
|
+
info.is_a?(Hash) && !!find_value_of_type(info, "shell_pid", Integer)
|
|
387
|
+
rescue PaneNotFoundError
|
|
388
|
+
false
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def verify_retained_shell!(pane_id)
|
|
392
|
+
info = find_hash(@executor.pane_process_info(pane_id).parsed_json, %w[result process_info])
|
|
393
|
+
return if info.is_a?(Hash) && find_value_of_type(info, "shell_pid", Integer)
|
|
394
|
+
|
|
395
|
+
raise Runtime::Error, "prepared pane '#{pane_id}' has no retained shell"
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def write_identity(path, id:, root:, preset:, prepared_pane: nil)
|
|
399
|
+
payload = {id: id, root: root, preset: preset}
|
|
400
|
+
payload[:prepared_pane] = prepared_pane if prepared_pane
|
|
401
|
+
atomic_write_json(path, payload)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def write_pointer_only(path, id:, prepared_pane:)
|
|
405
|
+
atomic_write_json(path, {id: id, prepared_pane: prepared_pane})
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def atomic_write_json(path, payload)
|
|
409
|
+
temp = "#{path}.#{Process.pid}.tmp"
|
|
410
|
+
File.open(temp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
|
|
411
|
+
file.write(JSON.generate(payload))
|
|
412
|
+
file.flush
|
|
413
|
+
file.fsync
|
|
414
|
+
end
|
|
415
|
+
File.rename(temp, path)
|
|
416
|
+
ensure
|
|
417
|
+
File.delete(temp) if temp && File.exist?(temp)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def agent_pane?(pane)
|
|
421
|
+
@executor.agent_get(pane)
|
|
422
|
+
true
|
|
423
|
+
rescue AgentNotFoundError
|
|
424
|
+
false
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def lifecycle_met?(condition, target, workspace:)
|
|
428
|
+
case condition
|
|
429
|
+
when "window-exists" then tab_exists?(target, workspace: workspace)
|
|
430
|
+
when "window-active" then tab_active?(target, workspace: workspace)
|
|
431
|
+
when "pane-exists" then pane_exists?(target)
|
|
432
|
+
when "pane-exited" then pane_exited?(target)
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def tab_exists?(target, workspace:)
|
|
437
|
+
@executor.tab_get(find_tab(target, workspace)[:id])
|
|
438
|
+
true
|
|
439
|
+
rescue Runtime::TargetNotFoundError, TabNotFoundError
|
|
440
|
+
false
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def tab_active?(target, workspace:)
|
|
444
|
+
tab = find_tab(target, workspace)
|
|
445
|
+
@executor.tab_get(tab[:id])
|
|
446
|
+
tab[:active] == true
|
|
447
|
+
rescue Runtime::TargetNotFoundError, TabNotFoundError
|
|
448
|
+
false
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Scoped to the workspace resolved once for the enclosing wait.
|
|
452
|
+
def find_tab(target, workspace)
|
|
453
|
+
tabs(workspace).find { |row| row[:id] == target || row[:name] == target } ||
|
|
454
|
+
raise(Runtime::TargetNotFoundError, "tab '#{target}' not found")
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def pane_exists?(target)
|
|
458
|
+
@executor.pane_get(target)
|
|
459
|
+
true
|
|
460
|
+
rescue PaneNotFoundError
|
|
461
|
+
false
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# Mirrors PaneTidyProbe.process_evidence semantics: herdr
|
|
465
|
+
# serializes `foreground_processes` with serde skip_serializing_if,
|
|
466
|
+
# so inside a well-formed result.process_info object an OMITTED key
|
|
467
|
+
# is the process-exit proof; an explicit null or non-array value is
|
|
468
|
+
# malformed evidence and stays inconclusive. Anything alive appears
|
|
469
|
+
# as a non-empty array; a list holding only the retained shell
|
|
470
|
+
# counts as exited (no submitted foreground command remains).
|
|
471
|
+
def pane_exited?(target)
|
|
472
|
+
return true unless pane_exists?(target)
|
|
473
|
+
|
|
474
|
+
info = find_hash(@executor.pane_process_info(target).parsed_json, %w[result process_info])
|
|
475
|
+
return false unless info.is_a?(Hash)
|
|
476
|
+
|
|
477
|
+
processes = info["foreground_processes"]
|
|
478
|
+
return false if info.key?("foreground_processes") && !processes.is_a?(Array)
|
|
479
|
+
|
|
480
|
+
# An omitted list is only trusted exit evidence inside a
|
|
481
|
+
# well-formed object that also carries the pane's shell pid;
|
|
482
|
+
# an empty or shell-only hash alone proves nothing.
|
|
483
|
+
shell_pid = find_value_of_type(info, "shell_pid", Integer)
|
|
484
|
+
return false unless shell_pid
|
|
485
|
+
|
|
486
|
+
processes = Array(processes)
|
|
487
|
+
return true if processes.empty?
|
|
488
|
+
|
|
489
|
+
processes.all? { |process| process.is_a?(Hash) && process["pid"] == shell_pid }
|
|
490
|
+
rescue PaneNotFoundError
|
|
491
|
+
true
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def find_value(value, keys)
|
|
495
|
+
Atoms::JsonFind.find_value(value, keys)
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# Pinned-path walk that fails closed: nil unless every hop resolves
|
|
499
|
+
def find_hash(value, keys)
|
|
500
|
+
keys.each do |key|
|
|
501
|
+
return nil unless value.is_a?(Hash)
|
|
502
|
+
|
|
503
|
+
value = value[key]
|
|
504
|
+
end
|
|
505
|
+
value
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def find_value_of_type(value, key, type)
|
|
509
|
+
return unless value.is_a?(Hash)
|
|
510
|
+
return value[key] if value[key].is_a?(type)
|
|
511
|
+
|
|
512
|
+
value.each_value { |child| found = find_value_of_type(child, key, type); return found if found }
|
|
513
|
+
nil
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def milliseconds(seconds)
|
|
517
|
+
(seconds * 1000).ceil
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def with_errors(condition: nil, target: nil, timeout: nil)
|
|
521
|
+
yield
|
|
522
|
+
rescue AgentBlockedError => e
|
|
523
|
+
raise Runtime::SendRejectedError, e.message
|
|
524
|
+
rescue ExecutorTimeoutError, Herdr::WaitTimeoutError => e
|
|
525
|
+
raise Runtime::WaitTimeoutError.new(condition: condition || "send", target: target, timeout: timeout || 0)
|
|
526
|
+
rescue AgentNotReadyError => e
|
|
527
|
+
error = condition ? Runtime::WaitTimeoutError.new(condition: condition, target: target, timeout: timeout) :
|
|
528
|
+
Runtime::SendStalledError.new(e.message)
|
|
529
|
+
raise error
|
|
530
|
+
rescue PaneNotFoundError, TabNotFoundError, AgentNotFoundError => e
|
|
531
|
+
raise Runtime::TargetNotFoundError, e.message
|
|
532
|
+
rescue WorkspaceNotFoundError, TargetResolutionError => e
|
|
533
|
+
# Unresolvable caller context, or the runtime responded but the
|
|
534
|
+
# answer was unusable (missing ids)
|
|
535
|
+
raise Runtime::RuntimeUnavailableError, e.message
|
|
536
|
+
rescue TabMaterializationError, ValidationError, CommandError => e
|
|
537
|
+
# Bad request configuration, a failed materialization, or an
|
|
538
|
+
# unmapped native failure keeps the native cause while surfacing
|
|
539
|
+
# under the contract error type.
|
|
540
|
+
raise Runtime::Error, e.message
|
|
541
|
+
rescue ExecutorUnavailableError => e
|
|
542
|
+
raise Runtime::RuntimeUnavailableError, e.message
|
|
543
|
+
rescue Runtime::Error
|
|
544
|
+
# Already a contract error (raised inside the block): pass through
|
|
545
|
+
# untouched — never re-wrap.
|
|
546
|
+
raise
|
|
547
|
+
rescue StandardError => e
|
|
548
|
+
# Adapter boundary: every runtime failure surfaces under the
|
|
549
|
+
# contract error model, with the native cause preserved.
|
|
550
|
+
raise Runtime::Error, "#{e.class}: #{e.message}"
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
end
|
data/lib/ace/herdr/version.rb
CHANGED
data/lib/ace/herdr.rb
CHANGED
|
@@ -15,6 +15,18 @@ module Ace
|
|
|
15
15
|
|
|
16
16
|
# Raised when a bounded wait exceeds its timeout
|
|
17
17
|
class WaitTimeoutError < Error; end
|
|
18
|
+
|
|
19
|
+
# Raised when a preset tab was created natively but a later
|
|
20
|
+
# materialization step (splits, commands, agents) failed; carries the
|
|
21
|
+
# created tab id so the owner can roll back exactly that tab.
|
|
22
|
+
class TabMaterializationError < Error
|
|
23
|
+
attr_reader :tab_id
|
|
24
|
+
|
|
25
|
+
def initialize(tab_id:, message:)
|
|
26
|
+
@tab_id = tab_id
|
|
27
|
+
super(message)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
18
30
|
end
|
|
19
31
|
end
|
|
20
32
|
|
|
@@ -23,6 +35,7 @@ require_relative "herdr/errors"
|
|
|
23
35
|
|
|
24
36
|
# Load all ace-herdr components
|
|
25
37
|
require_relative "herdr/atoms/answer_digest"
|
|
38
|
+
require_relative "herdr/atoms/json_find"
|
|
26
39
|
require_relative "herdr/models/delivery_record"
|
|
27
40
|
require_relative "herdr/models/dispatch_outcome"
|
|
28
41
|
require_relative "herdr/molecules/herdr_executor"
|
|
@@ -33,6 +46,7 @@ require_relative "herdr/molecules/preset_resolver"
|
|
|
33
46
|
require_relative "herdr/organisms/deliverer"
|
|
34
47
|
require_relative "herdr/organisms/dispatcher"
|
|
35
48
|
require_relative "herdr/organisms/control_surface"
|
|
49
|
+
require_relative "herdr/organisms/runtime_adapter"
|
|
36
50
|
require_relative "herdr/organisms/tidy"
|
|
37
51
|
require_relative "herdr/cli"
|
|
38
52
|
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-herdr
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-10-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-hitl
|
|
@@ -23,6 +23,20 @@ dependencies:
|
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '0.10'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: ace-runtime
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0.1'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0.1'
|
|
26
40
|
- !ruby/object:Gem::Dependency
|
|
27
41
|
name: ace-support-cli
|
|
28
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -170,6 +184,7 @@ files:
|
|
|
170
184
|
- exe/ace-herdr
|
|
171
185
|
- lib/ace/herdr.rb
|
|
172
186
|
- lib/ace/herdr/atoms/answer_digest.rb
|
|
187
|
+
- lib/ace/herdr/atoms/json_find.rb
|
|
173
188
|
- lib/ace/herdr/cli.rb
|
|
174
189
|
- lib/ace/herdr/cli/commands/capture.rb
|
|
175
190
|
- lib/ace/herdr/cli/commands/close.rb
|
|
@@ -194,8 +209,10 @@ files:
|
|
|
194
209
|
- lib/ace/herdr/organisms/control_surface.rb
|
|
195
210
|
- lib/ace/herdr/organisms/deliverer.rb
|
|
196
211
|
- lib/ace/herdr/organisms/dispatcher.rb
|
|
212
|
+
- lib/ace/herdr/organisms/runtime_adapter.rb
|
|
197
213
|
- lib/ace/herdr/organisms/tidy.rb
|
|
198
214
|
- lib/ace/herdr/version.rb
|
|
215
|
+
- lib/ace/runtime/adapters/herdr.rb
|
|
199
216
|
homepage: https://github.com/cs3b/ace
|
|
200
217
|
licenses:
|
|
201
218
|
- MIT
|