From 8e9e9f6856fb06bd54878aab075be824757b299c Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Tue, 11 Aug 2026 18:34:40 +0400 Subject: [PATCH 1/4] fix: recover watcher rebuilds and optional doctor clients --- CHANGELOG.md | 5 +++++ src/infrastructure/doctor.ts | 24 ++++++++++++++++------ src/infrastructure/watch.ts | 7 +++++-- tests/unit/doctor.test.ts | 26 ++++++++++++++++++++++++ tests/unit/watch.test.ts | 39 ++++++++++++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 8 deletions(-) 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..512c8b69 100644 --- a/src/infrastructure/doctor.ts +++ b/src/infrastructure/doctor.ts @@ -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..0ab5a1c6 100644 --- a/src/infrastructure/watch.ts +++ b/src/infrastructure/watch.ts @@ -1100,10 +1100,13 @@ 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.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))) + lastTriggerAt = Date.now() + retryDelayMs - debounceMs + state.next_reconciliation_at = new Date(Date.now() + retryDelayMs).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..6eb38dc4 100644 --- a/tests/unit/doctor.test.ts +++ b/tests/unit/doctor.test.ts @@ -260,6 +260,32 @@ 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('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..d094da39 100644 --- a/tests/unit/watch.test.ts +++ b/tests/unit/watch.test.ts @@ -846,6 +846,45 @@ 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 + if (rebuildAttempts === 1) { + return false + } + controller.abort() + return true + }) + const notify = vi.fn() + + writeFileSync(join(tempDir, 'main.py'), 'def hello():\n return 1\n', 'utf8') + const watcher = watch(tempDir, 0.02, { + signal: controller.signal, + pollIntervalMs: 10, + maxPollIntervalMs: 10, + rebuildCode: rebuild, + notifyOnly: notify, + logger: { log() {}, error() {} }, + }) + + await delay(30) + writeFileSync(join(tempDir, 'main.py'), 'def hello():\n return 2\n', 'utf8') + + await watcher + + expect(rebuild).toHaveBeenCalledTimes(2) + expect(notify).toHaveBeenCalledTimes(1) + expect(readWatcherStateForGraph(join(tempDir, 'out', 'graph.json'))).toMatchObject({ + status: 'stopped', + coverage: 'complete', + failure_reason: null, + }) + }) + }) + test('triggers rebuild for supported non-code changes', async () => { await withTempDirAsync(async (tempDir) => { const controller = new AbortController() From 3adc2895ca9070ff0ddfcc10332b6f5f58a7ddf2 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Tue, 11 Aug 2026 18:47:16 +0400 Subject: [PATCH 2/4] fix: preserve retry freshness and stale client repair --- src/infrastructure/doctor.ts | 16 ++++++++---- src/infrastructure/watch.ts | 14 ++++++++--- tests/unit/doctor.test.ts | 27 ++++++++++++++++++++ tests/unit/watch.test.ts | 48 +++++++++++++++++++++++++++--------- 4 files changed, 84 insertions(+), 21 deletions(-) diff --git a/src/infrastructure/doctor.ts b/src/infrastructure/doctor.ts index 512c8b69..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' @@ -655,17 +655,23 @@ export function buildDoctorReport(options: DoctorCommandOptions = {}): DoctorRep const agents: AgentCheck[] = [ { label: 'claude', - status: agentStatusFromFlags([claudeRuleConfigured, claudeHookConfigured, claudeMcpConfigured]), + status: agentStatusFromFlags( + [claudeRuleConfigured, claudeHookConfigured, claudeMcpConfigured], + claudeMcp.status === 'stale', + ), detail: `rules=${claudeRuleConfigured ? 'yes' : 'no'}, hook=${claudeHookConfigured ? 'yes' : 'no'}, mcp=${claudeMcp.status}`, }, { label: 'cursor', - status: agentStatusFromFlags([cursorRuleConfigured, cursorMcpConfigured]), + status: agentStatusFromFlags([cursorRuleConfigured, cursorMcpConfigured], cursorMcp.status === 'stale'), detail: `rules=${cursorRuleConfigured ? 'yes' : 'no'}, mcp=${cursorMcp.status}`, }, { label: 'gemini', - status: agentStatusFromFlags([geminiRuleConfigured, geminiHookConfigured, geminiMcpConfigured]), + status: agentStatusFromFlags( + [geminiRuleConfigured, geminiHookConfigured, geminiMcpConfigured], + geminiMcp.status === 'stale', + ), detail: `rules=${geminiRuleConfigured ? 'yes' : 'no'}, hook=${geminiHookConfigured ? 'yes' : 'no'}, mcp=${geminiMcp.status}`, }, { diff --git a/src/infrastructure/watch.ts b/src/infrastructure/watch.ts index 0ab5a1c6..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,13 @@ export async function watch(watchPath: string, debounce = 3, options: WatchOptio } if (!rebuilt) { state.status = 'failed' + 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))) - lastTriggerAt = Date.now() + retryDelayMs - debounceMs - state.next_reconciliation_at = new Date(Date.now() + retryDelayMs).toISOString() + 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) continue diff --git a/tests/unit/doctor.test.ts b/tests/unit/doctor.test.ts index 6eb38dc4..68b29d9e 100644 --- a/tests/unit/doctor.test.ts +++ b/tests/unit/doctor.test.ts @@ -286,6 +286,33 @@ describe('doctor command', () => { }) }) + 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 d094da39..6eb31185 100644 --- a/tests/unit/watch.test.ts +++ b/tests/unit/watch.test.ts @@ -850,6 +850,7 @@ describe('watch', () => { await withTempDirAsync(async (tempDir) => { const controller = new AbortController() let rebuildAttempts = 0 + let stateAfterFailure: ReturnType = null const rebuild = vi.fn(() => { rebuildAttempts += 1 if (rebuildAttempts === 1) { @@ -858,10 +859,12 @@ describe('watch', () => { controller.abort() return true }) - const notify = vi.fn() + const notify = vi.fn(() => { + stateAfterFailure = readWatcherStateForGraph(join(tempDir, 'out', 'graph.json')) + }) writeFileSync(join(tempDir, 'main.py'), 'def hello():\n return 1\n', 'utf8') - const watcher = watch(tempDir, 0.02, { + const watcher = watch(tempDir, 0.2, { signal: controller.signal, pollIntervalMs: 10, maxPollIntervalMs: 10, @@ -869,19 +872,40 @@ describe('watch', () => { notifyOnly: notify, logger: { log() {}, error() {} }, }) + const timeout = setTimeout(() => controller.abort(), 2_000) - await delay(30) - writeFileSync(join(tempDir, 'main.py'), 'def hello():\n return 2\n', 'utf8') + try { + await delay(30) + writeFileSync(join(tempDir, 'main.py'), 'def hello():\n return 2\n', 'utf8') - await watcher + await waitFor(() => rebuild.mock.calls.length === 1) + expect(stateAfterFailure).toMatchObject({ + status: 'failed', + coverage: 'failed', + failure_reason: expect.stringContaining('retry is scheduled automatically'), + }) - expect(rebuild).toHaveBeenCalledTimes(2) - expect(notify).toHaveBeenCalledTimes(1) - expect(readWatcherStateForGraph(join(tempDir, 'out', 'graph.json'))).toMatchObject({ - status: 'stopped', - coverage: 'complete', - failure_reason: null, - }) + await delay(50) + expect(readWatcherStateForGraph(join(tempDir, 'out', 'graph.json'))).toMatchObject({ + status: 'failed', + coverage: 'failed', + failure_reason: expect.stringContaining('retry is scheduled automatically'), + }) + + await watcher + + expect(rebuild).toHaveBeenCalledTimes(2) + expect(notify).toHaveBeenCalledTimes(1) + expect(readWatcherStateForGraph(join(tempDir, 'out', 'graph.json'))).toMatchObject({ + status: 'stopped', + coverage: 'complete', + failure_reason: null, + }) + } finally { + clearTimeout(timeout) + controller.abort() + await watcher + } }) }) From cbad2d0339a5f32f1d426717fcbb1b027963e2ae Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Tue, 11 Aug 2026 18:58:38 +0400 Subject: [PATCH 3/4] test: make watcher recovery assertions state-driven --- tests/unit/watch.test.ts | 50 ++++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/tests/unit/watch.test.ts b/tests/unit/watch.test.ts index 6eb31185..0f6a9179 100644 --- a/tests/unit/watch.test.ts +++ b/tests/unit/watch.test.ts @@ -850,18 +850,12 @@ describe('watch', () => { await withTempDirAsync(async (tempDir) => { const controller = new AbortController() let rebuildAttempts = 0 - let stateAfterFailure: ReturnType = null const rebuild = vi.fn(() => { rebuildAttempts += 1 - if (rebuildAttempts === 1) { - return false - } - controller.abort() - return true - }) - const notify = vi.fn(() => { - stateAfterFailure = readWatcherStateForGraph(join(tempDir, 'out', 'graph.json')) + 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, { @@ -872,31 +866,43 @@ describe('watch', () => { notifyOnly: notify, logger: { log() {}, error() {} }, }) - const timeout = setTimeout(() => controller.abort(), 2_000) + const timeout = setTimeout(() => controller.abort(), 5_000) try { - await delay(30) + await waitFor(() => readWatcherStateForGraph(graphPath)?.status === 'idle') writeFileSync(join(tempDir, 'main.py'), 'def hello():\n return 2\n', 'utf8') - await waitFor(() => rebuild.mock.calls.length === 1) - expect(stateAfterFailure).toMatchObject({ - status: 'failed', - coverage: 'failed', - failure_reason: expect.stringContaining('retry is scheduled automatically'), + await waitFor(() => { + const watcherState = readWatcherStateForGraph(graphPath) + return watcherState?.status === 'failed' + && watcherState.coverage === 'failed' + && watcherState.failure_reason?.includes('retry is scheduled automatically') === true }) - - await delay(50) - expect(readWatcherStateForGraph(join(tempDir, 'out', 'graph.json'))).toMatchObject({ + expect(rebuild).toHaveBeenCalledTimes(1) + expect(readWatcherStateForGraph(graphPath)).toMatchObject({ status: 'failed', coverage: 'failed', failure_reason: expect.stringContaining('retry is scheduled automatically'), }) - await watcher - + 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(join(tempDir, 'out', 'graph.json'))).toMatchObject({ + 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, From 3176da1b1a52a98ddbca93e37f6cff37eb3b0dd1 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Tue, 11 Aug 2026 19:20:50 +0400 Subject: [PATCH 4/4] ci: rerun final validation