Skip to content

Commit aa9dd4b

Browse files
Merge branch 'fix/workflow-run-accumulation' into fix/acp-protocol
2 parents 02d84bc + 4e9b89c commit aa9dd4b

4 files changed

Lines changed: 243 additions & 6 deletions

File tree

src/workflow/__tests__/persistence.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { tmpdir } from 'node:os'
1111
import { join } from 'node:path'
1212
import {
13+
cleanupOldRuns,
1314
getRunsDir,
1415
listPersistedRuns,
1516
readRunState,
@@ -197,3 +198,108 @@ test('getRunsDir returns <projectRoot>/.claude/workflow-runs shape', () => {
197198
// do not hard-code projectRoot (differs across machines), only check suffix structure
198199
expect(dir.endsWith(`${join('.claude', 'workflow-runs')}`)).toBe(true)
199200
})
201+
202+
test('listPersistedRuns limit N returns the N newest by updatedAt desc', async () => {
203+
const dir = await mkdtemp(join(tmpdir(), 'wf-'))
204+
try {
205+
await writeRunState(dir, makeRun({ runId: 'old', updatedAt: 1000 }))
206+
await writeRunState(dir, makeRun({ runId: 'mid', updatedAt: 2000 }))
207+
await writeRunState(dir, makeRun({ runId: 'new', updatedAt: 3000 }))
208+
209+
expect((await listPersistedRuns(dir, 0)).map(r => r.runId)).toEqual([])
210+
expect((await listPersistedRuns(dir, 1)).map(r => r.runId)).toEqual(['new'])
211+
expect((await listPersistedRuns(dir, 2)).map(r => r.runId)).toEqual([
212+
'new',
213+
'mid',
214+
])
215+
// limit larger than total → returns all (no padding)
216+
expect((await listPersistedRuns(dir, 99)).map(r => r.runId)).toEqual([
217+
'new',
218+
'mid',
219+
'old',
220+
])
221+
// undefined → unchanged "load everything" semantics (back-compat)
222+
expect((await listPersistedRuns(dir)).map(r => r.runId)).toEqual([
223+
'new',
224+
'mid',
225+
'old',
226+
])
227+
} finally {
228+
await rm(dir, { recursive: true, force: true })
229+
}
230+
})
231+
232+
test('cleanupOldRuns keeps the newest keepMax runs and removes the rest', async () => {
233+
const dir = await mkdtemp(join(tmpdir(), 'wf-'))
234+
try {
235+
await writeRunState(dir, makeRun({ runId: 'old', updatedAt: 1000 }))
236+
await writeRunState(dir, makeRun({ runId: 'mid', updatedAt: 2000 }))
237+
await writeRunState(dir, makeRun({ runId: 'new', updatedAt: 3000 }))
238+
239+
const removed = await cleanupOldRuns(dir, 1)
240+
expect(removed).toBe(2)
241+
const remaining = (await listPersistedRuns(dir)).map(r => r.runId)
242+
expect(remaining).toEqual(['new'])
243+
// pruned dirs are fully gone (state.json included)
244+
await expect(readRunState(dir, 'old')).resolves.toBeNull()
245+
await expect(readRunState(dir, 'mid')).resolves.toBeNull()
246+
} finally {
247+
await rm(dir, { recursive: true, force: true })
248+
}
249+
})
250+
251+
test('cleanupOldRuns prunes orphan dirs (no state.json) first', async () => {
252+
const dir = await mkdtemp(join(tmpdir(), 'wf-'))
253+
try {
254+
await writeRunState(dir, makeRun({ runId: 'r1', updatedAt: 1000 }))
255+
await writeRunState(dir, makeRun({ runId: 'r2', updatedAt: 2000 }))
256+
// orphan: no state.json → treated as updatedAt=0, sorted last, pruned first
257+
await mkdir(join(dir, 'orphan'), { recursive: true })
258+
259+
const removed = await cleanupOldRuns(dir, 2)
260+
expect(removed).toBe(1)
261+
const entries = await readdir(dir)
262+
expect(entries).not.toContain('orphan')
263+
expect(entries).toContain('r1')
264+
expect(entries).toContain('r2')
265+
} finally {
266+
await rm(dir, { recursive: true, force: true })
267+
}
268+
})
269+
270+
test('cleanupOldRuns under keepMax is a no-op', async () => {
271+
const dir = await mkdtemp(join(tmpdir(), 'wf-'))
272+
try {
273+
await writeRunState(dir, makeRun({ runId: 'r1', updatedAt: 1000 }))
274+
await writeRunState(dir, makeRun({ runId: 'r2', updatedAt: 2000 }))
275+
276+
const removed = await cleanupOldRuns(dir, 5)
277+
expect(removed).toBe(0)
278+
expect((await listPersistedRuns(dir)).map(r => r.runId)).toEqual([
279+
'r2',
280+
'r1',
281+
])
282+
} finally {
283+
await rm(dir, { recursive: true, force: true })
284+
}
285+
})
286+
287+
test('cleanupOldRuns on missing dir returns 0 (no throw)', async () => {
288+
const dir = await mkdtemp(join(tmpdir(), 'wf-'))
289+
await rm(dir, { recursive: true, force: true })
290+
await expect(cleanupOldRuns(dir, 5)).resolves.toBe(0)
291+
})
292+
293+
test('cleanupOldRuns negative keepMax is clamped to 0 (removes everything, no slice(-N) inversion)', async () => {
294+
const dir = await mkdtemp(join(tmpdir(), 'wf-'))
295+
try {
296+
await writeRunState(dir, makeRun({ runId: 'r1', updatedAt: 1000 }))
297+
await writeRunState(dir, makeRun({ runId: 'r2', updatedAt: 2000 }))
298+
299+
// Without the clamp, slice(-1) would keep 1 entry — violating "keep 0 means keep none".
300+
await expect(cleanupOldRuns(dir, -1)).resolves.toBe(2)
301+
expect(await listPersistedRuns(dir)).toEqual([])
302+
} finally {
303+
await rm(dir, { recursive: true, force: true })
304+
}
305+
})

src/workflow/__tests__/service.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,41 @@ test('launch inline script → returns scriptPath (persisted to cwdOverride dir)
220220
}
221221
})
222222

