Skip to content
Closed
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
276 changes: 276 additions & 0 deletions bench/axis_terminal_burn.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
package main

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)

const terminalBurnAxis = "terminal-burn"
const terminalBurnLineage = "issue-3572-terminal-burn"
const terminalBurnContract = "gentle-ai.review-integration/v2"

// This axis is deliberately non-portable: bench_fixture exposes only the
// admission barrier. Both contenders still run the shipped FINALIZE command;
// the harness controls ordering and checks the post-burn filesystem invariant.
func init() {
RegisterAxis(Axis{
Name: terminalBurnAxis, Title: "Deterministic concurrent FINALIZE admission after terminal burn", BlackBox: false,
Review: reviewOptedIn, Properties: []string{
"Requires a product binary built with -tags bench_fixture for the real contender admission barrier.",
"Both contenders are real gentle-ai review finalize subprocesses; no authority bytes are authored by the harness.",
"Opt-in and non-portable by declaration; the ordinary black-box corpus remains unchanged.",
}, Journeys: terminalBurnJourneys,
})
}

func terminalBurnJourneys() []Journey {
return []Journey{{
ID: "tb01-concurrent-finalize-cannot-recreate-burned-lineage",
Title: "#3572: a late concurrent FINALIZE cannot recreate a burned lineage journal",
Source: "#3572: the winner burns authority while a real contender is held before journal admission; release proves no lineage or residue is recreated",
Steps: []Step{
{Name: "fixture: repository", Fixture: baseRepo},
{Name: "fixture: staged low-risk candidate", Fixture: stageDocs("issue-3572")},
{Name: "start the exact active lineage", Requires: startNamedCapability, Args: productArgs("review", "start", "--lineage", terminalBurnLineage), After: func(sandbox *Sandbox, observation Observation) error {
if observation.ExitCode != 0 {
return fmt.Errorf("review start exited %d: %s", observation.ExitCode, firstLine(observation.Stderr))
}
sandbox.Lineage = terminalBurnLineage
return nil
}},
{Name: "concurrent FINALIZE burns before the held contender is admitted", Requires: finalizeCapability, Composite: runTerminalBurnConcurrency},
},
}}
}

type terminalBurnProcess struct {
args []string
cmd *exec.Cmd
stdout, stderr *bytes.Buffer
done chan error
cancel context.CancelFunc
waitErr error
waited bool
}

func startTerminalBurnFinalize(sandbox *Sandbox, lineage, barrier string, negotiated bool) (*terminalBurnProcess, error) {
args := []string{"review", "finalize"}
if negotiated {
args = append(args, "--contract", terminalBurnContract)
}
args = append(args, "--cwd", sandbox.Repo, "--lineage", lineage)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
cmd := exec.CommandContext(ctx, sandbox.Binary, args...)
cmd.Dir, cmd.Env = sandbox.Repo, sandbox.env()
if barrier != "" {
cmd.Env = append(cmd.Env, "GENTLE_AI_BENCH_FINALIZE_ADMISSION_BARRIER="+barrier)
}
stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
cmd.Stdout, cmd.Stderr = stdout, stderr
process := &terminalBurnProcess{args: args, cmd: cmd, stdout: stdout, stderr: stderr, done: make(chan error, 1), cancel: cancel}
if err := cmd.Start(); err != nil {
cancel()
return nil, err
}
go func() { process.done <- cmd.Wait() }()
return process, nil
}

func (process *terminalBurnProcess) wait() Observation {
if !process.waited {
process.waitErr, process.waited = <-process.done, true
process.cancel()
}
exitCode := 0
var exitErr *exec.ExitError
if errors.As(process.waitErr, &exitErr) {
exitCode = exitErr.ExitCode()
} else if process.waitErr != nil {
exitCode = -1
}
return Observation{Args: process.args, ExitCode: exitCode, Stdout: process.stdout.String(), Stderr: process.stderr.String(), StdoutCaptured: true, StderrCaptured: true}
}

func waitTerminalBurnReady(process *terminalBurnProcess, path string) error {
ticker, timeout := time.NewTicker(10*time.Millisecond), time.NewTimer(10*time.Second)
defer ticker.Stop()
defer timeout.Stop()
for {
if _, err := os.Stat(path); err == nil {
return nil
} else if !os.IsNotExist(err) {
return err
}
select {
case err := <-process.done:
process.done <- err
if err != nil {
return fmt.Errorf("held FINALIZE contender exited before admission barrier: %w", err)
}
return errors.New("held FINALIZE contender exited before admission barrier")
case <-timeout.C:
return errors.New("timed out waiting for the FINALIZE contender admission barrier")
case <-ticker.C:
}
}
}

