devrites 7.3.3 → 7.3.5
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.
- package/CHANGELOG.md +44 -3
- package/README.md +21 -1
- package/docs/adr/0039-autocomplete-structural-admission.md +87 -0
- package/docs/adr/README.md +1 -0
- package/docs/cli.md +182 -7
- package/docs/command-map.md +2 -2
- package/docs/engine/commands.md +53 -3
- package/engine/commands.go +1 -1
- package/engine/help.go +3 -1
- package/engine/internal/lib/claims.go +65 -8
- package/engine/internal/lib/continuation.go +38 -4
- package/engine/internal/lib/dispatch.go +124 -63
- package/engine/internal/lib/frontier.go +864 -0
- package/engine/internal/lib/frontier_controller.go +652 -0
- package/engine/internal/lib/frontier_roadmap.go +168 -0
- package/engine/internal/lib/handoff.go +2 -5
- package/engine/internal/lib/next.go +19 -0
- package/engine/internal/parallel/cli.go +16 -5
- package/engine/internal/parallel/ops.go +7 -5
- package/engine/internal/parallel/repository.go +23 -0
- package/engine/internal/release/release.go +96 -5
- package/engine/main.go +4 -0
- package/pack/.claude/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
- package/pack/.claude/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
- package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
- package/pack/.claude/skills/rite-autocomplete/SKILL.md +33 -38
- package/pack/.claude/skills/rite-autocomplete/reference/loop.md +46 -52
- package/pack/.claude/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
- package/pack/.claude/skills/rite-build/reference/parallel-batch.md +19 -17
- package/pack/generated/claude/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
- package/pack/generated/claude/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
- package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
- package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +33 -38
- package/pack/generated/claude/skills/rite-autocomplete/reference/loop.md +46 -52
- package/pack/generated/claude/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
- package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +19 -17
- package/pack/generated/codex/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
- package/pack/generated/codex/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
- package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
- package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +33 -38
- package/pack/generated/codex/skills/rite-autocomplete/reference/loop.md +46 -52
- package/pack/generated/codex/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
- package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +19 -17
- package/pack/generated/devin/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
- package/pack/generated/devin/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
- package/pack/generated/devin/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
- package/pack/generated/devin/skills/rite-autocomplete/SKILL.md +33 -38
- package/pack/generated/devin/skills/rite-autocomplete/reference/loop.md +46 -52
- package/pack/generated/devin/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
- package/pack/generated/devin/skills/rite-build/reference/parallel-batch.md +19 -17
- package/pack/generated/dsh/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
- package/pack/generated/dsh/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
- package/pack/generated/dsh/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
- package/pack/generated/dsh/skills/rite-autocomplete/SKILL.md +33 -38
- package/pack/generated/dsh/skills/rite-autocomplete/reference/loop.md +46 -52
- package/pack/generated/dsh/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
- package/pack/generated/dsh/skills/rite-build/reference/parallel-batch.md +19 -17
- package/pack/generated/omp/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
- package/pack/generated/omp/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
- package/pack/generated/omp/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
- package/pack/generated/omp/skills/rite-autocomplete/SKILL.md +33 -38
- package/pack/generated/omp/skills/rite-autocomplete/reference/loop.md +46 -52
- package/pack/generated/omp/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
- package/pack/generated/omp/skills/rite-build/reference/parallel-batch.md +19 -17
- package/pack/generated/pi/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
- package/pack/generated/pi/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
- package/pack/generated/pi/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
- package/pack/generated/pi/skills/rite-autocomplete/SKILL.md +33 -38
- package/pack/generated/pi/skills/rite-autocomplete/reference/loop.md +46 -52
- package/pack/generated/pi/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
- package/pack/generated/pi/skills/rite-build/reference/parallel-batch.md +19 -17
- package/package.json +1 -1
|
@@ -239,21 +239,39 @@ func validSession(s string) bool {
|
|
|
239
239
|
return true
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
-
// claimConflicts
|
|
243
|
-
// claimed directory covers every path beneath it, and vice versa.
|
|
244
|
-
func claimConflicts(live []*claimRecord, session string, paths []string) []*claimRecord {
|
|
242
|
+
// claimConflicts checks logical and physical overlap for other live sessions:
|
|
243
|
+
// a claimed directory covers every path beneath it, and vice versa.
|
|
244
|
+
func claimConflicts(root string, live []*claimRecord, session string, paths []string) ([]*claimRecord, error) {
|
|
245
245
|
var out []*claimRecord
|
|
246
|
+
var physical []string
|
|
246
247
|
for _, rec := range live {
|
|
247
248
|
if rec.Session == session {
|
|
248
249
|
continue
|
|
249
250
|
}
|
|
250
|
-
|
|
251
|
+
overlaps := slices.ContainsFunc(rec.Paths, func(held string) bool {
|
|
251
252
|
return slices.ContainsFunc(paths, func(p string) bool { return claimPathsOverlap(held, p) })
|
|
252
|
-
})
|
|
253
|
+
})
|
|
254
|
+
if !overlaps {
|
|
255
|
+
if physical == nil {
|
|
256
|
+
var err error
|
|
257
|
+
physical, err = physicalClaimPaths(root, paths)
|
|
258
|
+
if err != nil {
|
|
259
|
+
return nil, err
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
held, err := physicalClaimPaths(root, rec.Paths)
|
|
263
|
+
if err != nil {
|
|
264
|
+
return nil, fmt.Errorf("claim %s: %w", rec.ID, err)
|
|
265
|
+
}
|
|
266
|
+
overlaps = slices.ContainsFunc(held, func(h string) bool {
|
|
267
|
+
return slices.ContainsFunc(physical, func(p string) bool { return claimPathsOverlap(h, p) })
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
if overlaps {
|
|
253
271
|
out = append(out, rec)
|
|
254
272
|
}
|
|
255
273
|
}
|
|
256
|
-
return out
|
|
274
|
+
return out, nil
|
|
257
275
|
}
|
|
258
276
|
|
|
259
277
|
// claimPathsOverlap folds case always: on a case-sensitive filesystem a false
|
|
@@ -482,6 +500,37 @@ func addClaimTargets(root, session, reason string, ttl int, raw, workspaces []st
|
|
|
482
500
|
return id, code
|
|
483
501
|
}
|
|
484
502
|
|
|
503
|
+
// Caller holds the repository claims lock through new-wave publication.
|
|
504
|
+
func checkWriterClaimsLocked(root, session string, paths []string) error {
|
|
505
|
+
if len(paths) == 0 {
|
|
506
|
+
return nil
|
|
507
|
+
}
|
|
508
|
+
latest, order, bad, err := readClaims(claimsPath(root))
|
|
509
|
+
if err != nil {
|
|
510
|
+
return err
|
|
511
|
+
}
|
|
512
|
+
if bad > 0 {
|
|
513
|
+
return fmt.Errorf("%d unparsed ledger line(s); cannot rule out a writer claim", bad)
|
|
514
|
+
}
|
|
515
|
+
probe := newHolderProbe()
|
|
516
|
+
retained, err := retainedWriterConflicts(root, latest, paths, probe)
|
|
517
|
+
if err != nil {
|
|
518
|
+
return err
|
|
519
|
+
}
|
|
520
|
+
if len(retained) > 0 {
|
|
521
|
+
return fmt.Errorf("abandoned writer still holds claim %s on %s; confirm host termination and use dispatch abandon --release before retrying", retained[0].ID, strings.Join(retained[0].Paths, ","))
|
|
522
|
+
}
|
|
523
|
+
conflicts, err := claimConflicts(root, probe.withoutGone(liveClaims(latest, order, time.Now().UTC())), session, paths)
|
|
524
|
+
if err != nil {
|
|
525
|
+
return err
|
|
526
|
+
}
|
|
527
|
+
if len(conflicts) > 0 {
|
|
528
|
+
rec := conflicts[0]
|
|
529
|
+
return fmt.Errorf("live writer claim %s on %s held by session %q until %s", rec.ID, strings.Join(rec.Paths, ","), rec.Session, rec.ExpiresAt)
|
|
530
|
+
}
|
|
531
|
+
return nil
|
|
532
|
+
}
|
|
533
|
+
|
|
485
534
|
func claimAddLocked(path, session, reason string, ttl int, paths, admissionPaths []string, stdout, stderr io.Writer) (string, int) {
|
|
486
535
|
latest, order, bad, err := readClaims(path)
|
|
487
536
|
if err != nil {
|
|
@@ -503,7 +552,11 @@ func claimAddLocked(path, session, reason string, ttl int, paths, admissionPaths
|
|
|
503
552
|
printConflicts(stderr, retained)
|
|
504
553
|
return "", 3
|
|
505
554
|
}
|
|
506
|
-
conflicts := claimConflicts(probe.withoutGone(liveClaims(latest, order, now)), session,
|
|
555
|
+
conflicts, err := claimConflicts(filepath.Dir(path), probe.withoutGone(liveClaims(latest, order, now)), session, admissionPaths)
|
|
556
|
+
if err != nil {
|
|
557
|
+
fmt.Fprintf(stderr, "claim: %v\n", err)
|
|
558
|
+
return "", 3
|
|
559
|
+
}
|
|
507
560
|
if len(conflicts) > 0 {
|
|
508
561
|
printConflicts(stderr, conflicts)
|
|
509
562
|
return "", 3
|
|
@@ -698,7 +751,11 @@ func claimCheckLocked(root, session string, paths []string, stdout, stderr io.Wr
|
|
|
698
751
|
printConflicts(stdout, retained)
|
|
699
752
|
return 3
|
|
700
753
|
}
|
|
701
|
-
conflicts := claimConflicts(probe.withoutGone(liveClaims(latest, order, time.Now().UTC())), session, paths)
|
|
754
|
+
conflicts, err := claimConflicts(root, probe.withoutGone(liveClaims(latest, order, time.Now().UTC())), session, paths)
|
|
755
|
+
if err != nil {
|
|
756
|
+
fmt.Fprintf(stderr, "claim: %v\n", err)
|
|
757
|
+
return 2
|
|
758
|
+
}
|
|
702
759
|
if len(conflicts) > 0 {
|
|
703
760
|
printConflicts(stdout, conflicts)
|
|
704
761
|
return 3
|
|
@@ -10,6 +10,7 @@ import (
|
|
|
10
10
|
|
|
11
11
|
"github.com/devrites/devrites/internal/devritespaths"
|
|
12
12
|
"github.com/devrites/devrites/internal/gate"
|
|
13
|
+
"github.com/devrites/devrites/internal/markdowntext"
|
|
13
14
|
"github.com/devrites/devrites/internal/state"
|
|
14
15
|
"github.com/devrites/devrites/internal/workflow"
|
|
15
16
|
)
|
|
@@ -39,12 +40,31 @@ func RunCheckContinuation(root string, args []string, stdout, stderr io.Writer)
|
|
|
39
40
|
}
|
|
40
41
|
return code
|
|
41
42
|
}
|
|
43
|
+
mode, err := autocompleteMode(root)
|
|
44
|
+
if err != nil {
|
|
45
|
+
fmt.Fprintf(stderr, "check continuation: %v\n", err)
|
|
46
|
+
return 2
|
|
47
|
+
}
|
|
42
48
|
verdict, err := classifyContinuation(root, slug)
|
|
43
49
|
if err != nil {
|
|
44
50
|
fmt.Fprintf(stderr, "check continuation: %v\n", err)
|
|
45
51
|
return 2
|
|
46
52
|
}
|
|
53
|
+
var frontier []string
|
|
54
|
+
if afkPresent(root) {
|
|
55
|
+
frontier, err = FrontierLines(root, slug)
|
|
56
|
+
if err != nil {
|
|
57
|
+
fmt.Fprintf(stderr, "check continuation: frontier: %v\n", err)
|
|
58
|
+
return 2
|
|
59
|
+
}
|
|
60
|
+
if mode && frontierGlobalPriority(slug, frontier) || !mode && !verdict.Required && len(frontier) > 0 && frontier[0] == "frontier: actionable" {
|
|
61
|
+
verdict = continuationVerdict{Required: true, Invoke: "/rite-autocomplete", Source: slug, Reason: "global frontier requires native preparation, authority review, or selection; no Ship approval"}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
47
64
|
writeContinuation(stdout, verdict)
|
|
65
|
+
for _, line := range frontier {
|
|
66
|
+
fmt.Fprintln(stdout, line)
|
|
67
|
+
}
|
|
48
68
|
return 0
|
|
49
69
|
}
|
|
50
70
|
|
|
@@ -59,12 +79,16 @@ func classifyContinuation(root, slug string) (continuationVerdict, error) {
|
|
|
59
79
|
}
|
|
60
80
|
invoke, internal, err := internalPhaseEdge(report.NextAction, slug)
|
|
61
81
|
if err != nil {
|
|
62
|
-
|
|
82
|
+
if !frontierBoundSpec(root, slug, report.NextAction) {
|
|
83
|
+
return continuationVerdict{}, err
|
|
84
|
+
}
|
|
85
|
+
// A successor Spec belongs to the global controller, never this source's phase invocation.
|
|
86
|
+
invoke, internal = "", false
|
|
63
87
|
}
|
|
64
88
|
if !afkPresent(root) {
|
|
65
89
|
return continuationVerdict{Reason: "no AFK sentinel"}, nil
|
|
66
90
|
}
|
|
67
|
-
if report.Status != "running" {
|
|
91
|
+
if report.Status != "running" && !(report.Phase == state.PhaseVet && report.Status == "ready") {
|
|
68
92
|
if report.Status == "" {
|
|
69
93
|
return continuationVerdict{Reason: "status unset"}, nil
|
|
70
94
|
}
|
|
@@ -75,7 +99,17 @@ func classifyContinuation(root, slug string) (continuationVerdict, error) {
|
|
|
75
99
|
if err != nil {
|
|
76
100
|
return continuationVerdict{}, err
|
|
77
101
|
}
|
|
78
|
-
|
|
102
|
+
structural, err := markdowntext.Structural(data)
|
|
103
|
+
if err != nil {
|
|
104
|
+
return continuationVerdict{Reason: "malformed source cursor; native normalization required"}, nil
|
|
105
|
+
}
|
|
106
|
+
cursorLines := strings.Split(string(structural), "\n")
|
|
107
|
+
for _, key := range []string{state.CursorPhase, state.CursorStatus, state.CursorNextAction, state.CursorQuestionID} {
|
|
108
|
+
if _, _, err := frontierField(cursorLines, key); err != nil {
|
|
109
|
+
return continuationVerdict{Reason: err.Error() + "; native normalization required"}, nil
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if qid, ok := state.CursorField(cursorLines, state.CursorQuestionID); ok && strings.TrimSpace(qid) != "" {
|
|
79
113
|
return continuationVerdict{Reason: "question cursor set"}, nil
|
|
80
114
|
}
|
|
81
115
|
// #nosec G304 -- questions.md inside the checked feature workspace
|
|
@@ -93,7 +127,7 @@ func classifyContinuation(root, slug string) (continuationVerdict, error) {
|
|
|
93
127
|
if !internal {
|
|
94
128
|
return continuationVerdict{Reason: "next_action is not an internal phase edge"}, nil
|
|
95
129
|
}
|
|
96
|
-
return continuationVerdict{Required: true, Invoke: invoke, Source: slug, Reason: "AFK status=
|
|
130
|
+
return continuationVerdict{Required: true, Invoke: invoke, Source: slug, Reason: "AFK status=" + report.Status + " internal phase edge"}, nil
|
|
97
131
|
}
|
|
98
132
|
|
|
99
133
|
func afkPresent(root string) bool {
|
|
@@ -52,6 +52,8 @@ An abandoned writer's unreleased claim blocks overlapping writers across every
|
|
|
52
52
|
live or archived feature in this repository, even in the same session or after
|
|
53
53
|
TTL expiry, until explicit release or proven host termination.
|
|
54
54
|
return --handle, when supplied, must match the role's recorded host handle.
|
|
55
|
+
Completed return replay requires the exact recorded role and --handle and only
|
|
56
|
+
reconciles claim release; failed release exits nonzero without undoing the return.
|
|
55
57
|
status --wave exits
|
|
56
58
|
nonzero until that wave is complete; bare status lists every open, sealed,
|
|
57
59
|
or abandoned wave, folds complete waves into one count line (--all lists
|
|
@@ -114,7 +116,9 @@ type dispatchWave struct {
|
|
|
114
116
|
ResliceReviewed []string `json:"reslice_reviewed,omitempty"`
|
|
115
117
|
Claim string `json:"claim,omitempty"`
|
|
116
118
|
ClaimSession string `json:"claim_session,omitempty"`
|
|
117
|
-
Tree string `json:"tree,omitempty"`
|
|
119
|
+
Tree string `json:"tree,omitempty"` // work tree at a build wright open, for check windows --since-dispatch
|
|
120
|
+
ControllerBatch string `json:"controller_batch,omitempty"` // cohort admission identity, not evidence of actor checkout
|
|
121
|
+
AdmittedPaths map[string][]string `json:"admitted_paths,omitempty"` // immutable normalized write sets, keyed by source-local slice
|
|
118
122
|
}
|
|
119
123
|
|
|
120
124
|
// dispatchClaim carries the wave-claim flags; ttl 0 means unset.
|
|
@@ -150,32 +154,36 @@ func loadDispatch(root, slug string) (*dispatchFile, string, error) {
|
|
|
150
154
|
}
|
|
151
155
|
|
|
152
156
|
func loadDispatchFile(path string) (*dispatchFile, string, error) {
|
|
153
|
-
f := &dispatchFile{Waves: map[string]*dispatchWave{}}
|
|
154
157
|
data, err := os.ReadFile(path) // #nosec G304 -- path is a validated feature workspace artifact
|
|
155
158
|
if os.IsNotExist(err) {
|
|
156
|
-
return
|
|
159
|
+
return &dispatchFile{Waves: map[string]*dispatchWave{}}, path, nil
|
|
157
160
|
}
|
|
158
161
|
if err != nil {
|
|
159
162
|
return nil, path, err
|
|
160
163
|
}
|
|
161
|
-
f
|
|
164
|
+
f, err := decodeDispatch(data)
|
|
165
|
+
return f, path, err
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
func decodeDispatch(data []byte) (*dispatchFile, error) {
|
|
169
|
+
f := &dispatchFile{}
|
|
162
170
|
if err := json.Unmarshal(data, f); err != nil {
|
|
163
|
-
return nil,
|
|
171
|
+
return nil, fmt.Errorf("dispatch.json is not valid JSON: %w", err)
|
|
164
172
|
}
|
|
165
173
|
if f.Waves == nil {
|
|
166
|
-
return nil,
|
|
174
|
+
return nil, fmt.Errorf("dispatch.json is malformed (missing waves)")
|
|
167
175
|
}
|
|
168
176
|
// Validate every account before any caller can use or mutate the ledger.
|
|
169
177
|
for name, w := range f.Waves {
|
|
170
178
|
if w == nil || strings.TrimSpace(name) == "" || strings.TrimSpace(w.Phase) == "" || len(w.Roles) == 0 ||
|
|
171
179
|
!slices.Contains([]string{"open", "sealed", "complete", "abandoned"}, w.State) {
|
|
172
|
-
return nil,
|
|
180
|
+
return nil, fmt.Errorf("wave %q is malformed (invalid phase, state or roles)", name)
|
|
173
181
|
}
|
|
174
182
|
handles := map[string]bool{}
|
|
175
183
|
allStarted, allReturned := true, true
|
|
176
184
|
for role, r := range w.Roles {
|
|
177
185
|
if r == nil || strings.TrimSpace(role) == "" {
|
|
178
|
-
return nil,
|
|
186
|
+
return nil, fmt.Errorf("wave %q role %q is malformed (missing account)", name, role)
|
|
179
187
|
}
|
|
180
188
|
started := r.Handle != ""
|
|
181
189
|
if started != (r.StartedAt != "") || r.Returned != (r.ReturnedAt != "") ||
|
|
@@ -183,19 +191,19 @@ func loadDispatchFile(path string) (*dispatchFile, string, error) {
|
|
|
183
191
|
(w.State == "open" && r.Returned) ||
|
|
184
192
|
((w.State == "sealed" || w.State == "complete") && !started) ||
|
|
185
193
|
(w.State == "complete" && !r.Returned) {
|
|
186
|
-
return nil,
|
|
194
|
+
return nil, fmt.Errorf("wave %q role %q is malformed (inconsistent start or return)", name, role)
|
|
187
195
|
}
|
|
188
196
|
allStarted = allStarted && started
|
|
189
197
|
allReturned = allReturned && r.Returned
|
|
190
198
|
if started {
|
|
191
199
|
start, startErr := time.Parse(time.RFC3339Nano, r.StartedAt)
|
|
192
200
|
if startErr != nil {
|
|
193
|
-
return nil,
|
|
201
|
+
return nil, fmt.Errorf("wave %q role %q is malformed (invalid start timestamp)", name, role)
|
|
194
202
|
}
|
|
195
203
|
if r.Returned {
|
|
196
204
|
end, endErr := time.Parse(time.RFC3339Nano, r.ReturnedAt)
|
|
197
205
|
if endErr != nil || end.Before(start) {
|
|
198
|
-
return nil,
|
|
206
|
+
return nil, fmt.Errorf("wave %q role %q is malformed (invalid return timestamp)", name, role)
|
|
199
207
|
}
|
|
200
208
|
}
|
|
201
209
|
}
|
|
@@ -204,32 +212,32 @@ func loadDispatchFile(path string) (*dispatchFile, string, error) {
|
|
|
204
212
|
}
|
|
205
213
|
}
|
|
206
214
|
if (w.State == "open" && allStarted) || (w.State == "sealed" && allReturned) {
|
|
207
|
-
return nil,
|
|
215
|
+
return nil, fmt.Errorf("wave %q is malformed (inconsistent barrier state)", name)
|
|
208
216
|
}
|
|
209
217
|
if w.AbandonedAt != "" {
|
|
210
218
|
if _, err := time.Parse(time.RFC3339Nano, w.AbandonedAt); err != nil || w.State != "abandoned" {
|
|
211
|
-
return nil,
|
|
219
|
+
return nil, fmt.Errorf("wave %q is malformed (invalid abandonment timestamp)", name)
|
|
212
220
|
}
|
|
213
221
|
}
|
|
214
222
|
if w.TerminalAt != "" {
|
|
215
223
|
terminal, err := time.Parse(time.RFC3339Nano, w.TerminalAt)
|
|
216
224
|
abandoned, abandonErr := time.Parse(time.RFC3339Nano, w.AbandonedAt)
|
|
217
225
|
if err != nil || abandonErr != nil || w.State != "abandoned" || terminal.Before(abandoned) {
|
|
218
|
-
return nil,
|
|
226
|
+
return nil, fmt.Errorf("wave %q is malformed (invalid terminal timestamp)", name)
|
|
219
227
|
}
|
|
220
228
|
for _, account := range w.Roles {
|
|
221
229
|
for _, stamp := range []string{account.StartedAt, account.ReturnedAt} {
|
|
222
230
|
if stamp != "" {
|
|
223
231
|
at, _ := time.Parse(time.RFC3339Nano, stamp)
|
|
224
232
|
if terminal.Before(at) {
|
|
225
|
-
return nil,
|
|
233
|
+
return nil, fmt.Errorf("wave %q is malformed (terminal timestamp precedes account)", name)
|
|
226
234
|
}
|
|
227
235
|
}
|
|
228
236
|
}
|
|
229
237
|
}
|
|
230
238
|
}
|
|
231
239
|
}
|
|
232
|
-
return f,
|
|
240
|
+
return f, nil
|
|
233
241
|
}
|
|
234
242
|
|
|
235
243
|
func saveDispatch(path string, f *dispatchFile) error {
|
|
@@ -335,20 +343,61 @@ func RunDispatch(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
335
343
|
// Serialize load-modify-save so concurrent start/return calls never lose
|
|
336
344
|
// an update and strand the wave short of complete.
|
|
337
345
|
code := 0
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
return state.
|
|
341
|
-
|
|
342
|
-
return
|
|
346
|
+
recordedReturn := false
|
|
347
|
+
mutate := func() error {
|
|
348
|
+
return state.WithFeatureLock(root, slug, func() error {
|
|
349
|
+
// Controller → feature → claims; never acquire another feature.
|
|
350
|
+
return state.WithLock(claimsPath(root)+".lock", func() error {
|
|
351
|
+
code = dispatchLocked(root, slug, sub, phase, wave, role, handle, reason, roles, sliceIDs, review, contract, packet, outcome, result, all, claim, stdout, stderr, &recordedReturn)
|
|
352
|
+
return nil
|
|
353
|
+
})
|
|
343
354
|
})
|
|
344
|
-
}
|
|
355
|
+
}
|
|
356
|
+
controllerOpen := false
|
|
357
|
+
if sub == "open" && strings.EqualFold(phase, "build") {
|
|
358
|
+
for _, candidate := range roles {
|
|
359
|
+
controllerOpen = controllerOpen || strings.Contains(candidate, "wright")
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if controllerOpen {
|
|
363
|
+
mode, err := autocompleteMode(root)
|
|
364
|
+
if err != nil {
|
|
365
|
+
fmt.Fprintf(stderr, "dispatch: BLOCKED: %v\n", err)
|
|
366
|
+
return 3
|
|
367
|
+
}
|
|
368
|
+
controllerOpen = mode
|
|
369
|
+
}
|
|
370
|
+
var lockErr error
|
|
371
|
+
if controllerOpen {
|
|
372
|
+
lockErr = parallel.WithControlLock(root, mutate)
|
|
373
|
+
} else {
|
|
374
|
+
lockErr = mutate()
|
|
375
|
+
}
|
|
376
|
+
if err := lockErr; err != nil {
|
|
345
377
|
fmt.Fprintf(stderr, "dispatch: %v\n", err)
|
|
346
378
|
return 3
|
|
347
379
|
}
|
|
380
|
+
if recordedReturn {
|
|
381
|
+
mode, err := autocompleteMode(root)
|
|
382
|
+
if err != nil {
|
|
383
|
+
fmt.Fprintf(stderr, "dispatch: return recorded; global scheduling: %v\n", err)
|
|
384
|
+
return 3
|
|
385
|
+
}
|
|
386
|
+
if mode {
|
|
387
|
+
lines, err := FrontierLines(root, slug)
|
|
388
|
+
if err != nil {
|
|
389
|
+
fmt.Fprintf(stderr, "dispatch: return recorded; global scheduling: %v\n", err)
|
|
390
|
+
return 3
|
|
391
|
+
}
|
|
392
|
+
for _, line := range lines {
|
|
393
|
+
fmt.Fprintln(stdout, line)
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
348
397
|
return code
|
|
349
398
|
}
|
|
350
399
|
|
|
351
|
-
func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, roles, sliceIDs []string, review, contract, packet, outcome, result string, all bool, claim dispatchClaim, stdout, stderr io.Writer) int {
|
|
400
|
+
func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, roles, sliceIDs []string, review, contract, packet, outcome, result string, all bool, claim dispatchClaim, stdout, stderr io.Writer, recordedReturn *bool) int {
|
|
352
401
|
f, path, err := loadDispatch(root, slug)
|
|
353
402
|
if err != nil {
|
|
354
403
|
fmt.Fprintf(stderr, "dispatch: %v\n", err)
|
|
@@ -386,6 +435,16 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
|
|
|
386
435
|
if err := lintKnownRoles(root, phase, roles); err != nil {
|
|
387
436
|
return fail("BLOCKED: %v", err)
|
|
388
437
|
}
|
|
438
|
+
if isBuildWrightWave(w) {
|
|
439
|
+
binding, notices, err := autocompleteWriterAdmission(root, slug, w, f)
|
|
440
|
+
if err != nil {
|
|
441
|
+
return fail("BLOCKED: %v", err)
|
|
442
|
+
}
|
|
443
|
+
w.ControllerBatch = binding
|
|
444
|
+
for _, notice := range notices {
|
|
445
|
+
fmt.Fprintln(stdout, notice)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
389
448
|
rounds, code := admitWrightRounds(root, slug, f, w, review, stderr)
|
|
390
449
|
if code != 0 {
|
|
391
450
|
return code
|
|
@@ -422,10 +481,16 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
|
|
|
422
481
|
}
|
|
423
482
|
admissionPaths := slices.Clone(claimPaths)
|
|
424
483
|
if isWrightWave(w) {
|
|
425
|
-
|
|
484
|
+
if len(w.AdmittedPaths) > 0 {
|
|
485
|
+
for _, id := range w.Slices {
|
|
486
|
+
admissionPaths = append(admissionPaths, w.AdmittedPaths[id]...)
|
|
487
|
+
}
|
|
488
|
+
} else {
|
|
489
|
+
admissionPaths = append(admissionPaths, sliceWriteSet(root, slug, w.Slices)...)
|
|
490
|
+
}
|
|
426
491
|
}
|
|
427
492
|
if len(claimPaths) == 0 {
|
|
428
|
-
if err :=
|
|
493
|
+
if err := checkWriterClaimsLocked(root, claim.session, admissionPaths); err != nil {
|
|
429
494
|
return fail("BLOCKED: %v", err)
|
|
430
495
|
}
|
|
431
496
|
}
|
|
@@ -543,7 +608,7 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
|
|
|
543
608
|
|
|
544
609
|
case "return":
|
|
545
610
|
w := getWave()
|
|
546
|
-
if w == nil || w.State != "sealed" {
|
|
611
|
+
if w == nil || (w.State != "sealed" && w.State != "complete") {
|
|
547
612
|
return fail("wave %q is not sealed (state %s)", wave, waveState(w))
|
|
548
613
|
}
|
|
549
614
|
r := w.Roles[role]
|
|
@@ -553,6 +618,16 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
|
|
|
553
618
|
if handle != "" && handle != r.Handle {
|
|
554
619
|
return fail("handle %q does not match role %q's recorded handle %q", handle, role, r.Handle)
|
|
555
620
|
}
|
|
621
|
+
if w.State == "complete" {
|
|
622
|
+
if !r.Returned || handle == "" || r.Handle == "" || w.Claim == "" {
|
|
623
|
+
return fail("completed wave %q needs a returned role, its recorded --handle, and an owned claim to reconcile", wave)
|
|
624
|
+
}
|
|
625
|
+
if !releaseWaveClaim(root, wave, w, stdout, stderr) {
|
|
626
|
+
return 3
|
|
627
|
+
}
|
|
628
|
+
*recordedReturn = true
|
|
629
|
+
return 0
|
|
630
|
+
}
|
|
556
631
|
if r.Returned {
|
|
557
632
|
return fail("role %q already returned", role)
|
|
558
633
|
}
|
|
@@ -589,13 +664,14 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
|
|
|
589
664
|
}
|
|
590
665
|
return fail("%v", err)
|
|
591
666
|
}
|
|
667
|
+
*recordedReturn = true
|
|
592
668
|
recordMetricNote(root, slug, w.Phase, "return", role, note)
|
|
593
669
|
fmt.Fprintf(stdout, "dispatch: %s returned (wave %s %s)\n", role, wave, w.State)
|
|
594
670
|
if all {
|
|
595
671
|
absorbWave(root, slug, w, stderr)
|
|
596
672
|
}
|
|
597
|
-
if all && w.Claim != "" {
|
|
598
|
-
|
|
673
|
+
if all && w.Claim != "" && !releaseWaveClaim(root, wave, w, stdout, stderr) {
|
|
674
|
+
return 3
|
|
599
675
|
}
|
|
600
676
|
if stored != "" {
|
|
601
677
|
head, _, _ := strings.Cut(note, " slice=")
|
|
@@ -699,33 +775,11 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
|
|
|
699
775
|
return 2
|
|
700
776
|
}
|
|
701
777
|
|
|
702
|
-
//
|
|
703
|
-
|
|
704
|
-
if len(paths) == 0 {
|
|
705
|
-
return nil
|
|
706
|
-
}
|
|
707
|
-
latest, _, bad, err := readClaims(claimsPath(root))
|
|
708
|
-
if err != nil {
|
|
709
|
-
return err
|
|
710
|
-
}
|
|
711
|
-
if bad > 0 {
|
|
712
|
-
return fmt.Errorf("%d unparsed ledger line(s); cannot rule out an abandoned writer claim", bad)
|
|
713
|
-
}
|
|
714
|
-
conflicts, err := retainedWriterConflicts(root, latest, paths, newHolderProbe())
|
|
715
|
-
if err != nil {
|
|
716
|
-
return err
|
|
717
|
-
}
|
|
718
|
-
if len(conflicts) > 0 {
|
|
719
|
-
return fmt.Errorf("abandoned writer still holds claim %s on %s; confirm host termination and use dispatch abandon --release before retrying", conflicts[0].ID, strings.Join(conflicts[0].Paths, ","))
|
|
720
|
-
}
|
|
721
|
-
return nil
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
// releaseWaveClaim releases w's claim; explicit terminal abandonment also
|
|
725
|
-
// reconciles a verified prior release. A failure only warns.
|
|
778
|
+
// releaseWaveClaim releases w's claim or reconciles a verified prior release.
|
|
779
|
+
// Callers surface failure without undoing the recorded return or abandonment.
|
|
726
780
|
func releaseWaveClaim(root, wave string, w *dispatchWave, stdout, stderr io.Writer) bool {
|
|
727
781
|
n, err := releaseClaim(root, w.ClaimSession, w.Claim)
|
|
728
|
-
if
|
|
782
|
+
if errors.Is(err, errClaimAlreadyReleased) {
|
|
729
783
|
fmt.Fprintf(stdout, "dispatch: wave %s claim %s already released\n", wave, w.Claim)
|
|
730
784
|
return true
|
|
731
785
|
}
|
|
@@ -914,21 +968,21 @@ func admitWrightRounds(root, slug string, f *dispatchFile, w *dispatchWave, revi
|
|
|
914
968
|
if kind == "wright" {
|
|
915
969
|
lines = append(lines, "dispatch: ADVISORY: wright contract reminder: name the writer-safe checks the wright runs (syntax/type/lint, unit and self-check suites, a narrow local smoke) and never forbid them; only artifact-producing, consumptive, or full canonical gates stay root-owned")
|
|
916
970
|
}
|
|
971
|
+
if kind == "wright" {
|
|
972
|
+
if refusal := wrightFanoutRefusal(w); refusal != "" {
|
|
973
|
+
fmt.Fprintln(stderr, refusal)
|
|
974
|
+
return nil, 3
|
|
975
|
+
}
|
|
976
|
+
if refusal := widerBatchRefusal(root, slug, w.Slices); refusal != "" {
|
|
977
|
+
fmt.Fprintln(stderr, refusal)
|
|
978
|
+
return nil, 3
|
|
979
|
+
}
|
|
980
|
+
}
|
|
917
981
|
if len(overCap) == 0 {
|
|
918
982
|
if review != "" {
|
|
919
983
|
fmt.Fprintf(stderr, "dispatch: --reslice-review given but no slice has reached its %d-round cap\n", wrightRoundsPerReview)
|
|
920
984
|
return nil, 2
|
|
921
985
|
}
|
|
922
|
-
if kind == "wright" {
|
|
923
|
-
if refusal := wrightFanoutRefusal(w); refusal != "" {
|
|
924
|
-
fmt.Fprintln(stderr, refusal)
|
|
925
|
-
return nil, 3
|
|
926
|
-
}
|
|
927
|
-
if refusal := widerBatchRefusal(root, slug, w.Slices); refusal != "" {
|
|
928
|
-
fmt.Fprintln(stderr, refusal)
|
|
929
|
-
return nil, 3
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
986
|
return lines, 0
|
|
933
987
|
}
|
|
934
988
|
if review == "" {
|
|
@@ -1024,6 +1078,13 @@ func wrightLane(role string) (string, bool) {
|
|
|
1024
1078
|
// errors are never refused: the check widens the batch, it never stops a
|
|
1025
1079
|
// wave that already starts every remaining pick.
|
|
1026
1080
|
func widerBatchRefusal(root, slug string, wave []string) string {
|
|
1081
|
+
mode, modeErr := autocompleteMode(root)
|
|
1082
|
+
if modeErr != nil {
|
|
1083
|
+
return fmt.Sprintf("dispatch: BLOCKED: %v", modeErr)
|
|
1084
|
+
}
|
|
1085
|
+
if mode {
|
|
1086
|
+
return ""
|
|
1087
|
+
}
|
|
1027
1088
|
capN := min(afkMaxParallel(root), parallel.MaxParallelSlices)
|
|
1028
1089
|
if capN < 2 {
|
|
1029
1090
|
return ""
|