Skip to content

Commit 549046e

Browse files
committed
fix: improve idea handling reliability and crash safety
- Save selected idea to state BEFORE creating plan (crash recovery) - Delete idea file only AFTER plan is successfully saved - Handle resumption when currentIdeaPath exists in state - Clear idea tracking when cycle completes - Add auto-commit after tasks and auto-push after cycles - Import removeIdea function instead of removeIdeaByIndex Signed-off-by: leocavalcante <[email protected]>
1 parent a025639 commit 549046e

File tree

2 files changed

+174
-56
lines changed

2 files changed

+174
-56
lines changed

src/ideas.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Ideas queue management
33
*
4-
* Users can place .md files in .opencoder/ideas/ to provide specific tasks
4+
* Users can place .md files in .opencode/opencoder/ideas/ to provide specific tasks
55
* for the autonomous loop to work on.
66
*/
77

@@ -43,7 +43,12 @@ export async function loadAllIdeas(ideasDir: string): Promise<Idea[]> {
4343
filename,
4444
content: truncatedContent,
4545
})
46-
} catch {}
46+
} catch (err) {
47+
// Skip unreadable files (permission issues, file deleted, etc.)
48+
if (process.env.DEBUG) {
49+
console.debug(`[ideas] Failed to read ${filename}: ${err}`)
50+
}
51+
}
4752
}
4853

4954
return ideas
@@ -103,7 +108,11 @@ export function removeIdea(ideaPath: string): boolean {
103108
return true
104109
}
105110
return false
106-
} catch {
111+
} catch (err) {
112+
// Failed to remove idea file (permission issues, etc.)
113+
if (process.env.DEBUG) {
114+
console.debug(`[ideas] Failed to remove ${ideaPath}: ${err}`)
115+
}
107116
return false
108117
}
109118
}
@@ -177,13 +186,19 @@ export async function cleanupEmptyIdeas(ideasDir: string): Promise<number> {
177186
unlinkSync(path)
178187
removedCount++
179188
}
180-
} catch {
189+
} catch (err) {
181190
// Try to remove unreadable files
191+
if (process.env.DEBUG) {
192+
console.debug(`[ideas] Failed to read ${filename} during cleanup: ${err}`)
193+
}
182194
try {
183195
unlinkSync(path)
184196
removedCount++
185-
} catch {
186-
// Ignore
197+
} catch (unlinkErr) {
198+
// Could not remove file (permission issues, etc.)
199+
if (process.env.DEBUG) {
200+
console.debug(`[ideas] Failed to remove ${filename}: ${unlinkErr}`)
201+
}
187202
}
188203
}
189204
}

src/loop.ts

