ace-assign 0.62.0 → 0.63.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 17761502c8b23ff60844d6a3da34ddde53c776d18907a23854ad4168dec3c033
4
- data.tar.gz: 1538f48c6aec44ac548dcc116bddc0c4928cbc876485513b616604f886f0deb0
3
+ metadata.gz: 7d743c208ce34b070929c7c5cc2acdd200289e7a82e3e2acd62129ec1d22daa5
4
+ data.tar.gz: 3b4e13b527152e703f7a489f0c7b123b0676c682f6eccb3bd6f30680cf96ace2
5
5
  SHA512:
6
- metadata.gz: 7f6b66a87f7a32496f11974ced9cb200c092488f027eb233dde59703ba3dcffa2eaad309d1f57b0b023113eb3f08c58e451b69823a33533d80c5fb45b79110e2
7
- data.tar.gz: 6d0c8ef8b69d5002b98b60b22018fdc4aecf809818284e80bd11c70020a9733b9ba37140237b30fea470d539de23ce98a658bec902b1a6b9bf4454329ab50ed9
6
+ metadata.gz: eeba37f93e80112453a79322c0496b6dc5753f5da072556575836a142827c2a3724848978eec0025598babbe1077def70f77fee49df79dd5ae6f54ea668fdd2d
7
+ data.tar.gz: f78cf53f13cbb76fb67d7a72756189063fa39d0dddb8eb3b55c28e7acd16377e0fc0a486b126fafe926791905871610800f719d166a55a1e2e65431fe62a3724
@@ -26,7 +26,7 @@ workflow_source_paths:
26
26
  execution:
27
27
  provider: "claude:opus@yolo"
28
28
  timeout: 1800
29
- launch_mode: auto # auto | headless | tmux
29
+ launch_mode: auto # auto | headless | tmux | herdr
30
30
 
31
31
  # Attempt evidence persistence (managed attempts)
32
32
  attempt:
data/CHANGELOG.md CHANGED
@@ -7,12 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.63.1] - 2026-10-04
11
+
12
+ ### Changed
13
+
14
+ - Install both Herdr and tmux runtime adapters for standalone runtime switching, through an acyclic dependency graph.
15
+
16
+ ### Fixed
17
+
18
+ - Run terminal fork invocations as children of the prepared shell, retaining the same writable pane after a command exits.
19
+
20
+ - Managed assignment status JSON tests run against test-owned repositories and evidence journals instead of the invoking checkout's journal, evidence ref, or worktree registrations (task 8ws.t.ibk). A sentinel-based invariance test asserts the ambient repository's refs, registrations, and files stay untouched, and fixture journals cover absent, seed-only, and stale-checkout states.
21
+
22
+ ## [0.63.0] - 2026-10-04
23
+
24
+ ### Added
25
+ - Runtime-neutral fork launches: `launch_mode` gains `herdr` and `auto` detects the live terminal runtime (tmux first, then herdr; headless only outside any runtime). `TmuxControlSurfaceRunner` is replaced by the contract-backed `RuntimeControlSurfaceRunner`; fork windows/panes, sends, captures, and session metadata are runtime-neutral (`runtime`/`session`/`window`/`window_id`/`pane` replace the `tmux_*` metadata keys), and `--callback` works under herdr. Fork children now receive `ACE_RUNTIME` instead of `ACE_TMUX_SESSION` propagation, and the drive workflow callback rule prescribes `ace-runtime send`.
26
+ - New dependency: `ace-runtime` (~> 0.1); `ace-tmux` is kept to install the tmux adapter.
27
+
10
28
  ## [0.62.0] - 2026-10-04
11
29
 
12
30
  ### Added
13
31
  - **Lock-scoped external attempt authority (spec 8wq.t.34i)**: `AttemptCoordinator#with_verified_attempt` verifies that a managed attempt is live (reserved/running — uncertainty never grants external authority), bound to the exact assignment/project, and owned by the requesting actor, holding the assignment `LifecycleExclusion` shared side across the caller's whole locked transition. `finish` and `reconcile` hold the exclusive side across their terminal commits, so a liveness check can never observe an attempt as active after the terminal event landed (and vice versa). This is the authority behind ace-hitl's managed HITL request binding.
14
32
 
15
-
16
33
  ## [0.61.1] - 2026-10-02
17
34
 
18
35
  ### Fixed
data/README.md CHANGED
@@ -32,7 +32,7 @@ hidden: 66 | done: 64 active: 1 pending: 5 failed: 1
32
32
  Use `ace-assign status --mode full` when you need the whole tree, and `ace-assign step` when you need the raw instructions for the current or next step.
33
33
 
34
34
 
35
- The easiest way to start is through [ace-overseer](../ace-overseer) -- define a task and run `ace-overseer work-on --task <ref> --preset work-on-task` , which creates the assignment, worktree, and tmux window in one shot.
35
+ The easiest way to start is through [ace-overseer](../ace-overseer) -- define a task and run `ace-overseer work-on --task <ref> --preset work-on-task` , which creates the assignment, worktree, and terminal window in one shot.
36
36
 
37
37
  ## Testing Contract
38
38
 
data/docs/usage.md CHANGED
@@ -286,28 +286,27 @@ Options:
286
286
  - `--provider <provider:model>`
287
287
  - `--cli-args <args>`
288
288
  - `--timeout <seconds>`
289
- - `--launch-mode auto|headless|tmux`
289
+ - `--launch-mode auto|headless|tmux|herdr`
290
290
  - `--callback`
291
291
  - `--quiet, -q`
292
292
  - `--debug, -d`
