ace-assign 0.57.1 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/config.yml +13 -0
  3. data/CHANGELOG.md +46 -12
  4. data/docs/exit-codes.md +10 -0
  5. data/docs/usage.md +49 -0
  6. data/handbook/workflow-instructions/assign/drive.wf.md +64 -8
  7. data/lib/ace/assign/atoms/assignment_scope.rb +53 -0
  8. data/lib/ace/assign/atoms/attempt_state_machine.rb +76 -0
  9. data/lib/ace/assign/atoms/evidence_digest.rb +78 -0
  10. data/lib/ace/assign/cli/commands/attempt/base.rb +35 -0
  11. data/lib/ace/assign/cli/commands/attempt/evidence.rb +28 -0
  12. data/lib/ace/assign/cli/commands/attempt/finish.rb +35 -0
  13. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +38 -0
  14. data/lib/ace/assign/cli/commands/attempt/start.rb +45 -0
  15. data/lib/ace/assign/cli/commands/attempt/status.rb +54 -0
  16. data/lib/ace/assign/cli/commands/status.rb +24 -1
  17. data/lib/ace/assign/cli.rb +34 -2
  18. data/lib/ace/assign/models/assignment.rb +21 -4
  19. data/lib/ace/assign/models/attempt.rb +178 -0
  20. data/lib/ace/assign/models/attempt_binding.rb +116 -0
  21. data/lib/ace/assign/models/evidence_event.rb +82 -0
  22. data/lib/ace/assign/models/execution_receipt.rb +139 -0
  23. data/lib/ace/assign/molecules/assignment_manager.rb +35 -3
  24. data/lib/ace/assign/molecules/attempt_reconciler.rb +88 -0
  25. data/lib/ace/assign/molecules/attempt_store.rb +201 -0
  26. data/lib/ace/assign/molecules/evidence_calculator.rb +144 -91
  27. data/lib/ace/assign/molecules/evidence_journal.rb +423 -0
  28. data/lib/ace/assign/molecules/execution_identity_resolver.rb +97 -0
  29. data/lib/ace/assign/molecules/fork_session_launcher.rb +38 -19
  30. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +209 -0
  31. data/lib/ace/assign/molecules/receipt_verifier.rb +272 -0
  32. data/lib/ace/assign/organisms/assignment_executor.rb +12 -4
  33. data/lib/ace/assign/organisms/attempt_coordinator.rb +618 -0
  34. data/lib/ace/assign/organisms/task_assignment_creator.rb +1 -1
  35. data/lib/ace/assign/version.rb +1 -1
  36. data/lib/ace/assign.rb +56 -0
  37. metadata +36 -2
