Skip to content

Commit a335690

Browse files
committed
feat: add cycle timeout/watchdog mechanism
- Add cycleTimeoutMinutes config option (default: 60 minutes, 0 = disabled) - Track cycleStartTime in state for timeout calculation - Force cycle completion when timeout exceeded: - Plan phase: start new cycle - Build phase: skip to evaluation - Add isCycleTimedOut() and getCycleElapsedTime() helpers - Support via config file, env var OPENCODER_CYCLE_TIMEOUT_MINUTES - Add comprehensive tests for timeout functionality - Update documentation with new config option Signed-off-by: leocavalcante <[email protected]>
1 parent b8dc79a commit a335690

File tree

7 files changed

+230
-2
lines changed

7 files changed

+230
-2
lines changed

AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ OPENCODER_TASK_PAUSE_SECONDS=2
270270
OPENCODER_AUTO_COMMIT=true
271271
OPENCODER_AUTO_PUSH=true
272272
OPENCODER_COMMIT_SIGNOFF=false
273+
OPENCODER_CYCLE_TIMEOUT_MINUTES=60
273274
```
274275

275276
### Config File (.opencode/opencoder/config.json)
@@ -283,7 +284,8 @@ OPENCODER_COMMIT_SIGNOFF=false
283284
"taskPauseSeconds": 2,
284285
"autoCommit": true,
285286
"autoPush": true,
286-
"commitSignoff": false
287+
"commitSignoff": false,
288+
"cycleTimeoutMinutes": 60
287289
}
288290
```
289291

src/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const DEFAULTS: Omit<Config, "planModel" | "buildModel" | "projectDir"> = {
2323
autoCommit: true,
2424
autoPush: true,
2525
commitSignoff: false,
26+
cycleTimeoutMinutes: 60,
2627
}
2728

