Skip to content

Commit a025639

Browse files
committed
feat: add idea tracking to state for crash recovery
- Add currentIdeaPath and currentIdeaFilename fields to State interface - Persist idea tracking across restarts for crash recovery - Update state tests for new fields Signed-off-by: leocavalcante <[email protected]>
1 parent c608ef5 commit a025639

File tree

2 files changed

+91
-9
lines changed

2 files changed

+91
-9
lines changed

src/state.ts

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,14 @@ const DEFAULT_STATE: State = {
1111
phase: "init",
1212
taskIndex: 0,
1313
lastUpdate: "",
14+
currentIdeaPath: undefined,
15+
currentIdeaFilename: undefined,
1416
}
1517

1618
/**
17-
* Load state from file, or return default state if file doesn't exist
19+
* Load state from file, or return default state if file doesn't exist.
20+
* @param stateFile - Path to the state JSON file
21+
* @returns RuntimeState loaded from file or default state
1822
*/
1923
export async function loadState(stateFile: string): Promise<RuntimeState> {
2024
const content = await readFileOrNull(stateFile)
@@ -26,24 +30,65 @@ export async function loadState(stateFile: string): Promise<RuntimeState> {
2630
try {
2731
const parsed = JSON.parse(content) as Partial<State>
2832

33+
// Validate cycle is a positive number
34+
if (parsed.cycle !== undefined && (typeof parsed.cycle !== "number" || parsed.cycle < 1)) {
35+
console.warn(
36+
`Warning: Invalid cycle value in ${stateFile} (expected positive number, got ${parsed.cycle}). Using default.`,
37+
)
38+
parsed.cycle = DEFAULT_STATE.cycle
39+
}
40+
41+
// Validate taskIndex is a non-negative number
42+
if (
43+
parsed.taskIndex !== undefined &&
44+
(typeof parsed.taskIndex !== "number" || parsed.taskIndex < 0)
45+
) {
46+
console.warn(
47+
`Warning: Invalid taskIndex in ${stateFile} (expected non-negative number, got ${parsed.taskIndex}). Using default.`,
48+
)
49+
parsed.taskIndex = DEFAULT_STATE.taskIndex
50+
}
51+
52+
// Validate phase
53+
const validatedPhase = validatePhase(parsed.phase)
54+
if (parsed.phase !== undefined && validatedPhase === null) {
55+
console.warn(
56+
`Warning: Invalid phase in ${stateFile} (got "${parsed.phase}", expected one of: init, plan, build, evaluation). Using default.`,
57+
)
58+
}
59+
2960
// Merge with defaults to handle missing fields
3061
const state: State = {
3162
cycle: parsed.cycle ?? DEFAULT_STATE.cycle,
32-
phase: validatePhase(parsed.phase) ?? DEFAULT_STATE.phase,
63+
phase: validatedPhase ?? DEFAULT_STATE.phase,
3364
taskIndex: parsed.taskIndex ?? DEFAULT_STATE.taskIndex,
3465
sessionId: parsed.sessionId,
3566
lastUpdate: parsed.lastUpdate ?? DEFAULT_STATE.lastUpdate,
67+
currentIdeaPath: parsed.currentIdeaPath,
68+
currentIdeaFilename: parsed.currentIdeaFilename,
3669
}
3770

3871
return toRuntimeState(state)
3972
} catch (err) {
40-
console.warn(`Warning: Failed to parse state file, using defaults: ${err}`)
73+
// Provide specific guidance based on error type
74+
if (err instanceof SyntaxError) {
75+
console.warn(
76+
`Warning: Failed to parse ${stateFile} - invalid JSON syntax. ` +
77+
`The file may be corrupted. Using default state. Error: ${err.message}`,
78+
)
79+
} else {
80+
console.warn(
81+
`Warning: Failed to load state from ${stateFile}. Using default state. Error: ${err}`,
82+
)
83+
}
4184
return toRuntimeState(DEFAULT_STATE)
4285
}
4386
}
4487

4588
/**
46-
* Save state to file
89+
* Save state to file.
90+
* @param stateFile - Path to the state JSON file
91+
* @param state - RuntimeState to persist
4792
*/
4893
export async function saveState(stateFile: string, state: RuntimeState): Promise<void> {
4994
// Only persist the State fields, not RuntimeState extras
@@ -53,6 +98,8 @@ export async function saveState(stateFile: string, state: RuntimeState): Promise
5398
taskIndex: state.taskIndex,
5499
sessionId: state.sessionId,
55100
lastUpdate: getISOTimestamp(),
101+
currentIdeaPath: state.currentIdeaPath,
102+
currentIdeaFilename: state.currentIdeaFilename,
56103
}
57104

58105
const content = JSON.stringify(persistedState, null, 2)
@@ -83,7 +130,8 @@ function validatePhase(phase: unknown): Phase | null {
83130
}
84131

85132
/**
86-
* Reset state to initial values for a new run
133+
* Reset state to initial values for a new run.
134+
* @returns Fresh RuntimeState with cycle 1 and init phase
87135
*/
88136
export function resetState(): RuntimeState {
89137
return toRuntimeState({
@@ -93,7 +141,9 @@ export function resetState(): RuntimeState {
93141
}
94142

95143
/**
96-
* Create a fresh state for starting a new cycle
144+
* Create a fresh state for starting a new cycle.
145+
* @param currentCycle - The current cycle number
146+
* @returns Partial RuntimeState with incremented cycle and reset task fields
97147
*/
98148
export function newCycleState(currentCycle: number): Partial<RuntimeState> {
99149
return {
@@ -104,5 +154,7 @@ export function newCycleState(currentCycle: number): Partial<RuntimeState> {
104154
totalTasks: 0,
105155
currentTaskNum: 0,
106156
currentTaskDesc: "",
157+
currentIdeaPath: undefined,
158+
currentIdeaFilename: undefined,
107159
}
108160
}

tests/state.test.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
* Tests for state module
33
*/
44

5-
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
6-
import { mkdirSync, rmSync, existsSync } from "node:fs"
5+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
6+
import { existsSync, mkdirSync, rmSync } from "node:fs"
77
import { join } from "node:path"
8-
import { loadState, saveState, resetState, newCycleState } from "../src/state.ts"
8+
import { loadState, newCycleState, resetState, saveState } from "../src/state.ts"
99

1010
const TEST_DIR = "/tmp/opencoder-test-state"
1111

@@ -71,6 +71,36 @@ describe("state", () => {
7171
expect(state.phase).toBe("init") // default
7272
expect(state.taskIndex).toBe(0) // default
7373
})
74+
75+
test("handles invalid cycle value with default", async () => {
76+
const stateFile = join(TEST_DIR, "invalid-cycle.json")
77+
await Bun.write(stateFile, JSON.stringify({ cycle: -5, phase: "build" }))
78+
79+
const state = await loadState(stateFile)
80+
81+
expect(state.cycle).toBe(1) // default due to invalid negative value
82+
expect(state.phase).toBe("build") // valid phase should be kept
83+
})
84+
85+
test("handles invalid phase value with default", async () => {
86+
const stateFile = join(TEST_DIR, "invalid-phase.json")
87+
await Bun.write(stateFile, JSON.stringify({ cycle: 3, phase: "invalid_phase" }))
88+
89+
const state = await loadState(stateFile)
90+
91+
expect(state.cycle).toBe(3) // valid cycle should be kept
92+
expect(state.phase).toBe("init") // default due to invalid phase
93+
})
94+
95+
test("handles invalid taskIndex value with default", async () => {
96+
const stateFile = join(TEST_DIR, "invalid-taskindex.json")
97+
await Bun.write(stateFile, JSON.stringify({ cycle: 2, taskIndex: -1 }))
98+
99+
const state = await loadState(stateFile)
100+
101+
expect(state.cycle).toBe(2) // valid cycle should be kept
102+
expect(state.taskIndex).toBe(0) // default due to invalid negative value
103+
})
74104
})
75105

76106
describe("saveState", () => {

0 commit comments

Comments
 (0)