@@ -0,0 +1,423 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "open3"
6
+
7
+ module Ace
8
+ module Assign
9
+ module Molecules
10
+ # Append-only execution evidence journal backed by a dedicated Git ref.
11
+ #
12
+ # Accepted evidence for assignment attempts is committed to a separate
13
+ # configured evidence ref (default `refs/ace/execution`) in canonical,
14
+ # non-secret paths `execution/<assignment-id>/events/`. The ref lives
15
+ # outside the deliverable candidate branch: appending evidence never
16
+ # advances or changes the reviewed candidate. Working state uses an
17
+ # isolated, disposable detached worktree under the configured checkout
18
+ # root.
19
+ #
20
+ # Concurrency: all ref updates serialize on a checkout-root flock, and
21
+ # every update is additionally guarded by an expected-old-value
22
+ # `git update-ref` compare-and-swap. On a CAS conflict the journal
23
+ # reloads the authoritative ref and replays only still-valid,
24
+ # non-duplicate events.
25
+ class EvidenceJournal
26
+ CAS_ATTEMPTS = 3
27
+
28
+ # @param repo_root [String] Git repository root holding the evidence ref
29
+ # @param ref [String, nil] Evidence ref (default from config)
30
+ # @param checkout_root [String, nil] Isolated checkout root (default from config)
31
+ def initialize(repo_root:, ref: nil, checkout_root: nil)
32
+ @repo_root = repo_root
33
+ @ref = ref || default_config("evidence_git_ref") || "refs/ace/execution"
34
+ root = checkout_root || default_config("evidence_checkout_root") || ".ace-local/assign/evidence-checkout"
35
+ @checkout_root = Pathname.new(root).absolute? ? root : File.join(repo_root, root)
36
+ end
37
+
38
+ attr_reader :repo_root, :ref, :checkout_root
39
+
40
+ # @return [Boolean] True when the repo can hold the evidence ref
41
+ def available?
42
+ git_ok?("rev-parse", "--git-dir")
43
+ end
44
+
45
+ # Current value of the evidence ref.
46
+ #
47
+ # @return [String, nil] Commit SHA or nil when the ref does not exist
48
+ def ref_value
49
+ out, stderr, status = git("rev-parse", "--verify", "--quiet", @ref)
50
+ return out if status.success?
51
+
52
+ raise AttemptErrors::EvidenceUnavailable, "Cannot read evidence ref #{@ref}: #{stderr}" if git_broken?(stderr)
53
+
54
+ nil
55
+ end
56
+
57
+ # Append accepted events to the journal under lock + CAS.
58
+ #
59
+ # @param assignment_id [String] Assignment ID (journal path segment)
60
+ # @param attempt_id [String] Attempt ID (commit message + dedupe scope)
61
+ # @param events [Array<Hash>] Event payloads from Models::EvidenceEvent.build
62
+ # @return [String] Journal commit SHA after the append
63
+ # @raise [AttemptErrors::EvidenceUnavailable] when the ref stays conflicting
64
+ def append(assignment_id:, attempt_id:, events:)
65
+ return ref_value if events.empty?
66
+
67
+ with_lock do
68
+ CAS_ATTEMPTS.times do
69
+ # Re-read the authoritative value every attempt. nil means the
70
+ # ref does not exist yet; ensure_checkout! seeds it, so re-read
71
+ # before computing the compare-and-swap baseline.
72
+ old = ref_value
73
+ ensure_checkout!
74
+ old = ref_value if old.nil?
75
+ sync_checkout(old)
76
+ written = write_event_files(assignment_id, events)
77
+ new_commit = written.zero? ? old : commit_events(assignment_id, attempt_id, events)
78
+ return new_commit if update_ref_cas(new_commit, old)
79
+
80
+ # CAS conflict: another writer advanced the ref. The next pass
81
+ # resets the checkout to the authoritative tree and replays
82
+ # only the events that are still missing (duplicates skipped).
83
+ end
84
+
85
+ raise AttemptErrors::EvidenceUnavailable,
86
+ "Evidence ref #{@ref} stayed conflicting after #{CAS_ATTEMPTS} compare-and-swap attempts"
87
+ end
88
+ end
89
+
90
+ # Read all journal events recorded for an assignment, ordered by the
91
+ # digest chain (never by filename: same-second events sort
92
+ # alphabetically, which can invert lifecycle order).
93
+ #
94
+ # @param assignment_id [String] Assignment ID
95
+ # @return [Array<Hash>] Parsed events in journal order
96
+ def read_events(assignment_id)
97
+ value = ref_value
98
+ return [] if value.nil?
99
+
100
+ paths, stderr, status = git("ls-tree", "-r", "--name-only", value, "--",
101
+ "execution/#{assignment_id}/events/")
102
+ raise AttemptErrors::EvidenceUnavailable, "Cannot read journal events: #{stderr}" unless status.success?
103
+ events = paths.lines.map(&:strip).select { |path| path.end_with?(".json") }.map do |path|
104
+ content, error, read_status = git("show", "#{value}:#{path}")
105
+ raise AttemptErrors::EvidenceUnavailable, "Cannot read journal event: #{error}" unless read_status.success?
106
+ JSON.parse(content)
107
+ end
108
+ order_by_chain(events)
109
+ rescue JSON::ParserError => e
110
+ raise AttemptErrors::EvidenceUnavailable, "Corrupt journal event for #{assignment_id}: #{e.message}"
111
+ end
112
+
113
+ # Accepted receipt payloads recorded for an assignment.
114
+ #
115
+ # @param assignment_id [String] Assignment ID
116
+ # @return [Array<Hash>] Accepted receipt payloads (digest included)
117
+ def accepted_receipts(assignment_id)
118
+ read_events(assignment_id)
119
+ .select { |event| event["type"] == "receipt_accepted" }
120
+ .map { |event| event.dig("payload", "receipt") }
121
+ .compact
122
+ end
123
+
124
+ # All attempts derivable from the journal, reconstructed from intent
125
+ # and process_start facts with journal-authoritative state. The
126
+ # journal is the owner of attempt-state interpretation.
127
+ #
128
+ # @param assignment_id [String] Assignment ID
129
+ # @return [Array<Models::Attempt>] Derived attempts
130
+ def derived_attempts(assignment_id)
131
+ events = read_events(assignment_id)
132
+ by_attempt = events.group_by { |event| event["attempt_id"] }
133
+ by_attempt.delete(nil)
134
+
135
+ by_attempt.filter_map do |attempt_id, attempt_events|
136
+ intent = attempt_events.find { |event| event["type"] == "intent" }
137
+ next unless intent
138
+
139
+ state = derive_state(attempt_events)
140
+ next if state.nil?
141
+
142
+ build_attempt(assignment_id, attempt_id, intent["payload"], attempt_events, state)
143
+ end
144
+ end
145
+
146
+ # Non-terminal attempts derived from the journal.
147
+ #
148
+ # @param assignment_id [String] Assignment ID
149
+ # @return [Array<Models::Attempt>] Active attempts
150
+ def active_attempts(assignment_id)
151
+ derived_attempts(assignment_id).reject(&:terminal?)
152
+ end
153
+
154
+ # Assignment IDs present in the journal, discovered from the evidence
155
+ # ref itself (works even when the local assignment cache is gone).
156
+ #
157
+ # @return [Array<String>] Assignment IDs with journal evidence
158
+ def assignment_ids
159
+ value = ref_value
160
+ return [] if value.nil?
161
+
162
+ paths, stderr, status = git("ls-tree", "-d", "--name-only", value, "--", "execution/")
163
+ raise AttemptErrors::EvidenceUnavailable, "Cannot discover journal assignments: #{stderr}" unless status.success?
164
+ paths.lines.map { |path| path.strip.delete_prefix("execution/") }.sort
165
+ end
166
+
167
+ private
168
+
169
+ # Latest lifecycle state implied by the events, or nil when the
170
+ # events do not describe a full attempt (intent missing).
171
+ def derive_state(events)
172
+ return nil unless events.any? { |event| event["type"] == "intent" }
173
+
174
+ state = "running"
175
+ events.each do |event|
176
+ case event["type"]
177
+ when "receipt_accepted"
178
+ state = event.dig("payload", "receipt", "verdict") || state
179
+ when "transition"
180
+ state = event.dig("payload", "to") || state
181
+ when "reconciliation"
182
+ state = event.dig("payload", "resolution") || state
183
+ end
184
+ end
185
+ state
186
+ end
187
+
188
+ def build_attempt(assignment_id, attempt_id, intent_payload, events, state)
189
+ process_start = events.reverse.find do |event|
190
+ event["type"] == "process_start" && event["attempt_id"] == attempt_id
191
+ end
192
+ candidate_head = events.select { |event| event["type"] == "receipt_accepted" }
193
+ .map { |event| event.dig("payload", "receipt", "head") }
194
+ .compact
195
+ .first
196
+ binding = Models::AttemptBinding.new(
197
+ attempt_id: attempt_id,
198
+ assignment_id: assignment_id,
199
+ scope: intent_payload["scope"],
200
+ project_id: intent_payload["project_id"],
201
+ task_id: intent_payload["task_id"],
202
+ actor: process_start&.dig("payload", "actor") || "recovered",
203
+ role: process_start&.dig("payload", "role") || "coordinator",
204
+ runtime: process_start&.dig("payload", "runtime") || "recovered",
205
+ base_head: intent_payload["base_head"],
206
+ evidence_git_ref: ref,
207
+ created_at: parse_event_time(intent_time(events, attempt_id))
208
+ )
209
+ Models::Attempt.new(
210
+ binding: binding,
211
+ state: state,
212
+ candidate_head: candidate_head,
213
+ journal_commit: ref_value
214
+ )
215
+ end
216
+
217
+ def intent_time(events, attempt_id)
218
+ intent = events.find { |event| event["type"] == "intent" && event["attempt_id"] == attempt_id }
219
+ intent&.dig("recorded_at")
220
+ end
221
+
222
+ def parse_event_time(value)
223
+ return Time.now.utc if value.nil?
224
+
225
+ require "time"
226
+ Time.parse(value)
227
+ rescue ArgumentError
228
+ Time.now.utc
229
+ end
230
+
231
+ private
232
+
233
+ def event_files(assignment_id)
234
+ Dir.glob(File.join(checkout_dir, "execution", assignment_id, "events", "*.json")).sort
235
+ end
236
+
237
+ # Order events by following previous_digest links from chain roots;
238
+ # orphaned events (unknown predecessor) keep filename order after the
239
+ # resolved chains.
240
+ def order_by_chain(events)
241
+ by_digest = {}
242
+ events.each { |event| by_digest[event["digest"]] = event }
243
+
244
+ next_of = {}
245
+ events.each do |event|
246
+ previous = event["previous_digest"]
247
+ next_of[previous] = event if previous && by_digest.key?(previous)
248
+ end
249
+
250
+ roots = events.reject do |event|
251
+ previous = event["previous_digest"]
252
+ previous && by_digest.key?(previous)
253
+ end
254
+
255
+ ordered = []
256
+ visited = {}
257
+ roots.each do |root|
258
+ cursor = root
259
+ while cursor && !visited[cursor["digest"]]
260
+ visited[cursor["digest"]] = true
261
+ ordered << cursor
262
+ cursor = next_of[cursor["digest"]]
263
+ end
264
+ end
265
+
266
+ ordered + events.reject { |event| visited[event["digest"]] }
267
+ end
268
+
269
+ def event_filename(event)
270
+ "#{event["recorded_at"].to_s.tr("-:T Z", "")}-#{event["type"]}-#{event["digest"][0, 12]}.json"
271
+ end
272
+
273
+ def write_event_files(assignment_id, events)
274
+ written = 0
275
+ events.each do |event|
276
+ path = File.join(checkout_dir, "execution", assignment_id, "events", event_filename(event))
277
+ next if File.exist?(path)
278
+
279
+ FileUtils.mkdir_p(File.dirname(path))
280
+ File.write(path, JSON.pretty_generate(event))
281
+ written += 1
282
+ end
283
+ written
284
+ end
285
+
286
+ def commit_events(assignment_id, attempt_id, events)
287
+ types = events.map { |event| event["type"] }.uniq.join(",")
288
+ git!("-C", checkout_dir, "add", "-A", "execution/#{assignment_id}")
289
+ git!(
290
+ "-C", checkout_dir,
291
+ "-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
292
+ "commit", "-m", "evidence: #{assignment_id} #{attempt_id} (#{types})"
293
+ )
294
+ git!("-C", checkout_dir, "rev-parse", "HEAD").first
295
+ end
296
+
297
+ # Expected-old-value compare-and-swap; returns false on conflict.
298
+ # A nil expectation is the create case: the ref must not exist.
299
+ def update_ref_cas(new_commit, expected_old)
300
+ return true if !expected_old.nil? && new_commit == expected_old
301
+
302
+ expected = expected_old || ("0" * 40)
303
+ _out, stderr, status = git("update-ref", @ref, new_commit, expected)
304
+ return true if status.success?
305
+
306
+ raise AttemptErrors::EvidenceUnavailable, "Cannot update evidence ref #{@ref}: #{stderr}" if git_broken?(stderr)
307
+
308
+ false
309
+ end
310
+
311
+ def checkout_dir
312
+ File.join(@checkout_root, "journal")
313
+ end
314
+
315
+ def lock_path
316
+ File.join(@checkout_root, ".evidence.lock")
317
+ end
318
+
319
+ def with_lock
320
+ FileUtils.mkdir_p(@checkout_root)
321
+ File.open(lock_path, File::RDWR | File::CREAT) do |lock|
322
+ lock.flock(File::LOCK_EX)
323
+ begin
324
+ yield
325
+ ensure
326
+ lock.flock(File::LOCK_UN)
327
+ end
328
+ end
329
+ end
330
+
331
+ def ensure_checkout!
332
+ return if detached_worktree_at?(checkout_dir)
333
+
334
+ FileUtils.rm_rf(checkout_dir)
335
+ git!("worktree", "prune")
336
+ value = ref_value
337
+ if value.nil?
338
+ seed_ref
339
+ value = ref_value
340
+ end
341
+ git!("worktree", "add", "--detach", checkout_dir, value)
342
+ end
343
+
344
+ # Seed the evidence ref with an empty-tree commit so a worktree can
345
+ # attach before any real evidence exists. The create is a
346
+ # compare-and-swap against the zero SHA: a writer that loses the
347
+ # race keeps the winner's ref instead of resetting it.
348
+ def seed_ref
349
+ empty_tree = git!("mktree").first
350
+ seed = git!("-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
351
+ "commit-tree", empty_tree, "-m", "seed: ace-assign execution evidence").first
352
+ _out, stderr, status = git("update-ref", @ref, seed, "0" * 40)
353
+ return if status.success?
354
+
355
+ raise AttemptErrors::EvidenceUnavailable, "Cannot seed evidence ref #{@ref}: #{stderr}" if git_broken?(stderr)
356
+ end
357
+
358
+ def detached_worktree_at?(path)
359
+ dot_git = File.join(path, ".git")
360
+ return false unless File.exist?(dot_git)
361
+
362
+ out, _s = git("-C", path, "rev-parse", "--abbrev-ref", "HEAD")
363
+ out.to_s.strip == "HEAD"
364
+ end
365
+
366
+ # Sync the disposable checkout to an authoritative ref value.
367
+ def sync_checkout(target)
368
+ return if target.nil?
369
+
370
+ git!("-C", checkout_dir, "reset", "--hard", target)
371
+ rescue AttemptErrors::EvidenceUnavailable
372
+ # Stale, broken, or unregistered worktree: rebuild from scratch.
373
+ FileUtils.rm_rf(checkout_dir)
374
+ git!("worktree", "prune")
375
+ git!("worktree", "add", "--detach", checkout_dir, target)
376
+ end
377
+
378
+ def git(*argv)
379
+ unless File.directory?(@repo_root)
380
+ return ["", "repository root missing: #{@repo_root}", FAILED_RESULT]
381
+ end
382
+
383
+ out, stderr, status = Open3.capture3("git", *argv, chdir: @repo_root, stdin_data: "")
384
+ [out.to_s.strip, stderr.to_s.strip, status]
385
+ end
386
+
387
+ def git!(*argv)
388
+ out, stderr, status = git(*argv)
389
+ unless status.success?
390
+ raise AttemptErrors::EvidenceUnavailable, "git #{argv.join(' ')} failed: #{stderr}"
391
+ end
392
+
393
+ [out, stderr, status]
394
+ end
395
+
396
+ def git_ok?(*argv)
397
+ git(*argv)[2].success?
398
+ end
399
+
400
+ # Distinguish "ref missing" (normal, quiet, no stderr) and expected
401
+ # compare-and-swap conflicts (retryable) from a broken repository or
402
+ # ref store.
403
+ def git_broken?(stderr)
404
+ message = stderr.to_s.strip
405
+ return false if message.empty?
406
+ return false if message.include?("unknown revision") || message.include?("not a valid ref")
407
+ return false if message.include?("cannot lock ref") || message.include?("but expected")
408
+
409
+ true
410
+ end
411
+
412
+ # Stand-in status for commands that cannot run at all.
413
+ FailedResult = Struct.new(:success?)
414
+ FAILED_RESULT = FailedResult.new(false).freeze
415
+
416
+ def default_config(key)
417
+ section = Ace::Assign.config["attempt"]
418
+ section && section[key]
419
+ end
420
+ end
421
+ end
422
+ end
423
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+ require "json"
5
+ require "socket"
6
+
7
+ module Ace
8
+ module Assign
9
+ module Molecules
10
+ # Resolves the trusted execution-boundary identity for attempt
11
+ # transitions.
12
+ #
13
+ # Actor identity is derived from the execution boundary, never granted
14
+ # by command flags or receipt claims. Two adapters ship:
15
+ # - `local` — the interactive operator is the coordinator; identity
16
+ # comes from the OS login boundary.
17
+ # - `service` — a trusted service executor supplies a structured
18
+ # identity through a configured environment variable; the executor
19
+ # owns OS/process enforcement.
20
+ #
21
+ # Unknown adapter, missing actor, or malformed identity fails closed:
22
+ # unknown identity is an error, never permission.
23
+ class ExecutionIdentityResolver
24
+ ROLES = %w[coordinator service worker].freeze
25
+ TRUSTED_ROLES = %w[coordinator service].freeze
26
+
27
+ # Resolved boundary identity.
28
+ Identity = Struct.new(:actor, :role, :runtime, :adapter, keyword_init: true)
29
+
30
+ # @param adapter [String, nil] "local" or "service" (default from config)
31
+ # @param service_env [String, nil] Env var carrying the service identity (default from config)
32
+ def initialize(adapter: nil, service_env: nil)
33
+ section = Ace::Assign.config["attempt"] || {}
34
+ @adapter = adapter || section["identity_adapter"] || "local"
35
+ @service_env = service_env || section["service_identity_env"] || "ACE_ASSIGN_SERVICE_IDENTITY"
36
+ end
37
+
38
+ # @return [Identity] Trusted boundary identity
39
+ # @raise [AttemptErrors::UnauthorizedIdentity] when identity cannot be established
40
+ def resolve
41
+ case @adapter
42
+ when "local" then from_local_boundary
43
+ when "service" then from_service_env
44
+ else
45
+ raise AttemptErrors::UnauthorizedIdentity, "Unknown identity adapter '#{@adapter}'"
46
+ end
47
+ end
48
+
49
+ # @param identity [Identity] Identity to inspect
50
+ # @return [Boolean] True when the role may accept privileged transitions
51
+ def trusted?(identity)
52
+ TRUSTED_ROLES.include?(identity.role)
53
+ end
54
+
55
+ private
56
+
57
+ def from_local_boundary
58
+ actor = Etc.getlogin || ENV["USER"] || ENV["LOGNAME"]
59
+ if actor.nil? || actor.strip.empty?
60
+ raise AttemptErrors::UnauthorizedIdentity, "No OS login identity available at the execution boundary"
61
+ end
62
+
63
+ Identity.new(
64
+ actor: actor.strip,
65
+ role: "coordinator",
66
+ runtime: "local:#{Socket.gethostname}",
67
+ adapter: "local"
68
+ )
69
+ end
70
+
71
+ def from_service_env
72
+ raw = ENV[@service_env].to_s.strip
73
+ if raw.empty?
74
+ raise AttemptErrors::UnauthorizedIdentity,
75
+ "Service identity env #{@service_env} is empty; the trusted executor must supply it"
76
+ end
77
+
78
+ data = begin
79
+ JSON.parse(raw)
80
+ rescue JSON::ParserError
81
+ raise AttemptErrors::UnauthorizedIdentity, "Service identity env #{@service_env} is not valid JSON"
82
+ end
83
+
84
+ actor = data["actor"].to_s.strip
85
+ role = data["role"].to_s.strip
86
+ runtime = data["runtime"].to_s.strip
87
+ if actor.empty? || runtime.empty? || !ROLES.include?(role)
88
+ raise AttemptErrors::UnauthorizedIdentity,
89
+ "Service identity must supply actor, runtime, and a role in #{ROLES.join('/')}"
90
+ end
91
+
92
+ Identity.new(actor: actor, role: role, runtime: runtime, adapter: "service")
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -42,31 +42,46 @@ module Ace
42
42
  @interactive_builder = interactive_builder || Ace::LLM::Molecules::InteractiveCommandBuilder.new
