Skip to content

Commit bcfb722

Browse files
Refactor Ralph session tracking with KV-backed session mapping and configurable TTL (#172)
* Fix Ralph state type safety and null handling in status commands * Refactor Ralph to use worktree-keyed session mapping instead of sessionId lookups * Add configurable KV TTL with 7-day default and update agent prompts * Add --limit flag to CLI status and improve null safety for RalphState fields * Bump memory package version to 0.0.25
1 parent 86613b1 commit bcfb722

15 files changed

Lines changed: 387 additions & 273 deletions

File tree

backend/src/routes/memory.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -576,10 +576,10 @@ export function createMemoryRoutes(db: Database): Hono {
576576
app.post('/ralph/cancel', async (c) => {
577577
try {
578578
const body = await c.req.json()
579-
const { repoId, sessionId } = body
579+
const { repoId, worktreeName, sessionId } = body
580580

581-
if (!repoId || !sessionId) {
582-
return c.json({ error: 'Missing repoId or sessionId' }, 400)
581+
if (!repoId || (!worktreeName && !sessionId)) {
582+
return c.json({ error: 'Missing repoId or identifier (worktreeName or sessionId)' }, 400)
583583
}
584584

585585
const repo = getRepoById(db, parseInt(repoId, 10))
@@ -594,8 +594,23 @@ export function createMemoryRoutes(db: Database): Hono {
594594
return c.json({ cancelled: false })
595595
}
596596

597-
const kvEntry = pluginMemory.getKv(projectId, `ralph:${sessionId}`)
597+
let worktreeNameToUse: string | undefined
598598

599+
if (worktreeName) {
600+
worktreeNameToUse = worktreeName
601+
} else if (sessionId) {
602+
const sessionMappingEntry = pluginMemory.getKv(projectId, `ralph-session:${sessionId}`)
603+
if (!sessionMappingEntry) {
604+
return c.json({ cancelled: false })
605+
}
606+
worktreeNameToUse = sessionMappingEntry.data as string
607+
}
608+
609+
if (!worktreeNameToUse) {
610+
return c.json({ cancelled: false })
611+
}
612+
613+
const kvEntry = pluginMemory.getKv(projectId, `ralph:${worktreeNameToUse}`)
599614
if (!kvEntry) {
600615
return c.json({ cancelled: false })
601616
}
@@ -620,10 +635,10 @@ export function createMemoryRoutes(db: Database): Hono {
620635
completedAt: new Date().toISOString(),
621636
}
622637

623-
pluginMemory.setKv(projectId, `ralph:${sessionId}`, updatedState)
638+
pluginMemory.setKv(projectId, `ralph:${worktreeNameToUse}`, updatedState)
624639

625640
try {
626-
const abortUrl = new URL(`${OPENCODE_SERVER_URL}/session/${sessionId}/abort`)
641+
const abortUrl = new URL(`${OPENCODE_SERVER_URL}/session/${state.sessionId}/abort`)
627642
abortUrl.searchParams.set('directory', repo.fullPath)
628643
await fetch(abortUrl.toString(), { method: 'POST' })
629644
} catch {

backend/test/routes/memory.test.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ describe('Memory Routes - Ralph Status', () => {
147147
mockResolveProjectId.mockResolvedValue('test-project-id')
148148
mockListKv.mockReturnValue([
149149
{
150-
key: 'ralph:session-123',
150+
key: 'ralph:test-worktree',
151151
data: {
152152
active: true,
153153
sessionId: 'session-123',
@@ -194,28 +194,40 @@ describe('Memory Routes - Ralph Status', () => {
194194
mockResolveProjectId.mockResolvedValue('test-project-id')
195195
mockListKv.mockReturnValue([
196196
{
197-
key: 'ralph:session-123',
198-
data: { active: true, sessionId: 'session-123' },
197+
key: 'ralph:test-worktree-1',
198+
data: {
199+
active: true,
200+
sessionId: 'session-123',
201+
worktreeName: 'test-worktree-1',
202+
worktreeDir: '/tmp/test-worktree-1',
203+
iteration: 1,
204+
maxIterations: 10,
205+
startedAt: new Date().toISOString(),
206+
phase: 'coding',
207+
errorCount: 0,
208+
auditCount: 0,
209+
completionPromise: null,
210+
},
199211
createdAt: Date.now(),
200212
updatedAt: Date.now(),
201213
expiresAt: Date.now() + 86400000,
202214
},
203215
{
204-
key: 'ralph:session-456',
216+
key: 'ralph:test-worktree-2',
205217
data: null,
206218
createdAt: Date.now(),
207219
updatedAt: Date.now(),
208220
expiresAt: Date.now() + 86400000,
209221
},
210222
{
211-
key: 'ralph:session-789',
223+
key: 'ralph:test-worktree-3',
212224
data: 'string-data',
213225
createdAt: Date.now(),
214226
updatedAt: Date.now(),
215227
expiresAt: Date.now() + 86400000,
216228
},
217229
{
218-
key: 'ralph:session-abc',
230+
key: 'ralph:test-worktree-4',
219231
data: { sessionId: 'session-abc' },
220232
createdAt: Date.now(),
221233
updatedAt: Date.now(),

packages/memory/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@opencode-manager/memory",
3-
"version": "0.0.24",
3+
"version": "0.0.25",
44
"type": "module",
55
"main": "./dist/index.js",
66
"types": "./dist/index.d.ts",

packages/memory/src/agents/architect.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,12 @@ ${getInjectedMemory('architect')}
5959
6060
## Project KV Store
6161
62-
You have access to a project-scoped key-value store with 24-hour TTL for ephemeral state:
62+
You have access to a project-scoped key-value store with 7-day TTL for ephemeral state:
6363
- \`memory-kv-set\`: Store planning progress, research findings, or any project state
6464
- \`memory-kv-get\`: Retrieve previously stored state
6565
- \`memory-kv-list\`: See all active entries for the project
6666
67-
KV entries are scoped to the current project and expire after 24 hours. Use this for state that needs to survive compaction but isn't permanent enough for memory-write.
67+
KV entries are scoped to the current project and expire after 7 days. Use this for state that needs to survive compaction but isn't permanent enough for memory-write.
6868
6969
## Workflow
7070

packages/memory/src/agents/auditor.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,17 +149,17 @@ Example:
149149
150150
The KV store upserts by key, so storing a finding for the same file:line automatically updates the previous entry. No dedup checks needed.
151151
152-
When the calling agent reports that a finding has been fixed, update the finding by calling \`memory-kv-set\` with the same key and the status changed to "resolved" with a resolution date added.
152+
When a previously open finding has been addressed by the current changes, **delete it** using \`memory-kv-delete\` with the same key. Do not re-store resolved findings — removing them keeps the KV store clean and avoids extending the TTL on stale data.
153153
154-
Findings expire after 24 hours automatically. If an issue persists, the next review will re-discover it.
154+
Findings expire after 7 days automatically. If an issue persists, the next review will re-discover it.
155155
156156
## Retrieving Past Findings
157157
158158
At the start of every review, before analyzing the diff:
159159
1. Call \`memory-kv-list\` to get all active KV entries for the project
160160
2. Filter entries with keys starting with \`review-finding:\` that match files in the current diff
161161
3. If open findings exist for files being changed, include them under a "### Previously Identified Issues" heading before new findings
162-
4. Check if any previously open findings have been addressed by the current changes — if so, update their status to "resolved" via \`memory-kv-set\` with the same key
162+
4. Check if any previously open findings have been addressed by the current changes — if so, delete them via \`memory-kv-delete\` with the same key
163163
164164
${getInjectedMemory('auditor')}
165165
`,

packages/memory/src/agents/code.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,11 @@ You are the execution agent. Your job is to write code, not describe code.
7979
8080
## Project KV Store
8181
82-
You have access to a project-scoped key-value store with 24-hour TTL for ephemeral state:
82+
You have access to a project-scoped key-value store with 7-day TTL for ephemeral state:
8383
- \`memory-kv-set\`: Store ephemeral findings, planning progress, or session state
8484
- \`memory-kv-get\`: Retrieve previously stored state
8585
- \`memory-kv-list\`: See all active entries for the project
8686
87-
KV entries are scoped to the current project and expire after 24 hours. Use this for state that needs to survive compaction but isn't permanent enough for memory-write.
87+
KV entries are scoped to the current project and expire after 7 days. Use this for state that needs to survive compaction but isn't permanent enough for memory-write.
8888
`,
8989
}

packages/memory/src/cli/commands/status.ts

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface StatusArgs {
2323
server?: string
2424
listWorktrees?: boolean
2525
listWorktreesFilter?: string
26+
limit?: number
2627
}
2728

2829
export async function run(argv: StatusArgs): Promise<void> {
@@ -83,7 +84,7 @@ export async function run(argv: StatusArgs): Promise<void> {
8384
for (const row of rows) {
8485
try {
8586
const state = JSON.parse(row.data) as RalphState
86-
if (state.active) {
87+
if (state.active && state.sessionId && state.worktreeName && state.iteration != null && state.maxIterations != null && state.phase && state.startedAt) {
8788
activeLoops.push({
8889
sessionId: state.sessionId,
8990
worktreeName: state.worktreeName,
@@ -92,7 +93,7 @@ export async function run(argv: StatusArgs): Promise<void> {
9293
maxIterations: state.maxIterations,
9394
phase: state.phase,
9495
startedAt: state.startedAt,
95-
audit: state.audit,
96+
audit: state.audit ?? false,
9697
})
9798
} else if (state.completedAt) {
9899
recentLoops.push({ state, row })
@@ -164,7 +165,8 @@ export async function run(argv: StatusArgs): Promise<void> {
164165
}
165166

166167
const state = JSON.parse(row.data) as RalphState
167-
const duration = Date.now() - new Date(state.startedAt).getTime()
168+
const startedAt = state.startedAt!
169+
const duration = Date.now() - new Date(startedAt).getTime()
168170
const hours = Math.floor(duration / (1000 * 60 * 60))
169171
const minutes = Math.floor((duration % (1000 * 60 * 60)) / (1000 * 60))
170172
const seconds = Math.floor((duration % (1000 * 60)) / 1000)
@@ -184,9 +186,9 @@ export async function run(argv: StatusArgs): Promise<void> {
184186
console.log(` Iteration: ${state.iteration}/${state.maxIterations}`)
185187
console.log(` Duration: ${hours}h ${minutes}m ${seconds}s`)
186188
console.log(` Audit: ${state.audit ? 'Yes' : 'No'}`)
187-
console.log(` Error Count: ${state.errorCount}`)
188-
console.log(` Audit Count: ${state.auditCount}`)
189-
console.log(` Started: ${new Date(state.startedAt).toISOString()}`)
189+
console.log(` Error Count: ${state.errorCount ?? 0}`)
190+
console.log(` Audit Count: ${state.auditCount ?? 0}`)
191+
console.log(` Started: ${new Date(startedAt).toISOString()}`)
190192
if (state.completionPromise) {
191193
console.log(` Completion: ${state.completionPromise}`)
192194
}
@@ -196,7 +198,7 @@ export async function run(argv: StatusArgs): Promise<void> {
196198
}
197199
}
198200

199-
const sessionOutput = await tryFetchSessionOutput(argv.server ?? 'http://localhost:5551', state.sessionId, state.worktreeDir)
201+
const sessionOutput = await tryFetchSessionOutput(argv.server ?? 'http://localhost:5551', state.sessionId, state.worktreeDir!)
200202
if (sessionOutput) {
201203
console.log('Session Output:')
202204
for (const line of formatSessionOutput(sessionOutput)) {
@@ -207,7 +209,8 @@ export async function run(argv: StatusArgs): Promise<void> {
207209
} else {
208210
const state = matchedLoop.loop.state
209211
const completedAt = state.completedAt!
210-
const duration = new Date(completedAt).getTime() - new Date(state.startedAt).getTime()
212+
const startedAt = state.startedAt!
213+
const duration = new Date(completedAt).getTime() - new Date(startedAt).getTime()
211214
const hours = Math.floor(duration / (1000 * 60 * 60))
212215
const minutes = Math.floor((duration % (1000 * 60 * 60)) / (1000 * 60))
213216
const seconds = Math.floor((duration % (1000 * 60)) / 1000)
@@ -226,15 +229,15 @@ export async function run(argv: StatusArgs): Promise<void> {
226229
console.log(` Iteration: ${state.iteration}/${state.maxIterations}`)
227230
console.log(` Duration: ${hours}h ${minutes}m ${seconds}s`)
228231
console.log(` Reason: ${state.terminationReason ?? 'unknown'}`)
229-
console.log(` Started: ${new Date(state.startedAt).toISOString()}`)
232+
console.log(` Started: ${new Date(startedAt).toISOString()}`)
230233
console.log(` Completed: ${new Date(completedAt).toISOString()}`)
231234
if (state.lastAuditResult) {
232235
for (const line of formatAuditResult(state.lastAuditResult)) {
233236
console.log(line)
234237
}
235238
}
236239

237-
const sessionOutput = await tryFetchSessionOutput(argv.server ?? 'http://localhost:5551', state.sessionId, state.worktreeDir)
240+
const sessionOutput = await tryFetchSessionOutput(argv.server ?? 'http://localhost:5551', state.sessionId, state.worktreeDir!)
238241
if (sessionOutput) {
239242
console.log('Session Output:')
240243
for (const line of formatSessionOutput(sessionOutput)) {
@@ -270,14 +273,21 @@ export async function run(argv: StatusArgs): Promise<void> {
270273
console.log('Recently Completed:')
271274
console.log('')
272275

273-
for (const loop of recentLoops) {
276+
const limit = argv.limit ?? 10
277+
const displayedLoops = recentLoops.slice(0, limit)
278+
for (const loop of displayedLoops) {
274279
const reason = loop.state.terminationReason ?? 'unknown'
275280
const completed = new Date(loop.state.completedAt!).toLocaleString()
276281

277282
console.log(` ${loop.state.worktreeName}`)
278283
console.log(` Iterations: ${loop.state.iteration} Reason: ${reason} Completed: ${completed}`)
279284
console.log('')
280285
}
286+
287+
if (recentLoops.length > limit) {
288+
console.log(` ... and ${recentLoops.length - limit} more. Use 'ocm-mem status <name>' for details.`)
289+
console.log('')
290+
}
281291
}
282292

283293
if (activeLoops.length === 0 && recentLoops.length === 0) {
@@ -309,6 +319,7 @@ Options:
309319
--server <url> OpenCode server URL (default: http://localhost:5551)
310320
--list-worktrees List all worktree names (for shell completion)
311321
Optionally provide a filter: --list-worktrees <filter>
322+
--limit <n> Limit recent loops shown (default: 10)
312323
--project, -p <id> Project ID (auto-detected from git if not provided)
313324
--db-path <path> Path to memory database
314325
--help, -h Show this help message
@@ -332,6 +343,8 @@ export async function cli(args: string[], globalOpts: { dbPath?: string; resolve
332343
if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
333344
argv.listWorktreesFilter = args[++i]
334345
}
346+
} else if (arg === '--limit') {
347+
argv.limit = parseInt(args[++i], 10)
335348
} else if (arg === '--help' || arg === '-h') {
336349
help()
337350
process.exit(0)

0 commit comments

Comments
 (0)