Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions src/cli/run/poll-for-completion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(() => {})
Expand Down Expand Up @@ -110,6 +135,7 @@ describe("pollForCompletion", () => {
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
minStabilizationMs: 0,
})
const elapsedMs = Date.now() - startMs

Expand Down
19 changes: 19 additions & 0 deletions src/cli/run/poll-for-completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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))
Expand Down Expand Up @@ -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++
Expand Down