@jc_stack/ez-agents 0.1.0-beta.48 → 0.1.0-beta.50
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/AGENTS.md +8 -6
- package/CHANGELOG.md +24 -0
- package/README.md +9 -5
- package/bin/ez +16 -7
- package/docker/broker-update-smoke.mjs +16 -2
- package/docker/upgrade-smoke.mjs +1 -1
- package/docs/architecture/ai-selection.md +9 -1
- package/docs/docker-runtime.md +46 -2
- package/docs/plugin-guide.md +3 -3
- package/docs/plugins.md +13 -10
- package/docs/scheduling.md +84 -6
- package/docs/setup.md +2 -2
- package/docs/standalone-cli.md +4 -4
- package/docs/upgrades.md +13 -23
- package/package.json +1 -1
- package/src/agent-guidance.ts +2 -2
- package/src/ai-cli.ts +28 -15
- package/src/ai.ts +90 -14
- package/src/application-channel.ts +18 -14
- package/src/auth-profile.ts +53 -0
- package/src/codex-session.ts +14 -3
- package/src/control-state.ts +28 -21
- package/src/delivery-socket.ts +2 -0
- package/src/executor.ts +38 -26
- package/src/host-executor-client.ts +19 -7
- package/src/host-executor.ts +35 -10
- package/src/index.ts +320 -122
- package/src/menu.ts +8 -7
- package/src/plugin-broker.mjs +4 -11
- package/src/plugins/manager.mjs +70 -48
- package/src/runs.ts +74 -5
- package/src/schedule-cli.ts +39 -7
- package/src/scheduled-tasks.ts +42 -8
- package/src/scheduler.ts +9 -4
- package/src/session-close.ts +17 -0
- package/src/setup-fallback.ts +159 -0
- package/src/task-executor.ts +3 -2
- package/src/tools-line.d.mts +1 -0
- package/src/tools-line.mjs +3 -0
- package/src/updates/binding.mjs +4 -10
- package/src/updates/control.mjs +3 -2
- package/src/updates/runtime.mjs +79 -41
- package/src/updates/supervisor.mjs +27 -6
- package/src/workspace.ts +12 -1
- package/test/ai.test.ts +48 -2
- package/test/application-channel.test.ts +24 -0
- package/test/application-controls.test.ts +4 -1
- package/test/auth-profiles.test.ts +187 -0
- package/test/intake-relay.test.ts +63 -2
- package/test/model-policy.test.ts +1 -1
- package/test/plugin-broker.test.mjs +51 -1
- package/test/plugin-manager.test.mjs +76 -38
- package/test/run-handoff.test.ts +132 -0
- package/test/scheduled-tasks.test.ts +24 -2
- package/test/session-close.test.ts +17 -0
- package/test/setup-fallback.test.ts +423 -0
- package/test/updates.test.mjs +77 -15
- package/test/workspace.test.ts +6 -0
package/src/index.ts
CHANGED
|
@@ -9,9 +9,9 @@ import { packageVersion } from './version.js'
|
|
|
9
9
|
import {authorizeDeliveryContext} from './delivery-context.mjs'
|
|
10
10
|
import { dispatchChannel } from './channel-backend.js'
|
|
11
11
|
import { randomUUID } from 'node:crypto'
|
|
12
|
-
import { stat } from 'node:fs/promises'
|
|
12
|
+
import { mkdir, stat, writeFile } from 'node:fs/promises'
|
|
13
13
|
import { Scheduler } from './scheduler.js'
|
|
14
|
-
import { Scripts } from './scripts.js'
|
|
14
|
+
import { Scripts, DEFAULT_SCRIPT_TIMEOUT_SECONDS } from './scripts.js'
|
|
15
15
|
import { redactFailure } from './failure.js'
|
|
16
16
|
import { ownedScheduledTasks, scheduledTaskDetailText, scheduledTasksText } from './scheduled-tasks.js'
|
|
17
17
|
import { EventSources, eventRunId, batchReady, type SourceEvent } from './event-sources.js'
|
|
@@ -25,11 +25,12 @@ import { isOwner, ownsRun } from './identity.js'
|
|
|
25
25
|
import type { Update } from 'grammy/types'
|
|
26
26
|
import { InboxStore, type IncomingItem } from './inbox.js'
|
|
27
27
|
import { loadConfig, type Config } from './config.js'
|
|
28
|
-
import { ControlStore, telegramOwner, ownerId, ownerEpoch } from './control-state.js'
|
|
28
|
+
import { ControlStore, telegramOwner, ownerId, ownerEpoch, type SessionState } from './control-state.js'
|
|
29
29
|
import { ApprovalStore } from './approval.js'
|
|
30
|
-
import { startExecutorJob, terminateJob, opencodeDataHome, claudeConfigHome } from './executor.js'
|
|
30
|
+
import { CliUnavailableError, startExecutorJob, attachHostJob, terminateJob, opencodeDataHome, claudeConfigHome } from './executor.js'
|
|
31
|
+
import { attemptFailure, continuationLine, nextSetup, quotaScope, setupCandidates, startupObserver, unavailableSummary, SetupsUnavailableError, type SetupAttempt } from './setup-fallback.js'
|
|
31
32
|
import { assertScheduledModel } from './model-policy.js'
|
|
32
|
-
import { RunStore, type RunRecord } from './runs.js'
|
|
33
|
+
import { RunStore, saveRunHandoff, takeRunHandoff, type RunRecord } from './runs.js'
|
|
33
34
|
import { splitTelegramText } from './reply.js'
|
|
34
35
|
import { markdownToTelegramHtml, escapeHtml } from './format.js'
|
|
35
36
|
import { sanitizeFileName, stageChatAttachment, workspaceFile } from './files.js'
|
|
@@ -37,8 +38,9 @@ import { transcribeAudio, synthesizeSpeech } from './audio.js'
|
|
|
37
38
|
import { normalizeReactionEmoji } from './reaction.js'
|
|
38
39
|
import { downloadTelegramFile } from './read-request.js'
|
|
39
40
|
import { createConversationMenu } from './conversation-menu.js'
|
|
41
|
+
import { queueSessionClose } from './session-close.js'
|
|
40
42
|
import { createAiMenu, mainCommands, mainKeyboard } from './menu.js'
|
|
41
|
-
import { chatPreset, initialPreset, persistedPreset, presetLabel, statusPreset } from './ai.js'
|
|
43
|
+
import { chatPreset, initialPreset, persistedPreset, presetLabel, statusPreset, type AiPreset } from './ai.js'
|
|
42
44
|
import { discoverDefaults } from './client-defaults.js'
|
|
43
45
|
import { runPromptSuffix } from './prompt-suffix.js'
|
|
44
46
|
import { softwareStatus } from './software-status.js'
|
|
@@ -76,13 +78,27 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
76
78
|
const codexHome = join(config.controlDir, 'cli', 'codex')
|
|
77
79
|
const conversationMenu = createConversationMenu(control)
|
|
78
80
|
const aiMenu = createAiMenu(control, config.executorCli, undefined, config.workspace, codexHome,
|
|
79
|
-
undefined, opencodeDataHome(config.controlDir))
|
|
81
|
+
undefined, opencodeDataHome(config.controlDir), (session) => closeSession(session))
|
|
80
82
|
const durableWorkerChoice = () => control.captureChoice(aiMenu.initial)
|
|
81
83
|
const binDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin')
|
|
84
|
+
// Queue the replaced chat session's silent close turn (session-close.ts).
|
|
85
|
+
// A busy relay starts it after the active run, ahead of newer chat input.
|
|
86
|
+
const closeSession = async (session: SessionState | null): Promise<void> => {
|
|
87
|
+
if (config.channelBackendUrl) return
|
|
88
|
+
// The replacement is already saved; a failed close must not fail that reply.
|
|
89
|
+
try {
|
|
90
|
+
const run = await queueSessionClose(runs, (await control.status()).owner, session)
|
|
91
|
+
if (run) void startJob(run).catch((error) => console.error('Conversation close failed to start', safeError(error)))
|
|
92
|
+
} catch (error) { console.error('Conversation close failed to queue', safeError(error)) }
|
|
93
|
+
}
|
|
82
94
|
|
|
83
95
|
let activeTypingTimer: ReturnType<typeof setInterval> | null = null
|
|
84
96
|
let activeBackend = false
|
|
85
97
|
const ownerStopped = new WeakSet<ChildProcess>()
|
|
98
|
+
// Host-transport executors outlive this relay; a graceful stop hands them off.
|
|
99
|
+
const hostTransport = process.env.EZ_EXECUTOR_TRANSPORT === 'host' && !config.channelBackendUrl
|
|
100
|
+
const childRuns = new WeakMap<ChildProcess, string>()
|
|
101
|
+
const handedOff = new WeakSet<ChildProcess>()
|
|
86
102
|
let activeChild: ChildProcess | null = null
|
|
87
103
|
let runtimeStarted = false
|
|
88
104
|
let shuttingDown = false
|
|
@@ -113,6 +129,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
113
129
|
format: 'wav',
|
|
114
130
|
}) : undefined,
|
|
115
131
|
wake: () => { void drainSources().catch(error => console.error('Application queue unavailable', safeError(error))) },
|
|
132
|
+
closeSession: (session) => closeSession(session),
|
|
116
133
|
createTelegramPairing: async (bindingId, owner) => {
|
|
117
134
|
const username = bot?.botInfo?.username
|
|
118
135
|
if (!username || !/^[A-Za-z][A-Za-z0-9_]{4,31}$/.test(username))
|
|
@@ -177,7 +194,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
177
194
|
// stat uses the effective UID; access uses the relay's isolated real UID.
|
|
178
195
|
if (await stat(join(config.controlDir,'upgrade-pause.json')).then(()=>true,e=>{if(e.code==='ENOENT')return false;throw e})) return
|
|
179
196
|
if ((await runs.get(run.id))?.status !== 'queued') return
|
|
180
|
-
if (!telegramEnabled && !run.application && !run.delivery && !run.scheduled && !run.external) return
|
|
197
|
+
if (!telegramEnabled && !run.application && !run.delivery && !run.scheduled && !run.external && !run.sessionClose) return
|
|
181
198
|
if (!run.scheduled && await runs.running(false)) return
|
|
182
199
|
const owner = (await control.status()).owner
|
|
183
200
|
if (!owner || !ownsRun(owner, run)) {
|
|
@@ -236,6 +253,26 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
236
253
|
await releaseExternal(run)
|
|
237
254
|
return
|
|
238
255
|
}
|
|
256
|
+
// Shared by a first launch and a fallback launch that cannot start.
|
|
257
|
+
const startFailed = async (error: unknown) => {
|
|
258
|
+
if (!run.scheduled && activeTypingTimer) {
|
|
259
|
+
clearInterval(activeTypingTimer)
|
|
260
|
+
activeTypingTimer = null
|
|
261
|
+
}
|
|
262
|
+
if (run.scheduled) background.delete(run.id)
|
|
263
|
+
const unavailable = error instanceof SetupsUnavailableError
|
|
264
|
+
await runs.patch(run.id, { status: 'failed', failureReason: unavailable ? 'setups-unavailable' : 'executor-start', failure: await failureEvidence(config.controlDir, safeError(error)), endedAt: new Date().toISOString() })
|
|
265
|
+
await releaseExternal(run)
|
|
266
|
+
console.error('run start failed', run.id, safeError(error))
|
|
267
|
+
if (bot && !run.application && !run.delivery && run.chatId !== undefined)
|
|
268
|
+
await sendChat(run.chatId, unavailable ? `Run ${run.id} could not start. ${safeError(error)}` : `Run ${run.id} failed to start. Check the local relay log.`)
|
|
269
|
+
setImmediate(() => {
|
|
270
|
+
void runs
|
|
271
|
+
.nextQueued()
|
|
272
|
+
.then((next) => next && startJob(next))
|
|
273
|
+
.catch(console.error)
|
|
274
|
+
})
|
|
275
|
+
}
|
|
239
276
|
try {
|
|
240
277
|
const launchStarted = performance.now()
|
|
241
278
|
const started = await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
|
|
@@ -251,121 +288,198 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
251
288
|
: await control.executionSession(started.execution!)
|
|
252
289
|
const selected = script ? initialPreset('codex') : run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : started.execution!.preset
|
|
253
290
|
if (run.scheduled && !script) assertScheduledModel(selected.model)
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
nativeSession: Boolean(run.scheduled),
|
|
276
|
-
onSession: run.external || run.taskId ? undefined : async (id) => { await runs.patch(run.id,{nativeSessionId:id}); if (!run.scheduled) await control.saveNativeSession(session.sessionId,id) },
|
|
277
|
-
})
|
|
278
|
-
const executionStarted = performance.now()
|
|
279
|
-
// Attach before disk writes: a fast child can close while PID persistence
|
|
280
|
-
// is pending, and Node drains its remaining pipes during process close.
|
|
281
|
-
let failureReason = 'executor-exit', errorTail = '', interrupted = false, outputTail = '', timedOut = false
|
|
282
|
-
// Script output is bounded diagnostics for run inspection, never chat.
|
|
283
|
-
if (script) child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { outputTail = (outputTail + chunk).slice(-16384) })
|
|
284
|
-
const scriptTimer = script ? setTimeout(() => { timedOut = true; terminateJob(child) }, script.registration.timeoutSeconds * 1000) : undefined
|
|
285
|
-
child.once('close', () => clearTimeout(scriptTimer))
|
|
286
|
-
child.stderr?.setEncoding('utf8').on('data', (chunk: string) => {
|
|
287
|
-
errorTail = (errorTail + chunk).slice(-16384)
|
|
288
|
-
if (script) outputTail = (outputTail + chunk).slice(-16384)
|
|
289
|
-
if (chunk.includes('Host CLI executor is offline')) failureReason = 'host-executor-offline'
|
|
290
|
-
if (chunk.includes('Host executor client interrupted by')) {
|
|
291
|
-
failureReason = 'host-executor-transport-interrupted'
|
|
292
|
-
interrupted = true
|
|
291
|
+
// A scheduled occurrence may name ordered fallback setups. They share this
|
|
292
|
+
// run, workspace and concurrency slot; each is tried at most once.
|
|
293
|
+
const candidates = run.scheduled && !script ? setupCandidates(started.execution!) : [selected]
|
|
294
|
+
const ordered = candidates.length > 1
|
|
295
|
+
let attempts: SetupAttempt[] = []
|
|
296
|
+
const settle = (change: Partial<SetupAttempt>) => {
|
|
297
|
+
attempts = [...attempts.slice(0, -1), { ...attempts[attempts.length - 1], ...change, endedAt: new Date().toISOString() }]
|
|
298
|
+
}
|
|
299
|
+
// Advance only after an eligible failure (see attemptFailure) of a current,
|
|
300
|
+
// uncancelled occurrence. Setups in an exhausted scope are skipped and recorded.
|
|
301
|
+
const advance = async (): Promise<AiPreset | undefined> => {
|
|
302
|
+
let preset: AiPreset | undefined
|
|
303
|
+
const current = !shuttingDown && !await scheduler.cancelled(run.id) ? (await control.status()).owner : undefined
|
|
304
|
+
if (current && ownsRun(current, run) && await scheduler.current(run, current)) {
|
|
305
|
+
const next = nextSetup(candidates, attempts)
|
|
306
|
+
attempts = [...attempts, ...next.skipped]
|
|
307
|
+
if (!next.preset) {
|
|
308
|
+
await runs.patch(run.id, { attempts })
|
|
309
|
+
throw new SetupsUnavailableError(unavailableSummary(attempts))
|
|
310
|
+
}
|
|
311
|
+
preset = next.preset
|
|
293
312
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
313
|
+
await runs.patch(run.id, { attempts })
|
|
314
|
+
return preset
|
|
315
|
+
}
|
|
316
|
+
// Each attempt is a fresh native session: a rejected Claude attempt already
|
|
317
|
+
// claimed its ID, and a session cannot resume under another login's home.
|
|
318
|
+
const launchSetup = async (preset: AiPreset): Promise<{ child: ChildProcess; cleanup: () => Promise<void>; preset: AiPreset }> => {
|
|
319
|
+
for (;;) {
|
|
320
|
+
const sessionId = attempts.length ? randomUUID() : session.nativeSessionId || session.sessionId
|
|
321
|
+
const continuation = continuationLine(started.id, attempts)
|
|
322
|
+
if (ordered) {
|
|
323
|
+
attempts = [...attempts, { preset, quotaScope: quotaScope(preset), outcome: 'running', sessionId, startedAt: new Date().toISOString() }]
|
|
324
|
+
await runs.patch(run.id, { attempts })
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
return { ...await launch(continuation ? [...texts, continuation] : texts, script ? {
|
|
328
|
+
workspace: config.workspace, timeoutMs: 0, repairEnabled: config.repairEnabled, runId: started.id,
|
|
329
|
+
controlDir: config.controlDir, binDir,
|
|
330
|
+
script: { ...script.ref, scheduleId: script.scheduled.id, scheduleRevision: script.scheduled.revision, dueAt: script.scheduled.dueAt },
|
|
331
|
+
} : {
|
|
332
|
+
workspace: config.workspace,
|
|
333
|
+
timeoutMs: config.executorTimeoutMs,
|
|
334
|
+
repairEnabled: config.repairEnabled,
|
|
335
|
+
runId: started.id,
|
|
336
|
+
controlDir: config.controlDir,
|
|
337
|
+
binDir,
|
|
338
|
+
cli: preset.cli,
|
|
339
|
+
model: preset.model,
|
|
340
|
+
effort: preset.effort,
|
|
341
|
+
provider: preset.provider,
|
|
342
|
+
authProfile: preset.authProfile,
|
|
343
|
+
codexAutoCompactTokens: config.codexAutoCompactTokens,
|
|
344
|
+
codexSandbox: !run.taskId && preset.cli === 'codex' ? config.codexSandbox : undefined,
|
|
345
|
+
sessionId,
|
|
346
|
+
isResume: session.hasStarted,
|
|
347
|
+
promptSuffix: runPromptSuffix(started),
|
|
348
|
+
taskRun: Boolean(run.taskId),
|
|
349
|
+
nativeSession: Boolean(run.scheduled),
|
|
350
|
+
onSession: run.external || run.taskId ? undefined : async (id) => { await runs.patch(run.id,{nativeSessionId:id}); if (!run.scheduled) await control.saveNativeSession(session.sessionId,id) },
|
|
351
|
+
}), preset }
|
|
352
|
+
} catch (error) {
|
|
353
|
+
if (ordered && !(error instanceof CliUnavailableError)) {
|
|
354
|
+
settle({ outcome: 'failed', category: 'uncertain' })
|
|
355
|
+
await runs.patch(run.id, { attempts })
|
|
356
|
+
}
|
|
357
|
+
if (!ordered || !(error instanceof CliUnavailableError)) throw error
|
|
358
|
+
settle({ outcome: 'failed', category: 'cli-unavailable' })
|
|
359
|
+
const next = await advance()
|
|
360
|
+
if (!next) throw error
|
|
361
|
+
preset = next
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const watch = async ({ child, cleanup, preset }: Awaited<ReturnType<typeof launchSetup>>): Promise<void> => {
|
|
366
|
+
const executionStarted = performance.now()
|
|
367
|
+
// Attach before disk writes: a fast child can close while PID persistence
|
|
368
|
+
// is pending, and Node drains its remaining pipes during process close.
|
|
369
|
+
let failureReason = 'executor-exit', errorTail = '', interrupted = false, outputTail = '', timedOut = false
|
|
370
|
+
// Script output is bounded diagnostics for run inspection, never chat.
|
|
371
|
+
if (script) child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { outputTail = (outputTail + chunk).slice(-16384) })
|
|
372
|
+
// Structured startup events decide whether a failed setup may advance.
|
|
373
|
+
const startup = ordered ? startupObserver() : undefined
|
|
374
|
+
if (startup) child.stdout?.setEncoding('utf8').on('data', startup.write)
|
|
375
|
+
const scriptTimer = script ? setTimeout(() => { timedOut = true; terminateJob(child) }, script.registration.timeoutSeconds * 1000) : undefined
|
|
376
|
+
child.once('close', () => clearTimeout(scriptTimer))
|
|
377
|
+
child.stderr?.setEncoding('utf8').on('data', (chunk: string) => {
|
|
378
|
+
errorTail = (errorTail + chunk).slice(-16384)
|
|
379
|
+
if (script) outputTail = (outputTail + chunk).slice(-16384)
|
|
380
|
+
if (chunk.includes('Host CLI executor is offline')) failureReason = 'host-executor-offline'
|
|
381
|
+
if (chunk.includes('Host executor client interrupted by')) {
|
|
382
|
+
failureReason = 'host-executor-transport-interrupted'
|
|
383
|
+
interrupted = true
|
|
384
|
+
}
|
|
385
|
+
if (chunk.trim()) console.error('executor stderr', started.id, chunk.trim())
|
|
386
|
+
})
|
|
387
|
+
console.info('run timing', { run_id: run.id, phase: 'launch',
|
|
388
|
+
queue_ms: Math.max(0, Date.parse(started.startedAt!) - Date.parse(run.createdAt)),
|
|
389
|
+
startup_ms: Math.round(executionStarted - launchStarted), resumed: session.hasStarted })
|
|
390
|
+
if (run.scheduled) background.set(run.id,child)
|
|
391
|
+
else activeChild = child
|
|
392
|
+
childRuns.set(child, run.id)
|
|
393
|
+
const finished = new Promise<number | null>((resolve) => {
|
|
394
|
+
if (child.exitCode !== null || child.signalCode !== null) resolve(child.exitCode)
|
|
395
|
+
else child.once('close', resolve)
|
|
396
|
+
})
|
|
397
|
+
await runs.patch(started.id, { pid: child.pid })
|
|
398
|
+
console.info('run started', {
|
|
399
|
+
run_id: started.id,
|
|
400
|
+
pid: child.pid,
|
|
401
|
+
cli: script ? 'script' : preset.cli,
|
|
402
|
+
...(script ? { script: script.ref.id, script_revision: script.ref.revision } : {}),
|
|
403
|
+
...(ordered ? { setup: attempts.length } : {}),
|
|
404
|
+
session: session.sessionId,
|
|
405
|
+
isResume: session.hasStarted,
|
|
406
|
+
})
|
|
314
407
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
408
|
+
if (!run.scheduled && activeTypingTimer) clearInterval(activeTypingTimer)
|
|
409
|
+
if (bot && run.chatId !== undefined && !run.application && !run.external && !run.taskId && !run.scheduled) activeTypingTimer = setInterval(() => {
|
|
410
|
+
if (performance.now() - executionStarted < 30000) void bot.api.sendChatAction(run.chatId!, 'typing').catch(() => {})
|
|
411
|
+
}, 4000)
|
|
319
412
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
413
|
+
const free = () => {
|
|
414
|
+
if (run.scheduled) background.delete(run.id)
|
|
415
|
+
else {
|
|
416
|
+
activeChild = null
|
|
417
|
+
if (activeTypingTimer) clearInterval(activeTypingTimer)
|
|
418
|
+
activeTypingTimer = null
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const finalize = async (code: number | null, cancelled: boolean, failed: boolean) => {
|
|
422
|
+
await runs.patch(started.id, { status: cancelled ? 'cancelled' : failed ? 'failed' : 'completed', endedAt: new Date().toISOString(), exitCode: code,
|
|
423
|
+
...(script ? { output: redactFailure(safeError(outputTail)), ...(timedOut ? { timedOut } : {}) } : {}),
|
|
424
|
+
...(failed ? { failureReason, interrupted, failure: await failureEvidence(config.controlDir, safeError(timedOut ? `Script exceeded its ${script!.registration.timeoutSeconds}s timeout and was terminated` : errorTail || `Executor exited with ${code === null ? 'a signal' : `code ${code}`}`)) } : {}) })
|
|
425
|
+
await releaseExternal(run)
|
|
426
|
+
}
|
|
427
|
+
const completion = finished.then((code) => {
|
|
428
|
+
console.info('run timing', { run_id: run.id, phase: 'execution',
|
|
429
|
+
execution_ms: Math.round(performance.now() - executionStarted), exit_code: code })
|
|
430
|
+
return (async () => {
|
|
431
|
+
let eligible = false
|
|
432
|
+
await withStartLock(async () => {
|
|
433
|
+
try {
|
|
434
|
+
await cleanup()
|
|
435
|
+
// The next relay records a handed-off run's result.
|
|
436
|
+
if (handedOff.has(child)) return
|
|
437
|
+
if (code === 0 && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(session.sessionId)
|
|
438
|
+
const cancelled = !timedOut && (ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id)))
|
|
439
|
+
if (timedOut) failureReason = 'script-timeout'
|
|
440
|
+
const observed = startup?.result()
|
|
441
|
+
const rejected = observed?.rejection && !observed.workBegan ? observed.rejection : undefined
|
|
442
|
+
// A rejection before work is a failure even when the CLI exits 0.
|
|
443
|
+
const failed = !cancelled && (code !== 0 || timedOut || Boolean(rejected))
|
|
444
|
+
if (startup) {
|
|
445
|
+
const { continues, ...failure } = failed ? attemptFailure(observed!) : { continues: false }
|
|
446
|
+
settle({ outcome: cancelled ? 'cancelled' : failed ? 'failed' : 'completed', exitCode: code, ...failure })
|
|
447
|
+
await runs.patch(run.id, { attempts })
|
|
448
|
+
eligible = continues && !interrupted
|
|
449
|
+
}
|
|
450
|
+
if (!eligible) await finalize(code, cancelled, failed)
|
|
451
|
+
} catch (error) {
|
|
452
|
+
eligible = false
|
|
453
|
+
await runs.patch(started.id, { status: ownerStopped.has(child) ? 'cancelled' : 'failed', failureReason: 'session-finalization', failure: await failureEvidence(config.controlDir, safeError(error)), endedAt: new Date().toISOString() })
|
|
454
|
+
console.error('Session completion failed', safeError(error))
|
|
455
|
+
} finally {
|
|
456
|
+
// An advancing occurrence keeps its concurrency slot for the next setup.
|
|
457
|
+
if (!eligible) free()
|
|
344
458
|
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
459
|
+
})
|
|
460
|
+
console.info('run ended', { run_id: started.id, code, ...(eligible ? { advancing: true } : {}) })
|
|
461
|
+
// Re-check cancellation and shutdown under the start lock, so a stop
|
|
462
|
+
// that already snapshotted children never misses the next setup.
|
|
463
|
+
if (eligible) return withStartLock(async () => {
|
|
464
|
+
try {
|
|
465
|
+
const next = await advance()
|
|
466
|
+
if (next) return await watch(await launchSetup(next))
|
|
467
|
+
const cancelled = await scheduler.cancelled(run.id)
|
|
468
|
+
await finalize(code, cancelled, !cancelled)
|
|
469
|
+
free()
|
|
470
|
+
setImmediate(() => { void runs.nextQueued().then(queued => { if (queued && !shuttingDown) return startJob(queued) }).catch(console.error) })
|
|
471
|
+
} catch (error) { await startFailed(error) }
|
|
472
|
+
})
|
|
473
|
+
const next = await runs.nextQueued()
|
|
474
|
+
if (next && !shuttingDown) await startJob(next)
|
|
475
|
+
})().catch((error) => console.error('Run completion failed', error.message))
|
|
476
|
+
})
|
|
477
|
+
completions.add(completion)
|
|
478
|
+
void completion.finally(() => completions.delete(completion))
|
|
358
479
|
}
|
|
359
|
-
await
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
if (bot && !run.application && !run.delivery && run.chatId !== undefined) await sendChat(run.chatId, `Run ${run.id} failed to start. Check the local relay log.`)
|
|
363
|
-
setImmediate(() => {
|
|
364
|
-
void runs
|
|
365
|
-
.nextQueued()
|
|
366
|
-
.then((next) => next && startJob(next))
|
|
367
|
-
.catch(console.error)
|
|
368
|
-
})
|
|
480
|
+
await watch(await launchSetup(selected))
|
|
481
|
+
} catch (error) {
|
|
482
|
+
await startFailed(error)
|
|
369
483
|
}
|
|
370
484
|
})
|
|
371
485
|
}
|
|
@@ -935,7 +1049,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
935
1049
|
|
|
936
1050
|
if (text === '/new') {
|
|
937
1051
|
await control.aiState(aiMenu.initial)
|
|
1052
|
+
const previous = await control.getActiveSession()
|
|
938
1053
|
const next = await control.resetSession()
|
|
1054
|
+
await closeSession(previous)
|
|
939
1055
|
await ctx.reply(
|
|
940
1056
|
`🔄 Started fresh conversation session (<code>${next.sessionId.slice(0, 8)}</code>). Agent files and memory preserved.`,
|
|
941
1057
|
{ parse_mode: 'HTML' },
|
|
@@ -1123,7 +1239,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1123
1239
|
await ctx.reply(id ? `Incoming batch ${id} queued for retry.` : 'No failed incoming batch to retry.')
|
|
1124
1240
|
} else if (action === 'new') {
|
|
1125
1241
|
await control.aiState(aiMenu.initial)
|
|
1242
|
+
const previous = await control.getActiveSession()
|
|
1126
1243
|
const next = await control.resetSession()
|
|
1244
|
+
await closeSession(previous)
|
|
1127
1245
|
await ctx.answerCallbackQuery({ text: 'New session started' })
|
|
1128
1246
|
await ctx.reply(
|
|
1129
1247
|
`🔄 Started fresh conversation session (<code>${next.sessionId.slice(0, 8)}</code>).`,
|
|
@@ -1165,11 +1283,90 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1165
1283
|
})
|
|
1166
1284
|
}
|
|
1167
1285
|
|
|
1286
|
+
// Watch runs a previous relay handed off until they finish, with the normal
|
|
1287
|
+
// receipts. Never relaunch; existing loops still revoke and cancel them. An
|
|
1288
|
+
// unknown outcome is recorded interrupted, which holds its schedule.
|
|
1289
|
+
const adoptHandoff = async (): Promise<void> => {
|
|
1290
|
+
const handed = await takeRunHandoff(config.controlDir)
|
|
1291
|
+
if (!hostTransport) { if (handed.length) console.error('Ignoring run handoff without host transport', handed.length); return }
|
|
1292
|
+
// Admit every record before the first attach, so no trigger races a hold.
|
|
1293
|
+
const admitted = (await Promise.all(handed.map(run => runs.adopt(run).catch(() => null)))).filter((run): run is RunRecord => run !== null)
|
|
1294
|
+
for (const run of admitted) await withStartLock(async () => {
|
|
1295
|
+
let child: ChildProcess
|
|
1296
|
+
try {
|
|
1297
|
+
// The foreground lane is serial; a second foreground handoff is corrupt.
|
|
1298
|
+
if (!run.scheduled && activeChild) throw new Error('A second handed-off foreground run was cancelled; its outcome is unknown')
|
|
1299
|
+
child = await attachHostJob(config.controlDir, run.id)
|
|
1300
|
+
} catch (error) {
|
|
1301
|
+
// Never leave an unobserved executor: ask the host to stop it.
|
|
1302
|
+
await mkdir(join(config.controlDir, 'host-executor'), { recursive: true, mode: 0o700 })
|
|
1303
|
+
.then(() => writeFile(join(config.controlDir, 'host-executor', run.id + '.cancel'), '', { mode: 0o600 })).catch(() => {})
|
|
1304
|
+
await runs.patch(run.id, { status: 'failed', endedAt: new Date().toISOString(), interrupted: true, failureReason: 'handoff-outcome-unknown', failure: await failureEvidence(config.controlDir, safeError(error)) })
|
|
1305
|
+
return
|
|
1306
|
+
}
|
|
1307
|
+
// Listen before any await so a fast exit cannot be missed.
|
|
1308
|
+
const closed = new Promise<number | null>(resolve => child.once('close', resolve))
|
|
1309
|
+
let errorTail = '', outputTail = '', timedOut = false
|
|
1310
|
+
child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { if (run.script) outputTail = (outputTail + chunk).slice(-16384) })
|
|
1311
|
+
child.stderr?.setEncoding('utf8').on('data', (chunk: string) => {
|
|
1312
|
+
errorTail = (errorTail + chunk).slice(-16384)
|
|
1313
|
+
if (run.script) outputTail = (outputTail + chunk).slice(-16384)
|
|
1314
|
+
})
|
|
1315
|
+
if (run.scheduled) background.set(run.id, child)
|
|
1316
|
+
else activeChild = child
|
|
1317
|
+
childRuns.set(child, run.id)
|
|
1318
|
+
await runs.patch(run.id, { pid: child.pid })
|
|
1319
|
+
console.info('run adopted', { run_id: run.id, pid: child.pid })
|
|
1320
|
+
// A script keeps its registered bound from its original start.
|
|
1321
|
+
const limit = run.script ? await new Scripts(config.controlDir).get(run.script.id).then(script => script.timeoutSeconds, () => DEFAULT_SCRIPT_TIMEOUT_SECONDS) : 0
|
|
1322
|
+
const timer = limit ? setTimeout(() => { timedOut = true; terminateJob(child) }, Math.max(0, Date.parse(run.startedAt ?? run.createdAt) + limit * 1000 - Date.now())) : undefined
|
|
1323
|
+
const completion = closed.then(code => withStartLock(async () => {
|
|
1324
|
+
clearTimeout(timer)
|
|
1325
|
+
if (run.scheduled) background.delete(run.id)
|
|
1326
|
+
else if (activeChild === child) activeChild = null
|
|
1327
|
+
if (handedOff.has(child)) return
|
|
1328
|
+
const cancelled = !timedOut && (ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id)))
|
|
1329
|
+
const failed = !cancelled && code !== 0
|
|
1330
|
+
const status = cancelled ? 'cancelled' : failed ? 'failed' : 'completed'
|
|
1331
|
+
const unknown = errorTail.includes('outcome is unknown'), transport = errorTail.includes('Host executor client interrupted by')
|
|
1332
|
+
const failureReason = timedOut ? 'script-timeout' : unknown ? 'handoff-outcome-unknown' : transport ? 'host-executor-transport-interrupted'
|
|
1333
|
+
: errorTail.includes('Host CLI executor is offline') ? 'host-executor-offline' : 'executor-exit'
|
|
1334
|
+
if (code === 0 && run.execution && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(run.execution.sessionId)
|
|
1335
|
+
const at = new Date().toISOString(), last = run.attempts?.at(-1)
|
|
1336
|
+
await runs.patch(run.id, { status, endedAt: at, exitCode: code,
|
|
1337
|
+
...(last?.outcome === 'running' ? { attempts: [...run.attempts!.slice(0, -1), { ...last, outcome: status, exitCode: code, endedAt: at }] } : {}),
|
|
1338
|
+
...(run.script ? { output: redactFailure(safeError(outputTail)), ...(timedOut ? { timedOut } : {}) } : {}),
|
|
1339
|
+
...(failed ? { failureReason, interrupted: unknown || transport, failure: await failureEvidence(config.controlDir, safeError(errorTail || `Executor exited with ${code === null ? 'a signal' : `code ${code}`}`)) } : {}) })
|
|
1340
|
+
await releaseExternal(run)
|
|
1341
|
+
console.info('run ended', { run_id: run.id, code, adopted: true })
|
|
1342
|
+
})).then(async () => {
|
|
1343
|
+
const next = await runs.nextQueued()
|
|
1344
|
+
if (next && !shuttingDown) await startJob(next)
|
|
1345
|
+
}).catch(error => console.error('Adopted run completion failed', safeError(error)))
|
|
1346
|
+
completions.add(completion)
|
|
1347
|
+
void completion.finally(() => completions.delete(completion))
|
|
1348
|
+
}).catch(error => console.error('Run adoption failed', run.id, safeError(error)))
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1168
1351
|
let stopWork: Promise<void> | undefined
|
|
1169
1352
|
const stop = (): Promise<void> => stopWork ?? (stopWork = (async () => {
|
|
1170
1353
|
shuttingDown = true
|
|
1171
1354
|
runtimeStarted = false
|
|
1172
1355
|
pollingAbort.abort()
|
|
1356
|
+
// Hand off running host work before any wait can outlast the stop grace
|
|
1357
|
+
// period. The lock lets an in-flight launch register; later ones see shutdown.
|
|
1358
|
+
if (hostTransport) await withStartLock(async () => {
|
|
1359
|
+
const handoff = new Map<ChildProcess, RunRecord>()
|
|
1360
|
+
for (const child of [...(activeChild ? [activeChild] : []), ...background.values()]) {
|
|
1361
|
+
const run = childRuns.has(child) ? await runs.get(childRuns.get(child)!) : null
|
|
1362
|
+
if (run?.status === 'running' && !ownerStopped.has(child) && child.exitCode === null && child.signalCode === null) handoff.set(child, run)
|
|
1363
|
+
}
|
|
1364
|
+
if (!handoff.size) return
|
|
1365
|
+
try { await saveRunHandoff(config.controlDir, [...handoff.values()]) }
|
|
1366
|
+
catch (error) { console.error('Run handoff failed; stopping host work', safeError(error)); return }
|
|
1367
|
+
// SIGKILL leaves no cancel marker, so the host keeps each executor running.
|
|
1368
|
+
for (const child of handoff.keys()) { handedOff.add(child); child.kill('SIGKILL') }
|
|
1369
|
+
})
|
|
1173
1370
|
await deliveryServer?.stop().catch(() => {})
|
|
1174
1371
|
deliveryServer = null
|
|
1175
1372
|
await applicationChannel.stop()
|
|
@@ -1181,8 +1378,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1181
1378
|
await Promise.all([sourceWork, intakeWork].map(work => work?.catch(() => {})))
|
|
1182
1379
|
// Finish registering in-flight launches before taking the child snapshot.
|
|
1183
1380
|
await withStartLock(async () => {
|
|
1184
|
-
|
|
1185
|
-
for (const child of background.values()) terminateJob(child)
|
|
1381
|
+
for (const child of [...(activeChild ? [activeChild] : []), ...background.values()]) if (!handedOff.has(child)) terminateJob(child)
|
|
1186
1382
|
if (activeTypingTimer) clearInterval(activeTypingTimer)
|
|
1187
1383
|
})
|
|
1188
1384
|
await Promise.all(completions)
|
|
@@ -1208,11 +1404,13 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1208
1404
|
deliveryServer = await serveDeliverySocket(config.controlDir, handleDeliveryOp, {
|
|
1209
1405
|
...(Number.isSafeInteger(deliveryTcpPort) && deliveryTcpPort >= 1024 && deliveryTcpPort <= 65535 ? { tcpPort: deliveryTcpPort } : {}),
|
|
1210
1406
|
})
|
|
1407
|
+
// Reconcile, then adopt handed-off runs, before any intake can launch work.
|
|
1408
|
+
await scheduler.recover(runs)
|
|
1409
|
+
await adoptHandoff()
|
|
1211
1410
|
if (config.applicationPort) await applicationChannel.listen(config.applicationPort, config.applicationHost)
|
|
1212
1411
|
runtimeStarted = true
|
|
1213
1412
|
const owner = (await control.status()).owner
|
|
1214
1413
|
if (telegramEnabled && telegramOwner(owner) && !config.channelBackendUrl) await telegramSource!.start(owner!)
|
|
1215
|
-
await scheduler.recover(runs)
|
|
1216
1414
|
sourceTimer = setInterval(() => {
|
|
1217
1415
|
void drainSources().catch(error => console.error('Event-source drain failed', safeError(error)))
|
|
1218
1416
|
}, 1000)
|
|
@@ -1286,7 +1484,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1286
1484
|
}
|
|
1287
1485
|
}
|
|
1288
1486
|
}
|
|
1289
|
-
return { bot: bot!, telegramEnabled, isRunning: () => telegramEnabled ? Boolean(bot?.isRunning()) : runtimeStarted, start, stop, drainOutbox, drainInbox, drainSources, drainTaskRequests, applicationChannel }
|
|
1487
|
+
return { bot: bot!, telegramEnabled, isRunning: () => telegramEnabled ? Boolean(bot?.isRunning()) : runtimeStarted, start, stop, drainOutbox, drainInbox, drainSources, drainTaskRequests, adoptHandoff, applicationChannel }
|
|
1290
1488
|
}
|
|
1291
1489
|
|
|
1292
1490
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
package/src/menu.ts
CHANGED
|
@@ -3,8 +3,8 @@ import { readFile } from 'node:fs/promises'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { randomBytes } from 'node:crypto'
|
|
5
5
|
import { InlineKeyboard, type Context } from 'grammy'
|
|
6
|
-
import { ControlStore, type ControlGuard } from './control-state.js'
|
|
7
|
-
import { chatPreset, installed, persistedPreset, presetLabel, presetProvider, readModels, validateSelection, type AiPreset, type ModelChoice } from './ai.js'
|
|
6
|
+
import { ControlStore, type ControlGuard, type SessionState } from './control-state.js'
|
|
7
|
+
import { chatPreset, installed, persistedPreset, presetLabel, presetProvider, readModels, sameChoice, sameEngine, validateSelection, type AiPreset, type ModelChoice } from './ai.js'
|
|
8
8
|
import { discoverDefaults } from './client-defaults.js'
|
|
9
9
|
import { claudeConfigHome } from './executor.js'
|
|
10
10
|
|
|
@@ -25,13 +25,13 @@ export const mainKeyboard = () => new InlineKeyboard()
|
|
|
25
25
|
const clientLabel = (cli: string) => cli === 'codex-gui' ? 'codex-gui (desktop)' : cli
|
|
26
26
|
|
|
27
27
|
const matchesModel = (preset: AiPreset, model: ModelChoice) =>
|
|
28
|
-
model
|
|
28
|
+
sameEngine(model, preset) && model.provider === preset.provider && (preset.model === undefined || model.model === preset.model) &&
|
|
29
29
|
(preset.effort === undefined || model.efforts.includes(preset.effort))
|
|
30
30
|
|
|
31
31
|
// Short-lived opaque button IDs: no model names or executable arguments from callbacks.
|
|
32
32
|
// These are operational settings, not a second conversational/agent loop.
|
|
33
33
|
export const createAiMenu = (control: ControlStore, cli: string, catalog = readModels, workspace = process.cwd(), codexHome?: string,
|
|
34
|
-
isInstalled = installed, opencodeDataHome?: string) => {
|
|
34
|
+
isInstalled = installed, opencodeDataHome?: string, closeSession?: (session: SessionState | null) => Promise<void>) => {
|
|
35
35
|
const initial = chatPreset(cli)
|
|
36
36
|
const host = process.env.EZ_EXECUTOR_TRANSPORT === 'host'
|
|
37
37
|
if (host) catalog = async () => JSON.parse(await readFile(path.join(process.env.EZ_CONTROL_DIR!, 'host-executor/models.json'),'utf8'))
|
|
@@ -66,8 +66,9 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
66
66
|
const state = (await control.status()).ai
|
|
67
67
|
if (!state) throw new Error('AI settings not initialized')
|
|
68
68
|
const current = state.presets.find((p) => p.id === state.selectedId)!
|
|
69
|
-
const fresh = current
|
|
69
|
+
const fresh = !sameEngine(current, preset) || presetProvider(current) !== presetProvider(preset) || Boolean(session && !session.cli)
|
|
70
70
|
if (!await control.selectPreset(preset.id, expectedSession, fresh, guard)) throw new Error('AI binding changed. Refresh available AIs before trying again.')
|
|
71
|
+
if (fresh) await closeSession?.(session)
|
|
71
72
|
return { preset, fresh }
|
|
72
73
|
}
|
|
73
74
|
const choose = async (ctx: Context, preset: AiPreset) => {
|
|
@@ -127,9 +128,9 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
127
128
|
const state = (await control.status()).ai
|
|
128
129
|
if (!state) throw new Error('AI settings not initialized')
|
|
129
130
|
const candidate: AiPreset = { id: randomBytes(8).toString('hex'),
|
|
130
|
-
name: `${model.name}${effort ? ` · ${effort}` : ''}`.slice(0, 80), cli: model.cli, provider:model.provider, model: model.model, effort }
|
|
131
|
+
name: `${model.name}${effort ? ` · ${effort}` : ''}`.slice(0, 80), cli: model.cli, provider:model.provider, ...(model.authProfile ? {authProfile:model.authProfile} : {}), model: model.model, effort }
|
|
131
132
|
const stored = persistedPreset(candidate)
|
|
132
|
-
const existing = state.presets.find((preset) => preset
|
|
133
|
+
const existing = state.presets.find((preset) => sameChoice(preset, stored))
|
|
133
134
|
const preset = existing ?? candidate
|
|
134
135
|
await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : isInstalled)
|
|
135
136
|
// Private scopes persist their selection on the session, not the owner menu.
|