devrites 6.1.1 → 6.1.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 +12 -0
- package/README.md +1 -1
- package/docs/cli.md +4 -1
- package/docs/command-map.md +1 -1
- package/docs/engine/commands.md +4 -4
- package/docs/orchestration.md +4 -3
- package/docs/usage.md +1 -1
- package/engine/commands.go +1 -1
- package/engine/help.go +5 -1
- package/engine/internal/acceptance/cli.go +14 -5
- package/engine/internal/acceptance/run.go +20 -5
- package/engine/internal/lib/apply_bundle.go +474 -0
- package/engine/internal/lib/claims.go +12 -2
- package/engine/internal/lib/claims_holder.go +124 -0
- package/engine/internal/lib/cli_observe.go +14 -0
- package/engine/internal/lib/diffscope.go +2 -1
- package/engine/internal/lib/taskgraph.go +124 -0
- package/engine/internal/parallel/cli.go +111 -9
- package/engine/internal/parallel/lease.go +58 -4
- package/engine/internal/parallel/ops.go +244 -75
- package/engine/internal/parallel/select.go +31 -6
- package/engine/main.go +4 -0
- package/pack/.claude/agents/devrites-plan-drafter.md +13 -3
- package/pack/.claude/agents/devrites-slice-wright.md +5 -1
- package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +14 -5
- package/pack/.claude/skills/devrites-lib/reference/standards/afk-hitl.md +24 -9
- package/pack/.claude/skills/devrites-lib/reference/standards/agents.md +6 -2
- package/pack/.claude/skills/devrites-lib/reference/standards/core.md +3 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/gates.md +3 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/testing.md +7 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/workflow-artifacts.md +10 -6
- package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +1 -1
- package/pack/.claude/skills/rite-autocomplete/SKILL.md +2 -2
- package/pack/.claude/skills/rite-autocomplete/reference/loop.md +1 -1
- package/pack/.claude/skills/rite-autocomplete/reference/stop-conditions.md +6 -2
- package/pack/.claude/skills/rite-build/SKILL.md +7 -7
- package/pack/.claude/skills/rite-build/reference/afk-discipline.md +2 -2
- package/pack/.claude/skills/rite-build/reference/checkpoint.md +1 -1
- package/pack/.claude/skills/rite-build/reference/one-slice-cycle.md +3 -2
- package/pack/.claude/skills/rite-build/reference/parallel-batch.md +28 -21
- package/pack/.claude/skills/rite-build/reference/phase-contract.md +10 -1
- package/pack/.claude/skills/rite-build/reference/spec-drift-guard.md +3 -2
- package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +9 -2
- package/pack/.claude/skills/rite-define/SKILL.md +11 -2
- package/pack/.claude/skills/rite-handoff/SKILL.md +1 -1
- package/pack/.claude/skills/rite-plan/SKILL.md +20 -9
- package/pack/.claude/skills/rite-plan/reference/slicing.md +5 -0
- package/pack/.claude/skills/rite-polish/SKILL.md +6 -4
- package/pack/.claude/skills/rite-prove/SKILL.md +3 -2
- package/pack/.claude/skills/rite-review/SKILL.md +6 -1
- package/pack/.claude/skills/rite-seal/SKILL.md +2 -2
- package/pack/.claude/skills/rite-seal/reference/phase-contract.md +7 -1
- package/pack/.claude/skills/rite-vet/SKILL.md +9 -5
- package/pack/.claude/skills/rite-vet/reference/artifacts.md +8 -0
- package/pack/.claude/skills/rite-vet/reference/review-axes.md +1 -1
- package/pack/generated/claude/agents/devrites-plan-drafter.md +13 -3
- package/pack/generated/claude/agents/devrites-slice-wright.md +5 -1
- package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +14 -5
- package/pack/generated/claude/skills/devrites-lib/reference/standards/afk-hitl.md +24 -9
- package/pack/generated/claude/skills/devrites-lib/reference/standards/agents.md +6 -2
- package/pack/generated/claude/skills/devrites-lib/reference/standards/core.md +3 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/gates.md +3 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/testing.md +7 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/workflow-artifacts.md +10 -6
- package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +1 -1
- package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +2 -2
- package/pack/generated/claude/skills/rite-autocomplete/reference/loop.md +1 -1
- package/pack/generated/claude/skills/rite-autocomplete/reference/stop-conditions.md +6 -2
- package/pack/generated/claude/skills/rite-build/SKILL.md +7 -7
- package/pack/generated/claude/skills/rite-build/reference/afk-discipline.md +2 -2
- package/pack/generated/claude/skills/rite-build/reference/checkpoint.md +1 -1
- package/pack/generated/claude/skills/rite-build/reference/one-slice-cycle.md +3 -2
- package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +28 -21
- package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +10 -1
- package/pack/generated/claude/skills/rite-build/reference/spec-drift-guard.md +3 -2
- package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +9 -2
- package/pack/generated/claude/skills/rite-define/SKILL.md +11 -2
- package/pack/generated/claude/skills/rite-handoff/SKILL.md +1 -1
- package/pack/generated/claude/skills/rite-plan/SKILL.md +20 -9
- package/pack/generated/claude/skills/rite-plan/reference/slicing.md +5 -0
- package/pack/generated/claude/skills/rite-polish/SKILL.md +6 -4
- package/pack/generated/claude/skills/rite-prove/SKILL.md +3 -2
- package/pack/generated/claude/skills/rite-review/SKILL.md +6 -1
- package/pack/generated/claude/skills/rite-seal/SKILL.md +2 -2
- package/pack/generated/claude/skills/rite-seal/reference/phase-contract.md +7 -1
- package/pack/generated/claude/skills/rite-vet/SKILL.md +9 -5
- package/pack/generated/claude/skills/rite-vet/reference/artifacts.md +8 -0
- package/pack/generated/claude/skills/rite-vet/reference/review-axes.md +1 -1
- package/pack/generated/codex/agents/devrites-plan-drafter.toml +13 -3
- package/pack/generated/codex/agents/devrites-slice-wright.toml +5 -1
- package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +14 -5
- package/pack/generated/codex/skills/devrites-lib/reference/standards/afk-hitl.md +24 -9
- package/pack/generated/codex/skills/devrites-lib/reference/standards/agents.md +6 -2
- package/pack/generated/codex/skills/devrites-lib/reference/standards/core.md +3 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/gates.md +3 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/testing.md +7 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/workflow-artifacts.md +10 -6
- package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +1 -1
- package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +2 -2
- package/pack/generated/codex/skills/rite-autocomplete/reference/loop.md +1 -1
- package/pack/generated/codex/skills/rite-autocomplete/reference/stop-conditions.md +6 -2
- package/pack/generated/codex/skills/rite-build/SKILL.md +7 -7
- package/pack/generated/codex/skills/rite-build/reference/afk-discipline.md +2 -2
- package/pack/generated/codex/skills/rite-build/reference/checkpoint.md +1 -1
- package/pack/generated/codex/skills/rite-build/reference/one-slice-cycle.md +3 -2
- package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +28 -21
- package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +10 -1
- package/pack/generated/codex/skills/rite-build/reference/spec-drift-guard.md +3 -2
- package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +9 -2
- package/pack/generated/codex/skills/rite-define/SKILL.md +11 -2
- package/pack/generated/codex/skills/rite-handoff/SKILL.md +1 -1
- package/pack/generated/codex/skills/rite-plan/SKILL.md +20 -9
- package/pack/generated/codex/skills/rite-plan/reference/slicing.md +5 -0
- package/pack/generated/codex/skills/rite-polish/SKILL.md +6 -4
- package/pack/generated/codex/skills/rite-prove/SKILL.md +3 -2
- package/pack/generated/codex/skills/rite-review/SKILL.md +6 -1
- package/pack/generated/codex/skills/rite-seal/SKILL.md +2 -2
- package/pack/generated/codex/skills/rite-seal/reference/phase-contract.md +7 -1
- package/pack/generated/codex/skills/rite-vet/SKILL.md +9 -5
- package/pack/generated/codex/skills/rite-vet/reference/artifacts.md +8 -0
- package/pack/generated/codex/skills/rite-vet/reference/review-axes.md +1 -1
- package/pack/generated/devin/agents/devrites-plan-drafter.md +13 -3
- package/pack/generated/devin/agents/devrites-slice-wright.md +5 -1
- package/pack/generated/devin/skills/devrites-lib/reference/parallel-dispatch.md +14 -5
- package/pack/generated/devin/skills/devrites-lib/reference/standards/afk-hitl.md +24 -9
- package/pack/generated/devin/skills/devrites-lib/reference/standards/agents.md +6 -2
- package/pack/generated/devin/skills/devrites-lib/reference/standards/core.md +3 -0
- package/pack/generated/devin/skills/devrites-lib/reference/standards/gates.md +3 -0
- package/pack/generated/devin/skills/devrites-lib/reference/standards/testing.md +7 -0
- package/pack/generated/devin/skills/devrites-lib/reference/standards/workflow-artifacts.md +10 -6
- package/pack/generated/devin/skills/devrites-lib/reference/workspace-artifact-schema.md +1 -1
- package/pack/generated/devin/skills/rite-autocomplete/SKILL.md +2 -2
- package/pack/generated/devin/skills/rite-autocomplete/reference/loop.md +1 -1
- package/pack/generated/devin/skills/rite-autocomplete/reference/stop-conditions.md +6 -2
- package/pack/generated/devin/skills/rite-build/SKILL.md +7 -7
- package/pack/generated/devin/skills/rite-build/reference/afk-discipline.md +2 -2
- package/pack/generated/devin/skills/rite-build/reference/checkpoint.md +1 -1
- package/pack/generated/devin/skills/rite-build/reference/one-slice-cycle.md +3 -2
- package/pack/generated/devin/skills/rite-build/reference/parallel-batch.md +28 -21
- package/pack/generated/devin/skills/rite-build/reference/phase-contract.md +10 -1
- package/pack/generated/devin/skills/rite-build/reference/spec-drift-guard.md +3 -2
- package/pack/generated/devin/skills/rite-build/reference/wright-dispatch.md +9 -2
- package/pack/generated/devin/skills/rite-define/SKILL.md +11 -2
- package/pack/generated/devin/skills/rite-handoff/SKILL.md +1 -1
- package/pack/generated/devin/skills/rite-plan/SKILL.md +20 -9
- package/pack/generated/devin/skills/rite-plan/reference/slicing.md +5 -0
- package/pack/generated/devin/skills/rite-polish/SKILL.md +6 -4
- package/pack/generated/devin/skills/rite-prove/SKILL.md +3 -2
- package/pack/generated/devin/skills/rite-review/SKILL.md +6 -1
- package/pack/generated/devin/skills/rite-seal/SKILL.md +2 -2
- package/pack/generated/devin/skills/rite-seal/reference/phase-contract.md +7 -1
- package/pack/generated/devin/skills/rite-vet/SKILL.md +9 -5
- package/pack/generated/devin/skills/rite-vet/reference/artifacts.md +8 -0
- package/pack/generated/devin/skills/rite-vet/reference/review-axes.md +1 -1
- package/pack/generated/omp/agents/devrites-plan-drafter.md +13 -3
- package/pack/generated/omp/agents/devrites-slice-wright.md +5 -1
- package/pack/generated/omp/skills/devrites-lib/reference/parallel-dispatch.md +14 -5
- package/pack/generated/omp/skills/devrites-lib/reference/standards/afk-hitl.md +24 -9
- package/pack/generated/omp/skills/devrites-lib/reference/standards/agents.md +6 -2
- package/pack/generated/omp/skills/devrites-lib/reference/standards/core.md +3 -0
- package/pack/generated/omp/skills/devrites-lib/reference/standards/gates.md +3 -0
- package/pack/generated/omp/skills/devrites-lib/reference/standards/testing.md +7 -0
- package/pack/generated/omp/skills/devrites-lib/reference/standards/workflow-artifacts.md +10 -6
- package/pack/generated/omp/skills/devrites-lib/reference/workspace-artifact-schema.md +1 -1
- package/pack/generated/omp/skills/rite-autocomplete/SKILL.md +2 -2
- package/pack/generated/omp/skills/rite-autocomplete/reference/loop.md +1 -1
- package/pack/generated/omp/skills/rite-autocomplete/reference/stop-conditions.md +6 -2
- package/pack/generated/omp/skills/rite-build/SKILL.md +7 -7
- package/pack/generated/omp/skills/rite-build/reference/afk-discipline.md +2 -2
- package/pack/generated/omp/skills/rite-build/reference/checkpoint.md +1 -1
- package/pack/generated/omp/skills/rite-build/reference/one-slice-cycle.md +3 -2
- package/pack/generated/omp/skills/rite-build/reference/parallel-batch.md +28 -21
- package/pack/generated/omp/skills/rite-build/reference/phase-contract.md +10 -1
- package/pack/generated/omp/skills/rite-build/reference/spec-drift-guard.md +3 -2
- package/pack/generated/omp/skills/rite-build/reference/wright-dispatch.md +9 -2
- package/pack/generated/omp/skills/rite-define/SKILL.md +11 -2
- package/pack/generated/omp/skills/rite-handoff/SKILL.md +1 -1
- package/pack/generated/omp/skills/rite-plan/SKILL.md +20 -9
- package/pack/generated/omp/skills/rite-plan/reference/slicing.md +5 -0
- package/pack/generated/omp/skills/rite-polish/SKILL.md +6 -4
- package/pack/generated/omp/skills/rite-prove/SKILL.md +3 -2
- package/pack/generated/omp/skills/rite-review/SKILL.md +6 -1
- package/pack/generated/omp/skills/rite-seal/SKILL.md +2 -2
- package/pack/generated/omp/skills/rite-seal/reference/phase-contract.md +7 -1
- package/pack/generated/omp/skills/rite-vet/SKILL.md +9 -5
- package/pack/generated/omp/skills/rite-vet/reference/artifacts.md +8 -0
- package/pack/generated/omp/skills/rite-vet/reference/review-axes.md +1 -1
- package/pack/generated/pi/agents/devrites-plan-drafter.md +13 -3
- package/pack/generated/pi/agents/devrites-slice-wright.md +5 -1
- package/pack/generated/pi/skills/devrites-lib/reference/parallel-dispatch.md +14 -5
- package/pack/generated/pi/skills/devrites-lib/reference/standards/afk-hitl.md +24 -9
- package/pack/generated/pi/skills/devrites-lib/reference/standards/agents.md +6 -2
- package/pack/generated/pi/skills/devrites-lib/reference/standards/core.md +3 -0
- package/pack/generated/pi/skills/devrites-lib/reference/standards/gates.md +3 -0
- package/pack/generated/pi/skills/devrites-lib/reference/standards/testing.md +7 -0
- package/pack/generated/pi/skills/devrites-lib/reference/standards/workflow-artifacts.md +10 -6
- package/pack/generated/pi/skills/devrites-lib/reference/workspace-artifact-schema.md +1 -1
- package/pack/generated/pi/skills/rite-autocomplete/SKILL.md +2 -2
- package/pack/generated/pi/skills/rite-autocomplete/reference/loop.md +1 -1
- package/pack/generated/pi/skills/rite-autocomplete/reference/stop-conditions.md +6 -2
- package/pack/generated/pi/skills/rite-build/SKILL.md +7 -7
- package/pack/generated/pi/skills/rite-build/reference/afk-discipline.md +2 -2
- package/pack/generated/pi/skills/rite-build/reference/checkpoint.md +1 -1
- package/pack/generated/pi/skills/rite-build/reference/one-slice-cycle.md +3 -2
- package/pack/generated/pi/skills/rite-build/reference/parallel-batch.md +28 -21
- package/pack/generated/pi/skills/rite-build/reference/phase-contract.md +10 -1
- package/pack/generated/pi/skills/rite-build/reference/spec-drift-guard.md +3 -2
- package/pack/generated/pi/skills/rite-build/reference/wright-dispatch.md +9 -2
- package/pack/generated/pi/skills/rite-define/SKILL.md +11 -2
- package/pack/generated/pi/skills/rite-handoff/SKILL.md +1 -1
- package/pack/generated/pi/skills/rite-plan/SKILL.md +20 -9
- package/pack/generated/pi/skills/rite-plan/reference/slicing.md +5 -0
- package/pack/generated/pi/skills/rite-polish/SKILL.md +6 -4
- package/pack/generated/pi/skills/rite-prove/SKILL.md +3 -2
- package/pack/generated/pi/skills/rite-review/SKILL.md +6 -1
- package/pack/generated/pi/skills/rite-seal/SKILL.md +2 -2
- package/pack/generated/pi/skills/rite-seal/reference/phase-contract.md +7 -1
- package/pack/generated/pi/skills/rite-vet/SKILL.md +9 -5
- package/pack/generated/pi/skills/rite-vet/reference/artifacts.md +8 -0
- package/pack/generated/pi/skills/rite-vet/reference/review-axes.md +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
package lib
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"crypto/sha256"
|
|
5
|
+
"encoding/hex"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"errors"
|
|
8
|
+
"fmt"
|
|
9
|
+
"io"
|
|
10
|
+
"io/fs"
|
|
11
|
+
"os"
|
|
12
|
+
"path/filepath"
|
|
13
|
+
"regexp"
|
|
14
|
+
"slices"
|
|
15
|
+
"sort"
|
|
16
|
+
"strings"
|
|
17
|
+
|
|
18
|
+
"github.com/devrites/devrites/internal/devritespaths"
|
|
19
|
+
"github.com/devrites/devrites/internal/fsutil"
|
|
20
|
+
"github.com/devrites/devrites/internal/state"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
// maxBundleBytes bounds one drafter bundle read. A bundle carries hunks for a
|
|
24
|
+
// handful of planning artifacts, each capped at state.MaxArtifactBytes.
|
|
25
|
+
const maxBundleBytes = 8 << 20
|
|
26
|
+
|
|
27
|
+
// trackedIDRE matches the workspace ID families whose silent disappearance is
|
|
28
|
+
// a planning defect: an AC, slice, or horizon item never vanishes unrecorded.
|
|
29
|
+
var trackedIDRE = regexp.MustCompile(`\b(?:AC|REQ|HZN|SLICE|PF|INV|DEC|PROH|EDGE|DRIFT|ASM|Q|EVID)-\d+(?:-[A-Z][A-Z0-9]*)*\b`)
|
|
30
|
+
|
|
31
|
+
// datedHeadingRE finds a dated section heading. In the current-state planning
|
|
32
|
+
// artifacts it marks an appended repair delta that leaves the superseded clause
|
|
33
|
+
// beside its replacement, growing the file and the next review's surface.
|
|
34
|
+
var datedHeadingRE = regexp.MustCompile(`(?m)^#{2,4}\s.*\b\d{4}-\d{2}-\d{2}\b`)
|
|
35
|
+
|
|
36
|
+
var currentStatePlanning = map[string]bool{
|
|
37
|
+
"architecture.md": true, "plan.md": true, "tasks.md": true, "test-plan.md": true, "traceability.md": true,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// candidateBundle is the plan-drafter's candidate_files result. Unknown fields
|
|
41
|
+
// (acceptance_map, technical_decisions, ...) are the root's to reconcile and
|
|
42
|
+
// are ignored here.
|
|
43
|
+
type candidateBundle struct {
|
|
44
|
+
BaseHashes map[string]string `json:"base_hashes"`
|
|
45
|
+
CandidateFiles []candidateFile `json:"candidate_files"`
|
|
46
|
+
RetiredIDs []string `json:"retired_ids"`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type candidateFile struct {
|
|
50
|
+
Path string `json:"path"`
|
|
51
|
+
Operation string `json:"operation"`
|
|
52
|
+
Content *string `json:"content"`
|
|
53
|
+
Hunks []candidateHunk `json:"hunks"`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type candidateHunk struct {
|
|
57
|
+
ID string `json:"id"`
|
|
58
|
+
OldText string `json:"old_text"`
|
|
59
|
+
NewText string `json:"new_text"`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// bundleTarget is every requested change to one workspace file, merged across
|
|
63
|
+
// bundles so anchor-disjoint drafter clusters apply against one shared base.
|
|
64
|
+
type bundleTarget struct {
|
|
65
|
+
rel string
|
|
66
|
+
op string // create | replace | patch
|
|
67
|
+
content string
|
|
68
|
+
hunks []candidateHunk
|
|
69
|
+
pins []string // base hashes named by each bundle that touches rel
|
|
70
|
+
before []byte
|
|
71
|
+
existed bool
|
|
72
|
+
after []byte
|
|
73
|
+
hunkSeen int
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// RunApplyBundle implements `devrites-engine state apply-bundle <slug>
|
|
77
|
+
// <bundle.json>... [--dry-run]`. It replaces the root's hand fold of drafter
|
|
78
|
+
// hunks: every bundle is validated against the current bytes (base hash pin,
|
|
79
|
+
// unique non-overlapping old_text, no lost tracked ID, no new task-graph
|
|
80
|
+
// problem, no new material budget overshoot) and then all files are written,
|
|
81
|
+
// or none are. Several bundles combine when their hunks are disjoint.
|
|
82
|
+
//
|
|
83
|
+
// Exit codes:
|
|
84
|
+
//
|
|
85
|
+
// 0 applied (or dry-run clean)
|
|
86
|
+
// 2 usage error
|
|
87
|
+
// 3 blocked: a bundle failed validation; nothing was written
|
|
88
|
+
// 1 internal/IO error
|
|
89
|
+
func RunApplyBundle(root string, args []string, stdout, stderr io.Writer) int {
|
|
90
|
+
dryRun := false
|
|
91
|
+
var positional []string
|
|
92
|
+
for _, arg := range args {
|
|
93
|
+
if arg == "--dry-run" {
|
|
94
|
+
dryRun = true
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
if strings.HasPrefix(arg, "-") {
|
|
98
|
+
fmt.Fprintln(stderr, "usage: devrites-engine state apply-bundle <slug> <bundle.json>... [--dry-run]")
|
|
99
|
+
return 2
|
|
100
|
+
}
|
|
101
|
+
positional = append(positional, arg)
|
|
102
|
+
}
|
|
103
|
+
if len(positional) < 2 {
|
|
104
|
+
fmt.Fprintln(stderr, "usage: devrites-engine state apply-bundle <slug> <bundle.json>... [--dry-run]")
|
|
105
|
+
return 2
|
|
106
|
+
}
|
|
107
|
+
slug := positional[0]
|
|
108
|
+
workspace, err := devritespaths.ExistingFeatureDirChecked(root, slug)
|
|
109
|
+
if err != nil {
|
|
110
|
+
fmt.Fprintf(stderr, "apply-bundle: BLOCKED: workspace for %s: %v\n", slug, err)
|
|
111
|
+
return 3
|
|
112
|
+
}
|
|
113
|
+
if err := state.RequireWorkspaceSchema(root, slug); err != nil {
|
|
114
|
+
fmt.Fprintf(stderr, "apply-bundle: BLOCKED: %v\n", err)
|
|
115
|
+
return 3
|
|
116
|
+
}
|
|
117
|
+
bundles := make([]candidateBundle, 0, len(positional)-1)
|
|
118
|
+
for _, name := range positional[1:] {
|
|
119
|
+
bundle, err := readCandidateBundle(name)
|
|
120
|
+
if err != nil {
|
|
121
|
+
fmt.Fprintf(stderr, "apply-bundle: BLOCKED: bundle %s: %v\n", name, err)
|
|
122
|
+
return 3
|
|
123
|
+
}
|
|
124
|
+
bundles = append(bundles, bundle)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
code := 0
|
|
128
|
+
if err := state.WithFeatureLock(root, slug, func() error {
|
|
129
|
+
code = applyBundlesLocked(slug, workspace, bundles, dryRun, stdout, stderr)
|
|
130
|
+
return nil
|
|
131
|
+
}); err != nil {
|
|
132
|
+
fmt.Fprintf(stderr, "apply-bundle: %v\n", err)
|
|
133
|
+
return 1
|
|
134
|
+
}
|
|
135
|
+
return code
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func readCandidateBundle(name string) (candidateBundle, error) {
|
|
139
|
+
var bundle candidateBundle
|
|
140
|
+
raw, err := readBoundedRegularFile(name, maxBundleBytes)
|
|
141
|
+
if err != nil {
|
|
142
|
+
return bundle, err
|
|
143
|
+
}
|
|
144
|
+
if err := json.Unmarshal(raw, &bundle); err != nil {
|
|
145
|
+
return bundle, fmt.Errorf("not a JSON candidate bundle: %w", err)
|
|
146
|
+
}
|
|
147
|
+
if len(bundle.CandidateFiles) == 0 {
|
|
148
|
+
return bundle, errors.New("candidate_files is empty")
|
|
149
|
+
}
|
|
150
|
+
return bundle, nil
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
func applyBundlesLocked(slug, workspace string, bundles []candidateBundle, dryRun bool, stdout, stderr io.Writer) int {
|
|
154
|
+
ws, err := os.OpenRoot(workspace)
|
|
155
|
+
if err != nil {
|
|
156
|
+
fmt.Fprintf(stderr, "apply-bundle: %v\n", err)
|
|
157
|
+
return 1
|
|
158
|
+
}
|
|
159
|
+
defer func() { _ = ws.Close() }()
|
|
160
|
+
|
|
161
|
+
targets, retired, problems := collectBundleTargets(slug, bundles)
|
|
162
|
+
if len(problems) == 0 {
|
|
163
|
+
problems = materializeTargets(ws, targets)
|
|
164
|
+
}
|
|
165
|
+
if len(problems) == 0 {
|
|
166
|
+
problems = checkBundleResult(targets, retired)
|
|
167
|
+
}
|
|
168
|
+
if len(problems) > 0 {
|
|
169
|
+
for _, problem := range problems {
|
|
170
|
+
fmt.Fprintf(stderr, "apply-bundle: BLOCKED: %s\n", problem)
|
|
171
|
+
}
|
|
172
|
+
fmt.Fprintln(stderr, "apply-bundle: nothing written")
|
|
173
|
+
return 3
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
hunks := 0
|
|
177
|
+
for _, target := range targets {
|
|
178
|
+
hunks += len(target.hunks)
|
|
179
|
+
lines := state.CountLines(target.after)
|
|
180
|
+
budget := ""
|
|
181
|
+
if limit, ok := state.ArtifactLineBudget(filepath.Base(target.rel)); ok {
|
|
182
|
+
budget = fmt.Sprintf("/%d", limit)
|
|
183
|
+
}
|
|
184
|
+
fmt.Fprintf(stdout, "apply-bundle: %s %s %d hunk(s) sha256 %s -> %s lines %d%s\n",
|
|
185
|
+
target.rel, target.op, len(target.hunks), shortHash(target.before, target.existed), shortHash(target.after, true), lines, budget)
|
|
186
|
+
}
|
|
187
|
+
if dryRun {
|
|
188
|
+
fmt.Fprintf(stdout, "apply-bundle: dry-run clean: %d file(s), %d hunk(s); nothing written\n", len(targets), hunks)
|
|
189
|
+
return 0
|
|
190
|
+
}
|
|
191
|
+
if err := writeBundleTargets(ws, targets); err != nil {
|
|
192
|
+
fmt.Fprintf(stderr, "apply-bundle: %v\n", err)
|
|
193
|
+
return 1
|
|
194
|
+
}
|
|
195
|
+
fmt.Fprintf(stdout, "apply-bundle: applied %d file(s), %d hunk(s)\n", len(targets), hunks)
|
|
196
|
+
return 0
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// collectBundleTargets merges every bundle's candidate files by workspace path.
|
|
200
|
+
// One path takes either one create/replace or any number of patch entries.
|
|
201
|
+
func collectBundleTargets(slug string, bundles []candidateBundle) ([]*bundleTarget, map[string]bool, []string) {
|
|
202
|
+
var problems []string
|
|
203
|
+
byRel := map[string]*bundleTarget{}
|
|
204
|
+
retired := map[string]bool{}
|
|
205
|
+
prefix := ".devrites/work/" + slug + "/"
|
|
206
|
+
for bi, bundle := range bundles {
|
|
207
|
+
for _, id := range bundle.RetiredIDs {
|
|
208
|
+
retired[strings.TrimSpace(id)] = true
|
|
209
|
+
}
|
|
210
|
+
pins := map[string]string{}
|
|
211
|
+
for path, hash := range bundle.BaseHashes {
|
|
212
|
+
pins[strings.TrimPrefix(filepath.ToSlash(path), prefix)] = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(hash), "sha256:"))
|
|
213
|
+
}
|
|
214
|
+
for _, file := range bundle.CandidateFiles {
|
|
215
|
+
rel := strings.TrimPrefix(filepath.ToSlash(file.Path), prefix)
|
|
216
|
+
where := fmt.Sprintf("bundle %d %s", bi+1, file.Path)
|
|
217
|
+
if !filepath.IsLocal(filepath.FromSlash(rel)) || !strings.HasSuffix(rel, ".md") {
|
|
218
|
+
problems = append(problems, where+": path must be a Markdown file inside the "+slug+" workspace")
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
op := strings.ToLower(strings.TrimSpace(file.Operation))
|
|
222
|
+
switch op {
|
|
223
|
+
case "create", "replace":
|
|
224
|
+
if file.Content == nil {
|
|
225
|
+
problems = append(problems, where+": "+op+" needs content")
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
case "patch":
|
|
229
|
+
if len(file.Hunks) == 0 {
|
|
230
|
+
problems = append(problems, where+": patch needs hunks")
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
default:
|
|
234
|
+
problems = append(problems, fmt.Sprintf("%s: unknown operation %q", where, file.Operation))
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
target, seen := byRel[rel]
|
|
238
|
+
if !seen {
|
|
239
|
+
target = &bundleTarget{rel: rel, op: op}
|
|
240
|
+
byRel[rel] = target
|
|
241
|
+
} else if op != "patch" || target.op != "patch" {
|
|
242
|
+
problems = append(problems, where+": conflicts with another candidate for the same file (only patch hunks combine)")
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
if op != "create" {
|
|
246
|
+
pin, ok := pins[rel]
|
|
247
|
+
if !ok {
|
|
248
|
+
problems = append(problems, where+": base_hashes has no pin for this file")
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
target.pins = append(target.pins, pin)
|
|
252
|
+
}
|
|
253
|
+
if file.Content != nil {
|
|
254
|
+
target.content = *file.Content
|
|
255
|
+
}
|
|
256
|
+
target.hunks = append(target.hunks, file.Hunks...)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
targets := make([]*bundleTarget, 0, len(byRel))
|
|
260
|
+
for _, target := range byRel {
|
|
261
|
+
targets = append(targets, target)
|
|
262
|
+
}
|
|
263
|
+
sort.Slice(targets, func(i, j int) bool { return targets[i].rel < targets[j].rel })
|
|
264
|
+
return targets, retired, problems
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// materializeTargets reads each file's current bytes, verifies its pins, and
|
|
268
|
+
// computes the after bytes.
|
|
269
|
+
func materializeTargets(ws *os.Root, targets []*bundleTarget) []string {
|
|
270
|
+
var problems []string
|
|
271
|
+
for _, target := range targets {
|
|
272
|
+
before, existed, err := readBundleFile(ws, target.rel)
|
|
273
|
+
if err != nil {
|
|
274
|
+
problems = append(problems, fmt.Sprintf("%s: %v", target.rel, err))
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
target.before, target.existed = before, existed
|
|
278
|
+
if target.op == "create" {
|
|
279
|
+
if existed {
|
|
280
|
+
problems = append(problems, target.rel+": create target already exists")
|
|
281
|
+
continue
|
|
282
|
+
}
|
|
283
|
+
target.after = []byte(target.content)
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
if !existed {
|
|
287
|
+
problems = append(problems, target.rel+": "+target.op+" target does not exist")
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
current := sha256Hex(before)
|
|
291
|
+
for _, pin := range target.pins {
|
|
292
|
+
if pin != current {
|
|
293
|
+
problems = append(problems, fmt.Sprintf("%s: stale base: pinned %s, current %s", target.rel, abbrev(pin), abbrev(current)))
|
|
294
|
+
break
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if target.op == "replace" {
|
|
298
|
+
target.after = []byte(target.content)
|
|
299
|
+
continue
|
|
300
|
+
}
|
|
301
|
+
after, hunkProblems := applyHunks(string(before), target.hunks)
|
|
302
|
+
for _, problem := range hunkProblems {
|
|
303
|
+
problems = append(problems, target.rel+": "+problem)
|
|
304
|
+
}
|
|
305
|
+
target.after = []byte(after)
|
|
306
|
+
}
|
|
307
|
+
return problems
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// applyHunks replaces each hunk's old_text, which must occur exactly once in
|
|
311
|
+
// base and must not overlap another hunk's span.
|
|
312
|
+
func applyHunks(base string, hunks []candidateHunk) (string, []string) {
|
|
313
|
+
type span struct {
|
|
314
|
+
start, end int
|
|
315
|
+
hunk candidateHunk
|
|
316
|
+
}
|
|
317
|
+
var problems []string
|
|
318
|
+
spans := make([]span, 0, len(hunks))
|
|
319
|
+
for i, hunk := range hunks {
|
|
320
|
+
name := hunk.ID
|
|
321
|
+
if name == "" {
|
|
322
|
+
name = fmt.Sprintf("#%d", i+1)
|
|
323
|
+
}
|
|
324
|
+
hunk.ID = name
|
|
325
|
+
if hunk.OldText == "" {
|
|
326
|
+
problems = append(problems, "hunk "+name+": empty old_text")
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
start := strings.Index(base, hunk.OldText)
|
|
330
|
+
if start < 0 {
|
|
331
|
+
problems = append(problems, "hunk "+name+": old_text not found (stale anchor)")
|
|
332
|
+
continue
|
|
333
|
+
}
|
|
334
|
+
// Search from start+1 so an overlapping second occurrence also counts.
|
|
335
|
+
if strings.Contains(base[start+1:], hunk.OldText) {
|
|
336
|
+
problems = append(problems, "hunk "+name+": old_text occurs more than once (non-unique anchor)")
|
|
337
|
+
continue
|
|
338
|
+
}
|
|
339
|
+
spans = append(spans, span{start, start + len(hunk.OldText), hunk})
|
|
340
|
+
}
|
|
341
|
+
sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start })
|
|
342
|
+
for i := 1; i < len(spans); i++ {
|
|
343
|
+
if spans[i].start < spans[i-1].end {
|
|
344
|
+
problems = append(problems, fmt.Sprintf("hunks %s and %s overlap", spans[i-1].hunk.ID, spans[i].hunk.ID))
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if len(problems) > 0 {
|
|
348
|
+
return base, problems
|
|
349
|
+
}
|
|
350
|
+
var out strings.Builder
|
|
351
|
+
last := 0
|
|
352
|
+
for _, s := range spans {
|
|
353
|
+
out.WriteString(base[last:s.start])
|
|
354
|
+
out.WriteString(s.hunk.NewText)
|
|
355
|
+
last = s.end
|
|
356
|
+
}
|
|
357
|
+
out.WriteString(base[last:])
|
|
358
|
+
return out.String(), nil
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// checkBundleResult rejects an outcome the root would otherwise discover only
|
|
362
|
+
// at Vet: a tracked ID that vanished from every touched file without being
|
|
363
|
+
// listed in retired_ids, a new tasks.md graph problem, or a budgeted artifact
|
|
364
|
+
// that grew into material overshoot without a `Budget override:` line.
|
|
365
|
+
func checkBundleResult(targets []*bundleTarget, retired map[string]bool) []string {
|
|
366
|
+
var problems []string
|
|
367
|
+
beforeIDs, afterIDs := map[string]bool{}, map[string]bool{}
|
|
368
|
+
for _, target := range targets {
|
|
369
|
+
for _, id := range trackedIDRE.FindAllString(string(target.before), -1) {
|
|
370
|
+
beforeIDs[id] = true
|
|
371
|
+
}
|
|
372
|
+
for _, id := range trackedIDRE.FindAllString(string(target.after), -1) {
|
|
373
|
+
afterIDs[id] = true
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
var lost []string
|
|
377
|
+
for id := range beforeIDs {
|
|
378
|
+
if !afterIDs[id] && !retired[id] {
|
|
379
|
+
lost = append(lost, id)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if len(lost) > 0 {
|
|
383
|
+
slices.Sort(lost)
|
|
384
|
+
problems = append(problems, "IDs vanished from every touched file (list them in retired_ids if intended): "+strings.Join(lost, ", "))
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
for _, target := range targets {
|
|
388
|
+
name := filepath.Base(target.rel)
|
|
389
|
+
if name == "tasks.md" && filepath.Dir(target.rel) == "." {
|
|
390
|
+
old := map[string]bool{}
|
|
391
|
+
if target.existed {
|
|
392
|
+
for _, p := range state.ParseTaskGraph(target.before).Problems {
|
|
393
|
+
old[p] = true
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
for _, p := range state.ParseTaskGraph(target.after).Problems {
|
|
397
|
+
if !old[p] {
|
|
398
|
+
problems = append(problems, "tasks.md: "+p)
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if filepath.Dir(target.rel) == "." && currentStatePlanning[name] &&
|
|
403
|
+
len(datedHeadingRE.FindAllIndex(target.after, -1)) > len(datedHeadingRE.FindAllIndex(target.before, -1)) {
|
|
404
|
+
problems = append(problems, target.rel+": adds a dated delta section; rewrite the owning clause in place (rationale to decisions.md, closed narrative to history/)")
|
|
405
|
+
}
|
|
406
|
+
if filepath.Dir(target.rel) != "." || !state.ArtifactBudgetGateApplies(name) {
|
|
407
|
+
continue
|
|
408
|
+
}
|
|
409
|
+
after, _ := state.ArtifactBudget(name, target.after)
|
|
410
|
+
before, _ := state.ArtifactBudget(name, target.before)
|
|
411
|
+
grew := !target.existed || after.Lines > before.Lines || after.Bytes > before.Bytes
|
|
412
|
+
if after.Material && !after.Override && grew {
|
|
413
|
+
problems = append(problems, fmt.Sprintf("%s: %d lines / %d bytes is a material overshoot of its %d-line budget; split or relocate instead of growing it",
|
|
414
|
+
target.rel, after.Lines, after.Bytes, after.LineBudget))
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return problems
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// writeBundleTargets writes every target atomically and, if one write fails,
|
|
421
|
+
// restores the files already written so the workspace never holds half a
|
|
422
|
+
// bundle.
|
|
423
|
+
func writeBundleTargets(ws *os.Root, targets []*bundleTarget) error {
|
|
424
|
+
for i, target := range targets {
|
|
425
|
+
if err := fsutil.WriteFileAtomicIn(ws, filepath.FromSlash(target.rel), target.after, 0o644); err != nil {
|
|
426
|
+
for _, done := range targets[:i] {
|
|
427
|
+
if done.existed {
|
|
428
|
+
_ = fsutil.WriteFileAtomicIn(ws, filepath.FromSlash(done.rel), done.before, 0o644)
|
|
429
|
+
} else {
|
|
430
|
+
_ = ws.Remove(filepath.FromSlash(done.rel))
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return fmt.Errorf("write %s: %w (earlier files restored)", target.rel, err)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return nil
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
func readBundleFile(ws *os.Root, rel string) ([]byte, bool, error) {
|
|
440
|
+
info, err := ws.Lstat(filepath.FromSlash(rel))
|
|
441
|
+
if errors.Is(err, fs.ErrNotExist) {
|
|
442
|
+
return nil, false, nil
|
|
443
|
+
}
|
|
444
|
+
if err != nil {
|
|
445
|
+
return nil, false, err
|
|
446
|
+
}
|
|
447
|
+
if !info.Mode().IsRegular() {
|
|
448
|
+
return nil, false, errors.New("not a regular file")
|
|
449
|
+
}
|
|
450
|
+
if info.Size() > state.MaxArtifactBytes {
|
|
451
|
+
return nil, false, fmt.Errorf("exceeds the %d-byte artifact cap", state.MaxArtifactBytes)
|
|
452
|
+
}
|
|
453
|
+
raw, err := ws.ReadFile(filepath.FromSlash(rel))
|
|
454
|
+
return raw, true, err
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
func sha256Hex(raw []byte) string {
|
|
458
|
+
sum := sha256.Sum256(raw)
|
|
459
|
+
return hex.EncodeToString(sum[:])
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
func shortHash(raw []byte, exists bool) string {
|
|
463
|
+
if !exists {
|
|
464
|
+
return "(new)"
|
|
465
|
+
}
|
|
466
|
+
return abbrev(sha256Hex(raw))
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
func abbrev(hash string) string {
|
|
470
|
+
if len(hash) > 12 {
|
|
471
|
+
return hash[:12]
|
|
472
|
+
}
|
|
473
|
+
return hash
|
|
474
|
+
}
|
|
@@ -51,6 +51,10 @@ type claimRecord struct {
|
|
|
51
51
|
ClaimedAt string `json:"claimed_at"`
|
|
52
52
|
ExpiresAt string `json:"expires_at"`
|
|
53
53
|
Released bool `json:"released,omitempty"`
|
|
54
|
+
// Host and Anchors identify the claiming session's long-lived processes,
|
|
55
|
+
// so a later session can prove that holder exited (see holderProbe).
|
|
56
|
+
Host string `json:"host,omitempty"`
|
|
57
|
+
Anchors []claimProc `json:"anchors,omitempty"`
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
// RunClaim manages the advisory file-claim ledger.
|
|
@@ -299,7 +303,8 @@ func claimAddLocked(path, session, reason string, ttl int, paths []string, stdou
|
|
|
299
303
|
return 2
|
|
300
304
|
}
|
|
301
305
|
now := time.Now().UTC()
|
|
302
|
-
|
|
306
|
+
probe := newHolderProbe()
|
|
307
|
+
conflicts := claimConflicts(probe.withoutGone(liveClaims(latest, order, now)), session, paths)
|
|
303
308
|
if len(conflicts) > 0 {
|
|
304
309
|
printConflicts(stderr, conflicts)
|
|
305
310
|
return 3
|
|
@@ -311,6 +316,8 @@ func claimAddLocked(path, session, reason string, ttl int, paths []string, stdou
|
|
|
311
316
|
Reason: reason,
|
|
312
317
|
ClaimedAt: now.Format(time.RFC3339),
|
|
313
318
|
ExpiresAt: now.Add(time.Duration(ttl) * time.Minute).Format(time.RFC3339),
|
|
319
|
+
Host: probe.host,
|
|
320
|
+
Anchors: probe.anchors(now),
|
|
314
321
|
}
|
|
315
322
|
if err := appendClaim(path, rec); err != nil {
|
|
316
323
|
fmt.Fprintf(stderr, "claim: %v\n", err)
|
|
@@ -389,6 +396,7 @@ func claimList(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
389
396
|
return 2
|
|
390
397
|
}
|
|
391
398
|
now := time.Now().UTC()
|
|
399
|
+
probe := newHolderProbe()
|
|
392
400
|
rows := liveClaims(latest, order, now)
|
|
393
401
|
if all {
|
|
394
402
|
rows = rows[:0]
|
|
@@ -404,6 +412,8 @@ func claimList(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
404
412
|
state := "live"
|
|
405
413
|
if rec.Released {
|
|
406
414
|
state = "released"
|
|
415
|
+
} else if probe.gone(rec) {
|
|
416
|
+
state = "dead"
|
|
407
417
|
} else if exp, err := time.Parse(time.RFC3339, rec.ExpiresAt); err != nil || !exp.After(now) {
|
|
408
418
|
state = "expired"
|
|
409
419
|
}
|
|
@@ -453,7 +463,7 @@ func claimCheck(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
453
463
|
fmt.Fprintf(stderr, unparsedLedgerMsg, bad)
|
|
454
464
|
return 2
|
|
455
465
|
}
|
|
456
|
-
conflicts := claimConflicts(liveClaims(latest, order, time.Now().UTC()), session, paths)
|
|
466
|
+
conflicts := claimConflicts(newHolderProbe().withoutGone(liveClaims(latest, order, time.Now().UTC())), session, paths)
|
|
457
467
|
if len(conflicts) > 0 {
|
|
458
468
|
printConflicts(stdout, conflicts)
|
|
459
469
|
return 3
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
package lib
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"os/exec"
|
|
6
|
+
"slices"
|
|
7
|
+
"strconv"
|
|
8
|
+
"strings"
|
|
9
|
+
"time"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// claimAnchorAge separates a claiming session's long-lived host processes from
|
|
13
|
+
// the per-command shells and launchers that start moments before `claim add`.
|
|
14
|
+
const claimAnchorAge = time.Minute
|
|
15
|
+
|
|
16
|
+
// claimProc names one process by pid and start time, so a reused pid cannot
|
|
17
|
+
// impersonate it.
|
|
18
|
+
type claimProc struct {
|
|
19
|
+
PID int `json:"pid"`
|
|
20
|
+
Start string `json:"start"`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type procInfo struct {
|
|
24
|
+
ppid int
|
|
25
|
+
start string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// processTable lists running processes keyed by pid, or nil when the host
|
|
29
|
+
// cannot say. Tests replace it.
|
|
30
|
+
var processTable = psProcessTable
|
|
31
|
+
|
|
32
|
+
func psProcessTable() map[int]procInfo {
|
|
33
|
+
cmd := exec.Command("ps", "-A", "-o", "pid=,ppid=,lstart=")
|
|
34
|
+
cmd.Env = append(os.Environ(), "LC_ALL=C")
|
|
35
|
+
out, err := cmd.Output()
|
|
36
|
+
if err != nil {
|
|
37
|
+
return nil
|
|
38
|
+
}
|
|
39
|
+
table := map[int]procInfo{}
|
|
40
|
+
for _, line := range strings.Split(string(out), "\n") {
|
|
41
|
+
f := strings.Fields(line)
|
|
42
|
+
if len(f) != 7 {
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
pid, perr := strconv.Atoi(f[0])
|
|
46
|
+
ppid, pperr := strconv.Atoi(f[1])
|
|
47
|
+
start, serr := time.ParseInLocation("Mon Jan 2 15:04:05 2006", strings.Join(f[2:], " "), time.Local)
|
|
48
|
+
if perr != nil || pperr != nil || serr != nil {
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
table[pid] = procInfo{ppid: ppid, start: start.UTC().Format(time.RFC3339)}
|
|
52
|
+
}
|
|
53
|
+
if len(table) == 0 {
|
|
54
|
+
return nil
|
|
55
|
+
}
|
|
56
|
+
return table
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// lineage walks from pid up to, but not including, pid 1.
|
|
60
|
+
func lineage(table map[int]procInfo, pid int) []claimProc {
|
|
61
|
+
var out []claimProc
|
|
62
|
+
seen := map[int]bool{}
|
|
63
|
+
for pid > 1 && !seen[pid] {
|
|
64
|
+
seen[pid] = true
|
|
65
|
+
p, ok := table[pid]
|
|
66
|
+
if !ok {
|
|
67
|
+
break
|
|
68
|
+
}
|
|
69
|
+
out = append(out, claimProc{PID: pid, Start: p.start})
|
|
70
|
+
pid = p.ppid
|
|
71
|
+
}
|
|
72
|
+
return out
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// holderProbe answers whether a claim's holding session has exited. It only
|
|
76
|
+
// ever proves exit on this host; anything it cannot see counts as alive.
|
|
77
|
+
type holderProbe struct {
|
|
78
|
+
table map[int]procInfo
|
|
79
|
+
host string
|
|
80
|
+
self []claimProc
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func newHolderProbe() holderProbe {
|
|
84
|
+
table := processTable()
|
|
85
|
+
host, _ := os.Hostname()
|
|
86
|
+
return holderProbe{table: table, host: host, self: lineage(table, os.Getppid())}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// anchors returns the caller's ancestors that started more than
|
|
90
|
+
// claimAnchorAge before now: the session's host processes.
|
|
91
|
+
func (p holderProbe) anchors(now time.Time) []claimProc {
|
|
92
|
+
var out []claimProc
|
|
93
|
+
for _, proc := range p.self {
|
|
94
|
+
start, err := time.Parse(time.RFC3339, proc.Start)
|
|
95
|
+
if err == nil && now.Sub(start) > claimAnchorAge {
|
|
96
|
+
out = append(out, proc)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// gone reports whether rec's holder is proven exited: the claim was taken on
|
|
103
|
+
// this host and every anchor it does not share with the caller has exited.
|
|
104
|
+
func (p holderProbe) gone(rec *claimRecord) bool {
|
|
105
|
+
if p.table == nil || rec.Host == "" || rec.Host != p.host {
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
unique := 0
|
|
109
|
+
for _, a := range rec.Anchors {
|
|
110
|
+
if slices.Contains(p.self, a) {
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
unique++
|
|
114
|
+
if proc, ok := p.table[a.PID]; ok && proc.start == a.Start {
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return unique > 0
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// withoutGone drops claims whose holder session has exited.
|
|
122
|
+
func (p holderProbe) withoutGone(claims []*claimRecord) []*claimRecord {
|
|
123
|
+
return slices.DeleteFunc(claims, p.gone)
|
|
124
|
+
}
|
|
@@ -5,6 +5,7 @@ import (
|
|
|
5
5
|
"io"
|
|
6
6
|
|
|
7
7
|
"github.com/devrites/devrites/internal/devritespaths"
|
|
8
|
+
"github.com/devrites/devrites/internal/parallel"
|
|
8
9
|
"github.com/devrites/devrites/internal/state"
|
|
9
10
|
)
|
|
10
11
|
|
|
@@ -36,6 +37,19 @@ func RunTaskGraphCheck(root, slug string, stdout, stderr io.Writer) int {
|
|
|
36
37
|
if over > 0 {
|
|
37
38
|
recordMetric(root, slug, "define", "size-advisory", "", int64(over))
|
|
38
39
|
}
|
|
40
|
+
paths, serial := sliceWritePaths(raw, graph)
|
|
41
|
+
if rounds := scheduleRounds(graph, paths, root); rounds > 0 {
|
|
42
|
+
fmt.Fprintf(stdout, "task-graph: schedule: %d slices in %d build rounds at cap %d\n", len(graph.Slices), rounds, parallel.MaxParallelSlices)
|
|
43
|
+
}
|
|
44
|
+
if hubs := hubAdvisories(graph, paths, serial); len(hubs) > 0 {
|
|
45
|
+
for _, line := range hubs[:min(len(hubs), hubAdvisoryShown)] {
|
|
46
|
+
fmt.Fprintf(stdout, "task-graph: %s\n", line)
|
|
47
|
+
}
|
|
48
|
+
if extra := len(hubs) - hubAdvisoryShown; extra > 0 {
|
|
49
|
+
fmt.Fprintf(stdout, "task-graph: ADVISORY hub: %d more shared paths; fix the ones above first\n", extra)
|
|
50
|
+
}
|
|
51
|
+
recordMetric(root, slug, "define", "hub-advisory", "", int64(len(hubs)))
|
|
52
|
+
}
|
|
39
53
|
}
|
|
40
54
|
if edges := edgeAdvisories(graph); len(edges) > 0 {
|
|
41
55
|
for _, line := range edges {
|
|
@@ -177,7 +177,7 @@ func (o diffScopeOpts) changedPaths() ([]string, error) {
|
|
|
177
177
|
|
|
178
178
|
// engineMachineFiles are the feature-root files the engine itself writes:
|
|
179
179
|
// the dispatch ledger, metrics ledger, drift and regression baselines, the
|
|
180
|
-
// parallel lease, and the
|
|
180
|
+
// parallel lease, the feature lock, and the gate-execution lock.
|
|
181
181
|
var engineMachineFiles = map[string]bool{
|
|
182
182
|
"dispatch.json": true,
|
|
183
183
|
MetricsFile: true,
|
|
@@ -185,6 +185,7 @@ var engineMachineFiles = map[string]bool{
|
|
|
185
185
|
regressionBaselineFile: true,
|
|
186
186
|
"parallel-lease.md": true,
|
|
187
187
|
".lock": true,
|
|
188
|
+
"gates.md.lock": true,
|
|
188
189
|
}
|
|
189
190
|
|
|
190
191
|
// engineMachineFile reports whether rel, relative to the .devrites tree, is
|