223+
test('launch inline script with title → workflowName comes from title (not the "workflow" default)', async () => {
224+
__resetWorkflowServiceForTests()
225+
const { ports, store } = fakePorts()
226+
const svc = makeService(ports, store)
227+
const { runId } = await svc.launch(
228+
{ script: `return agent('x')`, title: 'Review PR #42' },
229+
stubTUC,
230+
stubCanUseTool,
231+
)
232+
await settle()
233+
const r = svc.getRun(runId)
234+
expect(r).toBeDefined()
235+
expect(r!.workflowName).toBe('Review PR #42')
236+
})
237+
238+
test('launch scriptPath with title → workflowName still honors title', async () => {
239+
__resetWorkflowServiceForTests()
240+
const dir = await mkdtemp(join(tmpdir(), 'wf-svc-'))
241+
try {
242+
const file = join(dir, 'wf.js')
243+
await writeFile(file, `return agent('x')`)
244+
const { ports, store } = fakePorts()
245+
const svc = makeService(ports, store)
246+
const { runId } = await svc.launch(
247+
{ scriptPath: file, title: 'From File' },
248+
stubTUC,
249+
stubCanUseTool,
250+
)
251+
await settle()
252+
expect(svc.getRun(runId)!.workflowName).toBe('From File')
253+
} finally {
254+
await rm(dir, { recursive: true, force: true })
255+
}
256+
})
257+
223258
test('kill goes through taskRegistrar.kill', async () => {
224259
__resetWorkflowServiceForTests()
225260
const { ports, store, killed } = fakePorts()

src/workflow/persistence.ts

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'
1+
import {
2+
mkdir,
3+
readFile,
4+
readdir,
5+
rename,
6+
rm,
7+
writeFile,
8+
} from 'node:fs/promises'
29
import { join } from 'node:path'
310
import { getProjectRoot } from '../bootstrap/state.js'
411
import { logForDebugging } from '../utils/debug.js'
@@ -10,6 +17,13 @@ const SCHEMA_VERSION = 1
1017
const STATE_FILE = 'state.json'
1118
const STATE_TMP = 'state.json.tmp'
1219

20+
/**
21+
* Hard ceiling on persisted run directories on disk. Beyond this, the oldest runs (by updatedAt)
22+
* are pruned by cleanupOldRuns. Set generously above LOAD_PERSISTED_LIMIT so runs hidden from the
23+
* panel can still be resumed manually before aging out.
24+
*/
25+
const KEEP_MAX_RUNS = 50
26+
1327
/**
1428
* Single source for runsDir: shares the same root as ports.ts journalStore (${projectRoot}/.claude/workflow-runs).
1529
* Extracted as a function: eliminates duplicated path concatenation between ports.ts and persistence logic, staying in the same root when entering worktree/subdirectory.
@@ -86,9 +100,12 @@ export async function readRunState(
86100
* - A subdirectory without state.json (half-written run) → skip
87101
* - A subdirectory whose state.json is corrupted → skip that single one, keep scanning the rest
88102
* - Sort by updatedAt descending (consistent with store.list() ordering)
103+
* - Optional limit: keep only the first N newest (used by loadPersistedRuns so the panel
104+
* doesn't drown under months of history; full scan stays available by omitting the arg).
89105
*/
90106
export async function listPersistedRuns(
91107
runsDir: string,
108+
limit?: number,
92109
): Promise<RunProgress[]> {
93110
let entries: string[]
94111
try {
@@ -101,7 +118,56 @@ export async function listPersistedRuns(
101118
const run = await readRunState(runsDir, name)
102119
if (run) runs.push(run)
103120
}
104-
return runs.sort((a, b) => b.updatedAt - a.updatedAt)
121+
runs.sort((a, b) => b.updatedAt - a.updatedAt)
122+
return limit !== undefined && limit >= 0 ? runs.slice(0, limit) : runs
123+
}
124+
125+
/**
126+
* Garbage-collect stale run directories: sort subdirs of runsDir by their state.json.updatedAt
127+
* (newest first), then recursively remove everything past keepMax. Subdirs without state.json are
128+
* treated as oldest (they're orphans — half-written, killed-mid-write, or pre-schema leftovers) so
129+
* they get pruned first.
130+
*
131+
* Best-effort: per-dir failures only log, do not abort the sweep. Safe to call repeatedly
132+
* (idempotent — once under the cap, it's a no-op).
133+
*
134+
* @returns number of directories actually removed.
135+
*/
136+
export async function cleanupOldRuns(
137+
runsDir: string,
138+
keepMax: number = KEEP_MAX_RUNS,
139+
): Promise<number> {
140+
let entries: string[]
141+
try {
142+
entries = await readdir(runsDir)
143+
} catch {
144+
return 0
145+
}
146+
type Candidate = { name: string; updatedAt: number }
147+
const candidates: Candidate[] = []
148+
for (const name of entries) {
149+
const run = await readRunState(runsDir, name)
150+
// updatedAt=0 → orphan dir without parseable state.json; sorts first → pruned first.
151+
candidates.push({ name, updatedAt: run?.updatedAt ?? 0 })
152+
}
153+
// Newest first; orphans (updatedAt=0) sink to the tail and get pruned first.
154+
candidates.sort((a, b) => b.updatedAt - a.updatedAt)
155+
// Guard against negative keepMax: slice(-N) would invert semantics and keep N newest instead of
156+
// pruning them, which contradicts the contract. Clamp to 0 so a bad caller at worst wipes everything.
157+
const cap = Math.max(0, Math.trunc(keepMax))
158+
const victims = candidates.slice(cap)
159+
let removed = 0
160+
for (const v of victims) {
161+
try {
162+
await rm(join(runsDir, v.name), { recursive: true, force: true })
163+
removed++
164+
} catch (e) {
165+
logForDebugging(
166+
`[workflow warn] cleanupOldRuns failed to remove ${v.name}: ${(e as Error).message}`,
167+
)
168+
}
169+
}
170+
return removed
105171
}
106172

107173
/**
@@ -113,6 +179,10 @@ export async function listPersistedRuns(
113179
* Disk write is best-effort: writeRunState swallows IO exceptions and only logs, does not propagate —
114180
* so other bus subscribers (store, etc.) are not affected by persistence failures.
115181
*
182+
* Also fires-and-forgets cleanupOldRuns so the runs directory stays bounded across long-lived
183+
* sessions (KEEP_MAX_RUNS). The cleanup runs *after* the new state is written, guaranteeing the
184+
* just-finished run is already on disk and counted as newest — never swept out from under itself.
185+
*
116186
* @param runsDirProvider Optional runsDir resolver (defaults to getRunsDir).
117187
* Production path uses the default; tests inject a tmpdir to avoid writing to the real project directory (Bun ESM module namespace is read-only,
118188
* cannot monkey-patch getRunsDir itself).
@@ -126,6 +196,15 @@ export function attachRunStatePersistence(
126196
if (event.type !== 'run_done') return
127197
const run = store.get(event.runId)
128198
if (!run) return
129-
void writeRunState(runsDirProvider(), run)
199+
const dir = runsDirProvider()
200+
void writeRunState(dir, run).then(() => {
201+
// Sweep only after the new state lands on disk — avoids a race where the just-finished run
202+
// itself gets pruned because its state.json wasn't counted yet.
203+
void cleanupOldRuns(dir).catch(e => {
204+
logForDebugging(
205+
`[workflow warn] cleanupOldRuns after run_done threw: ${(e as Error).message}`,
206+
)
207+
})
208+
})
130209
})
131210
}

src/workflow/service.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ import {
2121
listPersistedRuns,
2222
readRunState,
2323
} from './persistence.js'
24+
25+
/**
26+
* How many newest persisted runs to hydrate into the store on panel open. Tuned to cover a normal
27+
* day's worth of workflow iterations without overrunning the panel tab row; anything older stays
28+
* on disk and is still resumable via getRunAsync until cleanupOldRuns reclaims it.
29+
*/
30+
const LOAD_PERSISTED_LIMIT = 20
2431
import { createProgressBus } from './progress/bus.js'
2532
import {
2633
createProgressStoreFromBus,
@@ -135,19 +142,23 @@ export function makeService(
135142
script?: string
136143
name?: string
137144
scriptPath?: string
145+
title?: string
138146
}): Promise<{
139147
script: string
140148
workflowFile?: string
141149
workflowName: string
142150
}> {
151+
// Mirrors WorkflowTool.ts: name takes priority over title; only fall back to the literal
152+
// 'workflow' when neither is supplied (so /workflows tabs don't pile up under a same default name).
153+
const workflowName = input.name ?? input.title ?? 'workflow'
143154
if (input.script) {
144-
return { script: input.script, workflowName: 'workflow' }
155+
return { script: input.script, workflowName }
145156
}
146157
if (input.scriptPath) {
147158
return {
148159
script: await readFile(input.scriptPath, 'utf-8'),
149160
workflowFile: input.scriptPath,
150-
workflowName: 'workflow',
161+
workflowName,
151162
}
152163
}
153164
if (input.name) {
@@ -280,7 +291,13 @@ export function makeService(
280291
if (persistedLoaded) return
281292
persistedLoaded = true
282293
try {
283-
const runs = await listPersistedRuns(runsDirProvider())
294+
// Cap hydration at LOAD_PERSISTED_LIMIT newest runs so the panel tab row doesn't drown
295+
// under accumulated history. Older state.json files stay on disk (within KEEP_MAX_RUNS,
296+
// maintained by cleanupOldRuns) and remain resumable via getRunAsync.
297+
const runs = await listPersistedRuns(
298+
runsDirProvider(),
299+
LOAD_PERSISTED_LIMIT,
300+
)
284301
for (const run of runs) store.hydrate(run)
285302
} catch (e) {
286303
// Scan failure does not block the panel: log + reset flag to allow next retry

0 commit comments

Comments
 (0)