summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-07 09:15:08 +0300
committerPaul Buetow <paul@buetow.org>2026-04-07 09:15:08 +0300
commit695b0b5c3572494c98c45fdacd74d777ab37d36e (patch)
treeaa8fafc57998d30d2d03e87aca216ac00896508d
parent4185a422395bfe9d40c6f934fd56663d223bf782 (diff)
fix: recover gracefully from corrupted alias cache instead of hard-failingv0.29.1
When the task alias cache file contains invalid JSON (e.g. from a concurrent write race producing two concatenated JSON objects), the previous code returned a hard error that blocked all `ask` subcommands. Now loadTaskAliasCache discards the corrupt file and starts fresh, assigning new alias IDs on the next run. Validation errors (e.g. next_id reuse) still surface as errors since those indicate a logic bug. Also fix stale v1 reference in integration test aliasCachePath. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--integrationtests/ask_test.go2
-rw-r--r--internal/askcli/command_complete_uuids_test.go21
-rw-r--r--internal/askcli/task_alias_cache.go6
-rw-r--r--internal/askcli/task_alias_cache_test.go18
-rw-r--r--internal/version.go2
5 files changed, 37 insertions, 12 deletions
diff --git a/integrationtests/ask_test.go b/integrationtests/ask_test.go
index 0ce36fe..0ebdb01 100644
--- a/integrationtests/ask_test.go
+++ b/integrationtests/ask_test.go
@@ -272,7 +272,7 @@ func mustTaskAlias(t *testing.T, ctx context.Context, uuid string) string {
func aliasCachePath(t *testing.T, cacheRoot string) string {
t.Helper()
- return filepath.Join(cacheRoot, "hexai", "ask", "task-aliases-v1.json")
+ return filepath.Join(cacheRoot, "hexai", "ask", "task-aliases-v2.json")
}
// cleanupOrphanedIntegrationTasks deletes any tasks with the +integrationtest
diff --git a/internal/askcli/command_complete_uuids_test.go b/internal/askcli/command_complete_uuids_test.go
index 442e0a8..92bf244 100644
--- a/internal/askcli/command_complete_uuids_test.go
+++ b/internal/askcli/command_complete_uuids_test.go
@@ -80,7 +80,7 @@ func TestHandleCompleteUUIDs_ParseError(t *testing.T) {
}
}
-func TestHandleCompleteUUIDs_WarnsOnInvalidAliasCache(t *testing.T) {
+func TestHandleCompleteUUIDs_RecoverFromCorruptAliasCache(t *testing.T) {
dir := t.TempDir()
oldRoot := taskAliasCacheRoot
taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
@@ -93,6 +93,9 @@ func TestHandleCompleteUUIDs_WarnsOnInvalidAliasCache(t *testing.T) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
+ // Simulate a corrupted cache file (e.g. two JSON objects concatenated from a
+ // concurrent write race). The handler must recover by resetting the cache and
+ // assigning fresh aliases rather than erroring or degrading to UUID-only output.
if err := os.WriteFile(path, []byte("{bad"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
@@ -110,12 +113,18 @@ func TestHandleCompleteUUIDs_WarnsOnInvalidAliasCache(t *testing.T) {
if code != 0 {
t.Fatalf("handleCompleteUUIDs code = %d, want 0", code)
}
- // When alias cache is unavailable, output UUID with description (tab-separated).
- if got := stdout.String(); got != "uuid-1\tFallback task\n" {
- t.Fatalf("stdout = %q, want UUID-only fallback list with description", got)
+ // After recovery a fresh alias (e.g. "0") must be assigned, so the output
+ // includes both the short alias and the UUID (fish shows whichever the user
+ // types). No warning should appear on stderr.
+ got := stdout.String()
+ if !strings.Contains(got, "uuid-1\tFallback task") {
+ t.Fatalf("stdout = %q, want UUID with description", got)
}
- if !strings.Contains(stderr.String(), "failed to update task alias cache") {
- t.Fatalf("stderr = %q, want cache warning", stderr.String())
+ if !strings.Contains(got, "Fallback task") {
+ t.Fatalf("stdout = %q, want task description in output", got)
+ }
+ if stderr.Len() != 0 {
+ t.Fatalf("stderr = %q, want no warnings after graceful recovery", stderr.String())
}
}
diff --git a/internal/askcli/task_alias_cache.go b/internal/askcli/task_alias_cache.go
index 21967d8..e89dbb5 100644
--- a/internal/askcli/task_alias_cache.go
+++ b/internal/askcli/task_alias_cache.go
@@ -91,7 +91,11 @@ func loadTaskAliasCache() (taskAliasCache, string, error) {
var cache taskAliasCache
if err := json.Unmarshal(data, &cache); err != nil {
- return taskAliasCache{}, "", fmt.Errorf("parse task alias cache: %w", err)
+ // Cache file is unreadable (e.g. truncated write or duplicate-write
+ // corruption). Discard and start fresh — tasks will get new alias IDs on
+ // the next run, which is preferable to a hard failure.
+ _ = os.Remove(path)
+ return taskAliasCache{}, path, nil
}
if err := cache.validate(); err != nil {
return taskAliasCache{}, "", fmt.Errorf("validate task alias cache: %w", err)
diff --git a/internal/askcli/task_alias_cache_test.go b/internal/askcli/task_alias_cache_test.go
index 92d528b..f93a762 100644
--- a/internal/askcli/task_alias_cache_test.go
+++ b/internal/askcli/task_alias_cache_test.go
@@ -230,7 +230,7 @@ func TestEnsureTaskAliases_DoesNotPruneEntriesAt120DayBoundary(t *testing.T) {
}
}
-func TestEnsureTaskAliases_InvalidCacheReturnsError(t *testing.T) {
+func TestEnsureTaskAliases_CorruptedCacheIsResetGracefully(t *testing.T) {
dir := t.TempDir()
oldRoot := taskAliasCacheRoot
@@ -244,12 +244,24 @@ func TestEnsureTaskAliases_InvalidCacheReturnsError(t *testing.T) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
+ // Write a corrupted cache (e.g. two JSON objects concatenated due to a
+ // concurrent write race). The function must recover gracefully by resetting
+ // the cache instead of returning an error.
if err := os.WriteFile(path, []byte("{not-json"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
- if _, err := ensureTaskAliases([]TaskExport{{UUID: "uuid-1"}}); err == nil {
- t.Fatal("expected error for invalid cache file")
+ aliases, err := ensureTaskAliases([]TaskExport{{UUID: "uuid-1"}})
+ if err != nil {
+ t.Fatalf("expected graceful recovery from corrupted cache, got error: %v", err)
+ }
+ // A fresh alias should have been assigned after the corrupt file was discarded.
+ if aliases["uuid-1"] == "" {
+ t.Fatal("expected alias to be assigned after cache reset")
+ }
+ // The corrupt file should have been removed and replaced with a valid one.
+ if _, statErr := os.Stat(path); os.IsNotExist(statErr) {
+ t.Fatal("expected a new cache file to be written after reset")
}
}
diff --git a/internal/version.go b/internal/version.go
index f2804f2..56cd339 100644
--- a/internal/version.go
+++ b/internal/version.go
@@ -1,4 +1,4 @@
// Package internal provides the Hexai semantic version identifier used by CLI and LSP binaries.
package internal
-const Version = "0.29.0"
+const Version = "0.29.1"