fix(run): add stabilization period to prevent early exit
oh-my-opencode run exits within ~1.5s of starting because pollForCompletion checks completion too early. When the agent outputs its first text but hasn't created todos yet, the empty state (0 todos, 0 children) passes all checks. Root cause timeline: 1. promptAsync fires 2. Agent outputs text (e.g. 'ULTRAWORK MODE ENABLED!') -> hasReceivedMeaningfulWork = true 3. Agent pauses before todowrite -> session briefly goes idle 4. pollForCompletion: idle=true, tool=null, work=true -> checkCompletionConditions 5. 0 todos = 'all complete', 0 children = 'all idle' -> true 6. 3 consecutive checks (1.5s) -> premature exit 0 Fix: Add a minimum stabilization period (10s) after the first meaningful work before checking completion conditions. This gives agents time to create todos and spawn child sessions. The period is configurable via PollOptions for tests. Note: todo 0 remaining 'all complete' is correct behavior — some agents don't use todos. The stabilization period is the proper fix, not changing completion semantics.
This commit is contained in:
28
bun.lock
28
bun.lock
@@ -28,13 +28,13 @@
|
||||
"typescript": "^5.7.3",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.3.1",
|
||||
"oh-my-opencode-darwin-x64": "3.3.1",
|
||||
"oh-my-opencode-linux-arm64": "3.3.1",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.3.1",
|
||||
"oh-my-opencode-linux-x64": "3.3.1",
|
||||
"oh-my-opencode-linux-x64-musl": "3.3.1",
|
||||
"oh-my-opencode-windows-x64": "3.3.1",
|
||||
"oh-my-opencode-darwin-arm64": "3.5.1",
|
||||
"oh-my-opencode-darwin-x64": "3.5.1",
|
||||
"oh-my-opencode-linux-arm64": "3.5.1",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.5.1",
|
||||
"oh-my-opencode-linux-x64": "3.5.1",
|
||||
"oh-my-opencode-linux-x64-musl": "3.5.1",
|
||||
"oh-my-opencode-windows-x64": "3.5.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -226,19 +226,19 @@
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.3.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-R+o42Km6bsIaW6D3I8uu2HCF3BjIWqa/fg38W5y4hJEOw4mL0Q7uV4R+0vtrXRHo9crXTK9ag0fqVQUm+Y6iAQ=="],
|
||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.5.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-oH+c/+Z/ULIK+8T1jQFpzISHsvQPyYJfA6bceiD9sgFy1OY1NjRh4a3sFk8cXy6uRVKpivWDFOfbVTcZ2kbKWA=="],
|
||||
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.3.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-7VTbpR1vH3OEkoJxBKtYuxFPX8M3IbJKoeHWME9iK6FpT11W1ASsjyuhvzB1jcxSeqF8ddMnjitlG5ub6h5EVw=="],
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.5.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-wnBYQ9BZBLbzgSNIJZOIJS03zf+b4trAQeYmG+yCLn8y7FWXqw1KmjJ88/bbMXTuZ4RSMKWpXb1Afgdsred+DQ=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.3.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-BZ/r/CFlvbOxkdZZrRoT16xFOjibRZHuwQnaE4f0JvOzgK6/HWp3zJI1+2/aX/oK5GA6lZxNWRrJC/SKUi8LEg=="],
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.5.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-19KNJex1LeU/S14IsJbumOvZa9O6F7X4BLIY7MfjtHtTk0dRFL+tbbXmlafecBMigEKlLdJ+HTW3TnQgp7Ih8A=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.3.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-U90Wruf21h+CJbtcrS7MeTAc/5VOF6RI+5jr7qj/cCxjXNJtjhyJdz/maehArjtgf304+lYCM/Mh1i+G2D3YFQ=="],
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.5.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-mCCnym3nBTJP+xzK+AS4YPFQiT2sZWmjhOhOy7PjNY6Is4jkfT1C2e9ZrIU/2VoVLV6V5q7hQGh1jgleU+FxwQ=="],
|
||||
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.3.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-sYzohSNdwsAhivbXcbhPdF1qqQi2CCI7FSgbmvvfBOMyZ8HAgqOFqYW2r3GPdmtywzkjOTvCzTG56FZwEjx15w=="],
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.5.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-sDYt4adNuwb+p1RzHb7IR9zvbAnYYgZofjPvceirBorffp63f+aypYFxjFpfmbT87o/Eb/Hgzm4sHliJtd1UmQ=="],
|
||||
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.3.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-aG5pZ4eWS0YSGUicOnjMkUPrIqQV4poYF+d9SIvrfvlaMcK6WlQn7jXzgNCwJsfGn5lyhSmjshZBEU+v79Ua3w=="],
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.5.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-tz/0QSS5AKIiKj6cMom5VQSnEYpMIP/SRTaP5WYNOYhnUkXMwXEncQ7FIcj2vovMCXuqA9a8ujVY0zTs7TeALw=="],
|
||||
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.3.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-FGH7cnzBqNwjSkzCDglMsVttaq+MsykAxa7ehaFK+0dnBZArvllS3W13a3dGaANHMZzfK0vz8hNDUdVi7Z63cA=="],
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.5.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-zfpRS6HIkSwE8btajJzSYxhqsE5kDkop896/XGS3LLIAAZt0RtCmT3C1plxVfI9oAABfgcaiveCxJ5f9AlKPcQ=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ describe("pollForCompletion", () => {
|
||||
const result = await pollForCompletion(ctx, eventState, abortController, {
|
||||
pollIntervalMs: 10,
|
||||
requiredConsecutive: 3,
|
||||
minStabilizationMs: 0,
|
||||
})
|
||||
|
||||
//#then - exits with 0 but only after 3 consecutive checks
|
||||
@@ -53,6 +54,30 @@ describe("pollForCompletion", () => {
|
||||
expect(todoCallCount).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it("does not check completion during stabilization period after first meaningful work", async () => {
|
||||
//#given - 0 todos, 0 children, session idle, meaningful work done, short stabilization
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
spyOn(console, "error").mockImplementation(() => {})
|
||||
const ctx = createMockContext()
|
||||
const eventState = createEventState()
|
||||
eventState.mainSessionIdle = true
|
||||
eventState.hasReceivedMeaningfulWork = true
|
||||
const abortController = new AbortController()
|
||||
|
||||
//#when - abort before stabilization period ends
|
||||
setTimeout(() => abortController.abort(), 50)
|
||||
const result = await pollForCompletion(ctx, eventState, abortController, {
|
||||
pollIntervalMs: 10,
|
||||
requiredConsecutive: 3,
|
||||
minStabilizationMs: 200,
|
||||
})
|
||||
|
||||
//#then - should be aborted (130) because stabilization hadn't elapsed yet
|
||||
expect(result).toBe(130)
|
||||
const todoCallCount = (ctx.client.session.todo as ReturnType<typeof mock>).mock.calls.length
|
||||
expect(todoCallCount).toBe(0)
|
||||
})
|
||||
|
||||
it("does not exit when currentTool is set - resets consecutive counter", async () => {
|
||||
//#given
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
@@ -110,6 +135,7 @@ describe("pollForCompletion", () => {
|
||||
const result = await pollForCompletion(ctx, eventState, abortController, {
|
||||
pollIntervalMs: 10,
|
||||
requiredConsecutive: 3,
|
||||
minStabilizationMs: 0,
|
||||
})
|
||||
const elapsedMs = Date.now() - startMs
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@ import { checkCompletionConditions } from "./completion"
|
||||
const DEFAULT_POLL_INTERVAL_MS = 500
|
||||
const DEFAULT_REQUIRED_CONSECUTIVE = 3
|
||||
const ERROR_GRACE_CYCLES = 3
|
||||
const MIN_STABILIZATION_MS = 10_000
|
||||
|
||||
export interface PollOptions {
|
||||
pollIntervalMs?: number
|
||||
requiredConsecutive?: number
|
||||
minStabilizationMs?: number
|
||||
}
|
||||
|
||||
export async function pollForCompletion(
|
||||
@@ -21,8 +23,11 @@ export async function pollForCompletion(
|
||||
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
|
||||
const requiredConsecutive =
|
||||
options.requiredConsecutive ?? DEFAULT_REQUIRED_CONSECUTIVE
|
||||
const minStabilizationMs =
|
||||
options.minStabilizationMs ?? MIN_STABILIZATION_MS
|
||||
let consecutiveCompleteChecks = 0
|
||||
let errorCycleCount = 0
|
||||
let firstWorkTimestamp: number | null = null
|
||||
|
||||
while (!abortController.signal.aborted) {
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
|
||||
@@ -61,6 +66,20 @@ export async function pollForCompletion(
|
||||
continue
|
||||
}
|
||||
|
||||
// Track when first meaningful work was received
|
||||
if (firstWorkTimestamp === null) {
|
||||
firstWorkTimestamp = Date.now()
|
||||
}
|
||||
|
||||
// Don't check completion until stabilization period has elapsed.
|
||||
// Agents need time to set up todos and spawn child sessions after
|
||||
// their first output. Without this, empty todos + no children
|
||||
// triggers a false "all complete" within ~1.5s of starting.
|
||||
if (Date.now() - firstWorkTimestamp < minStabilizationMs) {
|
||||
consecutiveCompleteChecks = 0
|
||||
continue
|
||||
}
|
||||
|
||||
const shouldExit = await checkCompletionConditions(ctx)
|
||||
if (shouldExit) {
|
||||
consecutiveCompleteChecks++
|
||||
|
||||
Reference in New Issue
Block a user