devrites 7.3.1 → 7.3.2
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 +22 -0
- package/README.md +19 -3
- package/bin/devrites.mjs +31 -5
- package/docs/engine/commands.md +35 -3
- package/docs/release.md +5 -2
- package/engine/Makefile +2 -2
- package/engine/internal/acceptance/newfinal_origins.go +39 -0
- package/engine/internal/acceptance/preconditions.go +17 -11
- package/engine/internal/acceptance/transcript.go +23 -4
- package/engine/internal/gate/gate.go +8 -6
- package/engine/internal/gate/readiness_binding.go +50 -21
- package/engine/internal/install/apply.go +36 -21
- package/engine/internal/install/binary.go +67 -9
- package/engine/internal/install/install.go +63 -21
- package/engine/internal/install/manifest.go +4 -5
- package/engine/internal/install/merge.go +89 -33
- package/engine/internal/install/preflight.go +60 -19
- package/engine/internal/install/uninstall.go +19 -9
- package/engine/internal/install/update.go +0 -4
- package/engine/internal/lib/claims.go +170 -4
- package/engine/internal/lib/claims_holder.go +13 -16
- package/engine/internal/lib/closeout.go +52 -28
- package/engine/internal/lib/context_io.go +88 -4
- package/engine/internal/lib/continuation.go +111 -24
- package/engine/internal/lib/dispatch.go +157 -24
- package/engine/internal/lib/final_prerequisites.go +137 -0
- package/engine/internal/lib/manifest_union.go +62 -25
- package/engine/internal/lib/next.go +10 -10
- package/engine/internal/lib/packet.go +32 -8
- package/engine/internal/lib/packet_integrity.go +194 -2
- package/engine/internal/lib/resolve.go +128 -142
- package/engine/internal/lib/treebaseline.go +0 -1
- package/engine/internal/markdowntext/markdowntext.go +110 -31
- package/engine/internal/overhaul/records/check.go +45 -5
- package/engine/internal/overhaul/records/consistency.go +34 -8
- package/engine/internal/overhaul/records/run.go +55 -11
- package/engine/internal/parallel/landing.go +17 -0
- package/engine/internal/state/acceptance.go +35 -5
- package/engine/internal/state/feature.go +3 -2
- package/engine/internal/state/observation.go +1 -1
- package/engine/internal/state/write.go +20 -44
- package/engine/internal/workflow/commands.go +25 -3
- package/engine/main.go +19 -0
- package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
- package/pack/.claude/skills/overhaul/SKILL.md +8 -4
- package/pack/.claude/skills/overhaul/references/lifecycle.md +13 -6
- package/pack/.claude/skills/overhaul/references/orchestration.md +32 -21
- package/pack/.claude/skills/overhaul/references/records.md +23 -8
- package/pack/.claude/skills/overhaul/references/scope-and-safety.md +7 -2
- package/pack/.claude/skills/overhaul/references/scoring.md +7 -0
- package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +17 -17
- package/pack/.claude/skills/rite-clarify/SKILL.md +25 -25
- package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
- package/pack/generated/claude/skills/overhaul/SKILL.md +8 -4
- package/pack/generated/claude/skills/overhaul/references/lifecycle.md +13 -6
- package/pack/generated/claude/skills/overhaul/references/orchestration.md +32 -21
- package/pack/generated/claude/skills/overhaul/references/records.md +23 -8
- package/pack/generated/claude/skills/overhaul/references/scope-and-safety.md +7 -2
- package/pack/generated/claude/skills/overhaul/references/scoring.md +7 -0
- package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +17 -17
- package/pack/generated/claude/skills/rite-clarify/SKILL.md +25 -25
- package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
- package/pack/generated/codex/skills/overhaul/SKILL.md +8 -4
- package/pack/generated/codex/skills/overhaul/references/lifecycle.md +13 -6
- package/pack/generated/codex/skills/overhaul/references/orchestration.md +32 -21
- package/pack/generated/codex/skills/overhaul/references/records.md +23 -8
- package/pack/generated/codex/skills/overhaul/references/scope-and-safety.md +7 -2
- package/pack/generated/codex/skills/overhaul/references/scoring.md +7 -0
- package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +17 -17
- package/pack/generated/codex/skills/rite-clarify/SKILL.md +25 -25
- package/pack/generated/devin/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
- package/pack/generated/devin/skills/overhaul/SKILL.md +8 -4
- package/pack/generated/devin/skills/overhaul/references/lifecycle.md +13 -6
- package/pack/generated/devin/skills/overhaul/references/orchestration.md +32 -21
- package/pack/generated/devin/skills/overhaul/references/records.md +23 -8
- package/pack/generated/devin/skills/overhaul/references/scope-and-safety.md +7 -2
- package/pack/generated/devin/skills/overhaul/references/scoring.md +7 -0
- package/pack/generated/devin/skills/rite-build/reference/wright-dispatch.md +17 -17
- package/pack/generated/devin/skills/rite-clarify/SKILL.md +25 -25
- package/pack/generated/dsh/runtime/cordis.patch.yml +28 -14
- package/pack/generated/dsh/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
- package/pack/generated/dsh/skills/overhaul/SKILL.md +8 -4
- package/pack/generated/dsh/skills/overhaul/references/lifecycle.md +13 -6
- package/pack/generated/dsh/skills/overhaul/references/orchestration.md +32 -21
- package/pack/generated/dsh/skills/overhaul/references/records.md +23 -8
- package/pack/generated/dsh/skills/overhaul/references/scope-and-safety.md +7 -2
- package/pack/generated/dsh/skills/overhaul/references/scoring.md +6 -0
- package/pack/generated/dsh/skills/rite-build/reference/wright-dispatch.md +17 -17
- package/pack/generated/dsh/skills/rite-clarify/SKILL.md +25 -25
- package/pack/generated/omp/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
- package/pack/generated/omp/skills/overhaul/SKILL.md +8 -4
- package/pack/generated/omp/skills/overhaul/references/lifecycle.md +13 -6
- package/pack/generated/omp/skills/overhaul/references/orchestration.md +32 -21
- package/pack/generated/omp/skills/overhaul/references/records.md +23 -8
- package/pack/generated/omp/skills/overhaul/references/scope-and-safety.md +7 -2
- package/pack/generated/omp/skills/overhaul/references/scoring.md +7 -0
- package/pack/generated/omp/skills/rite-build/reference/wright-dispatch.md +17 -17
- package/pack/generated/omp/skills/rite-clarify/SKILL.md +25 -25
- package/pack/generated/pi/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
- package/pack/generated/pi/skills/overhaul/SKILL.md +8 -4
- package/pack/generated/pi/skills/overhaul/references/lifecycle.md +13 -6
- package/pack/generated/pi/skills/overhaul/references/orchestration.md +32 -21
- package/pack/generated/pi/skills/overhaul/references/records.md +23 -8
- package/pack/generated/pi/skills/overhaul/references/scope-and-safety.md +7 -2
- package/pack/generated/pi/skills/overhaul/references/scoring.md +7 -0
- package/pack/generated/pi/skills/rite-build/reference/wright-dispatch.md +17 -17
- package/pack/generated/pi/skills/rite-clarify/SKILL.md +25 -25
- package/package.json +1 -1
- package/scripts/dsh-generate.py +1 -1
- package/scripts/install-lib.sh +29 -0
- package/uninstall.sh +1 -0
|
@@ -3,6 +3,7 @@ package lib
|
|
|
3
3
|
import (
|
|
4
4
|
"bytes"
|
|
5
5
|
"crypto/sha256"
|
|
6
|
+
"errors"
|
|
6
7
|
"fmt"
|
|
7
8
|
"io"
|
|
8
9
|
"os"
|
|
@@ -34,10 +35,8 @@ func (s *contextSources) read(name string) ([]byte, error) {
|
|
|
34
35
|
if source, ok := s.files[name]; ok {
|
|
35
36
|
return source.data, source.err
|
|
36
37
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
source.data, source.err = readBoundedRegularFile(filepath.Join(s.dir, name), workspaceArtifactLimit)
|
|
40
|
-
}
|
|
38
|
+
source := contextSource{}
|
|
39
|
+
source.data, source.err = readContextArtifact(s.dir, name)
|
|
41
40
|
if source.err != nil {
|
|
42
41
|
source.err = fmt.Errorf("workspace artifact %s: %w", name, source.err)
|
|
43
42
|
}
|
|
@@ -48,6 +47,64 @@ func (s *contextSources) read(name string) ([]byte, error) {
|
|
|
48
47
|
return source.data, source.err
|
|
49
48
|
}
|
|
50
49
|
|
|
50
|
+
// Acquire workspace leaves through a confined root, not a checked absolute path.
|
|
51
|
+
func readContextArtifact(dir, name string) ([]byte, error) {
|
|
52
|
+
if !filepath.IsLocal(name) {
|
|
53
|
+
return nil, fmt.Errorf("workspace artifact %q escapes workspace", name)
|
|
54
|
+
}
|
|
55
|
+
if err := contextParents(dir, name); err != nil {
|
|
56
|
+
return nil, err
|
|
57
|
+
}
|
|
58
|
+
ws, err := os.OpenRoot(dir)
|
|
59
|
+
if err != nil {
|
|
60
|
+
return nil, err
|
|
61
|
+
}
|
|
62
|
+
defer func() { _ = ws.Close() }()
|
|
63
|
+
before, err := ws.Lstat(name)
|
|
64
|
+
if err != nil {
|
|
65
|
+
return nil, err
|
|
66
|
+
}
|
|
67
|
+
if !before.Mode().IsRegular() {
|
|
68
|
+
return nil, errors.New("artifact is not a regular file")
|
|
69
|
+
}
|
|
70
|
+
if before.Size() < 0 || before.Size() > workspaceArtifactLimit {
|
|
71
|
+
return nil, fmt.Errorf("artifact exceeds %s limit", byteLimitName(workspaceArtifactLimit))
|
|
72
|
+
}
|
|
73
|
+
file, err := ws.Open(name)
|
|
74
|
+
if err != nil {
|
|
75
|
+
return nil, err
|
|
76
|
+
}
|
|
77
|
+
defer func() { _ = file.Close() }()
|
|
78
|
+
opened, err := file.Stat()
|
|
79
|
+
if err != nil {
|
|
80
|
+
return nil, err
|
|
81
|
+
}
|
|
82
|
+
if !opened.Mode().IsRegular() || opened.Size() != before.Size() || !os.SameFile(before, opened) {
|
|
83
|
+
return nil, errors.New("artifact changed type while opening")
|
|
84
|
+
}
|
|
85
|
+
body, err := io.ReadAll(io.LimitReader(file, workspaceArtifactLimit+1))
|
|
86
|
+
if err != nil {
|
|
87
|
+
return nil, err
|
|
88
|
+
}
|
|
89
|
+
if int64(len(body)) > workspaceArtifactLimit {
|
|
90
|
+
return nil, fmt.Errorf("artifact exceeds %s limit", byteLimitName(workspaceArtifactLimit))
|
|
91
|
+
}
|
|
92
|
+
after, err := file.Stat()
|
|
93
|
+
if err != nil {
|
|
94
|
+
return nil, err
|
|
95
|
+
}
|
|
96
|
+
current, err := ws.Lstat(name)
|
|
97
|
+
if err != nil {
|
|
98
|
+
return nil, err
|
|
99
|
+
}
|
|
100
|
+
if !after.Mode().IsRegular() || after.Size() != opened.Size() || after.Size() != int64(len(body)) ||
|
|
101
|
+
!os.SameFile(opened, after) || !current.Mode().IsRegular() || !os.SameFile(opened, current) ||
|
|
102
|
+
current.Size() != after.Size() {
|
|
103
|
+
return nil, errors.New("artifact changed size or type while reading")
|
|
104
|
+
}
|
|
105
|
+
return body, nil
|
|
106
|
+
}
|
|
107
|
+
|
|
51
108
|
// Reject even in-workspace parent links: managed paths name actual directories.
|
|
52
109
|
func contextParents(dir, rel string) error {
|
|
53
110
|
parent := filepath.Dir(rel)
|
|
@@ -169,3 +226,30 @@ func publishContext(featureDir string, opts contextOpts, body []byte) (out strin
|
|
|
169
226
|
}
|
|
170
227
|
return out, false, nil
|
|
171
228
|
}
|
|
229
|
+
|
|
230
|
+
// Caller holds the feature lock; a wave/role read-set is never replaced.
|
|
231
|
+
func retainPacketContext(workspace, name string, body []byte) error {
|
|
232
|
+
rel := filepath.Join("packets", name)
|
|
233
|
+
if err := contextParents(workspace, rel); err != nil {
|
|
234
|
+
return err
|
|
235
|
+
}
|
|
236
|
+
prior, err := (&contextSources{dir: workspace}).read(rel)
|
|
237
|
+
if err == nil {
|
|
238
|
+
if !bytes.Equal(prior, body) {
|
|
239
|
+
return fmt.Errorf("%s already retains a different context; render the new read-set under a new wave", rel)
|
|
240
|
+
}
|
|
241
|
+
return nil
|
|
242
|
+
}
|
|
243
|
+
if !errors.Is(err, os.ErrNotExist) {
|
|
244
|
+
return err
|
|
245
|
+
}
|
|
246
|
+
ws, err := os.OpenRoot(workspace)
|
|
247
|
+
if err != nil {
|
|
248
|
+
return err
|
|
249
|
+
}
|
|
250
|
+
defer func() { _ = ws.Close() }()
|
|
251
|
+
if err := ws.MkdirAll("packets", 0o755); err != nil {
|
|
252
|
+
return err
|
|
253
|
+
}
|
|
254
|
+
return fsutil.WriteFileAtomicIn(ws, rel, body, 0o600)
|
|
255
|
+
}
|
|
@@ -5,39 +5,25 @@ import (
|
|
|
5
5
|
"io"
|
|
6
6
|
"os"
|
|
7
7
|
"path/filepath"
|
|
8
|
+
"strconv"
|
|
8
9
|
"strings"
|
|
9
10
|
|
|
10
11
|
"github.com/devrites/devrites/internal/devritespaths"
|
|
11
12
|
"github.com/devrites/devrites/internal/gate"
|
|
12
13
|
"github.com/devrites/devrites/internal/state"
|
|
14
|
+
"github.com/devrites/devrites/internal/workflow"
|
|
13
15
|
)
|
|
14
16
|
|
|
15
17
|
// ContinuationUsage is the help text for `check continuation`.
|
|
16
18
|
const ContinuationUsage = "usage: devrites-engine check continuation [slug]"
|
|
17
19
|
|
|
18
|
-
// internalPhaseVerbs are autocomplete phase edges. /rite-ship and
|
|
19
|
-
// /rite-autocomplete stay user-facing (fresh GO, parked reopen).
|
|
20
|
-
var internalPhaseVerbs = map[string]bool{
|
|
21
|
-
"/rite-spec": true,
|
|
22
|
-
"/rite-clarify": true,
|
|
23
|
-
"/rite-temper": true,
|
|
24
|
-
"/rite-define": true,
|
|
25
|
-
"/rite-plan": true,
|
|
26
|
-
"/rite-vet": true,
|
|
27
|
-
"/rite-build": true,
|
|
28
|
-
"/rite-prove": true,
|
|
29
|
-
"/rite-polish": true,
|
|
30
|
-
"/rite-review": true,
|
|
31
|
-
"/rite-converge": true,
|
|
32
|
-
"/rite-seal": true,
|
|
33
|
-
}
|
|
34
|
-
|
|
35
20
|
// continuationVerdict is the cursor-shaped answer to "may autocomplete reply?".
|
|
36
21
|
// Required is an internal phase edge, not a human pause (exit stays 0).
|
|
37
22
|
// shortcut: a host that ends the turn without running this check still stops; no Stop hook (ADR-0015). Upgrade if a host can block stop without a DevRites hook payload.
|
|
38
23
|
type continuationVerdict struct {
|
|
39
24
|
Required bool
|
|
40
25
|
Invoke string
|
|
26
|
+
Source string
|
|
41
27
|
Reason string
|
|
42
28
|
}
|
|
43
29
|
|
|
@@ -71,6 +57,10 @@ func classifyContinuation(root, slug string) (continuationVerdict, error) {
|
|
|
71
57
|
if err != nil {
|
|
72
58
|
return continuationVerdict{}, err
|
|
73
59
|
}
|
|
60
|
+
invoke, internal, err := internalPhaseEdge(report.NextAction, slug)
|
|
61
|
+
if err != nil {
|
|
62
|
+
return continuationVerdict{}, err
|
|
63
|
+
}
|
|
74
64
|
if !afkPresent(root) {
|
|
75
65
|
return continuationVerdict{Reason: "no AFK sentinel"}, nil
|
|
76
66
|
}
|
|
@@ -100,11 +90,10 @@ func classifyContinuation(root, slug string) (continuationVerdict, error) {
|
|
|
100
90
|
}
|
|
101
91
|
}
|
|
102
92
|
}
|
|
103
|
-
|
|
104
|
-
if !ok {
|
|
93
|
+
if !internal {
|
|
105
94
|
return continuationVerdict{Reason: "next_action is not an internal phase edge"}, nil
|
|
106
95
|
}
|
|
107
|
-
return continuationVerdict{Required: true, Invoke: invoke, Reason: "AFK status=running internal phase edge"}, nil
|
|
96
|
+
return continuationVerdict{Required: true, Invoke: invoke, Source: slug, Reason: "AFK status=running internal phase edge"}, nil
|
|
108
97
|
}
|
|
109
98
|
|
|
110
99
|
func afkPresent(root string) bool {
|
|
@@ -112,18 +101,116 @@ func afkPresent(root string) bool {
|
|
|
112
101
|
return err == nil && info.Mode().IsRegular()
|
|
113
102
|
}
|
|
114
103
|
|
|
115
|
-
func internalPhaseEdge(action string) (string, bool) {
|
|
104
|
+
func internalPhaseEdge(action, slug string) (string, bool, error) {
|
|
105
|
+
command := workflow.ForAction(action)
|
|
106
|
+
policy, known := state.PolicyFor(state.Phase(command.Verb))
|
|
107
|
+
if !known || policy.Target == state.PhaseFrame || policy.Target == state.PhaseShip || policy.ResumeVerb == "" {
|
|
108
|
+
return "", false, nil
|
|
109
|
+
}
|
|
116
110
|
fields := strings.Fields(action)
|
|
117
|
-
if len(fields) == 0 ||
|
|
118
|
-
return "", false
|
|
111
|
+
if len(fields) == 0 || fields[0] != command.Claude && fields[0] != command.Codex && fields[0] != command.Skill {
|
|
112
|
+
return "", false, fmt.Errorf("next_action must start with one native phase invocation")
|
|
113
|
+
}
|
|
114
|
+
if workflow.ForAction(strings.Join(fields[1:], " ")).Verb != "" {
|
|
115
|
+
return "", false, fmt.Errorf("next_action must contain only one phase invocation")
|
|
116
|
+
}
|
|
117
|
+
for i, field := range fields[1:] {
|
|
118
|
+
if field == "—" {
|
|
119
|
+
fields = fields[:i+1]
|
|
120
|
+
break
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
args, err := continuationArguments(command, fields[1:], slug)
|
|
124
|
+
if err != nil {
|
|
125
|
+
return "", false, err
|
|
126
|
+
}
|
|
127
|
+
return strings.Join(append(fields[:1], args...), " "), true, nil
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func continuationArguments(command workflow.Command, args []string, slug string) ([]string, error) {
|
|
131
|
+
badArgs := func() ([]string, error) {
|
|
132
|
+
return nil, fmt.Errorf("invalid next_action arguments for %s: %q", command.Claude, strings.Join(args, " "))
|
|
133
|
+
}
|
|
134
|
+
var positional []string
|
|
135
|
+
for i := 0; i < len(args); i++ {
|
|
136
|
+
arg := args[i]
|
|
137
|
+
switch {
|
|
138
|
+
case arg == "--full" && (command.Verb == "vet" || command.Verb == "review" || command.Verb == "seal"):
|
|
139
|
+
case arg == "--cross-model" && command.Verb == "vet":
|
|
140
|
+
case arg == "--lookahead" && command.Verb == "spec":
|
|
141
|
+
case arg == "--mode" && command.Verb == "temper":
|
|
142
|
+
i++
|
|
143
|
+
if i >= len(args) {
|
|
144
|
+
return badArgs()
|
|
145
|
+
}
|
|
146
|
+
switch args[i] {
|
|
147
|
+
case "expand", "selective", "hold-rigor", "reduce-to-MVP":
|
|
148
|
+
default:
|
|
149
|
+
return badArgs()
|
|
150
|
+
}
|
|
151
|
+
case arg == "--parallel" && command.Verb == "build":
|
|
152
|
+
i++
|
|
153
|
+
if i >= len(args) {
|
|
154
|
+
return badArgs()
|
|
155
|
+
}
|
|
156
|
+
n, err := strconv.Atoi(args[i])
|
|
157
|
+
if err != nil || n < 1 || n > 10 {
|
|
158
|
+
return badArgs()
|
|
159
|
+
}
|
|
160
|
+
case strings.HasPrefix(arg, "-"):
|
|
161
|
+
return badArgs()
|
|
162
|
+
default:
|
|
163
|
+
positional = append(positional, arg)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if command.SourceIsFirstArgument() {
|
|
167
|
+
if len(positional) > 1 {
|
|
168
|
+
return badArgs()
|
|
169
|
+
}
|
|
170
|
+
if len(positional) == 1 && positional[0] != slug {
|
|
171
|
+
return nil, fmt.Errorf("next_action targets workspace %q, not selected source %q", positional[0], slug)
|
|
172
|
+
}
|
|
173
|
+
if len(positional) == 0 {
|
|
174
|
+
args = append(args, slug)
|
|
175
|
+
}
|
|
176
|
+
return args, nil
|
|
177
|
+
}
|
|
178
|
+
switch command.Verb {
|
|
179
|
+
case "build", "polish":
|
|
180
|
+
// Build's positional argument is a slice number/name, not a workspace.
|
|
181
|
+
return args, nil
|
|
182
|
+
case "plan":
|
|
183
|
+
if len(positional) == 0 {
|
|
184
|
+
return args, nil
|
|
185
|
+
}
|
|
186
|
+
if len(positional) <= 2 {
|
|
187
|
+
switch positional[0] {
|
|
188
|
+
case "decompose", "reslice", "repair", "reorder", "split", "unblock", "course-correct", "revise":
|
|
189
|
+
if len(positional) == 2 && positional[1] != slug {
|
|
190
|
+
return nil, fmt.Errorf("next_action targets workspace %q, not selected source %q", positional[1], slug)
|
|
191
|
+
}
|
|
192
|
+
return args, nil
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
case "review":
|
|
196
|
+
if len(positional) == 0 || len(positional) == 1 && positional[0] == "feature" {
|
|
197
|
+
return args, nil
|
|
198
|
+
}
|
|
199
|
+
if len(positional) == 2 && positional[0] == "slice" {
|
|
200
|
+
n, err := strconv.Atoi(positional[1])
|
|
201
|
+
if err == nil && n > 0 || state.ValidSliceID(positional[1]) {
|
|
202
|
+
return args, nil
|
|
203
|
+
}
|
|
204
|
+
}
|
|
119
205
|
}
|
|
120
|
-
return
|
|
206
|
+
return badArgs()
|
|
121
207
|
}
|
|
122
208
|
|
|
123
209
|
func writeContinuation(stdout io.Writer, verdict continuationVerdict) {
|
|
124
210
|
if verdict.Required {
|
|
125
211
|
fmt.Fprintln(stdout, "continuation: required")
|
|
126
212
|
fmt.Fprintf(stdout, "invoke: %s\n", verdict.Invoke)
|
|
213
|
+
fmt.Fprintf(stdout, "source: %s\n", verdict.Source)
|
|
127
214
|
} else {
|
|
128
215
|
fmt.Fprintln(stdout, "continuation: clear")
|
|
129
216
|
}
|
|
@@ -38,7 +38,20 @@ open --claim-session --claim records one file claim for the wave and fails,
|
|
|
38
38
|
creating no wave, while a live foreign session holds a path; the return that
|
|
39
39
|
completes the wave releases it, and abandon keeps it unless --release (any
|
|
40
40
|
host-confirmed terminal path without a completing return, such as a stop or a
|
|
41
|
-
refused launch).
|
|
41
|
+
refused launch). Wave IDs are single-use, including abandoned waves; retries
|
|
42
|
+
need a fresh ID and never inherit or release an earlier wave's claim.
|
|
43
|
+
Native starts, returns and abandonment retain subsecond timestamps. abandon
|
|
44
|
+
--release also retains host-terminal confirmation, even without a claim or when
|
|
45
|
+
its owned claim was already released; a failed release does not certify termination.
|
|
46
|
+
Finalization requires every launched
|
|
47
|
+
role to settle, and abandoned writers need actual returns or this confirmation.
|
|
48
|
+
An abandoned reviewer or proof-runner still needs a later complete account in
|
|
49
|
+
the same phase, role and slice subject; rerun abandon on a legacy record to
|
|
50
|
+
record reconciliation time first.
|
|
51
|
+
An abandoned writer's unreleased claim blocks overlapping writers across every
|
|
52
|
+
live or archived feature in this repository, even in the same session or after
|
|
53
|
+
TTL expiry, until explicit release or proven host termination.
|
|
54
|
+
return --handle, when supplied, must match the role's recorded host handle.
|
|
42
55
|
status --wave exits
|
|
43
56
|
nonzero until that wave is complete; bare status lists every open, sealed,
|
|
44
57
|
or abandoned wave, folds complete waves into one count line (--all lists
|
|
@@ -93,6 +106,8 @@ type dispatchWave struct {
|
|
|
93
106
|
Phase string `json:"phase"`
|
|
94
107
|
State string `json:"state"` // open, sealed, abandoned, complete
|
|
95
108
|
Reason string `json:"reason,omitempty"`
|
|
109
|
+
AbandonedAt string `json:"abandoned_at,omitempty"`
|
|
110
|
+
TerminalAt string `json:"terminal_at,omitempty"`
|
|
96
111
|
Roles map[string]*dispatchRole `json:"roles"`
|
|
97
112
|
Slices []string `json:"slices,omitempty"`
|
|
98
113
|
ResliceReview string `json:"reslice_review,omitempty"`
|
|
@@ -131,7 +146,10 @@ func dispatchPath(root, slug string) string {
|
|
|
131
146
|
}
|
|
132
147
|
|
|
133
148
|
func loadDispatch(root, slug string) (*dispatchFile, string, error) {
|
|
134
|
-
|
|
149
|
+
return loadDispatchFile(dispatchPath(root, slug))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
func loadDispatchFile(path string) (*dispatchFile, string, error) {
|
|
135
153
|
f := &dispatchFile{Waves: map[string]*dispatchWave{}}
|
|
136
154
|
data, err := os.ReadFile(path) // #nosec G304 -- path is a validated feature workspace artifact
|
|
137
155
|
if os.IsNotExist(err) {
|
|
@@ -140,16 +158,75 @@ func loadDispatch(root, slug string) (*dispatchFile, string, error) {
|
|
|
140
158
|
if err != nil {
|
|
141
159
|
return nil, path, err
|
|
142
160
|
}
|
|
161
|
+
f = &dispatchFile{}
|
|
143
162
|
if err := json.Unmarshal(data, f); err != nil {
|
|
144
163
|
return nil, path, fmt.Errorf("dispatch.json is not valid JSON: %w", err)
|
|
145
164
|
}
|
|
146
165
|
if f.Waves == nil {
|
|
147
|
-
|
|
166
|
+
return nil, path, fmt.Errorf("dispatch.json is malformed (missing waves)")
|
|
148
167
|
}
|
|
149
|
-
//
|
|
168
|
+
// Validate every account before any caller can use or mutate the ledger.
|
|
150
169
|
for name, w := range f.Waves {
|
|
151
|
-
if w
|
|
152
|
-
|
|
170
|
+
if w == nil || strings.TrimSpace(name) == "" || strings.TrimSpace(w.Phase) == "" || len(w.Roles) == 0 ||
|
|
171
|
+
!slices.Contains([]string{"open", "sealed", "complete", "abandoned"}, w.State) {
|
|
172
|
+
return nil, path, fmt.Errorf("wave %q is malformed (invalid phase, state or roles)", name)
|
|
173
|
+
}
|
|
174
|
+
handles := map[string]bool{}
|
|
175
|
+
allStarted, allReturned := true, true
|
|
176
|
+
for role, r := range w.Roles {
|
|
177
|
+
if r == nil || strings.TrimSpace(role) == "" {
|
|
178
|
+
return nil, path, fmt.Errorf("wave %q role %q is malformed (missing account)", name, role)
|
|
179
|
+
}
|
|
180
|
+
started := r.Handle != ""
|
|
181
|
+
if started != (r.StartedAt != "") || r.Returned != (r.ReturnedAt != "") ||
|
|
182
|
+
(r.Returned && !started) || (started && handles[r.Handle]) ||
|
|
183
|
+
(w.State == "open" && r.Returned) ||
|
|
184
|
+
((w.State == "sealed" || w.State == "complete") && !started) ||
|
|
185
|
+
(w.State == "complete" && !r.Returned) {
|
|
186
|
+
return nil, path, fmt.Errorf("wave %q role %q is malformed (inconsistent start or return)", name, role)
|
|
187
|
+
}
|
|
188
|
+
allStarted = allStarted && started
|
|
189
|
+
allReturned = allReturned && r.Returned
|
|
190
|
+
if started {
|
|
191
|
+
start, startErr := time.Parse(time.RFC3339Nano, r.StartedAt)
|
|
192
|
+
if startErr != nil {
|
|
193
|
+
return nil, path, fmt.Errorf("wave %q role %q is malformed (invalid start timestamp)", name, role)
|
|
194
|
+
}
|
|
195
|
+
if r.Returned {
|
|
196
|
+
end, endErr := time.Parse(time.RFC3339Nano, r.ReturnedAt)
|
|
197
|
+
if endErr != nil || end.Before(start) {
|
|
198
|
+
return nil, path, fmt.Errorf("wave %q role %q is malformed (invalid return timestamp)", name, role)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if started {
|
|
203
|
+
handles[r.Handle] = true
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (w.State == "open" && allStarted) || (w.State == "sealed" && allReturned) {
|
|
207
|
+
return nil, path, fmt.Errorf("wave %q is malformed (inconsistent barrier state)", name)
|
|
208
|
+
}
|
|
209
|
+
if w.AbandonedAt != "" {
|
|
210
|
+
if _, err := time.Parse(time.RFC3339Nano, w.AbandonedAt); err != nil || w.State != "abandoned" {
|
|
211
|
+
return nil, path, fmt.Errorf("wave %q is malformed (invalid abandonment timestamp)", name)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if w.TerminalAt != "" {
|
|
215
|
+
terminal, err := time.Parse(time.RFC3339Nano, w.TerminalAt)
|
|
216
|
+
abandoned, abandonErr := time.Parse(time.RFC3339Nano, w.AbandonedAt)
|
|
217
|
+
if err != nil || abandonErr != nil || w.State != "abandoned" || terminal.Before(abandoned) {
|
|
218
|
+
return nil, path, fmt.Errorf("wave %q is malformed (invalid terminal timestamp)", name)
|
|
219
|
+
}
|
|
220
|
+
for _, account := range w.Roles {
|
|
221
|
+
for _, stamp := range []string{account.StartedAt, account.ReturnedAt} {
|
|
222
|
+
if stamp != "" {
|
|
223
|
+
at, _ := time.Parse(time.RFC3339Nano, stamp)
|
|
224
|
+
if terminal.Before(at) {
|
|
225
|
+
return nil, path, fmt.Errorf("wave %q is malformed (terminal timestamp precedes account)", name)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
153
230
|
}
|
|
154
231
|
}
|
|
155
232
|
return f, path, nil
|
|
@@ -166,7 +243,7 @@ func saveDispatch(path string, f *dispatchFile) error {
|
|
|
166
243
|
return state.AtomicWrite(path, data, 0o600)
|
|
167
244
|
}
|
|
168
245
|
|
|
169
|
-
func nowStamp() string { return time.Now().UTC().Format(time.
|
|
246
|
+
func nowStamp() string { return time.Now().UTC().Format(time.RFC3339Nano) }
|
|
170
247
|
|
|
171
248
|
// RunDispatch implements the dispatch wave state machine.
|
|
172
249
|
func RunDispatch(root string, args []string, stdout, stderr io.Writer) int {
|
|
@@ -259,8 +336,11 @@ func RunDispatch(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
259
336
|
// an update and strand the wave short of complete.
|
|
260
337
|
code := 0
|
|
261
338
|
if err := state.WithFeatureLock(root, slug, func() error {
|
|
262
|
-
|
|
263
|
-
return
|
|
339
|
+
// Feature → claims is the lock order; never acquire another feature.
|
|
340
|
+
return state.WithLock(claimsPath(root)+".lock", func() error {
|
|
341
|
+
code = dispatchLocked(root, slug, sub, phase, wave, role, handle, reason, roles, sliceIDs, review, contract, packet, outcome, result, all, claim, stdout, stderr)
|
|
342
|
+
return nil
|
|
343
|
+
})
|
|
264
344
|
}); err != nil {
|
|
265
345
|
fmt.Fprintf(stderr, "dispatch: %v\n", err)
|
|
266
346
|
return 3
|
|
@@ -293,8 +373,7 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
|
|
|
293
373
|
fmt.Fprint(stderr, dispatchUsage)
|
|
294
374
|
return 2
|
|
295
375
|
}
|
|
296
|
-
prev := getWave()
|
|
297
|
-
if prev != nil && prev.State != "abandoned" {
|
|
376
|
+
if prev := getWave(); prev != nil {
|
|
298
377
|
return fail("wave %q already exists (state %s)", wave, prev.State)
|
|
299
378
|
}
|
|
300
379
|
w := &dispatchWave{Phase: phase, State: "open", Roles: map[string]*dispatchRole{}, Slices: sliceIDs}
|
|
@@ -315,6 +394,9 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
|
|
|
315
394
|
if err := lintWrightContractFor(root, slug, contract); err != nil {
|
|
316
395
|
return fail("BLOCKED: %v", err)
|
|
317
396
|
}
|
|
397
|
+
if err := lintPacketDiffs(root, slug, contract, false); err != nil {
|
|
398
|
+
return fail("BLOCKED: %v", err)
|
|
399
|
+
}
|
|
318
400
|
}
|
|
319
401
|
for _, r := range roles {
|
|
320
402
|
if isReviewerRole(r) {
|
|
@@ -331,6 +413,22 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
|
|
|
331
413
|
break
|
|
332
414
|
}
|
|
333
415
|
}
|
|
416
|
+
var claimPaths []string
|
|
417
|
+
if len(claim.paths) > 0 {
|
|
418
|
+
claimPaths, err = normalizeClaimPaths(claim.paths)
|
|
419
|
+
if err != nil {
|
|
420
|
+
return fail("%v", err)
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
admissionPaths := slices.Clone(claimPaths)
|
|
424
|
+
if isWrightWave(w) {
|
|
425
|
+
admissionPaths = append(admissionPaths, sliceWriteSet(root, slug, w.Slices)...)
|
|
426
|
+
}
|
|
427
|
+
if len(claimPaths) == 0 {
|
|
428
|
+
if err := checkAbandonedWriterClaims(root, admissionPaths); err != nil {
|
|
429
|
+
return fail("BLOCKED: %v", err)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
334
432
|
if err := openTripwire(root, slug, w, claim.paths); err != nil {
|
|
335
433
|
return fail("BLOCKED: %v", err)
|
|
336
434
|
}
|
|
@@ -339,15 +437,16 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
|
|
|
339
437
|
fmt.Fprintln(stderr, "dispatch: --claim-session and --claim go together (--claim-ttl needs both)")
|
|
340
438
|
return 2
|
|
341
439
|
}
|
|
342
|
-
|
|
343
|
-
|
|
440
|
+
if !validSession(claim.session) {
|
|
441
|
+
fmt.Fprintln(stderr, "claim: --session is required (1-128 chars: letters, digits, - _ . : /)")
|
|
442
|
+
return 2
|
|
443
|
+
}
|
|
444
|
+
// The ledger lock spans admission, claim append, and publication.
|
|
445
|
+
id, code := claimAddLocked(claimsPath(root), claim.session, "dispatch wave "+wave, cmp.Or(claim.ttl, claimDefaultTTL), claimPaths, admissionPaths, stdout, stderr)
|
|
344
446
|
if code != 0 {
|
|
345
447
|
return code
|
|
346
448
|
}
|
|
347
449
|
w.Claim, w.ClaimSession = id, claim.session
|
|
348
|
-
} else if prev != nil {
|
|
349
|
-
// Inherit the abandoned wave's kept claim so the completing return releases it.
|
|
350
|
-
w.Claim, w.ClaimSession = prev.Claim, prev.ClaimSession
|
|
351
450
|
}
|
|
352
451
|
if project, _, ok := treeProject(root); ok && isBuildWrightWave(w) {
|
|
353
452
|
// Uncommitted rounds look like additions against HEAD; this snapshot
|
|
@@ -358,10 +457,9 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
|
|
|
358
457
|
w.Tree = tree
|
|
359
458
|
}
|
|
360
459
|
}
|
|
361
|
-
superseded := prev != nil && prev.Claim != "" && prev.Claim != w.Claim
|
|
362
460
|
f.Waves[wave] = w
|
|
363
461
|
if err := saveDispatch(path, f); err != nil {
|
|
364
|
-
if w.Claim != ""
|
|
462
|
+
if w.Claim != "" {
|
|
365
463
|
_, _ = releaseClaim(root, w.ClaimSession, w.Claim)
|
|
366
464
|
}
|
|
367
465
|
return fail("%v", err)
|
|
@@ -370,9 +468,6 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
|
|
|
370
468
|
if err := ensureTreeBaseline(root, slug, stdout); err != nil {
|
|
371
469
|
fmt.Fprintf(stderr, "dispatch: WARNING: %s not recorded: %v\n", treeBaselineFile, err)
|
|
372
470
|
}
|
|
373
|
-
if superseded {
|
|
374
|
-
releaseWaveClaim(root, wave, prev, stdout, stderr)
|
|
375
|
-
}
|
|
376
471
|
for _, line := range rounds {
|
|
377
472
|
fmt.Fprintln(stdout, line)
|
|
378
473
|
}
|
|
@@ -455,6 +550,9 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
|
|
|
455
550
|
if r == nil {
|
|
456
551
|
return fail("role %q not declared in wave %q", role, wave)
|
|
457
552
|
}
|
|
553
|
+
if handle != "" && handle != r.Handle {
|
|
554
|
+
return fail("handle %q does not match role %q's recorded handle %q", handle, role, r.Handle)
|
|
555
|
+
}
|
|
458
556
|
if r.Returned {
|
|
459
557
|
return fail("role %q already returned", role)
|
|
460
558
|
}
|
|
@@ -569,14 +667,23 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
|
|
|
569
667
|
already := w.State == "abandoned"
|
|
570
668
|
w.State = "abandoned"
|
|
571
669
|
w.Reason = reason
|
|
670
|
+
if w.AbandonedAt == "" {
|
|
671
|
+
w.AbandonedAt = nowStamp()
|
|
672
|
+
}
|
|
572
673
|
// A timeout or unknown outcome keeps the claim; --release is for any
|
|
573
674
|
// host-confirmed terminal path without a completing return (a stop or a
|
|
574
675
|
// refused launch), including a re-run once a timed-out child is confirmed.
|
|
575
676
|
if claim.release {
|
|
576
677
|
if w.Claim == "" {
|
|
577
678
|
fmt.Fprintf(stderr, "dispatch: --release: wave %s holds no claim\n", wave)
|
|
679
|
+
if w.TerminalAt == "" {
|
|
680
|
+
w.TerminalAt = nowStamp()
|
|
681
|
+
}
|
|
578
682
|
} else if releaseWaveClaim(root, wave, w, stdout, stderr) {
|
|
579
683
|
w.Claim, w.ClaimSession = "", ""
|
|
684
|
+
if w.TerminalAt == "" {
|
|
685
|
+
w.TerminalAt = nowStamp()
|
|
686
|
+
}
|
|
580
687
|
}
|
|
581
688
|
}
|
|
582
689
|
if err := saveDispatch(path, f); err != nil {
|
|
@@ -592,10 +699,36 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
|
|
|
592
699
|
return 2
|
|
593
700
|
}
|
|
594
701
|
|
|
595
|
-
//
|
|
596
|
-
|
|
702
|
+
// Caller holds the repository claims lock through new-wave publication.
|
|
703
|
+
func checkAbandonedWriterClaims(root string, paths []string) error {
|
|
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.
|
|
597
726
|
func releaseWaveClaim(root, wave string, w *dispatchWave, stdout, stderr io.Writer) bool {
|
|
598
727
|
n, err := releaseClaim(root, w.ClaimSession, w.Claim)
|
|
728
|
+
if w.State == "abandoned" && errors.Is(err, errClaimAlreadyReleased) {
|
|
729
|
+
fmt.Fprintf(stdout, "dispatch: wave %s claim %s already released\n", wave, w.Claim)
|
|
730
|
+
return true
|
|
731
|
+
}
|
|
599
732
|
if err != nil {
|
|
600
733
|
fmt.Fprintf(stderr, "dispatch: WARNING: wave %s claim not released: %v\n", wave, err)
|
|
601
734
|
return false
|
|
@@ -799,7 +932,7 @@ func admitWrightRounds(root, slug string, f *dispatchFile, w *dispatchWave, revi
|
|
|
799
932
|
return lines, 0
|
|
800
933
|
}
|
|
801
934
|
if review == "" {
|
|
802
|
-
fmt.Fprintf(stderr, "dispatch: BLOCKED: %s reached %d %s rounds; run a reslice review (acceptance-preserving split or simpler proof seam, or a drift.md entry recording why neither applies) and
|
|
935
|
+
fmt.Fprintf(stderr, "dispatch: BLOCKED: %s reached %d %s rounds; run a reslice review (acceptance-preserving split or simpler proof seam, or a drift.md entry recording why neither applies) and open a fresh wave with --reslice-review <DRIFT-ID>\n",
|
|
803
936
|
strings.Join(overCap, ","), wrightRoundsPerReview, kind)
|
|
804
937
|
return nil, 3
|
|
805
938
|
}
|