293
293
 
294
294
  Launch modes:
295
295
 
296
- - `auto` (default): use tmux when the current process is already inside tmux or `ACE_TMUX_SESSION` is set; otherwise use the headless subprocess path
297
- - `headless`: force the existing provider subprocess path and never create tmux panes
298
- - `tmux`: require tmux context, create or reuse `<origin-window>-fs`, start a real interactive agent in a pane there via `ace-llm --interactive`, and send the scoped `/as-assign-drive <assignment>@<root>` handoff automatically. The fork window name uses the shared `ace-tmux` safe-name policy, so punctuation in the base window is replaced with `-`. Fork windows and panes are created detached, so the current tmux focus stays where the user left it.
299
- - `tmux`: require tmux context, create or reuse `<origin-window>-fs`, start a real interactive agent in a pane there via `ace-llm --interactive`, and send the scoped `/as-assign-drive <assignment>@<root>` handoff automatically
296
+ - `auto` (default): use the detected terminal runtime — tmux when the current process is inside tmux or `ACE_TMUX_SESSION` is set (tmux wins when both are live), herdr when `HERDR_SESSION` + `HERDR_PANE` are live; with no runtime detected, use the headless subprocess path (assign is the only consumer with a headless fallback)
297
+ - `headless`: force the existing provider subprocess path and never create terminal panes
298
+ - `tmux`: require a live tmux context, create or reuse `<origin-window>-fs` through the `ace-runtime` contract, start a real interactive agent in a prepared pane via `ace-llm --interactive`, and send the scoped `/as-assign-drive <assignment>@<root>` handoff automatically
299
+ - `herdr`: the same flow through the Herdr adapter — the fork opens as a tab in the caller's live Herdr workspace and the child inherits `HERDR_*` plus `ACE_RUNTIME=herdr`
300
300
 
301
- - This mode consumes the shared `ace-tmux` runtime/control surface for tmux targeting, pane dispatch, and diagnostics.
302
- - The fork window name uses the shared `ace-tmux` safe-name policy, so punctuation in the base window is replaced with `-`.
303
- - Fork windows and panes are created detached, so the current tmux focus stays where the user left it.
301
+ - All terminal targeting, pane dispatch, and capture go through the runtime-neutral `ace-runtime` contract; the fork window name uses the shared `ace-runtime` safe-name policy, so punctuation in the base window is replaced with `-`.
302
+ - Fork windows/panes and herdr tabs are created detached, so the current focus stays where the user left it.
304
303
  - Assignment step state remains the source of truth for subtree completion or failure; pane capture is diagnostic support only.
305
304
 
306
305
  Callback mode:
307
306
 
308
- - `--callback`: tmux-only fork mode that captures the pane where `fork-run` was started and passes it into the child fork session as `ACE_ASSIGN_CALLBACK_PANE`
309
- - In callback mode the child agent is instructed to send one final status sentence back to the origin pane with `ace-tmux send` before stopping
310
- - Callback mode is intended for interactive parent/child agent tmux flows where the parent stays idle until the child sends the final message back
307
+ - `--callback`: terminal-only fork mode (requires `tmux` or `herdr` launch mode) that captures the pane where `fork-run` was started and passes it into the child fork session as `ACE_ASSIGN_CALLBACK_PANE`
308
+ - In callback mode the child agent is instructed to send one final status sentence back to the origin pane with `ace-runtime send` before stopping
309
+ - Callback mode is intended for interactive parent/child agent flows where the parent stays idle until the child sends the final message back
311
310
 
312
311
  Launch-mode precedence for fork execution:
313
312
 
@@ -442,21 +442,21 @@ After launching `ace-assign fork-run`, the driver remains inside the same drive
442
442
 
443
443
  When `ACE_ASSIGN_CALLBACK_PANE` is present in the forked child environment:
444
444
 
445
- - before stopping for either success or failure, send one final sentence back to the origin pane with direct `ace-tmux send`
445
+ - before stopping for either success or failure, send one final sentence back to the origin pane with direct `ace-runtime send`
446
446
  - use the exact pane target from `ACE_ASSIGN_CALLBACK_PANE`
447
447
  - use a success sentence shaped like:
448
448
 
449
449
  ```bash
450
- ace-tmux send --pane "$ACE_ASSIGN_CALLBACK_PANE" --msg "Fork subtree ${FORK_ROOT} for assignment ${ASSIGNMENT_ID} completed. Resume parent assignment drive now." --key Enter
450
+ ace-runtime send --pane "$ACE_ASSIGN_CALLBACK_PANE" --msg "Fork subtree ${FORK_ROOT} for assignment ${ASSIGNMENT_ID} completed. Resume parent assignment drive now." --key Enter
451
451
  ```
452
452
 
453
453
  - use a failure sentence shaped like:
454
454
 
455
455
  ```bash
456
- ace-tmux send --pane "$ACE_ASSIGN_CALLBACK_PANE" --msg "Fork subtree ${FORK_ROOT} for assignment ${ASSIGNMENT_ID} failed. Resume parent assignment drive and inspect scoped status." --key Enter
456
+ ace-runtime send --pane "$ACE_ASSIGN_CALLBACK_PANE" --msg "Fork subtree ${FORK_ROOT} for assignment ${ASSIGNMENT_ID} failed. Resume parent assignment drive and inspect scoped status." --key Enter
457
457
  ```
458
458
 
459
- - do not invent a new skill or wrapper for this callback; use `ace-tmux send` directly
459
+ - do not invent a new skill or wrapper for this callback; use `ace-runtime send` directly
460
460
 