Lines changed: 153 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,8 @@ import {
1717
readFileOrNull,
1818
writeFile,
1919
} from "./fs.ts"
20-
import {
21-
formatIdeasForSelection,
22-
loadAllIdeas,
23-
parseIdeaSelection,
24-
removeIdeaByIndex,
25-
} from "./ideas.ts"
20+
import { commitChanges, generateCommitMessage, hasChanges, pushChanges } from "./git.ts"
21+
import { formatIdeasForSelection, loadAllIdeas, parseIdeaSelection, removeIdea } from "./ideas.ts"
2622
import { Logger } from "./logger.ts"
2723
import { getTasks, getUncompletedTasks, markTaskComplete, validatePlan } from "./plan.ts"
2824
import { loadState, saveState } from "./state.ts"
@@ -88,7 +84,7 @@ export async function runLoop(config: Config): Promise<void> {
8884
break
8985

9086
case "evaluation":
91-
await runEvaluationPhase(state, builder, paths, logger)
87+
await runEvaluationPhase(state, builder, paths, logger, config)
9288
break
9389
}
9490
} catch (err) {
@@ -117,9 +113,12 @@ export async function runLoop(config: Config): Promise<void> {
117113
}
118114

119115
/**
120-
* Log startup information
116+
* Log startup information.
117+
* Exported for testing.
118+
* @param logger - Logger instance
119+
* @param config - Application configuration
121120
*/
122-
function logStartupInfo(logger: Logger, config: Config): void {
121+
export function logStartupInfo(logger: Logger, config: Config): void {
123122
// Get version from build-time define or package.json
124123
const version = typeof VERSION !== "undefined" ? VERSION : "1.0.0"
125124

@@ -136,10 +135,12 @@ function logStartupInfo(logger: Logger, config: Config): void {
136135
}
137136

138137
/**
139-
* Setup signal handlers for graceful shutdown
138+
* Setup signal handlers for graceful shutdown.
139+
* @param logger - Logger instance for output
140+
* @param _builder - Builder instance (unused but kept for potential future use)
140141
*/
141142
function setupSignalHandlers(logger: Logger, _builder: Builder): void {
142-
const handleShutdown = async (signal: string) => {
143+
const handleShutdown = async (signal: string): Promise<void> => {
143144
if (forceShutdown) {
144145
logger.say("\nForce quit!")
145146
process.exit(130)
@@ -156,8 +157,12 @@ function setupSignalHandlers(logger: Logger, _builder: Builder): void {
156157
logger.say("Press Ctrl+C again to force quit.")
157158
}
158159

159-
process.on("SIGINT", () => handleShutdown("SIGINT"))
160-
process.on("SIGTERM", () => handleShutdown("SIGTERM"))
160+
process.on("SIGINT", (): void => {
161+
handleShutdown("SIGINT")
162+
})
163+
process.on("SIGTERM", (): void => {
164+
handleShutdown("SIGTERM")
165+
})
161166
}
162167

163168
/**
@@ -170,58 +175,118 @@ async function runPlanPhase(
170175
logger: Logger,
171176
config: Config,
172177
): Promise<void> {
173-
// Check for ideas first
174-
const ideas = await loadAllIdeas(paths.ideasDir)
175-
176178
let planContent: string
179+
let ideaToRemove: { path: string; filename: string } | null = null
180+
181+
// Check if we're resuming with a previously selected idea
182+
if (state.currentIdeaPath && state.currentIdeaFilename) {
183+
logger.info(`Resuming with idea: ${state.currentIdeaFilename}`)
184+
185+
// Try to read the idea content (it might still exist if we crashed before deleting)
186+
try {
187+
const content = await Bun.file(state.currentIdeaPath).text()
188+
if (content.trim()) {
189+
ideaToRemove = { path: state.currentIdeaPath, filename: state.currentIdeaFilename }
190+
planContent = await builder.runIdeaPlan(content, state.currentIdeaFilename, state.cycle)
191+
} else {
192+
// Idea file is empty or gone, clear the state and proceed with normal planning
193+
logger.warn(
194+
`Idea file ${state.currentIdeaFilename} is empty, proceeding with autonomous plan`,
195+
)
196+
state.currentIdeaPath = undefined
197+
state.currentIdeaFilename = undefined
198+
planContent = await builder.runPlan(state.cycle, config.userHint)
199+
}
200+
} catch {
201+
// Idea file doesn't exist anymore, but we have it in state - clear and proceed
202+
logger.warn(
203+
`Idea file ${state.currentIdeaFilename} not found, proceeding with autonomous plan`,
204+
)
205+
state.currentIdeaPath = undefined
206+
state.currentIdeaFilename = undefined
207+
planContent = await builder.runPlan(state.cycle, config.userHint)
208+
}
209+
} else {
210+
// Check for new ideas
211+
const ideas = await loadAllIdeas(paths.ideasDir)
177212

178-
if (ideas.length > 0) {
179-
logger.info(`Found ${ideas.length} idea(s) in queue`)
213+
if (ideas.length > 0) {
214+
logger.info(`Found ${ideas.length} idea(s) in queue`)
180215

181-
if (ideas.length === 1) {
182-
// Single idea - use directly
183-
const idea = ideas[0]
184-
if (!idea) throw new Error("Unexpected: ideas[0] is undefined")
185-
logger.say(`Using idea: ${idea.filename}`)
186-
removeIdeaByIndex(ideas, 0)
187-
planContent = await builder.runIdeaPlan(idea.content, idea.filename, state.cycle)
188-
} else {
189-
// Multiple ideas - let AI select
190-
const formatted = formatIdeasForSelection(ideas)
191-
const selection = await builder.runIdeaSelection(formatted, state.cycle)
192-
const selectedIndex = parseIdeaSelection(selection)
193-
194-
if (selectedIndex !== null && selectedIndex < ideas.length) {
195-
const idea = ideas[selectedIndex]
196-
if (!idea) throw new Error("Unexpected: selected idea is undefined")
197-
logger.success(`AI selected idea: ${idea.filename}`)
198-
removeIdeaByIndex(ideas, selectedIndex)
199-
planContent = await builder.runIdeaPlan(idea.content, idea.filename, state.cycle)
216+
let selectedIdea: { path: string; filename: string; content: string } | null = null
217+
218+
if (ideas.length === 1) {
219+
// Single idea - use directly
220+
const idea = ideas[0]
221+
if (!idea) throw new Error("Unexpected: ideas[0] is undefined")
222+
selectedIdea = idea
223+
logger.say(`Using idea: ${idea.filename}`)
224+
} else {
225+
// Multiple ideas - let AI select
226+
const formatted = formatIdeasForSelection(ideas)
227+
const selection = await builder.runIdeaSelection(formatted, state.cycle)
228+
const selectedIndex = parseIdeaSelection(selection)
229+
230+
if (selectedIndex !== null && selectedIndex < ideas.length) {
231+
const idea = ideas[selectedIndex]
232+
if (!idea) throw new Error("Unexpected: selected idea is undefined")
233+
selectedIdea = idea
234+
logger.success(`AI selected idea: ${idea.filename}`)
235+
} else {
236+
// Fallback to autonomous plan
237+
logger.warn("Could not parse idea selection, falling back to autonomous plan")
238+
}
239+
}
240+
241+
if (selectedIdea) {
242+
// Save the selected idea to state BEFORE creating the plan
243+
// This ensures we can resume if the process crashes
244+
state.currentIdeaPath = selectedIdea.path
245+
state.currentIdeaFilename = selectedIdea.filename
246+
await saveState(paths.stateFile, state)
247+
248+
ideaToRemove = { path: selectedIdea.path, filename: selectedIdea.filename }
249+
planContent = await builder.runIdeaPlan(
250+
selectedIdea.content,
251+
selectedIdea.filename,
252+
state.cycle,
253+
)
200254
} else {
201-
// Fallback to autonomous plan
202-
logger.warn("Could not parse idea selection, falling back to autonomous plan")
255+
// No valid idea selected, fall back to autonomous plan
203256
planContent = await builder.runPlan(state.cycle, config.userHint)
204257
}
258+
} else {
259+
// No ideas - autonomous plan
260+
planContent = await builder.runPlan(state.cycle, config.userHint)
205261
}
206-
} else {
207-
// No ideas - autonomous plan
208-
planContent = await builder.runPlan(state.cycle, config.userHint)
209262
}
210263

211264
// Validate the plan
212265
const validation = validatePlan(planContent)
213266
if (!validation.valid) {
214267
logger.logError(`Invalid plan: ${validation.error}`)
215-
// Stay in plan phase to retry
268+
// Stay in plan phase to retry - don't remove the idea yet
216269
return
217270
}
218271

219-
// Save the plan
272+
// Save the plan FIRST
220273
await writeFile(paths.currentPlan, planContent)
221274

222275
const tasks = getTasks(planContent)
223276
logger.success(`Plan created with ${tasks.length} tasks`)
224277

278+
// Only NOW remove the idea file, after plan is safely saved
279+
if (ideaToRemove) {
280+
const removed = removeIdea(ideaToRemove.path)
281+
if (removed) {
282+
logger.logVerbose(`Removed processed idea: ${ideaToRemove.filename}`)
283+
}
284+
}
285+
286+
// Clear the idea from state since it's now processed
287+
state.currentIdeaPath = undefined
288+
state.currentIdeaFilename = undefined
289+
225290
// Transition to build
226291
state.phase = "build"
227292
state.taskIndex = 0
@@ -277,7 +342,7 @@ async function runBuildPhase(
277342
if (shutdownRequested) return
278343

279344
// Build the task
280-
logger.activity("Building task", `${state.currentTaskNum}/${tasks.length}`)
345+
logger.info(`Building task ${state.currentTaskNum}/${tasks.length}`)
281346
const result = await builder.runTask(
282347
nextTask.description,
283348
state.cycle,
@@ -291,6 +356,12 @@ async function runBuildPhase(
291356
await writeFile(paths.currentPlan, updatedPlan)
292357

293358
logger.success(`Task ${state.currentTaskNum}/${tasks.length} complete`)
359+
360+
// Auto-commit changes if enabled
361+
if (config.autoCommit && hasChanges(config.projectDir)) {
362+
const commitMessage = generateCommitMessage(nextTask.description)
363+
commitChanges(config.projectDir, logger, commitMessage, config.commitSignoff)
364+
}
294365
} else {
295366
logger.logError(`Task failed: ${result.error}`)
296367
// Continue to next task or retry logic could go here
@@ -310,6 +381,7 @@ async function runEvaluationPhase(
310381
builder: Builder,
311382
paths: Paths,
312383
logger: Logger,
384+
config: Config,
313385
): Promise<void> {
314386
// Read current plan for evaluation
315387
const planContent = await readFileOrNull(paths.currentPlan)
@@ -333,13 +405,20 @@ async function runEvaluationPhase(
333405
// Archive the completed plan
334406
await archivePlan(paths, state.cycle, logger)
335407

408+
// Auto-push commits if enabled
409+
if (config.autoPush && hasChanges(config.projectDir)) {
410+
pushChanges(config.projectDir, logger)
411+
}
412+
336413
// Start new cycle
337414
state.cycle++
338415
state.phase = "plan"
339416
state.taskIndex = 0
340417
state.totalTasks = 0
341418
state.currentTaskNum = 0
342419
state.currentTaskDesc = ""
420+
state.currentIdeaPath = undefined
421+
state.currentIdeaFilename = undefined
343422

344423
// Clear session for new cycle
345424
builder.clearSession()
@@ -353,9 +432,13 @@ async function runEvaluationPhase(
353432
}
354433

355434
/**
356-
* Archive the completed plan to history
435+
* Archive the completed plan to history.
436+
* Exported for testing.
437+
* @param paths - Workspace paths
438+
* @param cycle - Current cycle number
439+
* @param logger - Logger instance
357440
*/
358-
async function archivePlan(paths: Paths, cycle: number, logger: Logger): Promise<void> {
441+
export async function archivePlan(paths: Paths, cycle: number, logger: Logger): Promise<void> {
359442
const planContent = await readFileOrNull(paths.currentPlan)
360443
if (!planContent) return
361444

@@ -368,18 +451,38 @@ async function archivePlan(paths: Paths, cycle: number, logger: Logger): Promise
368451
}
369452

370453
/**
371-
* Sleep for a given number of milliseconds
454+
* Sleep for a given number of milliseconds.
455+
* Exported for testing and reuse.
456+
* @param ms - Number of milliseconds to sleep
457+
* @returns Promise that resolves after the specified time
372458
*/
373-
function sleep(ms: number): Promise<void> {
459+
export function sleep(ms: number): Promise<void> {
374460
return new Promise((resolve) => setTimeout(resolve, ms))
375461
}
376462

377463
/**
378-
* Check if shutdown has been requested
464+
* Check if shutdown has been requested.
465+
* Exported for testing and external shutdown checks.
379466
*/
380467
export function isShutdownRequested(): boolean {
381468
return shutdownRequested
382469
}
383470

471+
/**
472+
* Reset shutdown flags. Only for testing purposes.
473+
* WARNING: Do not use in production code.
474+
*/
475+
export function resetShutdownFlags(): void {
476+
shutdownRequested = false
477+
forceShutdown = false
478+
}
479+
480+
/**
481+
* Request a shutdown. Used for programmatic shutdown triggering.
482+
*/
483+
export function requestShutdown(): void {
484+
shutdownRequested = true
485+
}
486+
384487
// Declare VERSION as a global that will be defined at build time
385488
declare const VERSION: string

0 commit comments

Comments
 (0)