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
4 changes: 2 additions & 2 deletions packages/daemon-core/src/logging/async-batch-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ export class AsyncBatchWriter {
const prevPromise = this.writePromises.get(filePath);
if (prevPromise) await prevPromise;
await fs.promises.appendFile(filePath, dataToWrite, { encoding: 'utf-8', mode: 0o600 });
} catch (e) {
console.error(`[AsyncBatchWriter] Failed to write to ${filePath}:`, e);
} catch {
// Logging must never create secondary failures or late console noise.
}
};

Expand Down
20 changes: 17 additions & 3 deletions packages/daemon-core/src/providers/ide-provider-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,20 @@ type ReadChatPayload = {
[key: string]: unknown;
};

async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}

export class IdeProviderInstance implements ProviderInstance {
readonly type: string;
readonly category = 'ide' as const;
Expand Down Expand Up @@ -320,7 +334,7 @@ export class IdeProviderInstance implements ProviderInstance {
if (webviewScript) {
const matchText = this.provider.webviewMatchText;
const matchFn = matchText ? (body: string) => body.includes(matchText) : undefined;
const webviewRaw = await cdp.evaluateInWebviewFrame(webviewScript, matchFn);
const webviewRaw = await withTimeout(cdp.evaluateInWebviewFrame(webviewScript, matchFn), 30000, 'evaluateInWebviewFrame');
if (webviewRaw) {
raw = typeof webviewRaw === 'string' ? (() => { try { return JSON.parse(webviewRaw); } catch { return null; } })() : webviewRaw;
}
Expand All @@ -331,7 +345,7 @@ export class IdeProviderInstance implements ProviderInstance {
if (!raw) {
const readChatScript = this.getReadChatScript();
if (!readChatScript) return;
raw = await cdp.evaluate(readChatScript, 30000);
raw = await withTimeout(cdp.evaluate(readChatScript, 30000), 30000, 'evaluate.readChatScript');
if (typeof raw === 'string') {
try { raw = JSON.parse(raw); } catch { return; }
}
Expand Down Expand Up @@ -706,7 +720,7 @@ export class IdeProviderInstance implements ProviderInstance {
);

LOG.info('IdeInstance', `[IdeInstance:${this.type}] autoApprove: executing resolveAction for "${targetButton}"`);
let rawResult = await cdp.evaluate(script, 10000);
let rawResult = await withTimeout(cdp.evaluate(script, 10000), 10000, 'evaluate.autoApprove');
if (typeof rawResult === 'string') {
try { rawResult = JSON.parse(rawResult); } catch { }
}
Expand Down
14 changes: 10 additions & 4 deletions packages/daemon-core/src/providers/provider-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1067,13 +1067,17 @@ export class ProviderLoader {
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
});

let reloadTimer: ReturnType<typeof setTimeout> | null = null;
const handleChange = (filePath: string) => {
if (/[\/\\]fixtures[\/\\]/.test(filePath)) {
return;
}
if (filePath.endsWith('.js') || filePath.endsWith('.json')) {
this.log(`File changed: ${path.basename(filePath)}, reloading...`);
this.reload();
if (reloadTimer) clearTimeout(reloadTimer);
reloadTimer = setTimeout(() => {
this.log(`File changed: ${path.basename(filePath)}, reloading...`);
this.reload();
}, 300);
}
};

Expand Down Expand Up @@ -1130,7 +1134,9 @@ export class ProviderLoader {
return { updated: false };
}
const https = require('https') as typeof import('https');
const { execSync } = require('child_process') as typeof import('child_process');
const { exec } = require('child_process') as typeof import('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);

const metaPath = path.join(this.upstreamDir, ProviderLoader.META_FILE);
let prevEtag = '';
Expand Down Expand Up @@ -1207,7 +1213,7 @@ export class ProviderLoader {

// Extract
fs.mkdirSync(tmpExtract, { recursive: true });
execSync(`tar -xzf "${tmpTar}" -C "${tmpExtract}"`, { timeout: 30000 });
await execAsync(`tar -xzf "${tmpTar}" -C "${tmpExtract}"`, { timeout: 30000 });

// Tarball internal structure: adhdev-providers-main/ide/... → strip 1 level
const extracted = fs.readdirSync(tmpExtract);
Expand Down
15 changes: 15 additions & 0 deletions packages/daemon-core/src/status/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export class DaemonStatusReporter {
private lastStatusSentAt = 0;
private statusPendingThrottle = false;
private lastP2PStatusHash = '';
private lastP2PStatusSentAt: number = 0;
private p2pDebounceTimer: ReturnType<typeof setTimeout> | null = null;
private lastServerStatusHash = '';
private lastStatusSummary = '';

Expand Down Expand Up @@ -355,7 +357,20 @@ export class DaemonStatusReporter {
: { ...hashTarget, sessions };
const h = this.simpleHash(JSON.stringify(hashPayload));
if (h !== this.lastP2PStatusHash) {
const now = Date.now();
// Rate limit: max 1 per 500ms
if (this.lastP2PStatusSentAt && now - this.lastP2PStatusSentAt < 500) {
if (!this.p2pDebounceTimer) {
this.p2pDebounceTimer = setTimeout(() => {
this.p2pDebounceTimer = null;
this.sendUnifiedStatusReport({ reason: 'p2p_debounce' });
}, 500);
}
return false; // Dropped for now, but will trigger later
}

this.lastP2PStatusHash = h;
this.lastP2PStatusSentAt = now;
this.deps.p2p?.sendStatus(payload);
return true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,54 @@ describe('DaemonStatusReporter P2P publish behavior', () => {
})
})

it('debounces rapid p2p status changes while preserving full status payloads', async () => {
const { reporter, sendStatus } = createReporter({
serverConnected: false,
p2pConnected: true,
})

await reporter.sendUnifiedStatusReport({ p2pOnly: true, reason: 'initial' })
expect(sendStatus).toHaveBeenCalledTimes(1)
expect(sendStatus.mock.calls[0]?.[0]?._delta).toBeUndefined()
expect(sendStatus.mock.calls[0]?.[0]?.sessions).toHaveLength(1)

buildStatusSnapshotMock.mockReturnValue({
instanceId: 'daemon-1',
machine: { platform: 'darwin', hostname: 'test-host' },
timestamp: 456,
p2p: { available: true, state: 'connected', peers: 1, screenshotActive: false },
sessions: [
{
id: 'cli-1',
parentId: null,
providerType: 'hermes-cli',
providerName: 'Hermes Agent',
kind: 'agent',
transport: 'pty',
status: 'generating',
workspace: '/repo',
title: 'Hermes task',
unread: true,
inboxBucket: 'task_complete',
completionMarker: 'id:msg_2',
seenCompletionMarker: '',
lastUpdated: 456,
},
],
})

await reporter.sendUnifiedStatusReport({ p2pOnly: true, reason: 'rapid' })
expect(sendStatus).toHaveBeenCalledTimes(1)

await vi.advanceTimersByTimeAsync(500)
expect(sendStatus).toHaveBeenCalledTimes(2)
expect(sendStatus.mock.calls[1]?.[0]?._delta).toBeUndefined()
expect(sendStatus.mock.calls[1]?.[0]?.sessions?.[0]).toMatchObject({
id: 'cli-1',
status: 'generating',
})
})

it('preserves provider transcript metadata on canonical status events for completion refreshes', () => {
const { reporter, sendStatusEvent, sendMessage } = createReporter({
serverConnected: true,
Expand Down