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
|
@@ -14,7 +14,6 @@ import (
|
|
|
14
14
|
"strings"
|
|
15
15
|
|
|
16
16
|
"github.com/devrites/devrites/internal/hostpack"
|
|
17
|
-
"github.com/devrites/devrites/internal/safepath"
|
|
18
17
|
)
|
|
19
18
|
|
|
20
19
|
func (r *runner) preflightInstall() error {
|
|
@@ -46,10 +45,15 @@ func (r *runner) preflightInstall() error {
|
|
|
46
45
|
return err
|
|
47
46
|
}
|
|
48
47
|
if !snapshot.missing && r.isPinnedAlias(rel) {
|
|
49
|
-
//
|
|
50
|
-
|
|
48
|
+
// Carry pins without adopting edits; only legacy pins need a baseline.
|
|
49
|
+
if record.Hash == "" {
|
|
50
|
+
record.Hash = snapshot.hash
|
|
51
|
+
}
|
|
52
|
+
if kind := managedConflict(record, snapshot); kind != "" && !r.opts.Force {
|
|
53
|
+
conflicts = append(conflicts, fmt.Sprintf("%s: %s", kind, rel))
|
|
54
|
+
}
|
|
51
55
|
r.addManifest(rel)
|
|
52
|
-
r.addInstallRecord(rel,
|
|
56
|
+
r.addInstallRecord(rel, record.Hash)
|
|
53
57
|
continue
|
|
54
58
|
}
|
|
55
59
|
if !snapshot.missing {
|
|
@@ -101,23 +105,33 @@ func (r *runner) preflightMerges() error {
|
|
|
101
105
|
if _, _, err := r.markerMergeNext(m.merge); err != nil {
|
|
102
106
|
return fmt.Errorf("merge %s: %w", m.merge.TargetRel, err)
|
|
103
107
|
}
|
|
108
|
+
if err := r.preflightMarkerOwnership(m.merge.TargetRel, m.merge.MarkerRel, m.merge.MarkerText); err != nil {
|
|
109
|
+
return err
|
|
110
|
+
}
|
|
104
111
|
}
|
|
105
112
|
if r.opts.WithCodex {
|
|
106
113
|
if _, err := r.codexConfigNext(); err != nil {
|
|
107
114
|
return fmt.Errorf("merge %s: %w", hostpack.CodexConfigMerge.TargetRel, err)
|
|
108
115
|
}
|
|
116
|
+
merge := hostpack.CodexConfigMerge
|
|
117
|
+
if err := r.preflightMarkerOwnership(merge.TargetRel, merge.MarkerRel, merge.MarkerText); err != nil {
|
|
118
|
+
return err
|
|
119
|
+
}
|
|
109
120
|
}
|
|
110
121
|
_, settings, err := r.claudeSettingsNext()
|
|
111
122
|
if err != nil {
|
|
112
123
|
return fmt.Errorf("seed claude settings: %w", err)
|
|
113
124
|
}
|
|
114
|
-
|
|
125
|
+
merge := hostpack.ClaudeSettingsMerge
|
|
126
|
+
return r.preflightMarkerOwnership(merge.TargetRel, merge.MarkerRel, claudeSettingsMarkerText(r.pendingClaudeSettingsOwnership(settings)))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func (r *runner) preflightMarkerOwnership(target, marker, text string) error {
|
|
115
130
|
if snapshot := r.preflight[marker]; !snapshot.missing && !r.opts.Force {
|
|
116
131
|
if _, managed := r.prev[marker]; !managed {
|
|
117
|
-
|
|
118
|
-
sum := sha256.Sum256([]byte(text))
|
|
132
|
+
sum := sha256.Sum256([]byte(text + "\n"))
|
|
119
133
|
if snapshot.hash != fmt.Sprintf("sha256:%x", sum[:]) {
|
|
120
|
-
return fmt.Errorf("cannot merge %s without owning %s; use --force to replace the marker",
|
|
134
|
+
return fmt.Errorf("cannot merge %s without owning %s; use --force to replace the marker", target, marker)
|
|
121
135
|
}
|
|
122
136
|
}
|
|
123
137
|
}
|
|
@@ -249,10 +263,17 @@ func (r *runner) installMergeTargets() []string {
|
|
|
249
263
|
|
|
250
264
|
func (r *runner) rememberPath(rel string) (pathSnapshot, error) {
|
|
251
265
|
rel = filepath.ToSlash(rel)
|
|
266
|
+
if r.targetInfo == nil {
|
|
267
|
+
info, err := os.Stat(r.target)
|
|
268
|
+
if err != nil {
|
|
269
|
+
return pathSnapshot{}, err
|
|
270
|
+
}
|
|
271
|
+
r.targetInfo = info
|
|
272
|
+
}
|
|
252
273
|
if snapshot, ok := r.preflight[rel]; ok {
|
|
253
274
|
return snapshot, nil
|
|
254
275
|
}
|
|
255
|
-
snapshot, err := inspectManagedPath(
|
|
276
|
+
snapshot, err := r.inspectManagedPath(rel)
|
|
256
277
|
if err != nil {
|
|
257
278
|
return pathSnapshot{}, err
|
|
258
279
|
}
|
|
@@ -264,7 +285,7 @@ func (r *runner) rememberPath(rel string) (pathSnapshot, error) {
|
|
|
264
285
|
// verified write (marker merge, strip). Needed when several managed merges
|
|
265
286
|
// share one target such as AGENTS.md.
|
|
266
287
|
func (r *runner) refreshPreflight(rel string) error {
|
|
267
|
-
snap, err := inspectManagedPath(
|
|
288
|
+
snap, err := r.inspectManagedPath(rel)
|
|
268
289
|
if err != nil {
|
|
269
290
|
return err
|
|
270
291
|
}
|
|
@@ -273,12 +294,15 @@ func (r *runner) refreshPreflight(rel string) error {
|
|
|
273
294
|
}
|
|
274
295
|
|
|
275
296
|
func (r *runner) recheckPath(rel string) error {
|
|
297
|
+
if err := r.checkTargetIdentity(); err != nil {
|
|
298
|
+
return err
|
|
299
|
+
}
|
|
276
300
|
rel = filepath.ToSlash(rel)
|
|
277
301
|
before, ok := r.preflight[rel]
|
|
278
302
|
if !ok {
|
|
279
303
|
return fmt.Errorf("path %s was not preflighted", rel)
|
|
280
304
|
}
|
|
281
|
-
now, err := inspectManagedPath(
|
|
305
|
+
now, err := r.inspectManagedPath(rel)
|
|
282
306
|
if err != nil {
|
|
283
307
|
return err
|
|
284
308
|
}
|
|
@@ -287,6 +311,9 @@ func (r *runner) recheckPath(rel string) error {
|
|
|
287
311
|
}
|
|
288
312
|
if afterRecheckPath != nil {
|
|
289
313
|
afterRecheckPath(rel)
|
|
314
|
+
if err := r.checkTargetIdentity(); err != nil {
|
|
315
|
+
return err
|
|
316
|
+
}
|
|
290
317
|
}
|
|
291
318
|
return nil
|
|
292
319
|
}
|
|
@@ -295,20 +322,35 @@ func (r *runner) recheckPath(rel string) error {
|
|
|
295
322
|
// recheck and the write that follows it.
|
|
296
323
|
var afterRecheckPath func(rel string)
|
|
297
324
|
|
|
325
|
+
func (r *runner) inspectManagedPath(rel string) (pathSnapshot, error) {
|
|
326
|
+
if r.targetRoot != nil {
|
|
327
|
+
return inspectManagedPathIn(r.targetRoot, rel)
|
|
328
|
+
}
|
|
329
|
+
return inspectManagedPath(r.target, rel)
|
|
330
|
+
}
|
|
331
|
+
|
|
298
332
|
func inspectManagedPath(target, rel string) (pathSnapshot, error) {
|
|
333
|
+
root, err := os.OpenRoot(target)
|
|
334
|
+
if err != nil {
|
|
335
|
+
return pathSnapshot{}, err
|
|
336
|
+
}
|
|
337
|
+
defer func() { _ = root.Close() }()
|
|
338
|
+
return inspectManagedPathIn(root, rel)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
func inspectManagedPathIn(root *os.Root, rel string) (pathSnapshot, error) {
|
|
299
342
|
native := filepath.FromSlash(rel)
|
|
300
343
|
if !filepath.IsLocal(native) {
|
|
301
344
|
return pathSnapshot{}, fmt.Errorf("refusing unsafe managed path %q", rel)
|
|
302
345
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
return pathSnapshot{}, fmt.Errorf("refusing managed path outside target: %s", rel)
|
|
346
|
+
if root == nil {
|
|
347
|
+
return pathSnapshot{}, fmt.Errorf("install target is not open")
|
|
306
348
|
}
|
|
307
|
-
walk :=
|
|
349
|
+
walk := ""
|
|
308
350
|
parts := strings.Split(native, string(filepath.Separator))
|
|
309
351
|
for _, part := range parts {
|
|
310
352
|
walk = filepath.Join(walk, part)
|
|
311
|
-
info, err :=
|
|
353
|
+
info, err := root.Lstat(walk)
|
|
312
354
|
if os.IsNotExist(err) {
|
|
313
355
|
return pathSnapshot{missing: true}, nil
|
|
314
356
|
}
|
|
@@ -319,7 +361,7 @@ func inspectManagedPath(target, rel string) (pathSnapshot, error) {
|
|
|
319
361
|
return pathSnapshot{}, fmt.Errorf("refusing symlink or junction in managed path: %s", rel)
|
|
320
362
|
}
|
|
321
363
|
}
|
|
322
|
-
info, err :=
|
|
364
|
+
info, err := root.Lstat(native)
|
|
323
365
|
if os.IsNotExist(err) {
|
|
324
366
|
return pathSnapshot{missing: true}, nil
|
|
325
367
|
}
|
|
@@ -329,8 +371,7 @@ func inspectManagedPath(target, rel string) (pathSnapshot, error) {
|
|
|
329
371
|
if !info.Mode().IsRegular() {
|
|
330
372
|
return pathSnapshot{}, fmt.Errorf("refusing non-regular managed path: %s", rel)
|
|
331
373
|
}
|
|
332
|
-
|
|
333
|
-
f, err := os.Open(dest)
|
|
374
|
+
f, err := root.Open(native)
|
|
334
375
|
if err != nil {
|
|
335
376
|
return pathSnapshot{}, fmt.Errorf("read managed path %s: %w", rel, err)
|
|
336
377
|
}
|
|
@@ -12,13 +12,20 @@ import (
|
|
|
12
12
|
|
|
13
13
|
func (r *runner) uninstall() error {
|
|
14
14
|
mf := filepath.Join(r.target, ManifestName)
|
|
15
|
-
|
|
15
|
+
data, err := r.readConfined(mf)
|
|
16
|
+
if err != nil && !os.IsNotExist(err) {
|
|
17
|
+
return fmt.Errorf("read manifest %s: %w", mf, err)
|
|
18
|
+
}
|
|
19
|
+
_, entries, err := parseManifestData(data)
|
|
16
20
|
if err != nil {
|
|
17
21
|
return err
|
|
18
22
|
}
|
|
19
23
|
if len(entries) == 0 {
|
|
20
24
|
return fmt.Errorf("no DevRites manifest at %s - nothing to uninstall", mf)
|
|
21
25
|
}
|
|
26
|
+
if err := r.preflightBinaryRemoval(); err != nil {
|
|
27
|
+
return err
|
|
28
|
+
}
|
|
22
29
|
fmt.Fprintln(r.opts.Stdout, "DevRites uninstaller")
|
|
23
30
|
fmt.Fprintf(r.opts.Stdout, " target : %s\n", r.target)
|
|
24
31
|
fmt.Fprintf(r.opts.Stdout, " manifest: %s\n", mf)
|
|
@@ -99,6 +106,15 @@ func (r *runner) uninstall() error {
|
|
|
99
106
|
r.stats.missing++
|
|
100
107
|
}
|
|
101
108
|
}
|
|
109
|
+
if !r.opts.DryRun {
|
|
110
|
+
for _, d := range dirs {
|
|
111
|
+
r.pruneEmptyDirs(d)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Keep ownership available for a retry if the external binary cannot be removed.
|
|
115
|
+
if err := r.removeBinary(); err != nil {
|
|
116
|
+
return fmt.Errorf("remove engine binary: %w", err)
|
|
117
|
+
}
|
|
102
118
|
if r.opts.DryRun {
|
|
103
119
|
fmt.Fprintf(r.opts.Stdout, " [remove] %s\n", ManifestName)
|
|
104
120
|
} else {
|
|
@@ -106,13 +122,7 @@ func (r *runner) uninstall() error {
|
|
|
106
122
|
return err
|
|
107
123
|
}
|
|
108
124
|
_ = r.removeConfined(mf)
|
|
109
|
-
|
|
110
|
-
for _, d := range dirs {
|
|
111
|
-
pruneEmptyDirs(d, r.target)
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
if err := r.removeBinary(); err != nil {
|
|
115
|
-
return fmt.Errorf("remove engine binary: %w", err)
|
|
125
|
+
r.pruneEmptyDirs(filepath.Dir(mf))
|
|
116
126
|
}
|
|
117
127
|
fmt.Fprintln(r.opts.Stdout)
|
|
118
128
|
if r.opts.DryRun {
|
|
@@ -141,7 +151,7 @@ func (r *runner) dryRunStripMarker(tree map[string][]byte, merge hostpack.Manage
|
|
|
141
151
|
write := func(data []byte) error { tree[path] = data; return nil }
|
|
142
152
|
remove := func() error { tree[path] = nil; return nil }
|
|
143
153
|
if merge.MarkerRel == hostpack.LegacyCodexHooksMerge.MarkerRel {
|
|
144
|
-
return stripHooksWith(path, write, remove)
|
|
154
|
+
return r.stripHooksWith(path, write, remove)
|
|
145
155
|
}
|
|
146
156
|
if merge.TargetRel == hostpack.ClaudeSettingsMerge.TargetRel {
|
|
147
157
|
return r.stripClaudeSettingsWith(path, write, remove)
|
|
@@ -9,7 +9,6 @@ import (
|
|
|
9
9
|
"strings"
|
|
10
10
|
"time"
|
|
11
11
|
|
|
12
|
-
"github.com/devrites/devrites/internal/hostpack"
|
|
13
12
|
"github.com/devrites/devrites/internal/release"
|
|
14
13
|
)
|
|
15
14
|
|
|
@@ -65,9 +64,6 @@ func runUpdate(opts Options) error {
|
|
|
65
64
|
if err != nil {
|
|
66
65
|
return fmt.Errorf("resolve payload %s: %w", opts.PayloadDir, err)
|
|
67
66
|
}
|
|
68
|
-
if err := hostpack.ValidatePayload(os.DirFS(payload), true, true, true, true, true); err != nil {
|
|
69
|
-
return fmt.Errorf("local update payload under %s is invalid: %w", payload, err)
|
|
70
|
-
}
|
|
71
67
|
installFlags := manifestHeader(mf, "devrites-flags")
|
|
72
68
|
next := DefaultOptions(ModeInstall)
|
|
73
69
|
next.Stdout = opts.Stdout
|
|
@@ -5,6 +5,7 @@ import (
|
|
|
5
5
|
"crypto/rand"
|
|
6
6
|
"encoding/hex"
|
|
7
7
|
"encoding/json"
|
|
8
|
+
"errors"
|
|
8
9
|
"fmt"
|
|
9
10
|
"io"
|
|
10
11
|
"os"
|
|
@@ -17,6 +18,7 @@ import (
|
|
|
17
18
|
|
|
18
19
|
"github.com/devrites/devrites/internal/devritespaths"
|
|
19
20
|
"github.com/devrites/devrites/internal/parallel"
|
|
21
|
+
"github.com/devrites/devrites/internal/safepath"
|
|
20
22
|
"github.com/devrites/devrites/internal/state"
|
|
21
23
|
)
|
|
22
24
|
|
|
@@ -262,6 +264,137 @@ func claimPathsOverlap(a, b string) bool {
|
|
|
262
264
|
return a == b || strings.HasPrefix(a, b+"/") || strings.HasPrefix(b, a+"/")
|
|
263
265
|
}
|
|
264
266
|
|
|
267
|
+
// Retained writer ownership belongs to the repository ledger, not a session or
|
|
268
|
+
// TTL. Caller holds its lock; workspace moves use the same lock.
|
|
269
|
+
func retainedWriterConflicts(root string, latest map[string]*claimRecord, paths []string, probe holderProbe) ([]*claimRecord, error) {
|
|
270
|
+
var conflicts []*claimRecord
|
|
271
|
+
var physical []string
|
|
272
|
+
seen := map[string]bool{}
|
|
273
|
+
for _, parent := range []string{"work", "archive"} {
|
|
274
|
+
base := filepath.Join(root, parent)
|
|
275
|
+
if parent == "archive" {
|
|
276
|
+
var err error
|
|
277
|
+
base, err = devritespaths.ArchiveDirChecked(root)
|
|
278
|
+
if err != nil {
|
|
279
|
+
return nil, err
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if info, err := os.Lstat(base); os.IsNotExist(err) {
|
|
283
|
+
continue
|
|
284
|
+
} else if err != nil {
|
|
285
|
+
return nil, err
|
|
286
|
+
} else if !info.IsDir() {
|
|
287
|
+
return nil, fmt.Errorf("claim: workspace collection %s is not a canonical directory", base)
|
|
288
|
+
}
|
|
289
|
+
entries, err := os.ReadDir(base)
|
|
290
|
+
if err != nil {
|
|
291
|
+
return nil, err
|
|
292
|
+
}
|
|
293
|
+
for _, entry := range entries {
|
|
294
|
+
if entry.Type().IsRegular() {
|
|
295
|
+
continue
|
|
296
|
+
}
|
|
297
|
+
var dir string
|
|
298
|
+
if parent == "work" {
|
|
299
|
+
dir, err = devritespaths.ExistingCanonicalFeatureDirChecked(root, entry.Name())
|
|
300
|
+
} else {
|
|
301
|
+
dir, err = devritespaths.ExistingArchivedFeatureDirChecked(root, entry.Name())
|
|
302
|
+
}
|
|
303
|
+
if err != nil {
|
|
304
|
+
return nil, err
|
|
305
|
+
}
|
|
306
|
+
path := filepath.Join(dir, "dispatch.json")
|
|
307
|
+
if info, err := os.Lstat(path); os.IsNotExist(err) {
|
|
308
|
+
continue
|
|
309
|
+
} else if err != nil {
|
|
310
|
+
return nil, err
|
|
311
|
+
} else if !info.Mode().IsRegular() {
|
|
312
|
+
return nil, fmt.Errorf("claim: %s is not a regular dispatch ledger", path)
|
|
313
|
+
}
|
|
314
|
+
f, _, err := loadDispatchFile(path)
|
|
315
|
+
if err != nil {
|
|
316
|
+
return nil, fmt.Errorf("%s: %w", path, err)
|
|
317
|
+
}
|
|
318
|
+
for name, w := range f.Waves {
|
|
319
|
+
// A declared claim is write intent regardless of the host role label.
|
|
320
|
+
if w.State != "abandoned" || w.Claim == "" {
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
rec := latest[w.Claim]
|
|
324
|
+
if rec == nil || (w.ClaimSession != "" && w.ClaimSession != rec.Session) {
|
|
325
|
+
return nil, fmt.Errorf("abandoned writer wave %s/%s has unknown claim %q", entry.Name(), name, w.Claim)
|
|
326
|
+
}
|
|
327
|
+
if rec.Released || seen[rec.ID] {
|
|
328
|
+
continue
|
|
329
|
+
}
|
|
330
|
+
seen[rec.ID] = true
|
|
331
|
+
if retainedWriterGone(probe, rec) {
|
|
332
|
+
continue
|
|
333
|
+
}
|
|
334
|
+
held, err := normalizeClaimPaths(rec.Paths)
|
|
335
|
+
if err != nil {
|
|
336
|
+
return nil, fmt.Errorf("abandoned writer claim %s has invalid paths: %w", rec.ID, err)
|
|
337
|
+
}
|
|
338
|
+
if physical == nil {
|
|
339
|
+
physical, err = physicalClaimPaths(root, paths)
|
|
340
|
+
if err != nil {
|
|
341
|
+
return nil, err
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
held, err = physicalClaimPaths(root, held)
|
|
345
|
+
if err != nil {
|
|
346
|
+
return nil, err
|
|
347
|
+
}
|
|
348
|
+
if slices.ContainsFunc(held, func(h string) bool {
|
|
349
|
+
return slices.ContainsFunc(physical, func(p string) bool { return claimPathsOverlap(h, p) })
|
|
350
|
+
}) {
|
|
351
|
+
conflicts = append(conflicts, rec)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return conflicts, nil
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
func retainedWriterGone(probe holderProbe, rec *claimRecord) bool {
|
|
360
|
+
valid := func(p claimProc) bool {
|
|
361
|
+
_, err := time.Parse(time.RFC3339, p.Start)
|
|
362
|
+
return p.PID > 1 && err == nil
|
|
363
|
+
}
|
|
364
|
+
if rec.Holder != nil {
|
|
365
|
+
if !valid(*rec.Holder) {
|
|
366
|
+
return false
|
|
367
|
+
}
|
|
368
|
+
} else {
|
|
369
|
+
for _, anchor := range rec.Anchors {
|
|
370
|
+
if !valid(anchor) {
|
|
371
|
+
return false
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return probe.gone(rec)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
func physicalClaimPaths(root string, paths []string) ([]string, error) {
|
|
379
|
+
project := root
|
|
380
|
+
if filepath.Base(root) == devritespaths.DevritesRootName {
|
|
381
|
+
project = filepath.Dir(root)
|
|
382
|
+
}
|
|
383
|
+
out := make([]string, 0, len(paths))
|
|
384
|
+
for _, path := range paths {
|
|
385
|
+
normalized, err := parallel.NormalizePath(path)
|
|
386
|
+
if err != nil {
|
|
387
|
+
return nil, err
|
|
388
|
+
}
|
|
389
|
+
resolved, err := safepath.ResolveExisting(filepath.Join(project, filepath.FromSlash(normalized)))
|
|
390
|
+
if err != nil {
|
|
391
|
+
return nil, err
|
|
392
|
+
}
|
|
393
|
+
out = append(out, filepath.ToSlash(resolved))
|
|
394
|
+
}
|
|
395
|
+
return out, nil
|
|
396
|
+
}
|
|
397
|
+
|
|
265
398
|
// An unreadable line may be a live claim, so the conflict gates refuse to
|
|
266
399
|
// answer rather than report a path free.
|
|
267
400
|
const unparsedLedgerMsg = "claim: %d unparsed ledger line(s); cannot rule out a conflict, repair the claims ledger\n"
|
|
@@ -340,7 +473,7 @@ func addClaimTargets(root, session, reason string, ttl int, raw, workspaces []st
|
|
|
340
473
|
// sessions cannot both pass the check and claim the same path.
|
|
341
474
|
id, code := "", 0
|
|
342
475
|
if err := state.WithLock(path+".lock", func() error {
|
|
343
|
-
id, code = claimAddLocked(path, session, reason, ttl, paths, stdout, stderr)
|
|
476
|
+
id, code = claimAddLocked(path, session, reason, ttl, paths, paths, stdout, stderr)
|
|
344
477
|
return nil
|
|
345
478
|
}); err != nil {
|
|
346
479
|
fmt.Fprintf(stderr, "claim: %v\n", err)
|
|
@@ -349,7 +482,7 @@ func addClaimTargets(root, session, reason string, ttl int, raw, workspaces []st
|
|
|
349
482
|
return id, code
|
|
350
483
|
}
|
|
351
484
|
|
|
352
|
-
func claimAddLocked(path, session, reason string, ttl int, paths []string, stdout, stderr io.Writer) (string, int) {
|
|
485
|
+
func claimAddLocked(path, session, reason string, ttl int, paths, admissionPaths []string, stdout, stderr io.Writer) (string, int) {
|
|
353
486
|
latest, order, bad, err := readClaims(path)
|
|
354
487
|
if err != nil {
|
|
355
488
|
fmt.Fprintf(stderr, "claim: %v\n", err)
|
|
@@ -361,6 +494,15 @@ func claimAddLocked(path, session, reason string, ttl int, paths []string, stdou
|
|
|
361
494
|
}
|
|
362
495
|
now := time.Now().UTC()
|
|
363
496
|
probe := newHolderProbe()
|
|
497
|
+
retained, err := retainedWriterConflicts(filepath.Dir(path), latest, admissionPaths, probe)
|
|
498
|
+
if err != nil {
|
|
499
|
+
fmt.Fprintf(stderr, "claim: %v\n", err)
|
|
500
|
+
return "", 3
|
|
501
|
+
}
|
|
502
|
+
if len(retained) > 0 {
|
|
503
|
+
printConflicts(stderr, retained)
|
|
504
|
+
return "", 3
|
|
505
|
+
}
|
|
364
506
|
conflicts := claimConflicts(probe.withoutGone(liveClaims(latest, order, now)), session, paths)
|
|
365
507
|
if len(conflicts) > 0 {
|
|
366
508
|
printConflicts(stderr, conflicts)
|
|
@@ -413,6 +555,8 @@ func claimRelease(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
413
555
|
return 0
|
|
414
556
|
}
|
|
415
557
|
|
|
558
|
+
var errClaimAlreadyReleased = errors.New("already released")
|
|
559
|
+
|
|
416
560
|
// releaseClaim marks session's claim id released and returns its path count.
|
|
417
561
|
func releaseClaim(root, session, id string) (int, error) {
|
|
418
562
|
path := claimsPath(root)
|
|
@@ -428,7 +572,7 @@ func releaseClaim(root, session, id string) (int, error) {
|
|
|
428
572
|
return 0, fmt.Errorf("%q belongs to session %q, not %q", id, rec.Session, session)
|
|
429
573
|
}
|
|
430
574
|
if rec.Released {
|
|
431
|
-
return 0, fmt.Errorf("%q
|
|
575
|
+
return 0, fmt.Errorf("%q %w", id, errClaimAlreadyReleased)
|
|
432
576
|
}
|
|
433
577
|
released := *rec
|
|
434
578
|
released.Released = true
|
|
@@ -523,6 +667,18 @@ func claimCheck(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
523
667
|
fmt.Fprintf(stderr, "claim: %v\n", err)
|
|
524
668
|
return 2
|
|
525
669
|
}
|
|
670
|
+
code := 0
|
|
671
|
+
if err := state.WithLock(claimsPath(root)+".lock", func() error {
|
|
672
|
+
code = claimCheckLocked(root, session, paths, stdout, stderr)
|
|
673
|
+
return nil
|
|
674
|
+
}); err != nil {
|
|
675
|
+
fmt.Fprintf(stderr, "claim: %v\n", err)
|
|
676
|
+
return 2
|
|
677
|
+
}
|
|
678
|
+
return code
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
func claimCheckLocked(root, session string, paths []string, stdout, stderr io.Writer) int {
|
|
526
682
|
latest, order, bad, err := readClaims(claimsPath(root))
|
|
527
683
|
if err != nil {
|
|
528
684
|
fmt.Fprintf(stderr, "claim: %v\n", err)
|
|
@@ -532,7 +688,17 @@ func claimCheck(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
532
688
|
fmt.Fprintf(stderr, unparsedLedgerMsg, bad)
|
|
533
689
|
return 2
|
|
534
690
|
}
|
|
535
|
-
|
|
691
|
+
probe := newHolderProbe()
|
|
692
|
+
retained, err := retainedWriterConflicts(root, latest, paths, probe)
|
|
693
|
+
if err != nil {
|
|
694
|
+
fmt.Fprintf(stderr, "claim: %v\n", err)
|
|
695
|
+
return 2
|
|
696
|
+
}
|
|
697
|
+
if len(retained) > 0 {
|
|
698
|
+
printConflicts(stdout, retained)
|
|
699
|
+
return 3
|
|
700
|
+
}
|
|
701
|
+
conflicts := claimConflicts(probe.withoutGone(liveClaims(latest, order, time.Now().UTC())), session, paths)
|
|
536
702
|
if len(conflicts) > 0 {
|
|
537
703
|
printConflicts(stdout, conflicts)
|
|
538
704
|
return 3
|
|
@@ -8,6 +8,8 @@ import (
|
|
|
8
8
|
"strconv"
|
|
9
9
|
"strings"
|
|
10
10
|
"time"
|
|
11
|
+
|
|
12
|
+
"github.com/devrites/devrites/internal/hostpack"
|
|
11
13
|
)
|
|
12
14
|
|
|
13
15
|
// claimAnchorAge separates a claiming session's long-lived host processes from
|
|
@@ -27,13 +29,6 @@ type procInfo struct {
|
|
|
27
29
|
comm string
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
// shells outlive the agent sessions they launch (a terminal pane, a
|
|
31
|
-
// multiplexer's login shell), so a shell never stands for the holder.
|
|
32
|
-
var shells = map[string]bool{
|
|
33
|
-
"sh": true, "bash": true, "zsh": true, "fish": true, "dash": true, "ksh": true,
|
|
34
|
-
"tcsh": true, "csh": true, "nu": true, "pwsh": true, "powershell": true, "cmd": true,
|
|
35
|
-
}
|
|
36
|
-
|
|
37
32
|
// processTable lists running processes keyed by pid, or nil when the host
|
|
38
33
|
// cannot say. Tests replace it.
|
|
39
34
|
var processTable = psProcessTable
|
|
@@ -95,9 +90,12 @@ func newHolderProbe() holderProbe {
|
|
|
95
90
|
return holderProbe{table: table, host: host, self: lineage(table, os.Getppid())}
|
|
96
91
|
}
|
|
97
92
|
|
|
98
|
-
// anchors
|
|
99
|
-
//
|
|
93
|
+
// anchors retains long-lived ancestry only for an identified session.
|
|
94
|
+
// Unknown writers must not be released merely because their launchers exit.
|
|
100
95
|
func (p holderProbe) anchors(now time.Time) []claimProc {
|
|
96
|
+
if p.holder(now) == nil {
|
|
97
|
+
return nil
|
|
98
|
+
}
|
|
101
99
|
var out []claimProc
|
|
102
100
|
for _, proc := range p.self {
|
|
103
101
|
start, err := time.Parse(time.RFC3339, proc.Start)
|
|
@@ -108,13 +106,12 @@ func (p holderProbe) anchors(now time.Time) []claimProc {
|
|
|
108
106
|
return out
|
|
109
107
|
}
|
|
110
108
|
|
|
111
|
-
// holder
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if name != "" && !shells[name] {
|
|
109
|
+
// holder identifies the native session independently of its age or wrappers.
|
|
110
|
+
// Only native executable identities prove a session; generic runtimes do not.
|
|
111
|
+
func (p holderProbe) holder(_ time.Time) *claimProc {
|
|
112
|
+
for _, proc := range p.self {
|
|
113
|
+
switch filepath.Base(p.table[proc.PID].comm) {
|
|
114
|
+
case "claude", "codex", hostpack.PiSettings.Host, hostpack.OmpConfig.Host, "devin", "dsh":
|
|
118
115
|
return &proc
|
|
119
116
|
}
|
|
120
117
|
}
|
|
@@ -7,30 +7,27 @@ import (
|
|
|
7
7
|
"io/fs"
|
|
8
8
|
"os"
|
|
9
9
|
"path/filepath"
|
|
10
|
-
"
|
|
10
|
+
"strings"
|
|
11
11
|
|
|
12
12
|
"github.com/devrites/devrites/internal/devritespaths"
|
|
13
|
+
"github.com/devrites/devrites/internal/gate"
|
|
14
|
+
"github.com/devrites/devrites/internal/notes"
|
|
13
15
|
"github.com/devrites/devrites/internal/parallel"
|
|
14
16
|
"github.com/devrites/devrites/internal/state"
|
|
15
17
|
)
|
|
16
18
|
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
// open. renameWorkspace is the rename; both are variables so tests can inject the
|
|
20
|
-
// platform decision and observe the lock at the moment of the rename.
|
|
21
|
-
var (
|
|
22
|
-
releaseLockBeforeRename = runtime.GOOS == "windows"
|
|
23
|
-
renameWorkspace = os.Rename
|
|
24
|
-
)
|
|
19
|
+
// renameWorkspace lets tests observe exclusion at the archive boundary.
|
|
20
|
+
var renameWorkspace = os.Rename
|
|
25
21
|
|
|
26
|
-
// CloseOut
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
// the audit trail is preserved under archive/<slug>.
|
|
22
|
+
// CloseOut physically archives a workspace, including owner-authorized nonfinal
|
|
23
|
+
// exits. Seal, Ship, Done, and shipped/sealed/done statuses must first satisfy
|
|
24
|
+
// the final gates and retained prerequisites. The workspace is relocated, never
|
|
25
|
+
// deleted: the audit trail is preserved under archive/<slug>.
|
|
30
26
|
//
|
|
31
27
|
// args is `<slug>`; root is the already-resolved .devrites directory. Exit codes:
|
|
32
28
|
//
|
|
33
29
|
// 0 archived (ACTIVE cleared if it pointed here)
|
|
30
|
+
// 3 final prerequisites or a retained parallel lease block archiving
|
|
34
31
|
// 4 no slug given, or the feature has no workspace
|
|
35
32
|
// 5 an archive already exists at the destination: refuse to clobber it
|
|
36
33
|
func CloseOut(root string, args []string, stdout, stderr io.Writer) int {
|
|
@@ -70,14 +67,8 @@ func CloseOut(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
70
67
|
return 5
|
|
71
68
|
}
|
|
72
69
|
|
|
73
|
-
// Hold the
|
|
74
|
-
//
|
|
75
|
-
// writer already holding the lock finishes its save before the rename, so
|
|
76
|
-
// that save is archived. On unix a writer queued behind this close-out
|
|
77
|
-
// acquires a lock file that now lives in the archive; state.WithFeatureLock
|
|
78
|
-
// reports state.ErrWorkspaceMoved and the writer does not run, so it cannot
|
|
79
|
-
// re-create work/<slug>. Windows cannot rename a directory that holds an
|
|
80
|
-
// open file, so there the lock is released just before the rename.
|
|
70
|
+
// Hold the stable feature lock through verification, rename, ACTIVE clear,
|
|
71
|
+
// and rollback so queued writers cannot revive an archived workspace.
|
|
81
72
|
code := 1
|
|
82
73
|
if err := parallel.WithControlLock(dv, func() error {
|
|
83
74
|
leased, err := parallel.WorkspaceLeased(dv, slug)
|
|
@@ -89,9 +80,11 @@ func CloseOut(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
89
80
|
code = 3
|
|
90
81
|
return nil
|
|
91
82
|
}
|
|
92
|
-
return state.
|
|
93
|
-
|
|
94
|
-
|
|
83
|
+
return state.WithFeatureLock(dv, slug, func() error {
|
|
84
|
+
return state.WithLock(claimsPath(dv)+".lock", func() error {
|
|
85
|
+
code = closeOutLocked(work, arch, archiveDir, archDisplay, active, slug, stdout, stderr)
|
|
86
|
+
return nil
|
|
87
|
+
})
|
|
95
88
|
})
|
|
96
89
|
}); err != nil {
|
|
97
90
|
fmt.Fprintf(stderr, "state close: %v\n", err)
|
|
@@ -103,7 +96,41 @@ func CloseOut(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
103
96
|
// closeOutLocked performs the rename and the conditional ACTIVE clear. It runs
|
|
104
97
|
// under the feature lock held by CloseOut and reports an exit code rather than
|
|
105
98
|
// an error, preserving CloseOut's documented exit codes.
|
|
106
|
-
func closeOutLocked(
|
|
99
|
+
func closeOutLocked(work, arch, archiveDir, archDisplay, active, slug string, stdout, stderr io.Writer) int {
|
|
100
|
+
root := filepath.Dir(active)
|
|
101
|
+
ledger, err := readWorkspaceArtifact(root, slug, "state.md")
|
|
102
|
+
if err != nil {
|
|
103
|
+
fmt.Fprintf(stderr, "state close: BLOCKED: %v\n", err)
|
|
104
|
+
return 3
|
|
105
|
+
}
|
|
106
|
+
lines := strings.Split(string(ledger), "\n")
|
|
107
|
+
phase, _ := state.CursorField(lines, "phase")
|
|
108
|
+
status, _ := state.CursorField(lines, "status")
|
|
109
|
+
phase = string(state.CanonicalPhase(phase))
|
|
110
|
+
final := phase == string(state.PhaseSeal) || phase == string(state.PhaseShip) || phase == string(state.PhaseDone) ||
|
|
111
|
+
status == "sealed" || status == "shipped" || status == "done"
|
|
112
|
+
if final {
|
|
113
|
+
result, err := gate.Check(gate.Seal, root, slug)
|
|
114
|
+
if err != nil {
|
|
115
|
+
fmt.Fprintf(stderr, "state close: BLOCKED: %v\n", err)
|
|
116
|
+
return 3
|
|
117
|
+
}
|
|
118
|
+
if result.Blocked {
|
|
119
|
+
fmt.Fprint(stdout, result.Render())
|
|
120
|
+
return 3
|
|
121
|
+
}
|
|
122
|
+
if err := VerifyReleaseUnion(root, slug); err != nil {
|
|
123
|
+
fmt.Fprintf(stderr, "state close: BLOCKED: release-union: %v\n", err)
|
|
124
|
+
return 3
|
|
125
|
+
}
|
|
126
|
+
if err := CheckFinalPrerequisites(root, slug); err != nil {
|
|
127
|
+
fmt.Fprintf(stderr, "state close: BLOCKED: final-prerequisites: %v\n", err)
|
|
128
|
+
return 3
|
|
129
|
+
}
|
|
130
|
+
if EvidenceFresh(root, []string{slug}, stdout, stderr) != 0 || !notes.SealCheck(root, slug, stdout) {
|
|
131
|
+
return 3
|
|
132
|
+
}
|
|
133
|
+
}
|
|
107
134
|
activeSlug, err := devritespaths.PhysicalActiveSlug(filepath.Dir(active))
|
|
108
135
|
if err != nil {
|
|
109
136
|
fmt.Fprintf(stderr, "state close: invalid ACTIVE cursor: %v\n", err)
|
|
@@ -115,9 +142,6 @@ func closeOutLocked(release func(), work, arch, archiveDir, archDisplay, active,
|
|
|
115
142
|
fmt.Fprintf(stderr, "state close: cannot create archive dir: %v\n", err)
|
|
116
143
|
return 1
|
|
117
144
|
}
|
|
118
|
-
if releaseLockBeforeRename {
|
|
119
|
-
release()
|
|
120
|
-
}
|
|
121
145
|
if err := renameWorkspace(work, arch); err != nil {
|
|
122
146
|
fmt.Fprintf(stderr, "state close: cannot archive %s -> %s: %v\n", work, arch, err)
|
|
123
147
|
return 1
|