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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ React client (src/client)
- Provider adapters normalize three different wire protocols into
`HarnessEvent`s (`harness-types.ts`). Claude runs through the Agent SDK in
`agent.ts` directly; codex/cursor/pi produce `HarnessTurn`s.
- Shutdown cancels every in-flight turn and marks its chat `resumePending`
(`agent.interruptForShutdown`); the next boot restarts those turns with a
wire-only "carry on" prompt (`resume-turns.ts`). A user-initiated cancel
never sets the marker, and the marker is cleared before the attempt, so one
shutdown earns one resume.
- Transcripts are append-only JSONL per chat (`transcripts/<chatId>.jsonl`)
with a small LRU cache in the EventStore. `debugRaw` (raw provider JSON) is
stamped only on `system_init` — the one entry with a raw JSON view. Tool
Expand Down
138 changes: 138 additions & 0 deletions src/server/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
normalizeClaudeContextUsage,
normalizeClaudeStreamMessage,
normalizeClaudeUsageSnapshot,
RESUME_AFTER_RESTART_MESSAGE,
} from "./agent"
import type { HarnessTurn } from "./harness-types"
import type { ChatAttachment, TranscriptEntry } from "../shared/types"
Expand Down Expand Up @@ -2373,6 +2374,132 @@ describe("session restore on lost native session", () => {
})
})

describe("AgentCoordinator restart resume", () => {
test("shutdown cancels running turns and marks their chats for resume", async () => {
const events = new AsyncEventQueue<any>()
const fakeCodexManager = {
async startSession() {},
async startTurn(): Promise<HarnessTurn> {
return {
provider: "codex",
stream: events,
interrupt: async () => {},
close: () => events.close(),
}
},
}

const store = createFakeStore()
const coordinator = new AgentCoordinator({
store: store as never,
onStateChange: () => {},
codexManager: fakeCodexManager as never,
})

await coordinator.send({
type: "chat.send",
chatId: "chat-1",
provider: "codex",
content: "long running task",
})
await waitFor(() => coordinator.activeTurns.has("chat-1"))

await coordinator.interruptForShutdown()

expect(store.chat.resumePending).toBe(true)
expect(coordinator.activeTurns.size).toBe(0)
// The turn is still cancelled like any other, so a chat that never gets
// resumed reads exactly as it does today.
expect(store.messages.some((entry) => entry.kind === "interrupted")).toBe(true)
})

test("a user-initiated cancel leaves no resume marker", async () => {
const events = new AsyncEventQueue<any>()
const fakeCodexManager = {
async startSession() {},
async startTurn(): Promise<HarnessTurn> {
return {
provider: "codex",
stream: events,
interrupt: async () => {},
close: () => events.close(),
}
},
}

const store = createFakeStore()
const coordinator = new AgentCoordinator({
store: store as never,
onStateChange: () => {},
codexManager: fakeCodexManager as never,
})

await coordinator.send({
type: "chat.send",
chatId: "chat-1",
provider: "codex",
content: "long running task",
})
await waitFor(() => coordinator.activeTurns.has("chat-1"))

await coordinator.cancel("chat-1")

expect(store.chat.resumePending).toBeUndefined()
})

test("resuming an interrupted turn sends a wire-only continuation, not a user prompt", async () => {
const events = new AsyncEventQueue<any>()
const prompts: string[] = []
const store = createFakeStore()
store.chat.provider = "claude"
store.chat.sessionToken = "session-1"
store.chat.resumePending = true
store.chat.lastModel = "opus"

const coordinator = new AgentCoordinator({
store: store as never,
onStateChange: () => {},
checkSessionArtifact: () => "present" as SessionArtifactStatus,
startClaudeSession: async () => ({
provider: "claude",
stream: events,
getAccountInfo: async () => null,
interrupt: async () => {},
close: () => {},
setModel: async () => {},
setPermissionMode: async () => {},
sendPrompt: async (content: string) => {
prompts.push(content)
},
}),
})

expect(await coordinator.resumeInterruptedTurn("chat-1")).toBe(true)

expect(prompts).toEqual([RESUME_AFTER_RESTART_MESSAGE])
// Nobody typed anything, so nothing lands in the transcript as if they had.
expect(store.messages.some((entry) => entry.kind === "user_prompt")).toBe(false)
expect(coordinator.activeTurns.has("chat-1")).toBe(true)
})

test("does not resume a chat with no session to resume into", async () => {
const store = createFakeStore()
store.chat.provider = "claude"
store.chat.resumePending = true

const coordinator = new AgentCoordinator({
store: store as never,
onStateChange: () => {},
startClaudeSession: async () => {
throw new Error("Should not start a session")
},
})

expect(await coordinator.resumeInterruptedTurn("chat-1")).toBe(false)
expect(store.messages).toEqual([])
})
})

