@doguyilmaz/konvoy 0.1.1
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/LICENSE +21 -0
- package/README.md +300 -0
- package/package.json +52 -0
- package/src/adapters/claude.ts +83 -0
- package/src/adapters/codex.ts +67 -0
- package/src/adapters/effort.ts +16 -0
- package/src/adapters/index.ts +20 -0
- package/src/adapters/kiro.ts +79 -0
- package/src/adapters/opencode.ts +64 -0
- package/src/adapters/types.ts +108 -0
- package/src/args.ts +42 -0
- package/src/chart.ts +91 -0
- package/src/cli.ts +146 -0
- package/src/commands/attach.ts +85 -0
- package/src/commands/config.ts +113 -0
- package/src/commands/dashboard.ts +26 -0
- package/src/commands/doctor.ts +104 -0
- package/src/commands/ls.ts +15 -0
- package/src/commands/new.ts +24 -0
- package/src/commands/resume.ts +14 -0
- package/src/commands/rm.ts +28 -0
- package/src/commands/roster.ts +37 -0
- package/src/commands/send.ts +79 -0
- package/src/commands/status.ts +35 -0
- package/src/commands/table.ts +75 -0
- package/src/commands/update.ts +72 -0
- package/src/commands/usage.ts +77 -0
- package/src/config/load.ts +335 -0
- package/src/config/schema.ts +100 -0
- package/src/core/children.ts +62 -0
- package/src/core/detect.ts +211 -0
- package/src/core/facts.ts +113 -0
- package/src/core/gate.ts +73 -0
- package/src/core/prelude.ts +121 -0
- package/src/core/session.ts +334 -0
- package/src/core/turn.ts +263 -0
- package/src/dashboard/page.ts +211 -0
- package/src/format.ts +98 -0
- package/src/paths.ts +33 -0
- package/src/pricing.ts +86 -0
- package/src/store/db.ts +78 -0
- package/src/store/queries.ts +434 -0
- package/src/types.ts +71 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import { oneLine } from '../adapters/types'
|
|
3
|
+
import type { AgentId, Session, TurnContext } from '../types'
|
|
4
|
+
import type { Config } from '../config/schema'
|
|
5
|
+
import type { Adapter } from '../adapters/types'
|
|
6
|
+
import { resolveAgent, resolveRecipient, type AgentSettings } from '../config/load'
|
|
7
|
+
import { getAdapter } from '../adapters'
|
|
8
|
+
import { clampEffort } from '../adapters/effort'
|
|
9
|
+
import { detect, type Detection, type DetectOptions } from './detect'
|
|
10
|
+
import {
|
|
11
|
+
acquireLock,
|
|
12
|
+
clearForeignId,
|
|
13
|
+
createSession,
|
|
14
|
+
getBinding,
|
|
15
|
+
getSessionBySlug,
|
|
16
|
+
lastTurnAgent,
|
|
17
|
+
lastTurnId,
|
|
18
|
+
lockOwner,
|
|
19
|
+
releaseLock,
|
|
20
|
+
} from '../store/queries'
|
|
21
|
+
import { runTurn, type TurnOptions, type TurnResult } from './turn'
|
|
22
|
+
import { runGate } from './gate'
|
|
23
|
+
import { collectFacts, formatFacts, realFactsDeps } from './facts'
|
|
24
|
+
import { buildPrelude, parseEnvelope } from './prelude'
|
|
25
|
+
import { sessionDir } from '../paths'
|
|
26
|
+
|
|
27
|
+
// The prelude carries at most this many recent turns; section 28 caps it so a long session
|
|
28
|
+
// cannot let the handover grow until it dominates every turn.
|
|
29
|
+
const RECENT_TURNS = 3
|
|
30
|
+
|
|
31
|
+
// Captured from the real CLIs on 2026-09-19 by resuming an id that does not exist:
|
|
32
|
+
// claude "No conversation found with session ID <uuid>"
|
|
33
|
+
// codex "no rollout found for thread id <uuid>"
|
|
34
|
+
// opencode {"type":"error","error":{"message":"Session not found"}}
|
|
35
|
+
// kiro "error: ACP load_session failed" on stderr, exit 1, no stream events at all
|
|
36
|
+
// (re-measured 2026-09-21 against kiro-cli 2.22.1). The 2026-09-19 note here said
|
|
37
|
+
// kiro reported nothing and silently opened an empty session under whatever id it
|
|
38
|
+
// was handed, and recorded that as an undetectable limit — it is detectable, and
|
|
39
|
+
// leaving the old wording in place is what kept the rebind from firing for kiro.
|
|
40
|
+
const STALE =
|
|
41
|
+
/no conversation found|no rollout found|session not found|no such session|unknown session|not found with session|load_session failed/i
|
|
42
|
+
|
|
43
|
+
export function slugify(goal: string): string {
|
|
44
|
+
const base = goal
|
|
45
|
+
.toLowerCase()
|
|
46
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
47
|
+
.replace(/^-+|-+$/g, '')
|
|
48
|
+
.slice(0, 40)
|
|
49
|
+
.replace(/-+$/g, '')
|
|
50
|
+
return base || 'session'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function uniqueSlug(db: Database, base: string): string {
|
|
54
|
+
if (!getSessionBySlug(db, base)) return base
|
|
55
|
+
for (let n = 2; n < 1000; n++) {
|
|
56
|
+
const candidate = `${base}-${n}`
|
|
57
|
+
if (!getSessionBySlug(db, candidate)) return candidate
|
|
58
|
+
}
|
|
59
|
+
throw new Error(`cannot find a free slug for ${base}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function newSession(
|
|
63
|
+
db: Database,
|
|
64
|
+
input: { cwd: string; goal: string; slug?: string; lead: AgentId },
|
|
65
|
+
): Session {
|
|
66
|
+
const slug = uniqueSlug(db, input.slug ? slugify(input.slug) : slugify(input.goal))
|
|
67
|
+
return createSession(db, { slug, goal: input.goal, cwd: input.cwd, lead: input.lead })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SendDeps {
|
|
71
|
+
db: Database
|
|
72
|
+
cfg: Config
|
|
73
|
+
adapterFor?: (agent: AgentId) => Adapter
|
|
74
|
+
detect?: (agent: AgentId, opts: DetectOptions) => Promise<Detection>
|
|
75
|
+
/** base wait between upstream retries; tests that only care about the walk pass 0 */
|
|
76
|
+
upstreamBackoffMs?: number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A chain names an order, not a set: konvoy starts at the requested agent and follows the
|
|
80
|
+
// chain forward from wherever that agent sits in it. An agent the chain never mentions has
|
|
81
|
+
// nothing configured for it, so it runs alone.
|
|
82
|
+
function chainFrom(chain: readonly AgentId[], agent: AgentId): AgentId[] {
|
|
83
|
+
const idx = chain.indexOf(agent)
|
|
84
|
+
return idx === -1 ? [agent] : chain.slice(idx)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// a hammered upstream is the last thing to hammer again: a second, times the attempt
|
|
88
|
+
const UPSTREAM_BACKOFF_MS = 1000
|
|
89
|
+
|
|
90
|
+
export async function send(
|
|
91
|
+
deps: SendDeps,
|
|
92
|
+
session: Session,
|
|
93
|
+
agent: AgentId,
|
|
94
|
+
prompt: string,
|
|
95
|
+
opts: TurnOptions = {},
|
|
96
|
+
): Promise<TurnResult> {
|
|
97
|
+
const settings = resolveAgent(deps.cfg, agent)
|
|
98
|
+
if (!settings.enabled) throw new Error(`${agent} is disabled in this konvoy config`)
|
|
99
|
+
|
|
100
|
+
const adapterFor = (a: AgentId): Adapter => deps.adapterFor?.(a) ?? getAdapter(a)
|
|
101
|
+
const detectFor = deps.detect ?? detect
|
|
102
|
+
const adapter = adapterFor(agent)
|
|
103
|
+
const detection = await detectFor(agent, { model: settings.model, bin: settings.bin })
|
|
104
|
+
if (!detection.installed) throw new Error(`${agent}: not installed`)
|
|
105
|
+
|
|
106
|
+
const timeoutSec = opts.timeoutSec ?? deps.cfg.policy.turnTimeoutSec
|
|
107
|
+
const inherited = Bun.env.KONVOY_LEASE
|
|
108
|
+
const lease = inherited ?? crypto.randomUUID()
|
|
109
|
+
const alreadyHeld = inherited !== undefined && lockOwner(deps.db, session.id) === inherited
|
|
110
|
+
if (!alreadyHeld && !acquireLock(deps.db, session.id, lease)) {
|
|
111
|
+
throw new Error(`session "${session.slug}" is busy — another konvoy turn is running`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Set once withLock() has built the primary prelude — followHandoff needs its own, built
|
|
115
|
+
// fresh after the handing-off turn is recorded, so it is read rather than recomputed here.
|
|
116
|
+
let facts = ''
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const result = await withLock()
|
|
120
|
+
// The turn just finished writing its own row — runGate reads that row itself to decide
|
|
121
|
+
// whether there is anything for it to judge, so it is always safe to call here.
|
|
122
|
+
const turnId = lastTurnId(deps.db, session.id)
|
|
123
|
+
if (turnId) await runGate(deps.db, deps.cfg, session, turnId)
|
|
124
|
+
|
|
125
|
+
const handoff = turnId ? await followHandoff(result, turnId) : null
|
|
126
|
+
if (!handoff) return result
|
|
127
|
+
|
|
128
|
+
const handoffTurnId = lastTurnId(deps.db, session.id)
|
|
129
|
+
if (handoffTurnId) await runGate(deps.db, deps.cfg, session, handoffTurnId)
|
|
130
|
+
return handoff
|
|
131
|
+
} finally {
|
|
132
|
+
if (!alreadyHeld) releaseLock(deps.db, session.id, lease)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// One hop per send: this reads the envelope on the turn `send()` was asked to run, resolves
|
|
136
|
+
// it once, and returns whatever that recipient's own turn produces — including an envelope
|
|
137
|
+
// of its own, which is never fed back in here. A mistaken `to:` pointing back at the sender
|
|
138
|
+
// would otherwise loop until something ran out, and the caller asked for one turn.
|
|
139
|
+
async function followHandoff(result: TurnResult, turnId: string): Promise<TurnResult | null> {
|
|
140
|
+
if (!deps.cfg.delegation.enabled || result.error) return null
|
|
141
|
+
const envelope = parseEnvelope(result.final)
|
|
142
|
+
if (!envelope?.to) return null
|
|
143
|
+
|
|
144
|
+
const ranAsAgent = lastTurnAgent(deps.db, session.id) ?? agent
|
|
145
|
+
const recipient = resolveRecipient(deps.cfg, envelope.to)
|
|
146
|
+
if (!recipient) {
|
|
147
|
+
console.error(
|
|
148
|
+
`konvoy: ${ranAsAgent} handed off to "${oneLine(envelope.to, 80)}" — no such agent or role, ${ranAsAgent}'s turn stands`,
|
|
149
|
+
)
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const recipientSettings = resolveAgent(deps.cfg, recipient)
|
|
154
|
+
const recipientDetection = recipientSettings.enabled
|
|
155
|
+
? await detectFor(recipient, { model: recipientSettings.model, bin: recipientSettings.bin })
|
|
156
|
+
: { agent: recipient, installed: false, version: null }
|
|
157
|
+
// Reported, not silently dropped — the same rule the failover chain already follows for a
|
|
158
|
+
// disabled or uninstalled member.
|
|
159
|
+
if (!recipientSettings.enabled || !recipientDetection.installed) {
|
|
160
|
+
const why = !recipientSettings.enabled ? 'disabled in config' : 'not installed'
|
|
161
|
+
console.error(
|
|
162
|
+
`konvoy: ${ranAsAgent} handed off to ${recipient}, but ${recipient} is ${why} — ${ranAsAgent}'s turn stands`,
|
|
163
|
+
)
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
console.error(`konvoy: ${ranAsAgent} handed off to ${recipient} — "${oneLine(envelope.task)}"`)
|
|
168
|
+
|
|
169
|
+
const recipientEffort = clampEffort(recipientSettings.effort, recipientDetection.efforts)
|
|
170
|
+
// Built fresh, after the handing-off turn was recorded: the prelude built at the top of
|
|
171
|
+
// this send() describes the state before that turn ran — the opposite of what the
|
|
172
|
+
// recipient needs, which is its own cooperative handoff, envelope and all.
|
|
173
|
+
const delegatedPrelude = buildPrelude(deps.db, session, facts, { recent: RECENT_TURNS })
|
|
174
|
+
return runOnce(
|
|
175
|
+
recipient,
|
|
176
|
+
adapterFor(recipient),
|
|
177
|
+
() => ({
|
|
178
|
+
sessionId: session.id,
|
|
179
|
+
slug: session.slug,
|
|
180
|
+
cwd: session.cwd,
|
|
181
|
+
sessionDir: sessionDir(session.cwd, session.slug),
|
|
182
|
+
prompt: envelope.task,
|
|
183
|
+
prelude: delegatedPrelude,
|
|
184
|
+
binding: getBinding(deps.db, session.id, recipient),
|
|
185
|
+
model: recipientSettings.model,
|
|
186
|
+
effort: recipientEffort.value,
|
|
187
|
+
permission: recipientSettings.permission,
|
|
188
|
+
harness: recipientSettings.harness,
|
|
189
|
+
bin: recipientSettings.bin,
|
|
190
|
+
style: recipientSettings.style,
|
|
191
|
+
delegation: deps.cfg.delegation.enabled,
|
|
192
|
+
}),
|
|
193
|
+
turnId,
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// One attempt at one agent: run the turn, and — exactly as before failover existed — rebind
|
|
198
|
+
// a stale foreign session once and retry, never more. This is unchanged by the chain walk;
|
|
199
|
+
// it just now runs once per agent the chain visits instead of once per `send()` call.
|
|
200
|
+
async function runOnce(
|
|
201
|
+
current: AgentId,
|
|
202
|
+
currentAdapter: Adapter,
|
|
203
|
+
ctxBuild: () => TurnContext,
|
|
204
|
+
parentTurnId: string | null,
|
|
205
|
+
): Promise<TurnResult> {
|
|
206
|
+
const resumedId = getBinding(deps.db, session.id, current)?.foreignId ?? null
|
|
207
|
+
const wasResuming = resumedId != null
|
|
208
|
+
const first = await runTurn(
|
|
209
|
+
{ db: deps.db, adapter: currentAdapter },
|
|
210
|
+
{ ...ctxBuild(), lease },
|
|
211
|
+
{ ...opts, timeoutSec, parentTurnId },
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
// "produced nothing" has to mean nothing at all, not merely no text: a turn that ran tool
|
|
215
|
+
// calls edited files and plainly reached a live session, even if it never spoke.
|
|
216
|
+
const producedNothing = first.final.trim() === '' && !first.events.some((e) => e.t === 'tool')
|
|
217
|
+
// Only a crash can be a dead session. An auth failure phrased as "session not found" would
|
|
218
|
+
// otherwise be rebound instead of surfaced, discarding a live session and then failing again
|
|
219
|
+
// identically; a rate limit, a timeout and an interruption say nothing about the session at
|
|
220
|
+
// all. The kinds exist so that failures can be told apart — this is where it matters.
|
|
221
|
+
const recoverable = first.error?.kind === 'crash' || first.error?.kind === 'unknown'
|
|
222
|
+
const stale = first.error != null && recoverable && STALE.test(first.error.message) && producedNothing
|
|
223
|
+
if (!stale || !wasResuming) return first
|
|
224
|
+
|
|
225
|
+
// said out loud: a silent rebind looks like continuity and is not
|
|
226
|
+
console.error(`konvoy: ${current} could not load session ${oneLine(resumedId ?? '', 60)} — starting a new one`)
|
|
227
|
+
clearForeignId(deps.db, session.id, current)
|
|
228
|
+
return runTurn(
|
|
229
|
+
{ db: deps.db, adapter: currentAdapter },
|
|
230
|
+
{ ...ctxBuild(), lease },
|
|
231
|
+
{ ...opts, timeoutSec, parentTurnId },
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function withLock(): Promise<TurnResult> {
|
|
236
|
+
const chain = chainFrom(deps.cfg.failover.chain, agent)
|
|
237
|
+
const upstreamRetries = deps.cfg.failover.upstreamRetries
|
|
238
|
+
let firstTurnId: string | null = null
|
|
239
|
+
let result: TurnResult | undefined
|
|
240
|
+
|
|
241
|
+
// Built once for the whole send, not per chain member: recomputing would spend git calls
|
|
242
|
+
// and, worse, let the handover shift between attempts at the same question.
|
|
243
|
+
facts = formatFacts(await collectFacts(realFactsDeps(), deps.db, session))
|
|
244
|
+
const prelude = buildPrelude(deps.db, session, facts, { recent: RECENT_TURNS })
|
|
245
|
+
|
|
246
|
+
let blocked: { agent: AgentId; kind: string; message: string } | null = null
|
|
247
|
+
for (let i = 0; i < chain.length; i++) {
|
|
248
|
+
const current = chain[i]!
|
|
249
|
+
const isHead = i === 0
|
|
250
|
+
|
|
251
|
+
let currentSettings: AgentSettings
|
|
252
|
+
let currentAdapter: Adapter
|
|
253
|
+
let currentDetection: Detection
|
|
254
|
+
if (isHead) {
|
|
255
|
+
currentSettings = settings
|
|
256
|
+
currentAdapter = adapter
|
|
257
|
+
currentDetection = detection
|
|
258
|
+
} else {
|
|
259
|
+
currentSettings = resolveAgent(deps.cfg, current)
|
|
260
|
+
currentAdapter = adapterFor(current)
|
|
261
|
+
currentDetection = currentSettings.enabled
|
|
262
|
+
? await detectFor(current, { model: currentSettings.model, bin: currentSettings.bin })
|
|
263
|
+
: { agent: current, installed: false, version: null }
|
|
264
|
+
}
|
|
265
|
+
// A chain member the user hasn't actually set up can't take the handoff — skip it
|
|
266
|
+
// rather than aborting the whole chain, since a later member might still work. Say so:
|
|
267
|
+
// a three-agent chain that quietly becomes a two-agent chain is the user not being told.
|
|
268
|
+
if (!currentSettings.enabled || !currentDetection.installed) {
|
|
269
|
+
if (!isHead) {
|
|
270
|
+
const why = !currentSettings.enabled ? 'disabled in config' : 'not installed'
|
|
271
|
+
console.error(`konvoy: skipping ${current} in the failover chain — ${why}`)
|
|
272
|
+
}
|
|
273
|
+
if (result) continue
|
|
274
|
+
break
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// The move is announced here rather than where the block was detected, because only here
|
|
278
|
+
// is the successor known to be the one that actually runs. Naming chain[i + 1] earlier
|
|
279
|
+
// said "moving to claude" and then ran kiro when claude turned out to be unusable.
|
|
280
|
+
if (blocked) {
|
|
281
|
+
console.error(
|
|
282
|
+
`konvoy: ${blocked.agent} is blocked (${blocked.kind}) — "${oneLine(blocked.message)}" — ${current} is taking over`,
|
|
283
|
+
)
|
|
284
|
+
blocked = null
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const currentEffort = clampEffort(currentSettings.effort, currentDetection.efforts)
|
|
288
|
+
const ctxBuild = (): TurnContext => ({
|
|
289
|
+
sessionId: session.id,
|
|
290
|
+
slug: session.slug,
|
|
291
|
+
cwd: session.cwd,
|
|
292
|
+
sessionDir: sessionDir(session.cwd, session.slug),
|
|
293
|
+
prompt,
|
|
294
|
+
prelude,
|
|
295
|
+
binding: getBinding(deps.db, session.id, current),
|
|
296
|
+
model: currentSettings.model,
|
|
297
|
+
effort: currentEffort.value,
|
|
298
|
+
permission: currentSettings.permission,
|
|
299
|
+
harness: currentSettings.harness,
|
|
300
|
+
bin: currentSettings.bin,
|
|
301
|
+
style: currentSettings.style,
|
|
302
|
+
delegation: deps.cfg.delegation.enabled,
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
let retries = 0
|
|
306
|
+
let r: TurnResult
|
|
307
|
+
for (;;) {
|
|
308
|
+
r = await runOnce(current, currentAdapter, ctxBuild, firstTurnId)
|
|
309
|
+
if (firstTurnId === null) firstTurnId = lastTurnId(deps.db, session.id)
|
|
310
|
+
// upstream is transient and usually returns, so it is worth retrying on the same
|
|
311
|
+
// agent — with backoff, since a hammered upstream is the last thing to hammer again.
|
|
312
|
+
if (r.error?.kind === 'upstream' && retries < upstreamRetries) {
|
|
313
|
+
retries++
|
|
314
|
+
await Bun.sleep((deps.upstreamBackoffMs ?? UPSTREAM_BACKOFF_MS) * retries)
|
|
315
|
+
continue
|
|
316
|
+
}
|
|
317
|
+
break
|
|
318
|
+
}
|
|
319
|
+
result = r
|
|
320
|
+
|
|
321
|
+
const kind = result.error?.kind
|
|
322
|
+
// rate and auth switch at once — a rate window is hours and an auth failure needs a
|
|
323
|
+
// human, so retrying either is pointless. upstream only reaches here once its retries
|
|
324
|
+
// are spent. crash, timeout and interrupted never switch: the fault travels with the
|
|
325
|
+
// agent, not with the CLI running it, so a second agent would just fail the same way.
|
|
326
|
+
const movable = kind === 'rate' || kind === 'auth' || kind === 'upstream'
|
|
327
|
+
if (!movable) return result
|
|
328
|
+
|
|
329
|
+
blocked = { agent: current, kind: kind!, message: result.error!.message }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return result!
|
|
333
|
+
}
|
|
334
|
+
}
|
package/src/core/turn.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import type { KonvoyEvent, TurnContext } from '../types'
|
|
3
|
+
import type { Adapter } from '../adapters/types'
|
|
4
|
+
import { bumpBinding, recordEvent, recordTurn, upsertBinding } from '../store/queries'
|
|
5
|
+
import { onExit, track, untrack, DEFAULT_KILL_GRACE_MS, escalateKill } from './children'
|
|
6
|
+
|
|
7
|
+
export interface TurnResult {
|
|
8
|
+
final: string
|
|
9
|
+
foreignId: string | null
|
|
10
|
+
costUsd: number
|
|
11
|
+
credits: number
|
|
12
|
+
inputTokens: number
|
|
13
|
+
outputTokens: number
|
|
14
|
+
exitCode: number
|
|
15
|
+
error: { message: string; kind: string } | null
|
|
16
|
+
events: KonvoyEvent[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TurnDeps {
|
|
20
|
+
db: Database
|
|
21
|
+
adapter: Adapter
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TurnOptions {
|
|
25
|
+
timeoutSec?: number
|
|
26
|
+
onEvent?: (event: KonvoyEvent) => void
|
|
27
|
+
/** grace period after the timeout's SIGTERM before konvoy escalates to SIGKILL */
|
|
28
|
+
killGraceMs?: number
|
|
29
|
+
drainGraceMs?: number
|
|
30
|
+
/** the first blocked turn this one replaces, when a failover chain moved to a new agent */
|
|
31
|
+
parentTurnId?: string | null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// how long a pipe may stay open after the child is gone before the read is cut off
|
|
35
|
+
const DEFAULT_DRAIN_GRACE_MS = 500
|
|
36
|
+
|
|
37
|
+
// `until` ends the read on the caller's clock: a descendant that inherited the pipe would
|
|
38
|
+
// otherwise keep it open, and with it the turn, for as long as it lives
|
|
39
|
+
export async function drain(stream: ReadableStream<Uint8Array>, cap: number, until?: Promise<unknown>): Promise<string> {
|
|
40
|
+
const decoder = new TextDecoder()
|
|
41
|
+
const reader = stream.getReader()
|
|
42
|
+
void until?.then(() => reader.cancel().catch(() => undefined))
|
|
43
|
+
let text = ''
|
|
44
|
+
while (true) {
|
|
45
|
+
const { value, done } = await reader.read()
|
|
46
|
+
if (done) break
|
|
47
|
+
text += decoder.decode(value, { stream: true })
|
|
48
|
+
if (text.length > cap) {
|
|
49
|
+
text = text.slice(-cap)
|
|
50
|
+
const lead = text.charCodeAt(0)
|
|
51
|
+
if (lead >= 0xdc00 && lead <= 0xdfff) text = text.slice(1)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return text
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function runTurn(deps: TurnDeps, ctx: TurnContext, opts: TurnOptions = {}): Promise<TurnResult> {
|
|
58
|
+
const { db, adapter } = deps
|
|
59
|
+
const timeoutMs = (opts.timeoutSec ?? 900) * 1000
|
|
60
|
+
|
|
61
|
+
await adapter.prepare?.(ctx)
|
|
62
|
+
const plan = adapter.turn(ctx)
|
|
63
|
+
|
|
64
|
+
const result: TurnResult = {
|
|
65
|
+
final: '',
|
|
66
|
+
foreignId: ctx.binding?.foreignId ?? null,
|
|
67
|
+
costUsd: 0,
|
|
68
|
+
credits: 0,
|
|
69
|
+
inputTokens: 0,
|
|
70
|
+
outputTokens: 0,
|
|
71
|
+
exitCode: 0,
|
|
72
|
+
error: null,
|
|
73
|
+
events: [],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let accumulated = ''
|
|
77
|
+
let sawDone = false
|
|
78
|
+
let seq = 0
|
|
79
|
+
|
|
80
|
+
const parseLine = (line: string): KonvoyEvent[] => {
|
|
81
|
+
try {
|
|
82
|
+
return adapter.parse(line)
|
|
83
|
+
} catch (error) {
|
|
84
|
+
return [{ t: 'error', message: `${adapter.id} parser failed: ${String(error)}`, kind: 'crash' }]
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const turnId = recordTurn(db, {
|
|
89
|
+
sessionId: ctx.sessionId,
|
|
90
|
+
agent: adapter.id,
|
|
91
|
+
prompt: ctx.prompt,
|
|
92
|
+
final: '',
|
|
93
|
+
costUsd: 0,
|
|
94
|
+
exitCode: -1,
|
|
95
|
+
kind: ctx.kind ?? null,
|
|
96
|
+
parentTurnId: opts.parentTurnId ?? null,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
let finished = false
|
|
100
|
+
const finish = (): void => {
|
|
101
|
+
if (finished) return
|
|
102
|
+
finished = true
|
|
103
|
+
db.query(
|
|
104
|
+
`UPDATE turn SET final = $final, cost_usd = $cost, credits = $credits, input_tokens = $inTok,
|
|
105
|
+
output_tokens = $outTok, exit_code = $exit, error = $error, error_kind = $errorKind,
|
|
106
|
+
ended_at = $ended, model = $model WHERE id = $id`,
|
|
107
|
+
).run({
|
|
108
|
+
id: turnId,
|
|
109
|
+
final: result.final,
|
|
110
|
+
cost: result.costUsd,
|
|
111
|
+
credits: result.credits,
|
|
112
|
+
inTok: result.inputTokens,
|
|
113
|
+
outTok: result.outputTokens,
|
|
114
|
+
exit: result.exitCode,
|
|
115
|
+
error: result.error?.message ?? null,
|
|
116
|
+
errorKind: result.error?.kind ?? null,
|
|
117
|
+
ended: Date.now(),
|
|
118
|
+
model: ctx.model ?? null,
|
|
119
|
+
})
|
|
120
|
+
upsertBinding(db, {
|
|
121
|
+
sessionId: ctx.sessionId,
|
|
122
|
+
agent: adapter.id,
|
|
123
|
+
foreignId: result.foreignId,
|
|
124
|
+
effort: ctx.effort,
|
|
125
|
+
permission: ctx.permission,
|
|
126
|
+
model: ctx.model ?? null,
|
|
127
|
+
status: result.error?.kind === 'auth' ? 'auth_required' : undefined,
|
|
128
|
+
})
|
|
129
|
+
bumpBinding(db, ctx.sessionId, adapter.id, result.costUsd, result.credits)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const spawnedAt = Date.now()
|
|
133
|
+
let proc: ReturnType<typeof Bun.spawn>
|
|
134
|
+
try {
|
|
135
|
+
proc = Bun.spawn(plan.cmd, {
|
|
136
|
+
cwd: plan.cwd ?? ctx.cwd,
|
|
137
|
+
env: { ...(process.env as Record<string, string>), ...(plan.env ?? {}), ...(ctx.lease ? { KONVOY_LEASE: ctx.lease } : {}) },
|
|
138
|
+
stdin: plan.stdin ? new Response(plan.stdin) : 'ignore',
|
|
139
|
+
stdout: 'pipe',
|
|
140
|
+
stderr: 'pipe',
|
|
141
|
+
timeout: timeoutMs,
|
|
142
|
+
killSignal: 'SIGTERM',
|
|
143
|
+
})
|
|
144
|
+
} catch (error) {
|
|
145
|
+
// the binary exists but cannot start — no execute bit, a bad interpreter: detection cannot
|
|
146
|
+
// see it, and the turn row recorded above is what keeps the failure from leaving no trace
|
|
147
|
+
result.exitCode = 127
|
|
148
|
+
result.error = { message: `could not start ${plan.cmd[0]}: ${error instanceof Error ? error.message : String(error)}`, kind: 'crash' }
|
|
149
|
+
finish()
|
|
150
|
+
return result
|
|
151
|
+
}
|
|
152
|
+
track(proc)
|
|
153
|
+
|
|
154
|
+
const cancelEscalation = escalateKill(proc, timeoutMs + (opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS))
|
|
155
|
+
|
|
156
|
+
// once the child has exited, whoever still holds its pipes is something it left behind;
|
|
157
|
+
// it gets this long to flush, then both reads end
|
|
158
|
+
const afterExit = proc.exited.then(() => Bun.sleep(opts.drainGraceMs ?? DEFAULT_DRAIN_GRACE_MS))
|
|
159
|
+
const stderrText = drain(proc.stderr as ReadableStream<Uint8Array>, 64 * 1024, afterExit)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
// exit_code -1 means konvoy itself died before it could record the turn. Every ordinary
|
|
163
|
+
// ending, including an interruption, replaces it — so a surviving -1 is a real signal, not
|
|
164
|
+
// a default. Failure is decided by the error field, not by the exit code alone: an interrupted turn
|
|
165
|
+
// carries 130 or 143 and a message, and a codex turn can exit 0 with an informational error.
|
|
166
|
+
|
|
167
|
+
const emit = (event: KonvoyEvent): void => {
|
|
168
|
+
result.events.push(event)
|
|
169
|
+
recordEvent(db, turnId, seq++, event.t, event)
|
|
170
|
+
opts.onEvent?.(event)
|
|
171
|
+
switch (event.t) {
|
|
172
|
+
case 'session':
|
|
173
|
+
result.foreignId = event.foreignId
|
|
174
|
+
break
|
|
175
|
+
case 'text':
|
|
176
|
+
accumulated += event.text
|
|
177
|
+
break
|
|
178
|
+
case 'usage':
|
|
179
|
+
result.credits += event.credits ?? 0
|
|
180
|
+
result.costUsd += event.costUsd ?? 0
|
|
181
|
+
result.inputTokens += event.inputTokens ?? 0
|
|
182
|
+
result.outputTokens += event.outputTokens ?? 0
|
|
183
|
+
break
|
|
184
|
+
case 'error':
|
|
185
|
+
result.error = { message: event.message, kind: event.kind }
|
|
186
|
+
break
|
|
187
|
+
case 'done':
|
|
188
|
+
sawDone = true
|
|
189
|
+
result.final = event.final
|
|
190
|
+
break
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
// every path below must reach finish(): an onEvent callback that throws, a parser crash a
|
|
196
|
+
// wrapper missed, or a signal — otherwise the turn row stays at its INSERT placeholder and
|
|
197
|
+
// usage counts it as a free turn.
|
|
198
|
+
const releaseExitHandler = onExit((signal) => {
|
|
199
|
+
if (!result.error) result.error = { message: `konvoy was interrupted by ${signal}`, kind: 'interrupted' }
|
|
200
|
+
if (result.exitCode === 0) result.exitCode = signal === 'SIGINT' ? 130 : 143
|
|
201
|
+
finish()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
const decoder = new TextDecoder()
|
|
205
|
+
let buffer = ''
|
|
206
|
+
try {
|
|
207
|
+
try {
|
|
208
|
+
const stdout = (proc.stdout as ReadableStream<Uint8Array>).getReader()
|
|
209
|
+
void afterExit.then(() => stdout.cancel().catch(() => undefined))
|
|
210
|
+
while (true) {
|
|
211
|
+
const { value, done } = await stdout.read()
|
|
212
|
+
if (done) break
|
|
213
|
+
buffer += decoder.decode(value, { stream: true })
|
|
214
|
+
const lines = buffer.split('\n')
|
|
215
|
+
buffer = lines.pop() ?? ''
|
|
216
|
+
for (const line of lines) for (const event of parseLine(line)) emit(event)
|
|
217
|
+
}
|
|
218
|
+
if (buffer.trim()) for (const event of parseLine(buffer)) emit(event)
|
|
219
|
+
} finally {
|
|
220
|
+
if (proc.exitCode === null) proc.kill()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
result.exitCode = await proc.exited
|
|
224
|
+
|
|
225
|
+
if (!sawDone) result.final = accumulated
|
|
226
|
+
|
|
227
|
+
// an error event that carries no words (claude's is_error result on a dead session id)
|
|
228
|
+
// must not stand in for stderr, which is where that CLI puts the reason
|
|
229
|
+
if (result.exitCode !== 0 && !result.error?.message.trim()) {
|
|
230
|
+
const stderr = await stderrText
|
|
231
|
+
const timedOut = Date.now() - spawnedAt >= timeoutMs
|
|
232
|
+
result.error = {
|
|
233
|
+
message: timedOut ? `turn timed out after ${opts.timeoutSec ?? 900}s` : stderr.trim() || `exit ${result.exitCode}`,
|
|
234
|
+
kind: timedOut ? 'timeout' : 'crash',
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// The exit code says whether the turn produced its output; the error says whether the
|
|
239
|
+
// agent is now blocked. auth and rate never mean "just informational" — the agent cannot
|
|
240
|
+
// work until something changes — so they survive even a turn that answered and exited 0.
|
|
241
|
+
// crash and unknown keep the old behaviour: discarded once there was any output at all.
|
|
242
|
+
// an error that stayed wordless through the stream and stderr — say so, rather than show
|
|
243
|
+
// the user an empty quote
|
|
244
|
+
if (result.error && !result.error.message.trim()) {
|
|
245
|
+
result.error.message = `${adapter.id} exited ${result.exitCode} and reported an error without a message`
|
|
246
|
+
}
|
|
247
|
+
const failed = result.exitCode !== 0 || (result.error !== null && result.final.trim() === '')
|
|
248
|
+
const blocking = result.error?.kind === 'auth' || result.error?.kind === 'rate'
|
|
249
|
+
if (!failed && !blocking) result.error = null
|
|
250
|
+
} catch (error) {
|
|
251
|
+
result.error = result.error ?? { message: String(error), kind: 'crash' }
|
|
252
|
+
if (result.exitCode === 0) {
|
|
253
|
+
result.exitCode = proc.exitCode ?? (await proc.exited.catch(() => -1))
|
|
254
|
+
}
|
|
255
|
+
} finally {
|
|
256
|
+
cancelEscalation()
|
|
257
|
+
releaseExitHandler()
|
|
258
|
+
untrack(proc)
|
|
259
|
+
finish()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return result
|
|
263
|
+
}
|