devrites 7.3.0 → 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.
Files changed (153) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +19 -3
  3. package/bin/devrites.mjs +31 -5
  4. package/docs/engine/commands.md +41 -3
  5. package/docs/release.md +5 -2
  6. package/engine/Makefile +2 -2
  7. package/engine/internal/acceptance/newfinal_origins.go +39 -0
  8. package/engine/internal/acceptance/preconditions.go +17 -11
  9. package/engine/internal/acceptance/transcript.go +23 -4
  10. package/engine/internal/gate/gate.go +8 -6
  11. package/engine/internal/gate/readiness_binding.go +54 -24
  12. package/engine/internal/install/apply.go +36 -21
  13. package/engine/internal/install/binary.go +67 -9
  14. package/engine/internal/install/install.go +63 -21
  15. package/engine/internal/install/manifest.go +4 -5
  16. package/engine/internal/install/merge.go +89 -33
  17. package/engine/internal/install/preflight.go +60 -19
  18. package/engine/internal/install/uninstall.go +19 -9
  19. package/engine/internal/install/update.go +0 -4
  20. package/engine/internal/lib/claims.go +170 -4
  21. package/engine/internal/lib/claims_holder.go +13 -16
  22. package/engine/internal/lib/closeout.go +52 -28
  23. package/engine/internal/lib/context_io.go +88 -4
  24. package/engine/internal/lib/continuation.go +111 -24
  25. package/engine/internal/lib/dispatch.go +157 -24
  26. package/engine/internal/lib/final_prerequisites.go +137 -0
  27. package/engine/internal/lib/manifest_union.go +62 -25
  28. package/engine/internal/lib/next.go +10 -10
  29. package/engine/internal/lib/packet.go +32 -8
  30. package/engine/internal/lib/packet_integrity.go +194 -2
  31. package/engine/internal/lib/resolve.go +128 -142
  32. package/engine/internal/lib/treebaseline.go +0 -1
  33. package/engine/internal/markdowntext/markdowntext.go +110 -31
  34. package/engine/internal/overhaul/records/check.go +45 -5
  35. package/engine/internal/overhaul/records/consistency.go +34 -8
  36. package/engine/internal/overhaul/records/run.go +55 -11
  37. package/engine/internal/parallel/landing.go +17 -0
  38. package/engine/internal/state/acceptance.go +35 -5
  39. package/engine/internal/state/feature.go +3 -2
  40. package/engine/internal/state/observation.go +50 -3
  41. package/engine/internal/state/write.go +20 -44
  42. package/engine/internal/workflow/commands.go +25 -3
  43. package/engine/main.go +19 -0
  44. package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  45. package/pack/.claude/skills/devrites-lib/reference/parallel-workspaces.md +46 -12
  46. package/pack/.claude/skills/overhaul/SKILL.md +8 -4
  47. package/pack/.claude/skills/overhaul/references/lifecycle.md +13 -6
  48. package/pack/.claude/skills/overhaul/references/orchestration.md +32 -21
  49. package/pack/.claude/skills/overhaul/references/records.md +23 -8
  50. package/pack/.claude/skills/overhaul/references/scope-and-safety.md +7 -2
  51. package/pack/.claude/skills/overhaul/references/scoring.md +7 -0
  52. package/pack/.claude/skills/rite-autocomplete/SKILL.md +9 -10
  53. package/pack/.claude/skills/rite-autocomplete/reference/loop.md +8 -1
  54. package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +17 -17
  55. package/pack/.claude/skills/rite-clarify/SKILL.md +25 -25
  56. package/pack/.claude/skills/rite-prove/SKILL.md +15 -7
  57. package/pack/.claude/skills/rite-review/SKILL.md +7 -0
  58. package/pack/.claude/skills/rite-vet/SKILL.md +10 -1
  59. package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  60. package/pack/generated/claude/skills/devrites-lib/reference/parallel-workspaces.md +46 -12
  61. package/pack/generated/claude/skills/overhaul/SKILL.md +8 -4
  62. package/pack/generated/claude/skills/overhaul/references/lifecycle.md +13 -6
  63. package/pack/generated/claude/skills/overhaul/references/orchestration.md +32 -21
  64. package/pack/generated/claude/skills/overhaul/references/records.md +23 -8
  65. package/pack/generated/claude/skills/overhaul/references/scope-and-safety.md +7 -2
  66. package/pack/generated/claude/skills/overhaul/references/scoring.md +7 -0
  67. package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +9 -10
  68. package/pack/generated/claude/skills/rite-autocomplete/reference/loop.md +8 -1
  69. package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +17 -17
  70. package/pack/generated/claude/skills/rite-clarify/SKILL.md +25 -25
  71. package/pack/generated/claude/skills/rite-prove/SKILL.md +15 -7
  72. package/pack/generated/claude/skills/rite-review/SKILL.md +7 -0
  73. package/pack/generated/claude/skills/rite-vet/SKILL.md +10 -1
  74. package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  75. package/pack/generated/codex/skills/devrites-lib/reference/parallel-workspaces.md +46 -12
  76. package/pack/generated/codex/skills/overhaul/SKILL.md +8 -4
  77. package/pack/generated/codex/skills/overhaul/references/lifecycle.md +13 -6
  78. package/pack/generated/codex/skills/overhaul/references/orchestration.md +32 -21
  79. package/pack/generated/codex/skills/overhaul/references/records.md +23 -8
  80. package/pack/generated/codex/skills/overhaul/references/scope-and-safety.md +7 -2
  81. package/pack/generated/codex/skills/overhaul/references/scoring.md +7 -0
  82. package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +9 -10
  83. package/pack/generated/codex/skills/rite-autocomplete/reference/loop.md +8 -1
  84. package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +17 -17
  85. package/pack/generated/codex/skills/rite-clarify/SKILL.md +25 -25
  86. package/pack/generated/codex/skills/rite-prove/SKILL.md +15 -7
  87. package/pack/generated/codex/skills/rite-review/SKILL.md +7 -0
  88. package/pack/generated/codex/skills/rite-vet/SKILL.md +10 -1
  89. package/pack/generated/devin/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  90. package/pack/generated/devin/skills/devrites-lib/reference/parallel-workspaces.md +46 -12
  91. package/pack/generated/devin/skills/overhaul/SKILL.md +8 -4
  92. package/pack/generated/devin/skills/overhaul/references/lifecycle.md +13 -6
  93. package/pack/generated/devin/skills/overhaul/references/orchestration.md +32 -21
  94. package/pack/generated/devin/skills/overhaul/references/records.md +23 -8
  95. package/pack/generated/devin/skills/overhaul/references/scope-and-safety.md +7 -2
  96. package/pack/generated/devin/skills/overhaul/references/scoring.md +7 -0
  97. package/pack/generated/devin/skills/rite-autocomplete/SKILL.md +9 -10
  98. package/pack/generated/devin/skills/rite-autocomplete/reference/loop.md +8 -1
  99. package/pack/generated/devin/skills/rite-build/reference/wright-dispatch.md +17 -17
  100. package/pack/generated/devin/skills/rite-clarify/SKILL.md +25 -25
  101. package/pack/generated/devin/skills/rite-prove/SKILL.md +15 -7
  102. package/pack/generated/devin/skills/rite-review/SKILL.md +7 -0
  103. package/pack/generated/devin/skills/rite-vet/SKILL.md +10 -1
  104. package/pack/generated/dsh/runtime/cordis.patch.yml +28 -14
  105. package/pack/generated/dsh/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  106. package/pack/generated/dsh/skills/devrites-lib/reference/parallel-workspaces.md +46 -12
  107. package/pack/generated/dsh/skills/overhaul/SKILL.md +8 -4
  108. package/pack/generated/dsh/skills/overhaul/references/lifecycle.md +13 -6
  109. package/pack/generated/dsh/skills/overhaul/references/orchestration.md +32 -21
  110. package/pack/generated/dsh/skills/overhaul/references/records.md +23 -8
  111. package/pack/generated/dsh/skills/overhaul/references/scope-and-safety.md +7 -2
  112. package/pack/generated/dsh/skills/overhaul/references/scoring.md +6 -0
  113. package/pack/generated/dsh/skills/rite-autocomplete/SKILL.md +9 -10
  114. package/pack/generated/dsh/skills/rite-autocomplete/reference/loop.md +8 -1
  115. package/pack/generated/dsh/skills/rite-build/reference/wright-dispatch.md +17 -17
  116. package/pack/generated/dsh/skills/rite-clarify/SKILL.md +25 -25
  117. package/pack/generated/dsh/skills/rite-prove/SKILL.md +15 -7
  118. package/pack/generated/dsh/skills/rite-review/SKILL.md +7 -0
  119. package/pack/generated/dsh/skills/rite-vet/SKILL.md +10 -1
  120. package/pack/generated/omp/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  121. package/pack/generated/omp/skills/devrites-lib/reference/parallel-workspaces.md +46 -12
  122. package/pack/generated/omp/skills/overhaul/SKILL.md +8 -4
  123. package/pack/generated/omp/skills/overhaul/references/lifecycle.md +13 -6
  124. package/pack/generated/omp/skills/overhaul/references/orchestration.md +32 -21
  125. package/pack/generated/omp/skills/overhaul/references/records.md +23 -8
  126. package/pack/generated/omp/skills/overhaul/references/scope-and-safety.md +7 -2
  127. package/pack/generated/omp/skills/overhaul/references/scoring.md +7 -0
  128. package/pack/generated/omp/skills/rite-autocomplete/SKILL.md +9 -10
  129. package/pack/generated/omp/skills/rite-autocomplete/reference/loop.md +8 -1
  130. package/pack/generated/omp/skills/rite-build/reference/wright-dispatch.md +17 -17
  131. package/pack/generated/omp/skills/rite-clarify/SKILL.md +25 -25
  132. package/pack/generated/omp/skills/rite-prove/SKILL.md +15 -7
  133. package/pack/generated/omp/skills/rite-review/SKILL.md +7 -0
  134. package/pack/generated/omp/skills/rite-vet/SKILL.md +10 -1
  135. package/pack/generated/pi/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  136. package/pack/generated/pi/skills/devrites-lib/reference/parallel-workspaces.md +46 -12
  137. package/pack/generated/pi/skills/overhaul/SKILL.md +8 -4
  138. package/pack/generated/pi/skills/overhaul/references/lifecycle.md +13 -6
  139. package/pack/generated/pi/skills/overhaul/references/orchestration.md +32 -21
  140. package/pack/generated/pi/skills/overhaul/references/records.md +23 -8
  141. package/pack/generated/pi/skills/overhaul/references/scope-and-safety.md +7 -2
  142. package/pack/generated/pi/skills/overhaul/references/scoring.md +7 -0
  143. package/pack/generated/pi/skills/rite-autocomplete/SKILL.md +9 -10
  144. package/pack/generated/pi/skills/rite-autocomplete/reference/loop.md +8 -1
  145. package/pack/generated/pi/skills/rite-build/reference/wright-dispatch.md +17 -17
  146. package/pack/generated/pi/skills/rite-clarify/SKILL.md +25 -25
  147. package/pack/generated/pi/skills/rite-prove/SKILL.md +15 -7
  148. package/pack/generated/pi/skills/rite-review/SKILL.md +7 -0
  149. package/pack/generated/pi/skills/rite-vet/SKILL.md +10 -1
  150. package/package.json +1 -1
  151. package/scripts/dsh-generate.py +1 -1
  152. package/scripts/install-lib.sh +29 -0
  153. 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 already released", id)
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
- conflicts := claimConflicts(newHolderProbe().withoutGone(liveClaims(latest, order, time.Now().UTC())), session, paths)
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 returns the caller's ancestors that started more than
99
- // claimAnchorAge before now: the session's host processes.
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 returns the nearest anchor that is not a shell: the agent session
112
- // process that owns the claim. A shell or terminal multiplexer above it can
113
- // outlive the session, so only the session's own exit proves release.
114
- func (p holderProbe) holder(now time.Time) *claimProc {
115
- for _, proc := range p.anchors(now) {
116
- name := strings.TrimPrefix(filepath.Base(p.table[proc.PID].comm), "-")
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
- "runtime"
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
- // releaseLockBeforeRename drops the feature lock before the workspace rename on
18
- // Windows, which refuses to rename a directory while the lock file inside it is
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 retires a shipped feature: it moves the feature directory into the
27
- // archive and, when the feature is still the active one, clears the ACTIVE cursor
28
- // so the next /rite-spec starts clean. The workspace is relocated, never deleted:
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 per-feature lock across the rename and the ACTIVE clear, as
74
- // dispatch and the parallel lease ops do across their load-modify-save. A
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.WithFeatureLockRelease(dv, slug, func(release func()) error {
93
- code = closeOutLocked(release, work, arch, archiveDir, archDisplay, active, slug, stdout, stderr)
94
- return nil
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(release func(), work, arch, archiveDir, archDisplay, active, slug string, stdout, stderr io.Writer) int {
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
- var source contextSource
38
- if source.err = contextParents(s.dir, name); source.err == nil {
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
+ }