diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aa5f631..4fa66009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to the TypeScript package will be documented in this file. ## [Unreleased] +### Fixed + +- **Automatic refresh recovers after transient rebuild races**: watched rebuild failures such as a source file disappearing during a Git branch change keep the watcher alive, preserve fail-closed graph reads, retry automatically, and clear the recorded failure after the next successful rebuild. Closes #645. +- **Doctor treats uninstalled agent clients as optional**: a complete graph with one correctly configured agent now reports healthy even when other supported clients were never installed, while partial or stale attempted integrations remain actionable. Closes #619. + ## [0.32.0] - 2026-07-19 ### Added diff --git a/src/infrastructure/doctor.ts b/src/infrastructure/doctor.ts index ad7692c2..ead0b501 100644 --- a/src/infrastructure/doctor.ts +++ b/src/infrastructure/doctor.ts @@ -453,10 +453,10 @@ function readGraphCheck(graphPath: string, now: number, projectDir: string): Gra } } -function agentStatusFromFlags(flags: boolean[]): AgentStatus { +function agentStatusFromFlags(flags: boolean[], attempted = false): AgentStatus { const positives = flags.filter(Boolean).length if (positives === 0) { - return 'missing' + return attempted ? 'partial' : 'missing' } if (positives === flags.length) { return 'configured' @@ -528,9 +528,17 @@ function computeNextCommands(report: Omit [entry.label, entry])) const mcpByLabel = new Map(report.mcpChecks.map((entry) => [entry.label, entry])) + const configuredOrAttemptedAgents = report.agents.filter((agent) => agent.status !== 'missing') + + if (configuredOrAttemptedAgents.length === 0) { + nextCommands.add('madar claude install') + nextCommands.add('madar cursor install') + nextCommands.add('madar gemini install') + nextCommands.add('madar copilot install') + } const claude = agentByLabel.get('claude') - if (claude && claude.status !== 'configured') { + if (claude?.status === 'partial') { nextCommands.add('madar claude install') } else { const claudeMcp = mcpByLabel.get('claude') @@ -540,7 +548,7 @@ function computeNextCommands(report: Omit agent.status !== 'missing') + const configuredOrAttemptedLabels = new Set(configuredOrAttemptedAgents.map((agent) => agent.label)) + const configuredOrAttemptedMcpChecks = mcpChecks.filter((check) => configuredOrAttemptedLabels.has(check.label)) const healthy = graph.exists && graph.freshness === 'fresh' && !indexingRequiresAttention && graph.generationPolicy.match !== false && !watcherRequiresAttention - && agents.every((agent) => agent.status === 'configured') - && mcpChecks.every((check) => check.status === 'ok') + && configuredOrAttemptedAgents.length > 0 + && configuredOrAttemptedAgents.every((agent) => agent.status === 'configured') + && configuredOrAttemptedMcpChecks.every((check) => check.status === 'ok') return { ...partialReport, diff --git a/src/infrastructure/watch.ts b/src/infrastructure/watch.ts index 2525b3c2..c051aef5 100644 --- a/src/infrastructure/watch.ts +++ b/src/infrastructure/watch.ts @@ -1024,8 +1024,11 @@ export async function watch(watchPath: string, debounce = 3, options: WatchOptio while (!options.signal?.aborted) { const now = Date.now() + const retryingFailedRebuild = state.status === 'failed' && pending const rebuildAt = pending ? lastTriggerAt + debounceMs : Number.POSITIVE_INFINITY - const reconcileAt = eventDirty ? now : nextReconciliationAt + const reconcileAt = retryingFailedRebuild + ? Number.POSITIVE_INFINITY + : eventDirty ? now : nextReconciliationAt const nextActionAt = Math.min(rebuildAt, reconcileAt) await loopSignal.wait(Number.isFinite(nextActionAt) ? Math.max(0, nextActionAt - now) : currentIntervalMs, options.signal) if (options.signal?.aborted) { @@ -1033,7 +1036,7 @@ export async function watch(watchPath: string, debounce = 3, options: WatchOptio } const actionAt = Date.now() - if (eventDirty || actionAt >= nextReconciliationAt) { + if (!retryingFailedRebuild && (eventDirty || actionAt >= nextReconciliationAt)) { const trigger: WatchReconciliationMetrics['trigger'] = eventDirty ? 'event' : 'periodic' eventDirty = false state.status = 'reconciling' @@ -1100,10 +1103,16 @@ export async function watch(watchPath: string, debounce = 3, options: WatchOptio } if (!rebuilt) { state.status = 'failed' - state.failure_reason = 'Automatic graph rebuild failed; the graph must not be treated as fresh.' + state.coverage = 'failed' + state.failure_reason = 'Automatic graph rebuild failed; the graph must not be treated as fresh. A retry is scheduled automatically.' + const retryDelayMs = Math.max(10, Math.min(1_000, Math.max(debounceMs, minimumIntervalMs))) + const retryAt = Date.now() + retryDelayMs + lastTriggerAt = retryAt - debounceMs + nextReconciliationAt = retryAt + Math.max(1, minimumIntervalMs) + state.next_reconciliation_at = new Date(nextReconciliationAt).toISOString() persistState() runNotify(resolvedWatchPath, output) - return + continue } pending = false diff --git a/tests/unit/doctor.test.ts b/tests/unit/doctor.test.ts index 3aa49789..68b29d9e 100644 --- a/tests/unit/doctor.test.ts +++ b/tests/unit/doctor.test.ts @@ -260,6 +260,59 @@ describe('doctor command', () => { }) }) + test('reports a healthy Claude-only setup without requiring optional clients', () => { + withSandbox((sandboxDir) => { + writeText(resolve(sandboxDir, 'main.ts'), 'export const value = 1\n') + generateGraph(sandboxDir, { noHtml: true }) + claudeInstall(sandboxDir) + + const doctor = runDoctorCommand({ + projectDir: sandboxDir, + now: Date.now(), + }) + const status = runStatusCommand({ + projectDir: sandboxDir, + now: Date.now(), + }) + + expect(doctor).toContain('[madar doctor] healthy') + expect(doctor).toContain('claude: configured') + expect(doctor).toContain('cursor: missing') + expect(doctor).toContain('gemini: missing') + expect(doctor).toContain('copilot: missing') + expect(doctor).toContain('next commands: none') + expect(status).toContain('[madar status] healthy') + expect(status).toContain('next none') + }) + }) + + test('keeps a stale attempted Gemini integration actionable beside healthy Claude', () => { + withSandbox((sandboxDir) => { + writeText(resolve(sandboxDir, 'main.ts'), 'export const value = 1\n') + generateGraph(sandboxDir, { noHtml: true }) + claudeInstall(sandboxDir) + writeJson(resolve(sandboxDir, '.gemini', 'settings.json'), { + mcpServers: { + madar: { + command: 'madar', + args: ['serve', '--stdio'], + }, + }, + }) + + const doctor = runDoctorCommand({ + projectDir: sandboxDir, + now: Date.now(), + }) + + expect(doctor).toContain('[madar doctor] attention needed') + expect(doctor).toContain('claude: configured') + expect(doctor).toContain('gemini: partial') + expect(doctor).toContain('gemini: stale') + expect(doctor).toContain('madar gemini install') + }) + }) + test('recognizes the current Claude UserPromptSubmit hook as configured', () => { withSandbox((sandboxDir) => { writeText(resolve(sandboxDir, 'out', 'graph.json'), '{"nodes":[],"edges":[]}\n') diff --git a/tests/unit/watch.test.ts b/tests/unit/watch.test.ts index 3d2bf439..0f6a9179 100644 --- a/tests/unit/watch.test.ts +++ b/tests/unit/watch.test.ts @@ -846,6 +846,75 @@ describe('watch', () => { }) }) + test('retries a transient watched rebuild failure and clears the failure after success', async () => { + await withTempDirAsync(async (tempDir) => { + const controller = new AbortController() + let rebuildAttempts = 0 + const rebuild = vi.fn(() => { + rebuildAttempts += 1 + return rebuildAttempts > 1 + }) + const notify = vi.fn() + const graphPath = join(tempDir, 'out', 'graph.json') + + writeFileSync(join(tempDir, 'main.py'), 'def hello():\n return 1\n', 'utf8') + const watcher = watch(tempDir, 0.2, { + signal: controller.signal, + pollIntervalMs: 10, + maxPollIntervalMs: 10, + rebuildCode: rebuild, + notifyOnly: notify, + logger: { log() {}, error() {} }, + }) + const timeout = setTimeout(() => controller.abort(), 5_000) + + try { + await waitFor(() => readWatcherStateForGraph(graphPath)?.status === 'idle') + writeFileSync(join(tempDir, 'main.py'), 'def hello():\n return 2\n', 'utf8') + + await waitFor(() => { + const watcherState = readWatcherStateForGraph(graphPath) + return watcherState?.status === 'failed' + && watcherState.coverage === 'failed' + && watcherState.failure_reason?.includes('retry is scheduled automatically') === true + }) + expect(rebuild).toHaveBeenCalledTimes(1) + expect(readWatcherStateForGraph(graphPath)).toMatchObject({ + status: 'failed', + coverage: 'failed', + failure_reason: expect.stringContaining('retry is scheduled automatically'), + }) + + await waitFor(() => { + const watcherState = readWatcherStateForGraph(graphPath) + return rebuild.mock.calls.length === 2 + && watcherState?.status === 'idle' + && watcherState.coverage === 'complete' + && watcherState.failure_reason === null + }) + expect(rebuild).toHaveBeenCalledTimes(2) + expect(notify).toHaveBeenCalledTimes(1) + expect(readWatcherStateForGraph(graphPath)).toMatchObject({ + status: 'idle', + coverage: 'complete', + failure_reason: null, + }) + + controller.abort() + await watcher + expect(readWatcherStateForGraph(graphPath)).toMatchObject({ + status: 'stopped', + coverage: 'complete', + failure_reason: null, + }) + } finally { + clearTimeout(timeout) + controller.abort() + await watcher + } + }) + }) + test('triggers rebuild for supported non-code changes', async () => { await withTempDirAsync(async (tempDir) => { const controller = new AbortController()