c587f2cc8d
F62. The rotation is agent-driven: the worker asks, and the agent must write its handoff. When the agent never does, renewals stopped on the ordinary progress gate, the lease expired, and the task lost an attempt with nothing on record saying a handoff had ever been requested. Run 16 showed only "agent status idle and pane unchanged", 34 times. The request is now stamped, and the wait around it is bounded. While Orchestra is explicitly waiting the lease renews, because a quiet pane is the answer the agent was told to give. The request is re-sent once after four minutes, with the reason it was first asked with. At ten minutes the worker nacks with failure class handoff_unanswered, and the coordinator releases the task naming that cause instead of letting the lease die as generic idleness. The class is known to DebtClassForFailureClass, so a harness that ignores handoff requests accumulates as its own debt item rather than hiding inside lease_expired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
554 lines
19 KiB
Go
554 lines
19 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"`
|
|
// HandoffRequestedAt stamps that prompt. A request nobody answers used to
|
|
// end as an ordinary idle expiry, indistinguishable from an agent that
|
|
// never started (F62); the stamp is what makes the wait bounded and the
|
|
// giving-up causal.
|
|
HandoffRequestedAt time.Time `json:"handoff_requested_at,omitempty"`
|
|
// HandoffRetried records that the request was re-sent once, so a session
|
|
// waiting on an answer is not re-prompted every tick.
|
|
HandoffRetried bool `json:"handoff_retried,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
|
|
}
|