Skip to content

Commit 073eead

Browse files
authored
fix: batch-2 bug sweep — scheduler DST wedge, stale 429 masking, export route alias, rune-safe redaction, cutoff parity (#171)
* fix(schedule): Next() infinite loop across DST fall-back The hour-jump advanced via time.Date(...).Add(time.Hour). time.Date resolves an ambiguous wall time to its FIRST occurrence, so across a DST fall-back transition the hop could land back on the repeated wall hour and stop advancing entirely when that hour is not in the hour mask — an infinite loop that wedged the scheduler daemon and every schedule add/list/next invocation. When the jump makes no progress, fall back to plain duration arithmetic, which crosses the transition by construction. RED-first regression test: TestNext_DstFallBackRepeatedHourNotInMask (observed hanging 5s before the fix; hermetic via time/tzdata). * fix(llm): stale 429 state no longer masks the final failure lastStatus/lastBody were set on non-200 responses and never cleared, so a 429 early in the retry loop wrapped a LATER different failure in RateLimitError on exhaustion — the exact type the serve turn handler reads as 'provider throttled' (dead-prompt handling). A final malformed-200 (buffered) or streaming failure after an earlier 429 now reports its real cause. Fixed on both the buffered and streaming paths. RED-first regression test: TestClient_Call_Stale429DoesNotMaskMalformed200. * fix(subagents): redactGoal truncation is rune-safe redactGoal sliced by bytes while the constant promises chars: a multi-byte rune at the boundary was split, corrupting the goal text with invalid UTF-8 exactly when the clamp engaged (long goals are the normal case for real tasks). Now truncates on a rune boundary. RED-first regression test: TestRedactGoal_TruncationIsRuneSafe. * fix(serve): /export suffix stripped for GET only handleSessionByID stripped the /export suffix for ALL methods while only GET dispatches to the export handler — so DELETE /api/sessions/{id}/export fell through to the base-session delete (destroying the session through a documented read-only route) and POST .../export renamed it. Mirrors the GET-only /plan guard, which exists for exactly this reason. RED-first regression test: TestSessionExportSuffix_NotAliasedForMutatingMethods. * fix(cleanup): dry-run cutoffs share the sweep's DaysAgo math The sweep computes day-based retention cutoffs with duration arithmetic (N*24h) to avoid DST-sensitive calendar math; the dry-run preview used time.AddDate, so after a DST transition the previewed deletion set diverged from the sweep's by up to an hour of files. The helper is now exported (maintenance.DaysAgo) and shared, making preview/sweep divergence impossible by construction. RED observation: the regression test referenced the not-yet-existing maintenance.DaysAgo (capability-absent compile RED), then passed.
1 parent d18cf93 commit 073eead

12 files changed

Lines changed: 269 additions & 8 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package main
2+
3+
// Bug-sweep batch 2 — B7 cutoff-parity regression test.
4+
//
5+
// The real sweep computes day-based cutoffs with duration arithmetic
6+
// (maintenance daysAgo: now - N*24h), explicitly to avoid DST-sensitive
7+
// calendar arithmetic. The dry-run preview used time.AddDate, so after a
8+
// DST transition the previewed deletion set diverged from the sweep's by
9+
// up to an hour of files. The helper is now exported and shared, making
10+
// the divergence impossible by construction.
11+
//
12+
// RED observation: this file failed to compile before the fix —
13+
// maintenance.DaysAgo did not exist (capability-absent RED, same class as
14+
// the dry-run artifacts test in batch 1).
15+
16+
import (
17+
"testing"
18+
"time"
19+
20+
"github.com/BackendStack21/odek/internal/maintenance"
21+
)
22+
23+
func TestDaysAgo_MatchesSweepDurationMath(t *testing.T) {
24+
now := time.Date(2026, 9, 1, 6, 0, 0, 0, time.UTC)
25+
want := now.Add(-3 * 24 * time.Hour)
26+
if got := maintenance.DaysAgo(now, 3); !got.Equal(want) {
27+
t.Fatalf("DaysAgo(now, 3) = %v, want %v (pure duration arithmetic)", got, want)
28+
}
29+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package main
2+
3+
// Bug-sweep batch 2 — /export route alias regression test.
4+
//
5+
// RED-first: handleSessionByID stripped the /export suffix for ALL methods
6+
// while only GET dispatches to the export handler. DELETE /api/sessions/{id}/export
7+
// therefore deleted the session and POST .../export renamed it — destructive
8+
// aliases through a documented read-only route. (The sibling /plan guard is
9+
// GET-only for exactly this reason.)
10+
11+
import (
12+
"net/http"
13+
"net/http/httptest"
14+
"strings"
15+
"testing"
16+
17+
"github.com/BackendStack21/odek/internal/llm"
18+
)
19+
20+
func TestSessionExportSuffix_NotAliasedForMutatingMethods(t *testing.T) {
21+
store := newTestSessionStore(t)
22+
23+
sess, err := store.Create([]llm.Message{
24+
{Role: "user", Content: "hello"},
25+
}, "test-model", "greeting task")
26+
if err != nil {
27+
t.Fatalf("Create session: %v", err)
28+
}
29+
30+
handler := handleSessionByID(store, nil, "")
31+
32+
// DELETE through the export URL must NOT delete the session.
33+
w := httptest.NewRecorder()
34+
req := httptest.NewRequest(http.MethodDelete, "/api/sessions/"+sess.ID+"/export", nil)
35+
req.Header.Set("X-Session-Token", sess.AuthToken)
36+
handler(w, req)
37+
if w.Code == http.StatusNoContent {
38+
t.Fatalf("DELETE /export fell through to base-session delete (status 204) — destructive alias")
39+
}
40+
if _, err := store.Load(sess.ID); err != nil {
41+
t.Fatalf("session %s was deleted through the /export URL alias: %v", sess.ID, err)
42+
}
43+
44+
// POST through the export URL must NOT rename the session.
45+
body := strings.NewReader(`{"name":"renamed-via-export"}`)
46+
w2 := httptest.NewRecorder()
47+
req2 := httptest.NewRequest(http.MethodPost, "/api/sessions/"+sess.ID+"/export", body)
48+
req2.Header.Set("X-Session-Token", sess.AuthToken)
49+
handler(w2, req2)
50+
if w2.Code == http.StatusOK {
51+
t.Fatalf("POST /export fell through to session rename (status 200) — destructive alias")
52+
}
53+
after, err := store.Load(sess.ID)
54+
if err != nil {
55+
t.Fatalf("session %s missing after POST /export: %v", sess.ID, err)
56+
}
57+
if after.Task == "renamed-via-export" {
58+
t.Fatalf("session renamed through the /export URL alias")
59+
}
60+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package main
2+
3+
// Bug-sweep batch 2 (fix/bug-hunt-b2) — B6 regression test.
4+
//
5+
// RED-first: redactGoal truncated by BYTES (goal[:2048]) while the constant
6+
// is named GoalChars — a multi-byte rune at the boundary was split,
7+
// corrupting the goal text with an invalid UTF-8 sequence exactly when the
8+
// clamp engaged (long goals are the normal case for real tasks).
9+
10+
import (
11+
"strings"
12+
"testing"
13+
"unicode/utf8"
14+
)
15+
16+
func TestRedactGoal_TruncationIsRuneSafe(t *testing.T) {
17+
// 2041 ASCII runes followed by multi-byte runes straddling byte 2048:
18+
// each 🚀 is 4 bytes, so byte-index 2048 lands INSIDE the second
19+
// rocket (bytes 2045-2048), splitting it mid-rune.
20+
goal := strings.Repeat("a", 2041) + strings.Repeat("🚀", 100)
21+
22+
got := redactGoal(goal)
23+
24+
if !utf8.ValidString(got) {
25+
t.Fatalf("redactGoal produced invalid UTF-8 (byte-sliced a multi-byte rune)")
26+
}
27+
if n := utf8.RuneCountInString(got); n > maxSubagentRegistryGoalChars {
28+
t.Fatalf("redactGoal returned %d runes, want <= %d", n, maxSubagentRegistryGoalChars)
29+
}
30+
if !strings.HasPrefix(got, strings.Repeat("a", 2040)) {
31+
t.Fatalf("redactGoal mangled the ASCII prefix")
32+
}
33+
}

‎cmd/odek/cleanup.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,14 +135,14 @@ func collectCleanupCandidates(home string, cfg maintenance.Config) cleanupCandid
135135
var c cleanupCandidates
136136

137137
if cfg.SessionsMaxAgeDays > 0 {
138-
c.sessions = sessionCandidates(home, now.AddDate(0, 0, -cfg.SessionsMaxAgeDays))
138+
c.sessions = sessionCandidates(home, maintenance.DaysAgo(now, cfg.SessionsMaxAgeDays))
139139
}
140140
if cfg.AuditMaxAgeDays > 0 {
141-
c.audit = filesOlderThan(filepath.Join(home, "sessions", "audit"), now.AddDate(0, 0, -cfg.AuditMaxAgeDays), false)
141+
c.audit = filesOlderThan(filepath.Join(home, "sessions", "audit"), maintenance.DaysAgo(now, cfg.AuditMaxAgeDays), false)
142142
}
143143
if cfg.PlansMaxAgeDays > 0 {
144144
// Plans may be nested per chat (plans/chat<id>/), so walk recursively.
145-
c.plans = filesOlderThan(filepath.Join(home, "plans"), now.AddDate(0, 0, -cfg.PlansMaxAgeDays), true)
145+
c.plans = filesOlderThan(filepath.Join(home, "plans"), maintenance.DaysAgo(now, cfg.PlansMaxAgeDays), true)
146146
}
147147
if cfg.ArtifactsMaxAgeHours > 0 {
148148
// Duration-based cutoff, mirroring sweepArtifacts exactly.

‎cmd/odek/serve.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2411,8 +2411,13 @@ func handleSessionByID(store *session.Store, trustedProxies []string, wsToken st
24112411
id := strings.TrimPrefix(r.URL.Path, "/api/sessions/")
24122412
// /api/sessions/{id}/export — transcript download (md|json). Shares
24132413
// the GET auth path below (rate limit + session token).
2414+
// /export is a GET-only surface: the suffix is stripped for GET
2415+
// requests only, mirroring the /plan guard below. Stripping it for
2416+
// every method let DELETE /api/sessions/{id}/export fall through to
2417+
// the base-session delete (destroying the session through a
2418+
// read-only route) and POST .../export rename it.
24142419
exportFormat := ""
2415-
if strings.HasSuffix(id, "/export") {
2420+
if r.Method == http.MethodGet && strings.HasSuffix(id, "/export") {
24162421
id = strings.TrimSuffix(id, "/export")
24172422
exportFormat = r.URL.Query().Get("format")
24182423
}

‎cmd/odek/subagent_registry.go‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,8 +358,12 @@ func newSubagentTelemetryRelay(send func(v any) error, runKey string) func(taskI
358358
// but model-controlled text — same treatment as the log relay).
359359
func redactGoal(goal string) string {
360360
goal = redact.RedactSecrets(goal)
361-
if len(goal) > maxSubagentRegistryGoalChars {
362-
goal = goal[:maxSubagentRegistryGoalChars]
361+
// Rune-safe truncation: the constant promises chars, and a byte slice
362+
// at the boundary split multi-byte runes, corrupting the goal text with
363+
// invalid UTF-8 exactly when the clamp engaged (long goals are the
364+
// normal case for real tasks).
365+
if r := []rune(goal); len(r) > maxSubagentRegistryGoalChars {
366+
goal = string(r[:maxSubagentRegistryGoalChars])
363367
}
364368
return goal
365369
}

‎internal/llm/client.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,12 @@ func (c *Client) postChatWithRetry(ctx context.Context, reqBytes []byte) ([]byte
618618
// A 200 with an unparseable body or zero choices is often a transient
619619
// gateway/proxy artifact during an incident — retry it through the
620620
// same budget instead of aborting the turn on the first bad body.
621+
// A 200 response resets the stale-status window: lastStatus/
622+
// lastBody classify the error of the FINAL attempt, and a 429
623+
// earlier in the loop must not mask a malformed-200 exhaustion —
624+
// serve reads RateLimitError as "provider throttled".
625+
lastStatus = http.StatusOK
626+
lastBody = ""
621627
if err := validateCompletionBody(respBytes); err != nil {
622628
lastErr = err
623629
continue
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package llm
2+
3+
// Bug-sweep batch 2 — stale retry state regression test.
4+
//
5+
// RED-first: lastStatus/lastBody were set on non-200 responses and never
6+
// cleared, so a 429 early in the retry loop masked the REAL final failure:
7+
// a later malformed-200 exhaustion was wrapped in RateLimitError — the
8+
// exact type the serve turn handler reads as "provider throttled".
9+
10+
import (
11+
"context"
12+
"errors"
13+
"net/http"
14+
"net/http/httptest"
15+
"testing"
16+
)
17+
18+
func TestClient_Call_Stale429DoesNotMaskMalformed200(t *testing.T) {
19+
stubRetrySleep(t) // full retry budget without real backoff sleeps
20+
21+
n := 0
22+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
23+
n++
24+
if n == 1 {
25+
w.WriteHeader(http.StatusTooManyRequests)
26+
w.Write([]byte(`{"error":"rate limited"}`))
27+
return
28+
}
29+
// 200 with a body that fails completion-body validation: the final
30+
// failure is NOT a rate limit.
31+
w.Header().Set("Content-Type", "application/json")
32+
w.Write([]byte(`{"unexpected":true}`))
33+
}))
34+
defer server.Close()
35+
36+
c := New(server.URL, "sk-test", "test-model", "", 0, 0)
37+
_, err := c.Call(context.Background(), []Message{{Role: "user", Content: "hi"}}, nil, nil)
38+
if err == nil {
39+
t.Fatal("expected an error (malformed completion body)")
40+
}
41+
var rle *RateLimitError
42+
if errors.As(err, &rle) {
43+
t.Fatalf("final malformed-200 exhaustion misreported as RateLimitError (stale 429 state): %v", err)
44+
}
45+
}

‎internal/llm/stream.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,10 @@ func (c *Client) postChatStream(ctx context.Context, reqBytes []byte, cb func(De
246246
return nil, false, lastErr
247247
}
248248

249+
// 200 resets the stale-status window (see buffered Call): a 429
250+
// earlier in the retry loop must not mask a streaming failure.
251+
lastStatus = http.StatusOK
252+
lastBody = ""
249253
res, emitted, err := readSSE(ctx, reqCtx, cancelReq, resp.Body, cb)
250254
resp.Body.Close()
251255
if err != nil {

‎internal/maintenance/maintenance.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,16 @@ func Start(ctx context.Context, home string, cfg Config) {
216216
}()
217217
}
218218

219+
// DaysAgo returns the cutoff time for a day-based retention policy at the
220+
// given instant: pure duration arithmetic (N*24h), NOT calendar AddDate —
221+
// the two diverge by an hour across DST transitions. Exported so the cleanup
222+
// dry-run preview (cmd/odek) computes its candidate cutoffs with the same
223+
// math as the sweep; preview and deletion set can no longer disagree about
224+
// what "3 days old" means.
225+
func DaysAgo(now time.Time, days int) time.Time {
226+
return now.Add(-time.Duration(days) * 24 * time.Hour)
227+
}
228+
219229
// daysAgo returns the cutoff time for a day-based retention policy. Duration
220230
// arithmetic (instead of AddDate) avoids DST-sensitive behaviour where a
221231
// "day" isn't always 24 hours.

0 commit comments

Comments
 (0)