ace-assign 0.58.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c66fd1fc72e1bb186537931c960477dc7f9405ce3bc0f41c195a4b6601aa9bd1
4
- data.tar.gz: fef52c4c68bbb1435499d7cb0468d09919b5b330d9d7226b4c676c54c12ff9ad
3
+ metadata.gz: 3a8f387dd91c6da87ca5eb3712cb5fb71051a0326dfa13cf0d9d91e054c1cc00
4
+ data.tar.gz: 4698ad04a950a1401caec3387ef085e8c66512a07e9dd5af168446d80e7d89eb
5
5
  SHA512:
6
- metadata.gz: 8e31f4379b900c7906be0b15b92768c904d57bf3fcfccbaf284ca972168413be443907281e1f1b939dff66337daf2835f32fc27c27fc802738dbe19238f220c4
7
- data.tar.gz: f77a7e04992fddf9b8ddf99e57243759e1ec8ccbd7502fbae9836e32bd749a60b5feb7d7c4a63017f92b384efdce49654547b7ecb1d18fb29c66cab644dad18a
6
+ metadata.gz: 89c8df3b2e0823c195dfc3d3b59fbcc58e79c97b3978e0db2e4395c83e86307e8d450a637e633b9aa947efeb79ff94ba78307bc24de577effb8cb55b53e9b8f5
7
+ data.tar.gz: dd2e0a73591ea2e646c9c8164cb56269ff6353860005acdfb57bc268ca8e2d5c89b77b3a19af8a1b895dcfa8716199ae0272a1705387dc4c5f5bda424c062668
data/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.60.0] - 2026-10-01
11
+
12
+ ### Fixed
13
+ - Preserve task/project attachment when the public assignment creation path archives its source configuration, so managed attempts retain immutable journal authority.
14
+ - Cache-free evidence lookup handles a seed-only journal ref without creating a checkout or treating an absent attempt as a corrupt journal.
15
+
16
+ ### Added
17
+ - `attempt evidence --kind review-approval` validates accepted independent approval separately from collection; retained collection/approval receipts support explicit historical-head validation without granting current check authority.
18
+ - Bind campaign check names to accepted executed operations and review collection to accepted `review-collect` artifacts, through `attempt evidence` purpose checks.
19
+
20
+ - `ace-assign attempt evidence` exposes read-only coordinator-accepted current-head check proof. Journal reads use immutable Git objects without writing cache/audit checkout state.
21
+ - Review receipts may consume a durable ace-review campaign result. The existing coordinator still validates authority, live head, independent reviewer, executed checks and artifact digests; stale or conflicting campaign snapshots are rejected.
22
+
23
+ ## [0.59.0] - 2026-09-29
24
+
25
+ ### Added
26
+ - Durable lifecycle exclusion (`Ace::Assign::Molecules::LifecycleExclusion`): prune holds the exclusive side from final evidence reads through removal while every supported start path -- attempt registration, fork/driver session launch, and worktree provisioning -- holds the shared side. Locks live outside every deletion target (sandbox-aware cache base or the shared Git common dir) so they survive removal of the target, and a `removed` marker makes post-prune starts fail closed instead of recreating a deleted identity.
27
+
28
+ ### Changed
29
+ - `AttemptCoordinator#start` and `ForkSessionLauncher#launch` now participate in the prune exclusion: a concurrent prune blocks new writers for the identity, and a start targeting a pruned assignment raises a conflict instead of writing into a recreated path.
30
+
10
31
  ## [0.58.0] - 2026-09-28
11
32
 
12
33
  ### Added
@@ -503,7 +524,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
503
524
  - Unified dynamic step default instructions under a single `AssignmentExecutor::DEFAULT_DYNAMIC_STEP_INSTRUCTIONS` source.
504
525
  - Limited canonical batch expansion to explicit `workflow`, `skill`, or declared `sub_steps` inputs, preserving prior numbering behavior for plain flat batch inserts.
505
526
  - Updated usage, getting-started, and handbook docs to cover the new `add --from` workflow.
506
- - Updated `record-demo` step instructions to require diagnosis before skipping on failure — agents must check config, available fonts, and spike findings before reporting a non-blocking skip.
527
+ - Updated `record-demo` step instructions to require diagnosis before skipping on failure -- agents must check config, available fonts, and spike findings before reporting a non-blocking skip.
507
528
  - Added `decision_notes.non_blocking_policy` to `record-demo` step catalog entry clarifying that "non-blocking" means "diagnose and retry before skipping," not "skip on first failure."
508
529
 
509
530
  ### Technical
@@ -959,7 +980,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
959
980
  ## [0.20.0] - 2026-03-05
960
981
 
961
982
  ### Added
962
- - Provider-specific session detection fallback in `ForkSessionLauncher` — when a provider doesn't return a native `session_id`, scans local session storage via `SessionFinder` to detect the forked session by prompt matching.
983
+ - Provider-specific session detection fallback in `ForkSessionLauncher` -- when a provider doesn't return a native `session_id`, scans local session storage via `SessionFinder` to detect the forked session by prompt matching.
963
984
  - Session metadata file (`<root>-session.yml`) written for every fork run, capturing `session_id`, `provider`, `model`, and `completed_at` for traceability.
964
985
  - Stall error messages now include `Session: <id>` when session metadata is available, enabling direct trace to agent session.
965
986
 
@@ -1044,14 +1065,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1044
1065
  ## [0.16.1] - 2026-03-04
1045
1066
 
1046
1067
  ### Fixed
