ace-assign 0.57.1 → 0.58.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/.ace-defaults/assign/config.yml +13 -0
- data/CHANGELOG.md +13 -0
- data/docs/exit-codes.md +10 -0
- data/docs/usage.md +32 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +64 -8
- data/lib/ace/assign/atoms/assignment_scope.rb +53 -0
- data/lib/ace/assign/atoms/attempt_state_machine.rb +76 -0
- data/lib/ace/assign/atoms/evidence_digest.rb +78 -0
- data/lib/ace/assign/cli/commands/attempt/base.rb +35 -0
- data/lib/ace/assign/cli/commands/attempt/finish.rb +35 -0
- data/lib/ace/assign/cli/commands/attempt/reconcile.rb +38 -0
- data/lib/ace/assign/cli/commands/attempt/start.rb +45 -0
- data/lib/ace/assign/cli/commands/attempt/status.rb +54 -0
- data/lib/ace/assign/cli/commands/status.rb +24 -1
- data/lib/ace/assign/cli.rb +30 -2
- data/lib/ace/assign/models/assignment.rb +21 -4
- data/lib/ace/assign/models/attempt.rb +178 -0
- data/lib/ace/assign/models/attempt_binding.rb +116 -0
- data/lib/ace/assign/models/evidence_event.rb +82 -0
- data/lib/ace/assign/models/execution_receipt.rb +135 -0
- data/lib/ace/assign/molecules/assignment_manager.rb +35 -3
- data/lib/ace/assign/molecules/attempt_reconciler.rb +88 -0
- data/lib/ace/assign/molecules/attempt_store.rb +201 -0
- data/lib/ace/assign/molecules/evidence_calculator.rb +144 -91
- data/lib/ace/assign/molecules/evidence_journal.rb +424 -0
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +97 -0
- data/lib/ace/assign/molecules/receipt_verifier.rb +227 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +9 -3
- data/lib/ace/assign/organisms/attempt_coordinator.rb +544 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +1 -1
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +56 -0
- metadata +20 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c66fd1fc72e1bb186537931c960477dc7f9405ce3bc0f41c195a4b6601aa9bd1
|
|
4
|
+
data.tar.gz: fef52c4c68bbb1435499d7cb0468d09919b5b330d9d7226b4c676c54c12ff9ad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8e31f4379b900c7906be0b15b92768c904d57bf3fcfccbaf284ca972168413be443907281e1f1b939dff66337daf2835f32fc27c27fc802738dbe19238f220c4
|
|
7
|
+
data.tar.gz: f77a7e04992fddf9b8ddf99e57243759e1ec8ccbd7502fbae9836e32bd749a60b5feb7d7c4a63017f92b384efdce49654547b7ecb1d18fb29c66cab644dad18a
|
|
@@ -28,6 +28,19 @@ execution:
|
|
|
28
28
|
timeout: 1800
|
|
29
29
|
launch_mode: auto # auto | headless | tmux
|
|
30
30
|
|
|
31
|
+
# Attempt evidence persistence (managed attempts)
|
|
32
|
+
attempt:
|
|
33
|
+
# Dedicated Git ref holding accepted execution evidence, outside the
|
|
34
|
+
# deliverable candidate branch; journal paths are execution/<assignment-id>/
|
|
35
|
+
evidence_git_ref: "refs/ace/execution"
|
|
36
|
+
# Disposable isolated audit checkout root (relative to project root)
|
|
37
|
+
evidence_checkout_root: ".ace-local/assign/evidence-checkout"
|
|
38
|
+
# Trusted execution-boundary identity adapter: local (interactive
|
|
39
|
+
# coordinator) or service (identity supplied by a trusted service executor
|
|
40
|
+
# via service_identity_env). Unknown identity fails closed.
|
|
41
|
+
identity_adapter: "local"
|
|
42
|
+
service_identity_env: "ACE_ASSIGN_SERVICE_IDENTITY"
|
|
43
|
+
|
|
31
44
|
# Subtree execution behavior
|
|
32
45
|
subtree:
|
|
33
46
|
pre_commit_review: true
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.58.0] - 2026-09-28
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Durable assignment attempts: `ace-assign attempt start|status|finish|reconcile` bind an immutable attempt ID to an assignment step/subtree, project, boundary-derived actor identity, and `base_head`, with a `reserved -> running -> succeeded/failed/stopped/uncertain` lifecycle and conservative reconciliation of interruptions (no automatic replay of merge/publish/deploy).
|
|
14
|
+
- Managed attempts journal accepted execution evidence to the configured evidence Git ref (default `refs/ace/execution`) through an isolated audit checkout with flock serialization and expected-old-value `git update-ref` compare-and-swap; `refs/ace/execution` never advances the deliverable candidate branch.
|
|
15
|
+
- Structured execution receipts: verifiable artifact digests, executed checks, executed independent reviewer verdicts for the exact current head, and rejection of credentials/terminal output in accepted evidence.
|
|
16
|
+
- `ace-assign status` (JSON) now includes the active attempt, `base_head`, `candidate_head`, `evidence_git_ref`, `journal_commit`, and unresolved effects.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- BREAKING: delivery evidence is derived exclusively from accepted, current-head journal receipts. The previous interpretation that a current `.ace-local/review`/`.ace-local/release` report plus hardcoded `terminal` feedback authorizes merge is removed; `merge_decision` is now `authorized` or `approval-required` (never `report-only`). Existing `EvidenceCalculator` consumers must read evidence from attempts.
|
|
20
|
+
- BREAKING: taskless assignments persist attempts locally (`recovery_mode: local_only`), never claim Git-backed recovery, and cannot record external effects until attached to a source task.
|
|
21
|
+
- New `attempt` error family exits with code 5 (conflict, rejected receipt, unauthorized identity, unavailable evidence, illegal attempt state).
|
|
22
|
+
|
|
10
23
|
## [0.57.1] - 2026-09-27
|
|
11
24
|
|
|
12
25
|
### Technical
|
data/docs/exit-codes.md
CHANGED
|
@@ -20,6 +20,7 @@ This document describes the exit codes returned by the `ace-assign` CLI.
|
|
|
20
20
|
| 2 | Assignment error | No active assignment or assignment not found |
|
|
21
21
|
| 3 | Configuration not found | Config file does not exist |
|
|
22
22
|
| 4 | Step not found | Referencing a step that does not exist |
|
|
23
|
+
| 5 | Attempt error | Attempt conflict, rejected receipt, unauthorized identity, unavailable evidence, or illegal attempt state |
|
|
23
24
|
|
|
24
25
|
## Exit Code Details
|
|
25
26
|
|
|
@@ -55,6 +56,15 @@ The requested step reference does not exist in the target assignment:
|
|
|
55
56
|
- `retry` with unknown step number
|
|
56
57
|
- Commands that target a specific missing step
|
|
57
58
|
|
|
59
|
+
### Exit Code 5: Attempt Error
|
|
60
|
+
An attempt, receipt, identity, or evidence problem rejected the operation:
|
|
61
|
+
- `attempt start` with a conflicting binding for an owned subtree
|
|
62
|
+
- `attempt finish`/`attempt reconcile` with a receipt that fails verification (wrong binding, stale head, digest mismatch, self-approval, forbidden fields)
|
|
63
|
+
- Worker-identity attempts to accept a succeeded verdict
|
|
64
|
+
- Unknown or missing execution-boundary identity
|
|
65
|
+
- Reconciling a terminal attempt, or resolving uncertainty without a receipt
|
|
66
|
+
- Unavailable or unwritable evidence storage blocking a managed effect
|
|
67
|
+
|
|
58
68
|
## See Also
|
|
59
69
|
|
|
60
70
|
- [Usage Guide](usage.md) for full command reference
|
data/docs/usage.md
CHANGED
|
@@ -85,6 +85,38 @@ ace-assign add --step review-pr --after 100 --child
|
|
|
85
85
|
ace-assign add --yaml .ace-local/assign/jobs/add-task.yml --after 010 --child
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
+
## Attempt Evidence
|
|
89
|
+
|
|
90
|
+
Execution evidence lives in assignment attempts, not in reports. An attempt binds an immutable ID to an assignment, step/subtree scope, project, boundary-derived actor/role/runtime, source task, and `base_head`; the deliverable `candidate_head` is pinned only when accepted evidence is submitted, and the evidence journal commit is tracked separately from both.
|
|
91
|
+
|
|
92
|
+
### Attempt lifecycle
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
ace-assign attempt start --assignment ASSIGNMENT --step STEP --project PROJECT
|
|
96
|
+
ace-assign attempt status --assignment ASSIGNMENT --format json
|
|
97
|
+
ace-assign attempt finish --attempt ATTEMPT --receipt receipt.json
|
|
98
|
+
ace-assign attempt reconcile --attempt ATTEMPT --receipt receipt.json
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
- `attempt start` returns a JSON projection of the attempt (ID, state, binding facts, evidence references, digests). A repeated identical start returns the same attempt; a conflicting start exits 5 without launching a second writer.
|
|
102
|
+
- `attempt status` exposes only state, binding, references, and digests -- never credentials or receipt bytes.
|
|
103
|
+
- `attempt finish` accepts a structured receipt: operation, attributed producer, exact tested/reviewed head, verdict, artifact paths with SHA-256 digests, executed checks, and -- for review operations -- an executed independent reviewer verdict for that head.
|
|
104
|
+
- `attempt reconcile` classifies interrupted attempts (`stopped` before process start, `uncertain` when an effect cannot be proven either way, still `running` only for verifiably live processes) and resolves uncertainty only against a verified receipt attributed to the recorded execution boundary. Merge, publish, and deploy effects are never replayed automatically.
|
|
105
|
+
|
|
106
|
+
### State machine
|
|
107
|
+
|
|
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
|
+
|
|
110
|
+
### Evidence storage and recovery modes
|
|
111
|
+
|
|
112
|
+
- 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.
|
|
113
|
+
- Taskless assignments persist attempts under `.ace-local/assign` with `recovery_mode: local_only`. This path never claims Git-backed recovery and cannot record external effects (`merge`, `publish`, `deploy`, `release`); attach the assignment to a source task before managed delivery.
|
|
114
|
+
- Actor identity always comes from the execution boundary (OS login for local use, or the trusted service executor's identity). Unknown identity fails closed; flags never grant identity or authority.
|
|
115
|
+
|
|
116
|
+
### status integration
|
|
117
|
+
|
|
118
|
+
`ace-assign status --format json` includes the active attempt, `base_head`, `candidate_head`, `evidence_git_ref`, `journal_commit`, and unresolved effects. Feedback state is derived from accepted current-head evidence: `terminal` (accepted evidence at the current head), `open` (attempts without a terminal outcome), `uncertain` (unresolved interruptions), or `unknown` (no attempts). Merge authorization requires an executed independent review verdict for the current head plus an independent release receipt when relevant; report files, exit codes, and prose never authorize a merge.
|
|
119
|
+
|
|
88
120
|
## Commands
|
|
89
121
|
|
|
90
122
|
### `ace-assign create`
|
|
@@ -657,21 +657,71 @@ Follow the instructions directly, performing the described work:
|
|
|
657
657
|
|
|
658
658
|
### 4. External Action Rule (Attempt-First)
|
|
659
659
|
|
|
660
|
-
|
|
660
|
+
Every external-facing step (implement, verify, review, merge, publish, deploy, release) runs inside a scoped attempt. The attempt -- not the report and not exit status -- is the delivery record.
|
|
661
661
|
|
|
662
|
-
|
|
663
|
-
- If blocked, capture concrete evidence:
|
|
662
|
+
**Step 1: Start the attempt before executing the step.** Actor identity is derived from the execution boundary; never pass actor flags.
|
|
664
663
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
664
|
+
```bash
|
|
665
|
+
ATTEMPT_JSON=$(ace-assign attempt start --assignment "$ASSIGNMENT_ID" --step "$STEP_NUMBER" --project "$PROJECT_ID")
|
|
666
|
+
echo "$ATTEMPT_JSON"
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
- A repeated identical start returns the same attempt; a conflicting start fails (exit 5). Never launch a second writer for an owned subtree.
|
|
670
|
+
- `base_head` is captured exactly once at start. Record the `attempt_id`.
|
|
671
|
+
|
|
672
|
+
**Step 2: Execute the step command(s).** If blocked, capture concrete evidence:
|
|
673
|
+
|
|
674
|
+
- command attempted
|
|
675
|
+
- exact error output
|
|
676
|
+
- why the step cannot proceed
|
|
668
677
|
|
|
669
|
-
|
|
678
|
+
Mark the step failed with evidence (do not report synthetic completion):
|
|
670
679
|
|
|
671
680
|
```bash
|
|
672
681
|
ace-assign fail --message "Command failed: <cmd>. Error: <exact stderr>" --assignment "$ASSIGNMENT_TARGET"
|
|
673
682
|
```
|
|
674
683
|
|
|
684
|
+
**Step 3: Submit a structured receipt -- never prose -- to close the attempt.**
|
|
685
|
+
|
|
686
|
+
```bash
|
|
687
|
+
cat > /tmp/attempt-receipt.json << 'EOF'
|
|
688
|
+
{
|
|
689
|
+
"attempt_id": "<ATTEMPT_ID>",
|
|
690
|
+
"assignment_id": "<ASSIGNMENT_ID>",
|
|
691
|
+
"project_id": "<PROJECT_ID>",
|
|
692
|
+
"scope": "<STEP_NUMBER>",
|
|
693
|
+
"operation": "implement",
|
|
694
|
+
"producer": {"actor": "<boundary-actor>", "role": "worker", "runtime": "<boundary-runtime>"},
|
|
695
|
+
"head": "<exact tested/reviewed HEAD sha>",
|
|
696
|
+
"verdict": "succeeded",
|
|
697
|
+
"artifacts": [{"path": "report.md", "sha256": "<sha256>"}],
|
|
698
|
+
"checks": [{"name": "ace-test", "verdict": "passed"}],
|
|
699
|
+
"review": {"reviewer": {"actor": "<independent-reviewer>", "runtime": "<reviewer-runtime>"}, "verdict": "approved", "head": "<same HEAD sha>"}
|
|
700
|
+
}
|
|
701
|
+
EOF
|
|
702
|
+
|
|
703
|
+
ace-assign attempt finish --attempt "$ATTEMPT_ID" --receipt /tmp/attempt-receipt.json
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
Receipt rules enforced by the coordinator:
|
|
707
|
+
|
|
708
|
+
- `succeeded` verdicts require verifiable artifact digests, passing checks, and trusted coordinator/service authority; worker runtimes may submit attributable results but cannot self-approve.
|
|
709
|
+
- Review approval requires an executed independent reviewer verdict for the exact current head; same author/reviewer and report-only existence are rejected.
|
|
710
|
+
- Receipts carrying credentials, environment dumps, or terminal output are rejected outright.
|
|
711
|
+
- External effects (`merge`, `publish`, `deploy`, `release`) additionally require an accepted independent review receipt for the current head. Taskless assignments cannot record external effects.
|
|
712
|
+
|
|
713
|
+
**Step 4: Handle interruptions by classifying and reconciling -- never by assuming.**
|
|
714
|
+
|
|
715
|
+
```bash
|
|
716
|
+
ace-assign attempt status --assignment "$ASSIGNMENT_ID" --format json
|
|
717
|
+
ace-assign attempt reconcile --attempt "$ATTEMPT_ID"
|
|
718
|
+
ace-assign attempt reconcile --attempt "$ATTEMPT_ID" --receipt /tmp/attempt-receipt.json
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
- A lost receipt after a possible effect is `uncertain`; it resolves only against a verified receipt attributed to the recorded execution boundary.
|
|
722
|
+
- Running attempts stay running only while their recorded process is verifiably live; anything else becomes `stopped` or `uncertain`.
|
|
723
|
+
- Never replay `merge`, `publish`, or `deploy` automatically. Reconciliation records evidence; it never re-executes effects.
|
|
724
|
+
|
|
675
725
|
### Human-in-the-Loop (HITL) Stall Protocol
|
|
676
726
|
|
|
677
727
|
Canonical source of truth: `wfi://hitl`.
|
|
@@ -717,7 +767,7 @@ For a blocked step:
|
|
|
717
767
|
|
|
718
768
|
### 5. Write Report (Only After Real Execution)
|
|
719
769
|
|
|
720
|
-
After
|
|
770
|
+
After the attempt accepted a receipt (or the step was failed with evidence), write a brief report summarizing what was accomplished. The report advances the queue; the accepted attempt receipt carries the delivery evidence:
|
|
721
771
|
|
|
722
772
|
```bash
|
|
723
773
|
# Write report content to a temp file
|
|
@@ -737,6 +787,12 @@ EOF
|
|
|
737
787
|
ace-assign finish --message /tmp/step-report.md --assignment "$ASSIGNMENT_TARGET"
|
|
738
788
|
```
|
|
739
789
|
|
|
790
|
+
Prohibited:
|
|
791
|
+
|
|
792
|
+
- Advancing a step when its attempt has no accepted receipt (or recorded failure evidence).
|
|
793
|
+
- Treating a written report, exit 0, or prose as proof of completion. Check `ace-assign attempt status --assignment "$ASSIGNMENT_ID" --format json` for accepted evidence and unresolved effects.
|
|
794
|
+
- Reporting `uncertain` attempts as complete; reconcile them first.
|
|
795
|
+
|
|
740
796
|
### 6. Verify State Transition (Required)
|
|
741
797
|
|
|
742
798
|
After each `ace-assign finish --message` or `ace-assign fail`, verify queue state:
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Atoms
|
|
6
|
+
# Pure validation and canonicalization of attempt scopes.
|
|
7
|
+
#
|
|
8
|
+
# An attempt scope is the step/subtree an attempt owns, expressed as a
|
|
9
|
+
# step number ("010", "010.01"). Scopes are canonicalized before they
|
|
10
|
+
# key ownership locks, journal paths, or binding comparisons so that
|
|
11
|
+
# cosmetic differences cannot create a second writer.
|
|
12
|
+
module AssignmentScope
|
|
13
|
+
SCOPE_PATTERN = /\A\d+(?:\.\d+)*\z/.freeze
|
|
14
|
+
|
|
15
|
+
# Canonicalize and validate a step/subtree scope.
|
|
16
|
+
#
|
|
17
|
+
# @param step [String] Step or subtree root number
|
|
18
|
+
# @return [String] Canonical scope
|
|
19
|
+
# @raise [Ace::Assign::AttemptErrors::InvalidScope] if blank or malformed
|
|
20
|
+
def self.canonicalize(step)
|
|
21
|
+
scope = step.to_s.strip
|
|
22
|
+
if scope.empty? || !scope.match?(SCOPE_PATTERN)
|
|
23
|
+
raise Ace::Assign::AttemptErrors::InvalidScope,
|
|
24
|
+
"Invalid attempt scope '#{step}': expected a step number such as 010 or 010.01"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
scope
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Filesystem-safe token for an assignment/scope pair, used to key
|
|
31
|
+
# active-attempt ownership records.
|
|
32
|
+
#
|
|
33
|
+
# @param assignment_id [String] Assignment ID
|
|
34
|
+
# @param scope [String] Canonical scope
|
|
35
|
+
# @return [String] Ownership key token
|
|
36
|
+
def self.ownership_key(assignment_id, scope)
|
|
37
|
+
"#{assignment_id}-#{canonicalize(scope)}.json"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Compare two scopes for binding identity.
|
|
41
|
+
#
|
|
42
|
+
# @param left [String] First scope
|
|
43
|
+
# @param right [String] Second scope
|
|
44
|
+
# @return [Boolean] True if both canonicalize to the same scope
|
|
45
|
+
def self.equal?(left, right)
|
|
46
|
+
canonicalize(left) == canonicalize(right)
|
|
47
|
+
rescue Ace::Assign::AttemptErrors::InvalidScope
|
|
48
|
+
false
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Atoms
|
|
6
|
+
# Pure state machine for assignment attempt lifecycles.
|
|
7
|
+
#
|
|
8
|
+
# Legal transitions:
|
|
9
|
+
# - reserved -> running
|
|
10
|
+
# - running -> succeeded | failed | stopped | uncertain
|
|
11
|
+
# - uncertain -> succeeded | failed (only via reconciliation)
|
|
12
|
+
#
|
|
13
|
+
# Terminal states are succeeded, failed, and stopped. Terminal attempts
|
|
14
|
+
# never accept new transitions, effects, or receipts; accepted history is
|
|
15
|
+
# immutable. A stop is never success, and an uncertain attempt must be
|
|
16
|
+
# resolved through explicit reconciliation, never automatic replay.
|
|
17
|
+
module AttemptStateMachine
|
|
18
|
+
STATES = %w[reserved running succeeded failed stopped uncertain].freeze
|
|
19
|
+
TERMINAL_STATES = %w[succeeded failed stopped].freeze
|
|
20
|
+
RESOLVED_STATES = %w[succeeded failed].freeze
|
|
21
|
+
|
|
22
|
+
TRANSITIONS = {
|
|
23
|
+
"reserved" => %w[running].freeze,
|
|
24
|
+
"running" => %w[succeeded failed stopped uncertain].freeze,
|
|
25
|
+
"uncertain" => RESOLVED_STATES.freeze
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# @return [Array<String>] All known attempt states
|
|
29
|
+
def self.states
|
|
30
|
+
STATES
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param state [String] State to inspect
|
|
34
|
+
# @return [Boolean] True if the state is terminal (no further transitions)
|
|
35
|
+
def self.terminal?(state)
|
|
36
|
+
TERMINAL_STATES.include?(state.to_s)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param state [String] State to inspect
|
|
40
|
+
# @return [Boolean] True if the state awaits reconciliation
|
|
41
|
+
def self.uncertain?(state)
|
|
42
|
+
state.to_s == "uncertain"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Check whether a transition is legal without raising.
|
|
46
|
+
#
|
|
47
|
+
# @param from [String] Current state
|
|
48
|
+
# @param to [String] Requested next state
|
|
49
|
+
# @return [Boolean] True if the transition is legal
|
|
50
|
+
def self.can_transition?(from, to)
|
|
51
|
+
TRANSITIONS.fetch(from.to_s, []).include?(to.to_s)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Return the legal next states for a state.
|
|
55
|
+
#
|
|
56
|
+
# @param from [String] Current state
|
|
57
|
+
# @return [Array<String>] Legal next states (empty for unknown/terminal)
|
|
58
|
+
def self.next_states(from)
|
|
59
|
+
TRANSITIONS.fetch(from.to_s, [])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Validate a transition.
|
|
63
|
+
#
|
|
64
|
+
# @param from [String] Current state
|
|
65
|
+
# @param to [String] Requested next state
|
|
66
|
+
# @raise [Ace::Assign::AttemptErrors::InvalidTransition] if illegal
|
|
67
|
+
def self.transition!(from, to)
|
|
68
|
+
return to.to_s if can_transition?(from, to)
|
|
69
|
+
|
|
70
|
+
raise Ace::Assign::AttemptErrors::InvalidTransition,
|
|
71
|
+
"Illegal attempt transition #{from} -> #{to} (legal from #{from}: #{next_states(from).join(', ')})"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module Atoms
|
|
9
|
+
# Pure canonical digest helpers for attempt evidence.
|
|
10
|
+
#
|
|
11
|
+
# Every accepted evidence record (events, receipts, artifacts) carries a
|
|
12
|
+
# SHA-256 digest over a canonical JSON serialization (sorted keys, no
|
|
13
|
+
# insignificant whitespace) so records remain comparable across writers
|
|
14
|
+
# and reloads. Digests are over non-secret metadata only.
|
|
15
|
+
module EvidenceDigest
|
|
16
|
+
# Canonical JSON serialization of an object.
|
|
17
|
+
#
|
|
18
|
+
# Sorts hash keys recursively and strips insignificant whitespace so
|
|
19
|
+
# semantically equal payloads produce byte-identical output.
|
|
20
|
+
#
|
|
21
|
+
# @param object [Object] JSON-serializable object
|
|
22
|
+
# @return [String] Canonical JSON string
|
|
23
|
+
def self.canonical_json(object)
|
|
24
|
+
JSON.generate(sort_keys(object))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# SHA-256 hex digest over the canonical JSON of an object.
|
|
28
|
+
#
|
|
29
|
+
# @param object [Object] JSON-serializable object
|
|
30
|
+
# @return [String] 64-character hex digest
|
|
31
|
+
def self.digest(object)
|
|
32
|
+
digest_string(canonical_json(object))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# SHA-256 hex digest over raw bytes.
|
|
36
|
+
#
|
|
37
|
+
# @param bytes [String] Raw byte content
|
|
38
|
+
# @return [String] 64-character hex digest
|
|
39
|
+
def self.digest_string(bytes)
|
|
40
|
+
Digest::SHA256.hexdigest(bytes)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# SHA-256 hex digest of a file's contents.
|
|
44
|
+
#
|
|
45
|
+
# @param path [String] File path
|
|
46
|
+
# @return [String] 64-character hex digest
|
|
47
|
+
# @raise [Errno::ENOENT] if the file does not exist
|
|
48
|
+
def self.digest_file(path)
|
|
49
|
+
digest_string(File.read(path))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Short digest prefix for file naming (collision-resistant enough for
|
|
53
|
+
# append-only event filenames; full digests remain in the payload).
|
|
54
|
+
#
|
|
55
|
+
# @param object [Object] JSON-serializable object
|
|
56
|
+
# @return [String] 12-character hex prefix
|
|
57
|
+
def self.short_digest(object)
|
|
58
|
+
digest(object)[0, 12]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class << self
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def sort_keys(value)
|
|
65
|
+
case value
|
|
66
|
+
when Hash
|
|
67
|
+
value.sort.to_h.transform_values { |v| sort_keys(v) }
|
|
68
|
+
when Array
|
|
69
|
+
value.map { |item| sort_keys(item) }
|
|
70
|
+
else
|
|
71
|
+
value
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Assign
|
|
7
|
+
module CLI
|
|
8
|
+
module Commands
|
|
9
|
+
# Public `ace-assign attempt` command namespace (interface contract:
|
|
10
|
+
# start, status, finish, reconcile).
|
|
11
|
+
module Attempt
|
|
12
|
+
# Shared coordinator construction, option validation, and JSON output.
|
|
13
|
+
module Base
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def build_coordinator
|
|
17
|
+
Organisms::AttemptCoordinator.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def require_option(options, key, usage)
|
|
21
|
+
value = options[key].to_s.strip
|
|
22
|
+
raise Ace::Support::Cli::Error, "Missing --#{key}: usage: ace-assign attempt #{usage}" if value.empty?
|
|
23
|
+
|
|
24
|
+
value
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def emit_json(payload)
|
|
28
|
+
puts JSON.generate(payload)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module CLI
|
|
9
|
+
module Commands
|
|
10
|
+
module Attempt
|
|
11
|
+
# Accept a structured execution receipt and record the outcome.
|
|
12
|
+
# Terminal and uncertain attempts refuse new receipts.
|
|
13
|
+
class Finish < Ace::Support::Cli::Command
|
|
14
|
+
include Ace::Support::Cli::Base
|
|
15
|
+
include Attempt::Base
|
|
16
|
+
|
|
17
|
+
desc "Finish an attempt with a structured execution receipt"
|
|
18
|
+
|
|
19
|
+
option :attempt, desc: "Attempt ID"
|
|
20
|
+
option :receipt, desc: "Path to the receipt JSON file"
|
|
21
|
+
|
|
22
|
+
def call(**options)
|
|
23
|
+
attempt_id = require_option(options, :attempt, "finish --attempt ID --receipt FILE")
|
|
24
|
+
receipt = require_option(options, :receipt, "finish --attempt ID --receipt FILE")
|
|
25
|
+
|
|
26
|
+
result = build_coordinator.finish(attempt_id: attempt_id, receipt_path: receipt)
|
|
27
|
+
|
|
28
|
+
emit_json(result.projection)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module CLI
|
|
9
|
+
module Commands
|
|
10
|
+
module Attempt
|
|
11
|
+
# Classify interruptions and resolve uncertainty against verified,
|
|
12
|
+
# boundary-attributed receipts. Never replays external effects.
|
|
13
|
+
class Reconcile < Ace::Support::Cli::Command
|
|
14
|
+
include Ace::Support::Cli::Base
|
|
15
|
+
include Attempt::Base
|
|
16
|
+
|
|
17
|
+
desc "Reconcile an interrupted or uncertain attempt"
|
|
18
|
+
|
|
19
|
+
option :attempt, desc: "Attempt ID"
|
|
20
|
+
option :receipt, desc: "Path to the receipt JSON file (required to resolve uncertainty)"
|
|
21
|
+
|
|
22
|
+
def call(**options)
|
|
23
|
+
attempt_id = require_option(options, :attempt, "reconcile --attempt ID [--receipt FILE]")
|
|
24
|
+
receipt = options[:receipt].to_s.strip
|
|
25
|
+
|
|
26
|
+
result = build_coordinator.reconcile(
|
|
27
|
+
attempt_id: attempt_id,
|
|
28
|
+
receipt_path: receipt.empty? ? nil : receipt
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
emit_json(result.projection)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module CLI
|
|
9
|
+
module Commands
|
|
10
|
+
module Attempt
|
|
11
|
+
# Bind an immutable attempt to an assignment step/subtree.
|
|
12
|
+
#
|
|
13
|
+
# Identity is derived from the execution boundary; a repeated
|
|
14
|
+
# identical start returns the active attempt, and a conflicting
|
|
15
|
+
# binding fails without launching a second writer.
|
|
16
|
+
class Start < Ace::Support::Cli::Command
|
|
17
|
+
include Ace::Support::Cli::Base
|
|
18
|
+
include Attempt::Base
|
|
19
|
+
|
|
20
|
+
desc "Start a scoped attempt for an assignment step"
|
|
21
|
+
|
|
22
|
+
option :assignment, desc: "Assignment ID"
|
|
23
|
+
option :step, desc: "Step or subtree scope (e.g. 010 or 010.01)"
|
|
24
|
+
option :project, desc: "Project ID the attempt executes in"
|
|
25
|
+
|
|
26
|
+
def call(**options)
|
|
27
|
+
usage = "start --assignment ID --step STEP --project ID"
|
|
28
|
+
assignment = require_option(options, :assignment, usage)
|
|
29
|
+
step = require_option(options, :step, usage)
|
|
30
|
+
project = require_option(options, :project, usage)
|
|
31
|
+
|
|
32
|
+
attempt = build_coordinator.start(
|
|
33
|
+
assignment_id: assignment,
|
|
34
|
+
step: step,
|
|
35
|
+
project_id: project
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
emit_json(attempt.projection)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module CLI
|
|
9
|
+
module Commands
|
|
10
|
+
module Attempt
|
|
11
|
+
# Show attempt state, binding facts, and evidence references.
|
|
12
|
+
# JSON output carries only state, binding, references, and digests
|
|
13
|
+
# — never credentials or receipt bytes.
|
|
14
|
+
class Status < Ace::Support::Cli::Command
|
|
15
|
+
include Ace::Support::Cli::Base
|
|
16
|
+
include Attempt::Base
|
|
17
|
+
|
|
18
|
+
desc "Show attempt status for an assignment"
|
|
19
|
+
|
|
20
|
+
option :assignment, desc: "Assignment ID"
|
|
21
|
+
option :format, desc: "Output format (json, text)", default: "json"
|
|
22
|
+
|
|
23
|
+
def call(**options)
|
|
24
|
+
assignment = require_option(options, :assignment, "status --assignment ID --format json")
|
|
25
|
+
projection = build_coordinator.status(assignment)
|
|
26
|
+
|
|
27
|
+
if options[:format] == "text"
|
|
28
|
+
print_text(assignment, projection)
|
|
29
|
+
else
|
|
30
|
+
emit_json(projection || {"attempt" => nil})
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def print_text(assignment, projection)
|
|
37
|
+
return puts "No attempts recorded for #{assignment}" if projection.nil?
|
|
38
|
+
|
|
39
|
+
puts "Attempt: #{projection['attempt_id']} #{projection['state']} (#{projection['scope']})"
|
|
40
|
+
puts "Recovery: #{projection['recovery_mode']}"
|
|
41
|
+
puts "Base head: #{projection['base_head']}"
|
|
42
|
+
puts "Candidate head: #{projection['candidate_head'] || 'unpinned'}"
|
|
43
|
+
puts "Evidence ref: #{projection['evidence_git_ref'] || 'local-only'}"
|
|
44
|
+
puts "Journal commit: #{projection['journal_commit'] || 'none'}"
|
|
45
|
+
return unless projection["unresolved_effects"].any?
|
|
46
|
+
|
|
47
|
+
puts "Unresolved effects: #{projection['unresolved_effects'].join(', ')}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|