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: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,27 @@ Rules worth knowing before you write one:
whatever its head is by then. Neutral and skipped checks count as green.
The wait holds a worker slot, and a cancel during it ends the job the same
way. Omit `ci` and publish does not wait, exactly as before.
- **Red CI can be repaired inside the attempt.** `publish: {ci: {wait: true,
on_fail: {run: build, budget: 2}}}` gives red CI back to the `run` agent
phase instead of ending the job. A round hands it the failing checks and
the last 16 KiB of each failed GitHub Actions job's log (at most four logs),
framed as data, never instructions; then **every phase after `run`** runs
again, gates and repair edges included, and acceptance and `hold_when` are
judged anew. Only a fix that passes all of that, is not held, and actually
changed something is pushed, non-force, to the same pull request, and CI is
awaited again. There is no way to skip phases: `run` must be an agent phase
and not the last one. Rounds share the attempt's send budget and wall-clock
ceiling, run in a freshly wiped ephemeral HOME, and are fenced by the lease
again right before each push. jig never builds on someone else's commit: if
CI is red on a head it did not push, no round runs. When the budget runs
out, or a round fails, is held, is cancelled, or changes nothing, the job
ends `accepted_unpublished` exactly as red CI did before, with every round's
heads, changed paths, and outcome in the result. The publish-only retry
judges CI but never repairs — a continuation lives only in the process that
ran the chain. Budget is 1–3. A fix that weakens a check instead of the
code is the risk to design for: `factory.yaml` scores edits to CI or lint
configuration, and test files that lose more lines than they gain, as not
low, so such a "fix" is held for a person.
- **Validation happens before anything runs.** `jig def validate <file>`
is the same check the store applies at save time, offline.

Expand Down Expand Up @@ -318,7 +339,7 @@ seeds each one's auth material into that HOME:

| Runtime | Seeded from | Into the ephemeral HOME |
|---|---|---|
| Claude Code | `~/.claude/.credentials.json`, or the macOS keychain item `Claude Code-credentials`; plus `~/.claude.json` onboarding state | `~/.claude/.credentials.json`, `~/.claude.json` |
| Claude Code | on macOS the keychain item `Claude Code-credentials` (where the CLI keeps its live login), falling back to `~/.claude/.credentials.json`; elsewhere that file; plus `~/.claude.json` onboarding state | `~/.claude/.credentials.json`, `~/.claude.json` |
| Codex | `$CODEX_HOME/auth.json` (default `~/.codex/auth.json`), plus `config.toml` when present | `$HOME/.codex/auth.json`, `$HOME/.codex/sessions/` |

Two consequences:
Expand Down
191 changes: 191 additions & 0 deletions cmd/jig/ci_repair_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// ci_repair_test.go — one CI repair round through the real `jig worker`
// wiring (plan U5's required end-to-end test). Everything is production
// except the runtime (scripted) and the GitHub gateway (a fake): a live
// `jig serve`, workerAttemptRunner with its per-attempt trace stream and
// scratch, the publishing runner, and a real git remote. It proves what the
// U3 review found broken: the continuation's scratch (handoff notes
// included) and trace stream must outlive Execute until the round is done.
package main

import (
"context"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"sync"
"testing"
"time"

"github.com/StructuPath/jig/internal/controlplane"
"github.com/StructuPath/jig/internal/protocol"
"github.com/StructuPath/jig/internal/worker"
)

const ciRepairDefinition = `name: ci-repair-e2e
roster:
builder:
model: test-model
system_prompt: Build.
user_prompt: "Build."
env: [PATH]
writes: ["jig-note.txt", "jig-fix.txt"]
phases:
- {name: build, kind: agent, owner: builder}
- name: verify
kind: code
command: "test -s jig-note.txt"
acceptance: [all_phases_passed]
publish:
ci:
wait: true
on_fail: {run: build, budget: 1}
`

// e2eGateway is the fake GitHub: CI is red on the first head it judges and
// green on the next, and every poll runs the test's probe first.
type e2eGateway struct {
mutex sync.Mutex
first string
probe func(sha string, red bool)
polled []string
}

func (g *e2eGateway) FindOrCreatePullRequest(context.Context, worker.PullRequestRequest) (worker.PullRequest, error) {
return worker.PullRequest{Number: 1, URL: "https://github.com/example/repo/pull/1", State: "open"}, nil
}

func (g *e2eGateway) CommitChecks(_ context.Context, _ string, sha string) ([]worker.CICheck, error) {
g.mutex.Lock()
defer g.mutex.Unlock()
g.polled = append(g.polled, sha)
if g.first == "" {
g.first = sha
}
red := sha == g.first
g.probe(sha, red)
if red {
return []worker.CICheck{{Name: "lint", Verdict: worker.CIFail, Conclusion: "failure"}}, nil
}
return []worker.CICheck{{Name: "lint", Verdict: worker.CIPass, Conclusion: "success"}}, nil
}

func (g *e2eGateway) FailedCheckLogs(_ context.Context, _ string, checks []worker.CICheck) []worker.CICheck {
return checks
}

