21 Commits
Author SHA1 Message Date
David Gageot 3b013894c1 test(cache): remove timestamp granularity from dedup check
Assisted-By: docker-agent
2026-10-16 11:10:03 +01:00
Sayt-0 8d384b927a chore(test): upgrade testify to v1.12.1 2026-08-24 09:48:01 +02:00
Dwin Gharibi d37ef3a31a docs(cache): narrow what the adopt merge actually guarantees
adopt's comment said replacing c.entries "would discard entries an earlier
failed Store kept in memory on purpose", which reads as a general guarantee.
It is not one: maybeReload still replaces c.entries wholesale, so the first
sibling write drops an unpersisted entry anyway, and for a key that is on
disk the value read under the lock wins over the newer in-memory one.

Both behaviours are the right call for a cache — converging on the state the
lock protected, and overwriting by key as Store does — but the comment should
not let a reader treat unpersisted entries as durable.

Also notes on the package doc that Store adopts the state it read under the
lock, since the doc previously attributed cross-process visibility to Lookup
alone.
2026-08-07 10:50:21 +03:30
Dwin Gharibi 0eb7102c44 style(pkg/cache/cache.go): linting to fix the lint problems 2026-08-06 18:28:37 +03:30
Dwin Gharibi 8f75cd456b test(pkg/cache/cache_test.go): adding the edge case tests for file backend cache problem covering 2026-08-06 17:06:08 +03:30
Dwin Gharibi e1060d09bb fix(pkg/cache/cache.go): fixing problem of file backend cache 2026-08-06 17:05:39 +03:30
Trung Nguyen 3d4c839069 test(cache): tolerate Windows sharing violations in torn-file reader 2026-08-06 10:14:06 +02:00
Sayt-0 8f227f70e8 test: use portable filesystem expectations 2026-07-30 11:32:17 +02:00
Sayt-0 f827b8ed66 refactor: adopt atomicfile for atomic writes 2026-07-13 15:59:17 +02:00
David Gageot 69231c755e test: add t.Parallel() to eligible tests
Assisted-By: Claude
2026-06-29 13:39:47 +02:00
David Gageot 6a33f4ad70 chore(cache): tighten cache directory permissions to 0o700
The per-user cache (lock file dir and JSON cache dir) does not need to be world-readable. The lock file and JSON entries (via os.CreateTemp) are already 0o600.

Assisted-By: docker-agent
2026-05-07 10:04:47 +02:00
David Gageot 0fcece401e add js/wasm browser build with OpenRouter PKCE, agentic loop, and demo page 2026-05-06 17:11:57 +02:00
David Gageot c4dc536ead fix(cache): address TOCTOU race in maybeReload and improve documentation
- Fix TOCTOU race condition in Cache.maybeReload by re-stating the file
  under the write lock. The previous implementation stat'd the file before
  acquiring the lock, then used the stale FileInfo after the lock, which
  could lead to storing an outdated mtime that doesn't match the loaded
  content. This could cause subsequent Lookups to miss updates in
  high-concurrency scenarios.

- Clarify Windows lock semantics: the mandatory LockFileEx lock is on the
  separate .lock file (not the data file), so both Unix and Windows achieve
  the same advisory-lock effect for the cache operations.

- Document mtimeOf zero-value semantics: the zero time.Time{} is a sentinel
  for 'file not found' and is safe to compare with time.Time.Equal.

- Add comment explaining Store's update-after-persist ordering: the in-memory
  map is updated after the disk write so that persistence failures still keep
  the entry in memory for the current process.

- Document resolveCachePath's lexical-only path traversal check: symlinks are
  not resolved, which is acceptable for the threat model (cache paths come
  from agent configs, not untrusted user input).

All tests pass with -race. No functional changes to the cache behavior.

Assisted-By: docker-agent
2026-04-28 09:23:27 +02:00
David Gageot f55c7423dc refactor(cache): collapse lock helpers and inline persistEntry into Cache method
Three small simplifications in pkg/cache/cache.go that trim the file by
~10% and reduce conceptual surface, with no behavior change:

