Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 29 additions & 11 deletions src/infrastructure/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -528,9 +528,17 @@ function computeNextCommands(report: Omit<DoctorReport, 'nextCommands' | 'health

const agentByLabel = new Map(report.agents.map((entry) => [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')
Expand All @@ -540,7 +548,7 @@ function computeNextCommands(report: Omit<DoctorReport, 'nextCommands' | 'health
}

const cursor = agentByLabel.get('cursor')
if (cursor && cursor.status !== 'configured') {
if (cursor?.status === 'partial') {
nextCommands.add('madar cursor install')
} else {
const cursorMcp = mcpByLabel.get('cursor')
Expand All @@ -550,12 +558,12 @@ function computeNextCommands(report: Omit<DoctorReport, 'nextCommands' | 'health
}

const gemini = agentByLabel.get('gemini')
if (gemini && gemini.status !== 'configured') {
if (gemini?.status === 'partial') {
nextCommands.add('madar gemini install')
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const copilot = agentByLabel.get('copilot')
if (copilot && copilot.status !== 'configured') {
if (copilot?.status === 'partial') {
nextCommands.add('madar copilot install')
} else {
const copilotMcp = mcpByLabel.get('copilot')
Expand Down Expand Up @@ -647,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}`,
},
{
Expand Down Expand Up @@ -718,13 +732,17 @@ export function buildDoctorReport(options: DoctorCommandOptions = {}): DoctorRep
|| watcherStateBlocksGraphReads(graph.watcherState)
|| graph.watcherPolicyMatchesPublished === false
)
const configuredOrAttemptedAgents = agents.filter((agent) => 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,
Expand Down
17 changes: 13 additions & 4 deletions src/infrastructure/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1024,16 +1024,19 @@ 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) {
break
}

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'
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

pending = false
Expand Down
53 changes: 53 additions & 0 deletions tests/unit/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
69 changes: 69 additions & 0 deletions tests/unit/watch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading