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 +4 -4
- data/CHANGELOG.md +33 -12
- data/docs/usage.md +17 -0
- data/lib/ace/assign/cli/commands/attempt/evidence.rb +28 -0
- data/lib/ace/assign/cli.rb +4 -0
- data/lib/ace/assign/models/execution_receipt.rb +7 -3
- data/lib/ace/assign/molecules/evidence_journal.rb +11 -12
- data/lib/ace/assign/molecules/fork_session_launcher.rb +38 -19
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +209 -0
- data/lib/ace/assign/molecules/receipt_verifier.rb +45 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +3 -1
- data/lib/ace/assign/organisms/attempt_coordinator.rb +113 -39
- data/lib/ace/assign/version.rb +1 -1
- metadata +18 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3a8f387dd91c6da87ca5eb3712cb5fb71051a0326dfa13cf0d9d91e054c1cc00
|
|
4
|
+
data.tar.gz: 4698ad04a950a1401caec3387ef085e8c66512a07e9dd5af168446d80e7d89eb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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`
|
|
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`
|
|
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
|
|
1053
|
-
- New `task-load` catalog step
|
|
1054
|
-
- Taskref placeholder substitution in catalog step descriptions
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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`
|
|
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
|
|
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
|
data/lib/ace/assign/cli.rb
CHANGED
|
@@ -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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
data/lib/ace/assign/version.rb
CHANGED
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.
|
|
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-
|
|
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
|