43
43
  end
44
44
 
45
+ def initialize(config: nil, query_interface: Ace::LLM::QueryInterface, tmux_runner: nil, interactive_builder: nil,
46
+ lifecycle_exclusion: nil)
47
+ @config = config || Ace::Assign.config
48
+ @query_interface = query_interface
49
+ @tmux_runner = tmux_runner || TmuxControlSurfaceRunner.new
50
+ @interactive_builder = interactive_builder || Ace::LLM::Molecules::InteractiveCommandBuilder.new
51
+ @lifecycle_exclusion = lifecycle_exclusion
52
+ end
53
+
45
54
  def launch(assignment_id:, fork_root:, provider: nil, cli_args: nil, timeout: nil, cache_dir: nil, launch_mode: nil, callback_pane: nil)
46
55
  ensure_not_same_scoped_refork!(assignment_id: assignment_id, fork_root: fork_root)
47
56
  resolved_provider = provider || config.dig("execution", "provider") || DEFAULT_PROVIDER
48
57
  resolved_timeout = timeout || config.dig("execution", "timeout") || DEFAULT_TIMEOUT
49
58
  resolved_mode = resolve_launch_mode(launch_mode)
50
59
 
51
- if resolved_mode == "tmux"
52
- launch_tmux(
53
- assignment_id: assignment_id,
54
- fork_root: fork_root,
55
- provider: resolved_provider,
56
- cli_args: cli_args,
57
- timeout: resolved_timeout,
58
- cache_dir: cache_dir,
59
- callback_pane: callback_pane
60
- )
61
- else
62
- launch_provider_session(
63
- assignment_id: assignment_id,
64
- fork_root: fork_root,
65
- provider: resolved_provider,
66
- cli_args: cli_args,
67
- timeout: resolved_timeout,
68
- cache_dir: cache_dir
69
- )
60
+ # Driver startup participates in prune exclusion: prune holds the
61
+ # exclusive side from final evidence reads through removal; a removed
62
+ # marker fails the launch closed instead of writing into a deleted
63
+ # assignment.
64
+ lifecycle_exclusion.with_shared(lifecycle_exclusion.assignment_key(assignment_id)) do
65
+ if resolved_mode == "tmux"
66
+ launch_tmux(
67
+ assignment_id: assignment_id,
68
+ fork_root: fork_root,
69
+ provider: resolved_provider,
70
+ cli_args: cli_args,
71
+ timeout: resolved_timeout,
72
+ cache_dir: cache_dir,
73
+ callback_pane: callback_pane
74
+ )
75
+ else
76
+ launch_provider_session(
77
+ assignment_id: assignment_id,
78
+ fork_root: fork_root,
79
+ provider: resolved_provider,
80
+ cli_args: cli_args,
81
+ timeout: resolved_timeout,
82
+ cache_dir: cache_dir
83
+ )
84
+ end
70
85
  end
71
86
  end
72
87
 
@@ -111,6 +126,10 @@ module Ace
111
126
 
112
127
  attr_reader :config, :query_interface, :tmux_runner, :interactive_builder
113
128
 
129
+ def lifecycle_exclusion
130
+ @lifecycle_exclusion ||= Molecules::LifecycleExclusion.new
131
+ end
132
+
114
133
  def resolve_launch_mode(explicit_mode)
115
134
  mode = explicit_mode.to_s.strip
116
135
  mode = config.dig("execution", "launch_mode").to_s.strip if mode.empty?