2829
/** Environment variable prefix */
@@ -111,6 +112,7 @@ async function loadConfigFile(projectDir: string): Promise<Partial<Config>> {
111112
autoCommit: parsed.autoCommit,
112113
autoPush: parsed.autoPush,
113114
commitSignoff: parsed.commitSignoff,
115+
cycleTimeoutMinutes: parsed.cycleTimeoutMinutes,
114116
})
115117
} catch (err) {
116118
console.warn(`Warning: Failed to parse config.json: ${err}`)
@@ -166,6 +168,12 @@ function loadEnvConfig(): Partial<Config> {
166168
const commitSignoff = process.env[`${ENV_PREFIX}COMMIT_SIGNOFF`]
167169
if (commitSignoff) config.commitSignoff = commitSignoff === "true" || commitSignoff === "1"
168170

171+
const cycleTimeout = process.env[`${ENV_PREFIX}CYCLE_TIMEOUT_MINUTES`]
172+
if (cycleTimeout) {
173+
const parsed = Number.parseInt(cycleTimeout, 10)
174+
if (!Number.isNaN(parsed)) config.cycleTimeoutMinutes = parsed
175+
}
176+
169177
return config
170178
}
171179

src/loop.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,38 @@ export async function runLoop(config: Config): Promise<void> {
7979
while (!shutdownRequested) {
8080
logger.setCycleLog(state.cycle)
8181

82+
// Start cycle timer if not already set
83+
if (!state.cycleStartTime) {
84+
state.cycleStartTime = getISOTimestamp()
85+
logger.logVerbose(`Cycle ${state.cycle} started at ${state.cycleStartTime}`)
86+
}
87+
88+
// Check for cycle timeout
89+
if (isCycleTimedOut(state, config)) {
90+
const elapsed = getCycleElapsedTime(state)
91+
logger.alert(
92+
`TIMEOUT: Cycle ${state.cycle} exceeded ${config.cycleTimeoutMinutes} minute limit (elapsed: ${elapsed})`,
93+
)
94+
logger.warn("Forcing cycle completion due to timeout...")
95+
96+
// Force transition to evaluation to complete the cycle
97+
if (state.phase === "plan") {
98+
// Can't complete without a plan, start new cycle
99+
logger.warn("Timeout during planning, starting new cycle...")
100+
state.cycle++
101+
state.phase = "plan"
102+
state.cycleStartTime = undefined
103+
state.currentIdeaPath = undefined
104+
state.currentIdeaFilename = undefined
105+
builder.clearSession()
106+
} else if (state.phase === "build") {
107+
// Skip remaining tasks and move to evaluation
108+
logger.warn("Timeout during build, skipping to evaluation...")
109+
state.phase = "evaluation"
110+
}
111+
// If already in evaluation, it will complete normally
112+
}
113+
82114
try {
83115
switch (state.phase) {
84116
case "init":
@@ -466,6 +498,7 @@ async function runEvaluationPhase(
466498
state.currentTaskDesc = ""
467499
state.currentIdeaPath = undefined
468500
state.currentIdeaFilename = undefined
501+
state.cycleStartTime = undefined // Reset for new cycle
469502

470503
// Clear session for new cycle
471504
builder.clearSession()
@@ -572,6 +605,50 @@ function escapeRegExp(str: string): string {
572605
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
573606
}
574607

608+
/**
609+
* Check if the current cycle has exceeded the timeout.
610+
* @param state - Runtime state with cycle start time
611+
* @param config - Configuration with timeout setting
612+
* @returns True if cycle has timed out, false otherwise
613+
*/
614+
export function isCycleTimedOut(state: RuntimeState, config: Config): boolean {
615+
if (config.cycleTimeoutMinutes <= 0) {
616+
return false // Timeout disabled
617+
}
618+
619+
if (!state.cycleStartTime) {
620+
return false // No start time recorded
621+
}
622+
623+
const startTime = new Date(state.cycleStartTime).getTime()
624+
const elapsed = Date.now() - startTime
625+
const timeoutMs = config.cycleTimeoutMinutes * 60 * 1000
626+
627+
return elapsed >= timeoutMs
628+
}
629+
630+
/**
631+
* Get elapsed time in the current cycle as a human-readable string.
632+
* @param state - Runtime state with cycle start time
633+
* @returns Formatted elapsed time (e.g., "45m 30s") or empty string if no start time
634+
*/
635+
export function getCycleElapsedTime(state: RuntimeState): string {
636+
if (!state.cycleStartTime) {
637+
return ""
638+
}
639+
640+
const startTime = new Date(state.cycleStartTime).getTime()
641+
const elapsed = Date.now() - startTime
642+
643+
const minutes = Math.floor(elapsed / 60000)
644+
const seconds = Math.floor((elapsed % 60000) / 1000)
645+
646+
if (minutes > 0) {
647+
return `${minutes}m ${seconds}s`
648+
}
649+
return `${seconds}s`
650+
}
651+
575652
/**
576653
* Check if shutdown has been requested.
577654
* Exported for testing and external shutdown checks.

src/state.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const DEFAULT_STATE: State = {
1515
currentIdeaFilename: undefined,
1616
retryCount: 0,
1717
lastErrorTime: undefined,
18+
cycleStartTime: undefined,
1819
}
1920

2021
/**
@@ -78,6 +79,7 @@ export async function loadState(stateFile: string): Promise<RuntimeState> {
7879
currentIdeaFilename: parsed.currentIdeaFilename,
7980
retryCount: parsed.retryCount ?? DEFAULT_STATE.retryCount,
8081
lastErrorTime: parsed.lastErrorTime,
82+
cycleStartTime: parsed.cycleStartTime,
8183
}
8284

8385
return toRuntimeState(state)
@@ -114,6 +116,7 @@ export async function saveState(stateFile: string, state: RuntimeState): Promise
114116
currentIdeaFilename: state.currentIdeaFilename,
115117
retryCount: state.retryCount,
116118
lastErrorTime: state.lastErrorTime,
119+
cycleStartTime: state.cycleStartTime,
117120
}
118121

119122
const content = JSON.stringify(persistedState, null, 2)
@@ -172,5 +175,6 @@ export function newCycleState(currentCycle: number): Partial<RuntimeState> {
172175
currentIdeaFilename: undefined,
173176
retryCount: 0,
174177
lastErrorTime: undefined,
178+
cycleStartTime: undefined,
175179
}
176180
}

src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export interface Config {
3131
autoPush: boolean
3232
/** Add signoff flag (-s) to commits */
3333
commitSignoff: boolean
34+
/** Maximum minutes per cycle before timeout (0 = no limit) */
35+
cycleTimeoutMinutes: number
3436
}
3537

3638
/** Persisted state */
@@ -53,6 +55,8 @@ export interface State {
5355
retryCount: number
5456
/** ISO timestamp of last error (for backoff calculation) */
5557
lastErrorTime?: string
58+
/** ISO timestamp when current cycle started */
59+
cycleStartTime?: string
5660
}
5761

5862
/** Runtime state with additional non-persisted fields */
@@ -137,6 +141,7 @@ export interface ConfigFile {
137141
autoCommit?: boolean
138142
autoPush?: boolean
139143
commitSignoff?: boolean
144+
cycleTimeoutMinutes?: number
140145
}
141146

142147
/** CLI options from argument parsing */

tests/loop.test.ts

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ import { Logger } from "../src/logger.ts"
99
import {
1010
archivePlan,
1111
calculateBackoff,
12+
getCycleElapsedTime,
13+
isCycleTimedOut,
1214
isShutdownRequested,
1315
logStartupInfo,
1416
requestShutdown,
1517
resetShutdownFlags,
1618
sleep,
1719
} from "../src/loop.ts"
18-
import type { Config, Paths } from "../src/types.ts"
20+
import type { Config, Paths, RuntimeState } from "../src/types.ts"
1921

2022
const TEST_DIR = "/tmp/opencoder-test-loop"
2123

@@ -30,6 +32,7 @@ function createTestPaths(): Paths {
3032
alertsFile: join(TEST_DIR, "alerts.log"),
3133
historyDir: join(TEST_DIR, "history"),
3234
ideasDir: join(TEST_DIR, "ideas"),
35+
ideasHistoryDir: join(TEST_DIR, "ideas", "history"),
3336
configFile: join(TEST_DIR, "config.json"),
3437
}
3538
}
@@ -48,6 +51,22 @@ function createTestConfig(overrides?: Partial<Config>): Config {
4851
autoCommit: true,
4952
autoPush: true,
5053
commitSignoff: false,
54+
cycleTimeoutMinutes: 60,
55+
...overrides,
56+
}
57+
}
58+
59+
/** Create a test RuntimeState object */
60+
function createTestState(overrides?: Partial<RuntimeState>): RuntimeState {
61+
return {
62+
cycle: 1,
63+
phase: "plan",
64+
taskIndex: 0,
65+
lastUpdate: new Date().toISOString(),
66+
retryCount: 0,
67+
totalTasks: 0,
68+
currentTaskNum: 0,
69+
currentTaskDesc: "",
5170
...overrides,
5271
}
5372
}
@@ -458,4 +477,93 @@ Some notes with Unicode: 日本語 🎉`
458477
expect(archivedContent).toBe(planContent)
459478
})
460479
})
480+
481+
describe("isCycleTimedOut", () => {
482+
test("returns false when timeout is disabled (0)", () => {
483+
const state = createTestState({
484+
cycleStartTime: new Date(Date.now() - 120 * 60 * 1000).toISOString(), // 2 hours ago
485+
})
486+
const config = createTestConfig({ cycleTimeoutMinutes: 0 })
487+
488+
expect(isCycleTimedOut(state, config)).toBe(false)
489+
})
490+
491+
test("returns false when no start time is set", () => {
492+
const state = createTestState({ cycleStartTime: undefined })
493+
const config = createTestConfig({ cycleTimeoutMinutes: 60 })
494+
495+
expect(isCycleTimedOut(state, config)).toBe(false)
496+
})
497+
498+
test("returns false when within timeout", () => {
499+
const state = createTestState({
500+
cycleStartTime: new Date(Date.now() - 30 * 60 * 1000).toISOString(), // 30 minutes ago
501+
})
502+
const config = createTestConfig({ cycleTimeoutMinutes: 60 })
503+
504+
expect(isCycleTimedOut(state, config)).toBe(false)
505+
})
506+
507+
test("returns true when timeout exceeded", () => {
508+
const state = createTestState({
509+
cycleStartTime: new Date(Date.now() - 65 * 60 * 1000).toISOString(), // 65 minutes ago
510+
})
511+
const config = createTestConfig({ cycleTimeoutMinutes: 60 })
512+
513+
expect(isCycleTimedOut(state, config)).toBe(true)
514+
})
515+
516+
test("returns true exactly at timeout", () => {
517+
const state = createTestState({
518+
cycleStartTime: new Date(Date.now() - 60 * 60 * 1000).toISOString(), // Exactly 60 minutes ago
519+
})
520+
const config = createTestConfig({ cycleTimeoutMinutes: 60 })
521+
522+
expect(isCycleTimedOut(state, config)).toBe(true)
523+
})
524+
525+
test("works with short timeout values", () => {
526+
const state = createTestState({
527+
cycleStartTime: new Date(Date.now() - 2 * 60 * 1000).toISOString(), // 2 minutes ago
528+
})
529+
const config = createTestConfig({ cycleTimeoutMinutes: 1 })
530+
531+
expect(isCycleTimedOut(state, config)).toBe(true)
532+
})
533+
})
534+
535+
describe("getCycleElapsedTime", () => {
536+
test("returns empty string when no start time", () => {
537+
const state = createTestState({ cycleStartTime: undefined })
538+
539+
expect(getCycleElapsedTime(state)).toBe("")
540+
})
541+
542+
test("returns seconds only for short durations", () => {
543+
const state = createTestState({
544+
cycleStartTime: new Date(Date.now() - 30 * 1000).toISOString(), // 30 seconds ago
545+
})
546+
547+
const elapsed = getCycleElapsedTime(state)
548+
expect(elapsed).toMatch(/^\d+s$/)
549+
})
550+
551+
test("returns minutes and seconds for longer durations", () => {
552+
const state = createTestState({
553+
cycleStartTime: new Date(Date.now() - (5 * 60 + 30) * 1000).toISOString(), // 5m 30s ago
554+
})
555+
556+
const elapsed = getCycleElapsedTime(state)
557+
expect(elapsed).toMatch(/^\d+m \d+s$/)
558+
})
559+
560+
test("handles hours worth of minutes", () => {
561+
const state = createTestState({
562+
cycleStartTime: new Date(Date.now() - 90 * 60 * 1000).toISOString(), // 90 minutes ago
563+
})
564+
565+
const elapsed = getCycleElapsedTime(state)
566+
expect(elapsed).toContain("90m")
567+
})
568+
})
461569
})

tests/state.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ describe("state", () => {
114114
currentTaskNum: 1,
115115
currentTaskDesc: "Test task",
116116
lastUpdate: "",
117+
retryCount: 0,
117118
}
118119

119120
await saveState(stateFile, state)
@@ -126,6 +127,29 @@ describe("state", () => {
126127
expect(saved.taskIndex).toBe(1)
127128
expect(saved.lastUpdate).toBeTruthy()
128129
})
130+
131+
test("saves cycleStartTime when present", async () => {
132+
const stateFile = join(TEST_DIR, "save-cycle-time.json")
133+
const startTime = new Date().toISOString()
134+
const state = {
135+
cycle: 1,
136+
phase: "build" as const,
137+
taskIndex: 0,
138+
totalTasks: 3,
139+
currentTaskNum: 1,
140+
currentTaskDesc: "Task",
141+
lastUpdate: "",
142+
retryCount: 0,
143+
cycleStartTime: startTime,
144+
}
145+
146+
await saveState(stateFile, state)
147+
148+
const content = await Bun.file(stateFile).text()
149+
const saved = JSON.parse(content)
150+
151+
expect(saved.cycleStartTime).toBe(startTime)
152+
})
129153
})
130154

131155
describe("resetState", () => {

0 commit comments

Comments
 (0)