diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 52b616732..2b06438b7 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -109,15 +109,17 @@ no-mistakes axi run --intent "the user's goal" --base-branch epic/foo | `-y`, `--yes` | `bool` | `false` | Auto-resolve eligible gates until a decision point or outcome | | `--skip` | `string` | (none) | Comma-separated pipeline steps to skip | | `--base-branch` | `string` | (none) | Integration branch for this run only; overrides [`pr.base_branch`](/no-mistakes/reference/repo-config/#prbase_branch) | +| `--launch-nonce` | `string` | (none) | Non-secret correlation identifier for a durable pre-drive receipt; requires `--validation-generation` | +| `--validation-generation` | `string` | (none) | Caller-selected validation generation bound to `--launch-nonce`; requires that flag | `--intent` is not a description of the diff. It is the user's goal or request, and no-mistakes uses it verbatim instead of transcript inference. Err on the side of completeness: include the goal, important decisions and tradeoffs, constraints or approaches ruled in or out, and explicit requests that might otherwise look surprising in the diff. When starting a new run, `axi run` refuses the default branch and uncommitted working trees with actionable errors instead of auto-branching or auto-committing. -Reattaching to an in-flight run does not require `--intent`. +Ordinary reattachment to an in-flight run does not require `--intent`; [strict launch receipts](#strict-launch-receipts) require the original intent bytes on every retry. `--base-branch` is persisted on the run so rebase, PR, and CI honor it after resume. Reattaching with a `--base-branch` that differs from the active run's stored target is refused rather than silently discarded; omit the flag to reattach, or abort the active run first. -Reattachment accepts either the run's immutable submitted head or its current pipeline head, so pipeline-created fix commits do not detach an unchanged submitting worktree. +Ordinary reattachment accepts either the run's immutable submitted head or its current pipeline head, so pipeline-created fix commits do not detach an unchanged submitting worktree. When neither identity matches, `axi run` keeps the fresh-run path but refuses a gate push while `branch_sync` says the pipeline still owns the branch. That refusal returns the complete structured state and its `continue_active_run` or `recover_custody` next action instead of a raw Git non-fast-forward. Reattaching to an in-flight run can proceed while the daemon is already running even if the global config file has become invalid, but starting a fresh run still requires valid global config. @@ -140,6 +142,33 @@ Successful outcomes (`checks-passed`, `passed`, `passed-with-override`, and `pas `passed-with-skips` is a completed run where PR publication or CI verification automatically skipped because its provider was unavailable, or CI had no PR URL. It retains exit code 0: missing verification is not a failing code verdict. `run.automatic_skips` names each affected step and cause, and `run.head_sha` gives the full recorded head in both drive output and `axi status`. Report that missing evidence; this outcome does not establish CI readiness or a merge. Explicit per-run skips retain their existing behavior. If the run also has a CI approval override, `passed-with-override` takes precedence and the automatic skip causes remain visible. Legacy rows without a recorded skip cause keep their prior classification; their logs remain inspectable. When the pipeline applied fixes, they include a `fixes` table and a `help` instruction to acknowledge the misses and list those fixes for the user's review. +### Strict launch receipts + +Supply `--launch-nonce` and `--validation-generation` together to bind a launch to an exact request instead of reattaching by branch and head alone. +Both identifiers must be 1–128 ASCII characters from `A-Z`, `a-z`, `0-9`, `.`, `_`, `~`, and `-`; they are non-secret correlation values and must not contain credentials. +This mode requires the same exact `--intent` bytes on retries. + +```sh +no-mistakes axi run --intent "the user's goal" \ + --launch-nonce request-42 --validation-generation validation-3 \ + --base-branch epic/foo +``` + +After durable creation or claim, AXI writes a TOON `launch_receipt` object to stdout **before** driving the run. +The receipt remains available to a caller that captures stdout even if driving later stops at a gate or fails. +It contains `run_id`, `disposition` (`created` or `reused`), `launch_nonce`, `validation_generation`, `branch`, `head_sha`, `submitted_head_sha`, and `intent_digest`. +Both head fields contain the full, immutable submitted commit SHA; `intent_digest` is the lowercase SHA-256 digest of the exact persisted intent bytes, including whitespace. +Raw intent is not included in receipts, and generic run/status output does not expose the nonce binding or intent digest. + +The nonce is scoped to the repository and branch. +The first successful receipt claim returns `created`; subsequent matching claims return `reused` for that same run, including after the gate or pipeline head advances. +A conflicting submitted head, validation generation, or intent is refused. +A different nonce creates a distinct run rather than reattaching to a same-head run; both post-receive creation and the up-to-date-push fallback follow this contract. +The up-to-date-push fallback preserves the latest same-head run's PR URL unless its recorded PR state is closed or merged, including when an explicit `--base-branch` retargets that PR. +An explicit `--base-branch` is persisted on creation and must match the stored per-run base on replay; omitting it on replay preserves the stored base. +A conflicting claim does not consume the first `created` disposition. +Without the two proof flags, ordinary reattachment is unchanged, and historical runs without a nonce are not adopted into a proof binding. + ## no-mistakes axi respond Answer the current approval gate and continue until the next gate, CI-ready decision point, or final outcome. diff --git a/internal/cli/axi_drive.go b/internal/cli/axi_drive.go index f76d50362..088e16651 100644 --- a/internal/cli/axi_drive.go +++ b/internal/cli/axi_drive.go @@ -2,6 +2,7 @@ package cli import ( "context" + "crypto/sha256" "errors" "fmt" "io" @@ -76,6 +77,8 @@ func newAxiRunCmd() *cobra.Command { var autoYes bool var skipValue string var intent string + var launchNonce string + var validationGeneration string var baseBranch string cmd := &cobra.Command{ @@ -90,6 +93,10 @@ func newAxiRunCmd() *cobra.Command { "--intent is required when starting a new run: pass what the user set out\n" + "to accomplish (the goal behind the change, not a description of the diff)\n" + "so no-mistakes uses it directly instead of inferring it from transcripts.\n\n" + + "--launch-nonce with --validation-generation enables strict proof mode.\n" + + "Before driving, AXI emits a receipt with the durable run ID, created or\n" + + "reused disposition, full submitted head, and a digest of the exact\n" + + "persisted intent; raw intent is never included.\n\n" + "--base-branch targets an integration branch other than the repository default\n" + "for this run only (for example an epic branch). It overrides pr.base_branch\n" + "in repo config and is persisted on the run for rebase, PR, and CI steps.\n\n" + @@ -103,28 +110,35 @@ func newAxiRunCmd() *cobra.Command { SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { return trackAxiSurface("axi-run", "/axi/run", telemetry.Fields{ - "auto_yes": autoYes, - "has_intent": strings.TrimSpace(intent) != "", - "has_skip": strings.TrimSpace(skipValue) != "", - "has_base_branch": strings.TrimSpace(baseBranch) != "", + "auto_yes": autoYes, + "has_intent": strings.TrimSpace(intent) != "", + "has_skip": strings.TrimSpace(skipValue) != "", + "has_base_branch": strings.TrimSpace(baseBranch) != "", + "has_launch_nonce": launchNonce != "", }, func() error { skipSteps, err := parseSkipSteps(skipValue) if err != nil { return emitError(cmd, 2, err.Error(), "Valid steps: intent, rebase, review, test, document, lint, push, pr, ci") } - return runAxiRun(cmd, autoYes, skipSteps, intent, baseBranch) + return runAxiRunWithLaunchProof(cmd, autoYes, skipSteps, intent, baseBranch, launchNonce, validationGeneration) }) }, } cmd.Flags().BoolVarP(&autoYes, "yes", "y", false, "auto-resolve eligible gates (fix findings, then accept) until a decision point or outcome; protected-path refusals require an explicit response") cmd.Flags().StringVar(&skipValue, "skip", "", "comma-separated pipeline steps to skip") cmd.Flags().StringVar(&intent, "intent", "", "what the user set out to accomplish (not a description of the diff); used instead of inferring from transcripts (required to start a run)") + cmd.Flags().StringVar(&launchNonce, "launch-nonce", "", "opaque nonce for a daemon-bound pre-drive launch receipt") + cmd.Flags().StringVar(&validationGeneration, "validation-generation", "", "opaque generation bound to --launch-nonce proof mode") cmd.Flags().StringVar(&baseBranch, "base-branch", "", "integration branch to open the PR against for this run only (overrides pr.base_branch)") return cmd } func runAxiRun(cmd *cobra.Command, autoYes bool, skipSteps []types.StepName, intent, baseBranch string) error { + return runAxiRunWithLaunchProof(cmd, autoYes, skipSteps, intent, baseBranch, "", "") +} + +func runAxiRunWithLaunchProof(cmd *cobra.Command, autoYes bool, skipSteps []types.StepName, intent, baseBranch, launchNonce, validationGeneration string) error { ctx := cmd.Context() env, err := openAxiRunEnv() if err != nil { @@ -153,12 +167,30 @@ func runAxiRun(cmd *cobra.Command, autoYes bool, skipSteps []types.StepName, int } runID := "" - if active := activeRunInfo(env, branch, headSHA); active != nil { - if err := conflictingActiveRunPRBaseBranch(active, baseBranch); err != nil { - return emitError(cmd, 2, err.Error(), - "Omit --base-branch to reattach, or abort the active run before starting a new one") + var launchReceipt *ipc.LaunchReceipt + if launchNonce != "" { + if strings.TrimSpace(validationGeneration) == "" { + return emitError(cmd, 2, "--validation-generation is required with --launch-nonce") + } + receipt, err := claimLaunchReceipt(env.client, env.repo.ID, branch, launchNonce, headSHA, validationGeneration, digestLaunchIntent(intent), baseBranch) + if err != nil { + return emitError(cmd, 1, fmt.Sprintf("claim launch receipt: %v", err)) + } + if receipt != nil { + launchReceipt = receipt + runID = receipt.RunID + } + } else { + if validationGeneration != "" { + return emitError(cmd, 2, "--validation-generation requires --launch-nonce") + } + if active := activeRunInfo(env, branch, headSHA); active != nil { + if err := conflictingActiveRunPRBaseBranch(active, baseBranch); err != nil { + return emitError(cmd, 2, err.Error(), + "Omit --base-branch to reattach, or abort the active run before starting a new one") + } + runID = active.ID } - runID = active.ID } if runID == "" { if err := configErrorForFreshAxiRun(env, runID); err != nil { @@ -183,7 +215,14 @@ func runAxiRun(cmd *cobra.Command, autoYes bool, skipSteps []types.StepName, int return guard(cmd) } var err error - runID, err = triggerRun(ctx, env, branch, headSHA, skipSteps, intent, baseBranch) + if launchNonce != "" { + launchReceipt, err = triggerProofRun(ctx, env, branch, headSHA, skipSteps, intent, baseBranch, launchNonce, validationGeneration) + if err == nil { + runID = launchReceipt.RunID + } + } else { + runID, err = triggerRun(ctx, env, branch, headSHA, skipSteps, intent, baseBranch) + } if err != nil { if ownershipErr, ok := err.(*branchOwnershipError); ok { return emitBranchOwnershipError(cmd, ownershipErr) @@ -191,6 +230,9 @@ func runAxiRun(cmd *cobra.Command, autoYes bool, skipSteps []types.StepName, int return emitError(cmd, 1, err.Error()) } } + if launchReceipt != nil { + emitLaunchReceipt(cmd, *launchReceipt) + } run, ciReady, err := driveRun(ctx, cmd.ErrOrStderr(), env.client, env.p.Socket(), runID, autoYes) if err != nil { @@ -199,6 +241,11 @@ func runAxiRun(cmd *cobra.Command, autoYes bool, skipSteps []types.StepName, int return renderDriveResult(cmd, run, ciReady) } +func digestLaunchIntent(intent string) string { + sum := sha256.Sum256([]byte(intent)) + return fmt.Sprintf("%x", sum) +} + func configErrorForFreshAxiRun(env *axiEnv, runID string) error { if runID != "" { return nil @@ -451,6 +498,78 @@ func triggerRun(ctx context.Context, env *axiEnv, branch, headSHA string, skipSt return rr.RunID, nil } +func claimLaunchReceipt(client *ipc.Client, repoID, branch, launchNonce, submittedHeadSHA, validationGeneration, intentDigest, baseBranch string) (*ipc.LaunchReceipt, error) { + var result ipc.ClaimLaunchReceiptResult + if err := client.Call(ipc.MethodClaimLaunchReceipt, &ipc.ClaimLaunchReceiptParams{ + RepoID: repoID, Branch: branch, LaunchNonce: launchNonce, + SubmittedHeadSHA: submittedHeadSHA, ValidationGeneration: validationGeneration, IntentDigest: intentDigest, PRBaseBranch: baseBranch, + }, &result); err != nil { + return nil, err + } + return result.Receipt, nil +} + +// triggerProofRun captures the immutable submitted commit and waits only for +// the matching nonce-bound receipt. Ordinary active-run heuristics never prove +// strict launch identity. +func triggerProofRun(ctx context.Context, env *axiEnv, branch, headSHA string, skipSteps []types.StepName, intent, baseBranch, launchNonce, validationGeneration string) (*ipc.LaunchReceipt, error) { + pushOptions := formatSkipPushOptions(skipSteps) + pushOptions = append(pushOptions, + formatIntentPushOption(intent), + formatLaunchNoncePushOption(launchNonce), + formatValidationGenerationPushOption(validationGeneration), + ) + if opt := formatPRBaseBranchPushOption(baseBranch); opt != "" { + pushOptions = append(pushOptions, opt) + } + if state := freshRunBranchOwnershipState(ctx, env); state != nil { + return nil, &branchOwnershipError{state: *state} + } + pushErr := git.PushCommitWithOptions(ctx, ".", gate.RemoteName, headSHA, "refs/heads/"+branch, "", false, pushOptions) + if pushErr != nil { + if state := freshRunBranchOwnershipState(ctx, env); state != nil { + return nil, &branchOwnershipError{state: *state} + } + return nil, fmt.Errorf("push %q to gate: %w", branch, pushErr) + } + if receipt, err := waitForLaunchReceipt(ctx, env.client, env.repo.ID, branch, launchNonce, headSHA, validationGeneration, intent, baseBranch, triggerWaitTimeout); err != nil { + return nil, err + } else if receipt != nil { + return receipt, nil + } + var result ipc.StartFreshRunResult + if err := env.client.Call(ipc.MethodStartFreshRun, &ipc.StartFreshRunParams{ + RepoID: env.repo.ID, Branch: branch, HeadSHA: headSHA, SkipSteps: skipSteps, + Intent: intent, LaunchNonce: launchNonce, ValidationGeneration: validationGeneration, PRBaseBranch: baseBranch, + }, &result); err != nil { + return nil, fmt.Errorf("start fresh run: %w", err) + } + return &result.Receipt, nil +} + +func waitForLaunchReceipt(ctx context.Context, client *ipc.Client, repoID, branch, launchNonce, submittedHeadSHA, validationGeneration, intent, baseBranch string, timeout time.Duration) (*ipc.LaunchReceipt, error) { + deadline := time.NewTimer(timeout) + defer deadline.Stop() + poll := time.NewTicker(150 * time.Millisecond) + defer poll.Stop() + for { + receipt, err := claimLaunchReceipt(client, repoID, branch, launchNonce, submittedHeadSHA, validationGeneration, digestLaunchIntent(intent), baseBranch) + if err != nil { + return nil, err + } + if receipt != nil { + return receipt, nil + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-deadline.C: + return nil, nil + case <-poll.C: + } + } +} + // runIDsForHead snapshots the run IDs already present for a repo's exact branch // and head SHA before a push, so waitForTriggeredRunForHead can tell a run this // push created apart from a terminal run an earlier push left behind. Scoping to @@ -533,6 +652,21 @@ func rerunParams(repoID, branch string, skipSteps []types.StepName, intent, base return &ipc.RerunParams{RepoID: repoID, Branch: branch, SkipSteps: skipSteps, Intent: intent, PRBaseBranch: baseBranch} } +// emitLaunchReceipt writes the proof before driveRun subscribes, so callers +// retain the daemon-authored binding even if later driving blocks or fails. +func emitLaunchReceipt(cmd *cobra.Command, receipt ipc.LaunchReceipt) { + emitDoc(cmd, toon.Field{Key: "launch_receipt", Value: toon.NewObject( + toon.Field{Key: "run_id", Value: receipt.RunID}, + toon.Field{Key: "disposition", Value: receipt.Disposition}, + toon.Field{Key: "launch_nonce", Value: receipt.LaunchNonce}, + toon.Field{Key: "validation_generation", Value: receipt.ValidationGeneration}, + toon.Field{Key: "branch", Value: receipt.Branch}, + toon.Field{Key: "head_sha", Value: receipt.HeadSHA}, + toon.Field{Key: "submitted_head_sha", Value: receipt.SubmittedHeadSHA}, + toon.Field{Key: "intent_digest", Value: receipt.IntentDigest}, + )}) +} + // driveRun subscribes to a run and reconciles authoritative state on transition // events until it reaches an approval gate, a terminal state, or CI checks // pass, streaming step transitions to progress (stderr). When diff --git a/internal/cli/daemon_cmd.go b/internal/cli/daemon_cmd.go index 8a89666a0..01f8f174e 100644 --- a/internal/cli/daemon_cmd.go +++ b/internal/cli/daemon_cmd.go @@ -106,6 +106,17 @@ func newDaemonNotifyPushCmd() *cobra.Command { if err != nil { return err } + launchNonce, err := parseLaunchNoncePushOptions(pushOptions) + if err != nil { + return err + } + validationGeneration, err := parseValidationGenerationPushOptions(pushOptions) + if err != nil { + return err + } + if (launchNonce == "") != (validationGeneration == "") { + return fmt.Errorf("launch_nonce and validation_generation push options must be supplied together") + } prBaseBranch, err := parsePRBaseBranchPushOptions(pushOptions) if err != nil { return err @@ -128,13 +139,15 @@ func newDaemonNotifyPushCmd() *cobra.Command { var result ipc.PushReceivedResult return client.Call(ipc.MethodPushReceived, &ipc.PushReceivedParams{ - Gate: gatePath, - Ref: ref, - Old: oldSHA, - New: newSHA, - SkipSteps: skipSteps, - Intent: intent, - PRBaseBranch: prBaseBranch, + Gate: gatePath, + Ref: ref, + Old: oldSHA, + New: newSHA, + SkipSteps: skipSteps, + Intent: intent, + LaunchNonce: launchNonce, + ValidationGeneration: validationGeneration, + PRBaseBranch: prBaseBranch, }, &result) }, } @@ -199,6 +212,56 @@ func parseSkipSteps(value string) ([]types.StepName, error) { // survive the push-option transport (which is line-oriented). const intentPushOptionPrefix = "no-mistakes.intent=" +const ( + launchNoncePushOptionPrefix = "no-mistakes.launch-nonce=" + validationGenerationPushOptionPrefix = "no-mistakes.validation-generation=" +) + +func formatLaunchNoncePushOption(nonce string) string { + return formatOpaquePushOption(launchNoncePushOptionPrefix, nonce) +} + +func formatValidationGenerationPushOption(generation string) string { + return formatOpaquePushOption(validationGenerationPushOptionPrefix, generation) +} + +func formatOpaquePushOption(prefix, value string) string { + if value == "" { + return "" + } + return prefix + base64.StdEncoding.EncodeToString([]byte(value)) +} + +func parseLaunchNoncePushOptions(options []string) (string, error) { + return parseOpaquePushOptions(options, launchNoncePushOptionPrefix, "launch nonce") +} + +func parseValidationGenerationPushOptions(options []string) (string, error) { + return parseOpaquePushOptions(options, validationGenerationPushOptionPrefix, "validation generation") +} + +// parseOpaquePushOptions rejects conflicting duplicates rather than selecting +// one and manufacturing a receipt for a request no caller actually made. +func parseOpaquePushOptions(options []string, prefix, label string) (string, error) { + value := "" + for _, option := range options { + encoded, ok := strings.CutPrefix(option, prefix) + if !ok { + continue + } + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", fmt.Errorf("decode %s push option: %w", label, err) + } + parsed := string(decoded) + if value != "" && value != parsed { + return "", fmt.Errorf("conflicting %s push options", label) + } + value = parsed + } + return value, nil +} + // prBaseBranchPushOptionPrefix carries a per-run PR base branch through a git push. const prBaseBranchPushOptionPrefix = "no-mistakes.pr-base-branch=" diff --git a/internal/cli/daemon_cmd_test.go b/internal/cli/daemon_cmd_test.go index 3877120b4..847bfc7e1 100644 --- a/internal/cli/daemon_cmd_test.go +++ b/internal/cli/daemon_cmd_test.go @@ -110,13 +110,28 @@ func TestParseIntentPushOptionsNone(t *testing.T) { } } -func TestPRBaseBranchPushOptionRoundTrip(t *testing.T) { - opt := formatPRBaseBranchPushOption("epic/feature") - got, err := parsePRBaseBranchPushOptions([]string{opt}) +func TestProofAndPRBaseBranchPushOptionsRoundTrip(t *testing.T) { + nonce := "request-7f3" + generation := "generation-7" + nonceOpt := formatLaunchNoncePushOption(nonce) + generationOpt := formatValidationGenerationPushOption(generation) + baseBranchOpt := formatPRBaseBranchPushOption("epic/feature") + gotNonce, err := parseLaunchNoncePushOptions([]string{nonceOpt, baseBranchOpt}) if err != nil { t.Fatal(err) } - if got != "epic/feature" { - t.Fatalf("round-trip = %q, want epic/feature", got) + gotGeneration, err := parseValidationGenerationPushOptions([]string{generationOpt, baseBranchOpt}) + if err != nil { + t.Fatal(err) + } + gotBaseBranch, err := parsePRBaseBranchPushOptions([]string{nonceOpt, generationOpt, baseBranchOpt}) + if err != nil { + t.Fatal(err) + } + if gotNonce != nonce || gotGeneration != generation || gotBaseBranch != "epic/feature" { + t.Fatalf("push options = nonce %q generation %q base branch %q", gotNonce, gotGeneration, gotBaseBranch) + } + if _, err := parseValidationGenerationPushOptions([]string{generationOpt, formatValidationGenerationPushOption("generation-8")}); err == nil { + t.Fatal("conflicting validation generations were accepted") } } diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 80b98575f..cb6c2e914 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -1221,6 +1221,57 @@ func registerHandlers(srv *ipc.Server, mgr *RunManager, d *db.DB, shutdown func( return &ipc.AdmitPushResult{Context: gateContextResult(result)}, nil }) + srv.Handle(ipc.MethodClaimLaunchReceipt, func(_ context.Context, params json.RawMessage) (interface{}, error) { + var p ipc.ClaimLaunchReceiptParams + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + if err := validateLaunchNonce(p.LaunchNonce); err != nil { + return nil, err + } + if err := validateValidationGeneration(p.ValidationGeneration); err != nil { + return nil, err + } + prBaseBranch, err := normalizeRunPRBaseBranch(p.PRBaseBranch) + if err != nil { + return nil, err + } + run, claimed, err := d.ClaimLaunchReceipt(p.RepoID, p.Branch, p.LaunchNonce, p.SubmittedHeadSHA, p.ValidationGeneration, p.IntentDigest, prBaseBranch) + if err != nil { + return nil, fmt.Errorf("claim launch receipt: %w", err) + } + if run == nil { + return &ipc.ClaimLaunchReceiptResult{}, nil + } + if !launchPRBaseBranchMatches(run, prBaseBranch) { + return nil, conflictingLaunchPRBaseBranch(p.LaunchNonce) + } + + receipt, err := receiptForRun(run, claimed) + if err != nil { + return nil, err + } + if receipt.SubmittedHeadSHA != p.SubmittedHeadSHA || receipt.ValidationGeneration != p.ValidationGeneration || receipt.IntentDigest != p.IntentDigest { + return nil, fmt.Errorf("conflicting launch_nonce is already bound to a different validation generation, submitted head, or intent") + } + return &ipc.ClaimLaunchReceiptResult{Receipt: &receipt}, nil + }) + + srv.Handle(ipc.MethodStartFreshRun, func(ctx context.Context, params json.RawMessage) (interface{}, error) { + if err := refuseNested(ctx, false); err != nil { + return nil, err + } + var p ipc.StartFreshRunParams + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + receipt, err := mgr.HandleStartFreshRun(ctx, &p) + if err != nil { + return nil, err + } + return &ipc.StartFreshRunResult{Receipt: receipt}, nil + }) + srv.Handle(ipc.MethodRerun, func(ctx context.Context, params json.RawMessage) (interface{}, error) { if err := refuseNested(ctx, false); err != nil { return nil, err diff --git a/internal/daemon/manager.go b/internal/daemon/manager.go index 721c71df8..53492bb26 100644 --- a/internal/daemon/manager.go +++ b/internal/daemon/manager.go @@ -2,6 +2,7 @@ package daemon import ( "context" + "crypto/sha256" "fmt" "log/slog" "os" @@ -697,7 +698,8 @@ func assertGateTrustedConfigReadable(ctx context.Context, wtDir, defaultBranch, } // HandlePushReceived processes a push notification from the post-receive hook. -// It creates a run, sets up a worktree, and launches pipeline execution in the background. +// A proof-mode push creates an unclaimed row: the first matching observer +// receives the sole `created` disposition by atomically claiming it. func (m *RunManager) HandlePushReceived(ctx context.Context, params *ipc.PushReceivedParams) (string, error) { // Ref deletion (git push remote :branch) sends new SHA as all-zeros. // Nothing to validate - skip pipeline. @@ -709,7 +711,6 @@ func (m *RunManager) HandlePushReceived(ctx context.Context, params *ipc.PushRec if err != nil { return "", err } - repo, err := m.db.GetRepo(repoID) if err != nil { return "", fmt.Errorf("get repo: %w", err) @@ -719,9 +720,223 @@ func (m *RunManager) HandlePushReceived(ctx context.Context, params *ipc.PushRec } branch := branchFromRef(params.Ref) + if params.LaunchNonce != "" { + receipt, err := m.startFreshLaunch(ctx, repo, branch, params.New, params.Old, params.Gate, params.SkipSteps, params.Intent, params.LaunchNonce, params.ValidationGeneration, params.PRBaseBranch, "push") + if err != nil { + return "", err + } + return receipt.RunID, nil + } return m.startRun(ctx, repo, branch, params.New, params.Old, "push", params.SkipSteps, params.Intent, params.PRBaseBranch) } +// HandleStartFreshRun creates or replays a proof-mode launch only after +// checking the creation context under the repository/branch lock. +func (m *RunManager) HandleStartFreshRun(ctx context.Context, params *ipc.StartFreshRunParams) (ipc.LaunchReceipt, error) { + repo, err := m.db.GetRepo(params.RepoID) + if err != nil { + return ipc.LaunchReceipt{}, fmt.Errorf("get repo: %w", err) + } + if repo == nil { + return ipc.LaunchReceipt{}, fmt.Errorf("unknown repo %s", params.RepoID) + } + return m.startFreshLaunch(ctx, repo, params.Branch, params.HeadSHA, "", m.paths.RepoDir(repo.ID), params.SkipSteps, params.Intent, params.LaunchNonce, params.ValidationGeneration, params.PRBaseBranch, "fresh") +} + +// startFreshLaunch owns proof identity under the branch lock. A nonce may +// replay only its immutable submitted-head, generation, and persisted-intent +// digest. It must never fall back to ordinary same-head reattachment. +func (m *RunManager) startFreshLaunch(ctx context.Context, repo *db.Repo, branch, headSHA, baseSHA, gateDir string, skipSteps []types.StepName, intent, launchNonce, validationGeneration, prBaseBranch, trigger string) (ipc.LaunchReceipt, error) { + if err := validateLaunchNonce(launchNonce); err != nil { + return ipc.LaunchReceipt{}, err + } + if err := validateValidationGeneration(validationGeneration); err != nil { + return ipc.LaunchReceipt{}, err + } + storedPRBaseBranch, err := normalizeRunPRBaseBranch(prBaseBranch) + if err != nil { + return ipc.LaunchReceipt{}, err + } + + if strings.TrimSpace(intent) == "" { + return ipc.LaunchReceipt{}, fmt.Errorf("intent is required with launch_nonce") + } + // Proof launches bind exactly the bytes persisted. Ordinary AXI intent + // retains its historical normalization. + persistedIntent := intent + requestDigest := digestIntent(persistedIntent) + var receipt ipc.LaunchReceipt + _, err = m.withBranchLock(repo.ID, branch, func() (string, error) { + existing, err := m.db.GetRunByLaunchNonce(repo.ID, branch, launchNonce) + if err != nil { + return "", err + } + if existing != nil { + if !launchPRBaseBranchMatches(existing, storedPRBaseBranch) { + return "", conflictingLaunchPRBaseBranch(launchNonce) + } + + replayed, err := receiptForRun(existing, false) + if err != nil { + return "", err + } + // Submitted head is immutable proof provenance. Pipeline fixes and + // later gate pushes do not invalidate a same-nonce replay. + if replayed.ValidationGeneration != validationGeneration || replayed.SubmittedHeadSHA != headSHA || replayed.IntentDigest != requestDigest { + return "", fmt.Errorf("conflicting launch_nonce %q is already bound to a different validation generation, submitted head, or intent", launchNonce) + } + // Duplicate hook delivery is not observation: preserve an + // unclaimed push row for the first matching receipt observer. + if trigger == "push" { + receipt = replayed + return existing.ID, nil + } + claimedRun, claimed, err := m.db.ClaimLaunchReceipt(repo.ID, branch, launchNonce, headSHA, validationGeneration, requestDigest, storedPRBaseBranch) + if err != nil { + return "", err + } + if claimedRun == nil { + return "", fmt.Errorf("claimed launch receipt %q disappeared", launchNonce) + } + if !launchPRBaseBranchMatches(claimedRun, storedPRBaseBranch) { + return "", conflictingLaunchPRBaseBranch(launchNonce) + } + + receipt, err = receiptForRun(claimedRun, claimed) + if err != nil { + return "", err + } + return existing.ID, nil + } + + gateHead, err := git.Run(ctx, gateDir, "rev-parse", "refs/heads/"+branch+"^{commit}") + if err != nil { + return "", fmt.Errorf("resolve gate head: %w", err) + } + if gateHead != headSHA { + return "", fmt.Errorf("launch context drift: gate branch %q is at %s, not requested %s", branch, gateHead, headSHA) + } + inheritedPRURL := "" + if baseSHA == "" { + runs, err := m.db.GetRunsByRepoHead(repo.ID, branch, headSHA) + if err != nil { + return "", err + } + baseSHA = headSHA + if len(runs) > 0 { + baseSHA = runs[0].BaseSHA + inheritedPRURL = inheritablePRURL(runs[0]) + } + } + runID, err := m.startRunWithIntentSourceLocked(ctx, repo, branch, headSHA, baseSHA, trigger, skipSteps, persistedIntent, db.RunIntentSourceAgent, launchNonce, validationGeneration, requestDigest, storedPRBaseBranch, inheritedPRURL) + if err != nil { + return "", err + } + run, err := m.db.GetRun(runID) + if err != nil { + return "", fmt.Errorf("read created run: %w", err) + } + if trigger == "push" { + receipt, err = receiptForRun(run, true) + if err != nil { + return "", err + } + } else { + claimedRun, claimed, err := m.db.ClaimLaunchReceipt(repo.ID, branch, launchNonce, headSHA, validationGeneration, requestDigest, storedPRBaseBranch) + if err != nil { + return "", err + } + if claimedRun == nil { + return "", fmt.Errorf("claim newly created launch receipt") + } + if !launchPRBaseBranchMatches(claimedRun, storedPRBaseBranch) { + return "", conflictingLaunchPRBaseBranch(launchNonce) + } + + receipt, err = receiptForRun(claimedRun, claimed) + if err != nil { + return "", err + } + } + return runID, nil + }) + if err != nil { + return ipc.LaunchReceipt{}, err + } + return receipt, nil +} + +func normalizeRunPRBaseBranch(prBaseBranch string) (string, error) { + normalized, err := steps.ValidateRunPRBaseBranchName(prBaseBranch) + if err != nil { + return "", fmt.Errorf("pr base branch: %w", err) + } + return normalized, nil +} + +func launchPRBaseBranchMatches(run *db.Run, requested string) bool { + if requested == "" { + return true + } + return run != nil && run.PRBaseBranch != nil && strings.TrimSpace(*run.PRBaseBranch) == requested +} + +func conflictingLaunchPRBaseBranch(launchNonce string) error { + return fmt.Errorf("conflicting launch_nonce %q is already bound to a different pr base branch", launchNonce) +} + +func validateLaunchNonce(nonce string) error { + return validateLaunchValue("launch_nonce", nonce) +} + +func validateValidationGeneration(generation string) error { + return validateLaunchValue("validation_generation", generation) +} + +func validateLaunchValue(field, value string) error { + if len(value) == 0 || len(value) > 128 { + return fmt.Errorf("%s must be 1 to 128 ASCII URL-safe characters", field) + } + for _, char := range value { + if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || strings.ContainsRune("._~-", char) { + continue + } + return fmt.Errorf("%s contains unsupported character %q", field, char) + } + return nil +} + +func digestIntent(intent string) string { + sum := sha256.Sum256([]byte(intent)) + return fmt.Sprintf("%x", sum) +} + +func receiptForRun(run *db.Run, created bool) (ipc.LaunchReceipt, error) { + if run == nil { + return ipc.LaunchReceipt{}, fmt.Errorf("run is required") + } + if run.SubmittedHeadSHA == nil || *run.SubmittedHeadSHA == "" { + return ipc.LaunchReceipt{}, fmt.Errorf("run %s has no submitted head binding", run.ID) + } + if run.LaunchNonce == nil || run.LaunchValidationGeneration == nil || run.LaunchIntentDigest == nil { + return ipc.LaunchReceipt{}, fmt.Errorf("run %s has no launch binding", run.ID) + } + disposition := "reused" + if created { + disposition = "created" + } + return ipc.LaunchReceipt{ + RunID: run.ID, + Disposition: disposition, + LaunchNonce: *run.LaunchNonce, + ValidationGeneration: *run.LaunchValidationGeneration, + Branch: run.Branch, + HeadSHA: *run.SubmittedHeadSHA, + SubmittedHeadSHA: *run.SubmittedHeadSHA, + IntentDigest: *run.LaunchIntentDigest, + }, nil +} + // HandleRerun creates a new run for the latest recoverable head on a branch: // normally the gate branch, or the latest terminal run's verified unpublished // head while custody remains outstanding. An explicit intent overrides the @@ -808,18 +1023,21 @@ func (m *RunManager) HandleRerun(ctx context.Context, repoID, branch, previousRu if storedPRBaseBranch == "" && selectedRun.PRBaseBranch != nil { storedPRBaseBranch = strings.TrimSpace(*selectedRun.PRBaseBranch) } - inheritedPRURL := "" - if selectedRun.PRURL != nil { - state := "" - if selectedRun.PRState != nil { - state = strings.ToLower(strings.TrimSpace(*selectedRun.PRState)) - } - if state != "merged" && state != "closed" { - inheritedPRURL = strings.TrimSpace(*selectedRun.PRURL) - } - } + return m.startRunWithIntentSource(ctx, repo, branch, headSHA, baseSHA, "rerun", skipSteps, intent, intentSource, storedPRBaseBranch, inheritablePRURL(selectedRun)) +} - return m.startRunWithIntentSource(ctx, repo, branch, headSHA, baseSHA, "rerun", skipSteps, intent, intentSource, storedPRBaseBranch, inheritedPRURL) +func inheritablePRURL(run *db.Run) string { + if run.PRURL == nil { + return "" + } + state := "" + if run.PRState != nil { + state = strings.ToLower(strings.TrimSpace(*run.PRState)) + } + if state == "merged" || state == "closed" { + return "" + } + return strings.TrimSpace(*run.PRURL) } func resolveRerunHead(ctx context.Context, gateDir, branch string, latest *db.Run) (string, error) { @@ -888,6 +1106,23 @@ func (m *RunManager) startRun(ctx context.Context, repo *db.Repo, branch, headSH // when no intent is supplied, RunIntentSourceAgent for a new explicit // override, and RunIntentSourceRerun for inherited explicit intent. func (m *RunManager) startRunWithIntentSource(ctx context.Context, repo *db.Repo, branch, headSHA, baseSHA, trigger string, skipSteps []types.StepName, intent, source, prBaseBranch, inheritedPRURL string) (string, error) { + return m.withBranchLock(repo.ID, branch, func() (string, error) { + return m.startRunWithIntentSourceLocked(ctx, repo, branch, headSHA, baseSHA, trigger, skipSteps, intent, source, "", "", "", prBaseBranch, inheritedPRURL) + }) +} + +func (m *RunManager) withBranchLock(repoID, branch string, action func() (string, error)) (string, error) { + lockKey := repoID + "/" + branch + lockVal, _ := m.branchLocks.LoadOrStore(lockKey, &sync.Mutex{}) + branchMu := lockVal.(*sync.Mutex) + branchMu.Lock() + defer branchMu.Unlock() + return action() +} + +// startRunWithIntentSourceLocked performs run creation while the caller owns +// the repository/branch lock. Proof fields are empty for ordinary launches. +func (m *RunManager) startRunWithIntentSourceLocked(ctx context.Context, repo *db.Repo, branch, headSHA, baseSHA, trigger string, skipSteps []types.StepName, intent, source, launchNonce, validationGeneration, intentDigest, prBaseBranch, inheritedPRURL string) (string, error) { branchRole := telemetryBranchRole(branch, repo.DefaultBranch) trackStartFailure := func(stage string) { telemetry.Track("run", telemetry.Fields{ @@ -903,14 +1138,6 @@ func (m *RunManager) startRunWithIntentSource(ctx context.Context, repo *db.Repo return "", fmt.Errorf("daemon is shutting down") } - // Serialize per repo+branch to prevent two concurrent pushes from both - // passing cancelActiveRuns and creating duplicate pipelines. - lockKey := repo.ID + "/" + branch - lockVal, _ := m.branchLocks.LoadOrStore(lockKey, &sync.Mutex{}) - branchMu := lockVal.(*sync.Mutex) - branchMu.Lock() - defer branchMu.Unlock() - // Best-effort only: a clone's remotes may change after init. Refresh the // registered URLs before constructing any run-owned Git operation, but keep // the exact prior repo value and continue when discovery, validation, or the @@ -926,7 +1153,7 @@ func (m *RunManager) startRunWithIntentSource(ctx context.Context, repo *db.Repo m.cancelActiveRuns(repo.ID, branch) storedIntent := intent - if source != db.RunIntentSourceRerun { + if source != db.RunIntentSourceRerun && launchNonce == "" { storedIntent = strings.TrimSpace(storedIntent) } var runIntent *db.RunIntent @@ -937,13 +1164,13 @@ func (m *RunManager) startRunWithIntentSource(ctx context.Context, repo *db.Repo runIntent = &db.RunIntent{Summary: storedIntent, Source: source, Score: 1} } - storedPRBaseBranch, err := steps.ValidateRunPRBaseBranchName(prBaseBranch) + storedPRBaseBranch, err := normalizeRunPRBaseBranch(prBaseBranch) if err != nil { trackStartFailure("invalid_pr_base_branch") - return "", fmt.Errorf("pr base branch: %w", err) + return "", err } - run, err := m.db.InsertRunWithIntent(repo.ID, branch, headSHA, baseSHA, runIntent, storedPRBaseBranch) + run, err := m.db.InsertRunWithIntentAndLaunchNonce(repo.ID, branch, headSHA, baseSHA, runIntent, launchNonce, validationGeneration, intentDigest, storedPRBaseBranch) if err != nil { trackStartFailure("create_run") return "", fmt.Errorf("create run: %w", err) diff --git a/internal/daemon/manager_test.go b/internal/daemon/manager_test.go index ea4b44f59..baf158c45 100644 --- a/internal/daemon/manager_test.go +++ b/internal/daemon/manager_test.go @@ -2,6 +2,7 @@ package daemon import ( "context" + "encoding/json" "fmt" "os" "path/filepath" @@ -114,6 +115,180 @@ func TestPushReceivedTracksRunTelemetry(t *testing.T) { } } +func TestProofLaunchReceiptBindsIndependentGenerationAndFirstObserver(t *testing.T) { + step := &mockPassStep{name: types.StepReview} + p, d := startTestDaemonWithSteps(t, func() []pipeline.Step { return []pipeline.Step{step} }) + repo, headSHA := setupTestGitRepo(t, p, d, "proof-launch-repo") + + call := func(nonce, generation, intent string) (ipc.StartFreshRunResult, error) { + client, err := ipc.Dial(p.Socket()) + if err != nil { + t.Fatal(err) + } + defer client.Close() + var result ipc.StartFreshRunResult + err = client.Call(ipc.MethodStartFreshRun, &ipc.StartFreshRunParams{ + RepoID: repo.ID, Branch: "main", HeadSHA: headSHA, Intent: intent, + LaunchNonce: nonce, ValidationGeneration: generation, + }, &result) + return result, err + } + + const generation = "generation-001" + intent := "persist these exact bytes\nprivate validation intent" + first, err := call("nonce-1", generation, intent) + if err != nil { + t.Fatal(err) + } + if first.Receipt.Disposition != "created" || first.Receipt.RunID == "" || + first.Receipt.LaunchNonce != "nonce-1" || first.Receipt.ValidationGeneration != generation || + first.Receipt.Branch != "main" || first.Receipt.HeadSHA != headSHA || + first.Receipt.SubmittedHeadSHA != headSHA || first.Receipt.IntentDigest != digestIntent(intent) { + t.Fatalf("first receipt = %#v", first.Receipt) + } + encoded, err := json.Marshal(first.Receipt) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), "private validation intent") || strings.Contains(string(encoded), intent) { + t.Fatalf("launch receipt exposed raw intent: %s", encoded) + } + run, err := d.GetRun(first.Receipt.RunID) + if err != nil || run == nil || run.LaunchNonce == nil || *run.LaunchNonce != "nonce-1" || + run.LaunchValidationGeneration == nil || *run.LaunchValidationGeneration != generation || + run.LaunchIntentDigest == nil || *run.LaunchIntentDigest != digestIntent(intent) || + run.Intent == nil || *run.Intent != intent || run.LaunchReceiptClaimedAt == nil { + t.Fatalf("persisted proof run = %#v, err=%v", run, err) + } + + replay, err := call("nonce-1", generation, intent) + if err != nil { + t.Fatal(err) + } + if replay.Receipt.RunID != first.Receipt.RunID || replay.Receipt.Disposition != "reused" { + t.Fatalf("replay receipt = %#v, first = %#v", replay.Receipt, first.Receipt) + } + logLaunchEvidence(t, "first-and-replay", []ipc.LaunchReceipt{first.Receipt, replay.Receipt}) + if _, err := call("nonce-1", "generation-002", intent); err == nil { + t.Fatal("changed validation generation reused a nonce") + } + if _, err := call("nonce-1", generation, intent+" changed"); err == nil { + t.Fatal("changed intent reused a nonce") + } +} + +func TestProofLaunchReceiptPushCrashWindowConcurrentClaimsAndImmutableReplay(t *testing.T) { + step := &mockPassStep{name: types.StepReview} + p, d := startTestDaemonWithSteps(t, func() []pipeline.Step { return []pipeline.Step{step} }) + repo, headSHA := setupTestGitRepo(t, p, d, "proof-push-repo") + const generation = "generation-push-001" + const intent = "opaque push intent" + gitCmd(t, repo.WorkingPath, "branch", "review/base") + gitCmd(t, repo.WorkingPath, "push", "gate", "review/base:refs/heads/review/base") + + client, err := ipc.Dial(p.Socket()) + if err != nil { + t.Fatal(err) + } + defer client.Close() + var pushed ipc.PushReceivedResult + if err := client.Call(ipc.MethodPushReceived, &ipc.PushReceivedParams{ + Gate: p.RepoDir(repo.ID), Ref: "refs/heads/main", + Old: "0000000000000000000000000000000000000000", New: headSHA, + Intent: intent, LaunchNonce: "push-nonce", ValidationGeneration: generation, + PRBaseBranch: " review/base ", + }, &pushed); err != nil { + t.Fatal(err) + } + if pushed.RunID == "" { + t.Fatalf("push result = %#v", pushed) + } + if stored, err := d.GetRun(pushed.RunID); err != nil || stored == nil || stored.LaunchReceiptClaimedAt != nil { + t.Fatalf("push receipt claim state = %#v, err=%v", stored, err) + } + var freshMismatch ipc.StartFreshRunResult + err = client.Call(ipc.MethodStartFreshRun, &ipc.StartFreshRunParams{ + RepoID: repo.ID, Branch: "main", HeadSHA: headSHA, Intent: intent, + LaunchNonce: "push-nonce", ValidationGeneration: generation, PRBaseBranch: "other/base", + }, &freshMismatch) + if err == nil || !strings.Contains(err.Error(), "different pr base branch") { + t.Fatalf("mismatched fresh launch err = %v, want base mismatch", err) + } + + var mismatched ipc.ClaimLaunchReceiptResult + err = client.Call(ipc.MethodClaimLaunchReceipt, &ipc.ClaimLaunchReceiptParams{ + RepoID: repo.ID, Branch: "main", LaunchNonce: "push-nonce", + SubmittedHeadSHA: headSHA, ValidationGeneration: generation, IntentDigest: digestIntent(intent), + PRBaseBranch: "other/base", + }, &mismatched) + if err == nil || !strings.Contains(err.Error(), "different pr base branch") { + t.Fatalf("mismatched base claim err = %v, want base mismatch", err) + } + logLaunchEvidence(t, "base-conflict", err.Error()) + stored, err := d.GetRun(pushed.RunID) + if err != nil || stored == nil || stored.LaunchReceiptClaimedAt != nil { + t.Fatalf("mismatched base claim consumed first receipt: run=%#v err=%v", stored, err) + } + + const callers = 4 + results := make(chan ipc.StartFreshRunResult, callers) + errs := make(chan error, callers) + for range callers { + go func() { + c, err := ipc.Dial(p.Socket()) + if err == nil { + defer c.Close() + var result ipc.StartFreshRunResult + err = c.Call(ipc.MethodStartFreshRun, &ipc.StartFreshRunParams{ + RepoID: repo.ID, Branch: "main", HeadSHA: headSHA, Intent: intent, + LaunchNonce: "push-nonce", ValidationGeneration: generation, + }, &result) + results <- result + } + errs <- err + }() + } + created := 0 + for range callers { + if err := <-errs; err != nil { + t.Fatal(err) + } + result := <-results + if result.Receipt.RunID != pushed.RunID { + t.Fatalf("concurrent receipt run = %q, want %q", result.Receipt.RunID, pushed.RunID) + } + if result.Receipt.Disposition == "created" { + created++ + } + } + if created != 1 { + t.Fatalf("created receipts = %d, want 1", created) + } + + gitCmd(t, repo.WorkingPath, "commit", "--allow-empty", "-m", "advance gate") + gitCmd(t, repo.WorkingPath, "push", "gate", "HEAD:refs/heads/main") + if err := d.UpdateRunHeadSHA(pushed.RunID, "pipeline-fix-head"); err != nil { + t.Fatal(err) + } + var replay ipc.StartFreshRunResult + if err := client.Call(ipc.MethodStartFreshRun, &ipc.StartFreshRunParams{ + RepoID: repo.ID, Branch: "main", HeadSHA: headSHA, Intent: intent, + LaunchNonce: "push-nonce", ValidationGeneration: generation, + PRBaseBranch: " review/base ", + }, &replay); err != nil { + t.Fatal(err) + } + if replay.Receipt.HeadSHA != headSHA || replay.Receipt.SubmittedHeadSHA != headSHA || replay.Receipt.Disposition != "reused" { + t.Fatalf("immutable replay receipt = %#v, want submitted head %q", replay.Receipt, headSHA) + } + stored, err = d.GetRun(pushed.RunID) + if err != nil || stored == nil || stored.PRBaseBranch == nil || *stored.PRBaseBranch != "review/base" { + t.Fatalf("persisted proof base branch = %#v, err=%v", stored, err) + } + logLaunchEvidence(t, "advanced-head-replay", replay.Receipt) + logLaunchEvidence(t, "persisted-base", *stored.PRBaseBranch) +} + func TestPushReceivedSkipStepsConfiguresExecutor(t *testing.T) { review := &mockPassStep{name: types.StepReview} testStep := &mockPassStep{name: types.StepTest} @@ -1115,3 +1290,132 @@ func TestPushReceivedDemoModeBypassesAgentResolution(t *testing.T) { t.Error("mock step was never executed") } } + +func TestProofLaunchFallbackReturnsReusedWhenObserverClaimsDuringSetup(t *testing.T) { + entered := make(chan struct{}) + release := make(chan struct{}) + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + p, d := startTestDaemonWithSteps(t, func() []pipeline.Step { + close(entered) + <-release + return []pipeline.Step{&mockPassStep{name: types.StepReview}} + }) + defer unblock() + repo, head := setupTestGitRepo(t, p, d, "claim-during-setup") + client, err := ipc.Dial(p.Socket()) + if err != nil { + t.Fatal(err) + } + defer client.Close() + const intent = "claim while the fallback initializes" + var fresh ipc.StartFreshRunResult + done := make(chan error, 1) + go func() { + done <- client.Call(ipc.MethodStartFreshRun, &ipc.StartFreshRunParams{ + RepoID: repo.ID, Branch: "main", HeadSHA: head, Intent: intent, + LaunchNonce: "setup-nonce", ValidationGeneration: "generation", + }, &fresh) + }() + select { + case <-entered: + case err := <-done: + t.Fatalf("launch returned before setup: %v", err) + case <-time.After(10 * time.Second): + t.Fatal("launch did not reach setup") + } + observer, err := ipc.Dial(p.Socket()) + if err != nil { + t.Fatal(err) + } + defer observer.Close() + var first ipc.ClaimLaunchReceiptResult + if err := observer.Call(ipc.MethodClaimLaunchReceipt, &ipc.ClaimLaunchReceiptParams{ + RepoID: repo.ID, Branch: "main", SubmittedHeadSHA: head, + LaunchNonce: "setup-nonce", ValidationGeneration: "generation", IntentDigest: digestIntent(intent), + }, &first); err != nil { + t.Fatal(err) + } + if first.Receipt == nil || first.Receipt.Disposition != "created" { + t.Fatalf("first observer = %+v", first) + } + unblock() + if err := <-done; err != nil { + t.Fatal(err) + } + if fresh.Receipt.RunID != first.Receipt.RunID || fresh.Receipt.Disposition != "reused" { + t.Fatalf("fallback receipt = %+v, first = %+v", fresh.Receipt, first.Receipt) + } + run := waitForRunTerminalState(t, d, fresh.Receipt.RunID) + if run.Status != types.RunCompleted || run.LaunchReceiptClaimedAt == nil { + t.Fatalf("claimed run = %+v", run) + } + logLaunchEvidence(t, "observer-and-fallback", []ipc.LaunchReceipt{*first.Receipt, fresh.Receipt}) +} + +func TestProofLaunchFallbackInheritsOnlyLivePRIdentity(t *testing.T) { + for _, state := range []string{"", "open", "closed", "merged"} { + t.Run("state="+state, func(t *testing.T) { + p, d := startTestDaemonWithSteps(t, func() []pipeline.Step { + return []pipeline.Step{&mockPassStep{name: types.StepReview}} + }) + repo, head := setupTestGitRepo(t, p, d, "proof-pr-inheritance") + gitCmd(t, repo.WorkingPath, "branch", "review/base") + gitCmd(t, repo.WorkingPath, "push", "gate", "review/base:refs/heads/review/base") + client, err := ipc.Dial(p.Socket()) + if err != nil { + t.Fatal(err) + } + defer client.Close() + launch := func(nonce, base string) *db.Run { + t.Helper() + var result ipc.StartFreshRunResult + if err := client.Call(ipc.MethodStartFreshRun, &ipc.StartFreshRunParams{ + RepoID: repo.ID, Branch: "main", HeadSHA: head, Intent: "preserve existing PR", + LaunchNonce: nonce, ValidationGeneration: "generation", PRBaseBranch: base, + }, &result); err != nil { + t.Fatal(err) + } + if result.Receipt.Disposition != "created" { + t.Fatalf("new nonce receipt = %+v", result.Receipt) + } + return waitForRunTerminalState(t, d, result.Receipt.RunID) + } + prior := launch("prior-nonce", "") + const prURL = "https://github.com/test/repo/pull/42" + if err := d.UpdateRunPRURL(prior.ID, prURL); err != nil { + t.Fatal(err) + } + if state != "" { + if err := d.UpdateRunPRState(prior.ID, state); err != nil { + t.Fatal(err) + } + } + got := launch("new-nonce", " review/base ") + if got.ID == prior.ID || got.Status != types.RunCompleted || got.PRBaseBranch == nil || *got.PRBaseBranch != "review/base" { + t.Fatalf("fresh retargeted run = %+v", got) + } + if state == "closed" || state == "merged" { + if got.PRURL != nil && *got.PRURL != "" { + t.Fatalf("inherited retired PR: %s", *got.PRURL) + } + } else if got.PRURL == nil || *got.PRURL != prURL { + t.Fatalf("lost existing PR identity: %+v", got) + } + logLaunchEvidence(t, "persisted-pr-inheritance", map[string]any{ + "prior_run_id": prior.ID, "prior_pr_state": state, + "new_run_id": got.ID, "pr_url": got.PRURL, "pr_base_branch": got.PRBaseBranch, + }) + }) + } +} + +// Record only the public receipt and selected persisted launch state, never intent. +func logLaunchEvidence(t *testing.T, label string, value any) { + t.Helper() + encoded, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + t.Logf("launch-evidence %s: %s", label, encoded) +} diff --git a/internal/db/db_test.go b/internal/db/db_test.go index 527e129fe..3fb62b4b1 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -81,7 +81,7 @@ func TestOpenCreatesSchema(t *testing.T) { if !hasColumn(t, d, "repos", "fork_url") { t.Fatal("repos.fork_url column missing from fresh schema") } - for _, column := range []string{"worktree_dir", "submitted_head_sha", "no_mistakes_version", "no_mistakes_build_sha", "review_approved_head_sha", "last_pushed_sha", "push_target_fingerprint", "push_ref", "last_pushed_at", "push_generation", "push_active", "terminal_head_verified_at", "pr_state", "pr_state_observed_at", "ci_ready_at", "ci_ready_no_ci", "custody_returned_at"} { + for _, column := range []string{"worktree_dir", "submitted_head_sha", "no_mistakes_version", "no_mistakes_build_sha", "review_approved_head_sha", "last_pushed_sha", "push_target_fingerprint", "push_ref", "last_pushed_at", "push_generation", "push_active", "terminal_head_verified_at", "pr_state", "pr_state_observed_at", "ci_ready_at", "ci_ready_no_ci", "custody_returned_at", "launch_nonce", "launch_validation_generation", "launch_intent_digest", "launch_receipt_claimed_at", "pr_base_branch"} { if !hasColumn(t, d, "runs", column) { t.Fatalf("runs.%s column missing from fresh schema", column) } @@ -133,6 +133,9 @@ func TestOpenMigratesRunSyncProvenanceWithoutBackfillingMutableHead(t *testing.T if run.CustodyReturnedAt != nil { t.Fatalf("legacy run gained a custody-return stamp: %#v", run) } + if run.LaunchNonce != nil || run.LaunchValidationGeneration != nil || run.LaunchIntentDigest != nil || run.LaunchReceiptClaimedAt != nil || run.PRBaseBranch != nil { + t.Fatalf("legacy run gained a launch proof binding: %#v", run) + } var archiveCount int if err := d.sql.QueryRow("SELECT count(*) FROM recovery_archives").Scan(&archiveCount); err != nil || archiveCount != 0 { t.Fatalf("recovery archive migration = count %d, error %v", archiveCount, err) diff --git a/internal/db/run.go b/internal/db/run.go index 204936523..56b935a8a 100644 --- a/internal/db/run.go +++ b/internal/db/run.go @@ -71,6 +71,13 @@ type Run struct { IntentSource *string IntentSessionID *string IntentScore *float64 + // LaunchNonce, LaunchValidationGeneration, and LaunchIntentDigest are + // nullable for ordinary and historical rows. Together they bind one opaque + // proof request to this run; only receipt-specific IPC exposes them. + LaunchNonce *string + LaunchValidationGeneration *string + LaunchIntentDigest *string + LaunchReceiptClaimedAt *int64 // PRBaseBranch is a per-run override for the integration/PR target branch. // It is set by the operator (axi run --base-branch) and takes precedence // over pr.base_branch in repo config for this run only. @@ -79,7 +86,7 @@ type Run struct { UpdatedAt int64 } -const runColumns = `id, repo_id, branch, head_sha, base_sha, worktree_dir, submitted_head_sha, no_mistakes_version, no_mistakes_build_sha, review_approved_head_sha, status, pr_url, pr_state, pr_state_observed_at, ci_ready_at, COALESCE(ci_ready_no_ci, 0), last_pushed_sha, push_target_kind, push_target_fingerprint, push_ref, last_pushed_at, push_generation, COALESCE(push_active, 0), terminal_head_verified_at, custody_returned_at, error, awaiting_agent_since, COALESCE(parked_ms, 0), intent, intent_source, intent_session_id, intent_score, pr_base_branch, created_at, updated_at` +const runColumns = `id, repo_id, branch, head_sha, base_sha, worktree_dir, submitted_head_sha, no_mistakes_version, no_mistakes_build_sha, review_approved_head_sha, status, pr_url, pr_state, pr_state_observed_at, ci_ready_at, COALESCE(ci_ready_no_ci, 0), last_pushed_sha, push_target_kind, push_target_fingerprint, push_ref, last_pushed_at, push_generation, COALESCE(push_active, 0), terminal_head_verified_at, custody_returned_at, error, awaiting_agent_since, COALESCE(parked_ms, 0), intent, intent_source, intent_session_id, intent_score, launch_nonce, launch_validation_generation, launch_intent_digest, launch_receipt_claimed_at, pr_base_branch, created_at, updated_at` func scanRun(row interface { Scan(...any) error @@ -90,7 +97,9 @@ func scanRun(row interface { &r.LastPushedSHA, &r.PushTargetKind, &r.PushTargetFingerprint, &r.PushRef, &r.LastPushedAt, &r.PushGeneration, &r.PushActive, &r.TerminalHeadVerifiedAt, &r.CustodyReturnedAt, &r.Error, &r.AwaitingAgentSince, &r.ParkedMS, - &r.Intent, &r.IntentSource, &r.IntentSessionID, &r.IntentScore, &r.PRBaseBranch, + &r.Intent, &r.IntentSource, &r.IntentSessionID, &r.IntentScore, + &r.LaunchNonce, &r.LaunchValidationGeneration, &r.LaunchIntentDigest, &r.LaunchReceiptClaimedAt, + &r.PRBaseBranch, &r.CreatedAt, &r.UpdatedAt, ) } @@ -111,6 +120,13 @@ func (d *DB) InsertRun(repoID, branch, headSHA, baseSHA string) (*Run, error) { } func (d *DB) InsertRunWithIntent(repoID, branch, headSHA, baseSHA string, intent *RunIntent, prBaseBranch string) (*Run, error) { + return d.InsertRunWithIntentAndLaunchNonce(repoID, branch, headSHA, baseSHA, intent, "", "", "", prBaseBranch) +} + +// InsertRunWithIntentAndLaunchNonce persists an optional proof binding. The +// partial unique index remains the duplicate defense across daemon processes; +// callers additionally serialize selection under their branch lock. +func (d *DB) InsertRunWithIntentAndLaunchNonce(repoID, branch, headSHA, baseSHA string, intent *RunIntent, launchNonce, validationGeneration, intentDigest, prBaseBranch string) (*Run, error) { ts := now() version := buildinfo.CurrentVersion() buildSHA := buildinfo.Commit @@ -127,6 +143,11 @@ func (d *DB) InsertRunWithIntent(repoID, branch, headSHA, baseSHA string, intent CreatedAt: ts, UpdatedAt: ts, } + if launchNonce != "" { + r.LaunchNonce = &launchNonce + r.LaunchValidationGeneration = &validationGeneration + r.LaunchIntentDigest = &intentDigest + } if intent != nil { r.Intent = &intent.Summary r.IntentSource = &intent.Source @@ -138,8 +159,8 @@ func (d *DB) InsertRunWithIntent(repoID, branch, headSHA, baseSHA string, intent r.PRBaseBranch = &prBaseBranch } _, err := d.sql.Exec( - `INSERT INTO runs (id, repo_id, branch, head_sha, base_sha, submitted_head_sha, no_mistakes_version, no_mistakes_build_sha, status, pr_state, intent, intent_source, intent_session_id, intent_score, pr_base_branch, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'none', ?, ?, ?, ?, ?, ?, ?)`, - r.ID, r.RepoID, r.Branch, r.HeadSHA, r.BaseSHA, headSHA, r.NoMistakesVersion, r.NoMistakesBuildSHA, r.Status, r.Intent, r.IntentSource, r.IntentSessionID, r.IntentScore, r.PRBaseBranch, r.CreatedAt, r.UpdatedAt, + `INSERT INTO runs (id, repo_id, branch, head_sha, base_sha, submitted_head_sha, no_mistakes_version, no_mistakes_build_sha, status, pr_state, intent, intent_source, intent_session_id, intent_score, launch_nonce, launch_validation_generation, launch_intent_digest, pr_base_branch, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'none', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + r.ID, r.RepoID, r.Branch, r.HeadSHA, r.BaseSHA, headSHA, r.NoMistakesVersion, r.NoMistakesBuildSHA, r.Status, r.Intent, r.IntentSource, r.IntentSessionID, r.IntentScore, r.LaunchNonce, r.LaunchValidationGeneration, r.LaunchIntentDigest, r.PRBaseBranch, r.CreatedAt, r.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("insert run: %w", err) @@ -281,6 +302,62 @@ func (d *DB) GetRun(id string) (*Run, error) { return r, nil } +// GetRunByLaunchNonce returns the one durable proof binding for a repository +// branch, or nil when this nonce has not created a run. +func (d *DB) GetRunByLaunchNonce(repoID, branch, launchNonce string) (*Run, error) { + r := &Run{} + err := scanRun(d.sql.QueryRow( + `SELECT `+runColumns+` FROM runs WHERE repo_id = ? AND branch = ? AND launch_nonce = ?`, + repoID, branch, launchNonce, + ), r) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get run by launch nonce: %w", err) + } + return r, nil +} + +// ClaimLaunchReceipt atomically returns the exact nonce-bound row and whether +// this caller is its first observer. The expected immutable receipt binding, +// including an explicit PR base branch, is part of the UPDATE predicate, so a +// conflicting observer cannot consume `created`. +func (d *DB) ClaimLaunchReceipt(repoID, branch, launchNonce, submittedHeadSHA, validationGeneration, intentDigest, prBaseBranch string) (*Run, bool, error) { + prBaseBranch = strings.TrimSpace(prBaseBranch) + for { + r := &Run{} + err := scanRun(d.sql.QueryRow( + `UPDATE runs SET launch_receipt_claimed_at = ? + WHERE repo_id = ? AND branch = ? AND launch_nonce = ? + AND submitted_head_sha = ? AND launch_validation_generation = ? AND launch_intent_digest = ? + AND (? = '' OR pr_base_branch = ?) + AND launch_receipt_claimed_at IS NULL + RETURNING `+runColumns, + now(), repoID, branch, launchNonce, submittedHeadSHA, validationGeneration, intentDigest, prBaseBranch, prBaseBranch, + ), r) + if err == nil { + return r, true, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return nil, false, fmt.Errorf("claim launch receipt: %w", err) + } + r, err = d.GetRunByLaunchNonce(repoID, branch, launchNonce) + if err != nil { + return nil, false, err + } + if r == nil || r.LaunchReceiptClaimedAt != nil || + r.SubmittedHeadSHA == nil || *r.SubmittedHeadSHA != submittedHeadSHA || + r.LaunchValidationGeneration == nil || *r.LaunchValidationGeneration != validationGeneration || + r.LaunchIntentDigest == nil || *r.LaunchIntentDigest != intentDigest || + prBaseBranch != "" && (r.PRBaseBranch == nil || *r.PRBaseBranch != prBaseBranch) { + return r, false, nil + } + // A creator committed a matching row after UPDATE missed. Retry instead + // of labelling the first observer as a replay. + } +} + // GetRunsByRepo returns all runs for a repo, newest first. func (d *DB) GetRunsByRepo(repoID string) ([]*Run, error) { rows, err := d.sql.Query(`SELECT `+runColumns+` FROM runs WHERE repo_id = ? ORDER BY created_at DESC, id DESC`, repoID) diff --git a/internal/db/run_test.go b/internal/db/run_test.go index d4d9cdf82..9ea9ecb99 100644 --- a/internal/db/run_test.go +++ b/internal/db/run_test.go @@ -84,6 +84,158 @@ func TestInsertRunWithIntent(t *testing.T) { } } +func TestLaunchNonceBindingClaimsOnceAndPreservesLegacyRows(t *testing.T) { + d := openTestDB(t) + repo, err := d.InsertRepo("/home/user/project", "git@github.com:user/project.git", "main") + if err != nil { + t.Fatal(err) + } + legacy, err := d.InsertRun(repo.ID, "feature", "legacy-head", "base") + if err != nil { + t.Fatal(err) + } + if got, err := d.GetRun(legacy.ID); err != nil || got.LaunchNonce != nil || got.LaunchValidationGeneration != nil || got.LaunchIntentDigest != nil { + t.Fatalf("legacy launch binding = %#v, err = %v", got, err) + } + if claim, claimed, err := d.ClaimLaunchReceipt(repo.ID, "feature", "legacy-nonce", "legacy-head", "generation-1", "digest", ""); err != nil || claimed || claim != nil { + t.Fatalf("legacy receipt claim = %#v, claimed=%v, err=%v", claim, claimed, err) + } + + const generation = "generation-001" + const intentDigest = "intent-digest" + intent := RunIntent{Summary: "exact persisted intent\n", Source: RunIntentSourceAgent, Score: 1} + run, err := d.InsertRunWithIntentAndLaunchNonce(repo.ID, "feature", "head", "base", &intent, "nonce-1", generation, intentDigest, "") + if err != nil { + t.Fatal(err) + } + if run.LaunchNonce == nil || *run.LaunchNonce != "nonce-1" || run.LaunchValidationGeneration == nil || *run.LaunchValidationGeneration != generation || run.LaunchIntentDigest == nil || *run.LaunchIntentDigest != intentDigest { + t.Fatalf("launch binding = %#v", run) + } + if _, err := d.InsertRunWithIntentAndLaunchNonce(repo.ID, "feature", "head", "base", &intent, "nonce-1", generation, intentDigest, ""); err == nil { + t.Fatal("duplicate nonce insert succeeded") + } + + conflicting, claimed, err := d.ClaimLaunchReceipt(repo.ID, "feature", "nonce-1", "head", "generation-002", intentDigest, "") + if err != nil || claimed || conflicting == nil || conflicting.ID != run.ID { + t.Fatalf("conflicting generation claim = %#v, claimed=%v, err=%v", conflicting, claimed, err) + } + stored, err := d.GetRun(run.ID) + if err != nil { + t.Fatal(err) + } + if stored.LaunchReceiptClaimedAt != nil { + t.Fatal("conflicting generation claim consumed created disposition") + } + first, claimed, err := d.ClaimLaunchReceipt(repo.ID, "feature", "nonce-1", "head", generation, intentDigest, "") + if err != nil || !claimed || first.ID != run.ID { + t.Fatalf("first claim = %#v, claimed=%v, err=%v", first, claimed, err) + } + replay, claimed, err := d.ClaimLaunchReceipt(repo.ID, "feature", "nonce-1", "head", generation, intentDigest, "") + if err != nil || claimed || replay.ID != run.ID { + t.Fatalf("replay claim = %#v, claimed=%v, err=%v", replay, claimed, err) + } +} + +func TestClaimLaunchReceiptRejectsMismatchedPRBaseBranch(t *testing.T) { + d := openTestDB(t) + repo, err := d.InsertRepo("/home/user/project", "git@github.com:user/project.git", "main") + if err != nil { + t.Fatal(err) + } + intent := RunIntent{Summary: "exact persisted intent", Source: RunIntentSourceAgent, Score: 1} + const generation = "generation-base-001" + const intentDigest = "base-intent-digest" + const prBaseBranch = "release/v1" + run, err := d.InsertRunWithIntentAndLaunchNonce(repo.ID, "feature", "head", "base", &intent, "nonce-base", generation, intentDigest, prBaseBranch) + if err != nil { + t.Fatal(err) + } + + conflicting, claimed, err := d.ClaimLaunchReceipt(repo.ID, "feature", "nonce-base", "head", generation, intentDigest, "other-target") + if err != nil || claimed || conflicting == nil || conflicting.ID != run.ID { + t.Fatalf("conflicting base claim = %#v, claimed=%v, err=%v", conflicting, claimed, err) + } + stored, err := d.GetRun(run.ID) + if err != nil { + t.Fatal(err) + } + if stored.LaunchReceiptClaimedAt != nil { + t.Fatal("conflicting base claim consumed created disposition") + } + + first, claimed, err := d.ClaimLaunchReceipt(repo.ID, "feature", "nonce-base", "head", generation, intentDigest, " release/v1 ") + if err != nil || !claimed || first.ID != run.ID { + t.Fatalf("matching base claim = %#v, claimed=%v, err=%v", first, claimed, err) + } + if first.PRBaseBranch == nil || *first.PRBaseBranch != prBaseBranch { + t.Fatalf("claimed PR base branch = %#v, want %q", first.PRBaseBranch, prBaseBranch) + } + replay, claimed, err := d.ClaimLaunchReceipt(repo.ID, "feature", "nonce-base", "head", generation, intentDigest, "") + if err != nil || claimed || replay == nil || replay.ID != run.ID { + t.Fatalf("omitted base replay = %#v, claimed=%v, err=%v", replay, claimed, err) + } +} + +func TestClaimLaunchReceiptAtomicallyReturnsCreatedOnce(t *testing.T) { + d := openTestDB(t) + repo, err := d.InsertRepo("/home/user/project", "git@github.com:user/project.git", "main") + if err != nil { + t.Fatal(err) + } + intent := RunIntent{Summary: "exact persisted intent", Source: RunIntentSourceAgent, Score: 1} + const generation = "generation-race-001" + const intentDigest = "race-intent-digest" + run, err := d.InsertRunWithIntentAndLaunchNonce(repo.ID, "feature", "head", "base", &intent, "nonce-race", generation, intentDigest, "") + if err != nil { + t.Fatal(err) + } + + const callers = 16 + type result struct { + runID string + claimed bool + err error + } + start := make(chan struct{}) + results := make(chan result, callers) + for range callers { + go func() { + <-start + claimedRun, claimed, err := d.ClaimLaunchReceipt(repo.ID, "feature", "nonce-race", "head", generation, intentDigest, "") + runID := "" + if claimedRun != nil { + runID = claimedRun.ID + } + results <- result{runID: runID, claimed: claimed, err: err} + }() + } + close(start) + + created := 0 + for range callers { + got := <-results + if got.err != nil { + t.Fatal(got.err) + } + if got.runID != run.ID { + t.Fatalf("claim run ID = %q, want %q", got.runID, run.ID) + } + if got.claimed { + created++ + } + } + if created != 1 { + t.Fatalf("created claims = %d, want 1", created) + } + stored, err := d.GetRun(run.ID) + if err != nil { + t.Fatal(err) + } + if stored.LaunchReceiptClaimedAt == nil { + t.Fatal("created claim was not persisted") + } +} + func TestRunCIReadinessStoresNoCIDeclaration(t *testing.T) { d := openTestDB(t) repo, _ := d.InsertRepo("/home/user/project", "git@github.com:user/project.git", "main") diff --git a/internal/db/schema.go b/internal/db/schema.go index a2923e4af..5df9d0212 100644 --- a/internal/db/schema.go +++ b/internal/db/schema.go @@ -38,6 +38,11 @@ CREATE TABLE IF NOT EXISTS runs ( error TEXT, awaiting_agent_since INTEGER, parked_ms INTEGER, + launch_nonce TEXT, + launch_validation_generation TEXT, + launch_intent_digest TEXT, + launch_receipt_claimed_at INTEGER, + pr_base_branch TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); @@ -256,6 +261,15 @@ var migrationStatements = []string{ // unpublished head this run produced; a timestamp means an explicit // guarded recovery ended that ownership (internal/branchsync). `ALTER TABLE runs ADD COLUMN custody_returned_at INTEGER`, + // Proof bindings remain nullable for ordinary and historical rows. The + // partial unique index is the cross-process duplicate defense. + `ALTER TABLE runs ADD COLUMN launch_nonce TEXT`, + `ALTER TABLE runs ADD COLUMN launch_validation_generation TEXT`, + `ALTER TABLE runs ADD COLUMN launch_intent_digest TEXT`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_runs_repo_branch_launch_nonce ON runs (repo_id, branch, launch_nonce) WHERE launch_nonce IS NOT NULL`, + // The first successful conditional update marks the sole `created` + // observer; all later claims are durable replays. + `ALTER TABLE runs ADD COLUMN launch_receipt_claimed_at INTEGER`, // Per-run PR target branch chosen by the operator (e.g. axi run // --base-branch). Nullable: absent means fall back to repo config and the // forge default branch. diff --git a/internal/git/git.go b/internal/git/git.go index be1f5da63..81a50a130 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -511,6 +511,12 @@ func PushCommit(ctx context.Context, dir, remote, commitSHA, ref, expectedSHA st return pushSourceWithOptions(ctx, dir, remote, commitSHA, ref, expectedSHA, forceWithLease, nil) } +// PushCommitWithOptions pushes an immutable commit with hook-visible options. +// It keeps proof launch identity attached to the commit sampled before pushing. +func PushCommitWithOptions(ctx context.Context, dir, remote, commitSHA, ref, expectedSHA string, forceWithLease bool, pushOptions []string) error { + return pushSourceWithOptions(ctx, dir, remote, commitSHA, ref, expectedSHA, forceWithLease, pushOptions) +} + // PushWithOptions pushes HEAD to a remote with per-push options. func PushWithOptions(ctx context.Context, dir, remote, ref, expectedSHA string, forceWithLease bool, pushOptions []string) error { return pushSourceWithOptions(ctx, dir, remote, "HEAD", ref, expectedSHA, forceWithLease, pushOptions) diff --git a/internal/ipc/protocol.go b/internal/ipc/protocol.go index 51aa1999a..100070ab7 100644 --- a/internal/ipc/protocol.go +++ b/internal/ipc/protocol.go @@ -9,20 +9,22 @@ import ( // JSON-RPC 2.0 method names. const ( - MethodPushReceived = "push_received" - MethodGetRun = "get_run" - MethodGetStepDiff = "get_step_diff" - MethodGetRuns = "get_runs" - MethodGetRunsForHead = "get_runs_for_head" - MethodGetActiveRun = "get_active_run" - MethodRerun = "rerun" - MethodSubscribe = "subscribe" - MethodRespond = "respond" - MethodCancelRun = "cancel_run" - MethodGateContext = "gate_context" - MethodAdmitPush = "admit_push" - MethodHealth = "health" - MethodShutdown = "shutdown" + MethodPushReceived = "push_received" + MethodStartFreshRun = "start_fresh_run" + MethodClaimLaunchReceipt = "claim_launch_receipt" + MethodGetRun = "get_run" + MethodGetStepDiff = "get_step_diff" + MethodGetRuns = "get_runs" + MethodGetRunsForHead = "get_runs_for_head" + MethodGetActiveRun = "get_active_run" + MethodRerun = "rerun" + MethodSubscribe = "subscribe" + MethodRespond = "respond" + MethodCancelRun = "cancel_run" + MethodGateContext = "gate_context" + MethodAdmitPush = "admit_push" + MethodHealth = "health" + MethodShutdown = "shutdown" ) // JSON-RPC 2.0 error codes. @@ -64,16 +66,45 @@ func (e *RPCError) Error() string { return e.Message } // // Intent, when set, is an agent-supplied description of the change. It is // stamped onto the run so the intent step uses it verbatim instead of inferring -// intent from local transcripts. +// intent from local transcripts. LaunchNonce and ValidationGeneration together +// opt into a nonce-bound launch proof. type PushReceivedParams struct { // Gate is the absolute path to the gate bare repo. - Gate string `json:"gate"` - Ref string `json:"ref"` - Old string `json:"old"` - New string `json:"new"` - SkipSteps []types.StepName `json:"skip_steps,omitempty"` - Intent string `json:"intent,omitempty"` - PRBaseBranch string `json:"pr_base_branch,omitempty"` + Gate string `json:"gate"` + Ref string `json:"ref"` + Old string `json:"old"` + New string `json:"new"` + SkipSteps []types.StepName `json:"skip_steps,omitempty"` + Intent string `json:"intent,omitempty"` + LaunchNonce string `json:"launch_nonce,omitempty"` + ValidationGeneration string `json:"validation_generation,omitempty"` + PRBaseBranch string `json:"pr_base_branch,omitempty"` +} + +// StartFreshRunParams requests a nonce-bound fresh launch for one exact gate +// branch head. The daemon checks the gate while holding the branch lock, so a +// caller never receives a proof for a drifting creation context. +type StartFreshRunParams struct { + RepoID string `json:"repo_id"` + Branch string `json:"branch"` + HeadSHA string `json:"head_sha"` + SkipSteps []types.StepName `json:"skip_steps,omitempty"` + Intent string `json:"intent"` + LaunchNonce string `json:"launch_nonce"` + ValidationGeneration string `json:"validation_generation"` + PRBaseBranch string `json:"pr_base_branch,omitempty"` +} + +// ClaimLaunchReceiptParams identifies one exact opaque receipt binding. +// Generic run/status surfaces never expose launch bindings or intent digests. +type ClaimLaunchReceiptParams struct { + RepoID string `json:"repo_id"` + Branch string `json:"branch"` + LaunchNonce string `json:"launch_nonce"` + SubmittedHeadSHA string `json:"submitted_head_sha"` + ValidationGeneration string `json:"validation_generation"` + IntentDigest string `json:"intent_digest"` + PRBaseBranch string `json:"pr_base_branch,omitempty"` } // GetRunParams requests a single run by ID. @@ -184,11 +215,35 @@ type ShutdownParams struct{} // --- Method results --- -// PushReceivedResult confirms the push was accepted. +// PushReceivedResult confirms the push was accepted. Receipt observation is a +// separate atomic claim so a push-created row remains unclaimed until its first +// automation observer. type PushReceivedResult struct { RunID string `json:"run_id"` } +// LaunchReceipt is the machine-readable, privacy-safe proof that the daemon +// selected one durable run before the caller drives it. The validation +// generation and intent digest are persisted; raw intent is never included. +type LaunchReceipt struct { + RunID string `json:"run_id"` + Disposition string `json:"disposition"` + LaunchNonce string `json:"launch_nonce"` + ValidationGeneration string `json:"validation_generation"` + Branch string `json:"branch"` + HeadSHA string `json:"head_sha"` + SubmittedHeadSHA string `json:"submitted_head_sha"` + IntentDigest string `json:"intent_digest"` +} + +type StartFreshRunResult struct { + Receipt LaunchReceipt `json:"receipt"` +} + +type ClaimLaunchReceiptResult struct { + Receipt *LaunchReceipt `json:"receipt,omitempty"` +} + // GetRunResult wraps a single run. type GetRunResult struct { Run *RunInfo `json:"run"`