@cuongtran001/kanna 0.97.2 → 0.97.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/{arc-BeYqRlS4.js → arc-MpgmgmPa.js} +1 -1
- package/dist/client/assets/{architectureDiagram-3BPJPVTR-BTxcC3Bx.js → architectureDiagram-3BPJPVTR-BmyzKjcm.js} +1 -1
- package/dist/client/assets/{blockDiagram-GPEHLZMM-B_CLWeW3.js → blockDiagram-GPEHLZMM-DDwSQpXy.js} +1 -1
- package/dist/client/assets/{c4Diagram-AAUBKEIU-kZm6qlQm.js → c4Diagram-AAUBKEIU-DGU9MS8l.js} +1 -1
- package/dist/client/assets/channel-hDBiexM3.js +1 -0
- package/dist/client/assets/{chunk-2J33WTMH-C77Np9ft.js → chunk-2J33WTMH-CzAgupMo.js} +1 -1
- package/dist/client/assets/{chunk-4BX2VUAB-DUyKnkbT.js → chunk-4BX2VUAB-BuhpJag7.js} +1 -1
- package/dist/client/assets/{chunk-55IACEB6-HUFJLmvp.js → chunk-55IACEB6-C3whvGLO.js} +1 -1
- package/dist/client/assets/{chunk-727SXJPM-eMaVSOKi.js → chunk-727SXJPM-Czerk6m5.js} +1 -1
- package/dist/client/assets/{chunk-AQP2D5EJ-Dqr7iBWK.js → chunk-AQP2D5EJ-B_kOYwUJ.js} +1 -1
- package/dist/client/assets/{chunk-FMBD7UC4-BtAWu6Fv.js → chunk-FMBD7UC4-psmwuhCl.js} +1 -1
- package/dist/client/assets/{chunk-ND2GUHAM-CyxbGgpO.js → chunk-ND2GUHAM-tHiEXCGI.js} +1 -1
- package/dist/client/assets/{chunk-QZHKN3VN-aqVpGxUl.js → chunk-QZHKN3VN-RDSvxWqW.js} +1 -1
- package/dist/client/assets/classDiagram-4FO5ZUOK-CCsEgAfN.js +1 -0
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-CCsEgAfN.js +1 -0
- package/dist/client/assets/{cose-bilkent-S5V4N54A-GzK3z9WD.js → cose-bilkent-S5V4N54A-BSKH2P-t.js} +1 -1
- package/dist/client/assets/{dagre-BM42HDAG-Djd07Bgc.js → dagre-BM42HDAG-D0IFx6n9.js} +1 -1
- package/dist/client/assets/{diagram-2AECGRRQ-CPTC78mN.js → diagram-2AECGRRQ-fIDGRT5b.js} +1 -1
- package/dist/client/assets/{diagram-5GNKFQAL-DVTkaw4f.js → diagram-5GNKFQAL-WDW2pNsB.js} +1 -1
- package/dist/client/assets/{diagram-KO2AKTUF-10KOjvpy.js → diagram-KO2AKTUF-D4osuldj.js} +1 -1
- package/dist/client/assets/{diagram-LMA3HP47-Myxb2cAv.js → diagram-LMA3HP47-sXE6nZWg.js} +1 -1
- package/dist/client/assets/{diagram-OG6HWLK6-BuRZkgMd.js → diagram-OG6HWLK6-D0hJNJhu.js} +1 -1
- package/dist/client/assets/{erDiagram-TEJ5UH35-Be5hhZh4.js → erDiagram-TEJ5UH35-DZG5CvSu.js} +1 -1
- package/dist/client/assets/{flowDiagram-I6XJVG4X-BhiDJ4Mp.js → flowDiagram-I6XJVG4X-Bp8MtWSC.js} +1 -1
- package/dist/client/assets/{ganttDiagram-6RSMTGT7-CZ_jbXit.js → ganttDiagram-6RSMTGT7-DNZ5nlYY.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-PVQCEYII-DcliQiyM.js → gitGraphDiagram-PVQCEYII-cBXUm24V.js} +1 -1
- package/dist/client/assets/{index-BC-Tl-79.js → index-B4ajO29H.js} +1 -1
- package/dist/client/assets/{index-MixgFXRr.js → index-aHr593E5.js} +2 -2
- package/dist/client/assets/{infoDiagram-5YYISTIA-pteIHWnV.js → infoDiagram-5YYISTIA-B7EGfGYx.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-B_L5beaQ.js → ishikawaDiagram-YF4QCWOH-SQh4xs_U.js} +1 -1
- package/dist/client/assets/{journeyDiagram-JHISSGLW-hen4Bh6f.js → journeyDiagram-JHISSGLW-CbeaclIp.js} +1 -1
- package/dist/client/assets/{kanban-definition-UN3LZRKU-BtUymVff.js → kanban-definition-UN3LZRKU-COp-ikqh.js} +1 -1
- package/dist/client/assets/{linear-FUiPyB6F.js → linear-CrlbLXCR.js} +1 -1
- package/dist/client/assets/{mermaid.core-CoJEFYct.js → mermaid.core-MgV5Y9BW.js} +4 -4
- package/dist/client/assets/{mindmap-definition-RKZ34NQL-CazuLaBR.js → mindmap-definition-RKZ34NQL-D9CoXKCy.js} +1 -1
- package/dist/client/assets/{pieDiagram-4H26LBE5-CRb7TKXS.js → pieDiagram-4H26LBE5-CYiW7xSP.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-W4KKPZXB-BSJq42JJ.js → quadrantDiagram-W4KKPZXB-CIsSZ-Zh.js} +1 -1
- package/dist/client/assets/{requirementDiagram-4Y6WPE33-BnvGLDfm.js → requirementDiagram-4Y6WPE33-B7-GZSuQ.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-5OEKKPKP-BsefP6U4.js → sankeyDiagram-5OEKKPKP-BpI06biT.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-3UESZ5HK-9nUKArcj.js → sequenceDiagram-3UESZ5HK-AfJCM_jE.js} +1 -1
- package/dist/client/assets/{stateDiagram-AJRCARHV-BzYVPtVp.js → stateDiagram-AJRCARHV-BjE5oO5C.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CgYlyNXS.js +1 -0
- package/dist/client/assets/{timeline-definition-PNZ67QCA-Dk6D_V0F.js → timeline-definition-PNZ67QCA-RqjT3Z41.js} +1 -1
- package/dist/client/assets/{vennDiagram-CIIHVFJN-B-gVcndn.js → vennDiagram-CIIHVFJN-CFo_JoJQ.js} +1 -1
- package/dist/client/assets/{wardley-L42UT6IY-DKOWjaGp.js → wardley-L42UT6IY-xa7pZ69p.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-YWT4CUSO-DhXoRo4O.js → wardleyDiagram-YWT4CUSO-J9a616X6.js} +1 -1
- package/dist/client/assets/{xychartDiagram-2RQKCTM6-Bth68Xec.js → xychartDiagram-2RQKCTM6-DGpUoD4-.js} +1 -1
- package/dist/client/index.html +1 -1
- package/package.json +1 -1
- package/src/server/agent.openrouter-watchdog.test.ts +280 -0
- package/src/server/agent.ts +81 -5
- package/src/server/server.ts +4 -0
- package/src/shared/types.ts +16 -0
- package/dist/client/assets/channel-ohENpLj7.js +0 -1
- package/dist/client/assets/classDiagram-4FO5ZUOK-BlyPnDJH.js +0 -1
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-BlyPnDJH.js +0 -1
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-BRLtys3w.js +0 -1
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { AgentCoordinator } from "./agent"
|
|
3
|
+
import type { HarnessEvent } from "./harness-types"
|
|
4
|
+
import type { LlmProviderSnapshot, SlashCommand, TranscriptEntry } from "../shared/types"
|
|
5
|
+
import { AsyncEventQueue } from "./test-helpers/async-event-queue"
|
|
6
|
+
import { waitFor } from "./test-helpers/wait-for"
|
|
7
|
+
|
|
8
|
+
// Minimal store (trimmed copy from agent.openrouter-model.test.ts — do NOT
|
|
9
|
+
// modify agent.test.ts).
|
|
10
|
+
function createFakeStore() {
|
|
11
|
+
const chat = {
|
|
12
|
+
id: "chat-1",
|
|
13
|
+
projectId: "project-1",
|
|
14
|
+
title: "New Chat",
|
|
15
|
+
provider: null as string | null,
|
|
16
|
+
planMode: false,
|
|
17
|
+
sessionToken: null as string | null,
|
|
18
|
+
sessionTokensByProvider: {} as Record<string, string | null>,
|
|
19
|
+
slashCommands: undefined as SlashCommand[] | undefined,
|
|
20
|
+
pendingForkSessionToken: null as { provider: string; token: string } | null,
|
|
21
|
+
}
|
|
22
|
+
const project = { id: "project-1", localPath: "/tmp/project" }
|
|
23
|
+
return {
|
|
24
|
+
chat,
|
|
25
|
+
turnFailedCount: 0,
|
|
26
|
+
turnFailedReasons: [] as string[],
|
|
27
|
+
messages: [] as TranscriptEntry[],
|
|
28
|
+
queuedMessages: [] as Array<{ id: string; content: string }>,
|
|
29
|
+
async recordSessionCommandsLoaded(_chatId: string, commands: SlashCommand[]) {
|
|
30
|
+
chat.slashCommands = commands
|
|
31
|
+
},
|
|
32
|
+
requireChat() {
|
|
33
|
+
return chat
|
|
34
|
+
},
|
|
35
|
+
getChat(chatId: string) {
|
|
36
|
+
if (chatId !== "chat-1") return null
|
|
37
|
+
return chat
|
|
38
|
+
},
|
|
39
|
+
getProject() {
|
|
40
|
+
return project
|
|
41
|
+
},
|
|
42
|
+
getMessages() {
|
|
43
|
+
return this.messages
|
|
44
|
+
},
|
|
45
|
+
async setChatProvider(_chatId: string, provider: string) {
|
|
46
|
+
chat.provider = provider
|
|
47
|
+
},
|
|
48
|
+
async setPlanMode(_chatId: string, planMode: boolean) {
|
|
49
|
+
chat.planMode = planMode
|
|
50
|
+
},
|
|
51
|
+
async renameChat(_chatId: string, title: string) {
|
|
52
|
+
chat.title = title
|
|
53
|
+
},
|
|
54
|
+
async appendMessage(_chatId: string, entry: TranscriptEntry) {
|
|
55
|
+
this.messages.push(entry)
|
|
56
|
+
},
|
|
57
|
+
async recordTurnStarted() {},
|
|
58
|
+
async recordTurnFinished() {},
|
|
59
|
+
async recordTurnFailed(_chatId: string, reason: string) {
|
|
60
|
+
this.turnFailedCount += 1
|
|
61
|
+
this.turnFailedReasons.push(reason)
|
|
62
|
+
},
|
|
63
|
+
async recordTurnCancelled() {},
|
|
64
|
+
async setSessionToken(_chatId: string, sessionToken: string | null) {
|
|
65
|
+
chat.sessionToken = sessionToken
|
|
66
|
+
},
|
|
67
|
+
async setSessionTokenForProvider(_chatId: string, provider: string, sessionToken: string | null) {
|
|
68
|
+
chat.sessionTokensByProvider = { ...chat.sessionTokensByProvider, [provider]: sessionToken }
|
|
69
|
+
chat.sessionToken = sessionToken
|
|
70
|
+
},
|
|
71
|
+
async setPendingForkSessionToken(_chatId: string, value: { provider: string; token: string } | null) {
|
|
72
|
+
chat.pendingForkSessionToken = value
|
|
73
|
+
},
|
|
74
|
+
async createChat() {
|
|
75
|
+
return chat
|
|
76
|
+
},
|
|
77
|
+
async enqueueMessage() {
|
|
78
|
+
return { id: crypto.randomUUID(), content: "" }
|
|
79
|
+
},
|
|
80
|
+
getQueuedMessages() {
|
|
81
|
+
return [...this.queuedMessages]
|
|
82
|
+
},
|
|
83
|
+
*runningSubagentRuns() {},
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function openrouterSnapshot(): LlmProviderSnapshot {
|
|
88
|
+
return {
|
|
89
|
+
provider: "openrouter",
|
|
90
|
+
apiKey: "sk-or-v1-abcdef1234567890",
|
|
91
|
+
model: "moonshotai/kimi-k2.5:nitro",
|
|
92
|
+
baseUrl: "",
|
|
93
|
+
resolvedBaseUrl: "https://openrouter.ai/api",
|
|
94
|
+
enabled: true,
|
|
95
|
+
warning: null,
|
|
96
|
+
filePathDisplay: "~/.kanna/llm-provider.json",
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe("AgentCoordinator OpenRouter first-entry watchdog", () => {
|
|
101
|
+
test(
|
|
102
|
+
"fails closed when the OpenRouter stream emits session_token then no entry",
|
|
103
|
+
async () => {
|
|
104
|
+
const events = new AsyncEventQueue<HarnessEvent>()
|
|
105
|
+
const store = createFakeStore()
|
|
106
|
+
let closeCalled = 0
|
|
107
|
+
const coordinator = new AgentCoordinator({
|
|
108
|
+
store: store as never,
|
|
109
|
+
onStateChange: () => {},
|
|
110
|
+
readLlmProvider: async () => openrouterSnapshot(),
|
|
111
|
+
openrouterFirstEntryTimeoutMs: 200,
|
|
112
|
+
startClaudeSession: async () => {
|
|
113
|
+
return {
|
|
114
|
+
provider: "claude",
|
|
115
|
+
stream: events,
|
|
116
|
+
getAccountInfo: async () => null,
|
|
117
|
+
interrupt: async () => {},
|
|
118
|
+
close: () => {
|
|
119
|
+
closeCalled += 1
|
|
120
|
+
events.close()
|
|
121
|
+
},
|
|
122
|
+
setModel: async () => {},
|
|
123
|
+
setPermissionMode: async () => {},
|
|
124
|
+
getSupportedCommands: async () => [],
|
|
125
|
+
// Never emit an entry — reproduces session a71516d4's silent stall
|
|
126
|
+
// where the SDK connected (account_info) but no system_init/result
|
|
127
|
+
// ever arrived.
|
|
128
|
+
sendPrompt: async () => {},
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
} as never)
|
|
132
|
+
|
|
133
|
+
await coordinator.send({
|
|
134
|
+
type: "chat.send",
|
|
135
|
+
chatId: "chat-1",
|
|
136
|
+
provider: "openrouter" as never,
|
|
137
|
+
content: "tell me about this project",
|
|
138
|
+
model: "qwen/qwen3.7-plus",
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
await waitFor(() => store.turnFailedCount > 0, 4000, "turn failed via watchdog")
|
|
142
|
+
|
|
143
|
+
expect(store.turnFailedCount).toBeGreaterThan(0)
|
|
144
|
+
expect(closeCalled).toBeGreaterThan(0)
|
|
145
|
+
const errorResult = store.messages.find(
|
|
146
|
+
(m) => m.kind === "result" && (m as { isError?: boolean }).isError === true,
|
|
147
|
+
)
|
|
148
|
+
expect(errorResult).toBeDefined()
|
|
149
|
+
expect((errorResult as { result?: string }).result).toContain("OpenRouter produced no response")
|
|
150
|
+
},
|
|
151
|
+
10_000,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
test(
|
|
155
|
+
"does not fail the turn when the OpenRouter stream emits an entry in time",
|
|
156
|
+
async () => {
|
|
157
|
+
const events = new AsyncEventQueue<HarnessEvent>()
|
|
158
|
+
const store = createFakeStore()
|
|
159
|
+
const coordinator = new AgentCoordinator({
|
|
160
|
+
store: store as never,
|
|
161
|
+
onStateChange: () => {},
|
|
162
|
+
readLlmProvider: async () => openrouterSnapshot(),
|
|
163
|
+
openrouterFirstEntryTimeoutMs: 200,
|
|
164
|
+
startClaudeSession: async () => {
|
|
165
|
+
// Emit a result entry promptly — the watchdog must be cleared and
|
|
166
|
+
// never fire.
|
|
167
|
+
events.push({
|
|
168
|
+
type: "transcript",
|
|
169
|
+
entry: {
|
|
170
|
+
_id: "result-1",
|
|
171
|
+
createdAt: Date.now(),
|
|
172
|
+
kind: "result",
|
|
173
|
+
subtype: "success",
|
|
174
|
+
isError: false,
|
|
175
|
+
durationMs: 0,
|
|
176
|
+
result: "done",
|
|
177
|
+
} as never,
|
|
178
|
+
})
|
|
179
|
+
return {
|
|
180
|
+
provider: "claude",
|
|
181
|
+
stream: events,
|
|
182
|
+
getAccountInfo: async () => null,
|
|
183
|
+
interrupt: async () => {},
|
|
184
|
+
close: () => events.close(),
|
|
185
|
+
setModel: async () => {},
|
|
186
|
+
setPermissionMode: async () => {},
|
|
187
|
+
getSupportedCommands: async () => [],
|
|
188
|
+
sendPrompt: async () => {},
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
} as never)
|
|
192
|
+
|
|
193
|
+
await coordinator.send({
|
|
194
|
+
type: "chat.send",
|
|
195
|
+
chatId: "chat-1",
|
|
196
|
+
provider: "openrouter" as never,
|
|
197
|
+
content: "hi",
|
|
198
|
+
model: "moonshotai/kimi-k2.5:nitro",
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
// Wait past the watchdog window; the timely entry must keep it disarmed.
|
|
202
|
+
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
203
|
+
|
|
204
|
+
expect(store.turnFailedCount).toBe(0)
|
|
205
|
+
const errorResult = store.messages.find(
|
|
206
|
+
(m) => m.kind === "result" && (m as { isError?: boolean }).isError === true,
|
|
207
|
+
)
|
|
208
|
+
expect(errorResult).toBeUndefined()
|
|
209
|
+
},
|
|
210
|
+
10_000,
|
|
211
|
+
)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
describe("AgentCoordinator OpenRouter SDK-session prompt delivery", () => {
|
|
215
|
+
test(
|
|
216
|
+
"delivers the user prompt to the SDK session for openrouter (regression: prompt-delivery gate)",
|
|
217
|
+
async () => {
|
|
218
|
+
// Regression for the gate that delivered prompts only when
|
|
219
|
+
// `provider === "claude"`. OpenRouter rides the same SDK session but was
|
|
220
|
+
// excluded, so its prompt never reached `sendPrompt` and every turn hung
|
|
221
|
+
// until the watchdog. `providerUsesSdkSession` now covers both.
|
|
222
|
+
const events = new AsyncEventQueue<HarnessEvent>()
|
|
223
|
+
const store = createFakeStore()
|
|
224
|
+
const sentPrompts: string[] = []
|
|
225
|
+
const coordinator = new AgentCoordinator({
|
|
226
|
+
store: store as never,
|
|
227
|
+
onStateChange: () => {},
|
|
228
|
+
readLlmProvider: async () => openrouterSnapshot(),
|
|
229
|
+
openrouterFirstEntryTimeoutMs: 5000,
|
|
230
|
+
startClaudeSession: async () => {
|
|
231
|
+
return {
|
|
232
|
+
provider: "claude",
|
|
233
|
+
stream: events,
|
|
234
|
+
getAccountInfo: async () => null,
|
|
235
|
+
interrupt: async () => {},
|
|
236
|
+
close: () => events.close(),
|
|
237
|
+
setModel: async () => {},
|
|
238
|
+
setPermissionMode: async () => {},
|
|
239
|
+
getSupportedCommands: async () => [],
|
|
240
|
+
sendPrompt: async (content: string) => {
|
|
241
|
+
sentPrompts.push(content)
|
|
242
|
+
// A real upstream would now stream a turn; emit a success result
|
|
243
|
+
// so the turn completes cleanly and the watchdog never fires.
|
|
244
|
+
events.push({
|
|
245
|
+
type: "transcript",
|
|
246
|
+
entry: {
|
|
247
|
+
_id: "result-1",
|
|
248
|
+
createdAt: Date.now(),
|
|
249
|
+
kind: "result",
|
|
250
|
+
subtype: "success",
|
|
251
|
+
isError: false,
|
|
252
|
+
durationMs: 0,
|
|
253
|
+
result: "ok",
|
|
254
|
+
} as never,
|
|
255
|
+
})
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
} as never)
|
|
260
|
+
|
|
261
|
+
await coordinator.send({
|
|
262
|
+
type: "chat.send",
|
|
263
|
+
chatId: "chat-1",
|
|
264
|
+
provider: "openrouter" as never,
|
|
265
|
+
content: "hello openrouter",
|
|
266
|
+
model: "moonshotai/kimi-k2.5:nitro",
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
await waitFor(
|
|
270
|
+
() => sentPrompts.some((p) => p.includes("hello openrouter")),
|
|
271
|
+
4000,
|
|
272
|
+
"openrouter prompt delivered to the SDK session",
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
expect(sentPrompts.some((p) => p.includes("hello openrouter"))).toBe(true)
|
|
276
|
+
expect(store.turnFailedCount).toBe(0)
|
|
277
|
+
},
|
|
278
|
+
10_000,
|
|
279
|
+
)
|
|
280
|
+
})
|
package/src/server/agent.ts
CHANGED
|
@@ -48,7 +48,7 @@ import {
|
|
|
48
48
|
openrouterAuthReady,
|
|
49
49
|
} from "./provider-catalog"
|
|
50
50
|
import { readLlmProviderSnapshot } from "./llm-provider"
|
|
51
|
-
import { resolveClaudeApiModelId, type ClaudeDriverPreference } from "../shared/types"
|
|
51
|
+
import { providerUsesSdkSession, resolveClaudeApiModelId, type ClaudeDriverPreference } from "../shared/types"
|
|
52
52
|
import { fallbackTitleFromMessage } from "./generate-title"
|
|
53
53
|
import { AUTO_CONTINUE_EVENT_VERSION, type AutoContinueEvent } from "./auto-continue/events"
|
|
54
54
|
import { ClaudeLimitDetector, CodexLimitDetector, type LimitDetection, type LimitDetector } from "./auto-continue/limit-detector"
|
|
@@ -340,6 +340,17 @@ interface AgentCoordinatorArgs {
|
|
|
340
340
|
* 120000 (2 min). Bounded by `maxAgentWakes`.
|
|
341
341
|
*/
|
|
342
342
|
pendingWorkflowPollMs?: number
|
|
343
|
+
/**
|
|
344
|
+
* Watchdog (ms) for an OpenRouter turn whose SDK stream emits no transcript
|
|
345
|
+
* entry (no `system_init`) after the session-token handshake. OpenRouter
|
|
346
|
+
* routes through the Claude SDK; a stalled upstream leaves the stream open
|
|
347
|
+
* but silent, so the `runClaudeSession` for-await never returns or throws
|
|
348
|
+
* and the existing fail-close never fires. On timeout the watchdog
|
|
349
|
+
* interrupts + closes the session so the stream ends and the turn is
|
|
350
|
+
* recorded failed. OpenRouter-only; cleared on the first entry. Default
|
|
351
|
+
* 120000 (2 min).
|
|
352
|
+
*/
|
|
353
|
+
openrouterFirstEntryTimeoutMs?: number
|
|
343
354
|
getSubagents?: () => Subagent[]
|
|
344
355
|
getAppSettingsSnapshot?: () => {
|
|
345
356
|
claudeAuth?: { authenticated?: boolean } | null
|
|
@@ -1324,6 +1335,12 @@ const DEFAULT_CLAUDE_SESSION_SWEEP_INTERVAL_MS = 60 * 1000
|
|
|
1324
1335
|
// Keep a PTY session warm up to 30 min while a background Bash task is pending —
|
|
1325
1336
|
// comfortably longer than the 10-min idle window and typical CI durations.
|
|
1326
1337
|
const DEFAULT_PTY_BACKGROUND_TASK_MAX_MS = 30 * 60 * 1000
|
|
1338
|
+
// OpenRouter-only watchdog: a stalled upstream leaves the SDK stream open but
|
|
1339
|
+
// silent after the session-token handshake, so the runClaudeSession for-await
|
|
1340
|
+
// never ends and the existing fail-close never fires. Abort if no transcript
|
|
1341
|
+
// entry arrives within this window. system_init is the SDK init echo (precedes
|
|
1342
|
+
// model inference), so 2 min is generous; env-tunable per deployment.
|
|
1343
|
+
const DEFAULT_OPENROUTER_FIRST_ENTRY_TIMEOUT_MS = 2 * 60 * 1000
|
|
1327
1344
|
// Agent wakes are clamped to one idle window minus this buffer so a re-entry
|
|
1328
1345
|
// always lands before the idle reaper closes the PTY (see scheduleAgentWakeup).
|
|
1329
1346
|
const WAKE_GUARD_BUFFER_MS = 60 * 1000
|
|
@@ -1412,6 +1429,7 @@ export class AgentCoordinator {
|
|
|
1412
1429
|
private readonly agentWakeChainByChat = new Map<string, number>()
|
|
1413
1430
|
private readonly maxAgentWakes: number
|
|
1414
1431
|
private readonly pendingWorkflowPollMs: number
|
|
1432
|
+
private readonly openrouterFirstEntryTimeoutMs: number
|
|
1415
1433
|
// Per-tokenId rotation dedupe state. When a shared OAuth token throws
|
|
1416
1434
|
// limit/auth-error against N chats simultaneously, only the first chat
|
|
1417
1435
|
// pays the cost of marking the pool + picking a fresh target; subsequent
|
|
@@ -1456,6 +1474,8 @@ export class AgentCoordinator {
|
|
|
1456
1474
|
this.getAutoResumePreference = args.getAutoResumePreference ?? (() => false)
|
|
1457
1475
|
this.maxAgentWakes = args.maxAgentWakes ?? 25
|
|
1458
1476
|
this.pendingWorkflowPollMs = args.pendingWorkflowPollMs ?? 120_000
|
|
1477
|
+
this.openrouterFirstEntryTimeoutMs =
|
|
1478
|
+
args.openrouterFirstEntryTimeoutMs ?? DEFAULT_OPENROUTER_FIRST_ENTRY_TIMEOUT_MS
|
|
1459
1479
|
this.getSubagents = args.getSubagents ?? (() => [])
|
|
1460
1480
|
this.getAppSettingsSnapshot = args.getAppSettingsSnapshot ?? (() => ({}))
|
|
1461
1481
|
this.readLlmProvider = args.readLlmProvider ?? readLlmProviderSnapshot
|
|
@@ -2446,10 +2466,13 @@ export class AgentCoordinator {
|
|
|
2446
2466
|
.catch(() => undefined)
|
|
2447
2467
|
}
|
|
2448
2468
|
|
|
2449
|
-
if (args.provider
|
|
2469
|
+
if (providerUsesSdkSession(args.provider)) {
|
|
2470
|
+
// claude and openrouter both deliver their prompt through the SDK
|
|
2471
|
+
// session queue; gating this on `=== "claude"` is what left openrouter's
|
|
2472
|
+
// prompt undelivered, hanging every openrouter turn until the watchdog.
|
|
2450
2473
|
const session = this.claudeSessions.get(args.chatId)
|
|
2451
2474
|
if (!session) {
|
|
2452
|
-
throw new Error("
|
|
2475
|
+
throw new Error("SDK session was not initialized")
|
|
2453
2476
|
}
|
|
2454
2477
|
const promptSeq = session.nextPromptSeq + 1
|
|
2455
2478
|
session.nextPromptSeq = promptSeq
|
|
@@ -2487,12 +2510,12 @@ export class AgentCoordinator {
|
|
|
2487
2510
|
planMode: boolean
|
|
2488
2511
|
clientTraceId?: string
|
|
2489
2512
|
}): ActiveTurn | undefined {
|
|
2490
|
-
if (args.provider
|
|
2513
|
+
if (!providerUsesSdkSession(args.provider)) return undefined
|
|
2491
2514
|
const session = this.claudeSessions.get(args.chatId)
|
|
2492
2515
|
if (!session) return undefined
|
|
2493
2516
|
|
|
2494
2517
|
const ghostTurn: HarnessTurn = {
|
|
2495
|
-
provider:
|
|
2518
|
+
provider: args.provider,
|
|
2496
2519
|
stream: { async *[Symbol.asyncIterator]() {} },
|
|
2497
2520
|
getAccountInfo: session.session.getAccountInfo,
|
|
2498
2521
|
interrupt: session.session.interrupt,
|
|
@@ -3089,6 +3112,56 @@ export class AgentCoordinator {
|
|
|
3089
3112
|
}
|
|
3090
3113
|
|
|
3091
3114
|
private async runClaudeSession(session: ClaudeSessionState) {
|
|
3115
|
+
// OpenRouter-only first-entry watchdog. OpenRouter routes through the
|
|
3116
|
+
// Claude SDK; a stalled upstream emits the session-token handshake then
|
|
3117
|
+
// goes silent — the stream stays open with no entry, so this for-await
|
|
3118
|
+
// never returns or throws and the chat hangs "running" until restart. The
|
|
3119
|
+
// existing catch/finally fail-close is claude-provider-gated and depends
|
|
3120
|
+
// on an active turn that the openrouter path tears down early, so the
|
|
3121
|
+
// watchdog records the failure itself, then interrupts + closes the
|
|
3122
|
+
// session to end the stream. `firstEntrySeen` guards against a late real
|
|
3123
|
+
// entry; close() prevents any further entry from being processed.
|
|
3124
|
+
const isOpenRouterSession = session.openrouterModel !== null
|
|
3125
|
+
let firstEntrySeen = false
|
|
3126
|
+
let firstEntryWatchdog: ReturnType<typeof setTimeout> | null = null
|
|
3127
|
+
const clearFirstEntryWatchdog = () => {
|
|
3128
|
+
if (firstEntryWatchdog !== null) {
|
|
3129
|
+
clearTimeout(firstEntryWatchdog)
|
|
3130
|
+
firstEntryWatchdog = null
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
if (isOpenRouterSession) {
|
|
3134
|
+
firstEntryWatchdog = setTimeout(() => {
|
|
3135
|
+
if (firstEntrySeen) return
|
|
3136
|
+
if (this.claudeSessions.get(session.chatId) !== session) return
|
|
3137
|
+
firstEntrySeen = true
|
|
3138
|
+
const message = `OpenRouter produced no response within ${this.openrouterFirstEntryTimeoutMs}ms — the selected model may be invalid or the upstream stalled.`
|
|
3139
|
+
console.warn("[kanna/agent] openrouter stream produced no entry within watchdog window — failing turn", {
|
|
3140
|
+
chatId: session.chatId,
|
|
3141
|
+
sessionId: session.id,
|
|
3142
|
+
model: session.openrouterModel,
|
|
3143
|
+
timeoutMs: this.openrouterFirstEntryTimeoutMs,
|
|
3144
|
+
})
|
|
3145
|
+
void (async () => {
|
|
3146
|
+
await this.store.appendMessage(
|
|
3147
|
+
session.chatId,
|
|
3148
|
+
timestamped({
|
|
3149
|
+
kind: "result",
|
|
3150
|
+
subtype: "error",
|
|
3151
|
+
isError: true,
|
|
3152
|
+
durationMs: this.openrouterFirstEntryTimeoutMs,
|
|
3153
|
+
result: message,
|
|
3154
|
+
}),
|
|
3155
|
+
)
|
|
3156
|
+
await this.store.recordTurnFailed(session.chatId, message)
|
|
3157
|
+
const active = this.activeTurns.get(session.chatId)
|
|
3158
|
+
if (active) this.activeTurns.delete(session.chatId)
|
|
3159
|
+
this.emitStateChange(session.chatId)
|
|
3160
|
+
void session.session.interrupt().catch(() => {})
|
|
3161
|
+
session.session.close()
|
|
3162
|
+
})()
|
|
3163
|
+
}, this.openrouterFirstEntryTimeoutMs)
|
|
3164
|
+
}
|
|
3092
3165
|
try {
|
|
3093
3166
|
let simulateLimit = this.throwOnClaudeSessionStart
|
|
3094
3167
|
for await (const event of session.session.stream) {
|
|
@@ -3119,6 +3192,8 @@ export class AgentCoordinator {
|
|
|
3119
3192
|
}
|
|
3120
3193
|
|
|
3121
3194
|
if (!event.entry) continue
|
|
3195
|
+
firstEntrySeen = true
|
|
3196
|
+
clearFirstEntryWatchdog()
|
|
3122
3197
|
if (this.claudeSessions.get(session.chatId) !== session) break
|
|
3123
3198
|
// Suppress the interrupt-induced tail `result` of a cancelled turn.
|
|
3124
3199
|
// cancel() already removed the active turn, recorded the cancellation,
|
|
@@ -3337,6 +3412,7 @@ export class AgentCoordinator {
|
|
|
3337
3412
|
}
|
|
3338
3413
|
}
|
|
3339
3414
|
} finally {
|
|
3415
|
+
clearFirstEntryWatchdog()
|
|
3340
3416
|
const active = this.activeTurns.get(session.chatId)
|
|
3341
3417
|
const isCurrentSession = this.claudeSessions.get(session.chatId) === session
|
|
3342
3418
|
// Trace point: stream-end-without-final-result is the hang signature.
|
package/src/server/server.ts
CHANGED
|
@@ -432,6 +432,10 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
432
432
|
scheduleManager,
|
|
433
433
|
maxAgentWakes: parsePositiveIntEnv(process.env.KANNA_MAX_AGENT_WAKES, 25),
|
|
434
434
|
pendingWorkflowPollMs: parsePositiveIntEnv(process.env.KANNA_PENDING_WORKFLOW_POLL_MS, 120_000),
|
|
435
|
+
openrouterFirstEntryTimeoutMs: parsePositiveIntEnv(
|
|
436
|
+
process.env.KANNA_OPENROUTER_FIRST_ENTRY_TIMEOUT_MS,
|
|
437
|
+
2 * 60 * 1000,
|
|
438
|
+
),
|
|
435
439
|
claudeLimitDetector: options.agentOverrides?.claudeLimitDetector,
|
|
436
440
|
codexLimitDetector: options.agentOverrides?.codexLimitDetector,
|
|
437
441
|
throwOnClaudeSessionStart: options.agentOverrides?.throwOnClaudeSessionStart,
|
package/src/shared/types.ts
CHANGED
|
@@ -425,6 +425,22 @@ export function getProviderCatalog(provider: AgentProvider): ProviderCatalogEntr
|
|
|
425
425
|
return entry
|
|
426
426
|
}
|
|
427
427
|
|
|
428
|
+
/**
|
|
429
|
+
* True when the provider's turns run through the Claude SDK session transport
|
|
430
|
+
* (a live `claudeSessions` entry consumed by `runClaudeSession`, prompts
|
|
431
|
+
* delivered via `session.sendPrompt`) rather than the generic harness-turn
|
|
432
|
+
* transport (`runTurn` over `active.turn.stream`).
|
|
433
|
+
*
|
|
434
|
+
* `claude` and `openrouter` both ride the SDK session — openrouter just points
|
|
435
|
+
* the SDK at OpenRouter's Anthropic-compatible endpoint. Branching on
|
|
436
|
+
* `provider === "claude"` where the real intent is "uses the SDK session" is
|
|
437
|
+
* what silently dropped openrouter's prompt delivery; use this predicate so a
|
|
438
|
+
* new SDK-backed provider can never be forgotten by an `if`-chain again.
|
|
439
|
+
*/
|
|
440
|
+
export function providerUsesSdkSession(provider: AgentProvider): boolean {
|
|
441
|
+
return provider === "claude" || provider === "openrouter"
|
|
442
|
+
}
|
|
443
|
+
|
|
428
444
|
function getProviderModelMatch(provider: AgentProvider, modelId?: string): ProviderModelOption | undefined {
|
|
429
445
|
if (!modelId) return undefined
|
|
430
446
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{U as a,D as n}from"./mermaid.core-CoJEFYct.js";const t=(r,o)=>a.lang.round(n.parse(r)[o]);export{t as c};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{s as a,c as s,a as e,C as t}from"./chunk-727SXJPM-eMaVSOKi.js";import{_ as i}from"./mermaid.core-CoJEFYct.js";import"./chunk-FMBD7UC4-BtAWu6Fv.js";import"./chunk-ND2GUHAM-CyxbGgpO.js";import"./chunk-55IACEB6-HUFJLmvp.js";import"./chunk-2J33WTMH-C77Np9ft.js";import"./index-MixgFXRr.js";var n={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{n as diagram};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{s as a,c as s,a as e,C as t}from"./chunk-727SXJPM-eMaVSOKi.js";import{_ as i}from"./mermaid.core-CoJEFYct.js";import"./chunk-FMBD7UC4-BtAWu6Fv.js";import"./chunk-ND2GUHAM-CyxbGgpO.js";import"./chunk-55IACEB6-HUFJLmvp.js";import"./chunk-2J33WTMH-C77Np9ft.js";import"./index-MixgFXRr.js";var n={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{n as diagram};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{s as e,b as r,a,S as s}from"./chunk-AQP2D5EJ-Dqr7iBWK.js";import{_ as i}from"./mermaid.core-CoJEFYct.js";import"./chunk-55IACEB6-HUFJLmvp.js";import"./chunk-2J33WTMH-C77Np9ft.js";import"./index-MixgFXRr.js";var p={parser:a,get db(){return new s(2)},renderer:r,styles:e,init:i(t=>{t.state||(t.state={}),t.state.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")};export{p as diagram};
|