diff --git a/CHANGELOG.md b/CHANGELOG.md index 76fc7accc..3ad131d57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,4 @@ # Changelog - ## [1.60.1](https://github.com/kunchenguid/no-mistakes/compare/v1.60.0...v1.60.1) (2026-08-29) diff --git a/docs/src/content/docs/guides/launch-proof-maintenance.md b/docs/src/content/docs/guides/launch-proof-maintenance.md new file mode 100644 index 000000000..1f8d31eed --- /dev/null +++ b/docs/src/content/docs/guides/launch-proof-maintenance.md @@ -0,0 +1,39 @@ +--- +title: Launch Proof Fork Maintenance +description: Temporary release and retirement procedure for strict AXI launch receipts. +--- + +Strict AXI launch receipts are upstream-first. Use a fork binary only while no +upstream release exposes the `axi run --launch-nonce` receipt contract. + +## Publish a temporary fork build + +1. Rebase the isolated proof commit series onto the current upstream `main`. + Do not carry unrelated custody, routing, or release changes. +2. Build from an immutable fork commit and publish a public GitHub release whose + tag and release notes record that full commit SHA. +3. Configure the consuming automation with that exact release asset URL and + commit SHA. Never point an updater or binary source at a mutable branch, + `latest`, or a moving release tag. +4. Run the strict-mode smoke test against the installed fork binary: invoke + `no-mistakes axi run --intent --launch-nonce ` + on a committed feature branch, and verify a pre-drive `launch_receipt` has + `created`, the full branch/head bindings, and the SHA-256 digest of the exact + persisted intent. Reinvoke the same request and verify the same run ID with + `reused`. + +The nonce and intent digest are safe correlation material; do not add raw intent +to fork release notes, telemetry, status output, or update configuration. + +## Retire after upstream ships + +Do not infer support from an upstream version number. Install the candidate +upstream release, inspect `no-mistakes axi run --help` for `--launch-nonce`, and +run the same smoke test above against the upstream binary. Only after that smoke +test passes: + +1. Remove the fork binary source/update override and switch consumers to the + verified upstream release. +2. Delete the temporary fork release and proof branch. +3. Remove this temporary maintenance path in the next upstream documentation + update; no compatibility alias or permanent fork-only command remains. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index cc03e35b3..af8510eca 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -100,17 +100,50 @@ An active run on another branch does not block starting validation for the curre no-mistakes axi run --intent "the user's goal" no-mistakes axi run --intent "the user's goal" --skip test,lint no-mistakes axi run --intent "the user's goal" --yes +no-mistakes axi run --intent "the user's goal" --launch-nonce request-7f3 ``` -| Flag | Type | Default | Description | -| ------------- | -------- | ------- | ---------------------------------------------------------------- | -| `--intent` | `string` | (none) | What the user set out to accomplish; required to start a new run | -| `-y`, `--yes` | `bool` | `false` | Auto-resolve every gate until a decision point or outcome | -| `--skip` | `string` | (none) | Comma-separated pipeline steps to skip | +| Flag | Type | Default | Description | +| ------------------ | -------- | ------- | --------------------------------------------------------------------------- | +| `--intent` | `string` | (none) | What the user set out to accomplish; required to start a new run | +| `--launch-nonce` | `string` | (none) | Enables strict receipt mode with an opaque request nonce | +| `-y`, `--yes` | `bool` | `false` | Auto-resolve every gate until a decision point or outcome | +| `--skip` | `string` | (none) | Comma-separated pipeline steps to skip | `--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. + +### Strict launch receipt mode + +Passing `--launch-nonce` disables ordinary same-head reattachment for that +invocation. The nonce is opaque but must be 1–128 URL-safe ASCII characters +(`A-Z`, `a-z`, `0-9`, `.`, `_`, `~`, `-`). Before AXI subscribes to or drives +the run, stdout emits a separate `launch_receipt:` TOON document: + +```text +launch_receipt: + run_id: 01... + disposition: created + launch_nonce: request-7f3 + branch: feature/proof + head_sha: + submitted_head_sha: + intent_digest: +``` + +The daemon derives every receipt value from the persisted row. `intent_digest` +is SHA-256 of the exact bytes persisted for `--intent`; raw intent is absent +from the receipt, telemetry, and ordinary AXI status output. The first observed +receipt is `created`; lost-response retries and concurrent calls with the same +nonce return the same row as `reused`. A different nonce creates a distinct run +even at the same branch and head. Reusing a nonce with a changed head or intent, +malformed nonce, conflicting push options, or a branch/head context drift fails +without a receipt. + +The regular push path and the gate-already-up-to-date fallback use the same +daemon receipt contract. Legacy runs without a nonce and ordinary AXI +reattachment retain their existing behavior. 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`. 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. diff --git a/internal/cli/axi_drive.go b/internal/cli/axi_drive.go index 0805b0708..23c04880f 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" @@ -58,7 +59,7 @@ func newAxiRunCmd() *cobra.Command { var autoYes bool var skipValue string var intent string - + var launchNonce string cmd := &cobra.Command{ Use: "run", Short: "Validate your code changes, blocking until a decision point or the outcome", @@ -70,6 +71,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 enables strict proof mode. It must be a 1–128-character\n" + + "opaque URL-safe token. Before driving, AXI emits a receipt with the durable\n" + + "run ID, created/reused disposition, full heads, and a digest of the exact\n" + + "persisted intent; raw intent is never included.\n\n" + "The calling agent drives AXI approval gates but does not become the pipeline\n" + "agent. The daemon requires a supported native agent binary, the `agent: cursor`\n" + "ACP alias, or an explicit `acp:` through `acpx`, and fails before the\n" + @@ -80,26 +85,32 @@ 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) != "", + "auto_yes": autoYes, + "has_intent": strings.TrimSpace(intent) != "", + "has_skip": strings.TrimSpace(skipValue) != "", + "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) + return runAxiRunWithLaunchNonce(cmd, autoYes, skipSteps, intent, launchNonce) }) }, } cmd.Flags().BoolVarP(&autoYes, "yes", "y", false, "auto-resolve every gate (fix findings, then accept) until a decision point or outcome") 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; enables strict proof mode") return cmd } func runAxiRun(cmd *cobra.Command, autoYes bool, skipSteps []types.StepName, intent string) error { + return runAxiRunWithLaunchNonce(cmd, autoYes, skipSteps, intent, "") +} + +func runAxiRunWithLaunchNonce(cmd *cobra.Command, autoYes bool, skipSteps []types.StepName, intent, launchNonce string) error { ctx := cmd.Context() env, err := openAxiRunEnv() if err != nil { @@ -121,7 +132,24 @@ func runAxiRun(cmd *cobra.Command, autoYes bool, skipSteps []types.StepName, int return emitError(cmd, 1, fmt.Sprintf("get current HEAD: %v", err)) } - runID := activeRunID(env, branch, headSHA) + runID := "" + var launchReceipt *ipc.LaunchReceipt + if launchNonce != "" { + receipt, err := lookupLaunchReceipt(env.client, env.repo.ID, branch, launchNonce) + if err != nil { + return emitError(cmd, 1, fmt.Sprintf("look up launch receipt: %v", err)) + } + if receipt != nil { + if receipt.HeadSHA != headSHA || receipt.SubmittedHeadSHA != headSHA || receipt.IntentDigest != digestLaunchIntent(intent) { + return emitError(cmd, 1, "conflicting launch receipt: nonce is already bound to a different head or intent", + "Use a new --launch-nonce for a changed request") + } + launchReceipt = receipt + runID = receipt.RunID + } + } else { + runID = activeRunID(env, branch, headSHA) + } if runID == "" { if err := configErrorForFreshAxiRun(env, runID); err != nil { return emitError(cmd, 1, err.Error(), repoInitHelp(err)...) @@ -142,7 +170,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) + if launchNonce != "" { + launchReceipt, err = triggerProofRun(ctx, env, branch, headSHA, skipSteps, intent, launchNonce) + if err == nil { + runID = launchReceipt.RunID + } + } else { + runID, err = triggerRun(ctx, env, branch, headSHA, skipSteps, intent) + } if err != nil { if ownershipErr, ok := err.(*branchOwnershipError); ok { return emitBranchOwnershipError(cmd, ownershipErr) @@ -150,6 +185,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 { @@ -158,6 +196,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 @@ -354,6 +397,79 @@ func runsForHead(client *ipc.Client, repoID, branch, headSHA string) ([]ipc.RunI return result.Runs, nil } +func lookupLaunchReceipt(client *ipc.Client, repoID, branch, launchNonce string) (*ipc.LaunchReceipt, error) { + var result ipc.GetLaunchReceiptResult + if err := client.Call(ipc.MethodGetLaunchReceipt, &ipc.GetLaunchReceiptParams{ + RepoID: repoID, Branch: branch, LaunchNonce: launchNonce, + }, &result); err != nil { + return nil, err + } + return result.Receipt, nil +} + +// triggerProofRun captures the immutable commit selected before the push and +// waits only for the daemon's nonce-bound receipt. A same-head run selected by +// ordinary active-run heuristics is never accepted as proof. + +func claimLaunchReceipt(client *ipc.Client, repoID, branch, launchNonce string) (*ipc.LaunchReceipt, error) { + var result ipc.GetLaunchReceiptResult + if err := client.Call(ipc.MethodClaimLaunchReceipt, &ipc.GetLaunchReceiptParams{ + RepoID: repoID, Branch: branch, LaunchNonce: launchNonce, + }, &result); err != nil { + return nil, err + } + return result.Receipt, nil +} +func triggerProofRun(ctx context.Context, env *axiEnv, branch, headSHA string, skipSteps []types.StepName, intent, launchNonce string) (*ipc.LaunchReceipt, error) { + pushOptions := formatSkipPushOptions(skipSteps) + pushOptions = append(pushOptions, formatIntentPushOption(intent), formatLaunchNoncePushOption(launchNonce)) + 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, 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, + }, &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 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 := lookupLaunchReceipt(client, repoID, branch, launchNonce) + if err != nil { + return nil, err + } + if receipt != nil { + return claimLaunchReceipt(client, repoID, branch, launchNonce) + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-deadline.C: + return nil, nil + case <-poll.C: + } + } +} + // waitForTriggeredRunForHead waits for the run created by this trigger. The // active-run lookup handles normal execution; the head lookup catches a run // that fails before it can be observed as active. priorRunIDs prevents an @@ -576,6 +692,21 @@ func sendRespond(client *ipc.Client, runID string, step types.StepName, action t return nil } +// emitLaunchReceipt writes the proof document before driveRun subscribes, so a +// caller retains the daemon-authored binding even when driving later blocks or +// returns at a gate. It intentionally contains only an opaque nonce and digest. +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: "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}, + )}) +} + // renderDriveResult prints the run snapshot plus one of: the active gate (exit // 0, a normal decision point), a checks-passed outcome (exit 0, CI readiness is // established by green checks or the trusted no_ci declaration and the PR is diff --git a/internal/cli/daemon_cmd.go b/internal/cli/daemon_cmd.go index ae9127733..ccff33170 100644 --- a/internal/cli/daemon_cmd.go +++ b/internal/cli/daemon_cmd.go @@ -106,6 +106,10 @@ func newDaemonNotifyPushCmd() *cobra.Command { if err != nil { return err } + launchNonce, err := parseLaunchNoncePushOptions(pushOptions) + if err != nil { + return err + } gatePath, err := normalizeNotifyGatePath(gate) if err != nil { return err @@ -124,12 +128,13 @@ 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, + Gate: gatePath, + Ref: ref, + Old: oldSHA, + New: newSHA, + SkipSteps: skipSteps, + Intent: intent, + LaunchNonce: launchNonce, }, &result) }, } @@ -194,6 +199,39 @@ func parseSkipSteps(value string) ([]types.StepName, error) { // survive the push-option transport (which is line-oriented). const intentPushOptionPrefix = "no-mistakes.intent=" +// launchNoncePushOptionPrefix carries an opaque proof-mode request binding +// through the line-oriented Git push-option transport. +const launchNoncePushOptionPrefix = "no-mistakes.launch-nonce=" + +func formatLaunchNoncePushOption(nonce string) string { + if nonce == "" { + return "" + } + return launchNoncePushOptionPrefix + base64.StdEncoding.EncodeToString([]byte(nonce)) +} + +// parseLaunchNoncePushOptions rejects duplicate conflicting values instead of +// silently selecting one, because that would manufacture a false proof. +func parseLaunchNoncePushOptions(options []string) (string, error) { + nonce := "" + for _, option := range options { + encoded, ok := strings.CutPrefix(option, launchNoncePushOptionPrefix) + if !ok { + continue + } + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", fmt.Errorf("decode launch nonce push option: %w", err) + } + value := string(decoded) + if nonce != "" && nonce != value { + return "", fmt.Errorf("conflicting launch nonce push options") + } + nonce = value + } + return nonce, nil +} + // formatIntentPushOption encodes intent as a single push option, or returns "" // when there is no intent to carry. func formatIntentPushOption(intent string) string { diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 42e5414e1..725ba07b7 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -1186,6 +1186,65 @@ func registerHandlers(srv *ipc.Server, mgr *RunManager, d *db.DB, shutdown func( return &ipc.AdmitPushResult{Context: gateContextResult(result)}, nil }) + srv.Handle(ipc.MethodGetLaunchReceipt, func(_ context.Context, params json.RawMessage) (interface{}, error) { + var p ipc.GetLaunchReceiptParams + 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 + } + run, err := d.GetRunByLaunchNonce(p.RepoID, p.Branch, p.LaunchNonce) + if err != nil { + return nil, fmt.Errorf("get launch receipt: %w", err) + } + if run == nil { + return &ipc.GetLaunchReceiptResult{}, nil + } + receipt, err := receiptForRun(run, false) + if err != nil { + return nil, err + } + return &ipc.GetLaunchReceiptResult{Receipt: &receipt}, nil + }) + + srv.Handle(ipc.MethodClaimLaunchReceipt, func(_ context.Context, params json.RawMessage) (interface{}, error) { + var p ipc.GetLaunchReceiptParams + 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 + } + run, claimed, err := d.ClaimLaunchReceipt(p.RepoID, p.Branch, p.LaunchNonce) + if err != nil { + return nil, fmt.Errorf("claim launch receipt: %w", err) + } + if run == nil { + return &ipc.GetLaunchReceiptResult{}, nil + } + receipt, err := receiptForRun(run, claimed) + if err != nil { + return nil, err + } + return &ipc.GetLaunchReceiptResult{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 @@ -1212,11 +1271,11 @@ func registerHandlers(srv *ipc.Server, mgr *RunManager, d *db.DB, shutdown func( return nil, fmt.Errorf("invalid params: %w", err) } slog.Info("push received", "ref", p.Ref, "old", p.Old, "new", p.New, "gate", p.Gate) - runID, err := mgr.HandlePushReceived(ctx, &p) + receipt, err := mgr.HandlePushReceived(ctx, &p) if err != nil { return nil, err } - return &ipc.PushReceivedResult{RunID: runID}, nil + return &ipc.PushReceivedResult{RunID: receipt.RunID, Receipt: receipt}, nil }) srv.Handle(ipc.MethodRespond, func(ctx context.Context, params json.RawMessage) (interface{}, error) { diff --git a/internal/daemon/manager.go b/internal/daemon/manager.go index b5a9d5b25..4c6322251 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" @@ -689,28 +690,180 @@ 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. -func (m *RunManager) HandlePushReceived(ctx context.Context, params *ipc.PushReceivedParams) (string, error) { +func (m *RunManager) HandlePushReceived(ctx context.Context, params *ipc.PushReceivedParams) (ipc.LaunchReceipt, error) { // Ref deletion (git push remote :branch) sends new SHA as all-zeros. // Nothing to validate - skip pipeline. if git.IsZeroSHA(params.New) { - return "", fmt.Errorf("ref deletion push, no pipeline to run") + return ipc.LaunchReceipt{}, fmt.Errorf("ref deletion push, no pipeline to run") } repoID, err := repoIDFromGatePath(params.Gate) if err != nil { - return "", err + return ipc.LaunchReceipt{}, err } - repo, err := m.db.GetRepo(repoID) if err != nil { - return "", fmt.Errorf("get repo: %w", err) + return ipc.LaunchReceipt{}, fmt.Errorf("get repo: %w", err) } if repo == nil { - return "", fmt.Errorf("unknown repo for gate %s", params.Gate) + return ipc.LaunchReceipt{}, fmt.Errorf("unknown repo for gate %s", params.Gate) } branch := branchFromRef(params.Ref) - return m.startRun(ctx, repo, branch, params.New, params.Old, "push", params.SkipSteps, params.Intent) + if params.LaunchNonce != "" { + return m.startFreshLaunch(ctx, repo, branch, params.New, params.Old, params.Gate, params.SkipSteps, params.Intent, params.LaunchNonce, "push") + } + runID, err := m.startRun(ctx, repo, branch, params.New, params.Old, "push", params.SkipSteps, params.Intent) + if err != nil { + return ipc.LaunchReceipt{}, err + } + run, err := m.db.GetRun(runID) + if err != nil { + return ipc.LaunchReceipt{}, fmt.Errorf("read created run: %w", err) + } + return receiptForRun(run, true) +} + +// HandleStartFreshRun creates or replays a proof-mode launch only when the +// daemon can confirm the gate branch is still at the caller's exact head. +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, "fresh") +} + +// startFreshLaunch owns proof-mode identity under the existing repository / +// branch lock. It must not delegate the select-or-create decision to callers: +// a same nonce is an idempotent replay, while a different nonce never aliases +// a same-branch/head row. +func (m *RunManager) startFreshLaunch(ctx context.Context, repo *db.Repo, branch, headSHA, baseSHA, gateDir string, skipSteps []types.StepName, intent, launchNonce, trigger string) (ipc.LaunchReceipt, error) { + if err := validateLaunchNonce(launchNonce); err != nil { + return ipc.LaunchReceipt{}, err + } + if strings.TrimSpace(intent) == "" { + return ipc.LaunchReceipt{}, fmt.Errorf("intent is required with launch_nonce") + } + // Proof mode binds the bytes actually persisted, including leading/trailing + // whitespace. Ordinary AXI intent keeps 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 { + replayed, err := receiptForRun(existing, false) + if err != nil { + return "", err + } + if replayed.HeadSHA != headSHA || replayed.SubmittedHeadSHA != headSHA || replayed.IntentDigest != requestDigest { + return "", fmt.Errorf("conflicting launch_nonce %q is already bound to a different branch, head, or intent", launchNonce) + } + receipt = replayed + 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) + } + 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 + } + } + runID, err := m.startRunWithIntentSourceLocked(ctx, repo, branch, headSHA, baseSHA, trigger, skipSteps, persistedIntent, db.RunIntentSourceAgent, launchNonce) + if err != nil { + return "", err + } + run, err := m.db.GetRun(runID) + if err != nil { + return "", fmt.Errorf("read created run: %w", err) + } + receipt, err = receiptForRun(run, true) + if err != nil { + return "", err + } + // A direct fresh/no-op fallback returns its receipt on this RPC, so + // consume the one created disposition now. Post-receive pushes leave + // it available for the initiating CLI's separate receipt claim. + if trigger != "push" { + if _, claimed, err := m.db.ClaimLaunchReceipt(repo.ID, branch, launchNonce); err != nil { + return "", err + } else if !claimed { + return "", fmt.Errorf("claim newly created launch receipt") + } + } + slog.Info("proof-mode launch bound", "run_id", receipt.RunID, "disposition", receipt.Disposition, "branch", receipt.Branch, "submitted_head_sha", receipt.SubmittedHeadSHA, "launch_nonce", receipt.LaunchNonce, "intent_digest", receipt.IntentDigest) + return runID, nil + }) + if err != nil { + return ipc.LaunchReceipt{}, err + } + return receipt, nil +} + +func validateLaunchNonce(nonce string) error { + if len(nonce) == 0 || len(nonce) > 128 { + return fmt.Errorf("launch_nonce must be 1 to 128 ASCII URL-safe characters") + } + for _, char := range nonce { + if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || strings.ContainsRune("._~-", char) { + continue + } + return fmt.Errorf("launch_nonce contains unsupported character %q", 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) + } + launchNonce := "" + if run.LaunchNonce != nil { + launchNonce = *run.LaunchNonce + } + intent := "" + if run.Intent != nil { + intent = *run.Intent + } + disposition := "reused" + if created { + disposition = "created" + } + return ipc.LaunchReceipt{ + RunID: run.ID, + Disposition: disposition, + LaunchNonce: launchNonce, + Branch: run.Branch, + HeadSHA: run.HeadSHA, + SubmittedHeadSHA: *run.SubmittedHeadSHA, + IntentDigest: digestIntent(intent), + }, nil } // HandleRerun creates a new run for the latest recoverable head on a branch: @@ -857,6 +1010,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 string) (string, error) { + return m.withBranchLock(repo.ID, branch, func() (string, error) { + return m.startRunWithIntentSourceLocked(ctx, repo, branch, headSHA, baseSHA, trigger, skipSteps, intent, source, "") + }) +} + +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. launchNonce is optional for ordinary launches. +func (m *RunManager) startRunWithIntentSourceLocked(ctx context.Context, repo *db.Repo, branch, headSHA, baseSHA, trigger string, skipSteps []types.StepName, intent, source, launchNonce string) (string, error) { branchRole := telemetryBranchRole(branch, repo.DefaultBranch) trackStartFailure := func(stage string) { telemetry.Track("run", telemetry.Fields{ @@ -872,14 +1042,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 @@ -895,7 +1057,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 @@ -906,7 +1068,7 @@ func (m *RunManager) startRunWithIntentSource(ctx context.Context, repo *db.Repo runIntent = &db.RunIntent{Summary: storedIntent, Source: source, Score: 1} } - run, err := m.db.InsertRunWithIntent(repo.ID, branch, headSHA, baseSHA, runIntent) + run, err := m.db.InsertRunWithIntentAndLaunchNonce(repo.ID, branch, headSHA, baseSHA, runIntent, launchNonce) 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 b425d0364..f1e37d088 100644 --- a/internal/daemon/manager_test.go +++ b/internal/daemon/manager_test.go @@ -114,6 +114,155 @@ func TestPushReceivedTracksRunTelemetry(t *testing.T) { } } +func TestProofLaunchReceiptsBindNonceIntentAndExactHead(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, 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, + }, &result) + return result, err + } + + first, err := call("nonce-1", "persist these exact bytes\n") + if err != nil { + t.Fatal(err) + } + if first.Receipt.Disposition != "created" || first.Receipt.RunID == "" || first.Receipt.LaunchNonce != "nonce-1" || + first.Receipt.Branch != "main" || first.Receipt.HeadSHA != headSHA || first.Receipt.SubmittedHeadSHA != headSHA || + first.Receipt.IntentDigest != digestIntent("persist these exact bytes\n") { + t.Fatalf("first receipt = %#v", first.Receipt) + } + run, err := d.GetRun(first.Receipt.RunID) + if err != nil || run == nil || run.LaunchNonce == nil || *run.LaunchNonce != "nonce-1" || run.Intent == nil || *run.Intent != "persist these exact bytes\n" { + t.Fatalf("persisted proof run = %#v, err=%v", run, err) + } + + replay, err := call("nonce-1", "persist these exact bytes\n") + 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) + } + if _, err := call("nonce-1", "different intent"); err == nil { + t.Fatal("conflicting intent reused a nonce") + } + other, err := call("nonce-2", "different intent") + if err != nil { + t.Fatal(err) + } + if other.Receipt.RunID == first.Receipt.RunID || other.Receipt.Disposition != "created" { + t.Fatalf("different nonce receipt = %#v, first = %#v", other.Receipt, first.Receipt) + } +} + +func TestProofLaunchRejectsMalformedNonceAndContextDrift(t *testing.T) { + p, d := startTestDaemonWithSteps(t, func() []pipeline.Step { return []pipeline.Step{&mockPassStep{name: types.StepReview}} }) + repo, headSHA := setupTestGitRepo(t, p, d, "proof-drift-repo") + client, err := ipc.Dial(p.Socket()) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + var result ipc.StartFreshRunResult + if err := client.Call(ipc.MethodStartFreshRun, &ipc.StartFreshRunParams{ + RepoID: repo.ID, Branch: "main", HeadSHA: headSHA, Intent: "goal", LaunchNonce: "bad nonce", + }, &result); err == nil { + t.Fatal("malformed nonce started a run") + } + if err := client.Call(ipc.MethodStartFreshRun, &ipc.StartFreshRunParams{ + RepoID: repo.ID, Branch: "main", HeadSHA: strings.Repeat("0", len(headSHA)), Intent: "goal", LaunchNonce: "drift-nonce", + }, &result); err == nil { + t.Fatal("drifting head started a run") + } + if run, err := d.GetRunByLaunchNonce(repo.ID, "main", "drift-nonce"); err != nil || run != nil { + t.Fatalf("drift binding = %#v, err=%v", run, err) + } +} + +func TestProofPushNonceAndConcurrentReplaysConverge(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") + + 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: "push intent", LaunchNonce: "push-nonce", + }, &pushed); err != nil { + t.Fatal(err) + } + if pushed.Receipt.Disposition != "created" || pushed.Receipt.LaunchNonce != "push-nonce" || pushed.Receipt.RunID == "" { + t.Fatalf("push receipt = %#v", pushed.Receipt) + } + + 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: "concurrent intent", LaunchNonce: "concurrent-nonce", + }, &result) + results <- result + } + errs <- err + }() + } + var runID string + created := 0 + for range callers { + if err := <-errs; err != nil { + t.Fatal(err) + } + result := <-results + if runID == "" { + runID = result.Receipt.RunID + } + if result.Receipt.RunID != runID { + t.Fatalf("concurrent receipts disagree: %q and %q", runID, result.Receipt.RunID) + } + if result.Receipt.Disposition == "created" { + created++ + } + } + if runID == "" || created != 1 { + t.Fatalf("concurrent launch id=%q created=%d", runID, created) + } + runs, err := d.GetRunsByRepoHead(repo.ID, "main", headSHA) + if err != nil { + t.Fatal(err) + } + matches := 0 + for _, run := range runs { + if run.LaunchNonce != nil && *run.LaunchNonce == "concurrent-nonce" { + matches++ + } + } + if matches != 1 { + t.Fatalf("durable concurrent nonce rows = %d", matches) + } +} + func TestPushReceivedSkipStepsConfiguresExecutor(t *testing.T) { review := &mockPassStep{name: types.StepReview} testStep := &mockPassStep{name: types.StepTest} diff --git a/internal/db/run.go b/internal/db/run.go index ed317dddd..96be94527 100644 --- a/internal/db/run.go +++ b/internal/db/run.go @@ -71,11 +71,16 @@ type Run struct { IntentSource *string IntentSessionID *string IntentScore *float64 - CreatedAt int64 - UpdatedAt int64 + // LaunchNonce is nullable for rows created before proof-mode launches. It + // binds one opaque caller request to this row; callers retrieve it only + // through the receipt-specific IPC method, never generic status output. + LaunchNonce *string + LaunchReceiptClaimedAt *int64 + CreatedAt int64 + 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, 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_receipt_claimed_at, created_at, updated_at` func scanRun(row interface { Scan(...any) error @@ -86,7 +91,7 @@ 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.Intent, &r.IntentSource, &r.IntentSessionID, &r.IntentScore, &r.LaunchNonce, &r.LaunchReceiptClaimedAt, &r.CreatedAt, &r.UpdatedAt, ) } @@ -107,6 +112,13 @@ func (d *DB) InsertRun(repoID, branch, headSHA, baseSHA string) (*Run, error) { } func (d *DB) InsertRunWithIntent(repoID, branch, headSHA, baseSHA string, intent *RunIntent) (*Run, error) { + return d.InsertRunWithIntentAndLaunchNonce(repoID, branch, headSHA, baseSHA, intent, "") +} + +// InsertRunWithIntentAndLaunchNonce persists an optional opaque launch +// binding. The unique partial index is the final duplicate defense; callers +// should still perform select-or-create under their branch lock. +func (d *DB) InsertRunWithIntentAndLaunchNonce(repoID, branch, headSHA, baseSHA string, intent *RunIntent, launchNonce string) (*Run, error) { ts := now() version := buildinfo.CurrentVersion() buildSHA := buildinfo.Commit @@ -123,6 +135,9 @@ func (d *DB) InsertRunWithIntent(repoID, branch, headSHA, baseSHA string, intent CreatedAt: ts, UpdatedAt: ts, } + if launchNonce != "" { + r.LaunchNonce = &launchNonce + } if intent != nil { r.Intent = &intent.Summary r.IntentSource = &intent.Source @@ -130,8 +145,8 @@ func (d *DB) InsertRunWithIntent(repoID, branch, headSHA, baseSHA string, intent r.IntentScore = &intent.Score } _, 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, 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.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, 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.CreatedAt, r.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("insert run: %w", err) @@ -273,6 +288,45 @@ func (d *DB) GetRun(id string) (*Run, error) { return r, nil } +// GetRunByLaunchNonce returns the one durable proof-mode 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 returns a nonce-bound run and whether this caller is the +// first to receive its receipt. The conditional update is atomic in SQLite; +// later retries retain the same run but receive a reused disposition. +func (d *DB) ClaimLaunchReceipt(repoID, branch, launchNonce string) (*Run, bool, error) { + result, err := d.sql.Exec( + `UPDATE runs SET launch_receipt_claimed_at = ? WHERE repo_id = ? AND branch = ? AND launch_nonce = ? AND launch_receipt_claimed_at IS NULL`, + now(), repoID, branch, launchNonce, + ) + if err != nil { + return nil, false, fmt.Errorf("claim launch receipt: %w", err) + } + claimed, err := result.RowsAffected() + if err != nil { + return nil, false, fmt.Errorf("claim launch receipt rows: %w", err) + } + run, err := d.GetRunByLaunchNonce(repoID, branch, launchNonce) + if err != nil { + return nil, false, err + } + return run, claimed == 1, nil +} + // 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 8bf0b570b..03e3960a3 100644 --- a/internal/db/run_test.go +++ b/internal/db/run_test.go @@ -81,6 +81,42 @@ 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 { + t.Fatalf("legacy launch nonce = %v, err = %v", got.LaunchNonce, err) + } + + intent := RunIntent{Summary: "exact persisted intent\n", Source: RunIntentSourceAgent, Score: 1} + run, err := d.InsertRunWithIntentAndLaunchNonce(repo.ID, "feature", "head", "base", &intent, "nonce-1") + if err != nil { + t.Fatal(err) + } + if run.LaunchNonce == nil || *run.LaunchNonce != "nonce-1" { + t.Fatalf("launch nonce = %v", run.LaunchNonce) + } + if _, err := d.InsertRunWithIntentAndLaunchNonce(repo.ID, "feature", "head", "base", &intent, "nonce-1"); err == nil { + t.Fatal("duplicate nonce insert succeeded") + } + + first, claimed, err := d.ClaimLaunchReceipt(repo.ID, "feature", "nonce-1") + 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") + if err != nil || claimed || replay.ID != run.ID { + t.Fatalf("replay claim = %#v, claimed=%v, err=%v", replay, claimed, err) + } +} + 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 d10be6f0c..6eaff239a 100644 --- a/internal/db/schema.go +++ b/internal/db/schema.go @@ -38,6 +38,8 @@ CREATE TABLE IF NOT EXISTS runs ( error TEXT, awaiting_agent_since INTEGER, parked_ms INTEGER, + launch_nonce TEXT, + launch_receipt_claimed_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); @@ -221,7 +223,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`, + // A launch nonce binds a caller's proof request to one durable run. It is + // nullable so historical runs remain readable; the partial unique index + // makes concurrent same-nonce launches converge even across processes. + `ALTER TABLE runs ADD COLUMN launch_nonce 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`, `ALTER TABLE step_results ADD COLUMN last_activity_at INTEGER`, + // Receipt claiming distinguishes the one first caller-visible receipt from + // idempotent replays without ever exposing intent on generic run surfaces. + `ALTER TABLE runs ADD COLUMN launch_receipt_claimed_at INTEGER`, `ALTER TABLE step_results ADD COLUMN last_activity TEXT`, `ALTER TABLE step_results ADD COLUMN agent_pid INTEGER`, `ALTER TABLE step_results ADD COLUMN auto_fix_limit INTEGER`, diff --git a/internal/git/git.go b/internal/git/git.go index 91468dbe1..9b59c4288 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -511,6 +511,13 @@ func PushCommit(ctx context.Context, dir, remote, commitSHA, ref, expectedSHA st return pushSourceWithOptions(ctx, dir, remote, commitSHA, ref, expectedSHA, forceWithLease, nil) } +// PushCommitWithOptions pushes one immutable commit with per-push options. It +// is the proof-mode variant of PushWithOptions: a concurrent local HEAD move +// cannot silently change the submitted commit after the caller captured it. +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 e1970e7ce..4a7be35b3 100644 --- a/internal/ipc/protocol.go +++ b/internal/ipc/protocol.go @@ -9,20 +9,23 @@ 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" + MethodGetLaunchReceipt = "get_launch_receipt" + 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. @@ -67,12 +70,35 @@ func (e *RPCError) Error() string { return e.Message } // intent from local transcripts. 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"` + 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"` +} + +// StartFreshRunParams requests a nonce-bound, fresh launch for one exact gate +// branch head. The daemon checks that the gate still names HeadSHA while it +// holds its repository/branch lock, so a caller never receives a proof for a +// drifting branch 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"` +} + +// GetLaunchReceiptParams resolves a receipt by its durable opaque binding. +// It is deliberately receipt-specific: generic run/status surfaces do not +// expose launch nonces or intent digests. +type GetLaunchReceiptParams struct { + RepoID string `json:"repo_id"` + Branch string `json:"branch"` + LaunchNonce string `json:"launch_nonce"` } // GetRunParams requests a single run by ID. @@ -179,9 +205,35 @@ type ShutdownParams struct{} // --- Method results --- -// PushReceivedResult confirms the push was accepted. +// PushReceivedResult confirms the push was accepted. Receipt is populated for +// every created run, while RunID remains for hook compatibility. type PushReceivedResult struct { - RunID string `json:"run_id"` + RunID string `json:"run_id"` + Receipt LaunchReceipt `json:"receipt"` +} + +// LaunchReceipt is the machine-readable, privacy-safe proof that the daemon +// selected one durable run before the caller starts driving it. IntentDigest is +// SHA-256 over the exact intent bytes persisted on the row; raw intent is never +// present in this receipt or in generic status output. +type LaunchReceipt struct { + RunID string `json:"run_id"` + Disposition string `json:"disposition"` + LaunchNonce string `json:"launch_nonce"` + Branch string `json:"branch"` + HeadSHA string `json:"head_sha"` + SubmittedHeadSHA string `json:"submitted_head_sha"` + IntentDigest string `json:"intent_digest"` +} + +// StartFreshRunResult is returned before any caller drives the run. +type StartFreshRunResult struct { + Receipt LaunchReceipt `json:"receipt"` +} + +// GetLaunchReceiptResult is empty when no durable nonce binding exists. +type GetLaunchReceiptResult struct { + Receipt *LaunchReceipt `json:"receipt,omitempty"` } // GetRunResult wraps a single run. diff --git a/internal/pipeline/steps/intent.go b/internal/pipeline/steps/intent.go index e383985ea..5a96257d8 100644 --- a/internal/pipeline/steps/intent.go +++ b/internal/pipeline/steps/intent.go @@ -55,6 +55,13 @@ func (s *IntentStep) Execute(sctx *pipeline.StepContext) (outcome *pipeline.Step if sctx != nil && sctx.Run != nil && sctx.Run.Intent != nil && strings.TrimSpace(*sctx.Run.Intent) != "" { if sctx.Log != nil { sctx.Log("using intent supplied by the agent") + if marker := firstmateValidationGeneration(*sctx.Run.Intent); marker != "" { + // This bounded correlation marker is intentionally the only + // caller-provided intent content surfaced in the step log. It + // lets Firstmate bind its validation run without exposing the + // surrounding opaque intent to logs, status, or telemetry. + sctx.Log(marker) + } } return &pipeline.StepOutcome{}, nil } @@ -158,6 +165,27 @@ func (s *IntentStep) Execute(sctx *pipeline.StepContext) (outcome *pipeline.Step return &pipeline.StepOutcome{}, nil } +const firstmateValidationGenerationPrefix = "Firstmate-Validation-Generation: " + +func firstmateValidationGeneration(intent string) string { + for _, line := range strings.Split(intent, "\n") { + if !strings.HasPrefix(line, firstmateValidationGenerationPrefix) { + continue + } + generation := strings.TrimPrefix(line, firstmateValidationGenerationPrefix) + if len(generation) != 32 { + return "" + } + for _, char := range generation { + if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f')) { + return "" + } + } + return line + } + return "" +} + // errIntentEmptyDiff is returned by defaultRunIntent when the diff between // base and head produces no files. It is reported as the "empty_diff" // telemetry outcome. diff --git a/internal/pipeline/steps/intent_test.go b/internal/pipeline/steps/intent_test.go index 640797a8a..fe3d17edf 100644 --- a/internal/pipeline/steps/intent_test.go +++ b/internal/pipeline/steps/intent_test.go @@ -310,7 +310,7 @@ func TestIntentStep_PanicReturnsSkipped(t *testing.T) { func TestIntentStep_UsesSuppliedIntent(t *testing.T) { sctx := newIntentStepContext(t) - supplied := "agent-supplied: add retry to the uploader" + supplied := "agent-supplied: add retry to the uploader\nFirstmate-Validation-Generation: 0123456789abcdef0123456789abcdef\nsecret surrounding intent" sctx.Run.Intent = &supplied var logs []string sctx.Log = func(s string) { logs = append(logs, s) } @@ -337,12 +337,22 @@ func TestIntentStep_UsesSuppliedIntent(t *testing.T) { t.Errorf("supplied intent was mutated: %v", sctx.Run.Intent) } found := false - for _, l := range logs { - if strings.Contains(l, "using intent supplied by the agent") { + marker := "Firstmate-Validation-Generation: 0123456789abcdef0123456789abcdef" + for _, line := range logs { + if strings.Contains(line, "using intent supplied by the agent") { found = true } + if line == marker { + marker = "" + } + if strings.Contains(line, "secret surrounding intent") { + t.Fatalf("intent log exposed raw intent: %q", line) + } } if !found { t.Errorf("missing supplied-intent log line; logs: %v", logs) } + if marker != "" { + t.Errorf("missing validation marker; logs: %v", logs) + } }