type terminalBurnFailure struct {
Schema string `json:"schema"`
Contract string `json:"contract"`
Operation string `json:"operation"`
Phase string `json:"phase"`
Code string `json:"code"`
Message string `json:"message"`
Cause string `json:"cause"`
MutationOutcome string `json:"mutation_outcome"`
AuthorityApplicability string `json:"authority_applicability"`
RetrySafe bool `json:"retry_safe"`
Replayability string `json:"replayability"`
LineageID string `json:"lineage_id"`
RequiredInputs []string `json:"required_inputs"`
NextAction string `json:"next_action"`
}

func requireTerminalBurnLoser(observation Observation) error {
if observation.ExitCode == 0 {
return errors.New("late concurrent FINALIZE contender unexpectedly succeeded")
}
payload := strings.TrimSpace(observation.Stdout)
var fields map[string]json.RawMessage
if err := json.Unmarshal([]byte(payload), &fields); err != nil {
return fmt.Errorf("parse negotiated FINALIZE loser: %w", err)
}
for _, name := range []string{"schema", "contract", "operation", "phase", "code", "message", "cause", "mutation_outcome", "authority_applicability", "retry_safe", "replayability", "lineage_id", "required_inputs", "next_action"} {
if _, ok := fields[name]; !ok {
return fmt.Errorf("negotiated FINALIZE loser omitted contract field %q", name)
}
}
var failure terminalBurnFailure
if err := json.Unmarshal([]byte(payload), &failure); err != nil {
return fmt.Errorf("decode negotiated FINALIZE loser: %w", err)
}
if failure.Schema != "gentle-ai.review-integration.failure/v2" || failure.Contract != terminalBurnContract || failure.Operation != "review.finalize" ||
failure.Phase == "" || failure.Code != "operation_outcome_unknown" || failure.Message == "" || failure.Cause == "" || failure.MutationOutcome != "unknown" ||
failure.AuthorityApplicability != "not_evaluated" || failure.RetrySafe || failure.Replayability != "status_required" ||
failure.LineageID != terminalBurnLineage || failure.RequiredInputs == nil || failure.NextAction != "review.status" {
return fmt.Errorf("negotiated FINALIZE loser classification = %+v", failure)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return nil
}

func recordTerminalBurnObservation(run *journeyRun, observation Observation) error {
gitCalls := run.sandbox.gitCallsSince()
if gitCalls == nil {
return errors.New("git subprocess count for direct FINALIZE observation was unavailable")
}
record := run.accumulator.observe(run.step, observation, gitCalls, false)
run.accumulator.records = append(run.accumulator.records, record)
return nil
}

func runTerminalBurnConcurrency(run *journeyRun) (err error) {
ready := filepath.Join(run.sandbox.Root, "terminal-burn-contender.ready")
release := filepath.Join(run.sandbox.Root, "terminal-burn-contender.release")
barrier := strings.Join([]string{terminalBurnLineage, ready, release}, "|")
var contender, winner *terminalBurnProcess
released := false
defer func() {
if !released {
_ = os.WriteFile(release, []byte("release\n"), 0o600)
}
if winner != nil {
winner.cancel()
_ = winner.wait()
}
if contender != nil {
contender.cancel()
_ = contender.wait()
}
}()
contender, err = startTerminalBurnFinalize(run.sandbox, terminalBurnLineage, barrier, true)
if err != nil {
return fmt.Errorf("start held FINALIZE contender: %w", err)
}
if err = waitTerminalBurnReady(contender, ready); err != nil {
return err
}
winner, err = startTerminalBurnFinalize(run.sandbox, terminalBurnLineage, "", false)
if err != nil {
return fmt.Errorf("start FINALIZE winner: %w", err)
}
winnerObservation := winner.wait()
if err = recordTerminalBurnObservation(run, winnerObservation); err != nil {
return err
}
if err = requireBurnedApproval(terminalBurnLineage)(run.sandbox, winnerObservation); err != nil {
return fmt.Errorf("winner FINALIZE: %w", err)
}
if err = os.WriteFile(release, []byte("release\n"), 0o600); err != nil {
return fmt.Errorf("release held FINALIZE contender: %w", err)
}
released = true
contenderObservation := contender.wait()
if err = recordTerminalBurnObservation(run, contenderObservation); err != nil {
return err
}
if err = requireTerminalBurnLoser(contenderObservation); err != nil {
return err
}
return assertTerminalBurnResiduesAbsent(run.sandbox, terminalBurnLineage)
}

func assertTerminalBurnResiduesAbsent(sandbox *Sandbox, lineage string) error {
common, err := terminalBurnGitCommonDir(sandbox)
if err != nil {
return err
}
base := filepath.Join(common, "gentle-ai", "review-transactions")
for _, path := range []string{filepath.Join(base, "v2", lineage), filepath.Join(base, "effect-markers", "v1", lineage), filepath.Join(base, "incidents", lineage)} {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("terminal burn residue at %s: %v", path, err)
}
}
entries, err := os.ReadDir(base)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
prefix := ".review-burn-v2-" + lineage + "-"
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), prefix) {
return fmt.Errorf("terminal burn staging residue at %s", entry.Name())
}
}
return nil
}