1047
- - Correct `.agents/skills` typo to `.agent/skills` in default config and `SkillAssignSourceResolver` — skill discovery now uses the canonical provider-neutral path
1068
+ - Correct `.agents/skills` typo to `.agent/skills` in default config and `SkillAssignSourceResolver` -- skill discovery now uses the canonical provider-neutral path
1048
1069
 
1049
1070
  ## [0.16.0] - 2026-03-04
1050
1071
 
1051
1072
  ### Added
1052
- - New `onboard-base` catalog step — loads base project context via `ace-bundle project-base`
1053
- - New `task-load` catalog step — loads task behavioral spec via `ace-bundle task://<taskref>`
1054
- - Taskref placeholder substitution in catalog step descriptions — `<taskref>` in step descriptions is replaced with actual task reference during child instruction building
1073
+ - New `onboard-base` catalog step -- loads base project context via `ace-bundle project-base`
1074
+ - New `task-load` catalog step -- loads task behavioral spec via `ace-bundle task://<taskref>`
1075
+ - Taskref placeholder substitution in catalog step descriptions -- `<taskref>` in step descriptions is replaced with actual task reference during child instruction building
1055
1076
 
1056
1077
  ### Changed
1057
1078
  - Default assignment presets updated to use ace-task
@@ -1065,7 +1086,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1065
1086
  ## [0.15.1] - 2026-03-01
1066
1087
 
1067
1088
  ### Added
1068
- - Fork-run crash recovery protocol in drive workflow — detection, commit partial work, progress report, inject recovery steps, re-fork pattern for partial completion scenarios
1089
+ - Fork-run crash recovery protocol in drive workflow -- detection, commit partial work, progress report, inject recovery steps, re-fork pattern for partial completion scenarios
1069
1090
 
1070
1091
  ## [0.15.0] - 2026-02-28
1071
1092
 
@@ -1222,7 +1243,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1222
1243
  ## [0.12.14] - 2026-02-22
1223
1244
 
1224
1245
  ### Added
1225
- - Subtree guard step in drive workflow — driver reviews all fork report files before continuing to next step
1246
+ - Subtree guard step in drive workflow -- driver reviews all fork report files before continuing to next step
1226
1247
  - Report review instruction in split-subtree-root step template for fork context
1227
1248
 
1228
1249
  ## [0.12.13] - 2026-02-22
@@ -1295,13 +1316,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1295
1316
  ## [0.12.1] - 2026-02-19
1296
1317
 
1297
1318
  ### Fixed
1298
- - `reflect-verify-cycle` pair changed from `sequential` to `conditional` — prevents overriding optional strength of reflect-and-refactor
1319
+ - `reflect-verify-cycle` pair changed from `sequential` to `conditional` -- prevents overriding optional strength of reflect-and-refactor
1299
1320
  - Renamed `max_recursion` to `max_reruns` in replan config for clarity
1300
1321
 
1301
1322
  ## [0.12.0] - 2026-02-19
1302
1323
 
1303
1324
  ### Added
1304
- - New `reflect-and-refactor` step in catalog — analyzes implementation against ATOM principles and executes targeted refactoring before PR creation
1325
+ - New `reflect-and-refactor` step in catalog -- analyzes implementation against ATOM principles and executes targeted refactoring before PR creation
1305
1326
  - Composition rules for reflect-and-refactor: ordering (after verify, before mark-done/release/retro), pairs (verify cycle, fix cycle, replan cycle), and conditional suggestion
1306
1327
  - `create-retro` step now consumes `findings-report` from reflect-and-refactor as recommended prerequisite
1307
1328
  - `implement-with-pr` recipe updated to include optional reflect-and-refactor step after work-on-task
@@ -1315,7 +1336,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1315
1336
 
1316
1337
  ### Fixed
1317
1338
 
1318
- - `assignment_state` now checks `completed` before `failed` — assignments where all steps are done/failed correctly report `:completed` instead of `:failed`
1339
+ - `assignment_state` now checks `completed` before `failed` -- assignments where all steps are done/failed correctly report `:completed` instead of `:failed`
1319
1340
 
1320
1341
  ### Added
1321
1342
 
@@ -1906,7 +1927,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1906
1927
 
1907
1928
  ### Fixed
1908
1929
 
1909
- - CLI `start` command crashes with positional argument (`ace-assign start job.yaml`) because `option :config` requires `--config` flag — renamed command to `create` with `argument :config`
1930
+ - CLI `start` command crashes with positional argument (`ace-assign start job.yaml`) because `option :config` requires `--config` flag -- renamed command to `create` with `argument :config`
1910
1931
  - `ace-bundle wfi://prepare-assignment` fails due to missing project-level wfi:// protocol registration
1911
1932
 
1912
1933
  ### Added
data/docs/usage.md CHANGED
@@ -107,6 +107,23 @@ ace-assign attempt reconcile --attempt ATTEMPT --receipt receipt.json
107
107
 
108
108
  `reserved -> running -> succeeded | failed | stopped | uncertain`; reconciliation may resolve `uncertain -> succeeded | failed`. Terminal attempts accept no new effects, and accepted history is append-only.
109
109
 