461
461
  #### Post-Fork Resume Checklist
462
462
 
@@ -21,8 +21,8 @@ module Ace
21
21
  option :provider, desc: "LLM provider:model override (e.g., codex:gpt-5, claude:sonnet)"
22
22
  option :cli_args, desc: "Extra CLI args for provider process"
23
23
  option :timeout, type: :integer, desc: "Execution timeout in seconds"
24
- option :launch_mode, desc: "Launch mode: auto, headless, or tmux"
25
- option :callback, type: :boolean, default: false, desc: "Capture the origin tmux pane and let the forked agent send a final callback message there"
24
+ option :launch_mode, desc: "Launch mode: auto, headless, tmux, or herdr"
25
+ option :callback, type: :boolean, default: false, desc: "Capture the origin pane and let the forked agent send a final callback message there"
26
26
  option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
27
27
  option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
28
28
 
@@ -225,13 +225,13 @@ module Ace
225
225
  def resolve_callback_pane(callback_enabled, launch_mode)
226
226
  return nil unless callback_enabled
227
227
 
228
- unless launch_mode == "tmux"
229
- raise Error, "--callback requires tmux launch mode so the origin pane can be addressed."
228
+ unless Molecules::ForkSessionLauncher::TERMINAL_LAUNCH_MODES.include?(launch_mode)
229
+ raise Error, "--callback requires a terminal launch mode (tmux or herdr) so the origin pane can be addressed."
230
230
  end
231
231
 
232
- pane = launcher.respond_to?(:callback_pane) ? launcher.callback_pane : nil
232
+ pane = launcher.respond_to?(:callback_pane) ? launcher.callback_pane(runtime: launch_mode) : nil
233
233
  pane = pane.to_s.strip
234
- raise Error, "--callback requires a resolvable origin tmux pane." if pane.empty?
234
+ raise Error, "--callback requires a resolvable origin pane." if pane.empty?
235
235
 
236
236
  pane
237
237
  end
@@ -40,7 +40,7 @@ require_relative "molecules/step_writer"
40
40
  require_relative "molecules/step_renumberer"
41
41
  require_relative "molecules/skill_assign_source_resolver"
42
42
  require_relative "molecules/fork_session_launcher"
43
- require_relative "molecules/tmux_control_surface_runner"
43
+ require_relative "molecules/runtime_control_surface_runner"
44
44
  require_relative "molecules/preset_inferrer"
45
45
  require_relative "molecules/evidence_calculator"
46
46
  require_relative "molecules/evidence_journal"
@@ -11,8 +11,9 @@ module Ace
11
11
  DEFAULT_PROVIDER = "claude:sonnet"
12
12
  DEFAULT_TIMEOUT = 1800
13
13
  DEFAULT_LAUNCH_MODE = "auto"
14
- VALID_LAUNCH_MODES = %w[auto headless tmux].freeze
15
- TMUX_POLL_INTERVAL = 0.5
14
+ VALID_LAUNCH_MODES = %w[auto headless tmux herdr].freeze
15
+ TERMINAL_LAUNCH_MODES = %w[tmux herdr].freeze
16
+ SUBTREE_POLL_INTERVAL = 0.5
16
17
  DEFAULT_TARGET_ENV = "ACE_ASSIGN_DEFAULT_TARGET"
17
18
  CURRENT_ASSIGNMENT_ID_ENV = "ACE_ASSIGN_CURRENT_ASSIGNMENT_ID"
18
19
  CURRENT_FORK_ROOT_ENV = "ACE_ASSIGN_CURRENT_FORK_ROOT"
@@ -35,18 +36,11 @@ module Ace
35
36
  env[CURRENT_FORK_ROOT_ENV].to_s.strip == root_ref
36
37
  end
37
38
 
