* docs: pathfinder refactor corpus + Node 20 preflight
Adds the PATHFINDER-2026-04-22 principle-driven refactor plan (11 docs,
cross-checked PASS) plus the exploratory PATHFINDER-2026-04-21 corpus
that motivated it. Bumps engines.node to >=20.0.0 per the ingestion-path
plan preflight (recursive fs.watch). Adds the pathfinder skill.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: land PATHFINDER Plan 01 — data integrity
Schema, UNIQUE constraints, self-healing claim, Chroma upsert fallback.
- Phase 1: fresh schema.sql regenerated at post-refactor shape.
- Phase 2: migrations 23+24 — rebuild pending_messages without
started_processing_at_epoch; UNIQUE(session_id, tool_use_id);
UNIQUE(memory_session_id, content_hash) on observations; dedup
duplicate rows before adding indexes.
- Phase 3: claimNextMessage rewritten to self-healing query using
worker_pid NOT IN live_worker_pids; STALE_PROCESSING_THRESHOLD_MS
and the 60-s stale-reset block deleted.
- Phase 4: DEDUP_WINDOW_MS and findDuplicateObservation deleted;
observations.insert now uses ON CONFLICT DO NOTHING.
- Phase 5: failed-message purge block deleted from worker-service
2-min interval; clearFailedOlderThan method deleted.
- Phase 6: repairMalformedSchema and its Python subprocess repair
path deleted from Database.ts; SQLite errors now propagate.
- Phase 7: Chroma delete-then-add fallback gated behind
CHROMA_SYNC_FALLBACK_ON_CONFLICT env flag as bridge until
Chroma MCP ships native upsert.
- Phase 8: migration 19 no-op block absorbed into fresh schema.sql.
Verification greps all return 0 matches. bun test tests/sqlite/
passes 63/63. bun run build succeeds.
Plan: PATHFINDER-2026-04-22/01-data-integrity.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: land PATHFINDER Plan 02 — process lifecycle
OS process groups replace hand-rolled reapers. Worker runs until
killed; orphans are prevented by detached spawn + kill(-pgid).
- Phase 1: src/services/worker/ProcessRegistry.ts DELETED. The
canonical registry at src/supervisor/process-registry.ts is the
sole survivor; SDK spawn site consolidated into it via new
createSdkSpawnFactory/spawnSdkProcess/getSdkProcessForSession/
ensureSdkProcessExit/waitForSlot helpers.
- Phase 2: SDK children spawn with detached:true + stdio:
['ignore','pipe','pipe']; pgid recorded on ManagedProcessInfo.
- Phase 3: shutdown.ts signalProcess teardown uses
process.kill(-pgid, signal) on Unix when pgid is recorded;
Windows path unchanged (tree-kill/taskkill).
- Phase 4: all reaper intervals deleted — startOrphanReaper call,
staleSessionReaperInterval setInterval (including the co-located
WAL checkpoint — SQLite's built-in wal_autocheckpoint handles
WAL growth without an app-level timer), killIdleDaemonChildren,
killSystemOrphans, reapOrphanedProcesses, reapStaleSessions, and
detectStaleGenerator. MAX_GENERATOR_IDLE_MS and MAX_SESSION_IDLE_MS
constants deleted.
- Phase 5: abandonedTimer — already 0 matches; primary-path cleanup
via generatorPromise.finally() already lives in worker-service
startSessionProcessor and SessionRoutes ensureGeneratorRunning.
- Phase 6: evictIdlestSession and its evict callback deleted from
SessionManager. Pool admission gates backpressure upstream.
- Phase 7: SDK-failure fallback — SessionManager has zero matches
for fallbackAgent/Gemini/OpenRouter. Failures surface to hooks
via exit code 2 through SessionRoutes error mapping.
- Phase 8: ensureWorkerRunning in worker-utils.ts rewritten to
lazy-spawn — consults isWorkerPortAlive (which gates
captureProcessStartToken for PID-reuse safety via commit
99060bac), then spawns detached with unref(), then
waitForWorkerPort({ attempts: 3, backoffMs: 250 }) hand-rolled
exponential backoff 250→500→1000ms. No respawn npm dep.
- Phase 9: idle self-shutdown — zero matches for
idleCheck/idleTimeout/IDLE_MAX_MS/idleShutdown. Worker exits
only on external SIGTERM via supervisor signal handlers.
Three test files that exercised deleted code removed:
tests/worker/process-registry.test.ts,
tests/worker/session-lifecycle-guard.test.ts,
tests/services/worker/reap-stale-sessions.test.ts.
Pass count: 1451 → 1407 (-44), all attributable to deleted test
files. Zero new failures. 31 pre-existing failures remain
(schema-repair suite, logger-usage-standards, environmental
openclaw / plugin-distribution) — none introduced by Plan 02.
All 10 verification greps return 0. bun run build succeeds.
Plan: PATHFINDER-2026-04-22/02-process-lifecycle.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: land PATHFINDER Plan 04 (narrowed) — search fail-fast
Phases 3, 5, 6 only. Plan-doc inaccuracies for phases 1/2/4/7/8/9
deferred for plan reconciliation:
- Phase 1/2: ObservationRow type doesn't exist; the four
"formatters" operate on three incompatible types.
- Phase 4: RECENCY_WINDOW_MS already imported from
SEARCH_CONSTANTS at every call site.
- Phase 7: getExistingChromaIds is NOT @deprecated and has an
active caller in ChromaSync.backfillMissingSyncs.
- Phase 8: estimateTokens already consolidated.
- Phase 9: knowledge-corpus rewrite blocked on PG-3
prompt-caching cost smoke test.
Phase 3 — Delete SearchManager.findByConcept/findByFile/findByType.
SearchRoutes handlers (handleSearchByConcept/File/Type) now call
searchManager.getOrchestrator().findByXxx() directly via new
getter accessors on SearchManager. ~250 LoC deleted.
Phase 5 — Fail-fast Chroma. Created
src/services/worker/search/errors.ts with ChromaUnavailableError
extends AppError(503, 'CHROMA_UNAVAILABLE'). Deleted
SearchOrchestrator.executeWithFallback's Chroma-failed
SQLite-fallback branch; runtime Chroma errors now throw 503.
"Path 3" (chromaSync was null at construction — explicit-
uninitialized config) preserved as legitimate empty-result state
per plan text. ChromaSearchStrategy.search no longer wraps in
try/catch — errors propagate.
Phase 6 — Delete HybridSearchStrategy three try/catch silent
fallback blocks (findByConcept, findByType, findByFile) at lines
~82-95, ~120-132, ~161-172. Removed `fellBack` field from
StrategySearchResult type and every return site
(SQLiteSearchStrategy, BaseSearchStrategy.emptyResult,
SearchOrchestrator).
Tests updated (Principle 7 — delete in same PR):
- search-orchestrator.test.ts: "fall back to SQLite" rewritten
as "throw ChromaUnavailableError (HTTP 503)".
- chroma/hybrid/sqlite-search-strategy tests: rewritten to
rejects.toThrow; removed fellBack assertions.
Verification: SearchManager.findBy → 0; fellBack → 0 in src/.
bun test tests/worker/search/ → 122 pass, 0 fail.
bun test (suite-wide) → 1407 pass, baseline maintained, 0 new
failures. bun run build succeeds.
Plan: PATHFINDER-2026-04-22/04-read-path.md (Phases 3, 5, 6)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: land PATHFINDER Plan 03 — ingestion path
Fail-fast parser, direct in-process ingest, recursive fs.watch,
DB-backed tool pairing. Worker-internal HTTP loopback eliminated.
- Phase 0: Created src/services/worker/http/shared.ts exporting
ingestObservation/ingestPrompt/ingestSummary as direct
in-process functions plus ingestEventBus (Node EventEmitter,
reusing existing pattern — no third event bus introduced).
setIngestContext wires the SessionManager dependency from
worker-service constructor.
- Phase 1: src/sdk/parser.ts collapsed to one parseAgentXml
returning { valid:true; kind: 'observation'|'summary'; data }
| { valid:false; reason: string }. Inspects root element;
<skip_summary reason="…"/> is a first-class summary case
with skipped:true. NEVER returns undefined. NEVER coerces.
- Phase 2: ResponseProcessor calls parseAgentXml exactly once,
branches on the discriminated union. On invalid → markFailed
+ logger.warn(reason). On observation → ingestObservation.
On summary → ingestSummary then emit summaryStoredEvent
{ sessionId, messageId } (consumed by Plan 05's blocking
/api/session/end).
- Phase 3: Deleted consecutiveSummaryFailures field
(ResponseProcessor + SessionManager + worker-types) and
MAX_CONSECUTIVE_SUMMARY_FAILURES constant. Circuit-breaker
guards and "tripped" log lines removed.
- Phase 4: coerceObservationToSummary deleted from sdk/parser.ts.
- Phase 5: src/services/transcripts/watcher.ts rescan setInterval
replaced with fs.watch(transcriptsRoot, { recursive: true,
persistent: true }) — Node 20+ recursive mode.
- Phase 6: src/services/transcripts/processor.ts pendingTools
Map deleted. tool_use rows insert with INSERT OR IGNORE on
UNIQUE(session_id, tool_use_id) (added by Plan 01). New
pairToolUsesByJoin query in PendingMessageStore for read-time
pairing (UNIQUE INDEX provides idempotency; explicit consumer
not yet wired).
- Phase 7: HTTP loopback at processor.ts:252 replaced with
direct ingestObservation call. maybeParseJson silent-passthrough
rewritten to fail-fast (throws on malformed JSON).
- Phase 8: src/utils/tag-stripping.ts countTags + stripTagsInternal
collapsed into one alternation regex, single-pass over input.
- Phase 9: src/utils/transcript-parser.ts (dead TranscriptParser
class) deleted. The active extractLastMessage at
src/shared/transcript-parser.ts:41-144 is the sole survivor.
Tests updated (Principle 7 — same-PR delete):
- tests/sdk/parser.test.ts + parse-summary.test.ts: rewritten
to assert discriminated-union shape; coercion-specific
scenarios collapse into { valid:false } assertions.
- tests/worker/agents/response-processor.test.ts: circuit-breaker
describe block skipped; non-XML/empty-response tests assert
fail-fast markFailed behavior.
Verification: every grep returns 0. transcript-parser.ts deleted.
bun run build succeeds. bun test → 1399 pass / 28 fail / 7 skip
(net -8 pass = the 4 retired circuit-breaker tests + 4 collapsed
parser cases). Zero new failures vs baseline.
Deferred (out of Plan 03 scope, will land in Plan 06): SessionRoutes
HTTP route handlers still call sessionManager.queueObservation
inline rather than the new shared helpers — the helpers are ready,
the route swap is mechanical and belongs with the Zod refactor.
Plan: PATHFINDER-2026-04-22/03-ingestion-path.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: land PATHFINDER Plan 05 — hook surface
Worker-call plumbing collapsed to one helper. Polling replaced by
server-side blocking endpoint. Fail-loud counter surfaces persistent
worker outages via exit code 2.
- Phase 1: plugin/hooks/hooks.json — three 20-iteration `for i in
1..20; do curl -sf .../health && break; sleep 0.1; done` shell
retry wrappers deleted. Hook commands invoke their bun entry
point directly.
- Phase 2: src/shared/worker-utils.ts — added
executeWithWorkerFallback<T>(url, method, body) returning
T | { continue: true; reason?: string }. All 8 hook handlers
(observation, session-init, context, file-context, file-edit,
summarize, session-complete, user-message) rewritten to use
it instead of duplicating the ensureWorkerRunning →
workerHttpRequest → fallback sequence.
- Phase 3: blocking POST /api/session/end in SessionRoutes.ts
using validateBody + sessionEndSchema (z.object({sessionId})).
One-shot ingestEventBus.on('summaryStoredEvent') listener,
30 s timer, req.aborted handler — all share one cleanup so
the listener cannot leak. summarize.ts polling loop, plus
MAX_WAIT_FOR_SUMMARY_MS / POLL_INTERVAL_MS constants, deleted.
- Phase 4: src/shared/hook-settings.ts — loadFromFileOnce()
memoizes SettingsDefaultsManager.loadFromFile per process.
Per-handler settings reads collapsed.
- Phase 5: src/shared/should-track-project.ts — single exclusion
check entry; isProjectExcluded no longer referenced from
src/cli/handlers/.
- Phase 6: cwd validation pushed into adapter normalizeInput
(all 6 adapters: claude-code, cursor, raw, gemini-cli,
windsurf). New AdapterRejectedInput error in
src/cli/adapters/errors.ts. Handler-level isValidCwd checks
deleted from file-edit.ts and observation.ts. hook-command.ts
catches AdapterRejectedInput → graceful fallback.
- Phase 7: session-init.ts conditional initAgent guard deleted;
initAgent is idempotent. tests/hooks/context-reinjection-guard
test (validated the deleted conditional) deleted in same PR
per Principle 7.
- Phase 8: fail-loud counter at ~/.claude-mem/state/hook-failures
.json. Atomic write via .tmp + rename. CLAUDE_MEM_HOOK_FAIL_LOUD
_THRESHOLD setting (default 3). On consecutive worker-unreachable
≥ N: process.exit(2). On success: reset to 0. NOT a retry.
- Phase 9: ensureWorkerAliveOnce() module-scope memoization
wrapping ensureWorkerRunning. executeWithWorkerFallback calls
the memoized version.
Minimal validateBody middleware stub at
src/services/worker/http/middleware/validateBody.ts. Plan 06 will
expand with typed inference + error envelope conventions.
Verification: 4/4 grep targets pass. bun run build succeeds.
bun test → 1393 pass / 28 fail / 7 skip; -6 pass attributable
solely to deleted context-reinjection-guard test file. Zero new
failures vs baseline.
Plan: PATHFINDER-2026-04-22/05-hook-surface.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: land PATHFINDER Plan 06 — API surface
One Zod-based validator wrapping every POST/PUT. Rate limiter,
diagnostic endpoints, and shutdown wrappers deleted. Failure-
marking consolidated to one helper.
- Phase 1 (preflight): zod@^3 already installed.
- Phase 2: validateBody middleware confirmed at canonical shape
in src/services/worker/http/middleware/validateBody.ts —
safeParse → 400 { error: 'ValidationError', issues: [...] }
on failure, replaces req.body with parsed value on success.
- Phase 3: Per-route Zod schemas declared at the top of each
route file. 24 POST endpoints across SessionRoutes,
CorpusRoutes, DataRoutes, MemoryRoutes, SearchRoutes,
LogsRoutes, SettingsRoutes now wrap with validateBody().
/api/session/end (Plan 05) confirmed using same middleware.
- Phase 4: validateRequired() deleted from BaseRouteHandler
along with every call site. Inline coercion helpers
(coerceStringArray, coercePositiveInteger) and inline
if (!req.body...) guards deleted across all route files.
- Phase 5: Rate limiter middleware and its registration deleted
from src/services/worker/http/middleware.ts. Worker binds
127.0.0.1:37777 — no untrusted caller.
- Phase 6: viewer.html cached at module init in ViewerRoutes.ts
via fs.readFileSync; served as Buffer with text/html content
type. SKILL.md + per-operation .md files cached in
Server.ts as Map<string, string>; loadInstructionContent
helper deleted. NO fs.watch, NO TTL — process restart is the
cache-invalidation event.
- Phase 7: Four diagnostic endpoints deleted from DataRoutes.ts
— /api/pending-queue (GET), /api/pending-queue/process (POST),
/api/pending-queue/failed (DELETE), /api/pending-queue/all
(DELETE). Helper methods that ONLY served them
(getQueueMessages, getStuckCount, getRecentlyProcessed,
clearFailed, clearAll) deleted from PendingMessageStore.
KEPT: /api/processing-status (observability), /health
(used by ensureWorkerRunning).
- Phase 8: stopSupervisor wrapper deleted from supervisor/index.ts.
GracefulShutdown now calls getSupervisor().stop() directly.
Two functions retained with clear roles:
- performGracefulShutdown — worker-side 6-step shutdown
- runShutdownCascade — supervisor-side child teardown
(process.kill(-pgid), Windows tree-kill, PID-file cleanup)
Each has unique non-trivial logic and a single canonical caller.
- Phase 9: transitionMessagesTo(status, filter) is the sole
failure-marking path on PendingMessageStore. Old methods
markSessionMessagesFailed and markAllSessionMessagesAbandoned
deleted along with all callers (worker-service,
SessionCompletionHandler, tests/zombie-prevention).
Tests updated (Principle 7 same-PR delete): coercion test files
refactored to chain validateBody → handler. Zombie-prevention
tests rewritten to call transitionMessagesTo.
Verification: all 4 grep targets → 0. bun run build succeeds.
bun test → 1393 pass / 28 fail / 7 skip — exact match to
baseline. Zero new failures.
Plan: PATHFINDER-2026-04-22/06-api-surface.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: land PATHFINDER Plan 07 — dead code sweep
ts-prune-driven sweep across the tree after Plans 01-06 landed.
Deleted unused exports, orphan helpers, and one fully orphaned
file. Earlier-plan deletions verified.
Deleted:
- src/utils/bun-path.ts (entire file — getBunPath, getBunPathOrThrow,
isBunAvailable: zero importers)
- bun-resolver.getBunVersionString: zero callers
- PendingMessageStore.retryMessage / resetProcessingToPending /
abortMessage: superseded by transitionMessagesTo (Plan 06 Phase 9)
- EnvManager.MANAGED_CREDENTIAL_KEYS, EnvManager.setCredential:
zero callers
- CodexCliInstaller.checkCodexCliStatus: zero callers; no status
command exists in npx-cli
- Two "REMOVED: cleanupOrphanedSessions" stale-fence comments
Kept (with documented justification):
- Public API surface in dist/sdk/* (parseAgentXml, prompt
builders, ParsedObservation, ParsedSummary, ParseResult,
SUMMARY_MODE_MARKER) — exported via package.json sdk path.
- generateContext / loadContextConfig / token utilities — used
via dynamic await import('../../../context-generator.js') in
worker SearchRoutes.
- MCP_IDE_INSTALLERS, install/uninstall functions for codex/goose
— used via dynamic await import in npx-cli/install.ts +
uninstall.ts (ts-prune cannot trace dynamic imports).
- getExistingChromaIds — active caller in
ChromaSync.backfillMissingSyncs (Plan 04 narrowed scope).
- processPendingQueues / getSessionsWithPendingMessages — active
orphan-recovery caller in worker-service.ts plus
zombie-prevention test coverage.
- StoreAndMarkCompleteResult legacy alias — return-type annotation
in same file.
- All Database.ts barrel re-exports — used downstream.
Earlier-plan verification:
- Plan 03 Phase 9: VERIFIED — src/utils/transcript-parser.ts
is gone; TranscriptParser has 0 references in src/.
- Plan 01 Phase 8: VERIFIED — migration 19 no-op absorbed.
- SessionStore.ts:52-70 consolidation NOT executed (deferred):
the methods are not thin wrappers but ~900 LoC of bodies, and
two methods are documented as intentional mirrors so the
context-generator.cjs bundle stays schema-consistent without
pulling MigrationRunner. Deserves its own plan, not a sweep.
Verification: TranscriptParser → 0; transcript-parser.ts → gone;
no commented-out code markers remain. bun run build succeeds.
bun test → 1393 pass / 28 fail / 7 skip — EXACT match to
baseline. Zero regressions.
Plan: PATHFINDER-2026-04-22/07-dead-code.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: remove residual ProcessRegistry comment reference
Plan 07 dead-code sweep missed one comment-level reference to the
deleted in-memory ProcessRegistry class in SessionManager.ts:347.
Rewritten to describe the supervisor.json scope without naming the
deleted class, completing the verification grep target.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address Greptile review (P1 + 2× P2)
P1 — Plan 05 Phase 3 blocking endpoint was non-functional:
executeWithWorkerFallback used HEALTH_CHECK_TIMEOUT_MS (3 s) for
the POST /api/session/end call, but the server holds the
connection for SERVER_SIDE_SUMMARY_TIMEOUT_MS (30 s). Client
always raced to a "timed out" rejection that isWorkerUnavailable
classified as worker-unreachable, so the hook silently degraded
instead of waiting for summaryStoredEvent.
- Added optional timeoutMs to executeWithWorkerFallback,
forwarded to workerHttpRequest.
- summarize.ts call site now passes 35_000 (5 s above server
hold window).
P2 — ingestSummary({ kind: 'parsed' }) branch was dead code:
ResponseProcessor emitted summaryStoredEvent directly via the
event bus, bypassing the centralized helper that the comment
claimed was the single source.
- ResponseProcessor now calls ingestSummary({ kind: 'parsed',
sessionDbId, messageId, contentSessionId, parsed }) so the
event-emission path is single-sourced.
- ingestSummary's requireContext() resolution moved inside the
'queue' branch (the only branch that needs sessionManager /
dbManager). 'parsed' is a pure event-bus emission and
doesn't need worker-internal context — fixes mocked
ResponseProcessor unit tests that don't call
setIngestContext.
P2 — isWorkerFallback could false-positive on legitimate API
responses whose schema includes { continue: true, ... }:
- Added a Symbol.for('claude-mem/worker-fallback') brand to
WorkerFallback. isWorkerFallback now checks the brand, not
a duck-typed property name.
Verification: bun run build succeeds. bun test → 1393 pass /
28 fail / 7 skip — exact baseline match. Zero new failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address Greptile iteration 2 (P1 + P2)
P1 — summaryStoredEvent fired regardless of whether the row was
persisted. ResponseProcessor's call to ingestSummary({ kind:
'parsed' }) ran for every parsed.kind === 'summary' even when
result.summaryId came back null (e.g. FK violation, null
memory_session_id at commit). The blocking /api/session/end
endpoint then returned { ok: true } and the Stop hook logged
'Summary stored' for a non-existent row.
- Gate ingestSummary call on (parsed.data.skipped ||
session.lastSummaryStored). Skipped summaries are an explicit
no-op bypass and still confirm; real summaries only confirm
when storage actually wrote a row.
- Non-skipped + summaryId === null path logs a warn and lets
the server-side timeout (504) surface to the hook instead of
a false ok:true.
P2 — PendingMessageStore.enqueue() returns 0 when INSERT OR
IGNORE suppresses a duplicate (the UNIQUE(session_id, tool_use_id)
constraint added by Plan 01 Phase 1). The two callers
(SessionManager.queueObservation and queueSummarize) previously
logged 'ENQUEUED messageId=0' which read like a row was inserted.
- Branch on messageId === 0 and emit a 'DUP_SUPPRESSED' debug
log instead of the misleading ENQUEUED line. No behavior
change — the duplicate is still correctly suppressed by the
DB (Principle 3); only the log surface is corrected.
- confirmProcessed is never called with the enqueue() return
value (it operates on session.processingMessageIds[] from
claimNextMessage), so no caller is broken; the visibility
fix prevents future misuse.
Verification: bun run build succeeds. bun test → 1393 pass /
28 fail / 7 skip — exact baseline match. Zero new failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address Greptile iteration 3 (P1 + 2× P2)
- P1 worker-service.ts: wire ensureGeneratorRunning into the ingest
context after SessionRoutes is constructed. setIngestContext runs
before routes exist, so transcript-watcher observations queued via
ingestObservation() had no way to auto-start the SDK generator.
Added attachIngestGeneratorStarter() to patch the callback in.
- P2 shared.ts: IngestEventBus now sets maxListeners to 0. Concurrent
/api/session/end calls register one listener each and clean up on
completion, so the default-10 warning fires spuriously under normal
load.
- P2 SessionRoutes.ts: handleObservationsByClaudeId now delegates to
ingestObservation() instead of duplicating skip-tool / meta /
privacy / queue logic. Single helper, matching the Plan 03 goal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address Greptile iteration 4 (P1 tool-pair + P2 parse/path/doc)
- processor.handleToolResult: restore in-memory tool-use→tool-result
pairing via session.pendingTools for schemas (e.g. Codex) whose
tool_result events carry only tool_use_id + output. Without this,
neither handler fired — all tool observations silently dropped.
- processor.maybeParseJson: return raw string on parse failure instead
of throwing. Previously a single malformed JSON-shaped field caused
handleLine's outer catch to discard the entire transcript line.
- watcher.deepestNonGlobAncestor: split on / and \\, emit empty string
for purely-glob inputs so the caller skips the watch instead of
anchoring fs.watch at the filesystem root. Windows-compatible.
- PendingMessageStore.enqueue: tighten docstring — callers today only
log on the returned id; the SessionManager branches on id === 0.
* fix: forward tool_use_id through ingestObservation (Greptile iter 5)
P1 — Plan 01's UNIQUE(content_session_id, tool_use_id) dedup never
fired because the new shared ingest path dropped the toolUseId before
queueObservation. SQLite treats NULL values as distinct for UNIQUE,
so every replayed transcript line landed a duplicate row.
- shared.ingestObservation: forward payload.toolUseId to
queueObservation so INSERT OR IGNORE can actually collapse.
- SessionRoutes.handleObservationsByClaudeId: destructure both
tool_use_id (HTTP convention) and toolUseId (JS convention) from
req.body and pass into ingestObservation.
- observationsByClaudeIdSchema: declare both keys explicitly so the
validator doesn't rely on .passthrough() alone.
* fix: drop dead pairToolUsesByJoin, close session-end listener race
- PendingMessageStore: delete pairToolUsesByJoin. The method was never
called and its self-join semantics are structurally incompatible
with UNIQUE(content_session_id, tool_use_id): INSERT OR IGNORE
collapses any second row with the same pair, so a self-join can
only ever match a row to itself. In-memory pendingTools in
processor.ts remains the pairing path for split-event schemas.
- IngestEventBus: retain a short-lived (60s) recentStored map keyed
by sessionId. Populated on summaryStoredEvent emit, evicted on
consume or TTL.
- handleSessionEnd: drain the recent-events buffer before attaching
the listener. Closes the register-after-emit race where the summary
can persist between the hook's summarize POST and its session/end
POST — previously that window returned 504 after the 30s timeout.
* chore: merge origin/main into vivacious-teeth
Resolves conflicts with 15 commits on main (v12.3.9, security
observation types, Telegram notifier, PID-reuse worker start-guard).
Conflict resolution strategy:
- plugin/hooks/hooks.json, plugin/scripts/*.cjs, plugin/ui/viewer-bundle.js:
kept ours — PATHFINDER Plan 05 deletes the for-i-in-1-to-20 curl retry
loops and the built artifacts regenerate on build.
- src/cli/handlers/summarize.ts: kept ours — Plan 05 blocking
POST /api/session/end supersedes main's fire-and-forget path.
- src/services/worker-service.ts: kept ours — Plan 05 ingest bus +
summaryStoredEvent supersedes main's SessionCompletionHandler DI
refactor + orphan-reaper fallback.
- src/services/worker/http/routes/SessionRoutes.ts: kept ours — same
reason; generator .finally() Stop-hook self-clean is a guard for a
path our blocking endpoint removes.
- src/services/worker/http/routes/CorpusRoutes.ts: merged — added
security_alert / security_note to ALLOWED_CORPUS_TYPES (feature from
#2084) while preserving our Zod validateBody schema.
Typecheck: 294 errors (vs 298 pre-merge). No new errors introduced; all
remaining are pre-existing (Component-enum gaps, DOM lib for viewer,
bun:sqlite types).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address Greptile P2 findings
1) SessionRoutes.handleSessionEnd was the only route handler not wrapped
in wrapHandler — synchronous exceptions would hang the client rather
than surfacing as 500s. Wrap it like every other handler.
2) processor.handleToolResult only consumed the session.pendingTools
entry when the tool_result arrived without a toolName. In the
split-schema path where tool_result carries both toolName and toolId,
the entry was never deleted and the map grew for the life of the
session. Consume the entry whenever toolId is present.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: typing cleanup and viewer tsconfig split for PR feedback
- Add explicit return types for SessionStore query methods
- Exclude src/ui/viewer from root tsconfig, give it its own DOM-typed config
- Add bun to root tsconfig types, plus misc typing tweaks flagged by Greptile
- Rebuilt plugin/scripts/* artifacts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address Greptile P2 findings (iter 2)
- PendingMessageStore.transitionMessagesTo: require sessionDbId (drop
the unscoped-drain branch that would nuke every pending/processing
row across all sessions if a future caller omitted the filter).
- IngestEventBus.takeRecentSummaryStored: make idempotent — keep the
cached event until TTL eviction so a retried Stop hook's second
/api/session/end returns immediately instead of hanging 30 s.
- TranscriptWatcher fs.watch callback: skip full glob scan for paths
already tailed (JSONL appends fire on every line; only unknown
paths warrant a rescan).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: call finalizeSession in terminal session paths (Greptile iter 3)
terminateSession and runFallbackForTerminatedSession previously called
SessionCompletionHandler.finalizeSession before removeSessionImmediate;
the refactor dropped those calls, leaving sdk_sessions.status='active'
for every session killed by wall-clock limit, unrecoverable error, or
exhausted fallback chain. The deleted reapStaleSessions interval was
the only prior backstop.
Re-wires finalizeSession (idempotent: marks completed, drains pending,
broadcasts) into both paths; no reaper reintroduced.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: GC failed pending_messages rows at startup (Greptile iter 4)
Plan 07 deleted clearFailed/clearFailedOlderThan as "dead code", but
with the periodic sweep also removed, nothing reaps status='failed'
rows now — they accumulate indefinitely. Since claimNextMessage's
self-healing subquery scans this table, unbounded growth degrades
claim latency over time.
Re-introduces clearFailedOlderThan and calls it once at worker startup
(not a reaper — one-shot, idempotent). 7-day retention keeps enough
history for operator inspection while bounding the table.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: finalize sessions on normal exit; cleanup hoist; share handler (iter 5)
1. startSessionProcessor success branch now calls completionHandler.
finalizeSession before removeSessionImmediate. Hooks-disabled installs
(and any Stop hook that fails before POST /api/sessions/complete) no
longer leave sdk_sessions rows as status='active' forever. Idempotent
— a subsequent /api/sessions/complete is a no-op.
2. Hoist SessionRoutes.handleSessionEnd cleanup declaration above the
closures that reference it (TDZ safety; safe at runtime today but
fragile if timeout ever shrinks).
3. SessionRoutes now receives WorkerService's shared SessionCompletionHandler
instead of constructing its own — prevents silent divergence if the
handler ever becomes stateful.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: stop runaway crash-recovery loop on dead sessions
Two distinct bugs were combining to keep a dead session restarting forever:
Bug 1 (uncaught "The operation was aborted."):
child_process.spawn emits 'error' asynchronously for ENOENT/EACCES/abort
signal aborts. spawnSdkProcess() never attached an 'error' listener, so
any async spawn failure became uncaughtException and escaped to the
daemon-level handler. Attach an 'error' listener immediately after spawn,
before the !child.pid early-return, so async spawn errors are logged
(with errno code) and swallowed locally.
Bug 2 (sliding-window limiter never trips on slow restart cadence):
RestartGuard tripped only when restartTimestamps.length exceeded
MAX_WINDOWED_RESTARTS (10) within RESTART_WINDOW_MS (60s). With the 8s
exponential-backoff cap, only ~7-8 restarts fit in the window, so a dead
session that fail-restart-fail-restart on 8s cycles would loop forever
(consecutiveRestarts climbing past 30+ in observed logs). Add a
consecutiveFailures counter that increments on every restart and resets
only on recordSuccess(). Trip when consecutive failures exceed
MAX_CONSECUTIVE_FAILURES (5) — meaning 5 restarts with zero successful
processing in between proves the session is dead. Both guards now run in
parallel: tight loops still trip the windowed cap; slow loops trip the
consecutive-failure cap.
Also: when the SessionRoutes path trips the guard, drain pending messages
to 'abandoned' so the session does not reappear in
getSessionsWithPendingMessages and trigger another auto-start cycle. The
worker-service.ts path already does this via terminateSession.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* perf: streamline worker startup and consolidate database connections
1. Database Pooling: Modified DatabaseManager, SessionStore, and SessionSearch to share a single bun:sqlite connection, eliminating redundant file descriptors.
2. Non-blocking Startup: Refactored WorktreeAdoption and Chroma backfill to run in the background (fire-and-forget), preventing them from stalling core initialization.
3. Diagnostic Routes: Added /api/chroma/status and bypassed the initialization guard for health/readiness endpoints to allow diagnostics during startup.
4. Robust Search: Implemented reliable SQLite FTS5 fallback in SearchManager for when Chroma (uvx) fails or is unavailable.
5. Code Cleanup: Removed redundant loopback MCP checks and mangled initialization logic from WorkerService.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: hard-exclude observer-sessions from hooks; bundle migration 29 (#2124)
* fix: hard-exclude observer-sessions from hooks; backfill bundle migrations
Stop hook + SessionEnd hook were storing the SDK observer's own
init/continuation/summary prompts in user_prompts, leaking into the
viewer (meta-observation regression). 25 such rows accumulated.
- shouldTrackProject: hard-reject OBSERVER_SESSIONS_DIR (and its subtree)
before consulting user-configured exclusion globs.
- summarize.ts (Stop) and session-complete.ts (SessionEnd): early-return
when shouldTrackProject(cwd) is false, so the observer's own hooks
cannot bootstrap the worker or queue a summary against the meta-session.
- SessionRoutes: cap user-prompt body at 256 KiB at the session-init
boundary so a runaway observer prompt cannot blow up storage.
- SessionStore: add migration 29 (UNIQUE(memory_session_id, content_hash)
on observations) inline so bundled artifacts (worker-service.cjs,
context-generator.cjs) stay schema-consistent — without it, the
ON CONFLICT clause in observation inserts throws.
- spawnSdkProcess: stdio[stdin] from 'ignore' to 'pipe' so the
supervisor can actually feed the observer's stdin.
Also rebuilds plugin/scripts/{worker-service,context-generator}.cjs.
* fix: walk back to UTF-8 boundary on prompt truncation (Greptile P2)
Plain Buffer.subarray at MAX_USER_PROMPT_BYTES can land mid-codepoint,
which the utf8 decoder silently rewrites to U+FFFD. Walk back over any
continuation bytes (0b10xxxxxx) before decoding so the truncated prompt
ends on a valid sequence boundary instead of a replacement character.
* fix: cross-platform observer-dir containment; clarify SDK stdin pipe
claude-review feedback on PR #2124.
- shouldTrackProject: literal `cwd.startsWith(OBSERVER_SESSIONS_DIR + '/')`
hard-coded a POSIX separator and missed Windows backslash paths plus any
trailing-slash variance. Switched to a path.relative-based isWithin()
helper so Windows hook input under observer-sessions\\... is also excluded.
- spawnSdkProcess: added a comment explaining why stdin must be 'pipe' —
SpawnedSdkProcess.stdin is typed NonNullable and the Claude Agent SDK
consumes that pipe; 'ignore' would null it and the null-check below
would tear the child down on every spawn.
* fix: make Stop hook fire-and-forget; remove dead /api/session/end
The Stop hook was awaiting a 35-second long-poll on /api/session/end,
which the worker held open until the summary-stored event fired (or its
30s server-side timeout elapsed). Followed by another await on
/api/sessions/complete. Three sequential awaits, the middle one a 30s
hold — not fire-and-forget despite repeated requests.
The Stop hook now does ONE thing: POST /api/sessions/summarize to
queue the summary work and return. The worker drives the rest async.
Session-map cleanup is performed by the SessionEnd handler
(session-complete.ts), not duplicated here.
- summarize.ts: drop the /api/session/end long-poll and the trailing
/api/sessions/complete await; ~40 lines removed; unused
SessionEndResponse interface gone; header comment rewritten.
- SessionRoutes: delete handleSessionEnd, sessionEndSchema, the
SERVER_SIDE_SUMMARY_TIMEOUT_MS constant, and the /api/session/end
route registration. Drop the now-unused ingestEventBus and
SummaryStoredEvent imports.
- ResponseProcessor + shared.ts + worker-utils.ts: update stale
comments that referenced the dead endpoint. The IngestEventBus is
left in place dormant (no listeners) for follow-up cleanup so this
PR stays focused on the blocker.
Bundle artifact (worker-service.cjs) rebuilt via build-and-sync.
Verification:
- grep '/api/session/end' plugin/scripts/worker-service.cjs → 0
- grep 'timeoutMs:35' plugin/scripts/worker-service.cjs → 0
- Worker restarted clean, /api/health ok at pid 92368
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* deps: bump all dependencies to latest including majors
Upgrades: React 18→19, Express 4→5, Zod 3→4, TypeScript 5→6,
@types/node 20→25, @anthropic-ai/claude-agent-sdk 0.1→0.2,
@clack/prompts 0.9→1.2, plus minors. Adds Daily Maintenance section
to CLAUDE.md mandating latest-version policy across manifests.
Express 5 surfaced a race in Server.listen() where the 'error' handler
was attached after listen() was invoked; refactored to use
http.createServer with both 'error' and 'listening' handlers attached
before listen(), restoring port-conflict rejection semantics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: surface real chroma errors and add deep status probe
Replace the misleading "Vector search failed - semantic search unavailable.
Install uv... restart the worker." string in SearchManager with the actual
exception text from chroma_query_documents. The lying message blamed `uv`
for any failure — even when the real cause was a chroma-mcp transport
timeout, an empty collection, or a dead subprocess.
Also add /api/chroma/status?deep=1 backed by a new
ChromaMcpManager.probeSemanticSearch() that round-trips a real query
(chroma_list_collections + chroma_query_documents) instead of just
checking the stdio handshake. The cheap default path is unchanged.
Includes the diagnostic plan (PLAN-fix-mcp-search.md) and updated test
fixtures for the new structured failure message.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: rebuild worker-service bundle to match merged src
Bundle was stale after the squash merge of #2124 — it still contained
the old "Install uv... semantic search unavailable" string and lacked
probeSemanticSearch. Rebuilt via bun run build-and-sync.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: address coderabbit feedback on PLAN-fix-mcp-search.md
- replace machine-specific /Users/alexnewman absolute paths with portable
<repo-root> placeholder (MD-style portability)
- add blank lines around the TypeScript fenced block (MD031)
- tag the bare fenced block with `text` (MD040)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
45 KiB
Plan 02 — sqlite-persistence (clean)
Target: claude-mem v6.5.0 brutal-audit refactor, flowchart 3.3.
Design authority: PATHFINDER-2026-04-21/05-clean-flowcharts.md section 3.3.
Corrections authority: PATHFINDER-2026-04-21/06-implementation-plan.md Phase 0 verified-findings V12, V13, V14, V15, V19.
Date: 2026-04-22.
Dependencies
- Upstream (must land before this plan): none. This is a leaf plan.
- Downstream (blocked on this plan):
03-response-parsing-storage— depends onUNIQUE(session_id, tool_use_id)+ON CONFLICT DO NOTHINGadded in Phase 1 below (dedup gate moves from content-hash window to DB constraint).04-vector-search-sync— depends on thechroma_synced INTEGER DEFAULT 0column added in Phase 2 below. 04's whole backfill simplification (WHERE chroma_synced=0 LIMIT 1000) cannot ship until that column exists.07-session-lifecycle-management— depends on the boot-oncerecoverStuckProcessing()extracted in Phase 4 below (07 wires it into the worker startup sequence).
Reporting block 1 — Sources consulted
PATHFINDER-2026-04-21/05-clean-flowcharts.md— full file (607 lines). Section 3.3 is the canonical clean design for sqlite-persistence (lines 159–194). Part 1 items #15 (30-s dedup window → UNIQUE constraint, line 33), #16 (60-s claim stale-reset → boot recovery, line 34), #27 (Python sqlite3 repair →claude-mem repair, line 45), #28 (27 migrations →schema.sql+ upgrade-only runner, line 46). Part 5 ledger rows for SQLite referenced in06-implementation-plan.mdPhase 9.PATHFINDER-2026-04-21/06-implementation-plan.mdPhase 0 verified-findings:- V12 (line 39): audit claimed 27 migrations; reality is 19 private methods in
MigrationRunner.runAllMigrations()atrunner.ts:22–41; highestschema_versions.versionwritten is 27 (legacy system fromDatabaseManagercontributed ~5 more numbers). Plan target: "19 methods + legacy →schema.sql+ N upgrade-only migrations". - V13 (line 40): Python sqlite3 subprocess lives in production code (
Database.ts:79–99, not just tests). Test file exists attests/services/sqlite/schema-repair.test.ts(253 lines). Phase 5 must delete from production; test file becomes a CLI test. - V14 (line 41):
DEDUP_WINDOW_MS = 30_000atobservations/store.ts:13. Dedup key is SHA-256 of(memory_session_id, title, narrative)at:21–29— NOTtool_use_id. The new UNIQUE is an additive gate (different key space); it does not automatically subsume every path the content-hash hit. - V15 (line 42): No
chroma_syncedcolumn exists today; Phase 2 creates it. - V19 (line 46):
STALE_PROCESSING_THRESHOLD_MS = 60_000atPendingMessageStore.ts:6; stale reset happens inside everyclaimNextMessage()call (lines 99–145). - Phase 9 (lines 412–448) is prior scope draft — superseded where this plan differs.
- V12 (line 39): audit claimed 27 migrations; reality is 19 private methods in
PATHFINDER-2026-04-21/01-flowcharts/sqlite-persistence.md— "before" diagram (97 lines). Confirms: 27 migrations claim (V12 corrects), content-hash dedup with 30-s window, claim-confirm self-heal, Python schema repair at boot.- Live codebase:
src/services/sqlite/Database.ts(359 lines). Python repair at:37–109, reopen wrapper at:115–132, PRAGMA block at:163–168,MigrationRunnerinvocation at:171–172.src/services/sqlite/migrations/runner.ts(1018 lines). 19 private methods listed at:22–41. Schema-version INSERTs write versions {4,5,6,7,8,9,10,11,16,17,19,20,21,22,23,24,25,27} — gaps (12–15, 18, 26) confirm the legacyDatabaseManagernumbering V12 mentions.src/services/sqlite/observations/store.ts(108 lines).DEDUP_WINDOW_MSat:13,computeObservationContentHashat:21–30,findDuplicateObservationat:36–46,storeObservationat:53–108.src/services/sqlite/PendingMessageStore.ts(529 lines).STALE_PROCESSING_THRESHOLD_MSat:6, stale-reset block insideclaimNextMessagetransaction at:99–145(reset SQL at:107–115, peek at:118–124, mark-processing at:129–134).tests/services/sqlite/schema-repair.test.ts(253 lines) — Python script invoked viaexecSync, per V13.tests/services/sqlite/migration-runner.test.ts(361 lines) — existing migration regression tests; these must still pass after consolidation.- No
src/services/sqlite/schema.sqlexists today (grep confirms). Phase 3 must create it.
PATHFINDER-2026-04-21/07-plans/— empty of dependency plans (this is the first plan written).
Reporting block 2 — Concrete findings
| Claim | Verified? | Evidence |
|---|---|---|
| Migration method count is 22 (V12 audit) | Partially — actual is 19 private methods enumerated in runAllMigrations at runner.ts:22–41. 27 is the highest schema_versions.version written (legacy DatabaseManager migrations 1–3, 12–15, 18, 26 contribute the gap). |
runner.ts:22–41 + grep of schema_versions.*VALUES.*run(N) lines. |
| Highest current schema version is 27 | Yes — last INSERT at runner.ts:1015 writes version 27 for addObservationSubagentColumns. |
runner.ts:1015. |
UNIQUE(session_id, tool_use_id) exists today |
No — zero references to tool_use_id anywhere under src/services/sqlite/. The identifier only appears in src/types/transcript.ts and src/services/worker/SDKAgent.ts (input payload shape). |
Grep tool_use_id in src/services/sqlite/ returns zero files. |
Dedup is content-hash based, NOT tool_use_id |
Yes — computeObservationContentHash hashes (memory_session_id, title, narrative) at store.ts:21–29. Subagent agent_type/agent_id intentionally excluded per the comment at :18–19. |
store.ts:13–46. |
chroma_synced column exists |
No — no migration adds it; no reference in runner.ts or any store. |
Grep confirms. |
| 60-s stale reset fires per-claim, not at boot | Yes — reset UPDATE lives inside the claimTx transaction at PendingMessageStore.ts:107–115, run every time claimNextMessage() is called. |
PendingMessageStore.ts:99–145. |
| Python sqlite3 lives in production, not just tests | Yes — execFileSync('python3', [scriptPath, dbPath, objectName], ...) at Database.ts:99 inside the production repairMalformedSchema function (:37–109). Test file at tests/services/sqlite/schema-repair.test.ts exercises that production code path. |
Database.ts:99. |
schema.sql file exists today |
No — Phase 3 must create it. "HOW" is detailed below (dump current state from a clean fresh-install DB). | Glob **/*.sql under src/ returns zero. |
Net count correction propagated to every phase below: "19 methods (not 22 or 27)" where migration count is cited.
Reporting block 3 — Copy-ready snippet locations
| Destination | Source file:line | What to copy |
|---|---|---|
src/services/sqlite/migrations/2026-04-22_add_observations_tool_use_id.ts (new upgrade migration) |
Existing patterns from runner.ts:658–842 (migration addOnUpdateCascadeToForeignKeys, idempotent ALTER) |
The idempotent "check column via PRAGMA table_info, ALTER if missing, mark schema_versions" pattern. |
src/services/sqlite/observations/store.ts (Phase 1 rewrite) |
Existing INSERT shape at store.ts:77–102 |
Keep the 17-column INSERT layout; only change the body from "compute hash → check dup → INSERT" to "INSERT … ON CONFLICT (memory_session_id, tool_use_id) DO NOTHING RETURNING id". |
src/services/sqlite/migrations/2026-04-23_add_observations_chroma_synced.ts (new upgrade migration) |
Pattern from addObservationContentHashColumn at runner.ts:844–864 |
Exact template: PRAGMA table_info → ALTER TABLE observations ADD COLUMN chroma_synced INTEGER DEFAULT 0 → record version. |
src/services/sqlite/schema.sql (new — created in Phase 3) |
runner.ts:52–124 (initializeSchema block) + tables from migrations 5,6,8,9,10,11,16,17,19,20,21,22,23,24,25,27 |
Run the current MigrationRunner end-to-end on a fresh :memory: DB, then dump via SELECT sql FROM sqlite_master WHERE type IN ('table','index') ORDER BY rootpage — this is the authoritative generator. Detail in Phase 3 tasks. |
src/services/sqlite/PendingMessageStore.ts (Phase 4) |
Stale-reset block at PendingMessageStore.ts:107–115 |
Copy the SQL verbatim into a new recoverStuckProcessing() method; delete the copy from inside claimTx. claimNextMessage keeps only peek (:118–124) + mark-processing (:129–134) inside its transaction. |
src/cli/handlers/repair.ts (new — Phase 5) |
Database.ts:79–107 (Python script body + execFileSync call) |
Move the whole Python-script-written-to-tempfile + execFileSync pattern into a user-invoked CLI command handler; remove boot-time auto-call. |
Reporting block 4 — Confidence + gaps
Confidence: HIGH on:
- Phases 1, 2, 4, 6 — all reference existing, stable code (V14/V15/V19 are pinned to single-file call sites).
- Phase 5 — Python block is small (~70 lines of wrapper + embedded script at
Database.ts:37–109) and test coverage already exists attests/services/sqlite/schema-repair.test.ts.
Confidence: MEDIUM on:
- Phase 3 (schema.sql generation).
schema.sqldoes not exist today. The mechanical path is: (a) spin up:memory:DB, (b) run currentMigrationRunner.runAllMigrations()unchanged, (c) dumpSELECT sql FROM sqlite_masterin a stable order, (d) check the dump into the repo. Risk: FTS5 virtual tables and their implicit rowid-shadow tables may need hand-tuning becausesqlite_masterincludes internal*_content/*_idxtables that must NOT be inschema.sql(they're auto-created by theCREATE VIRTUAL TABLE USING fts5statement). The schema.sql generator must filtername NOT LIKE '%_content' AND name NOT LIKE '%_segments' AND name NOT LIKE '%_segdir' AND name NOT LIKE '%_docsize' AND name NOT LIKE '%_config'(all standard FTS5 shadow-table suffixes). - Phase 1 ordering w.r.t. Phase 6. Dropping
DEDUP_WINDOW_MS+findDuplicateObservation(Phase 6) ONLY after Phase 1 lands AND verification proves every observation-ingest path writes atool_use_id. The transcript-watcher ingest path (src/services/transcripts/watcher.ts, referenced by downstream plan07-session-lifecycle-management) may emit observations wheretool_use_idis derived from JSONL line parsing rather than the hook payload — if that path produces a non-unique or missingtool_use_id, the UNIQUE constraint will not cover it and the content-hash gate still provides value. Phase 6 is gated by a concrete grep + runtime check that every call site intostoreObservationsupplies a realtool_use_id.
Top gaps:
schema.sqldoesn't exist today — must be generated mechanically. Phase 3 specifies the exact generator script so this is reproducible. The risk is that FTS5 shadow tables leak into the dump; the filter list above must be applied. If a future migration adds aUSING fts5virtual table with a non-default suffix, the filter will need updating.- Dedup semantics may differ across ingest paths. V14 confirms the current dedup key (SHA of title+narrative) and V14's warning applies: the transcript watcher,
/api/sessions/observationshook path, and/sessions/:id/observationslegacy path may each derivetool_use_iddifferently. Phase 1 adds the UNIQUE constraint but Phase 6 (dedup-window removal) must verify all three paths supply a consistenttool_use_idBEFORE the content-hash fallback is deleted. If the transcript-watcher path uses synthetic IDs (e.g.,file:offset) instead of the real Claude Codetool_use_id, that's a real gap to flag to the owner of plan07-session-lifecycle-managementbefore both plans land.
Phase contract — template applied below
Every phase specifies:
- (a) What to implement — framed as "Copy from
<file>:<line>into<dest>". - (b) Documentation references — 05 section + V-numbers + live file:line.
- (c) Verification checklist — concrete greps + tests.
- (d) Anti-pattern guards — A (invent migration methods), B (polling), C (silent fallback), E (two dedup paths).
Phase 1 — Add UNIQUE(session_id, tool_use_id) and ON CONFLICT DO NOTHING INSERT
Outcome: Observations have a tool_use_id column; (memory_session_id, tool_use_id) is UNIQUE; storeObservation uses INSERT ... ON CONFLICT DO NOTHING RETURNING id (idempotent, constraint-based). Content-hash dedup still runs underneath (removed in Phase 6 after verification).
(a) Tasks
- Create new migration
src/services/sqlite/migrations/(add a method toMigrationRunner.runAllMigrationsbetweenaddObservationSubagentColumns(line 41) and a new methodaddObservationToolUseIdUnique, assigningschema_versions.version = 28).- Copy the idempotent pattern from
addObservationContentHashColumnatrunner.ts:844–864:PRAGMA table_info(observations)→ iftool_use_idcolumn missing,ALTER TABLE observations ADD COLUMN tool_use_id TEXT. - Backfill legacy rows:
UPDATE observations SET tool_use_id = 'legacy:' || id WHERE tool_use_id IS NULL. Legacy synthetic IDs must be unique across existing rows (rowidis unique by PK) and prefixed so future realtool_use_idvalues never collide. - Create unique partial index:
CREATE UNIQUE INDEX IF NOT EXISTS idx_observations_session_tool_use_id ON observations(memory_session_id, tool_use_id) WHERE tool_use_id IS NOT NULL. - Register version 28.
- Copy the idempotent pattern from
- Rewrite
src/services/sqlite/observations/store.ts:53–108(storeObservation):- Add
tool_use_id: stringtoObservationInput(src/services/sqlite/observations/types.ts). - Replace the INSERT at
:77–102with:INSERT INTO observations (memory_session_id, project, type, title, subtitle, facts, narrative, concepts, files_read, files_modified, prompt_number, discovery_tokens, agent_type, agent_id, content_hash, tool_use_id, created_at, created_at_epoch) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(memory_session_id, tool_use_id) DO NOTHING RETURNING id, created_at_epoch - If
RETURNINGreturns a row → new insert, return it. - If no row returned → SELECT the existing row:
SELECT id, created_at_epoch FROM observations WHERE memory_session_id = ? AND tool_use_id = ?and return. - Keep
computeObservationContentHashandfindDuplicateObservationand the pre-INSERT dedup check intact in this phase. Phase 6 removes them. (Rationale: additive gate first, drop old gate only after confirming coverage — anti-pattern E avoidance.)
- Add
- Wire
tool_use_idthrough every call site that creates an observation. Grep: everystoreObservation(caller must now passtool_use_id. The three known ingest paths are (i)/api/sessions/observationsHTTP route, (ii)/sessions/:id/observationslegacy route, (iii) transcript-watcher ingest. Each must readtool_use_idfrom the incoming payload (hook sends it; transcript JSONL lines contain it).
(b) Documentation references
05-clean-flowcharts.mdsection 3.3, line 172 (INSERT observations UNIQUE(session_id, tool_use_id)) and line 188 (deletion ledger entry). Part 1 item #15 at line 33.- Verified-finding V14 (
06-implementation-plan.md:41). - Live code:
observations/store.ts:13–108,runner.ts:844–864(copy-from template).
(c) Verification checklist
- Grep:
grep -n "tool_use_id" src/services/sqlite/returns at least 3 hits (types, store INSERT, migration). - Grep:
grep -n "tool_use_id" src/services/worker/http/routes/SessionRoutes.tsconfirms both observation route handlers read it from body. - New unit test
tests/services/sqlite/observations/unique-constraint.test.ts: insert two observations with same(memory_session_id, tool_use_id); assert second returns the first'sid; assertSELECT COUNT(*) FROM observationsincremented by exactly 1. - Existing
tests/services/sqlite/migration-runner.test.ts(361 lines) still passes — no regressions on migrations 4–27. - Fresh-install smoke: delete DB, boot worker, confirm
PRAGMA index_list(observations)includesidx_observations_session_tool_use_id. - Upgrade smoke: copy a v6.5.0 DB into place, boot worker, confirm legacy rows got
tool_use_id = 'legacy:<id>'and new index exists.
(d) Anti-pattern guards
- A (invent migration methods): do NOT add any migration method besides
addObservationToolUseIdUniquein this phase. Enumerate before adding. - C (silent fallback):
ON CONFLICT DO NOTHINGis idempotent, not silent — conflicts are expected and return the existing id. The route handler must not treat "no new row inserted" as an error; the caller gets the existing id back. - E (two dedup paths): both dedup gates are present in this phase intentionally. The old one exits in Phase 6 after every path is verified.
Blast radius
Schema change (one new column, one new index). Hook + route payload shapes gain tool_use_id. No runtime behavior change on happy path (first INSERT wins as before); conflict path now returns the existing id faster (no pre-check query, one INSERT round-trip).
Phase 2 — Add chroma_synced column (blocks plan 04)
Outcome: observations.chroma_synced INTEGER DEFAULT 0, session_summaries.chroma_synced INTEGER DEFAULT 0, and user_prompts.chroma_synced INTEGER DEFAULT 0 exist. Partial index on chroma_synced = 0 for the backfill scan on all three tables. Plan 04-vector-search-sync can now consume these.
Preflight edit 2026-04-22 (reconciliation C3): The original phase covered only
observations+session_summaries. Reconciliation identified that plan 04 also backfillsuser_prompts, so this phase must add the column there too. Migration body below extends to all three tables.
(a) Tasks
- Add migration method
addChromaSyncedColumnstoMigrationRunner.runAllMigrations(between the newaddObservationToolUseIdUniquefrom Phase 1 and end of list), assigningschema_versions.version = 29.- Template:
addObservationContentHashColumnatrunner.ts:844–864. - Body:
const obsInfo = this.db.query('PRAGMA table_info(observations)').all() as TableColumnInfo[]; if (!obsInfo.some(c => c.name === 'chroma_synced')) { this.db.run('ALTER TABLE observations ADD COLUMN chroma_synced INTEGER NOT NULL DEFAULT 0'); } const sumInfo = this.db.query('PRAGMA table_info(session_summaries)').all() as TableColumnInfo[]; if (!sumInfo.some(c => c.name === 'chroma_synced')) { this.db.run('ALTER TABLE session_summaries ADD COLUMN chroma_synced INTEGER NOT NULL DEFAULT 0'); } const promptInfo = this.db.query('PRAGMA table_info(user_prompts)').all() as TableColumnInfo[]; if (!promptInfo.some(c => c.name === 'chroma_synced')) { this.db.run('ALTER TABLE user_prompts ADD COLUMN chroma_synced INTEGER NOT NULL DEFAULT 0'); } this.db.run('CREATE INDEX IF NOT EXISTS idx_observations_chroma_synced ON observations(chroma_synced) WHERE chroma_synced = 0'); this.db.run('CREATE INDEX IF NOT EXISTS idx_summaries_chroma_synced ON session_summaries(chroma_synced) WHERE chroma_synced = 0'); this.db.run('CREATE INDEX IF NOT EXISTS idx_prompts_chroma_synced ON user_prompts(chroma_synced) WHERE chroma_synced = 0'); this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(29, new Date().toISOString());
- Template:
- Do NOT modify
ChromaSync.tsin this phase — that is plan 04's responsibility. This phase only lands the schema.
(b) Documentation references
05-clean-flowcharts.mdsection 3.4 line 226 ("Adds:chroma_syncedboolean column onobservations. Schema migration.").- Verified-finding V15 (
06-implementation-plan.md:42). - Live code:
runner.ts:844–864(copy template).
(c) Verification checklist
PRAGMA table_info(observations)on a fresh-boot DB includeschroma_synced.PRAGMA table_info(session_summaries)includeschroma_synced.PRAGMA table_info(user_prompts)includeschroma_synced.- Partial indexes exist:
SELECT name FROM sqlite_master WHERE type='index' AND name LIKE '%chroma_synced%'returns 3 rows. - Upgrade smoke: on a pre-Phase-2 DB, both ALTERs run exactly once; second boot is a no-op (idempotency gate).
migration-runner.test.tsextended with a case assertingschema_versions.version = 29after fresh install.
(d) Anti-pattern guards
- A: one method, one version. Do not add a backfill-on-migration step here (that's plan 04).
- E: do NOT touch
ChromaSync.tswrite path in this phase; keep concerns isolated so plans can land independently.
Blast radius
Pure additive schema. Zero runtime behavior change until plan 04 starts writing to the column.
Phase 3 — Consolidate 19 migrations into schema.sql + slim upgrade-only runner
Outcome: Fresh DBs execute src/services/sqlite/schema.sql in one shot and write schema_versions.version = <current>. Existing DBs continue running only upgrade-step migrations whose version is > max(schema_versions.version). The 19 CREATE TABLE IF NOT EXISTS / ALTER TABLE idempotency bodies shrink dramatically since fresh-DB paths no longer traverse them.
(a) Tasks
- Generate
src/services/sqlite/schema.sqlby a reproducible script, not by hand:- Write a one-shot generator at
scripts/dump-schema.ts:import { Database } from 'bun:sqlite'; import { MigrationRunner } from '../src/services/sqlite/migrations/runner.js'; import { writeFileSync } from 'fs'; const db = new Database(':memory:'); new MigrationRunner(db).runAllMigrations(); // Filter out FTS5 shadow tables — they're created automatically by CREATE VIRTUAL TABLE. const rows = db.query(` SELECT sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_content' AND name NOT LIKE '%_segments' AND name NOT LIKE '%_segdir' AND name NOT LIKE '%_docsize' AND name NOT LIKE '%_config' AND name NOT LIKE '%_data' AND name NOT LIKE '%_idx' ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 WHEN 'trigger' THEN 2 ELSE 3 END, name `).all() as { sql: string }[]; writeFileSync('src/services/sqlite/schema.sql', rows.map(r => r.sql + ';').join('\n\n') + '\n'); - Run
bun run scripts/dump-schema.ts, commit the resultingschema.sql. schema.sqlmust end withINSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (29, datetime('now'));(where 29 = current max after Phases 1 and 2).
- Write a one-shot generator at
- Rewrite
Database.ts:171–172to check for fresh DB:- After PRAGMAs, query
SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_versions'. - If zero (true fresh DB): read
schema.sql(bundled viaimport.metaor FS at a known path), execute viadb.exec(sql), done. - Else: run
MigrationRunneras today (it's already idempotent per-migration viaPRAGMA table_infochecks).
- After PRAGMAs, query
- DO NOT delete the 19 migration methods. They remain as upgrade paths for existing DBs from v6.4.x or earlier. What shrinks is the fresh-install path cost (19 idempotent ALTER checks → 1
db.exec(schema.sql)). - Add a CI check in
tests/services/sqlite/schema-consistency.test.ts: runs the dump-schema generator in-memory, diffs against the checked-inschema.sql; fails if they drift. This is the only way to keepschema.sqlhonest as new migrations land.
(b) Documentation references
05-clean-flowcharts.mdsection 3.3 lines 166–170 (Boot → Check → Fresh? → Executeschema.sqlvs Migrate). Line 191 in the deletion ledger.- Verified-finding V12 (
06-implementation-plan.md:39) — confirms 19 methods, not 27. - Live code:
Database.ts:163–173(boot sequence),runner.ts:22–41(method list). - Gap note from reporting block 4 (#1): the FTS5 shadow-table filter list in the generator is non-obvious; comment it inline with a link to the SQLite FTS5 docs section on shadow tables.
(c) Verification checklist
ls src/services/sqlite/schema.sqlexists and is > 0 bytes.- Fresh-install test: delete DB → boot → dump
sqlite_master→ byte-equal toschema.sqlcontent (modulo theschema_versionsINSERT). - Upgrade test: copy a v6.4 fixture DB → boot → all 19 migration methods run → final schema matches
schema.sql. schema-consistency.test.ts(new) passes on CI.migration-runner.test.ts(existing, 361 lines) still passes — upgrade path is unchanged.- No FTS5 shadow table names appear in
schema.sql(grep:_content\|_segments\|_segdir\|_docsize\|_config\|_data\|_idxreturns zero).
(d) Anti-pattern guards
- A (invent migration methods):
schema.sqlis NOT a replacement for the runner's upgrade methods — it's a fresh-install fast-path. Don't invent a "migration framework".db.exec()+ a list of functions is the whole system. - C (silent fallback): if
schema.sqlparsing throws on boot, do not fall back to running the runner from scratch — fail boot with a clear error. A fresh-DB schema failure is a shipped-bug bug; users should see it.
Blast radius
Fresh-install boot drops from ~19 idempotency checks to one db.exec. Existing DBs: identical behavior. Risk: schema.sql drift from runner — mitigated by the consistency test.
Lines deleted estimate for this phase alone: 0 net from runner (methods stay for upgrades). Lines added: ~200 for schema.sql, ~30 for consistency test, ~15 for boot branch.
Phase 4 — Move all SQLite housekeeping to boot-once (revised 2026-04-22)
Outcome: zero repeating SQLite-related setIntervals anywhere in the worker. PendingMessageStore.claimNextMessage() becomes pure SELECT+UPDATE (no self-healing per call). Three boot-once jobs exist on PendingMessageStore / Database, called exactly once at worker startup:
recoverStuckProcessing()— resetsstatus='processing'rows left by a crashed prior worker.clearFailedOlderThan(1h)— prunes old failed rows that accumulated before this boot (no schema constraint requires periodic execution; see Reporting block 2).- Deletion of the periodic
PRAGMA wal_checkpoint(PASSIVE)call — replaced by SQLite's nativewal_autocheckpointdefault (1000 pages).Database.ts:162-168sets no override so the default is already active; no new code is required.
Why zero-timer (authoritative rationale, supersedes any older plan text): SQLite auto-checkpoints when the WAL reaches 1000 pages of writes, which is the correct contract for a long-running worker. An explicit 2-min PRAGMA wal_checkpoint(PASSIVE) call accelerates checkpoints beyond that default but is not required for correctness — it was a band-aid layered on top of the stale-reaper interval (worker-service.ts:547-589). Similarly, clearFailedOlderThan(1h) running every 2 min purges rows that realistically accumulate at single-digit-per-hour rates; once-per-boot is sufficient and no pending_messages query cares about row count or stale-row presence. See 08-reconciliation.md Part 4 revised cross-check (Invariant 4).
(a) Tasks
- Add new method
PendingMessageStore.recoverStuckProcessing():- Copy the stale-reset SQL block from
PendingMessageStore.ts:106–115verbatim into the new method:recoverStuckProcessing(): number { const staleCutoff = Date.now() - STALE_PROCESSING_THRESHOLD_MS; const resetStmt = this.db.prepare(` UPDATE pending_messages SET status = 'pending', started_processing_at_epoch = NULL WHERE status = 'processing' AND started_processing_at_epoch < ? `); const result = resetStmt.run(staleCutoff); if (result.changes > 0) { logger.info('QUEUE', `BOOT_RECOVERY | recovered ${result.changes} stale processing message(s)`); } return result.changes as number; } - Note the SQL changes one thing: no
session_db_id = ?predicate — boot recovery is global across all sessions.
- Copy the stale-reset SQL block from
- Delete
PendingMessageStore.ts:103–116(thestaleCutoff/resetStmtblock insideclaimTx). The transaction body shrinks to peek (lines 118–124) + mark-processing (lines 129–134). - Confirm
clearFailedOlderThan()is callable standalone. Current signature atPendingMessageStore.ts:486-495accepts athresholdMsnumber and runs a single-statement UPDATE/DELETE. No change to the method body; this phase only moves where it is called from. No new method is added for this — the existing one is sufficient. - Delete the explicit
PRAGMA wal_checkpoint(PASSIVE)call fromworker-service.ts:~581as part of plan 07 Phase 4's deletion of the stale-reaper block (worker-service.ts:547-589). This plan is the authority that it is safe to delete:Database.ts:162-168setsjournal_mode=WAL,synchronous=NORMAL,cache_size,mmap_size, and leaveswal_autocheckpointat SQLite's default (1000 pages). No override was ever introduced. Verification in (c) confirms. - Wire the three boot calls in the downstream plan
07-session-lifecycle-managementPhase 3 Mechanism C (boot-once reconciliation block). That plan's responsibility to placependingStore.recoverStuckProcessing()andpendingStore.clearFailedOlderThan(60 * 60 * 1000)in the worker startup sequence. This plan adds/confirms the methods but does not modifyworker-service.tsdirectly (single-responsibility per plan).
(b) Documentation references
05-clean-flowcharts.mdsection 3.3 lines 183–184 ("Worker startup ONCE (not on every claim) … crash recovery") and line 190 (deletion ledger).05-clean-flowcharts.mdPart 2 D3 (revised 2026-04-22 — zero repeating background timers).05-clean-flowcharts.mdPart 4 timer census (revised —clearFailedOlderThanandPRAGMA wal_checkpointexplicit disposition).- Part 1 item #16 (line 34) and Part 2 decision on "Crash-recovery that solves a real OS-level problem … keep but consolidate".
- Verified-finding V19 (
06-implementation-plan.md:46). 08-reconciliation.mdPart 4 revised — Invariant 4 (SQLite auto-checkpoint default is active).- Live code:
PendingMessageStore.ts:6(threshold),:99–145(fullclaimNextMessage),:486-495(clearFailedOlderThan),Database.ts:162-168(PRAGMA block — confirms nowal_autocheckpointoverride),worker-service.ts:547-589(stale-reaper block being deleted by plan 07 Phase 4).
(c) Verification checklist
- Grep:
grep -n "STALE_PROCESSING_THRESHOLD_MS" src/services/sqlite/PendingMessageStore.ts→ 2 matches max (constant +recoverStuckProcessingbody). - Grep:
grep -n "status = 'processing'" src/services/sqlite/PendingMessageStore.tsfinds exactly one UPDATE that flips processing→pending (inrecoverStuckProcessing), NOT inclaimNextMessage. - Inspect
claimNextMessage: transaction body has no UPDATE-to-pending step. - Grep:
grep -rn "clearFailedOlderThan" src/→ exactly 2 matches (the method definition inPendingMessageStore.tsand a single call site in the boot-once reconciliation block insideworker-service.ts). No call inside anysetIntervalor handler. - Grep:
grep -rn "wal_checkpoint" src/services/worker/ src/services/worker-service.ts→ 0 matches inworker-service.ts. If the codebase introduces an observability read ofPRAGMA wal_autocheckpointat boot for logging purposes, that is fine — but no explicitPRAGMA wal_checkpoint(...)execution anywhere. - Grep:
grep -n "wal_autocheckpoint" src/services/sqlite/Database.ts→ 0 matches (confirms we are relying on SQLite's default of 1000 pages; any future non-zero override must be reviewed against this plan). - Grep:
grep -rn "setInterval" src/services/sqlite/ src/services/worker-service.ts→ 0 matches for SQLite-related intervals. - New unit test
tests/services/sqlite/PendingMessageStore.boot-recovery.test.ts:- Insert a row with
status='processing',started_processing_at_epoch = Date.now() - 2*60_000. - Call
recoverStuckProcessing(); assert return = 1; assertstatus='pending'andstarted_processing_at_epoch=NULL.
- Insert a row with
- New unit test
tests/services/sqlite/PendingMessageStore.failed-purge.test.ts:- Insert three
status='failed'rows withupdated_at_epochvaluesnow-2h,now-30min,now-5min. - Call
clearFailedOlderThan(60 * 60 * 1000); assert exactly thenow-2hrow is removed; the other two remain.
- Insert three
- WAL-checkpoint regression test: with
wal_autocheckpointat SQLite default, write > 1000 pages to the DB in a loop; assert the WAL file size stabilizes (does not grow unbounded). Proves the default is sufficient without explicitPRAGMA wal_checkpoint. - Existing
tests/services/sqlite/PendingMessageStore.test.tstests forclaimNextMessagestill pass, but the "self-healing" test case (if present) is rewritten againstrecoverStuckProcessinginstead.
(d) Anti-pattern guards
- B (no polling, no new interval): none of the three boot-once jobs may run on a timer, inside
claimNextMessage, or inside any request handler. Boot-once is the contract. The canonical check isgrep -rn "setInterval" src/services/sqlite/ src/services/worker-service.ts→ 0. - A (no invented abstractions): no
SqliteHousekeepingServiceclass, noBootRecoveryOrchestrator. The three calls live as three plain method invocations inside plan 07's boot-once reconciliation block. If a fourth housekeeping job appears later, then extract. - D (no facade-over-facade):
clearFailedOlderThanis called directly onPendingMessageStore— do not add ahousekeepFailed()wrapper that just forwards.
Blast radius
PendingMessageStore (new method + deletion of in-transaction self-heal) and — through plan 07's boot block — worker-service.ts (deletion of the periodic wal_checkpoint + clearFailedOlderThan calls inside the stale-reaper interval). Downstream 07-session-lifecycle-management adds the call sites; until that plan lands, recoverStuckProcessing() is dead code (acceptable — additive, doesn't break anything). Deleting the explicit wal_checkpoint call has no user-visible effect; the WAL grows slightly larger between auto-checkpoints, which is within SQLite's designed behavior.
Phase 5 — Delete Python sqlite3 schema-repair; replace with user-facing claude-mem repair
Outcome: Database.ts:37–132 (repairMalformedSchema + repairMalformedSchemaWithReopen) gone. Production boot never shells out to Python. A new CLI subcommand claude-mem repair exists (or is stubbed with a documented follow-up plan) for users hitting pre-v6.5 corruption.
(a) Tasks
- Delete
Database.ts:2–5(imports:execFileSync,fshelpers,tmpdir,path.join) andDatabase.ts:37–132(bothrepairMalformedSchemafunctions and their reopen wrapper). - Delete
Database.ts:160(the call torepairMalformedSchemaWithReopen) in theClaudeMemDatabaseconstructor. PRAGMAs now execute directly afternew Database(). - Create CLI subcommand
src/cli/handlers/repair.ts:- Copy the Python script body +
execFileSyncpattern from the deletedDatabase.ts:81–99verbatim. - Expose via
src/cli/index.ts(or wherever subcommand dispatch lives) asclaude-mem repair. - On success, print a human-readable summary: "Dropped N orphaned schema objects; reset migration versions. Restart the worker."
- On failure: exit code 1 with the Python error surfaced.
- Acceptable alternative if CLI scaffolding is heavier than expected: ship this phase as a stub handler that prints a "Feature scheduled — see follow-up plan [link]" message and register the follow-up plan explicitly. Do not leave the production Python path alive "until the CLI is ready" — the boot-time auto-repair must be deleted in this phase.
- Copy the Python script body +
- Move the existing test
tests/services/sqlite/schema-repair.test.ts(253 lines) to exercise the CLI handler instead of the production boot path. If the stub route is taken, the test becomes a skipped/TODO stub with a reference to the follow-up plan.
(b) Documentation references
05-clean-flowcharts.mdPart 1 item #27 (line 45): "Users on malformed DBs from v<X run a one-shotclaude-mem repaircommand manually."- Section 3.3 deletion ledger line 187 (~120 lines estimate).
- Verified-finding V13 (
06-implementation-plan.md:40). - Live code:
Database.ts:37–132(delete),tests/services/sqlite/schema-repair.test.ts(repoint).
(c) Verification checklist
grep -n "execFileSync\|execSync" src/services/sqlite/→ zero hits.grep -n "python3" src/services/→ zero hits.grep -rn "repairMalformedSchema" src/→ zero hits.wc -l src/services/sqlite/Database.tsshows ~100 fewer lines than today (359 → ~260).claude-mem repair --helpprints usage (or stub message with follow-up-plan link).- Fresh boot smoke: start worker with a healthy DB; confirm no Python process spawned (check
psor instrumentation log). - Malformed-DB smoke: deliberately corrupt
sqlite_master, boot worker → expect a clean error with instruction "runclaude-mem repair" (not a silent auto-heal).
(d) Anti-pattern guards
- C (silent fallback): boot must not auto-recover from malformed schema. Surface the error. That's the whole point of V13's call-out.
- A: do not invent an
AutoRepairService. One CLI handler, done. - E:
claude-mem repairis the ONE repair entry point. Delete everywhere else.
Blast radius
Boot path simplifies. Users on corrupt DBs get a clear message instead of silent auto-fix. Risk: users accustomed to auto-repair will see hard failure — mitigated by the message pointing at claude-mem repair.
Lines deleted estimate: ~100 from Database.ts.
Phase 6 — Delete DEDUP_WINDOW_MS + findDuplicateObservation (gated on Phase 1 verification)
Outcome: Content-hash dedup window removed. UNIQUE constraint is the sole dedup gate. store.ts drops to the single INSERT-with-conflict path.
CRITICAL GATE: this phase ONLY runs after the gap in reporting block 4 (#2) has been closed: every call site into storeObservation provably supplies a real, hook-or-transcript-sourced tool_use_id. Before running the rm commands below, execute the verification grep AND the integration test described.
(a) Tasks
Pre-phase gate (must pass before any deletion):
- Run
grep -rn "storeObservation(" src/→ enumerate every caller. - For each caller, trace the
tool_use_idfield back to its source. Must be either (i) the Claude Code hook payload (tool_use_idfield fromPostToolUse), (ii) a JSONL transcript line'stool_use_id, or (iii) a synthetic-but-stable identifier documented in the caller's comments. - If any caller has no stable
tool_use_id, stop. Flag to plan owner, keep content-hash fallback, exit this phase.
If gate passes:
- Delete from
observations/store.ts:- Line 13 (
DEDUP_WINDOW_MS). - Lines 21–30 (
computeObservationContentHashexport) — KEEP the column and the value written into it for analytics, but the function itself is no longer a public export; inline the SHA computation insidestoreObservationso the column still gets populated on INSERT. Alternative: keepcomputeObservationContentHashas a utility if any caller outside this file uses it (grep first; V14 implies it's only used here). - Lines 36–46 (
findDuplicateObservation). - Lines 69–75 (the pre-INSERT dup check block).
- Line 13 (
- Simplify
storeObservationbody to a single INSERT path (the one added in Phase 1).
(b) Documentation references
05-clean-flowcharts.mdsection 3.3 lines 188–189 (deletion ledger).- Verified-finding V14 (
06-implementation-plan.md:41). - Gap #2 in reporting block 4 above — this phase's gate is the closure mechanism for that gap.
(c) Verification checklist
- Grep:
grep -rn "DEDUP_WINDOW_MS\|findDuplicateObservation" src/→ zero hits. - Grep:
grep -n "computeObservationContentHash" src/services/sqlite/observations/→ limited tostore.ts(inline) OR zero external callers. - New integration test: simulate two PostToolUse hook payloads with the same content (title+narrative) but different
tool_use_id→ assert both observations are persisted (UNIQUE doesn't trigger, content-hash no longer blocks). This validates the coverage shift is correct behavior. - New integration test: simulate two PostToolUse hook payloads with the same
(session, tool_use_id)→ assert only one row persists, both return the same id. - End-to-end: run the full hook cycle; confirm observations land in DB and no dedup log lines from the deleted path appear.
(d) Anti-pattern guards
- E (two dedup paths): the WHOLE POINT of this phase. Grep must prove the old path is gone before merge.
- C: the UNIQUE constraint raises a conflict, which
ON CONFLICT DO NOTHINGconverts to a no-op + SELECT-existing. That's idempotent, not silent — the caller gets the existing id. Do not introduce anytry/catchthat swallows the conflict differently.
Blast radius
observations/store.ts shrinks to ~40 lines. If the gate fails and this phase is skipped, content-hash dedup survives harmlessly alongside the UNIQUE constraint (extra work per INSERT, no correctness loss).
Lines deleted estimate: ~40 from store.ts (file goes from 108 → ~65 lines).
Phase 7 — Final verification
Outcome: All six phases above land; regression suite green; anti-pattern greps zero.
(a) Tasks
- Run anti-pattern grep pass (cite these exact patterns):
grep -rn "DEDUP_WINDOW_MS" src/→ zero (Phase 6).grep -rn "findDuplicateObservation" src/→ zero (Phase 6).grep -rn "repairMalformedSchema\|execFileSync.*python" src/services/→ zero (Phase 5).grep -rn "STALE_PROCESSING_THRESHOLD_MS" src/→ 2 hits max: constant definition +recoverStuckProcessingbody (Phase 4).grep -n "status = 'processing'" src/services/sqlite/PendingMessageStore.tsfinds exactly one pending-flip UPDATE, insiderecoverStuckProcessing(Phase 4).grep -n "tool_use_id" src/services/sqlite/observations/store.ts≥ 2 hits (type + INSERT) (Phase 1).grep -n "chroma_synced" src/services/sqlite/migrations/runner.tsfinds the Phase 2 migration (Phase 2).ls src/services/sqlite/schema.sqlexists (Phase 3).
- Run tests:
bun test tests/services/sqlite/— all existing + new tests green.- Specifically:
migration-runner.test.ts(361 lines, unchanged test set must still pass),PendingMessageStore.test.ts,schema-repair.test.ts(retargeted to CLI), plus new:unique-constraint.test.ts,boot-recovery.test.ts,schema-consistency.test.ts.
- Run fresh-install smoke:
- Delete
~/.claude-mem/claude-mem.db. - Boot worker via
npm run build-and-sync. - Assert:
schema.sqlpath taken (no Python process, no 19 migration logs on fresh install). - Assert:
schema_versions.version = 29(or whatever the final version is after Phase 2's migration 29 lands).
- Delete
- Run upgrade smoke:
- Copy a v6.4.x fixture DB to the live path.
- Boot worker.
- Assert: all upgrade migrations through version 29 run; final schema matches
schema.sql.
- Count deleted lines:
git diff main -- src/services/sqlite/ | grep -c "^-"should show:- ~40 lines from
store.ts(Phase 6). - ~100 lines from
Database.ts(Phase 5). - ~15 lines from
PendingMessageStore.ts(Phase 4 — net ~0 becauserecoverStuckProcessingis added). - Net deletions: ~140 lines (before counting Phase 3's
schema.sqlwhich is additive).
- ~40 lines from
(b) Documentation references
05-clean-flowcharts.mdsection 3.3 (full).06-implementation-plan.mdPhase 9 (lines 412–448) — superseded-but-aligned.06-implementation-plan.mdPhase 15 (lines 631–655) — final-verification template.
(c) Verification checklist
- All anti-pattern greps pass.
- All tests green.
- Fresh + upgrade smoke tests pass.
- Deleted-line count ≥ 140.
- Downstream plan owners (03, 04, 07) notified that their prerequisites (UNIQUE constraint,
chroma_syncedcolumn,recoverStuckProcessing) are available.
(d) Anti-pattern guards
- A/B/C/E: final grep pass is the enforcement.
Summary
- Phase count: 7 (matches minimum expected set).
- Net lines deleted (estimate, source-only, excluding
schema.sqlwhich is added): ~140, split:- Phase 5: ~100 lines from
Database.ts(Python repair). - Phase 6: ~40 lines from
observations/store.ts(dedup window + helper + call block). - Phase 4: ~0 net (delete ~13, add ~15 for
recoverStuckProcessing). - Phase 3: 0 from source (migrations stay for upgrade path;
schema.sqlis new). - Phases 1, 2: additive only (new migration methods + column + constraint).
- Phase 5: ~100 lines from
- Top gaps (see reporting block 4):
schema.sqlgenerator must filter FTS5 shadow tables; Phase 3 includes the exact NOT-LIKE filter list, but a new FTS5 virtual table with a non-default suffix in a future migration would break this — needs a convention-lock or a more general regex.- Phase 6 is gated by cross-path
tool_use_idverification (Phase 1's UNIQUE must provably cover the transcript-watcher ingest path, owned by plan07-session-lifecycle-management). If transcript-watcher produces synthetictool_use_ids (e.g.,file:offset) that don't match hook-path IDs, the content-hash gate cannot be removed safely and Phase 6 must be deferred to a follow-up plan.