diff --git a/docs/releases/1.49.0.md b/docs/releases/1.49.0.md index d27cb5d..e0c41a1 100644 --- a/docs/releases/1.49.0.md +++ b/docs/releases/1.49.0.md @@ -11,7 +11,7 @@ The sidebar could already close a hand-picked set of sessions — after a Ctrl+c ## The Background listing no longer starts a process on every poll -`claude agents --json --all` is a Claude Code process — about 340 ms of a 300 MB executable — that every poll started, on this PC and on every paired one, nearly always to answer what it had answered the time before. DevDeck now looks first at what that command reads: the job records under `~/.claude/jobs`, one folder per background session with its `state.json` (a readdir and a stat per record, about a millisecond). No records means no sessions and no process at all; records unchanged since the last listing means the last answer, for up to five minutes; only a change runs the command. On a PC with no background sessions the process never starts. +`claude agents --json --all` is a Claude Code process — about 340 ms of a 300 MB executable — that every poll started, on this PC and on every paired one, nearly always to answer what it had answered the time before. DevDeck now looks first at what that command reads: the job records under `~/.claude/jobs`, one folder per background session with its `state.json` (a readdir and a stat per record, about a millisecond). Records unchanged since the last listing — including none at all — mean the last answer holds, for up to five minutes; a change runs the command at once. The records only stretch the cache, they never replace the command, so a Claude Code that keeps its records elsewhere is still asked, at the slow cadence. At rest this is one process per five minutes instead of one per 30–120 s. ## From a review of the sidebar and the close flows diff --git a/src/main/backgroundAgents.test.ts b/src/main/backgroundAgents.test.ts index 66fbfce..1cbf6c4 100644 --- a/src/main/backgroundAgents.test.ts +++ b/src/main/backgroundAgents.test.ts @@ -100,15 +100,21 @@ describe('listing off the job records before starting a claude process', () => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); }); - it('answers an empty list without starting claude when there are no records (or no folder)', async () => { + it('with no records (or no folder) still asks claude once, then reuses the answer for five minutes', async () => { + // The records only stretch the cache: a claude that keeps its records elsewhere (or a fake one + // in a harness) must still be asked — just not every poll. fakeClaude(() => ({ code: 0 })); const m = await import('./backgroundAgents.js'); const dir = jobsDir(); writeFileSync(join(dir, 'pins.json'), '[]'); // not a record - expect(await m.listBackgroundAgents(Date.now(), { jobsDir: dir })).toEqual([]); - m._clearBackgroundAgentCache(); - expect(await m.listBackgroundAgents(Date.now(), { jobsDir: join(dir, 'missing') })).toEqual([]); - expect(spawn).not.toHaveBeenCalled(); + const t0 = Date.now(); + expect(await m.listBackgroundAgents(t0, { jobsDir: dir })).toEqual([]); + expect(calls()).toEqual(['agents --json --all']); + expect(await m.listBackgroundAgents(t0 + 60_000, { jobsDir: dir })).toEqual([]); + expect(await m.listBackgroundAgents(t0 + 240_000, { jobsDir: join(dir, 'missing') })).toEqual([]); + expect(calls()).toHaveLength(1); + expect(await m.listBackgroundAgents(t0 + 6 * 60_000, { jobsDir: dir })).toEqual([]); + expect(calls()).toHaveLength(2); }); it('runs the listing when a record exists, and not again while the records are unchanged', async () => { diff --git a/src/main/backgroundAgents.ts b/src/main/backgroundAgents.ts index cd2ea2b..8391552 100644 --- a/src/main/backgroundAgents.ts +++ b/src/main/backgroundAgents.ts @@ -33,8 +33,10 @@ export async function pauseBackgroundAgents(on: boolean): Promise { * Claude Code process (~340 ms, a 300 MB executable) started every poll, here and on every paired * PC, almost always to answer what it answered last time. It reads the job records under * `~/.claude/jobs`, so those are looked at first (a readdir and a stat per record, about a - * millisecond): no records means no sessions and no process; records unchanged since the last - * listing means the last answer, for up to five minutes; only a change runs the command. + * millisecond): records unchanged since the last listing — including none at all — mean the last + * answer holds, for up to five minutes; a change runs the command at once. The records only + * stretch the cache, they never replace the command: a `claude` that keeps its records elsewhere + * (or a fake one in a harness) is still asked, at the slow cadence. */ export async function listBackgroundAgents(nowMs = Date.now(), opts: { jobsDir?: string } = {}): Promise { if (paused) return cached?.list ?? []; @@ -42,7 +44,6 @@ export async function listBackgroundAgents(nowMs = Date.now(), opts: { jobsDir?: if (inFlight) return inFlight; inFlight = (async () => { const records = await jobRecordsSignature(opts.jobsDir ?? JOBS_DIR); - if (records === '') return { list: [], records, listedAt: nowMs }; if (cached && records !== null && cached.records === records && nowMs - cached.listedAt < UNCHANGED_RELIST_MS) { return { list: cached.list, records, listedAt: cached.listedAt }; }