Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,26 @@ Initial implementation.
model-floor pre-flight, budget warn/degrade/refuse bands, run journals,
artifact discovery + schema validation + rendering, and spend declaration.
- Read-only report browsing (`report`) and ledger views (`ledger`).

### Fixes from first field use

- Budget policy parsing accepts the shipped sequence-form `bands:`
(`- {at: 1.5, action: warn}`) in addition to the map form.
- Harness failures now surface the stderr tail (provider 401s are visible
without opening the journal) and are recorded in `result.json` as
`harness_error`.
- Pre-flight model availability check (crush: verified against
`crush models`): unrunnable models fail fast with actionable guidance
instead of an authentication error two seconds into the run.
- The run monitor no longer looks frozen on quiet harnesses: the status bar
shows how long since the last output, and ctrl+c requires a confirming
second press within six seconds — a single stray ctrl+c cannot kill a
long-running audit.
- Force-quitting the monitor (or any early exit) now tears the harness down
and joins the runner before returning: no more "run produced no result",
and no send-on-closed-channel races.
- Failed, refused and aborted runs still discover and validate their
artifacts — partial output and checkpoint state are visible exactly when a
run dies mid-flight.
- The run summary (artifacts, journal path, harness error, final message)
prints after the interactive monitor exits, not only in plain mode.
9 changes: 8 additions & 1 deletion cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,9 @@ func executeRun(state *bootstrap.State, skill *catalog.Skill, args []string) {
if runFlags.json {
out, _ := json.MarshalIndent(res, "", " ")
fmt.Println(string(out))
} else if !interactive {
} else {
// The summary prints after the monitor too: artifacts, journal
// location and the failure reason must not vanish with the TUI.
printRunSummary(res)
}
os.Exit(res.ExitCode)
Expand All @@ -192,6 +194,11 @@ func printRunSummary(res *runner.RunResult) {
if res.BudgetCap > 0 {
kv("budget", fmt.Sprintf("%s band · cap %d output tokens", res.BudgetBand, res.BudgetCap))
}
if res.HarnessError != "" {
fmt.Println()
fmt.Println(styles.Err.Render("harness error:"))
fmt.Println(indent(res.HarnessError, " "))
}
if len(res.Artifacts) > 0 {
fmt.Println()
for _, a := range res.Artifacts {
Expand Down
20 changes: 20 additions & 0 deletions internal/harness/adapters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,26 @@ func TestBinaryEnvOverride(t *testing.T) {
}
}

func TestMatchCrushModel(t *testing.T) {
listing := "PriceTag/rits/zai-org/glm-5-3\nPriceTag/Inferact/Qwen3.8-Flash-Next\nanthropic/claude-mythos-5\n"
if err := matchCrushModel(listing, "claude-mythos-5"); err != nil {
t.Errorf("exact provider-prefixed match should pass: %v", err)
}
if err := matchCrushModel(listing, "rits/zai-org/glm-5-3"); err != nil {
t.Errorf("exact match should pass: %v", err)
}
if err := matchCrushModel(listing, "glm-5-3"); err != nil {
t.Errorf("suffix match should pass: %v", err)
}
if err := matchCrushModel(listing, "claude-mythos-6"); err == nil {
t.Error("unknown model should fail")
}
// A suffix must not match mid-segment: glm-5-30 is a different model.
if err := matchCrushModel(listing, "glm-5-30"); err == nil {
t.Error("mid-segment suffix should not match")
}
}

func contains(list []string, want string) bool {
for _, v := range list {
if v == want {
Expand Down
36 changes: 36 additions & 0 deletions internal/harness/crush.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package harness

import (
"context"
"fmt"
"os/exec"
"strings"
"time"
)

// Crush is the Charm Crush adapter (`crush run`). Crush's run mode emits the
Expand Down Expand Up @@ -45,3 +47,37 @@ func (c *Crush) Notes() []string {

// ParseEvent always returns false: crush run output is unstructured.
func (c *Crush) ParseEvent(line []byte) (Event, bool) { return Event{}, false }

// CheckModel verifies the model appears in `crush models` before launch.
// crush accepts "model" or "provider/model", and silently routes unknown
// ids to its default provider — which turns an unrunnable registry floor
// model into an authentication failure two seconds into a run. Failing
// here, with the list in hand, is the cheap time to find out.
func (c *Crush) CheckModel(ctx context.Context, model string) error {
if model == "" {
return nil
}
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
out, err := exec.CommandContext(cctx, c.Binary(), "models").Output()
if err != nil {
// The listing is advisory: never block a run on it.
return nil
}
return matchCrushModel(string(out), model)
}

// matchCrushModel reports whether model is runnable per a `crush models`
// listing (exact or provider-prefixed match).
func matchCrushModel(listing, model string) error {
for _, line := range strings.Split(listing, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if line == model || strings.HasSuffix(line, "/"+model) {
return nil
}
}
return fmt.Errorf("model %q is not in `crush models`", model)
}
9 changes: 9 additions & 0 deletions internal/harness/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,15 @@ type Harness interface {
Notes() []string
}

// ModelChecker is implemented by harnesses that can verify a model id is
// runnable before the process launches. The runner uses it to fail fast
// with actionable guidance — a registry floor model the local harness
// cannot authenticate to otherwise dies two seconds later with an opaque
// provider 401.
type ModelChecker interface {
CheckModel(ctx context.Context, model string) error
}

// IDs returns the harness priority order.
func IDs() []string {
return []string{"claude", "codex", "pi", "opencode", "crush", "goose"}
Expand Down
61 changes: 49 additions & 12 deletions internal/runner/budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,10 @@ type rawPolicy struct {
Units string `yaml:"units"`
PerRunDefaults map[string]int64 `yaml:"per_run_defaults"`
PerRunDefault *int64 `yaml:"per_run_default"`
Bands *struct {
Warn *float64 `yaml:"warn"`
Degrade *float64 `yaml:"degrade"`
Refuse *float64 `yaml:"refuse"`
} `yaml:"bands"`
// Bands accepts both shipped shapes: the map form ({warn: 1.5, ...})
// and the sequence form the harness actually ships
// (- {at: 1.5, action: warn}).
Bands any `yaml:"bands"`
}

type policyFile struct {
Expand Down Expand Up @@ -116,17 +115,55 @@ func LoadPolicy(configHome string) (*Policy, error) {
delete(p.PerRun, "default")
}
if rp.Bands != nil {
if rp.Bands.Warn != nil {
p.Warn = *rp.Bands.Warn
applyBands(p, rp.Bands)
}
return p, nil
}

// applyBands folds the two shipped band shapes into the policy.
func applyBands(p *Policy, bands any) {
set := func(action string, at float64) {
switch action {
case "warn":
p.Warn = at
case "degrade":
p.Degrade = at
case "refuse":
p.Refuse = at
}
if rp.Bands.Degrade != nil {
p.Degrade = *rp.Bands.Degrade
}
switch b := bands.(type) {
case []any: // sequence form: - {at: 1.5, action: warn}
for _, entry := range b {
m, ok := entry.(map[string]any)
if !ok {
continue
}
set(toString(m["action"]), toFloat(m["at"]))
}
if rp.Bands.Refuse != nil {
p.Refuse = *rp.Bands.Refuse
case map[string]any: // map form: {warn: 1.5, degrade: 2.0, refuse: 3.0}
for action, v := range b {
set(action, toFloat(v))
}
}
return p, nil
}

func toFloat(v any) float64 {
switch n := v.(type) {
case float64:
return n
case int:
return float64(n)
case int64:
return float64(n)
default:
return 0
}
}

func toString(v any) string {
s, _ := v.(string)
return s
}

// CapFor returns the per-run output-token cap for a skill.
Expand Down
30 changes: 30 additions & 0 deletions internal/runner/budget_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,36 @@ per_run_defaults:
}
}

// TestLoadPolicySequenceBands covers the shape the harness actually ships:
// bands as a list of {at, action} entries.
func TestLoadPolicySequenceBands(t *testing.T) {
dir := writePolicy(t, `
budget_policy:
enforcement: enforced
units: output_tokens
per_run_defaults:
secure-code-audit: 2_500_000
default: 1_000_000
bands:
- at: 1.5
action: warn
- at: 2.0
action: degrade
- at: 3.0
action: refuse
`)
p, err := LoadPolicy(dir)
if err != nil {
t.Fatal(err)
}
if p.Warn != 1.5 || p.Degrade != 2.0 || p.Refuse != 3.0 {
t.Errorf("sequence bands = %v/%v/%v", p.Warn, p.Degrade, p.Refuse)
}
if got := p.CapFor("secure-code-audit"); got != 2_500_000 {
t.Errorf("cap = %d", got)
}
}

func TestBandFor(t *testing.T) {
p := &Policy{DefaultCap: 1000, Warn: 1.5, Degrade: 2.0, Refuse: 3.0}
cases := []struct {
Expand Down
60 changes: 54 additions & 6 deletions internal/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,12 @@ type RunResult struct {
BudgetRefused bool `json:"budget_refused"`
Aborted bool `json:"aborted"`
HarnessExit int `json:"harness_exit"`
ResultText string `json:"result_text,omitempty"`
ExitCode int `json:"exit_code"`
// HarnessError carries the process failure plus the tail of the
// harness stderr log, so the actual provider error (e.g. a 401) is
// visible without opening the journal by hand.
HarnessError string `json:"harness_error,omitempty"`
ResultText string `json:"result_text,omitempty"`
ExitCode int `json:"exit_code"`
}

// Run executes the skill through the harness.
Expand Down Expand Up @@ -150,6 +154,31 @@ func Run(ctx context.Context, opts Options, sink Sink) (*RunResult, error) {
return nil, err
}
res.Dir = journal.Dir

// Pre-flight model availability: when the harness can verify model ids,
// fail fast with guidance instead of an opaque provider 401 two
// seconds into the run.
if pf.Model != "" {
if mc, ok := h.(harness.ModelChecker); ok {
if err := mc.CheckModel(ctx, pf.Model); err != nil {
msg := fmt.Sprintf("model %q is not runnable in %s (%v)", pf.Model, h.DisplayName(), err)
if pf.Role != "" {
msg += fmt.Sprintf(" — the registry floor for role %q resolved to it; map the role to a model your harness can run in %s (traust registry models resolve %s), or pass --model", pf.Role, filepath.Join(state.ConfigHome, "model-registry.yaml"), pf.Role)
} else {
msg += " — pass a --model your harness can run"
}
res.ExitCode = ExitError
res.HarnessError = msg
sink.Progress(msg)
if jerr := journal.WriteResult(res); jerr != nil {
sink.Progress("warning: could not write run journal: " + jerr.Error())
}
sink.Done()
return res, nil
}
}
}

before := snapshotArtifacts(root)

// Capability caveats, up front.
Expand Down Expand Up @@ -238,7 +267,11 @@ func Run(ctx context.Context, opts Options, sink Sink) (*RunResult, error) {
sink.Progress("run aborted")
case waitErr != nil:
res.ExitCode = ExitError
sink.Progress(fmt.Sprintf("harness exited with error: %v (stderr: %s)", waitErr, stderrPath))
res.HarnessError = waitErr.Error()
if tail := fileTail(stderrPath, 12); tail != "" {
res.HarnessError += "\n" + tail
}
sink.Progress("harness exited with error: " + res.HarnessError)
case scanErr != nil:
res.ExitCode = ExitError
sink.Progress(fmt.Sprintf("stream read error: %v", scanErr))
Expand All @@ -248,9 +281,11 @@ func Run(ctx context.Context, opts Options, sink Sink) (*RunResult, error) {
res.ResultText = strings.TrimRight(plainOut.String(), "\n")
}

// Post-run: artifacts, validation, rendering, spend — unless the run was
// aborted or refused before producing trustworthy output.
if res.ExitCode == ExitOK || (res.ExitCode == ExitError && res.HarnessExit == 0) {
// Post-run: artifacts, validation, rendering, spend. Discovery runs even
// for failed, refused or aborted runs — partial outputs are exactly what
// the user needs to see when a run dies mid-flight, and skills checkpoint
// their state for resume.
{
after := snapshotArtifacts(root)
res.Artifacts = discoverArtifacts(root, before, after)
validateArtifacts(ctx, opts.API, res.Artifacts, opts.StrictValidation)
Expand Down Expand Up @@ -290,6 +325,19 @@ func Run(ctx context.Context, opts Options, sink Sink) (*RunResult, error) {
return res, nil
}

// fileTail returns the last n lines of a file, or "" when unreadable.
func fileTail(path string, n int) string {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return strings.Join(lines, "\n")
}

// applyEvent folds a stream event into the run result. Usage is cumulative in
// every reporting harness, so tokens are maxed, not summed.
func applyEvent(res *RunResult, ev harness.Event) {
Expand Down
Loading
Loading