diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 56a3947..844e506 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -89,7 +89,13 @@ This stage alone does not enable delivery and is not a finished feature. ## Stage 2 — Forward Cache Intent and Apply Force Precedence -Status: Not started +Status: Complete (2026-09-16) + +Completion: Carried typed cache intent through extract requests, applied force +precedence in stage orchestration, mapped validated effective values to exact +Notarius flags, and confirmed both controls remain outside semantic resume +evidence. Verified normal and race tests for `./internal/stage` and +`./internal/adapters/notarius`. Read: `internal/stage/extract.go`, `stage.go`, `extract_test.go`, `semantic_contracts_delivery.go`, `semantic_contracts_delivery_test.go`; diff --git a/internal/adapters/notarius/runner.go b/internal/adapters/notarius/runner.go index 8090c33..0d1785d 100644 --- a/internal/adapters/notarius/runner.go +++ b/internal/adapters/notarius/runner.go @@ -35,6 +35,8 @@ type RunRequest struct { ReceiptPath string LogPath string Timeout time.Duration + Resume bool + ChunkCache string References []ReferenceBinding } diff --git a/internal/adapters/notarius/subprocess.go b/internal/adapters/notarius/subprocess.go index c449774..ff0e69d 100644 --- a/internal/adapters/notarius/subprocess.go +++ b/internal/adapters/notarius/subprocess.go @@ -73,6 +73,12 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult, "--input", req.InputPath, "--output-dir", req.OutputRoot, } + if req.Resume { + args = append(args, "--resume") + } + if req.ChunkCache != "" { + args = append(args, "--chunk_cache", req.ChunkCache) + } for _, reference := range references { args = append(args, "--reference", reference.Selector+"="+reference.Path) } @@ -159,6 +165,9 @@ func validateRunRequest(req RunRequest) ([]ReferenceBinding, error) { if req.Timeout <= 0 { return nil, fmt.Errorf("notarius timeout must be positive") } + if !validChunkCacheMode(req.ChunkCache) { + return nil, fmt.Errorf("notarius chunk cache mode must be empty, auto, bypass, or refresh") + } for label, path := range map[string]string{ "config": req.ConfigPath, "input": req.InputPath, @@ -217,6 +226,15 @@ func validateRunRequest(req RunRequest) ([]ReferenceBinding, error) { return references, nil } +func validChunkCacheMode(mode string) bool { + switch mode { + case "", "auto", "bypass", "refresh": + return true + default: + return false + } +} + type receiptDocument struct { SchemaVersion string `json:"schema_version"` RunID string `json:"run_id"` diff --git a/internal/adapters/notarius/subprocess_test.go b/internal/adapters/notarius/subprocess_test.go index e1639d2..f1ff8fb 100644 --- a/internal/adapters/notarius/subprocess_test.go +++ b/internal/adapters/notarius/subprocess_test.go @@ -77,6 +77,68 @@ func TestSubprocessRunnerBuildsExactInvocationAndDiscoversBundle(t *testing.T) { } } +func TestSubprocessRunnerMapsCacheControlsToArguments(t *testing.T) { + tests := []struct { + name string + resume bool + chunkCache string + want []string + }{ + {name: "resume", resume: true, want: []string{"--resume"}}, + {name: "auto", chunkCache: "auto", want: []string{"--chunk_cache", "auto"}}, + {name: "bypass", chunkCache: "bypass", want: []string{"--chunk_cache", "bypass"}}, + {name: "refresh", chunkCache: "refresh", want: []string{"--chunk_cache", "refresh"}}, + {name: "resume and mode", resume: true, chunkCache: "auto", want: []string{"--resume", "--chunk_cache", "auto"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + req := validRunRequest(t) + req.Resume = test.resume + req.ChunkCache = test.chunkCache + var captured sharedsubprocess.RunRequest + runner := &SubprocessRunner{run: func(_ context.Context, processReq sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) { + captured = processReq + writeValidBundleAndReceipt(t, req, false) + return sharedsubprocess.RunResult{ExitCode: 0}, nil + }} + + if _, err := runner.Run(context.Background(), req); err != nil { + t.Fatalf("Run() error = %v", err) + } + want := []string{ + "run", req.PipelineID, "--config", req.ConfigPath, "--input", req.InputPath, + "--output-dir", req.OutputRoot, + } + want = append(want, test.want...) + want = append(want, "--json") + if !reflect.DeepEqual(captured.Args, want) { + t.Fatalf("subprocess args = %#v, want %#v", captured.Args, want) + } + }) + } +} + +func TestSubprocessRunnerRejectsInvalidChunkCacheBeforeLaunch(t *testing.T) { + for _, mode := range []string{"AUTO", "invalid", " auto "} { + t.Run(mode, func(t *testing.T) { + req := validRunRequest(t) + req.ChunkCache = mode + started := false + runner := &SubprocessRunner{run: func(context.Context, sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) { + started = true + return sharedsubprocess.RunResult{}, nil + }} + _, err := runner.Run(context.Background(), req) + if err == nil || !strings.Contains(err.Error(), "chunk cache mode") { + t.Fatalf("Run() error = %v, want chunk cache validation failure", err) + } + if started { + t.Fatal("subprocess started with invalid chunk cache mode") + } + }) + } +} + func TestSubprocessRunnerForwardsLoadedCredentialWithoutRereadingEnvironment(t *testing.T) { const envName = "NARRATIO_NOTARIUS_CUSTOM_LLM_KEY" const loadedValue = "loaded-notarius-credential" diff --git a/internal/stage/extract.go b/internal/stage/extract.go index a88a088..f7df307 100644 --- a/internal/stage/extract.go +++ b/internal/stage/extract.go @@ -146,10 +146,16 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (re if err != nil { return nil, fmt.Errorf("extract: build configuration fingerprint: %w", err) } + resume := notariusConfig.Resume + chunkCache := notariusConfig.ChunkCache + if env.Force { + resume = false + chunkCache = "refresh" + } request := notarius.RunRequest{ Binary: resolvedBinary, LLMAPIKey: notariusConfig.LLMAPIKey, ConfigPath: configPath, PipelineID: notariusConfig.PipelineID, InputPath: inputPath, OutputRoot: outputRoot, WorkingDirectory: workingDirectory, - ReceiptPath: receiptPath, LogPath: logPath, Timeout: timeout, References: referenceBindings, + ReceiptPath: receiptPath, LogPath: logPath, Timeout: timeout, Resume: resume, ChunkCache: chunkCache, References: referenceBindings, } stageSucceeded := false bundleMoved := false diff --git a/internal/stage/extract_test.go b/internal/stage/extract_test.go index 012c56b..b83ea6d 100644 --- a/internal/stage/extract_test.go +++ b/internal/stage/extract_test.go @@ -96,6 +96,9 @@ notarius: if len(req.References) != 0 { t.Fatalf("references = %#v, want none", req.References) } + if req.Resume || req.ChunkCache != "" { + t.Fatalf("default cache controls = resume %v, mode %q", req.Resume, req.ChunkCache) + } if req.LLMAPIKey != loadedPipeline.Notarius.LLMAPIKey || req.LLMAPIKey.EnvName() != credentialName || !req.LLMAPIKey.IsResolved() { t.Fatal("request did not retain the loaded Notarius credential reference") } @@ -114,6 +117,41 @@ notarius: } } +func TestExtractStageAppliesNotariusCacheControlsAndForcePrecedence(t *testing.T) { + tests := []struct { + name string + configuredResume bool + configuredMode string + force bool + wantResume bool + wantMode string + }{ + {name: "ordinary defaults"}, + {name: "ordinary configured", configuredResume: true, configuredMode: "bypass", wantResume: true, wantMode: "bypass"}, + {name: "force overrides configured", configuredResume: true, configuredMode: "bypass", force: true, wantMode: "refresh"}, + {name: "force overrides defaults", force: true, wantMode: "refresh"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + env, m, fake := setupExtractEnv(t) + env.Config.Pipeline.Notarius.Resume = test.configuredResume + env.Config.Pipeline.Notarius.ChunkCache = test.configuredMode + env.Force = test.force + + if _, err := (extractStage{}).Run(context.Background(), env, m); err != nil { + t.Fatalf("Run() error = %v", err) + } + if len(fake.Requests) != 1 { + t.Fatalf("requests = %d, want 1", len(fake.Requests)) + } + request := fake.Requests[0] + if request.Resume != test.wantResume || request.ChunkCache != test.wantMode { + t.Fatalf("cache controls = resume %v, mode %q; want resume %v, mode %q", request.Resume, request.ChunkCache, test.wantResume, test.wantMode) + } + }) + } +} + func TestExtractStageResolvesAllPreparedReferencesInSelectorOrder(t *testing.T) { env, m, fake := setupExtractEnv(t) configured := []struct { diff --git a/internal/stage/semantic_contracts_delivery_test.go b/internal/stage/semantic_contracts_delivery_test.go index f78b254..a04d12b 100644 --- a/internal/stage/semantic_contracts_delivery_test.go +++ b/internal/stage/semantic_contracts_delivery_test.go @@ -76,6 +76,26 @@ func TestExtractSemanticConfigSensitivityAndOperationalExclusions(t *testing.T) if !want.Equal(got) { t.Fatalf("operational settings changed fingerprint: got %#v want %#v", got, want) } + + for _, test := range []struct { + name string + mutate func(*config.NotariusConfig) + }{ + {name: "resume", mutate: func(cfg *config.NotariusConfig) { cfg.Resume = true }}, + {name: "chunk cache", mutate: func(cfg *config.NotariusConfig) { cfg.ChunkCache = "refresh" }}, + } { + t.Run("operational "+test.name, func(t *testing.T) { + env := downstreamSemanticEnv() + test.mutate(env.Config.Pipeline.Notarius) + got, err := (extractStage{}).SemanticConfigFingerprint(env) + if err != nil { + t.Fatal(err) + } + if !want.Equal(got) { + t.Fatalf("%s changed semantic fingerprint: got %#v want %#v", test.name, got, want) + } + }) + } } func TestExtractResumeValidationExcludesOperationalSettings(t *testing.T) { @@ -85,6 +105,8 @@ func TestExtractResumeValidationExcludesOperationalSettings(t *testing.T) { env.Config.Pipeline.Notarius.ConfigPath = "/not/present/private.yml" env.Config.Pipeline.Notarius.Timeout = "not-a-duration" env.Config.Pipeline.Notarius.WorkingDirectory = "/not/present/work" + env.Config.Pipeline.Notarius.Resume = true + env.Config.Pipeline.Notarius.ChunkCache = "bypass" validation, err := (extractStage{}).ValidateResume(context.Background(), env, m) if err != nil {