38
- def initialize(config: nil, query_interface: Ace::LLM::QueryInterface, tmux_runner: nil, interactive_builder: nil)
39
- @config = config || Ace::Assign.config
40
- @query_interface = query_interface
41
- @tmux_runner = tmux_runner || TmuxControlSurfaceRunner.new
42
- @interactive_builder = interactive_builder || Ace::LLM::Molecules::InteractiveCommandBuilder.new
43
- end
44
-
45
- def initialize(config: nil, query_interface: Ace::LLM::QueryInterface, tmux_runner: nil, interactive_builder: nil,
39
+ def initialize(config: nil, query_interface: Ace::LLM::QueryInterface, runner: nil, interactive_builder: nil,
46
40
  lifecycle_exclusion: nil)
47
41
  @config = config || Ace::Assign.config
48
42
  @query_interface = query_interface
49
- @tmux_runner = tmux_runner || TmuxControlSurfaceRunner.new
43
+ @runner = runner || RuntimeControlSurfaceRunner.new
50
44
  @interactive_builder = interactive_builder || Ace::LLM::Molecules::InteractiveCommandBuilder.new
51
45
  @lifecycle_exclusion = lifecycle_exclusion
52
46
  end
@@ -62,31 +56,32 @@ module Ace
62
56
  # marker fails the launch closed instead of writing into a deleted
63
57
  # assignment.
64
58
  lifecycle_exclusion.with_shared(lifecycle_exclusion.assignment_key(assignment_id)) do
65
- if resolved_mode == "tmux"
66
- launch_tmux(
59
+ if resolved_mode == "headless"
60
+ launch_provider_session(
67
61
  assignment_id: assignment_id,
68
62
  fork_root: fork_root,
69
63
  provider: resolved_provider,
70
64
  cli_args: cli_args,
71
65
  timeout: resolved_timeout,
72
- cache_dir: cache_dir,
73
- callback_pane: callback_pane
66
+ cache_dir: cache_dir
74
67
  )
75
68
  else
76
- launch_provider_session(
69
+ launch_in_runtime(
70
+ runtime: resolved_mode,
77
71
  assignment_id: assignment_id,
78
72
  fork_root: fork_root,
79
73
  provider: resolved_provider,
80
74
  cli_args: cli_args,
81
75
  timeout: resolved_timeout,
82
- cache_dir: cache_dir
76
+ cache_dir: cache_dir,
77
+ callback_pane: callback_pane
83
78
  )
84
79
  end
85
80
  end
86
81
  end
87
82
 
88
- def callback_pane
89
- tmux_runner.current_pane
83
+ def callback_pane(runtime: nil)
84
+ runtime_runner_for(runtime).current_pane
90
85
  end
91
86
 
92
87
  def launch_provider_session(assignment_id:, fork_root:, provider:, cli_args: nil, timeout: nil, cache_dir: nil,
@@ -124,7 +119,7 @@ module Ace
124
119
 
125
120
  private
126
121
 
127
- attr_reader :config, :query_interface, :tmux_runner, :interactive_builder
122
+ attr_reader :config, :query_interface, :runner, :interactive_builder
128
123
 
129
124
  def lifecycle_exclusion
130
125
  @lifecycle_exclusion ||= Molecules::LifecycleExclusion.new
@@ -140,36 +135,35 @@ module Ace
140
135
 
141
136
  return mode unless mode == "auto"
142
137
 
143
- tmux_runner.tmux_context? ? "tmux" : "headless"
138
+ # Assign is the only consumer whose auto mode selects headless,
139
+ # and only when no terminal runtime is detected (k86.3 spec).
140
+ runner.detect_runtime || "headless"
144
141
  end
145
142
 
146
- def launch_tmux(assignment_id:, fork_root:, provider:, cli_args:, timeout:, cache_dir:, callback_pane: nil)
143
+ def launch_in_runtime(runtime:, assignment_id:, fork_root:, provider:, cli_args:, timeout:, cache_dir:, callback_pane: nil)
147
144
  ensure_not_same_scoped_refork!(assignment_id: assignment_id, fork_root: fork_root)
148
- session = tmux_runner.current_session
149
- raise Error, "Launch mode tmux requires an active tmux session (TMUX or ACE_TMUX_SESSION)." unless session
150
- raise Error, "Tmux launch requires assignment cache_dir for subtree polling." if cache_dir.to_s.strip.empty?
145
+ launch_runner = runtime_runner_for(runtime)
146
+ context = launch_runner.context
147
+ unless context[:in_runtime]
148
+ raise Error, "Launch mode #{runtime} requires a live #{runtime} runtime (run inside it or set its session environment)."
149
+ end
150
+ raise Error, "Runtime launch requires assignment cache_dir for subtree polling." if cache_dir.to_s.strip.empty?
151
151
 
152
- current_window = tmux_runner.current_window
153
- raise Error, "Could not resolve current tmux window for fork launch." if current_window.to_s.strip.empty?
152
+ origin_window = context[:window]
153
+ raise Error, "Could not resolve current runtime window for fork launch." if origin_window.to_s.strip.empty?
154
154
 
155
155
  fork_window = ENV["ACE_ASSIGN_FORK_WINDOW"].to_s.strip
156
- fork_window = tmux_runner.fork_window_name(current_window) if fork_window.empty?
156
+ fork_window = launch_runner.fork_window_name(origin_window) if fork_window.empty?
157
157
 
158
- window_info = tmux_runner.ensure_window(session: session, name: fork_window, root: Dir.pwd)
159
- pane_target = tmux_runner.prepare_pane(
160
- session: session,
161
- window: fork_window,
162
- window_target: window_info[:target],
163
- root: Dir.pwd,
164
- keep_existing: window_info[:created]
165
- )
158
+ window = launch_runner.ensure_window(name: fork_window, root: Dir.pwd)
159
+ pane_target = launch_runner.prepare_pane(window: fork_window)
166
160
 
167
161
  session_meta_file = build_session_meta_file(cache_dir, fork_root)
168
162
  prompt = "/as-assign-drive #{assignment_id}@#{fork_root}"
169
- tmux_env = tmux_subprocess_env(
163
+ subprocess_env = runtime_subprocess_env(
164
+ runtime: runtime,
170
165
  assignment_id: assignment_id,
171
166
  fork_root: fork_root,
172
- session: session,
173
167
  fork_window: fork_window,
174
168
  callback_pane: callback_pane
175
169
  )
@@ -178,32 +172,32 @@ module Ace
178
172
  prompt: prompt,
179
173
  cli_args: cli_args,
180
174
  working_dir: Dir.pwd,
181
- subprocess_env: tmux_env
175
+ subprocess_env: subprocess_env
182
176
  )
183
177
 
184
- tmux_runner.run_invocation_in_pane(
178
+ launch_runner.run_invocation_in_pane(
185
179
  pane_target: pane_target,
186
180
  command: invocation[:command],
187
181
  env: invocation[:env],
188
182
  working_dir: invocation[:working_dir],
189
183
  visible_handoff: invocation[:prompt]
190
184
  )
191
- write_tmux_launch_metadata(
185
+ write_runtime_launch_metadata(
192
186
  session_meta_file: session_meta_file,
193
187
  provider: invocation[:provider],
194
188
  model: invocation[:model],
195
189
  prompt: invocation[:prompt]
196
190
  )
197
- tmux_runner.merge_tmux_metadata(
191
+ launch_runner.merge_runtime_metadata(
198
192
  session_meta_file: session_meta_file,
199
- session: session,
193
+ session: context[:session],
200
194
  window: fork_window,
201
195
  pane: pane_target,
202
- window_id: window_info[:window_id],
196
+ window_id: window,
203
197
  callback_pane: callback_pane
204
198
  )
205
199
 
206
- return {tmux: true, pane_target: pane_target, callback_mode: true, callback_pane: callback_pane} if callback_pane
200
+ return {runtime: runtime, pane_target: pane_target, callback_mode: true, callback_pane: callback_pane} if callback_pane
207
201
 
208
202
  wait_for_subtree_terminal(
209
203
  assignment_id: assignment_id,
@@ -212,10 +206,10 @@ module Ace
212
206
  timeout: timeout
213
207
  )
214
208
 
215
- {tmux: true, pane_target: pane_target}
209
+ {runtime: runtime, pane_target: pane_target}
216
210
  end
217
211
 
218
- def write_tmux_launch_metadata(session_meta_file:, provider:, model:, prompt:)
212
+ def write_runtime_launch_metadata(session_meta_file:, provider:, model:, prompt:)
219
213
  detected = detect_provider_session(provider, prompt)
220
214
  meta = {}
221
215
  meta = YAML.safe_load_file(session_meta_file) || {} if File.exist?(session_meta_file)
@@ -278,11 +272,19 @@ module Ace
278
272
  "Cannot fork-run subtree #{assignment_id}@#{fork_root}: already running inside that scoped subtree. Continue inline instead of calling fork-run again."
279
273
  end
280
274
 
281
- def tmux_subprocess_env(assignment_id:, fork_root:, session:, fork_window:, callback_pane: nil)
275
+ def runtime_runner_for(runtime)
276
+ if runtime == runner.runtime_name
277
+ runner
278
+ else
279
+ RuntimeControlSurfaceRunner.new(runtime: runtime)
280
+ end
281
+ end
282
+
283
+ def runtime_subprocess_env(runtime:, assignment_id:, fork_root:, fork_window:, callback_pane: nil)
282
284
  env = self.class.fork_scope_env(assignment_id: assignment_id, fork_root: fork_root).merge(
283
285
  "PROJECT_ROOT_PATH" => Dir.pwd,
284
- "ACE_TMUX_SESSION" => session,
285
- "ACE_ASSIGN_LAUNCH_MODE" => "tmux",
286
+ "ACE_RUNTIME" => runtime,
287
+ "ACE_ASSIGN_LAUNCH_MODE" => runtime,
286
288
  "ACE_ASSIGN_FORK_WINDOW" => fork_window
287
289
  )
288
290
  env["ACE_ASSIGN_CALLBACK_PANE"] = callback_pane if callback_pane && !callback_pane.empty?
@@ -303,13 +305,13 @@ module Ace
303
305
  loop do
304
306
  state = scanner.scan(assignment.steps_dir, assignment: assignment)
305
307
  return state if state.subtree_complete?(fork_root)
306
- raise Error, "Fork session execution failed in tmux subtree #{fork_root}." if state.subtree_failed?(fork_root)
308
+ raise Error, "Fork session execution failed in the #{fork_root} subtree." if state.subtree_failed?(fork_root)
307
309
 
308
310
  if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
309
- raise Error, "Timed out waiting for tmux fork subtree #{fork_root} to reach a terminal state."
311
+ raise Error, "Timed out waiting for the fork subtree #{fork_root} to reach a terminal state."
310
312
  end
311
313
 
312
- sleep(TMUX_POLL_INTERVAL)
314
+ sleep(SUBTREE_POLL_INTERVAL)
313
315
  end
314
316
  end
315
317
  end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/runtime"
4
+ require "fileutils"
5
+ require "shellwords"
6
+ require "yaml"
7
+
8
+ module Ace
9
+ module Assign
10
+ module Molecules
11
+ # Contract-backed terminal helper for fork launches. Every terminal
12
+ # operation delegates to the resolved Ace::Runtime adapter; assign
13
+ # never calls a runtime-native API directly (k86.3 interface rule).
14
+ class RuntimeControlSurfaceRunner
15
+ def initialize(runtime: nil, env: ENV)
16
+ @runtime_name = runtime
17
+ @env = env
18
+ end
19
+
20
+ attr_reader :runtime_name, :env
21
+
22
+ def runtime
23
+ @runtime ||= Ace::Runtime.resolve(runtime_name || selected_name)
24
+ end
25
+
26
+ def context
27
+ runtime.context
28
+ end
29
+
30
+ # Side-effect-free runtime detection ("tmux", "herdr", or nil) for
31
+ # the assign auto launch mode.
32
+ def detect_runtime
33
+ detected = Ace::Runtime.detect(env: env)
34
+ detected&.to_s
35
+ end
36
+
37
+ def in_runtime?
38
+ context[:in_runtime] ? true : false
39
+ end
40
+
41
+ def current_session
42
+ context[:session]
43
+ end
44
+
45
+ def current_pane
46
+ explicit = env["ACE_ASSIGN_CALLBACK_PANE"].to_s.strip
47
+ return explicit unless explicit.empty?
48
+
49
+ context[:pane]
50
+ end
51
+
52
+ def fork_window_name(base_window)
53
+ base = base_window.to_s.strip.sub(/-fs\z/, "")
54
+
55
+ "#{Ace::Runtime.sanitize_name(base, fallback: "fork")}-fs"
56
+ end
57
+
58
+ def ensure_window(name:, root:)
59
+ runtime.ensure_window(name: name, root: root)
60
+ rescue Ace::Runtime::Error => e
61
+ raise Error, "Failed to ensure fork window #{name}: #{e.message}"
62
+ end
63
+
64
+ def prepare_pane(window:)
65
+ runtime.prepare_pane(window: window)
66
+ rescue Ace::Runtime::Error => e
67
+ raise Error, "Failed to prepare fork pane in #{window}: #{e.message}"
68
+ end
69
+
70
+ def run_invocation_in_pane(pane_target:, command:, env: nil, working_dir: nil, visible_handoff: nil)
71
+ shell_command = build_pane_shell_command(
72
+ command: command,
73
+ env: env,
74
+ working_dir: working_dir,
75
+ visible_handoff: visible_handoff
76
+ )
77
+ runtime.send_command(pane: pane_target, command: shell_command)
78
+ rescue Ace::Runtime::Error => e
79
+ raise Error, "Failed to send fork command: #{e.message}"
80
+ end
81
+
82
+ def run_script_in_pane(pane_target:, script_path:)
83
+ runtime.send_command(pane: pane_target, command: "bash #{File.expand_path(script_path).shellescape}")
84
+ rescue Ace::Runtime::Error => e
85
+ raise Error, "Failed to send fork command: #{e.message}"
86
+ end
87
+
88
+ def capture_recent_output(pane_target:, lines: 40)
89
+ runtime.capture(pane: pane_target, lines: lines)
90
+ rescue Ace::Runtime::Error => e
91
+ raise Error, "Failed to capture pane #{pane_target}: #{e.message}"
92
+ end
93
+
94
+ def merge_runtime_metadata(session_meta_file:, session:, window:, pane:, window_id: nil, callback_pane: nil)
95
+ data = if File.exist?(session_meta_file)
96
+ YAML.safe_load_file(session_meta_file) || {}
97
+ else
98
+ {}
99
+ end
100
+ data["launch_mode"] = runtime_name
101
+ data["runtime"] = runtime_name
102
+ data["session"] = session
103
+ data["window"] = window
104
+ data["window_id"] = window_id if window_id
105
+ data["pane"] = pane
106
+ data["callback_pane"] = callback_pane if callback_pane && !callback_pane.empty?
107
+ File.write(session_meta_file, data.to_yaml)
108
+ end
109
+
110
+ private
111
+
112
+ def selected_name
113
+ detected = Ace::Runtime.detect(env: env)
114
+ return detected.to_s if detected
115
+
116
+ raise Error, "no terminal runtime detected: run inside tmux/herdr, set ACE_RUNTIME, or pass an explicit runtime"
117
+ end
118
+
119
+ def build_pane_shell_command(command:, env:, working_dir:, visible_handoff:)
120
+ steps = []
121
+ resolved_working_dir = working_dir.to_s.strip
122
+ steps << "cd #{Shellwords.escape(File.expand_path(resolved_working_dir))}" unless resolved_working_dir.empty?
123
+
124
+ handoff = visible_handoff.to_s
125
+ steps << "printf '%s\\n' #{Shellwords.escape(handoff)}" unless handoff.empty?
126
+
127
+ # The prepared pane owns a retained interactive shell. Run the
128
+ # invocation as its child so another submission can use the same
129
+ # target after this command exits.
130
+ steps << build_exec_command(command: command, env: env)
131
+ steps.join(" && ")
132
+ end
133
+
134
+ def build_exec_command(command:, env:)
135
+ cmd = Array(command).map { |part| Shellwords.escape(part.to_s) }.join(" ")
136
+ env_hash = env.respond_to?(:to_h) ? env.to_h : {}
137
+ unset_parts = []
138
+ assign_parts = []
139
+
140
+ env_hash.each do |key, value|
141
+ next if key.to_s.strip.empty?
142
+
143
+ if value.nil?
144
+ unset_parts << "-u #{Shellwords.escape(key.to_s)}"
145
+ else
146
+ assign_parts << "#{key}=#{Shellwords.escape(value.to_s)}"
147
+ end
148
+ end
149
+ env_parts = unset_parts + assign_parts
150
+
151
+ return cmd if env_parts.empty?
152
+
153
+ "env #{env_parts.join(' ')} #{cmd}"
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Assign
5
- VERSION = "0.62.0"
5
+ VERSION = "0.63.1"
6
6
  end
7
7
  end
data/lib/ace/assign.rb CHANGED
@@ -5,7 +5,7 @@ require "ace/support/config"
5
5
  require "ace/support/fs"
6
6
  require "ace/support/nav"
7
7
  require "ace/support/cli"
8
- require "ace/tmux"
8
+ require "ace/runtime"
9
9
  require "pathname"
10
10
 
11
11
  # CLI and commands
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-assign
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.62.0
4
+ version: 0.63.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
@@ -135,6 +135,20 @@ dependencies:
135
135
  - - "~>"
136
136
  - !ruby/object:Gem::Version
137
137
  version: '0.37'
138
+ - !ruby/object:Gem::Dependency
139
+ name: ace-runtime
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - "~>"
143
+ - !ruby/object:Gem::Version
144
+ version: '0.1'
145
+ type: :runtime
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: '0.1'
138
152
  - !ruby/object:Gem::Dependency
139
153
  name: ace-tmux
140
154
  requirement: !ruby/object:Gem::Requirement
@@ -149,6 +163,20 @@ dependencies:
149
163
  - - "~>"
150
164
  - !ruby/object:Gem::Version
151
165
  version: '0.18'
166
+ - !ruby/object:Gem::Dependency
167
+ name: ace-herdr
168
+ requirement: !ruby/object:Gem::Requirement
169
+ requirements:
170
+ - - "~>"
171
+ - !ruby/object:Gem::Version
172
+ version: 0.3.2
173
+ type: :runtime
174
+ prerelease: false
175
+ version_requirements: !ruby/object:Gem::Requirement
176
+ requirements:
177
+ - - "~>"
178
+ - !ruby/object:Gem::Version
179
+ version: 0.3.2
152
180
  - !ruby/object:Gem::Dependency
153
181
  name: ace-support-test-helpers
154
182
  requirement: !ruby/object:Gem::Requirement
@@ -344,10 +372,10 @@ files:
344
372
  - lib/ace/assign/molecules/preset_inferrer.rb
345
373
  - lib/ace/assign/molecules/queue_scanner.rb
346
374
  - lib/ace/assign/molecules/receipt_verifier.rb
375
+ - lib/ace/assign/molecules/runtime_control_surface_runner.rb
347
376
  - lib/ace/assign/molecules/skill_assign_source_resolver.rb
348
377
  - lib/ace/assign/molecules/step_renumberer.rb
349
378
  - lib/ace/assign/molecules/step_writer.rb
350
- - lib/ace/assign/molecules/tmux_control_surface_runner.rb
351
379
  - lib/ace/assign/organisms/assignment_executor.rb
352
380
  - lib/ace/assign/organisms/attempt_coordinator.rb
353
381
  - lib/ace/assign/organisms/task_assignment_creator.rb
@@ -1,249 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "ace/tmux"
4
- require "fileutils"
5
- require "shellwords"
6
- require "yaml"
7
-
8
- module Ace
9
- module Assign
10
- module Molecules
11
- # Shared ace-tmux backed runtime helper for tmux fork launches.
12
- class TmuxControlSurfaceRunner
13
- def initialize(executor: Ace::Tmux::Molecules::TmuxExecutor.new, resolver: nil, control_surface: nil, tmux: "tmux", env: ENV)
14
- @executor = executor
15
- @tmux = tmux
16
- @env = env
17
- @resolver = resolver || Ace::Tmux::Molecules::RuntimeTargetResolver.new(executor: executor, tmux: tmux, env: env)
18
- @control_surface = control_surface || Ace::Tmux::Organisms::ControlSurface.new(
19
- executor: executor,
20
- resolver: @resolver,
21
- tmux: tmux
22
- )
23
- end
24
-
25
- def tmux_context?
26
- !current_session.to_s.strip.empty?
27
- end
28
-
29
- def current_session
30
- resolver.resolve_session.session
31
- rescue Ace::Tmux::TargetResolutionError
32
- nil
33
- end
34
-
35
- def current_window
36
- explicit = env["ACE_ASSIGN_FORK_WINDOW"].to_s.strip
37
- return explicit unless explicit.empty?
38
-
39
- resolver.resolve_window(session: current_session).window
40
- rescue Ace::Tmux::TargetResolutionError
41
- nil
42
- end
43
-
44
- def current_pane
45
- explicit = env["ACE_ASSIGN_CALLBACK_PANE"].to_s.strip
46
- return explicit unless explicit.empty?
47
-
48
- resolver.resolve_pane(session: current_session, window: current_window).pane_target
49
- rescue Ace::Tmux::TargetResolutionError
50
- nil
51
- end
52
-
53
- def fork_window_name(base_window)
54
- base = base_window.to_s.strip.sub(/-fs\z/, "")
55
- sanitized = Ace::Tmux::Atoms::WindowNameSanitizer.call(base, fallback: "fork")
56
-
57
- "#{sanitized}-fs"
58
- end
59
-
60
- def ensure_window(session:, name:, root:)
61
- if (window_id = find_window_id(session: session, name: name))
62
- return {created: false, target: window_id, window_id: window_id, name: name}
63
- end
64
-
65
- result = executor.capture(
66
- Ace::Tmux::Atoms::TmuxCommandBuilder.new_window(
67
- session,
68
- name: name,
69
- root: root,
70
- print_format: '#{window_id}',
71
- tmux: tmux
72
- )
73
- )
74
- raise Error, "Failed to create tmux fork window #{name}: #{result.stderr}" unless result.success?
75
-
76
- window_id = result.stdout.to_s.strip
77
- raise Error, "Failed to create tmux fork window #{name}: empty window id" if window_id.empty?
78
-
79
- {created: true, target: window_id, window_id: window_id, name: name}
80
- end
81
-
82
- def prepare_pane(session:, window:, root:, keep_existing:, window_target: nil)
83
- target = window_target || "#{session}:#{window}"
84
- pane = if keep_existing
85
- first_pane(target)
86
- else
87
- create_pane(target, root)
88
- end
89
-
90
- set_pane_remain_on_exit(pane)
91
- select_layout(target)
92
- pane
93
- end
94
-
95
- def select_window(session:, window:, window_target: nil)
96
- target = window_target || "#{session}:#{window}"
97
- run!(
98
- Ace::Tmux::Atoms::TmuxCommandBuilder.select_window(target, tmux: tmux),
99
- "select tmux fork window #{window}"
100
- )
101
- end
102
-
103
- def run_invocation_in_pane(pane_target:, command:, env: nil, working_dir: nil, visible_handoff: nil)
104
- shell_command = build_pane_shell_command(
105
- command: command,
106
- env: env,
107
- working_dir: working_dir,
108
- visible_handoff: visible_handoff
109
- )
110
- control_surface.send_command(pane: pane_target, command: shell_command)
111
- rescue Ace::Tmux::Error => e
112
- raise Error, "Failed to send tmux fork command: #{e.message}"
113
- end
114
-
115
- def run_script_in_pane(pane_target:, script_path:)
116
- control_surface.send_command(pane: pane_target, command: "bash #{File.expand_path(script_path).shellescape}")
117
- rescue Ace::Tmux::Error => e
118
- raise Error, "Failed to send tmux fork command: #{e.message}"
119
- end
120
-
121
- def capture_recent_output(pane_target:, lines: 40)
122
- control_surface.capture_recent_output(pane: pane_target, lines: lines)
123
- rescue Ace::Tmux::Error => e
124
- raise Error, "Failed to capture pane #{pane_target}: #{e.message}"
125
- end
126
-
127
- def merge_tmux_metadata(session_meta_file:, session:, window:, pane:, window_id: nil, callback_pane: nil)
128
- data = if File.exist?(session_meta_file)
129
- YAML.safe_load_file(session_meta_file) || {}
130
- else
131
- {}
132
- end
133
- data["launch_mode"] = "tmux"
134
- data["tmux_session"] = session
135
- data["tmux_window"] = window
136
- data["tmux_window_id"] = window_id if window_id
137
- data["tmux_pane_id"] = pane
138
- data["callback_pane"] = callback_pane if callback_pane && !callback_pane.empty?
139
- File.write(session_meta_file, data.to_yaml)
140
- end
141
-
142
- private
143
-
144
- attr_reader :control_surface, :env, :executor, :resolver, :tmux
145
-
146
- def find_window_id(session:, name:)
147
- result = executor.capture(
148
- Ace::Tmux::Atoms::TmuxCommandBuilder.list_windows(
149
- session,
150
- format: '#{window_id}' + "\t" + '#{window_name}',
151
- tmux: tmux
152
- )
153
- )
154
- return nil unless result.success?
155
-
156
- result.stdout.split("\n").map(&:strip).reject(&:empty?).each do |line|
157
- window_id, window_name = line.split("\t", 2)
158
- return window_id if window_name == name && !window_id.to_s.empty?
159
- end
160
-
161
- nil
162
- end
163
-
164
- def first_pane(target)
165
- result = executor.capture(
166
- Ace::Tmux::Atoms::TmuxCommandBuilder.list_panes(target, format: '#{pane_id}', tmux: tmux)
167
- )
168
- raise Error, "Failed to inspect panes for #{target}: #{result.stderr}" unless result.success?
169
-
170
- pane = result.stdout.split("\n").map(&:strip).reject(&:empty?).first
171
- raise Error, "No panes found for #{target}" if pane.to_s.empty?
172
-
173
- pane
174
- end
175
-
176
- def create_pane(target, root)
177
- result = executor.capture(
178
- Ace::Tmux::Atoms::TmuxCommandBuilder.split_window(
179
- target,
180
- root: root,
181
- print_format: '#{pane_id}',
182
- tmux: tmux
183
- )
184
- )
185
- raise Error, "Failed to create tmux fork pane in #{target}: #{result.stderr}" unless result.success?
186
-
187
- pane = result.stdout.to_s.strip
188
- raise Error, "Failed to create tmux fork pane in #{target}: empty pane id" if pane.empty?
189
-
190
- pane
191
- end
192
-
193
- def set_pane_remain_on_exit(pane_target)
194
- run!(
195
- Ace::Tmux::Atoms::TmuxCommandBuilder.set_pane_option(pane_target, "remain-on-exit", "on", tmux: tmux),
196
- "enable remain-on-exit"
197
- )
198
- end
199
-
200
- def select_layout(target)
201
- run!(
202
- Ace::Tmux::Atoms::TmuxCommandBuilder.select_layout(target, "tiled", tmux: tmux),
203
- "apply tiled layout"
204
- )
205
- end
206
-
207
- def run!(cmd, action)
208
- return if executor.run(cmd)
209
-
210
- raise Error, "Failed to #{action}"
211
- end
212
-
213
- def build_pane_shell_command(command:, env:, working_dir:, visible_handoff:)
214
- steps = []
215
- resolved_working_dir = working_dir.to_s.strip
216
- steps << "cd #{Shellwords.escape(File.expand_path(resolved_working_dir))}" unless resolved_working_dir.empty?
217
-
218
- handoff = visible_handoff.to_s
219
- steps << "printf '%s\\n' #{Shellwords.escape(handoff)}" unless handoff.empty?
220
-
221
- steps << "exec #{build_exec_command(command: command, env: env)}"
222
- steps.join(" && ")
223
- end
224
-
225
- def build_exec_command(command:, env:)
226
- cmd = Array(command).map { |part| Shellwords.escape(part.to_s) }.join(" ")
227
- env_hash = env.respond_to?(:to_h) ? env.to_h : {}
228
- unset_parts = []
229
- assign_parts = []
230
-
231
- env_hash.each do |key, value|
232
- next if key.to_s.strip.empty?
233
-
234
- if value.nil?
235
- unset_parts << "-u #{Shellwords.escape(key.to_s)}"
236
- else
237
- assign_parts << "#{key}=#{Shellwords.escape(value.to_s)}"
238
- end
239
- end
240
- env_parts = unset_parts + assign_parts
241
-
242
- return cmd if env_parts.empty?
243
-
244
- "env #{env_parts.join(' ')} #{cmd}"
245
- end
246
- end
247
- end
248
- end
249
- end