110
+ ### Read accepted execution authority
111
+
112
+ `attempt evidence` reads a managed coordinator-accepted receipt from immutable journal history without transitions, cache writes, or audit checkout creation. Supply the accepted attempt ID and exact canonical receipt digest. The trusted local operator or OS-enforced service verifies the actual executed outcome before accepting its receipt; worker-authored success claims cannot accept themselves. This reader consumes that attestation and does not execute a test or model.
113
+
114
+ ```sh
115
+ ace-assign attempt evidence --attempt ATTEMPT --receipt-digest SHA256 --format json \
116
+ --kind check --check-name tests
117
+ ace-assign attempt evidence --attempt ATTEMPT --receipt-digest SHA256 --format json \
118
+ --kind review-collection
119
+ ace-assign attempt evidence --attempt ATTEMPT --receipt-digest SHA256 --format json \
120
+ --kind review-collection --historical-head EXACT_RECORDED_HEAD
121
+ ace-assign attempt evidence --attempt ATTEMPT --receipt-digest SHA256 --format json \
122
+ --kind review-approval
123
+ ```
124
+
125
+ A check proof requires the live candidate head, intact accepted artifacts, and the matching executed operation: `tests` uses `test`, other explicit check names use that operation name. Review collection requires an accepted `review-collect` operation with a passed `review-execution` outcome and the retained metadata/report/prompt artifacts. Review approval requires an ordinary accepted `review` operation with the approved independent reviewer verdict, exact head and retained report artifacts; collection proof cannot substitute for approval. Both collection and approval support an exact `--historical-head`. Historical review validation exposes `historical: true` and the recorded head; it preserves accepted source authority across head drift and cannot certify live checks. Unknown receipts, changed artifacts, unaccepted/unmanaged history, wrong purposes and stale current queries fail closed. Retain the configured evidence ref alongside durable review campaigns.
126
+
110
127
  ### Evidence storage and recovery modes
111
128
 
112
129
  - Task-attached (managed) attempts journal accepted evidence to the configured evidence Git ref (default `refs/ace/execution`) through an isolated, disposable audit checkout, outside the deliverable candidate branch. Journal commits never advance or exempt the reviewed candidate.
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+ require_relative "base"
3
+ module Ace
4
+ module Assign
5
+ module CLI
6
+ module Commands
7
+ module Attempt
8
+ class Evidence < Ace::Support::Cli::Command
9
+ include Attempt::Base
10
+ desc "Read coordinator-accepted live execution or historical review authority"
11
+ option :attempt, required: true, desc: "Accepted execution attempt ID"
12
+ option :receipt_digest, required: true, desc: "Accepted receipt digest"
13
+ option :kind, default: "check", desc: "Evidence purpose: check, review-collection or review-approval"
14
+ option :check_name, default: "tests", desc: "Required check name (tests binds operation test)"
15
+ option :historical_head, desc: "Recorded collection/approval head for historical authority only"
16
+ option :format, default: "json", desc: "JSON evidence projection"
17
+
18
+ def call(**options)
19
+ raise Ace::Support::Cli::Error, "evidence supports --format json" unless options[:format] == "json"
20
+ emit_json(build_coordinator.evidence(attempt_id: options[:attempt], receipt_digest: options[:receipt_digest],
21
+ kind: options[:kind], check_name: options[:check_name], historical_head: options[:historical_head]))
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -31,6 +31,7 @@ require_relative "atoms/evidence_digest"
31
31
  require_relative "atoms/assignment_scope"
32
32
 
33
33
  # Molecules
34
+ require_relative "molecules/lifecycle_exclusion"
34
35
  require_relative "molecules/assignment_manager"
35
36
  require_relative "molecules/attempt_store"
36
37
  require_relative "molecules/assignment_discoverer"
@@ -70,6 +71,7 @@ require_relative "cli/commands/attempt/start"
70
71
  require_relative "cli/commands/attempt/status"
71
72
  require_relative "cli/commands/attempt/finish"
72
73
  require_relative "cli/commands/attempt/reconcile"
74
+ require_relative "cli/commands/attempt/evidence"
73
75
 
74
76
  module Ace
75
77
  module Assign
@@ -94,6 +96,7 @@ module Ace
94
96
  ["fork-run", "Run subtree in forked process"],
95
97
  ["attempt start", "Start a scoped attempt for an assignment step"],
96
98
  ["attempt status", "Show attempt status for an assignment"],
99
+ ["attempt evidence", "Read accepted current-head check evidence"],
97
100
  ["attempt finish", "Finish an attempt with a structured execution receipt"],
98
101
  ["attempt reconcile", "Reconcile an interrupted or uncertain attempt"]
99
102
  ].freeze
@@ -157,6 +160,7 @@ module Ace
157
160
  register "fork-session", wrap_command(Commands::ForkSession)
158
161
  register "attempt start", wrap_command(Commands::Attempt::Start)
159
162
  register "attempt status", wrap_command(Commands::Attempt::Status)
163
+ register "attempt evidence", wrap_command(Commands::Attempt::Evidence)
160
164
  register "attempt finish", wrap_command(Commands::Attempt::Finish)
161
165
  register "attempt reconcile", wrap_command(Commands::Attempt::Reconcile)
162
166
 
@@ -24,7 +24,7 @@ module Ace
24
24
  ].freeze
25
25
 
26
26
  attr_reader :attempt_id, :assignment_id, :project_id, :scope, :operation,
27
- :producer, :head, :verdict, :artifacts, :checks, :review, :recorded_at, :digest
27
+ :producer, :head, :verdict, :artifacts, :checks, :review, :campaign, :recorded_at, :digest
28
28
 
29
29
  # @param attempt_id [String] Attempt the receipt belongs to
30
30
  # @param assignment_id [String] Owning assignment ID
@@ -40,7 +40,7 @@ module Ace
40
40
  # @param recorded_at [Time] Receipt creation time
41
41
  # @param digest [String, nil] Canonical digest (computed when nil)