function createFakeChat(id: string, projectId: string, title = "New Chat") {
return {
id,
Expand All @@ -2383,6 +2510,9 @@ function createFakeChat(id: string, projectId: string, title = "New Chat") {
autoPlan: false,
sessionToken: null as string | null,
pendingForkSessionToken: null as string | null,
resumePending: undefined as boolean | undefined,
lastModel: undefined as string | undefined,
deletedAt: undefined as number | undefined,
}
}

Expand Down Expand Up @@ -2441,6 +2571,14 @@ function createFakeStore(options?: {
throw new Error("Did not expect turn failure")
},
async recordTurnCancelled() {},
async setTurnResumePending(chatId: string, pending: boolean) {
const target = requireChat(chatId)
if (pending) {
target.resumePending = true
} else {
delete target.resumePending
}
},
async setSessionToken(chatId: string, sessionToken: string | null) {
requireChat(chatId).sessionToken = sessionToken
},
Expand Down
81 changes: 81 additions & 0 deletions src/server/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,18 @@ const STEERED_MESSAGE_PREFIX = `<system-message>
The user would like to inform you of something while you continue to work. Acknowledge receipt immediately with a text response, then continue with the task at hand, incorporating the user's feedback if needed.
</system-message>`

/**
* Wire-only prompt that restarts a turn Kanna killed by shutting down (see
* `AgentCoordinator.resumeInterruptedTurn`). The harness session carries the
* original prompt and the work already done, so this only has to say what
* happened and warn that the last thing it was doing may be half-finished —
* the process died mid-tool-call as often as not.
*/
export const RESUME_AFTER_RESTART_MESSAGE = `<system-message>
Kanna restarted while you were working on this, so your process was stopped mid-task and is now back up. Continue the task you were on from where it left off.
Whatever you were doing last may not have completed — re-check the state of any file you were editing and any command you had running before assuming it finished.
</system-message>`

interface SendMessageOptions {
provider?: AgentProvider
model?: string
Expand Down Expand Up @@ -1833,6 +1845,75 @@ export class AgentCoordinator {
return scanned?.path ? { name: scanned.name, path: scanned.path } : undefined
}

/**
* Cancel every in-flight turn because Kanna itself is going down, marking
* each chat so the next boot picks the work back up (`resumeInterruptedTurn`).
*
* Everything a user-initiated cancel does still happens — the harness is
* interrupted, the pending tool call is discarded, the transcript gets its
* `interrupted` entry — so a chat that never gets resumed reads exactly as it
* does today. The marker is written first: a shutdown that dies partway
* through leaves a chat resumable-but-not-cancelled, which the resume pass
* handles, rather than cancelled-but-forgotten, which it can't.
*/
async interruptForShutdown() {
for (const chatId of [...this.activeTurns.keys()]) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Shutdown misses replacement turns

When a client sends or steers a message after shutdown snapshots activeTurns but before the live router is disposed, the replacement turn falls outside the captured IDs and never receives a resumePending marker, causing its in-progress work to be lost when the server stops.

Knowledge Base Used: Agent execution and provider integration

Fix in Codex

try {
await this.store.setTurnResumePending(chatId, true)
} catch {
// Best effort — a chat we can't mark still gets cancelled cleanly.
}
await this.cancel(chatId)
}
}

/**
* Restart a turn that the previous process cut short by shutting down.
*
* The prompt is wire-only (`appendUserPrompt: false`), so the transcript
* shows the interrupted turn picking back up rather than a user message
* nobody typed. Resuming leans on the harness session having survived: it
* holds the original prompt and everything the turn did before it died, so
* "carry on" is all that has to be said. When the session is gone,
* `startTurnForChat`'s own recovery notices and rebuilds the context from our
* transcript first (`prepareSessionRestore`), which is exactly what's wanted.
*
* Returns whether a turn was actually started.
*/
async resumeInterruptedTurn(chatId: string) {
const chat = this.store.getChat(chatId)
if (!chat || chat.deletedAt) return false
if (!chat.provider) return false
if (this.activeTurns.has(chatId)) return false
// No session to resume into means the harness never got far enough to have
// context worth continuing; a bare "carry on" would be sent into an empty
// session, so leave the chat interrupted instead.
if (!chat.sessionToken && !chat.pendingForkSessionToken) return false

// Everything the chat record remembers about how the turn was running:
// the model it actually ran with plus the two persisted modes. Reasoning
// effort and fast mode are picked in the composer and never stored server
// side, so the resumed turn falls back to the provider defaults for those.
const settings = this.getProviderSettings(chat.provider, {
model: chat.lastModel,
planMode: chat.planMode,
autoPlan: chat.autoPlan,
})
await this.startTurnForChat({
chatId,
provider: chat.provider,
content: RESUME_AFTER_RESTART_MESSAGE,
attachments: [],
model: settings.model,
effort: settings.effort,
serviceTier: settings.serviceTier,
planMode: settings.planMode,
autoPlan: settings.autoPlan,
appendUserPrompt: false,
})
return true
}

async forkChat(chatId: string) {
const chat = this.store.requireChat(chatId)
if (this.activeTurns.has(chatId) || this.drainingStreams.has(chatId)) {
Expand Down
29 changes: 29 additions & 0 deletions src/server/event-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,35 @@ describe("EventStore", () => {
expect(store.getMessages(forked.id)).toEqual(store.getMessages(source.id))
})

test("the resume marker survives a restart and a compaction", async () => {
const dataDir = await createTempDataDir()
const store = new EventStore(dataDir)
await store.initialize()

const project = await store.openProject("/tmp/project")
const chat = await store.createChat(project.id)
await store.recordTurnStarted(chat.id)
await store.setTurnResumePending(chat.id, true)
// Shutdown cancels the turn like any other cancel; the marker is what tells
// the next boot the difference.
await store.recordTurnCancelled(chat.id)
expect(store.requireChat(chat.id).resumePending).toBe(true)
expect(store.requireChat(chat.id).lastTurnOutcome).toBe("cancelled")

const reloaded = new EventStore(dataDir)
await reloaded.initialize()
expect(reloaded.requireChat(chat.id).resumePending).toBe(true)

// Cleared by the boot that acts on it, and the clear sticks the same way.
await reloaded.setTurnResumePending(chat.id, false)
await reloaded.compact()
expect(reloaded.requireChat(chat.id).resumePending).toBeUndefined()

const afterCompaction = new EventStore(dataDir)
await afterCompaction.initialize()
expect(afterCompaction.requireChat(chat.id).resumePending).toBeUndefined()
})

test("lastAgentMessageAt tracks agent entries mid-turn, ignoring user prompts", async () => {
const dataDir = await createTempDataDir()
const store = new EventStore(dataDir)
Expand Down
30 changes: 30 additions & 0 deletions src/server/event-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ function getReplayEventPriority(event: StoreEvent) {
case "pending_fork_session_token_set":
return 6
case "turn_cancelled":
case "turn_resume_pending_set":
return 7
case "turn_finished":
case "turn_failed":
Expand Down Expand Up @@ -861,6 +862,16 @@ export class EventStore {
chat.lastTurnEndedAt = event.timestamp
break
}
case "turn_resume_pending_set": {
const chat = this.state.chatsById.get(event.chatId)
if (!chat) break
if (event.pending) {
chat.resumePending = true
} else {
delete chat.resumePending
}
break
}
case "session_token_set": {
const chat = this.state.chatsById.get(event.chatId)
if (!chat) break
Expand Down Expand Up @@ -1826,6 +1837,25 @@ export class EventStore {
this.onTurnEnded?.(chatId)
}

/**
* Flag (or clear) a chat whose turn Kanna cut short by shutting down, so the
* next process can pick it back up. Deliberately does not touch `updatedAt`:
* it's bookkeeping about the process, not activity in the chat, and bumping
* it would shuffle the sidebar on every boot.
*/
async setTurnResumePending(chatId: string, pending: boolean) {
const chat = this.requireChat(chatId)
if (Boolean(chat.resumePending) === pending) return
const event: TurnEvent = {
v: STORE_VERSION,
type: "turn_resume_pending_set",
timestamp: Date.now(),
chatId,
pending,
}
await this.append(this.turnsLogPath, event)
}

async setSessionToken(chatId: string, sessionToken: string | null) {
const chat = this.requireChat(chatId)
if (chat.sessionToken === sessionToken) return
Expand Down
19 changes: 19 additions & 0 deletions src/server/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,18 @@ export interface ChatRecord {
*/
lastAgentMessagePreviewAt?: number
lastTurnOutcome: "success" | "failed" | "cancelled" | null
/**
* Set when a turn was cut short because Kanna itself went down, and cleared
* by the next boot's resume pass. A turn the *user* stopped never gets this,
* which is the whole point: it's how the next process tells "this chat was
* mid-task when we exited" from "this chat was stopped on purpose".
*
* Persisted rather than derived from `lastTurnStartedAt > lastTurnEndedAt`:
* shutdown cancels the turn like any other cancel, so the timestamps alone
* can't distinguish the two, and inferring it would make every chat killed
* by an old `kill -9` resume out of nowhere on upgrade.
*/
resumePending?: boolean
/**
* Files this chat has changed, unioned across its turns and measured by
* diffing worktree snapshots at each turn boundary (see `TurnFileTracker`).
Expand Down Expand Up @@ -356,6 +368,13 @@ export type TurnEvent =
timestamp: number
chatId: string
}
| {
v: 2
type: "turn_resume_pending_set"
timestamp: number
chatId: string
pending: boolean
}
| {
v: 2
type: "session_token_set"
Expand Down
Loading