diff --git a/cmd/autoinit_test.go b/cmd/autoinit_test.go index efcbd50c..12bb0977 100644 --- a/cmd/autoinit_test.go +++ b/cmd/autoinit_test.go @@ -13,6 +13,7 @@ import ( // produces a recognized context file and that MaybeRun invokes it exactly once // for a fresh project, then gates on the marker thereafter. func TestAutoInitRunner_WritesContextFileOnce(t *testing.T) { + t.Setenv("HAWK_STATE_DIR", filepath.Join(t.TempDir(), "state")) root := t.TempDir() // A trivial Go file so BuildHierarchy has something to summarize. if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n\nfunc Foo() {}\n"), 0o644); err != nil { diff --git a/external/eyrie b/external/eyrie index df2c88ff..bb563fd8 160000 --- a/external/eyrie +++ b/external/eyrie @@ -1 +1 @@ -Subproject commit df2c88ffc369e725a72f621c41b903bbd81b9d55 +Subproject commit bb563fd8af477e95ab701c3208bccc9b32a0212b diff --git a/internal/autoinit/autoinit.go b/internal/autoinit/autoinit.go index 9f4a8e5a..c69ad89b 100644 --- a/internal/autoinit/autoinit.go +++ b/internal/autoinit/autoinit.go @@ -99,8 +99,8 @@ func HasRun(root string) bool { // 4. Otherwise -> run, then mark. // // The marker is written whenever a run is attempted (even on runner error) so -// a failing analysis is not retried on every invocation. MaybeRun never -// returns an error for gating decisions; it only propagates a runner error. +// a failing analysis is not retried on every invocation. Marker write failures +// are returned before running because otherwise auto-init can retry forever. func MaybeRun(ctx context.Context, opts Options) (Decision, error) { if opts.Root == "" { return Decision{Skipped: "no project root"}, nil @@ -121,13 +121,17 @@ func MaybeRun(ctx context.Context, opts Options) (Decision, error) { if !opts.Force && HasContext(opts.Root) { // Project already has context: record the marker so we never probe // again, and skip the analysis. - _ = writeMarker(opts.Root) + if err := writeMarker(opts.Root); err != nil { + return Decision{Skipped: "project already has context"}, err + } return Decision{Skipped: "project already has context"}, nil } // Write the marker before running so a crash mid-analysis does not cause a // retry storm on every subsequent invocation. - _ = writeMarker(opts.Root) + if err := writeMarker(opts.Root); err != nil { + return Decision{Skipped: "marker write failed"}, err + } if opts.Run == nil { return Decision{Skipped: "no runner configured"}, nil diff --git a/internal/autoinit/autoinit_test.go b/internal/autoinit/autoinit_test.go index 3bb7b193..60d92022 100644 --- a/internal/autoinit/autoinit_test.go +++ b/internal/autoinit/autoinit_test.go @@ -8,6 +8,7 @@ import ( ) func TestMaybeRun_RunsOnceThenSkips(t *testing.T) { + setTestStateDir(t) root := t.TempDir() calls := 0 run := func(ctx context.Context, r string) error { @@ -50,6 +51,7 @@ func TestMaybeRun_RunsOnceThenSkips(t *testing.T) { } func TestMaybeRun_SkipsWhenContextExists(t *testing.T) { + setTestStateDir(t) root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte("# x"), 0o644); err != nil { t.Fatal(err) @@ -71,6 +73,7 @@ func TestMaybeRun_SkipsWhenContextExists(t *testing.T) { } func TestMaybeRun_Disabled(t *testing.T) { + setTestStateDir(t) root := t.TempDir() calls := 0 run := func(ctx context.Context, r string) error { calls++; return nil } @@ -92,6 +95,7 @@ func TestMaybeRun_Disabled(t *testing.T) { } func TestMaybeRun_ForceIgnoresExistingContext(t *testing.T) { + setTestStateDir(t) root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "HAWK.md"), []byte("# x"), 0o644); err != nil { t.Fatal(err) @@ -109,6 +113,7 @@ func TestMaybeRun_ForceIgnoresExistingContext(t *testing.T) { } func TestMaybeRun_MarkerWrittenOnRunnerError(t *testing.T) { + setTestStateDir(t) root := t.TempDir() run := func(ctx context.Context, r string) error { return context.Canceled } @@ -125,6 +130,7 @@ func TestMaybeRun_MarkerWrittenOnRunnerError(t *testing.T) { } func TestMaybeRun_NoRunnerStillGates(t *testing.T) { + setTestStateDir(t) root := t.TempDir() dec, err := MaybeRun(context.Background(), Options{Root: root}) if err != nil { @@ -138,6 +144,31 @@ func TestMaybeRun_NoRunnerStillGates(t *testing.T) { } } +func TestMaybeRun_ReturnsMarkerWriteError(t *testing.T) { + root := t.TempDir() + stateFile := filepath.Join(t.TempDir(), "state") + if err := os.WriteFile(stateFile, []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("HAWK_STATE_DIR", stateFile) + calls := 0 + run := func(context.Context, string) error { + calls++ + return nil + } + + dec, err := MaybeRun(context.Background(), Options{Root: root, Run: run}) + if err == nil { + t.Fatal("expected marker write error") + } + if dec.Skipped != "marker write failed" { + t.Fatalf("Skipped = %q, want marker write failed", dec.Skipped) + } + if calls != 0 { + t.Fatalf("runner should not run when marker cannot be written, got %d calls", calls) + } +} + func TestHasContext(t *testing.T) { root := t.TempDir() if HasContext(root) { @@ -150,3 +181,8 @@ func TestHasContext(t *testing.T) { t.Error("dir with CLAUDE.md should have context") } } + +func setTestStateDir(t *testing.T) { + t.Helper() + t.Setenv("HAWK_STATE_DIR", filepath.Join(t.TempDir(), "state")) +} diff --git a/internal/engine/provider_chat_client.go b/internal/engine/provider_chat_client.go index 70ec6093..3597c487 100644 --- a/internal/engine/provider_chat_client.go +++ b/internal/engine/provider_chat_client.go @@ -2,6 +2,7 @@ package engine import ( "context" + "errors" "github.com/GrayCodeAI/hawk/internal/types" ) @@ -16,14 +17,43 @@ func NewProviderChatClient(p types.ChatProvider) ChatClient { return &providerChatClient{p: p} } +// NewUnavailableChatClient preserves Session construction while surfacing +// Eyrie transport setup failures at the first chat call. +func NewUnavailableChatClient(err error) ChatClient { + if err == nil { + err = errors.New("hawk: chat transport unavailable") + } + return &unavailableChatClient{err: err} +} + func (w *providerChatClient) Chat(ctx context.Context, messages []types.EyrieMessage, opts types.ChatOptions) (*types.EyrieResponse, error) { + if w == nil || w.p == nil { + return nil, errors.New("hawk: chat provider unavailable") + } return w.p.Chat(ctx, messages, opts) } func (w *providerChatClient) StreamChatContinue(ctx context.Context, messages []types.EyrieMessage, opts types.ChatOptions, cfg types.ContinuationConfig) (*types.StreamResult, error) { + if w == nil || w.p == nil { + return nil, errors.New("hawk: chat provider unavailable") + } return types.StreamChatWithContinuation(ctx, w.p, messages, opts, cfg) } func (w *providerChatClient) SetAPIKey(_, _ string) { // Credentials live on concrete adapters inside DeploymentRouter; Session env keys are unused here. } + +type unavailableChatClient struct { + err error +} + +func (c *unavailableChatClient) Chat(context.Context, []types.EyrieMessage, types.ChatOptions) (*types.EyrieResponse, error) { + return nil, c.err +} + +func (c *unavailableChatClient) StreamChatContinue(context.Context, []types.EyrieMessage, types.ChatOptions, types.ContinuationConfig) (*types.StreamResult, error) { + return nil, c.err +} + +func (c *unavailableChatClient) SetAPIKey(_, _ string) {} diff --git a/internal/engine/session_factory.go b/internal/engine/session_factory.go index afc19013..dd9cbe71 100644 --- a/internal/engine/session_factory.go +++ b/internal/engine/session_factory.go @@ -3,6 +3,7 @@ package engine import ( "context" "errors" + "fmt" "strings" "github.com/GrayCodeAI/eyrie/runtime" @@ -14,7 +15,7 @@ import ( func transportBoolPtr(v bool) *bool { return &v } // BuildChatClient returns an LLM client and whether deployment routing is active. -func BuildChatClient(ctx context.Context, selection runtime.SelectionState, legacyProvider string) (ChatClient, string, bool) { +func BuildChatClient(ctx context.Context, selection runtime.SelectionState, legacyProvider string) (ChatClient, string, bool, error) { provider := strings.TrimSpace(selection.Provider) if provider == "" { provider = legacyProvider @@ -33,16 +34,20 @@ func BuildChatClient(ctx context.Context, selection runtime.SelectionState, lega if label == "" { label = provider } - return NewProviderChatClient(types.WrapClientProvider(transport.Provider)), label, transport.Selection.DeploymentRouting + return NewProviderChatClient(types.WrapClientProvider(transport.Provider)), label, transport.Selection.DeploymentRouting, nil } - return NewProviderChatClient(types.NewLazyChatProvider(&types.ClientConfig{ - Provider: provider, - })), provider, false + if err != nil { + return nil, provider, false, fmt.Errorf("eyrie transport: %w", err) + } + return nil, provider, false, fmt.Errorf("eyrie transport: no provider resolved for %q", provider) } // NewHawkSession constructs a Session using an engine-resolved selection. func NewHawkSession(ctx context.Context, selection runtime.SelectionState, provider, model, systemPrompt string, registry *tool.Registry) *Session { - chat, label, deploy := BuildChatClient(ctx, selection, provider) + chat, label, deploy, err := BuildChatClient(ctx, selection, provider) + if err != nil { + chat = NewUnavailableChatClient(err) + } resolvedModel := strings.TrimSpace(selection.Model) if resolvedModel == "" { resolvedModel = model @@ -55,7 +60,10 @@ func RebuildSessionTransport(ctx context.Context, s *Session, selection runtime. if s == nil { return errors.New("session is nil") } - chat, label, deploy := BuildChatClient(ctx, selection, legacyProvider) + chat, label, deploy, err := BuildChatClient(ctx, selection, legacyProvider) + if err != nil { + return err + } s.ReattachTransport(chat, label, deploy) return nil }