42
42
  def initialize(attempt_id:, assignment_id:, project_id:, scope:, operation:, producer:, head:, verdict:,
43
- artifacts: [], checks: [], review: nil, recorded_at:, digest: nil)
43
+ artifacts: [], checks: [], review: nil, campaign: nil, recorded_at:, digest: nil)
44
44
  @attempt_id = attempt_id
45
45
  @assignment_id = assignment_id
46
46
  @project_id = project_id
@@ -52,6 +52,7 @@ module Ace
52
52
  @artifacts = artifacts
53
53
  @checks = checks
54
54
  @review = review
55
+ @campaign = campaign
55
56
  @recorded_at = recorded_at
56
57
  @digest = digest || Atoms::EvidenceDigest.digest(digest_payload)
57
58
  end
@@ -61,7 +62,7 @@ module Ace
61
62
  #
62
63
  # @return [Hash] Digest payload
63
64
  def digest_payload
64
- {
65
+ payload = {
65
66
  "attempt_id" => attempt_id,
66
67
  "assignment_id" => assignment_id,
67
68
  "project_id" => project_id,
@@ -74,6 +75,8 @@ module Ace
74
75
  "checks" => checks,
75
76
  "review" => review
76
77
  }
78
+ payload["campaign"] = campaign if campaign
79
+ payload
77
80
  end
78
81
 
79
82
  # Convert to a hash for serialization.
@@ -102,6 +105,7 @@ module Ace
102
105
  artifacts: data["artifacts"] || [],
103
106
  checks: data["checks"] || [],
104
107
  review: data["review"],
108
+ campaign: data["campaign"],
105
109
  recorded_at: parse_time(data["recorded_at"]) || Time.now.utc,
106
110
  digest: data["digest"]
107
111
  )
@@ -97,11 +97,15 @@ module Ace
97
97
  value = ref_value
98
98
  return [] if value.nil?
99
99
 
100
- with_lock do
101
- ensure_checkout!
102
- sync_checkout(value)
103
- order_by_chain(event_files(assignment_id).map { |path| JSON.parse(File.read(path)) })
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)
104
107
  end
108
+ order_by_chain(events)
105
109
  rescue JSON::ParserError => e
106
110
  raise AttemptErrors::EvidenceUnavailable, "Corrupt journal event for #{assignment_id}: #{e.message}"
107
111
  end
@@ -155,14 +159,9 @@ module Ace
155
159
  value = ref_value
156
160
  return [] if value.nil?
157
161
 
158
- with_lock do
159
- ensure_checkout!
160
- sync_checkout(value)
161
- Dir.glob(File.join(checkout_dir, "execution", "*"))
162
- .select { |path| File.directory?(path) }
163
- .map { |path| File.basename(path) }
164
- .sort
165
- end
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
166
165
  end
167
166
 
168
167
  private
