D2: the domain SDK names the state again, Ended, Passing, PassingCause, HolderSilent, AgainAfter and Drop (ADR 0664 §1, §2, issue 1748)

Shared line for the join with milestone G: sdk/buildstep/ask.go (AgainAfter beside AskRuns).

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
2026-11-09 22:55:34 +01:00
parent 440d02afb3
commit bfbdaf34ea
4 changed files with 120 additions and 2 deletions
+9
View File
@@ -77,6 +77,15 @@ const (
AskMaxDeliver = AskRuns + 2
)
// AgainAfter is how long the bus waits before it gives an ask again after its run-th run ended on a transport fault: 1
// minute after the first, 4 after the second (ADR 0664 §2). It outlasts a per-source penalty of a forge's sshd.
func AgainAfter(run int) time.Duration {
if run < 1 {
run = 1
}
return time.Minute << (2 * (run - 1))
}
// StepAskVersion is the layout version of an ask.
const StepAskVersion = 1
+33
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
@@ -313,6 +314,35 @@ func TestPauseCapacityAndComposerKey(t *testing.T) {
if ComposerKey("novox/r", testCommit, "d", "p", tc, nil) != ComposerKey("novox/r", testCommit, "d", "p", tc, PackageVersions{}) {
t.Error("an empty ask changed the key")
}
// Issue 1748 (ADR 0664): a transport fault is told from a wrong source by its words; a step given again is no end; the
// bus waits 1 minute after the first run and 4 after the second.
for words, want := range map[string]bool{
`Get "https://proxy": dial tcp: lookup proxy.golang.org: i/o timeout`: true,
`Head http://store.internal:5100/v2/x: dial tcp 10.10.0.1:5100: i/o timeout`: true,
"kex_exchange_identification: read: Connection reset by peer": true,
"Connection closed by 10.10.0.1 port 222": true,
"npm error code EAI_AGAIN": true,
"npm error code ETIMEDOUT": true,
"npm error code ECONNRESET": true,
(&HolderSilent{Seat: "go-package-registry", Holder: "gopkgs", Node: "ace"}).Error(): true,
"nats: no responders available for request": false,
"fatal: unknown revision or path not in the working tree": false,
"verifying module: checksum mismatch": false,
"./main.go:3:1: syntax error: unexpected }": false,
} {
if PassingCause(words) != want {
t.Errorf("PassingCause(%q) = %v, want %v", words, !want, want)
}
}
if !IsPassing(fmt.Errorf("fetch: %w", &HolderSilent{Seat: "s"})) || IsPassing(errors.New("i/o timeout")) {
t.Error("a HolderSilent is a Passing error and a bare error is none")
}
if Ended(Step{State: StepRunning}) || Ended(Step{State: StepAgain}) || !Ended(Step{State: StepPassed}) || !Ended(Step{State: StepFailed}) {
t.Error("Ended: running and again are no end")
}
if AgainAfter(1) != time.Minute || AgainAfter(2) != 4*time.Minute {
t.Errorf("AgainAfter = %v, %v", AgainAfter(1), AgainAfter(2))
}
}
// The part `build-step` is one layout for the three build seats: each seat's fixtures read strictly in the neutral
@@ -350,6 +380,9 @@ func TestTheThreeSeatsFixturesFitTheNeutralForms(t *testing.T) {
case strings.HasPrefix(f.Of, "state:pauses/paused."):
seen++
strict(t, f.Given, &Pause{})
case strings.HasPrefix(f.Of, "state:pauses/dropped."):
seen++
strict(t, f.Given, &Drop{})
case strings.HasPrefix(f.Of, "state:pauses/capacity."):
seen++
strict(t, f.Given, &Capacity{})
+63
View File
@@ -0,0 +1,63 @@
package buildstep
import (
"errors"
"fmt"
"strings"
"time"
)
// Passing is a fault of the network between a build node and what it reaches (a name lookup, a timeout, a reset
// connection, a forge's sshd refusing a burst, a store that does not answer), not of the source or the toolchain. A
// build module wraps it, and the shared runner then writes the step again and settles its ask with a delayed nak, up to
// AskRuns runs (ADR 0664 §1, §2). Any other error ends the step failed at once.
type Passing struct{ Err error }
func (p *Passing) Error() string { return p.Err.Error() }
func (p *Passing) Unwrap() error { return p.Err }
// IsPassing says err is, or wraps, a Passing.
func IsPassing(err error) bool {
var p *Passing
return errors.As(err, &p)
}
// HolderSilentWords opens the words of a HolderSilent, so PassingCause reads them from a step's reason too.
const HolderSilentWords = "the holder is silent"
// HolderSilent is a seat verb that answered "no responders" while the assigner names a holder of the seat: its tool
// runner is being replaced, a moment later it answers. It is a Passing fault (ADR 0664 §1); a seat nothing holds is not.
type HolderSilent struct{ Seat, Holder, Node string }
func (h *HolderSilent) Error() string {
return fmt.Sprintf("%s: seat %s, held by %s on %s, answered no responders", HolderSilentWords, h.Seat, h.Holder, h.Node)
}
// Unwrap makes a HolderSilent a Passing.
func (h *HolderSilent) Unwrap() error { return &Passing{Err: errors.New(h.Error())} }
// passingWords are the words of a network that did not answer, lower-cased: go's and git's (a lookup, a timeout, a refused or
// reset connection, ssh's key exchange cut by a forge's per-source penalty) and npm's error codes. "no responders" alone is not
// one: it is the same for a seat nothing holds (HolderSilent is the case a holder is named).
var passingWords = []string{"i/o timeout", "no such host", "temporary failure in name resolution", "connection refused",
"connection reset", "tls handshake timeout", "lookup ", "kex_exchange_identification", "connection closed by",
"eai_again", "etimedout", "econnreset", "econnrefused", "enotfound", strings.ToLower(HolderSilentWords)}
// PassingCause says words (a tool's output or a step's reason) are those of a transport fault.
func PassingCause(words string) bool {
words = strings.ToLower(words)
for _, w := range passingWords {
if strings.Contains(words, w) {
return true
}
}
return false
}
// Drop is the value of the key dropped.<ask id> of a build seat's pauses state: an ask its caller no longer waits for.
type Drop struct {
V int `json:"v,omitempty"`
By string `json:"by,omitempty"`
Why string `json:"why"`
At time.Time `json:"at,omitzero"`
}
+15 -2
View File
@@ -23,6 +23,9 @@ var ErrUnknownVersion = errors.New("buildstep: unknown value version")
// The states of a step.
const (
StepRunning = "running"
// StepAgain is a step that ended on a transport fault with runs left: the bus gives its ask again after AgainAt
// (ADR 0664 §1). It is not an end (Ended), and nothing acknowledges the ask.
StepAgain = "again"
StepPassed = "passed"
StepFailed = "failed"
StepRefused = "refused"
@@ -47,8 +50,18 @@ type Step struct {
Phase string `json:"phase,omitempty"`
PhaseAt time.Time `json:"phase-at,omitzero"`
Owner string `json:"owner,omitempty"`
// Run is which run of the ask this is, counted by the bus's delivery count (1 for the first); AgainAt when the bus
// gives it again (the state again); Passing says the step ended on a transport fault, so a failed end after the
// last run says so (ADR 0664 §1, §2).
Run int `json:"run,omitempty"`
AgainAt time.Time `json:"again-at,omitzero"`
Passing bool `json:"passing,omitempty"`
}
// Ended says a step's state is an end: passed, failed or refused. A step running, or ended on a transport fault with runs
// left (StepAgain), is not.
func Ended(s Step) bool { return s.State != StepRunning && s.State != StepAgain && s.State != "" }
var (
nameOK = regexp.MustCompile(`^[-/_=a-zA-Z0-9]+$`)
stepIDOK = regexp.MustCompile(`^[a-z0-9]+$`)
@@ -106,9 +119,9 @@ func (s Step) check() error {
return fmt.Errorf("buildstep: a step needs its tool")
}
switch s.State {
case StepRunning, StepPassed, StepFailed, StepRefused:
case StepRunning, StepAgain, StepPassed, StepFailed, StepRefused:
default:
return fmt.Errorf("buildstep: the step state %q is not running, passed, failed or refused", s.State)
return fmt.Errorf("buildstep: the step state %q is not running, again, passed, failed or refused", s.State)
}
if len(s.Result) > 0 && !json.Valid(s.Result) {
return fmt.Errorf("buildstep: the step's result is not JSON")