func terminalBurnGitCommonDir(sandbox *Sandbox) (string, error) {
cmd := exec.Command("git", "-C", sandbox.Repo, "rev-parse", "--git-common-dir")
env := sandbox.env()
for index := range env {
if strings.HasPrefix(env[index], "GIT_TRACE=") {
env[index] = "GIT_TRACE="
}
}
cmd.Env = env
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("resolve Git common directory: %w", err)
}
common := strings.TrimSpace(string(output))
if !filepath.IsAbs(common) {
common = filepath.Join(sandbox.Repo, common)
}
return filepath.Clean(common), nil
}
26 changes: 26 additions & 0 deletions bench/axis_terminal_burn_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package main

import (
"strings"
"testing"
)

func TestTerminalBurnAxisDeclaresItsFixtureBoundary(t *testing.T) {
for _, axis := range Axes() {
if axis.Name != terminalBurnAxis {
continue
}
if axis.BlackBox {
t.Fatal("terminal-burn uses the bench_fixture admission seam and is not black-box")
}
journeys := axis.Journeys()
if len(journeys) != 1 || !strings.HasPrefix(journeys[0].ID, "tb01-") {
t.Fatalf("terminal-burn journeys = %+v, want one tb01 journey", journeys)
}
if journeys[0].Review != reviewOptedIn {
t.Fatalf("terminal-burn review precondition = %q, want opted-in", journeys[0].Review)
}
return
}
t.Fatal("terminal-burn axis is not registered")
}
33 changes: 33 additions & 0 deletions internal/reviewtransaction/bench_fixture.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"os"
"strings"
"time"
)

// benchCrashAtPhaseFired makes the deterministic crash-position hook below
Expand All @@ -16,6 +17,7 @@ import (
// following resume run are always separate processes, so no explicit reset
// between them is needed or possible.
var benchCrashAtPhaseFired bool
var benchFinalizeAdmissionBarrierFired bool

// init overrides compactReclaimPhaseHook β€” the exact seam
// TestAuthorityDispositionResumeCrashPositionMatrix (authority_disposition_resume_test.go)
Expand Down Expand Up @@ -47,4 +49,35 @@ func init() {
}
return original(ctx, current, record)
}

// GENTLE_AI_BENCH_FINALIZE_ADMISSION_BARRIER is a bench-only process
// barrier. It publishes readiness, then lets the harness release this real
// FINALIZE after a competing process has completed the terminal burn.
originalFinalizeAdmissionHook := finalizeAttemptAdmissionHook
finalizeAttemptAdmissionHook = func(ctx context.Context, lineageID string) error {
if target := os.Getenv("GENTLE_AI_BENCH_FINALIZE_ADMISSION_BARRIER"); target != "" && !benchFinalizeAdmissionBarrierFired {
parts := strings.SplitN(target, "|", 3)
if len(parts) == 3 && parts[0] == lineageID {
benchFinalizeAdmissionBarrierFired = true
if err := os.WriteFile(parts[1], []byte("ready\n"), 0o600); err != nil {
return err
}
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
if _, err := os.Stat(parts[2]); err == nil {
return nil
} else if !os.IsNotExist(err) {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}
}
return originalFinalizeAdmissionHook(ctx, lineageID)
}
}
14 changes: 14 additions & 0 deletions internal/reviewtransaction/finalize_attempt_journal.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ const finalizeAttemptJournalSchema = "gentle-ai.review-finalize-attempt-journal/

var writeFinalizeAttemptAtomic = writeAtomic

// finalizeAttemptAdmissionHook is inert in product binaries. The bench_fixture
// build uses it only to hold a real concurrent FINALIZE before lock admission.
var finalizeAttemptAdmissionHook = func(context.Context, string) error { return nil }

// FinalizeAttemptRequest is a content-bound FINALIZE invocation. Input paths
// intentionally do not appear here: paths are transport, their payloads are
// the authority-relevant request.
Expand Down Expand Up @@ -157,11 +161,21 @@ func (store CompactStore) ReconcileFinalizeAttempt(ctx context.Context, request
if err := validateFinalizeAttemptRequest(store.lineageID, request); err != nil {
return FinalizeAttempt{}, false, err
}
if err := finalizeAttemptAdmissionHook(ctx, store.lineageID); err != nil {
return FinalizeAttempt{}, false, err
}
lock, err := acquireStoreLock(store.lockPath)
if err != nil {
return FinalizeAttempt{}, false, err
}
defer lock.release()
// The shared maintenance and compact version locks are held here, so this
// absence check cannot race a terminal burn. A late contender must not treat
// a burned lineage's missing journal as a fresh attempt: writing it would
// recreate authoritative lineage state below the already-burned path.
if _, err := os.Stat(store.StatePath()); err != nil {
return FinalizeAttempt{}, false, err
}
journal, err := store.loadFinalizeAttemptJournalLocked()
if err != nil {
return FinalizeAttempt{}, false, err
Expand Down
Loading
Loading