@@ -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?
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "open3"
7
+ require "time"
8
+
9
+ module Ace
10
+ module Assign
11
+ module Molecules
12
+ # Durable start/prune exclusion for assignment and worktree identities.
13
+ #
14
+ # Lock files live OUTSIDE every deletion target — under the shared Git
15
+ # common dir — so they survive removal of the assignment cache, the
16
+ # worktree, or a recreated lock pathname. Prune holds the exclusive
17
+ # side from its final evidence reads through completion of removal;
18
+ # every supported start path (attempt registration, fork/driver
19
+ # session launch, worktree provisioning) holds the shared side while
20
+ # registering a writer.
21
+ #
22
+ # A `removed` marker recorded by prune makes post-deletion starts fail
23
+ # closed instead of recreating the deleted identity: acquiring the
24
+ # shared side raises {AttemptErrors::Conflict} when the marker is
25
+ # present. Legitimate fresh provisioning (a new worktree for a task)
26
+ # clears the marker explicitly via `reset_removed`.
27
+ class LifecycleExclusion
28
+ # @param repo_root [String, nil] Repository used to resolve the
29
+ # shared Git common dir (default: project root)
30
+ # @param root [String, nil] Explicit exclusion root (tests)
31
+ def initialize(repo_root: nil, root: nil)
32
+ @root = root || self.class.default_root(repo_root)
33
+ end
34
+
35
+ # The exclusion root shared by every writer and prune for this repo.
36
+ # Sandboxed environments (CACHE_BASE) scope it under the cache base,
37
+ # which itself is never a deletion target; otherwise it lives under
38
+ # the shared Git common dir, which survives worktree removal.
39
+ def self.default_root(_repo_root = nil)
40
+ cache_base = ENV["CACHE_BASE"]
41
+ if cache_base && !cache_base.empty?
42
+ return File.join(File.expand_path(cache_base), ".exclusion")
43
+ end
44
+
45
+ repo_root = Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
46
+ common_dir, _stderr, status = Open3.capture3(
47
+ "git", "-C", repo_root, "rev-parse", "--path-format=absolute", "--git-common-dir"
48
+ )
49
+ base = status.success? && !common_dir.to_s.strip.empty? ? common_dir.strip : File.join(repo_root, ".git")
50
+ File.join(base, "ace", "lifecycle-exclusion")
51
+ end
52
+
53
+ def assignment_key(assignment_id)
54
+ "assignment:#{assignment_id}"
55
+ end
56
+
57
+ # Task identity is the canonical worktree identity: work-on derives
58
+ # the worktree path from the task ref, and prune candidates carry
59
+ # the same task id.
60
+ def task_key(task_id)
61
+ "task:#{task_id}"
62
+ end
63
+
64
+ def worktree_key(worktree_path)
65
+ canonical = begin
66
+ File.realpath(worktree_path.to_s)
67
+ rescue Errno::ENOENT, Errno::EACCES
68
+ File.expand_path(worktree_path.to_s)
69
+ end
70
+ "worktree:#{canonical}"
71
+ end
72
+
73
+ # Hold the exclusive side while recomputing proofs and removing the
74
+ # target. Start paths block for the whole block.
75
+ #
76
+ # @param key [String] Identity key
77
+ # @yield Required block executed under exclusive exclusion
78
+ def with_exclusive(key)
79
+ raise ArgumentError, "exclusion key is required" if key.to_s.empty?
80
+
81
+ lock = open_lock(key)
82
+ lock.flock(File::LOCK_EX)
83
+ begin
84
+ yield
85
+ ensure
86
+ lock.flock(File::LOCK_UN)
87
+ lock.close
88
+ end
89
+ end
90
+
91
+ # Hold the shared side while registering a writer or creating a
92
+ # resource. Fails closed when the identity was pruned.
93
+ #
94
+ # @param key [String] Identity key
95
+ # @param reset_removed [Boolean] Clear a stale removed marker instead
96
+ # of raising (legitimate fresh provisioning of the same pathname)
97
+ def with_shared(key, reset_removed: false)
98
+ raise ArgumentError, "exclusion key is required" if key.to_s.empty?
99
+
100
+ lock = open_lock(key)
101
+ lock.flock(File::LOCK_SH)
102
+ begin
103
+ if removed?(key)
104
+ unless reset_removed
105
+ raise AttemptErrors::Conflict, removed_message(key)
106
+ end
107
+
108
+ clear_removed!(key)
109
+ end
110
+ yield
111
+ ensure
112
+ lock.flock(File::LOCK_UN)
113
+ lock.close
114
+ end
115
+ end
116
+
117
+ # Hold the shared side on several identities at once (acquired in the
118
+ # given order, released in reverse — callers must use one consistent
119
+ # global order, task before assignment, to avoid deadlock). Fails
120
+ # closed when any identity was pruned.
121
+ def with_shared_multi(keys, reset_removed: false)
122
+ ordered = Array(keys).compact.reject { |key| key.to_s.empty? }.uniq
123
+ raise ArgumentError, "exclusion keys are required" if ordered.empty?
124
+
125
+ locks = ordered.map { |key| open_lock(key) }
126
+ begin
127
+ locks.each { |lock| lock.flock(File::LOCK_SH) }
128
+ ordered.each do |key|
129
+ if removed?(key)
130
+ unless reset_removed
131
+ raise AttemptErrors::Conflict, removed_message(key)
132
+ end
133
+
134
+ clear_removed!(key)
135
+ end
136
+ end
137
+ yield
138
+ ensure
139
+ locks.reverse_each do |lock|
140
+ lock.flock(File::LOCK_UN)
141
+ lock.close
142
+ end
143
+ end
144
+ end
145
+
146
+ # Record that the identity was removed. Call while holding the
147
+ # exclusive side, BEFORE deleting the target.
148
+ def record_removed!(key)
149
+ atomic_write(
150
+ state_path(key),
151
+ JSON.generate({"removed" => true, "removed_at" => Time.now.utc.iso8601, "key" => key})
152
+ )
153
+ end
154
+
155
+ def removed?(key)
156
+ state = JSON.parse(File.read(state_path(key)))
157
+ state["removed"] == true
158
+ rescue Errno::ENOENT, JSON::ParserError, TypeError
159
+ false
160
+ end
161
+
162
+ def clear_removed!(key)
163
+ File.delete(state_path(key))
164
+ rescue Errno::ENOENT
165
+ nil
166
+ end
167
+
168
+ private
169
+
170
+ attr_reader :root
171
+
172
+ def open_lock(key)
173
+ FileUtils.mkdir_p(root)
174
+ File.open(lock_path(key), File::RDWR | File::CREAT)
175
+ end
176
+
177
+ def lock_path(key)
178
+ File.join(root, "#{Digest::SHA256.hexdigest(key)}.lock")
179
+ end
180
+
181
+ def state_path(key)
182
+ File.join(root, "#{Digest::SHA256.hexdigest(key)}.state.json")
183
+ end
184
+
185
+ def removed_message(key)
186
+ identity = key.to_s.sub(/\A[^:]+:/, "")
187
+ case key
188
+ when /\Aassignment:/
189
+ "Assignment #{identity} was pruned; refusing to start a writer on a removed identity"
190
+ when /\Atask:/
191
+ "Task #{identity} worktree was pruned; refusing to start a writer on a removed identity"
192
+ else
193
+ "Worktree #{identity} was pruned; refusing to start a writer on a removed identity"
194
+ end
195
+ end
196
+
197
+ def atomic_write(path, content)
198
+ dir = File.dirname(path)
199
+ FileUtils.mkdir_p(dir)
200
+ temp = File.join(dir, ".tmp-#{Process.pid}-#{rand(1_000_000)}")
201
+ File.write(temp, content)
202
+ File.rename(temp, path)
203
+ ensure
204
+ File.delete(temp) if temp && File.exist?(temp)
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "digest"
4
+ require "json"
4
5
 
5
6
  module Ace
6
7
  module Assign
@@ -55,12 +56,21 @@ module Ace
55
56
  verify_artifacts(data, repo_root) if data["verdict"] == "succeeded"
56
57
  verify_checks(data)
57
58
  verify_review(data)
59
+ verify_campaign(data, repo_root: repo_root, live_head: live_head)
58
60
 
59
61
  receipt = Models::ExecutionReceipt.from_h(data.merge("recorded_at" => Time.now.utc))
60
62
  verify_digest(receipt)
61
63
 