func TestACIRepairRoundRunsThroughTheRealWorkerWiring(t *testing.T) {
repo := initRepo(t)
serverData, workerData := t.TempDir(), t.TempDir()
scriptRuntime(t,
map[string]any{"files": map[string]any{"jig-note.txt": "note\n"},
"text": envelopeJSON(t, map[string]any{"status": "success", "summary": "wrote the note"})},
map[string]any{"files": map[string]any{"jig-fix.txt": "fixed\n"},
"text": envelopeJSON(t, map[string]any{"status": "success", "summary": "fixed lint"})},
)
base, stopServe := startServe(t, serverData, "--no-github-poll")
defer stopServe()

var definition protocol.Definition
if err := apiCall(context.Background(), base, "POST", "/api/definitions",
controlplane.DefinitionInput{Source: ciRepairDefinition}, &definition); err != nil {
t.Fatal(err)
}
var view controlplane.RunView
if err := apiCall(context.Background(), base, "POST", "/api/runs", controlplane.RunInvocation{
DefinitionID: definition.ID, Instructions: "write the note",
Targets: []controlplane.InvocationTarget{{Repository: repo}},
}, &view); err != nil {
t.Fatal(err)
}

// The handoff note is planted while CI is red — after Execute returned,
// before the round — and must still be there when CI judges the fix.
var probeErrors []string
note := ""
gateway := &e2eGateway{probe: func(sha string, red bool) {
handoffs, _ := filepath.Glob(filepath.Join(workerData, "scratch", "*", "handoff"))
if len(handoffs) != 1 {
probeErrors = append(probeErrors, fmt.Sprintf(
"CI poll on %.8s (red=%v) found %d scratch handoff dirs; the continuation's scratch is gone",
sha, red, len(handoffs)))
return
}
if red && note == "" {
note = filepath.Join(handoffs[0], "plan.md")
if err := os.WriteFile(note, []byte("the plan\n"), 0o600); err != nil {
probeErrors = append(probeErrors, err.Error())
}
return
}
if !red {
if _, err := os.Stat(note); err != nil {
probeErrors = append(probeErrors, "the handoff note did not survive the round: "+err.Error())
}
}
}}

ctx := context.Background()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
selected, err := selectRuntime(ctx, "claude-code", true)
if err != nil {
t.Fatal(err)
}
var host *worker.Worker
publisher := worker.NewPublishingRunner(
workerAttemptRunner(ctx, workerData, selected, logger, func() *worker.Worker { return host }),
gateway, worker.PublishOptions{
CommitAuthorName: "jig-test", CommitAuthorEmail: "jig@test",
CIPollInterval: 5 * time.Millisecond, CIRegistrationGrace: 20 * time.Millisecond,
})
host, err = worker.New(worker.Config{
ServerURL: base, DataDir: workerData, Capacity: 1,
Probes: []worker.RuntimeProbe{selected.Runtime}, Runner: publisher, Logger: logger,
})
if err != nil {
t.Fatal(err)
}
publisher.Bind(host)
if _, err := host.RegisterOnce(ctx); err != nil {
t.Fatal(err)
}

attempt, err := host.ClaimOnce(ctx)
if err != nil || attempt == nil {
t.Fatalf("claim: attempt=%v err=%v", attempt, err)
}
for _, problem := range probeErrors {
t.Error(problem)
}
if attempt.State != protocol.AttemptAccepted {
t.Fatalf("attempt = %s (%s)\n%s, want accepted after one repair round", attempt.State, attempt.Error, attempt.Result)
}
if len(gateway.polled) < 2 || note == "" {
t.Fatalf("CI polls = %d, note planted = %v: the round never ran", len(gateway.polled), note != "")
}

// The round's trace reached the control plane: the stream was still open.
var page struct {
Events []protocol.Event `json:"events"`
}
if err := apiCall(ctx, base, "GET", "/api/attempts/"+attempt.ID+"/events?limit=500", nil, &page); err != nil {
t.Fatal(err)
}
repairStarted, acceptances := false, 0
for _, event := range page.Events {
repairStarted = repairStarted || event.Name == "ci_repair_start"
if event.Name == "acceptance" {
acceptances++
}
}
if !repairStarted || acceptances != 2 {
t.Fatalf("control-plane trace has ci_repair_start=%v and %d acceptance event(s), want the round's events too",
repairStarted, acceptances)
}

// And once the attempt is over, its scratch is gone (KTD11).
if entries, _ := os.ReadDir(filepath.Join(workerData, "scratch")); len(entries) != 0 {
t.Fatalf("attempt scratch survived the attempt: %v", entries)
}
}
157 changes: 157 additions & 0 deletions cmd/jig/factory_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// factory_test.go — the stock factory's risk classifier, run as written in
// examples/definitions/factory.yaml. It decides what ships without a person,
// and with CI repair it is what holds a "fix" that weakens a check instead
// of the code, so its script is tested rather than trusted.
package main

import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/StructuPath/jig/internal/protocol"
)

