@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.
Files changed (58) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +24 -0
  3. package/README.md +9 -5
  4. package/bin/ez +16 -7
  5. package/docker/broker-update-smoke.mjs +16 -2
  6. package/docker/upgrade-smoke.mjs +1 -1
  7. package/docs/architecture/ai-selection.md +9 -1
  8. package/docs/docker-runtime.md +46 -2
  9. package/docs/plugin-guide.md +3 -3
  10. package/docs/plugins.md +13 -10
  11. package/docs/scheduling.md +84 -6
  12. package/docs/setup.md +2 -2
  13. package/docs/standalone-cli.md +4 -4
  14. package/docs/upgrades.md +13 -23
  15. package/package.json +1 -1
  16. package/src/agent-guidance.ts +2 -2
  17. package/src/ai-cli.ts +28 -15
  18. package/src/ai.ts +90 -14
  19. package/src/application-channel.ts +18 -14
  20. package/src/auth-profile.ts +53 -0
  21. package/src/codex-session.ts +14 -3
  22. package/src/control-state.ts +28 -21
  23. package/src/delivery-socket.ts +2 -0
  24. package/src/executor.ts +38 -26
  25. package/src/host-executor-client.ts +19 -7
  26. package/src/host-executor.ts +35 -10
  27. package/src/index.ts +320 -122
  28. package/src/menu.ts +8 -7
  29. package/src/plugin-broker.mjs +4 -11
  30. package/src/plugins/manager.mjs +70 -48
  31. package/src/runs.ts +74 -5
  32. package/src/schedule-cli.ts +39 -7
  33. package/src/scheduled-tasks.ts +42 -8
  34. package/src/scheduler.ts +9 -4
  35. package/src/session-close.ts +17 -0
  36. package/src/setup-fallback.ts +159 -0
  37. package/src/task-executor.ts +3 -2
  38. package/src/tools-line.d.mts +1 -0
  39. package/src/tools-line.mjs +3 -0
  40. package/src/updates/binding.mjs +4 -10
  41. package/src/updates/control.mjs +3 -2
  42. package/src/updates/runtime.mjs +79 -41
  43. package/src/updates/supervisor.mjs +27 -6
  44. package/src/workspace.ts +12 -1
  45. package/test/ai.test.ts +48 -2
  46. package/test/application-channel.test.ts +24 -0
  47. package/test/application-controls.test.ts +4 -1
  48. package/test/auth-profiles.test.ts +187 -0
  49. package/test/intake-relay.test.ts +63 -2
  50. package/test/model-policy.test.ts +1 -1
  51. package/test/plugin-broker.test.mjs +51 -1
  52. package/test/plugin-manager.test.mjs +76 -38
  53. package/test/run-handoff.test.ts +132 -0
  54. package/test/scheduled-tasks.test.ts +24 -2
  55. package/test/session-close.test.ts +17 -0
  56. package/test/setup-fallback.test.ts +423 -0
  57. package/test/updates.test.mjs +77 -15
  58. 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