62
64
  receipt
63
65
  end
66
+ # Recheck the source artifacts/checks of an already accepted receipt.
67
+ def verify_accepted_evidence!(data, live_head:, repo_root:)
68
+ verify_head(data, live_head)
69
+ verify_artifacts(data, repo_root)
70
+ verify_checks(data)
71
+ verify_review(data)
72
+ end
73
+
64
74
  # @param operation [String] Receipt operation
65
75
  # @return [Boolean] True when the operation acts outside the repository
66
76
  def external_effect?(operation)
@@ -196,6 +206,41 @@ module Ace
196
206
  end
197
207
  end
198
208
 
209
+ # Consume ace-review's single campaign authority through the existing
210
+ # receipt boundary; a worker-supplied local result grants no authority.
211
+ def verify_campaign(data, repo_root:, live_head:)
212
+ campaign = data["campaign"]
213
+ return unless campaign
214
+ reject_unless(campaign.is_a?(Hash), "campaign must be an object")
215
+ reject("campaign result requires a succeeded review receipt") unless
216
+ data["operation"] == REVIEW_OPERATION && data["verdict"] == "succeeded"
217
+ reference = campaign["result"]
218
+ reject_unless(reference.is_a?(Hash), "campaign requires a result artifact reference")
219
+ unless Array(data["artifacts"]).include?(reference)
220
+ reject("campaign result must be included in verified receipt artifacts")
221
+ end
222
+ resolved = safe_resolve(repo_root, reference["path"].to_s)
223
+ reject("campaign result path escapes repository or is missing") unless resolved
224
+ result = JSON.parse(File.read(resolved))
225
+ reject_unless(result.is_a?(Hash), "campaign result must be an object")
226
+ require "ace/review"
227
+ current = Ace::Review::Organisms::CampaignManager.new(repo_root: repo_root).status(campaign["id"])
228
+ unless result["campaign_id"] == campaign["id"] && result["accepted"] == true &&
229
+ result["dry_run"] == false && current["accepted"] == true &&
230
+ result["result_identity"] == current["result_identity"] &&
231
+ result.dig("evidence", "current_head") == live_head && current.dig("evidence", "current_head") == live_head
232
+ reject("campaign result is stale, incomplete, blocked or does not match live authority")
233
+ end
234
+ approval = current["rounds"].last["approval"]
235
+ unless approval["producer"] == data.dig("producer", "actor") &&
236
+ approval["reviewer"] == data.dig("review", "reviewer", "actor") &&
237
+ approval["head"] == data["head"]
238
+ reject("campaign producer/reviewer attribution does not match independently validated receipt")
239
+ end
240
+ rescue JSON::ParserError, ArgumentError, TypeError, KeyError => e
241
+ reject("invalid campaign result: #{e.message}")
242
+ end
243
+
199
244
  # Resolve a project-relative artifact path to its real location,
200
245
  # rejecting traversal and symlink escapes (an in-repo symlink must
201
246
  # not smuggle in an external file); returns nil when the path leaves
@@ -114,7 +114,9 @@ module Ace
114
114
  updated_at: assignment.updated_at,
115
115
  source_config: archived_path,
116
116
  cache_dir: assignment.cache_dir,
117
- parent: assignment.parent
117
+ parent: assignment.parent,
118
+ task_id: assignment.task_id,
119
+ project_id: assignment.project_id
118
120
  )
119
121
  assignment_manager.update(assignment)
120
122
 
@@ -26,13 +26,15 @@ module Ace
26
26
  # @param journal [Molecules::EvidenceJournal, nil] Evidence journal (default built per repo)
27
27
  # @param identity_resolver [Molecules::ExecutionIdentityResolver, nil] Trust boundary
28
28
  # @param verifier [Molecules::ReceiptVerifier, nil] Receipt verifier
29
- def initialize(cache_base: nil, repo_root: nil, journal: nil, identity_resolver: nil, verifier: nil)
29
+ def initialize(cache_base: nil, repo_root: nil, journal: nil, identity_resolver: nil, verifier: nil,
30
+ lifecycle_exclusion: nil)
30
31
  @manager = Molecules::AssignmentManager.new(cache_base: cache_base)
31
32
  @store = @manager.attempt_store
32
33
  @repo_root = repo_root || Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
33
34
  @journal = journal
34
35
  @identity_resolver = identity_resolver || Molecules::ExecutionIdentityResolver.new
35
36
  @verifier = verifier || Molecules::ReceiptVerifier.new(identity_resolver: @identity_resolver)
37
+ @lifecycle_exclusion = lifecycle_exclusion
36
38
  end
37
39
 
38
40
  attr_reader :store
@@ -62,49 +64,61 @@ module Ace
62
64
  identity ||= @identity_resolver.resolve
63
65
  base_head = candidate_head!
64
66
 
