1f5bf7e66e
The brief told the agent to ask for a phase change and never carried the asking. The agent asked in prose, no code represented the request, and the session idled until its lease expired. That is what failed run 3. F21. The agent asks with .orchestra/phase-request.json, and seals research.json or plan.json where the phase it is leaving produces one. At a verified turn boundary the worker checks the phase belief, the transition and the artifact, then calls the coordinator with its lease epoch and a derived operation id. AdvanceWorkPhase is unchanged, so a request cannot reach a move the operator surface could not also make. Redelivery is idempotent. F22. A session now records the phase it was launched to run. One that no longer matches its task rotates with reason phase_changed, whether this worker asked for the change or an operator made it. F20. CLIAdapter.prompt sent handoff and rotation prompts without confirming them, which is the failure F20 exists to catch. Fixed at the shared call site. F23 needed no change. Issue comments already become decisions with no submission, through Reconciler.Reconcile at PreLease and at every turn boundary. The earlier finding searched internal/operations alone and was wrong. Tests now cover the boundary it turns on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011xsXyr5J1RACo71YeKG3Pu
546 lines
18 KiB
Go
546 lines
18 KiB
Go
// Package herdr contains the small, protocol-oriented execution seam used by
|
|
// the orchestration layer. It deliberately does not shell out to a harness.
|
|
package herdr
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
var ErrProtocol = errors.New("herdr protocol error")
|
|
|
|
type Request struct {
|
|
ID string `json:"id"`
|
|
Method string `json:"method"`
|
|
// Herdr's JSON-RPC decoder requires params to be present, including for
|
|
// parameterless calls such as ping. Encode nil as an explicit JSON null.
|
|
Params any `json:"params"`
|
|
}
|
|
type Response struct {
|
|
ID string `json:"id"`
|
|
Result json.RawMessage `json:"result"`
|
|
Error *struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error,omitempty"`
|
|
}
|
|
type Client struct {
|
|
Path string
|
|
Timeout time.Duration
|
|
dial func() (net.Conn, error)
|
|
mu sync.Mutex
|
|
next uint64
|
|
panes map[string]string
|
|
agents map[string]string // pane ID -> machine-global agent name
|
|
}
|
|
|
|
type WorktreeInfo struct {
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
type worktreeResponse struct {
|
|
Path string `json:"path"`
|
|
Worktree WorktreeInfo `json:"worktree"`
|
|
RootPane struct {
|
|
PaneID string `json:"pane_id"`
|
|
Agent string `json:"agent"`
|
|
} `json:"root_pane"`
|
|
Workspace struct {
|
|
RootPane struct {
|
|
PaneID string `json:"pane_id"`
|
|
} `json:"root_pane"`
|
|
} `json:"workspace"`
|
|
}
|
|
|
|
func New(path string) *Client {
|
|
return &Client{Path: path, Timeout: 10 * time.Second, panes: map[string]string{}, agents: map[string]string{}}
|
|
}
|
|
func (c *Client) conn() (net.Conn, error) {
|
|
if c.dial != nil {
|
|
return c.dial()
|
|
}
|
|
network := "unix"
|
|
if strings.Contains(c.Path, "://") || (strings.Contains(c.Path, ":") && !strings.HasPrefix(c.Path, "/")) {
|
|
network = "tcp"
|
|
}
|
|
return net.DialTimeout(network, c.Path, c.Timeout)
|
|
}
|
|
func (c *Client) Call(ctx context.Context, method string, params any, out any) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.next++
|
|
id := fmt.Sprint(c.next)
|
|
cn, err := c.conn()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cn.Close()
|
|
if d, ok := ctx.Deadline(); ok {
|
|
_ = cn.SetDeadline(d)
|
|
} else if c.Timeout > 0 {
|
|
_ = cn.SetDeadline(time.Now().Add(c.Timeout))
|
|
}
|
|
if params == nil {
|
|
params = map[string]any{}
|
|
}
|
|
if err = json.NewEncoder(cn).Encode(Request{ID: id, Method: method, Params: params}); err != nil {
|
|
return err
|
|
}
|
|
var r Response
|
|
if err = json.NewDecoder(bufio.NewReader(cn)).Decode(&r); err != nil {
|
|
return err
|
|
}
|
|
if r.Error != nil {
|
|
return fmt.Errorf("%w: %s", ErrProtocol, r.Error.Message)
|
|
}
|
|
if out != nil && len(r.Result) > 0 {
|
|
return json.Unmarshal(r.Result, out)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type PingResult struct {
|
|
Protocol json.RawMessage `json:"protocol"`
|
|
Version json.RawMessage `json:"version"`
|
|
}
|
|
|
|
func (c *Client) Ping(ctx context.Context) (PingResult, error) {
|
|
var p PingResult
|
|
err := c.Call(ctx, "ping", nil, &p)
|
|
return p, err
|
|
}
|
|
func (c *Client) CheckProtocol(ctx context.Context, want string) error {
|
|
p, e := c.Ping(ctx)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if want != "" {
|
|
var text string
|
|
if err := json.Unmarshal(p.Protocol, &text); err != nil {
|
|
text = string(p.Protocol)
|
|
}
|
|
if text != want {
|
|
return fmt.Errorf("%w: want %s, got %s", ErrProtocol, want, text)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type Session struct {
|
|
PaneID string `json:"pane_id"`
|
|
Worktree string `json:"worktree"`
|
|
Harness string `json:"harness"`
|
|
AgentName string `json:"agent_name,omitempty"`
|
|
HerdrID string `json:"herdr_id,omitempty"`
|
|
// SessionFile is the filesystem path to the harness's own session/
|
|
// transcript state (a Claude Code transcript, a Codex rollout, ...).
|
|
// ClaudeUsage/CodexUsage/OpenCodeUsage read *this*, never the herdr pane
|
|
// id — occupancy is a property of the harness's session state, not of
|
|
// the pane multiplexing it. Left empty until resolved (see
|
|
// CLIAdapter.Occupancy), since the file may not exist yet immediately
|
|
// after lease.
|
|
SessionFile string `json:"session_file,omitempty"`
|
|
// SessionID is the harness-native identity when its usage is stored in a
|
|
// database rather than a transcript. OpenCode's SQLite session ID is kept
|
|
// here so rotation never guesses "the newest session" after a restart.
|
|
SessionID string `json:"session_id,omitempty"`
|
|
// TaskFileSHA is the sha256 of the worktree's TASK.md at the time this
|
|
// session's lease was created — the immutable-spec hash continuity's
|
|
// pickup validation compares against on the next rotation (§6.2).
|
|
TaskFileSHA string `json:"task_file_sha,omitempty"`
|
|
// Phase is the work phase this session was launched in. Orchestra may
|
|
// advance the phase while the session runs; a session that no longer
|
|
// matches its task's phase is finished, because a phase change is a
|
|
// change of cognitive context and not a change of instruction.
|
|
Phase string `json:"phase,omitempty"`
|
|
// DeliveredDecisions holds the ids of the human decisions this session has
|
|
// already been shown. A decision recorded while the lease is live is
|
|
// delivered at the next verified turn boundary, and recording it here is
|
|
// what stops the same correction being re-sent every turn.
|
|
DeliveredDecisions []string `json:"delivered_decisions,omitempty"`
|
|
// HandoffRequested is set once rotate() has prompted the agent to write
|
|
// its §6.1 handoff (HandoffFile) — avoids re-sending the same prompt
|
|
// every tick while Release keeps waiting for the file to appear.
|
|
HandoffRequested bool `json:"handoff_requested,omitempty"`
|
|
// HandoffReason is selected by the coordinator when it asks for the
|
|
// semantic report. The checkout worker, rather than the harness, copies
|
|
// it into the canonical handoff it seals at release time.
|
|
HandoffReason string `json:"handoff_reason,omitempty"`
|
|
// ConventionsHash is continuity.ConventionsHash of the project's shared
|
|
// *.md docs (AGENTS.md/CLAUDE.md/VOCAB.md) at the time this session was
|
|
// last notified of (or started with) their content — §6.3's staleness
|
|
// tracking lives at the orchestra layer, never trusted from the agent's
|
|
// cached view.
|
|
ConventionsHash string `json:"conventions_hash,omitempty"`
|
|
// ContextHandoffSHA is the last HANDOFF.md content consumed by Claude's
|
|
// in-place /clear rollover. It is initialized when the session starts so
|
|
// an older checked-in HANDOFF.md is not mistaken for a fresh hook result.
|
|
ContextHandoffSHA string `json:"context_handoff_sha,omitempty"`
|
|
// ContextResetSHA/ContextResetPhase make the two-command Claude rollover
|
|
// recoverable across worker restarts. They are unrelated to the canonical
|
|
// cross-worker handoff transaction above.
|
|
ContextResetSHA string `json:"context_reset_sha,omitempty"`
|
|
ContextResetPhase string `json:"context_reset_phase,omitempty"`
|
|
}
|
|
|
|
// bootDeadline bounds the retry loops below. Freshly created panes/agents
|
|
// have been observed (live, 2026-07-28) to reject the very next call for a
|
|
// range of different transient reasons as herdr finishes bringing them up —
|
|
// "not an available shell", "not an active named agent", "target ... not
|
|
// found" — a new wording each time a prior one got fixed. There is no other
|
|
// legitimate reason a call against state orchestra itself just created would
|
|
// fail immediately, so these loops retry any error rather than pattern-match
|
|
// an open-ended and apparently still-growing set of herdr wordings, bounded
|
|
// by wall-clock time rather than attempt count so a slow-booting pane still
|
|
// gets the same real budget as a fast-failing one.
|
|
const (
|
|
bootRetryWindow = 15 * time.Second
|
|
bootRetryDelay = 500 * time.Millisecond
|
|
|
|
// agentAttachWindow/agentAttachPoll bound StartAgent's post-success
|
|
// confirmation poll (B13, found live 2026-07-28): agent.start can return
|
|
// no error while never actually starting an agent, observed on two of
|
|
// three back-to-back leases — the pane's agent_status stayed "unknown"
|
|
// after 2+ minutes of polling, with no error surfaced anywhere. A
|
|
// legitimate attach has been observed taking "well over a minute", so
|
|
// this window is deliberately longer than bootRetryWindow.
|
|
claudeTrustObserveWindow = 15 * time.Second
|
|
claudeTrustClearWindow = 15 * time.Second
|
|
claudeTrustPoll = 500 * time.Millisecond
|
|
)
|
|
|
|
var (
|
|
agentAttachWindow = 90 * time.Second
|
|
agentAttachPoll = 2 * time.Second
|
|
)
|
|
|
|
type paneStatus struct {
|
|
Agent string `json:"agent"`
|
|
AgentStatus string `json:"agent_status"`
|
|
}
|
|
|
|
// paneGetResponse mirrors herdr's pane.get result envelope. The pane fields
|
|
// are not returned at the top level: the result is {"type":"pane_info",
|
|
// "pane": {...}}. Keeping that wrapper explicit prevents a successful
|
|
// agent.start from being falsely reported as unattached.
|
|
type paneGetResponse struct {
|
|
Pane paneStatus `json:"pane"`
|
|
}
|
|
|
|
type paneReadResponse struct {
|
|
Read struct {
|
|
Text string `json:"text"`
|
|
} `json:"read"`
|
|
}
|
|
|
|
// paneAgentAttached reports whether herdr's own view of the pane shows a
|
|
// real agent bound to it, rather than trusting agent.start's return value.
|
|
func (c *Client) paneAgentAttached(ctx context.Context, paneID string) (bool, error) {
|
|
var p paneGetResponse
|
|
if err := c.Call(ctx, "pane.get", map[string]any{"pane_id": paneID}, &p); err != nil {
|
|
return false, err
|
|
}
|
|
return p.Pane.Agent != "" && p.Pane.AgentStatus != "" && p.Pane.AgentStatus != "unknown", nil
|
|
}
|
|
|
|
func (c *Client) paneText(ctx context.Context, paneID string) (string, error) {
|
|
var p paneReadResponse
|
|
if err := c.Call(ctx, "pane.read", map[string]any{"pane_id": paneID, "source": "recent"}, &p); err != nil {
|
|
return "", err
|
|
}
|
|
return p.Read.Text, nil
|
|
}
|
|
|
|
func claudeWorkspaceTrustPrompt(text string) bool {
|
|
return strings.Contains(text, "Accessing workspace:") && strings.Contains(text, "Yes, I trust this folder")
|
|
}
|
|
|
|
// confirmClaudeWorkspaceTrust accepts only Claude Code's exact workspace
|
|
// trust prompt. Orchestra creates its worktrees from registered project
|
|
// repositories, so leaving this interactive would make every new worktree
|
|
// permanently unattended-ineligible. It deliberately does not accept any
|
|
// other Claude confirmation (in particular bypass-permissions mode).
|
|
func (c *Client) confirmClaudeWorkspaceTrust(ctx context.Context, paneID string) error {
|
|
deadline := time.Now().Add(claudeTrustObserveWindow)
|
|
accepted := false
|
|
for {
|
|
text, err := c.paneText(ctx, paneID)
|
|
if err == nil && claudeWorkspaceTrustPrompt(text) {
|
|
if !accepted {
|
|
if err := c.Call(ctx, "pane.send_text", map[string]any{"pane_id": paneID, "text": "1\n"}, nil); err != nil {
|
|
return fmt.Errorf("herdr: accept Claude workspace trust for pane %s: %w", paneID, err)
|
|
}
|
|
accepted = true
|
|
deadline = time.Now().Add(claudeTrustClearWindow)
|
|
}
|
|
} else if accepted {
|
|
return nil
|
|
}
|
|
if time.Now().After(deadline) {
|
|
if accepted {
|
|
return fmt.Errorf("herdr: Claude workspace trust prompt did not clear for pane %s", paneID)
|
|
}
|
|
return nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(claudeTrustPoll):
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *Client) Prompt(ctx context.Context, pane, text string, wait time.Duration) error {
|
|
var status paneGetResponse
|
|
if err := c.Call(ctx, "pane.get", map[string]any{"pane_id": pane}, &status); err != nil {
|
|
return fmt.Errorf("herdr: inspect pane before prompt: %w", err)
|
|
}
|
|
if strings.EqualFold(status.Pane.AgentStatus, "blocked") {
|
|
return fmt.Errorf("herdr: refusing prompt to blocked pane %s", pane)
|
|
}
|
|
transcript, err := c.paneText(ctx, pane)
|
|
if err != nil {
|
|
return fmt.Errorf("herdr: inspect pane text before prompt: %w", err)
|
|
}
|
|
if permissionPrompt(transcript) {
|
|
return fmt.Errorf("herdr: refusing prompt while pane %s shows a permission dialog", pane)
|
|
}
|
|
c.mu.Lock()
|
|
target := c.agents[pane]
|
|
c.mu.Unlock()
|
|
if target == "" {
|
|
// Session records created before unique agent names were introduced
|
|
// used the pane as target; retain that compatibility path.
|
|
target = pane
|
|
}
|
|
p := map[string]any{"target": target, "text": text}
|
|
if wait > 0 {
|
|
p["wait"] = map[string]any{"until": []string{"idle"}, "timeout_ms": wait.Milliseconds()}
|
|
}
|
|
callCtx := ctx
|
|
var cancel context.CancelFunc
|
|
if wait > 0 {
|
|
if d, ok := ctx.Deadline(); !ok || time.Until(d) < wait+5*time.Second {
|
|
callCtx, cancel = context.WithTimeout(ctx, wait+5*time.Second)
|
|
defer cancel()
|
|
}
|
|
}
|
|
// A transport failure after writing is ambiguous: herdr may already have
|
|
// delivered the UI-changing request, so never resend it. A JSON-RPC
|
|
// protocol error, however, is herdr's explicit rejection before it acted
|
|
// (notably its short post-start readiness window); that is safe to retry
|
|
// for the bounded boot window.
|
|
deadline := time.Now().Add(bootRetryWindow)
|
|
for {
|
|
err := c.Call(callCtx, "agent.prompt", p, nil)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if !errors.Is(err, ErrProtocol) || time.Now().After(deadline) {
|
|
return fmt.Errorf("herdr: prompt delivery is uncertain; not retrying: %w", err)
|
|
}
|
|
select {
|
|
case <-callCtx.Done():
|
|
return fmt.Errorf("herdr: prompt delivery is uncertain; not retrying: %w", callCtx.Err())
|
|
case <-time.After(bootRetryDelay):
|
|
}
|
|
}
|
|
}
|
|
|
|
// BindAgent restores the pane-to-agent routing from a persisted Session.
|
|
func (c *Client) BindAgent(pane, agent string) {
|
|
if pane == "" || agent == "" {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.agents == nil {
|
|
c.agents = map[string]string{}
|
|
}
|
|
c.agents[pane] = agent
|
|
}
|
|
|
|
func permissionPrompt(text string) bool {
|
|
text = strings.ToLower(text)
|
|
return strings.Contains(text, "permission required") || strings.Contains(text, "approval required") || strings.Contains(text, "waiting for approval")
|
|
}
|
|
func (c *Client) Worktree(ctx context.Context, cwd, path, branch string) (string, error) {
|
|
var r worktreeResponse
|
|
// Protocol 17 requires exactly one of path or branch. Use the explicit
|
|
// path so the worker owns the checkout location; herdr manages the branch
|
|
// associated with that worktree.
|
|
p := map[string]any{"cwd": cwd, "path": path}
|
|
e := c.Call(ctx, "worktree.create", p, &r)
|
|
if e != nil && strings.Contains(strings.ToLower(e.Error()), "already exists") {
|
|
e = c.Call(ctx, "worktree.open", map[string]any{"cwd": cwd, "path": path}, &r)
|
|
}
|
|
if e != nil {
|
|
return "", e
|
|
}
|
|
if r.RootPane.PaneID != "" {
|
|
c.mu.Lock()
|
|
c.panes[path] = r.RootPane.PaneID
|
|
c.mu.Unlock()
|
|
}
|
|
if r.Path != "" {
|
|
return r.Path, nil
|
|
}
|
|
return r.Worktree.Path, nil
|
|
}
|
|
|
|
func (c *Client) StartAgent(ctx context.Context, cwd, path, branch, harness, taskID string) (Session, error) {
|
|
c.mu.Lock()
|
|
paneID := c.panes[path]
|
|
c.mu.Unlock()
|
|
if paneID == "" {
|
|
return Session{}, fmt.Errorf("herdr: no pane recorded for worktree %s", path)
|
|
}
|
|
var s Session
|
|
deadline := time.Now().Add(bootRetryWindow)
|
|
var err error
|
|
for {
|
|
s = Session{}
|
|
err = c.Call(ctx, "agent.start", map[string]any{
|
|
"pane_id": paneID,
|
|
"kind": harness,
|
|
"name": agentName(harness, taskID),
|
|
"args": harnessStartArgs(harness),
|
|
}, &s)
|
|
if err == nil {
|
|
break
|
|
}
|
|
if strings.Contains(strings.ToLower(err.Error()), "already") {
|
|
err = nil
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return Session{}, err
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return Session{}, ctx.Err()
|
|
case <-time.After(bootRetryDelay):
|
|
}
|
|
}
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
// B13: agent.start's success does not mean an agent actually attached.
|
|
// Confirm via pane.get before declaring the lease started, so a silent
|
|
// no-op surfaces as an observable error (TaskBlocked) instead of leaving
|
|
// the task leased against a pane that will never produce a session.
|
|
attachDeadline := time.Now().Add(agentAttachWindow)
|
|
for {
|
|
ok, statusErr := c.paneAgentAttached(ctx, paneID)
|
|
if statusErr == nil && ok {
|
|
break
|
|
}
|
|
if time.Now().After(attachDeadline) {
|
|
if statusErr != nil {
|
|
return Session{}, fmt.Errorf("herdr: agent.start reported success for pane %s but confirming attach failed: %w", paneID, statusErr)
|
|
}
|
|
return Session{}, fmt.Errorf("herdr: agent.start reported success for pane %s but no agent attached within %s", paneID, agentAttachWindow)
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return Session{}, ctx.Err()
|
|
case <-time.After(agentAttachPoll):
|
|
}
|
|
}
|
|
if strings.EqualFold(harness, "claude") {
|
|
if err := c.confirmClaudeWorkspaceTrust(ctx, paneID); err != nil {
|
|
return Session{}, err
|
|
}
|
|
}
|
|
s.PaneID = paneID
|
|
s.Worktree = path
|
|
s.Harness = harness
|
|
s.AgentName = agentName(harness, taskID)
|
|
c.BindAgent(paneID, s.AgentName)
|
|
return s, nil
|
|
}
|
|
|
|
var invalidAgentName = regexp.MustCompile(`[^a-z0-9_-]+`)
|
|
|
|
func agentName(harness, taskID string) string {
|
|
prefix := map[string]string{"opencode": "oc", "claude": "cl", "codex": "cx"}[strings.ToLower(harness)]
|
|
if prefix == "" {
|
|
prefix = "agent"
|
|
}
|
|
id := strings.Trim(invalidAgentName.ReplaceAllString(strings.ToLower(taskID), "-"), "-_")
|
|
if id == "" {
|
|
id = "session"
|
|
}
|
|
name := prefix + "-" + id
|
|
if len(name) <= 32 {
|
|
return name
|
|
}
|
|
// Keeping only the leading task-id characters made distinct long task
|
|
// IDs collide in herdr's machine-global name namespace. Reserve a stable
|
|
// digest suffix so truncation remains bounded *and* task-specific.
|
|
sum := sha256.Sum256([]byte(taskID))
|
|
const suffixLen = 8
|
|
keep := 32 - len(prefix) - 1 - 1 - suffixLen // prefix + "-" + stem + "-" + digest
|
|
return prefix + "-" + strings.TrimRight(id[:keep], "-_") + "-" + fmt.Sprintf("%x", sum[:])[:suffixLen]
|
|
}
|
|
|
|
// harnessStartArgs stays empty for Claude: --dangerously-skip-permissions
|
|
// introduces a separate first-run disclaimer. StartAgent instead acknowledges
|
|
// only the registered-worktree trust prompt after Claude is running.
|
|
func harnessStartArgs(harness string) []string {
|
|
return []string{}
|
|
}
|
|
|
|
// HeadSHA returns the current commit of a worktree. The rotation path uses
|
|
// this to populate TaskReleased.anchor_sha without trusting the adapter's
|
|
// opaque handoff-ref return value.
|
|
func HeadSHA(root string) (string, error) {
|
|
out, err := exec.Command("git", "-C", root, "rev-parse", "HEAD").Output()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
sha := string(out)
|
|
if len(sha) > 0 && sha[len(sha)-1] == '\n' {
|
|
sha = sha[:len(sha)-1]
|
|
}
|
|
if len(sha) != 40 {
|
|
return "", fmt.Errorf("herdr: unexpected HEAD output %q", sha)
|
|
}
|
|
return sha, nil
|
|
}
|
|
|
|
// AnchorValid checks the split-then-close safety condition without trusting a
|
|
// predecessor's prose. dirty maps contain path -> expected SHA-256.
|
|
func AnchorValid(root, sha string, dirty map[string]string) error {
|
|
out, e := exec.Command("git", "-C", root, "rev-parse", "HEAD").Output()
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if string(out) != sha+"\n" {
|
|
return fmt.Errorf("anchor: HEAD mismatch")
|
|
}
|
|
for p, want := range dirty {
|
|
b, e := os.ReadFile(filepath.Join(root, p))
|
|
if e != nil {
|
|
return e
|
|
}
|
|
h := sha256.Sum256(b)
|
|
if fmt.Sprintf("%x", h) != want {
|
|
return fmt.Errorf("anchor: %s changed", p)
|
|
}
|
|
}
|
|
return nil
|
|
}
|