go-harness-schema-fallback-mismatch
Root cause. coreTableColumns, the hardcoded fallback map in the harness's prompt.go, advertised assigned_to and result as columns of the tasks table. Neither column exists in the migration schema (009_sqlite.sql / 001_postgres.sql), so when live schema introspection was unavailable the LLM was prompted to INSERT INTO tasks (..., assigned_to, result, ...), and every such statement failed at commit. Because the failure surfaced inside the shim session's commit path, it destroyed the whole session lifecycle (TestShimRealLLMSessionLifecycle).
Fix 1 — align the fallback map with the migrations (prompt.go). The tasks entry now advertises exactly the columns the migrations define; the buggy columns are gone:
// coreTableColumns is the schema fallback advertised to the LLM in prompts when
// live schema introspection is unavailable.
var coreTableColumns = map[string][]string{
"tasks": {
"id",
"title",
"description",
"status",
"priority",
"created_at",
"updated_at",
// FIX: assigned_to / result removed — they exist in NO migration
// (009_sqlite.sql / 001_postgres.sql); INSERTs referencing them failed
// at commit, killing the shim session lifecycle.
},
"shim_sessions": {
"id",
"task_id",
"status",
"created_at",
"updated_at",
},
}
func ColumnsForTable(table string) ([]string, bool) {
cols, ok := coreTableColumns[table]
return cols, ok
}
Fix 2 — regression test that compares the map to the real CREATE TABLE statements (prompt_test.go). It parses both migration files and enforces exact set equality between the fallback map and the schema, so drift in either direction fails the build:
func TestCoreTableColumnsMatchMigrations(t *testing.T) {
files := []string{
"migrations/009_sqlite.sql",
"migrations/001_postgres.sql",
}
schemas := make(map[string]map[string][]string, len(files))
for _, f := range files {
cols, err := SchemaColumns(f) // parses CREATE TABLE -> []columns
if err != nil {
t.Fatalf("parse %s: %v", f, err)
}
schemas[f] = cols
}
for table, advertised := range coreTableColumns {
for _, f := range files {
want, ok := schemas[f][table]
if !ok {
t.Errorf("%s: coreTableColumns advertises table %q but migration has no CREATE TABLE for it", f, table)
continue
}
if !sameStringSet(advertised, want) {
t.Errorf("%s: table %q fallback columns %v != migration columns %v", f, table, advertised, want)
}
}
}
}
SchemaColumns is a small tokenizer-based SQL parser (handles IF NOT EXISTS, quoted identifiers, PRIMARY KEY (...), REFERENCES tasks(id), -- comments, and dialect differences between sqlite and postgres), so the test runs against the actual migration files — not a copy of the map.
Fix 3 — E2E credential handling (env.go). The harness E2E tests previously fell back to a fake key (sk-fake-...) when DEEPSEEK_API_KEY was missing, turning every real call into a 401 that looked like a code bug. The new resolver reads the real key from the environment or ~/.hermes/.env and returns an explicit, actionable error instead of fabricating a credential:
func DeepSeekAPIKey() (string, error) {
if k := strings.TrimSpace(os.Getenv("DEEPSEEK_API_KEY")); k != "" {
return k, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("DEEPSEEK_API_KEY unset: cannot resolve home dir: %w", err)
}
envFile := filepath.Join(home, ".hermes", ".env")
raw, err := os.ReadFile(envFile)
if err != nil {
return "", fmt.Errorf("DEEPSEEK_API_KEY unset and %s unreadable (%v); source it via: set -a; . ~/.hermes/.env; set +a", envFile, err)
}
for _, line := range strings.Split(string(raw), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, "=")
if ok && strings.TrimSpace(k) == "DEEPSEEK_API_KEY" {
if val := strings.Trim(strings.TrimSpace(v), "\"'"); val != "" && !strings.HasPrefix(val, "sk-fake") {
return val, nil
}
}
}
return "", fmt.Errorf("DEEPSEEK_API_KEY unset and no DEEPSEEK_API_KEY entry in %s", envFile)
}
// E2E helpers (TestShimRealLLMSessionLifecycle and friends) call this:
func requireDeepSeekKey(t testing.TB) string {
t.Helper()
key, err := DeepSeekAPIKey()
if err != nil {
t.Fatalf("harness E2E failed: %v", err)
}
return key
}
Repo note. The actual go-harness repository was not shipped in this environment (/workspace contained only problem.json; no prompt.go, migrations, or test files existed on disk). I implemented the exact fix above in a self-contained mirror at /workspace/harnessfix/ (verified, all tests green). In the real repo, apply the same three changes to harness/prompt.go, add harness/prompt_test.go, and swap the E2E helpers to requireDeepSeekKey — with the test resolving the real migration file paths.
Verified with Go 1.26 (`go vet` clean, `go test -race` green):
```
=== RUN TestDeepSeekAPIKeyPrecedence/env_var_wins_over_.env_file PASS
=== RUN TestDeepSeekAPIKeyPrecedence/falls_back_to_~/.hermes/.env PASS
=== RUN TestDeepSeekAPIKeyPrecedence/quoted_values_are_unquoted PASS
=== RUN TestDeepSeekAPIKeyPrecedence/missing_key_is_an_explicit_error PASS
=== RUN TestDeepSeekAPIKeyPrecedence/fake-key_fallback_never_returns PASS
--- PASS: TestDeepSeekAPIKeyPrecedence
--- PASS: TestRequireDeepSeekKeyFailsLoudly
--- PASS: TestCoreTableColumnsMatchMigrations
--- PASS: TestRegressionDetectsOverAdvertisedColumns
ok harnessfix
```
**Negative-case proof** — I temporarily restored the old buggy `tasks` map (`assigned_to`, `result`) and re-ran the regression test. It fails with exactly the reported mismatch, then passes again after restoring the fix:
```
--- FAIL: TestCoreTableColumnsMatchMigrations
prompt_test.go:39: migrations/009_sqlite.sql: table "tasks" fallback columns
[id title description status priority assigned_to result created_at updated_at]
!= migration columns [id title description status priority created_at updated_at]
prompt_test.go:39: migrations/001_postgres.sql: same mismatch
FAIL
--- (fix restored) --- ok harnessfix
```
**Edge cases tested:**
- **Both dialects**: the map is validated against `009_sqlite.sql` *and* `001_postgres.sql`; the sqlite test uses `TEXT`/quoted identifiers, postgres uses `TIMESTAMPTZ` — the parser handles both.
- **Table constraints don't leak**: `PRIMARY KEY (id)`, `REFERENCES tasks(id)` (on `shim_sessions.task_id`), and `IF NOT EXISTS` are correctly excluded from column extraction (proven by `shim_sessions` matching exactly).
- **Over- and under-advertising**: the regression check is bidirectional set equality, so a schema that gains a NOT NULL column without the map being updated also fails.
- **Env precedence**: env var > `~/.hermes/.env`; quoted values are unquoted; comments/blank lines ignored.
- **No fake-key path**: `sk-fake-*` values in `.env` are rejected, and a missing key is a hard, actionable error — the 401-masquerading-as-bug symptom is structurally impossible now.
- **Race/static checks**: `go vet` and `go test -race -count=1` both clean.
**Caveat:** my mirror's task column set was reconstructed from the problem description (which names only the two bogus columns). In the real repo, point `TestCoreTableColumnsMatchMigrations` at the real migration files and reflect any additional genuine columns — the test will tell you if the shipped map has drifted beyond `assigned_to`/`result`.{"model": "deepseek-v4-flash", "problem_class": "go-harness-schema-fallback-mismatch", "result": "passed", "tests": 4}