65
- @store.with_lock(assignment_id) do
66
- journal_actives = assignment.managed? ? active_journal_attempts(assignment) : []
67
-
68
- existing = @store.active(assignment_id, scope) ||
69
- journal_actives.find { |attempt| Atoms::AssignmentScope.equal?(attempt.binding.scope, scope) }
70
- if existing
71
- if existing.binding.project_id == project && existing.binding.task_id == assignment.task_id &&
72
- existing.binding.actor == identity.actor && existing.binding.role == identity.role
73
- return existing
67
+ # Serialize writer registration against prune: prune holds the
68
+ # exclusive exclusion from its final evidence reads through removal.
69
+ # A worktree-backed task identity shares the same protocol (task
70
+ # key first, then assignment key — the one consistent global
71
+ # order), so a worktree prune and an attempt start cannot interleave.
72
+ start_keys = []
73
+ unless assignment.task_id.to_s.strip.empty?
74
+ start_keys << lifecycle_exclusion.task_key(assignment.task_id)
75
+ end
76
+ start_keys << lifecycle_exclusion.assignment_key(assignment_id)
77
+ lifecycle_exclusion.with_shared_multi(start_keys) do
78
+ @store.with_lock(assignment_id) do
79
+ journal_actives = assignment.managed? ? active_journal_attempts(assignment) : []
80
+
81
+ existing = @store.active(assignment_id, scope) ||
82
+ journal_actives.find { |attempt| Atoms::AssignmentScope.equal?(attempt.binding.scope, scope) }
83
+ if existing
84
+ if existing.binding.project_id == project && existing.binding.task_id == assignment.task_id &&
85
+ existing.binding.actor == identity.actor && existing.binding.role == identity.role
86
+ return existing
87
+ end
88
+
89
+ raise AttemptErrors::Conflict,
90
+ "Active attempt #{existing.attempt_id} already owns #{assignment_id}@#{scope} " \
91
+ "(project #{existing.binding.project_id}); refusing to launch a second writer"
74
92
  end
75
93
 
76
- raise AttemptErrors::Conflict,
77
- "Active attempt #{existing.attempt_id} already owns #{assignment_id}@#{scope} " \
78
- "(project #{existing.binding.project_id}); refusing to launch a second writer"
79
- end
94
+ active_scopes = @store.list(assignment_id).select(&:active?).map(&:binding)
95
+ active_scopes.concat(journal_actives.map(&:binding))
96
+ blocker = active_scopes.find { |binding| scopes_overlap?(binding.scope, scope) }
97
+ if blocker
98
+ raise AttemptErrors::Conflict,
99
+ "Active attempt scope #{blocker.scope} overlaps requested scope #{scope} on #{assignment_id}; " \
100
+ "refusing overlapping writers"
101
+ end
80
102
 
81
- active_scopes = @store.list(assignment_id).select(&:active?).map(&:binding)
82
- active_scopes.concat(journal_actives.map(&:binding))
83
- blocker = active_scopes.find { |binding| scopes_overlap?(binding.scope, scope) }
84
- if blocker
85
- raise AttemptErrors::Conflict,
86
- "Active attempt scope #{blocker.scope} overlaps requested scope #{scope} on #{assignment_id}; " \
87
- "refusing overlapping writers"
88
- end
103
+ attempt_id = unique_attempt_id(assignment_id, journal_actives.map(&:attempt_id))
104
+ events = start_events(assignment_id, assignment, scope, project, identity, base_head, attempt_id)
105
+ attempt = reserve(assignment, scope, project, identity, base_head, attempt_id)
106
+ if attempt.managed?
107
+ commit = journal_for.append(
108
+ assignment_id: assignment_id,
109
+ attempt_id: attempt.attempt_id,
110
+ events: events
111
+ )
112
+ attempt = attempt.with(journal_commit: commit)
113
+ attempt = yield_lost_ownership_race(attempt, assignment_id, scope)
114
+ else
115
+ attempt = attempt.with(events: events)
116
+ end
89
117
 
90
- attempt_id = unique_attempt_id(assignment_id, journal_actives.map(&:attempt_id))
91
- events = start_events(assignment_id, assignment, scope, project, identity, base_head, attempt_id)
92
- attempt = reserve(assignment, scope, project, identity, base_head, attempt_id)
93
- if attempt.managed?
94
- commit = journal_for.append(
95
- assignment_id: assignment_id,
96
- attempt_id: attempt.attempt_id,
97
- events: events
98
- )
99
- attempt = attempt.with(journal_commit: commit)
100
- attempt = yield_lost_ownership_race(attempt, assignment_id, scope)
101
- else
102
- attempt = attempt.with(events: events)
118
+ @store.save(attempt)
119
+ @store.claim(assignment_id, scope, attempt)
120
+ attempt
103
121
  end
104
-
105
- @store.save(attempt)
106
- @store.claim(assignment_id, scope, attempt)
107
- attempt
108
122
  end
109
123
  end
110
124
 
@@ -165,6 +179,60 @@ module Ace
165
179
  attempt.projection
166
180
  end
167
181
 