- Replace the fileLock struct + acquireFileLock + (l).release method
  trio with a single `lockFile(path) (func(), error)` that returns a
  release closure. The struct existed only to carry an *os.File and
  expose a method that called Close — exactly what a closure does.
  Saves the type declaration, two separate exports of the same thing,
  and the awkward "lock, err := acquireFileLock(...); defer
  lock.release()" dance.

- Inline the free-function persistEntry into a *Cache method
  persistToDisk that updates c.mtime directly. The old shape had
  persistEntry returning (time.Time, error) just so the caller could
  do `if !mtime.IsZero() { c.mtime = mtime }`; the IsZero gymnastics
  was only there because errors and a missing-stat shared the same
  zero return. Method form lets us simply assign and return on error.

- Extract a tiny `mtimeOf(path) time.Time` helper for the three places
  that want "current mtime, zero if unavailable" (New, persistToDisk's
  dedup branch, persistToDisk's post-write branch). Three identical
  three-line stat-or-zero blocks become one one-liner.

- Move the `c.path != ""` short-circuit into Cache.maybeReload itself
  so Lookup's body no longer needs to know about the file/in-memory
  distinction.

Validation: cross-platform builds (linux/windows amd64) clean, full
mise lint clean, mise test green, go test -race ./pkg/cache/... clean.

Assisted-By: docker-agent
2026-04-28 09:23:27 +02:00
David Gageot 60c7fc3aa9 feat(cache): make file-backed caches concurrent-write-safe across processes
Two processes (or two runtimes) sharing the same cache.json could
silently lose entries: each held its own in-memory map, snapshotted
under its own mutex, and wrote the file independently — last writer
won, the other side's just-stored entries vanished from disk.

Add cross-process serialization via an advisory lock on a sibling
`<path>.lock` file (POSIX flock(2) on Unix, LockFileEx on Windows),
and surface external writes through mtime-based reload on Lookup.

- pkg/cache/lock_unix.go, pkg/cache/lock_windows.go: thin wrappers
  around unix.Flock and windows.LockFileEx (both already in our
  golang.org/x/sys dep tree, no new packages).
- pkg/cache/cache.go: rework Cache.Store from "snapshot under c.mu,
  write" to "lock <path>.lock, reload disk, merge our entry, write
  atomically". The lock window is short — one read, one
  fsync+rename — and serializes the read-modify-write across every
  process holding the same path. Add Cache.maybeReload (called by
  Lookup) to pick up external writes when the file mtime has
  advanced. The persist callback is gone; path/mtime now live on
  the struct.
- The lock file is a long-lived sentinel — never renamed or
  deleted. Removing it would let two processes lock different
  inodes and lose mutual exclusion.
- pkg/cache/cache_test.go: three new tests (and the leftover-files
  test learned about the .lock sentinel):
    * TestFileCache_crossProcessConcurrentStoresPreserveAllEntries
      — two *Cache instances on the same path, 50 distinct keys
      each, asserts all 100 entries are on disk after the race.
      Without the lock this test loses ~50% of entries.
    * TestFileCache_lookupReloadsAfterExternalWrite — one cache
      writes, the other's Lookup picks up the change after the
      mtime advances.
    * TestFileCache_lockFileNeverDeleted — pins the invariant.
- docs/configuration/agents/index.md and examples/cached_responses.yaml
  now describe the cross-process semantics so users know they can
  point multiple agents (or multiple processes) at the same path.

In-memory dedup is preserved as a cheap short-circuit: when our local
view already matches, Store skips the file lock + disk hit entirely.
After a recent Lookup our view is fresh, so this catches the common
"store the answer we just replayed" path of cache_response.

Validation: cross-platform builds (linux/windows amd64) clean,
mise lint clean, mise test green, go test -race ./pkg/cache/...
./pkg/runtime/ ./pkg/teamloader/ clean.

Assisted-By: docker-agent
2026-04-28 09:23:27 +02:00
David Gageot 808323635c docs(cache): document invariants surfaced by review; add edge-case tests
Targeted follow-ups from a second-pass review. No behavior change for
existing scenarios; the only functional delta is that
after_llm_call hooks now also receive AgentName / LastUserMessage,
matching what the field's doc comment already promised.

Bug fixes / contract tightening:

- pkg/runtime/runtime.go: populate AgentName and LastUserMessage in
  executeAfterLLMCallHooks, not just executeStopHooks. The Input doc
  comment listed both events; this finishes the implementation.
- pkg/runtime/cache.go: log a Debug line in cacheResponseBuiltin when
  team.Agent(in.AgentName) fails so a misconfigured team is at least
  visible in --debug output instead of silently dropping the write.

Comments to prevent future regressions:

- pkg/cache/cache.go: Store now explicitly documents WHY the persist
  callback runs under the write lock. Releasing the lock between
  cloning the snapshot and writing it would let two concurrent stores
  persist out-of-order and silently overwrite a newer entry with an
  older snapshot on the next restart — exactly the lost-write race
  fixed in 73d806943. The comment makes the trade-off (brief read
  latency vs. strict serialization) the deliberate choice it is.
- pkg/runtime/cache.go: explain why tryReplayCachedResponse treats an
  empty cached value as a miss instead of replaying it.
- pkg/cache/cache.go: document the trim-then-lowercase order in
  keyNormalizer so a future change doesn't accidentally swap them.

Tests:

- pkg/cache/cache_test.go::TestFileCache_persistenceFailureKeepsInMemory
  pins the invariant that a write failure (here: cache directory
  stripped of write permission after New) does not break Store —
  the in-memory map still receives the entry, only the disk mirror
  is skipped. Skipped when running as root.
- pkg/runtime/cache_test.go::TestCache_EmptyResponseIsNotCached pins
  the empty-response semantics: an empty assistant content is dropped
  from the cache and the model is consulted on every repeat ask.

Validation: mise lint clean, full mise test green,
go test -race ./pkg/cache/... ./pkg/runtime/ ./pkg/teamloader/ clean.

Assisted-By: docker-agent
2026-04-28 09:23:27 +02:00
David Gageot 5255d3f9f8 refactor(cache): drop interface, dedup at the source, extract loader helper
Three small simplifications, no feature change.

- pkg/cache: collapse the single-implementation Cache interface into an
  exported *Cache struct (Go's "accept interfaces, return structs"
  guideline). New now returns *cache.Cache, callers use a pointer
  field/parameter, and the unexported `cache` type goes away. Net: one
  fewer indirection per call site, ~10 fewer lines.
- pkg/cache: push the "skip rewrite when (q, response) already match"
  optimization down into Cache.Store. The cache itself is the right
  place for it, and any future caller benefits — the runtime
  cache_response builtin no longer needs an extra Lookup before Store.
  Add a unit test (TestFileCache_dedupSkipsRedundantWrite) that
  asserts the file's mtime is preserved on identical writes.
- pkg/runtime/cache.go: as a follow-up to the above, cacheResponseBuiltin
  now collapses to a 3-line happy path (resolve agent, get cache,
  Store) — the dedup branch is gone.
- pkg/teamloader: extract the 24-line inline cache wiring (path
  resolution, traversal check, cache.New) into a dedicated
  buildAgentCache helper in a new cache.go file alongside the loader.
  The agent loop body now reads as 5 lines. Add unit tests for the
  helper covering disabled config, in-memory, relative path,
  absolute path, and the path-traversal rejection.

Validation: mise lint clean, full mise test green (incl. existing
runtime cache tests, new pkg/cache and pkg/teamloader unit tests, and
race detector on pkg/cache/runtime/teamloader).

Assisted-By: docker-agent
2026-04-28 09:23:27 +02:00
David Gageot 2dea44efee fix(cache): address race condition, resource leak, and path traversal
- Fix race condition in cache.Store: hold lock during persist to ensure
  snapshot consistency with disk writes
- Fix resource leak in syncDir: use defer to ensure directory fd is closed
- Add path traversal validation in teamloader: prevent cache paths from
  escaping parent directory using filepath.Clean and prefix check
- Add test for path traversal protection

Assisted-By: docker-agent
2026-04-28 09:21:01 +02:00
David Gageot 7b898074c2 refactor(cache): collapse memory/file backends and extract loop helpers
Same feature, less code. The cache package and the runtime integration
each had visible duplication that made the moving parts harder to
follow than they needed to be.

- pkg/cache: merge `memoryCache` and `fileCache` into a single `cache`
  struct with an optional `persist func(map[string]string)` callback.
  The in-memory backend leaves persist nil; the file-backed backend
  sets it to a closure that writes the snapshot via the existing
  atomic-rename helper. Lookup, Store, and the locking pattern now
  exist in one place instead of two.
- pkg/cache: tidy `writeJSON` (drop the dir != "." special case since
  filepath.Dir always returns a usable directory; pull the directory
  fsync into a small `syncDir` helper) and use `maps.Clone` for the
  snapshot.
- pkg/runtime: move the cache hit/store logic out of the long
  `RunStream` body into two named helpers in a new `cache.go` file:
  `tryReplayCachedResponse` (returns the question + a replayed flag)
  and `cacheTurnResponse` (stores then clears the question pointer).
  The main loop now reads as a two-line cache check and a one-line
  store call.

Net change: -45 lines across the two files. No behavior change; lint,
existing cache unit tests, and runtime cache tests all pass unchanged.

Assisted-By: docker-agent
2026-04-28 09:20:58 +02:00
David Gageot 59bb1afa28 docs(cache): document response cache and harden file write durability
Add a "Response Cache" section to the agent configuration reference and
strengthen the file-backed cache so writes are not just atomic but also
durable.

- docs/configuration/agents/index.md: extend the schema example and
  property table with the new `cache` field, and add a dedicated
  "Response Cache" section describing the matching options
  (case_sensitive, trim_spaces), the storage backends (in-memory vs
  JSON file), and the atomicity guarantees of the file-backed store.
- pkg/cache: fsync the temp file before rename and fsync the parent
  directory after rename so the rename itself is persisted across an
  OS crash. Concurrent readers are still guaranteed to see either the
  previous or the new content in full, never a torn write.
- pkg/cache: add tests asserting that the success path leaves no
  leftover temp files and that a parallel reader never observes a
  partially written cache file under sustained concurrent stores.

Assisted-By: docker-agent
2026-04-28 09:19:47 +02:00
David Gageot 89922cf352 feat(agent): add configurable response cache
Add a per-agent response cache that maps a normalized user question to
its previous assistant answer, replaying it verbatim on a hit and
skipping the model entirely.

- New pkg/cache package exposing a Cache interface backed by either an
  in-memory map (default) or a JSON file that is loaded on startup and
  rewritten atomically on every store.
- Two normalization options: case_sensitive (default false) and
  trim_spaces (default false), so "Hello" and "  hello  " can map to
  the same cached answer when configured.
- New CacheConfig in pkg/config/latest with matching agent-schema.json
  definition, plus an examples/cached_responses.yaml demonstrating the
  feature.
- Agent gains a Cache() accessor and a WithCache option; teamloader
  builds the cache from config and resolves a relative path against
  the agent config directory.
- Runtime loop looks up the latest user message at the start of
  RunStream and replays the cached answer (with AgentInfo,
  MessageAdded, and stop hooks) on a hit, then stores the final
  response on the first stop so subsequent identical turns short-
  circuit the model.
- Unit tests for both cache backends and runtime-level tests covering
  hit/miss, case-insensitive matching, whitespace trimming, and the
  no-cache path.

Assisted-By: docker-agent
2026-04-28 09:19:47 +02:00