func classifyRiskCommand(t *testing.T) string {
t.Helper()
source, err := os.ReadFile(stockDefinition("factory.yaml"))
if err != nil {
t.Fatal(err)
}
spec, err := protocol.ParseDefinition(source)
if err != nil {
t.Fatal(err)
}
for _, phase := range spec.Phases {
if phase.Name == "classify-risk" {
return phase.Command
}
}
t.Fatal("factory.yaml has no classify-risk phase")
return ""
}

// classify commits base, applies change on top, and runs the classifier.
func classify(t *testing.T, base map[string]string, change func(dir string)) map[string]any {
t.Helper()
dir := t.TempDir()
write := func(files map[string]string) {
for name, body := range files {
full := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
}
gitIn(t, dir, "init", "-q")
write(map[string]string{"README.md": "hello\n"})
write(base)
gitIn(t, dir, "add", "-A")
gitIn(t, dir, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "base")
baseSHA := strings.TrimSpace(gitIn(t, dir, "rev-parse", "HEAD"))
change(dir)
gitIn(t, dir, "add", "-A")
gitIn(t, dir, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "change")

command := exec.Command("sh", "-c", classifyRiskCommand(t))
command.Dir = dir
command.Env = append(os.Environ(), "JIG_BASE_SHA="+baseSHA)
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("classify-risk failed: %v\n%s", err, output)
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
var report map[string]any
if err := json.Unmarshal([]byte(lines[len(lines)-1]), &report); err != nil {
t.Fatalf("classify-risk's last line is not a report: %v\n%s", err, output)
}
return report
}

func writeFile(t *testing.T, dir, name, body string) {
t.Helper()
full := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}

const threeTests = "package app\n\nfunc TestA(t *testing.T) {}\nfunc TestB(t *testing.T) {}\nfunc TestC(t *testing.T) {}\n"

func TestTheFactoryClassifierHoldsChangesThatWeakenChecks(t *testing.T) {
for _, tc := range []struct {
name, risk, reason string
base map[string]string
change func(t *testing.T, dir string)
}{
{"an ordinary change", "low", "no sensitive paths", nil, func(t *testing.T, dir string) {
writeFile(t, dir, "app.go", "package app\n")
}},
{"a change that adds tests", "low", "no sensitive paths", map[string]string{"app_test.go": "package app\n"},
func(t *testing.T, dir string) { writeFile(t, dir, "app_test.go", threeTests) }},
{"a change that deletes tests", "high", "removes more test lines", map[string]string{"app_test.go": threeTests},
func(t *testing.T, dir string) { writeFile(t, dir, "app_test.go", "package app\n") }},
{"a change that deletes a test file", "high", "removes more test lines", map[string]string{"tests/test_app.py": "def test_a():\n assert True\n"},
func(t *testing.T, dir string) { os.Remove(filepath.Join(dir, "tests/test_app.py")) }},
{"a change to lint configuration", "high", "sensitive paths", map[string]string{".golangci.yml": "linters:\n enable: [errcheck]\n"},
func(t *testing.T, dir string) { writeFile(t, dir, ".golangci.yml", "linters:\n disable-all: true\n") }},
{"a change anywhere under .github", "high", "sensitive paths", nil, func(t *testing.T, dir string) {
writeFile(t, dir, ".github/CODEOWNERS", "* @someone\n")
}},
{"a change to a test runner config", "high", "sensitive paths", nil, func(t *testing.T, dir string) {
writeFile(t, dir, "vitest.config.ts", "export default {}\n")
}},
{"a change to an auth path", "high", "sensitive paths", nil, func(t *testing.T, dir string) {
writeFile(t, dir, "internal/auth/token.go", "package auth\n")
}},
{"a large change", "high", "exceeds threshold", nil, func(t *testing.T, dir string) {
writeFile(t, dir, "big.txt", strings.Repeat("line\n", 450))
}},
} {
t.Run(tc.name, func(t *testing.T) {
report := classify(t, tc.base, func(dir string) { tc.change(t, dir) })
if report["risk"] != tc.risk || !strings.Contains(report["risk_reason"].(string), tc.reason) {
t.Fatalf("report = %v, want risk %s because %q", report, tc.risk, tc.reason)
}
})
}
}

// The stock factory opts into CI repair, and its repair phase is the builder
// with every reviewer and the classifier after it.
func TestTheFactoryRepairsRedCIThroughItsWholeReviewPanel(t *testing.T) {
source, err := os.ReadFile(stockDefinition("factory.yaml"))
if err != nil {
t.Fatal(err)
}
spec, err := protocol.ParseDefinition(source)
if err != nil {
t.Fatal(err)
}
if !spec.WaitsForCI() || spec.Publish.CI.OnFail == nil || spec.Publish.CI.OnFail.Run != "build" ||
spec.Publish.CI.OnFail.Budget < 1 {
t.Fatalf("publish.ci = %+v, want on_fail repairing through build", spec.Publish.CI)
}
after := map[string]bool{}
seen := false
for _, phase := range spec.Phases {
if seen {
after[phase.Name] = true
}
seen = seen || phase.Name == "build"
}
for _, judge := range []string{"test", "review-correctness", "review-security", "review-maintainability", "classify-risk"} {
if !after[judge] {
t.Fatalf("%s does not run after build, so it would not judge a CI repair", judge)
}
}
}
Loading
Loading