182
+ # Read accepted check evidence without transitions, locks, cache writes
183
+ # or audit checkout creation. Managed history comes from the Git ref.
184
+ def evidence(attempt_id:, receipt_digest:, kind: "check", check_name: "tests", historical_head: nil)
185
+ unless %w[check review-collection review-approval].include?(kind) && check_name.is_a?(String) && !check_name.strip.empty?
186
+ raise AttemptErrors::ReceiptRejected, "Invalid evidence kind or check name"
187
+ end
188
+ if !historical_head.nil? && (!%w[review-collection review-approval].include?(kind) || !historical_head.is_a?(String) ||
189
+ !historical_head.match?(/\A[0-9a-f]{40,64}\z/))
190
+ raise AttemptErrors::ReceiptRejected, "Historical evidence requires review collection/approval and an exact recorded head"
191
+ end
192
+ unless attempt_id.to_s.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/) &&
193
+ receipt_digest.to_s.match?(/\A[0-9a-f]{64}\z/)
194
+ raise AttemptErrors::ReceiptRejected, "Invalid attempt ID or receipt digest"
195
+ end
196
+ attempt = @store.find(attempt_id) || recover_managed_attempt(nil, attempt_id)
197
+ raise AttemptErrors::NotFound, "Attempt #{attempt_id} not found" unless attempt
198
+ raise AttemptErrors::ReceiptRejected, "Execution evidence requires managed immutable journal history" unless attempt.managed?
199
+ attempt = recover_managed_attempt(attempt.binding.assignment_id, attempt_id)
200
+ raise AttemptErrors::ReceiptRejected, "No succeeded accepted attempt" unless attempt&.state == "succeeded"
201
+ accepted = journal_for.accepted_receipts(attempt.binding.assignment_id)
202
+ data = accepted.find { |r| r["attempt_id"] == attempt_id && r["digest"] == receipt_digest }
203
+ raise AttemptErrors::ReceiptRejected, "Receipt was not accepted by coordinator" unless data
204
+ if kind == "check" && (%w[review review-collect].include?(data["operation"]) || @verifier.external_effect?(data["operation"]))
205
+ raise AttemptErrors::ReceiptRejected, "Review or external-effect operations cannot be check evidence"
206
+ end
207
+ expected_operation = case kind
208
+ when "review-collection" then "review-collect"
209
+ when "review-approval" then "review"
210
+ else check_name == "tests" ? "test" : check_name
211
+ end
212
+ unless data["campaign"].nil? && data["operation"] == expected_operation
213
+ raise AttemptErrors::ReceiptRejected, "Accepted receipt operation does not prove #{kind}: #{expected_operation}"
214
+ end
215
+ required_check = kind == "review-collection" ? "review-execution" : check_name
216
+ unless kind == "review-approval" || Array(data["checks"]).any? { |check| check["name"] == required_check && check["verdict"] == "passed" }
217
+ raise AttemptErrors::ReceiptRejected, "Accepted receipt does not prove the required check #{required_check}"
218
+ end
219
+ receipt = Models::ExecutionReceipt.from_h(data)
220
+ unless receipt.digest == Atoms::EvidenceDigest.digest(receipt.digest_payload)
221
+ raise AttemptErrors::ReceiptRejected, "Accepted receipt digest is corrupt"
222
+ end
223
+ evidence_head = historical_head || candidate_head!
224
+ unless data["verdict"] == "succeeded" && data["head"] == evidence_head && attempt.candidate_head == evidence_head
225
+ raise AttemptErrors::ReceiptRejected, "Accepted execution evidence is stale or unsuccessful"
226
+ end
227
+ @verifier.verify_accepted_evidence!(data, live_head: evidence_head, repo_root: @repo_root)
228
+ {"attempt_id" => attempt_id, "receipt_digest" => receipt_digest, "head" => evidence_head, "kind" => kind,
229
+ "historical" => !historical_head.nil?,
230
+ "operation" => data["operation"], "checks" => data["checks"], "producer" => data["producer"], "review" => data["review"],
231
+ "artifacts" => data["artifacts"], "assignment_id" => data["assignment_id"],
232
+ "project_id" => data["project_id"], "scope" => data["scope"],
233
+ "evidence_git_ref" => attempt.binding.evidence_git_ref, "journal_commit" => attempt.journal_commit}
234
+ end
235
+
168
236
  # Reconcile an interrupted attempt.
169
237
  #
170
238
  # Running attempts are classified conservatively (stopped before
@@ -531,6 +599,12 @@ module Ace
531
599
  @journal
532
600
  end
533
601
 
602
+ # Prune/writer exclusion shared with overseer prune (durable, outside
603
+ # every deletion target).
604
+ def lifecycle_exclusion
605
+ @lifecycle_exclusion ||= Molecules::LifecycleExclusion.new(repo_root: @repo_root)
606
+ end
607
+
534
608
  def candidate_head!
535
609
  out, status = Open3.capture2("git", "rev-parse", "HEAD", chdir: @repo_root, stdin_data: "")
536
610
  head = out.to_s.strip
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Assign
5
- VERSION = "0.58.0"
5
+ VERSION = "0.60.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-assign
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.58.0
4
+ version: 0.60.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-29 00:00:00.000000000 Z
10
+ date: 2026-10-01 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli
@@ -107,6 +107,20 @@ dependencies:
107
107
  - - "~>"
108
108
  - !ruby/object:Gem::Version
109
109
  version: '0.39'
110
+ - !ruby/object:Gem::Dependency
111
+ name: ace-review
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '0.57'
117
+ type: :runtime
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '0.57'
110
124
  - !ruby/object:Gem::Dependency
111
125
  name: ace-task
112
126
  requirement: !ruby/object:Gem::Requirement
@@ -294,6 +308,7 @@ files:
294
308
  - lib/ace/assign/cli/commands/add.rb
295
309
  - lib/ace/assign/cli/commands/assignment_target.rb
296
310
  - lib/ace/assign/cli/commands/attempt/base.rb
311
+ - lib/ace/assign/cli/commands/attempt/evidence.rb
297
312
  - lib/ace/assign/cli/commands/attempt/finish.rb
298
313
  - lib/ace/assign/cli/commands/attempt/reconcile.rb
299
314
  - lib/ace/assign/cli/commands/attempt/start.rb
@@ -325,6 +340,7 @@ files:
325
340
  - lib/ace/assign/molecules/evidence_journal.rb
326
341
  - lib/ace/assign/molecules/execution_identity_resolver.rb
327
342
  - lib/ace/assign/molecules/fork_session_launcher.rb
343
+ - lib/ace/assign/molecules/lifecycle_exclusion.rb
328
344
  - lib/ace/assign/molecules/preset_inferrer.rb
329
345
  - lib/ace/assign/molecules/queue_scanner.rb
330
346
  - lib/ace/assign/molecules/receipt_verifier.rb