Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/cozy-shirts-try.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

fix session restore race that triggered premature exit
7 changes: 7 additions & 0 deletions cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,8 +278,15 @@ export class CLI {
logs.debug("SessionManager workspace directory set", "CLI", { workspace })

if (this.options.session) {
// Set flag BEFORE restoring session to prevent race condition
// The session restoration triggers async state updates that may contain
// historical completion_result messages. Without this flag set first,
// the CI exit logic may trigger before the prompt can execute.
this.store.set(taskResumedViaContinueOrSessionAtom, true)
await this.sessionService?.restoreSession(this.options.session)
} else if (this.options.fork) {
// Set flag BEFORE forking session (same race condition as restore)
this.store.set(taskResumedViaContinueOrSessionAtom, true)
logs.info("Forking session from share ID", "CLI", { shareId: this.options.fork })
await this.sessionService?.forkSession(this.options.fork)
}
Expand Down
71 changes: 71 additions & 0 deletions cli/src/state/atoms/__tests__/effects-ci-completion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Tests for CI completion detection in effects.ts
*/

import { describe, it, expect, beforeEach, vi } from "vitest"
import { createStore } from "jotai"
import { messageHandlerEffectAtom } from "../effects.js"
import { extensionServiceAtom } from "../service.js"
import { ciCompletionDetectedAtom, ciCompletionIgnoreBeforeTimestampAtom } from "../ci.js"
import { taskResumedViaContinueOrSessionAtom } from "../extension.js"
import type { ExtensionMessage, ExtensionChatMessage } from "../../../types/messages.js"
import type { ExtensionService } from "../../../services/extension.js"

describe("CI completion detection in effects", () => {
let store: ReturnType<typeof createStore>

beforeEach(() => {
store = createStore()

const mockService: Partial<ExtensionService> = {
initialize: vi.fn(),
dispose: vi.fn(),
on: vi.fn(),
off: vi.fn(),
}
store.set(extensionServiceAtom, mockService as ExtensionService)
})

it("skips completion detection when session was resumed", () => {
const completionMessage: ExtensionChatMessage = {
ts: Date.now(),
type: "ask",
ask: "completion_result",
text: "Task completed",
}

const stateMessage: ExtensionMessage = {
type: "state",
state: {
chatMessages: [completionMessage],
} as ExtensionMessage["state"],
}

store.set(taskResumedViaContinueOrSessionAtom, true)
store.set(messageHandlerEffectAtom, stateMessage)

expect(store.get(ciCompletionDetectedAtom)).toBe(false)
})

it("uses the ignore timestamp to skip historical completion_result", () => {
const historicalTs = Date.now()
const completionMessage: ExtensionChatMessage = {
ts: historicalTs,
type: "ask",
ask: "completion_result",
text: "Task completed",
}

const stateMessage: ExtensionMessage = {
type: "state",
state: {
chatMessages: [completionMessage],
} as ExtensionMessage["state"],
}

store.set(ciCompletionIgnoreBeforeTimestampAtom, historicalTs)
store.set(messageHandlerEffectAtom, stateMessage)

expect(store.get(ciCompletionDetectedAtom)).toBe(false)
})
})
6 changes: 6 additions & 0 deletions cli/src/state/atoms/ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ export const ciTimeoutAtom = atom<number | undefined>(undefined)
*/
export const ciCompletionDetectedAtom = atom<boolean>(false)

/**
* Ignore completion_result messages at or before this timestamp
*/
export const ciCompletionIgnoreBeforeTimestampAtom = atom<number>(0)

/**
* Atom to track if command/message execution has finished
*/
Expand All @@ -45,6 +50,7 @@ export const resetCIStateAtom = atom(null, (get, set) => {
set(ciModeAtom, false)
set(ciTimeoutAtom, undefined)
set(ciCompletionDetectedAtom, false)
set(ciCompletionIgnoreBeforeTimestampAtom, 0)
set(ciCommandFinishedAtom, false)
set(ciExitReasonAtom, null)
})
20 changes: 16 additions & 4 deletions cli/src/state/atoms/effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import {
updateRouterModelsAtom,
chatMessagesAtom,
updateChatMessagesAtom,
taskResumedViaContinueOrSessionAtom,
} from "./extension.js"
import { ciCompletionDetectedAtom } from "./ci.js"
import { ciCompletionDetectedAtom, ciCompletionIgnoreBeforeTimestampAtom } from "./ci.js"
import {
updateProfileDataAtom,
updateBalanceDataAtom,
Expand Down Expand Up @@ -611,11 +612,22 @@ export const messageHandlerEffectAtom = atom(null, (get, set, message: Extension
if (message.state?.chatMessages) {
const lastMessage = message.state.chatMessages[message.state.chatMessages.length - 1]
if (lastMessage?.type === "ask" && lastMessage?.ask === "completion_result") {
logs.info("Completion result detected in state update", "effects")
const completionIgnoreBeforeTimestamp = get(ciCompletionIgnoreBeforeTimestampAtom)
const taskResumedViaSession = get(taskResumedViaContinueOrSessionAtom)
const isHistoricalCompletion =
lastMessage.ts !== undefined && lastMessage.ts <= completionIgnoreBeforeTimestamp

// Skip completion detection if session was just restored via --session or --continue
// The historical completion_result from the previous task should not trigger CI exit
if (taskResumedViaSession || isHistoricalCompletion) {
logs.debug("Skipping completion_result detection - historical completion_result", "effects")
} else {
logs.info("Completion result detected in state update", "effects")

set(ciCompletionDetectedAtom, true)
set(ciCompletionDetectedAtom, true)

SessionManager.init()?.doSync(true)
SessionManager.init()?.doSync(true)
}
}
}
} catch (error) {
Expand Down
126 changes: 126 additions & 0 deletions cli/src/state/hooks/__tests__/useCIMode.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* Tests for useCIMode hook behavior
*/

import React from "react"
import { describe, it, expect, beforeEach, vi } from "vitest"
import { createStore } from "jotai"
import { Provider } from "jotai"
import { render } from "ink-testing-library"
import { useCIMode } from "../useCIMode.js"
import { chatMessagesAtom, taskResumedViaContinueOrSessionAtom } from "../../atoms/extension.js"
import { ciCompletionDetectedAtom, ciCompletionIgnoreBeforeTimestampAtom, ciExitReasonAtom } from "../../atoms/ci.js"
import type { ExtensionChatMessage } from "../../../types/messages.js"

vi.mock("../../../services/logs.js", () => ({
logs: {
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}))

const noop = () => {}

const TestComponent = ({ enabled }: { enabled: boolean }) => {
useCIMode({ enabled, onExit: noop })
return null
}

describe("useCIMode", () => {
let store: ReturnType<typeof createStore>

beforeEach(() => {
store = createStore()
})

it("skips historical completion_result after session resume", async () => {
const completionMessage: ExtensionChatMessage = {
ts: Date.now(),
type: "ask",
ask: "completion_result",
text: "Completed",
}

store.set(taskResumedViaContinueOrSessionAtom, true)
store.set(ciCompletionIgnoreBeforeTimestampAtom, completionMessage.ts)
store.set(chatMessagesAtom, [completionMessage])

const { unmount } = render(
<Provider store={store}>
<TestComponent enabled={true} />
</Provider>,
)

await new Promise((resolve) => setTimeout(resolve, 0))

expect(store.get(ciExitReasonAtom)).toBeNull()

unmount()
})

it("exits on completion_result when not ignored", async () => {
const completionMessage: ExtensionChatMessage = {
ts: Date.now(),
type: "ask",
ask: "completion_result",
text: "Completed",
}

store.set(taskResumedViaContinueOrSessionAtom, false)
store.set(ciCompletionIgnoreBeforeTimestampAtom, 0)
store.set(chatMessagesAtom, [completionMessage])

const { unmount } = render(
<Provider store={store}>
<TestComponent enabled={true} />
</Provider>,
)

await new Promise((resolve) => setTimeout(resolve, 0))

expect(store.get(ciExitReasonAtom)).toBe("completion_result")

unmount()
})

it("exits when a new completion_result arrives after the ignore timestamp", async () => {
const historicalTs = Date.now()
const historicalMessage: ExtensionChatMessage = {
ts: historicalTs,
type: "ask",
ask: "completion_result",
text: "Completed",
}

store.set(taskResumedViaContinueOrSessionAtom, false)
store.set(ciCompletionIgnoreBeforeTimestampAtom, historicalTs)
store.set(chatMessagesAtom, [historicalMessage])

const { unmount } = render(
<Provider store={store}>
<TestComponent enabled={true} />
</Provider>,
)

await new Promise((resolve) => setTimeout(resolve, 0))
expect(store.get(ciExitReasonAtom)).toBeNull()

const newMessage: ExtensionChatMessage = {
ts: historicalTs + 1000,
type: "ask",
ask: "completion_result",
text: "Completed again",
}

store.set(chatMessagesAtom, [historicalMessage, newMessage])
await new Promise((resolve) => setTimeout(resolve, 0))
store.set(ciCompletionDetectedAtom, true)

await new Promise((resolve) => setTimeout(resolve, 0))
expect(store.get(ciExitReasonAtom)).toBe("completion_result")

unmount()
})
})
29 changes: 25 additions & 4 deletions cli/src/state/hooks/useCIMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@

import { useAtomValue, useSetAtom } from "jotai"
import { useEffect, useState, useCallback, useRef } from "react"
import { ciCompletionDetectedAtom, ciCommandFinishedAtom, ciExitReasonAtom } from "../atoms/ci.js"
import {
ciCompletionDetectedAtom,
ciCompletionIgnoreBeforeTimestampAtom,
ciCommandFinishedAtom,
ciExitReasonAtom,
} from "../atoms/ci.js"
import { taskResumedViaContinueOrSessionAtom } from "../atoms/extension.js"
import { useExtensionMessage } from "./useExtensionMessage.js"
import { logs } from "../../services/logs.js"

Expand Down Expand Up @@ -67,8 +73,9 @@ export function useCIMode(options: UseCIModeOptions): UseCIModeReturn {
const { enabled, timeout } = options

const completionDetected = useAtomValue(ciCompletionDetectedAtom)
const completionIgnoreBeforeTimestamp = useAtomValue(ciCompletionIgnoreBeforeTimestampAtom)
const commandFinished = useAtomValue(ciCommandFinishedAtom)

const taskResumedViaSession = useAtomValue(taskResumedViaContinueOrSessionAtom)
// Write atoms
const setCommandFinished = useSetAtom(ciCommandFinishedAtom)
const setExitReason = useSetAtom(ciExitReasonAtom)
Expand All @@ -86,10 +93,18 @@ export function useCIMode(options: UseCIModeOptions): UseCIModeReturn {
// Get extension messages to monitor for completion_result
const { lastMessage } = useExtensionMessage()

const isHistoricalCompletion = lastMessage?.ts !== undefined && lastMessage.ts <= completionIgnoreBeforeTimestamp

// Monitor for completion_result messages
useEffect(() => {
if (!enabled || !lastMessage || exitTriggeredRef.current) return

// Skip if session was just restored - the completion_result is historical
if (taskResumedViaSession || isHistoricalCompletion) {
logs.debug("CI mode: Skipping completion_result check - historical message", "useCIMode")
return
}

// Check if this is a completion_result message
if (lastMessage.type === "ask" && lastMessage.ask === "completion_result") {
logs.info("CI mode: completion_result message received", "useCIMode")
Expand All @@ -98,7 +113,7 @@ export function useCIMode(options: UseCIModeOptions): UseCIModeReturn {
setShouldExit(true)
exitTriggeredRef.current = true
}
}, [enabled, lastMessage])
}, [enabled, lastMessage, taskResumedViaSession, isHistoricalCompletion])

// Monitor for command finished
useEffect(() => {
Expand All @@ -115,12 +130,18 @@ export function useCIMode(options: UseCIModeOptions): UseCIModeReturn {
useEffect(() => {
if (!enabled || !completionDetected || exitTriggeredRef.current) return

// Skip if session was just restored - the completion_result is historical
if (taskResumedViaSession || isHistoricalCompletion) {
logs.debug("CI mode: Skipping completion detected atom - historical message", "useCIMode")
return
}

logs.info("CI mode: completion detected via atom", "useCIMode")
setLocalExitReason("completion_result")
setExitReason("completion_result")
setShouldExit(true)
exitTriggeredRef.current = true
}, [enabled, completionDetected])
}, [enabled, completionDetected, taskResumedViaSession, isHistoricalCompletion])

// Setup timeout if provided
useEffect(() => {
Expand Down
Loading