devrites 7.3.1 → 7.3.3
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 +38 -0
- package/README.md +19 -3
- package/bin/devrites.mjs +31 -5
- package/docs/cli.md +15 -7
- package/docs/engine/commands.md +37 -5
- package/docs/engine/workspace-schema.md +4 -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/hostpack/hostpack.go +1 -1
- 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/apply_bundle.go +25 -8
- 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/regression.go +0 -5
- package/engine/internal/lib/resolve.go +148 -145
- 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/rootfacts/facts.go +11 -0
- package/engine/internal/state/acceptance.go +35 -5
- package/engine/internal/state/feature.go +3 -2
- package/engine/internal/state/lock_other.go +4 -0
- package/engine/internal/state/observation.go +1 -1
- package/engine/internal/state/write.go +56 -39
- package/engine/internal/workflow/commands.go +25 -3
- package/engine/main.go +21 -2
- 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/.claude/skills/rite-doctor/SKILL.md +70 -68
- package/pack/generated/README.md +1 -1
- 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/claude/skills/rite-doctor/SKILL.md +70 -68
- 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/codex/skills/rite-doctor/SKILL.md +70 -68
- 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/devin/skills/rite-doctor/SKILL.md +70 -68
- 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/dsh/skills/rite-doctor/SKILL.md +70 -68
- package/pack/generated/omp/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
- package/pack/generated/omp/skills/devrites-lib/reference/standards/agents.md +1 -1
- 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 +18 -18
- package/pack/generated/omp/skills/rite-clarify/SKILL.md +25 -25
- package/pack/generated/omp/skills/rite-doctor/SKILL.md +70 -68
- package/pack/generated/pi/AGENTS.md +1 -1
- 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/pack/generated/pi/skills/rite-doctor/SKILL.md +70 -68
- package/package.json +1 -1
- package/scripts/build-host-artifacts.sh +1 -1
- package/scripts/dsh-generate.py +6 -2
- package/scripts/install-lib.sh +29 -0
- package/scripts/omp-generate.sh +4 -4
- package/scripts/pi-generate.sh +3 -3
- package/uninstall.sh +1 -0
|
@@ -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
|
|
@@ -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
|
+
}
|