- const { child, cleanup } = await launch(texts, script ? {
255
- workspace: config.workspace, timeoutMs: 0, repairEnabled: config.repairEnabled, runId: started.id,
256
- controlDir: config.controlDir, binDir,
257
- script: { ...script.ref, scheduleId: script.scheduled.id, scheduleRevision: script.scheduled.revision, dueAt: script.scheduled.dueAt },
258
- } : {
259
- workspace: config.workspace,
260
- timeoutMs: config.executorTimeoutMs,
261
- repairEnabled: config.repairEnabled,
262
- runId: started.id,
263
- controlDir: config.controlDir,
264
- binDir,
265
- cli: selected.cli,
266
- model: selected.model,
267
- effort: selected.effort,
268
- provider: selected.provider,
269
- codexAutoCompactTokens: config.codexAutoCompactTokens,
270
- codexSandbox: !run.taskId && selected.cli === 'codex' ? config.codexSandbox : undefined,
271
- sessionId: session.nativeSessionId || session.sessionId,
272
- isResume: session.hasStarted,
273
- promptSuffix: runPromptSuffix(started),
274
- taskRun: Boolean(run.taskId),
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
- if (chunk.trim()) console.error('executor stderr', started.id, chunk.trim())
295
- })
296
- console.info('run timing', { run_id: run.id, phase: 'launch',
297
- queue_ms: Math.max(0, Date.parse(started.startedAt!) - Date.parse(run.createdAt)),
298
- startup_ms: Math.round(executionStarted - launchStarted), resumed: session.hasStarted })
299
- if (run.scheduled) background.set(run.id,child)
300
- else activeChild = child
301
- const finished = new Promise<number | null>((resolve) => {
302
- if (child.exitCode !== null || child.signalCode !== null) resolve(child.exitCode)
303
- else child.once('close', resolve)
304
- })
305
- await runs.patch(started.id, { pid: child.pid })
306
- console.info('run started', {
307
- run_id: started.id,
308
- pid: child.pid,
309
- cli: script ? 'script' : selected.cli,
310
- ...(script ? { script: script.ref.id, script_revision: script.ref.revision } : {}),
311
- session: session.sessionId,
312
- isResume: session.hasStarted,
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
- if (!run.scheduled && activeTypingTimer) clearInterval(activeTypingTimer)
316
- if (bot && run.chatId !== undefined && !run.application && !run.external && !run.taskId && !run.scheduled) activeTypingTimer = setInterval(() => {
317
- if (performance.now() - executionStarted < 30000) void bot.api.sendChatAction(run.chatId!, 'typing').catch(() => {})
318
- }, 4000)
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
- const completion = finished.then((code) => {
321
- console.info('run timing', { run_id: run.id, phase: 'execution',
322
- execution_ms: Math.round(performance.now() - executionStarted), exit_code: code })
323
- return (async () => {
324
- await withStartLock(async () => {
325
- try {
326
- await cleanup()
327
- if (code === 0 && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(session.sessionId)
328
- const cancelled = !timedOut && (ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id)))
329
- if (timedOut) failureReason = 'script-timeout'
330
- const failed = !cancelled && (code !== 0 || timedOut)
331
- await runs.patch(started.id, { status: cancelled ? 'cancelled' : failed ? 'failed' : 'completed', endedAt: new Date().toISOString(), exitCode: code,
332
- ...(script ? { output: redactFailure(safeError(outputTail)), ...(timedOut ? { timedOut } : {}) } : {}),
333
- ...(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}`}`)) } : {}) })
334
- await releaseExternal(run)
335
- } catch (error) {
336
- 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() })
337
- console.error('Session completion failed', safeError(error))
338
- } finally {
339
- if (run.scheduled) background.delete(run.id)
340
- else {
341
- activeChild = null
342
- if (activeTypingTimer) clearInterval(activeTypingTimer)
343
- activeTypingTimer = null
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
- console.info('run ended', { run_id: started.id, code })
348
- const next = await runs.nextQueued()
349
- if (next && !shuttingDown) await startJob(next)
350
- })().catch((error) => console.error('Run completion failed', error.message))
351
- })
352
- completions.add(completion)
353
- void completion.finally(() => completions.delete(completion))
354
- } catch (error) {
355
- if (!run.scheduled && activeTypingTimer) {
356
- clearInterval(activeTypingTimer)
357
- activeTypingTimer = null
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 runs.patch(run.id, { status: 'failed', failureReason: 'executor-start', failure: await failureEvidence(config.controlDir, safeError(error)), endedAt: new Date().toISOString() })
360
- await releaseExternal(run)
361
- console.error('run start failed', run.id, safeError(error))
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
- if (activeChild) terminateJob(activeChild)
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.cli === preset.cli && model.provider === preset.provider && (preset.model === undefined || model.model === preset.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.cli !== preset.cli || presetProvider(current) !== presetProvider(preset) || Boolean(session && !session.cli)
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.cli === stored.cli && preset.provider === stored.provider && preset.model === stored.model && preset.effort === stored.effort)
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.