@jc_stack/ez-agents 0.1.0-beta.48 → 0.1.0-beta.49

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/src/index.ts CHANGED
@@ -27,7 +27,8 @@ import { InboxStore, type IncomingItem } from './inbox.js'
27
27
  import { loadConfig, type Config } from './config.js'
28
28
  import { ControlStore, telegramOwner, ownerId, ownerEpoch } from './control-state.js'
29
29
  import { ApprovalStore } from './approval.js'
30
- import { startExecutorJob, terminateJob, opencodeDataHome, claudeConfigHome } from './executor.js'
30
+ import { CliUnavailableError, startExecutorJob, terminateJob, opencodeDataHome, claudeConfigHome } from './executor.js'
31
+ import { advances, nextSetup, quotaScope, setupCandidates, startupObserver, unavailableSummary, SetupsUnavailableError, type SetupAttempt } from './setup-fallback.js'
31
32
  import { assertScheduledModel } from './model-policy.js'
32
33
  import { RunStore, type RunRecord } from './runs.js'
33
34
  import { splitTelegramText } from './reply.js'
@@ -38,7 +39,7 @@ import { normalizeReactionEmoji } from './reaction.js'
38
39
  import { downloadTelegramFile } from './read-request.js'
39
40
  import { createConversationMenu } from './conversation-menu.js'
40
41
  import { createAiMenu, mainCommands, mainKeyboard } from './menu.js'
41
- import { chatPreset, initialPreset, persistedPreset, presetLabel, statusPreset } from './ai.js'
42
+ import { chatPreset, initialPreset, persistedPreset, presetLabel, statusPreset, type AiPreset } from './ai.js'
42
43
  import { discoverDefaults } from './client-defaults.js'
43
44
  import { runPromptSuffix } from './prompt-suffix.js'
44
45
  import { softwareStatus } from './software-status.js'
@@ -236,6 +237,26 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
236
237
  await releaseExternal(run)
237
238
  return
238
239
  }
240
+ // Shared by a first launch and a fallback launch that cannot start.
241
+ const startFailed = async (error: unknown) => {
242
+ if (!run.scheduled && activeTypingTimer) {
243
+ clearInterval(activeTypingTimer)
244
+ activeTypingTimer = null
245
+ }
246
+ if (run.scheduled) background.delete(run.id)
247
+ const unavailable = error instanceof SetupsUnavailableError
248
+ await runs.patch(run.id, { status: 'failed', failureReason: unavailable ? 'setups-unavailable' : 'executor-start', failure: await failureEvidence(config.controlDir, safeError(error)), endedAt: new Date().toISOString() })
249
+ await releaseExternal(run)
250
+ console.error('run start failed', run.id, safeError(error))
251
+ if (bot && !run.application && !run.delivery && run.chatId !== undefined)
252
+ await sendChat(run.chatId, unavailable ? `Run ${run.id} could not start. ${safeError(error)}` : `Run ${run.id} failed to start. Check the local relay log.`)
253
+ setImmediate(() => {
254
+ void runs
255
+ .nextQueued()
256
+ .then((next) => next && startJob(next))
257
+ .catch(console.error)
258
+ })
259
+ }
239
260
  try {
240
261
  const launchStarted = performance.now()
241
262
  const started = await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
@@ -251,121 +272,194 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
251
272
  : await control.executionSession(started.execution!)
252
273
  const selected = script ? initialPreset('codex') : run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : started.execution!.preset
253
274
  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
275
+ // A scheduled occurrence may name ordered fallback setups. They share this
276
+ // run, workspace and concurrency slot; each is tried at most once.
277
+ const candidates = run.scheduled && !script ? setupCandidates(started.execution!) : [selected]
278
+ const ordered = candidates.length > 1
279
+ let attempts: SetupAttempt[] = []
280
+ const settle = (change: Partial<SetupAttempt>) => {
281
+ attempts = [...attempts.slice(0, -1), { ...attempts[attempts.length - 1], ...change, endedAt: new Date().toISOString() }]
282
+ }
283
+ // Advance only after an eligible pre-work failure of a current, uncancelled
284
+ // occurrence. Setups in an exhausted quota scope are skipped and recorded.
285
+ const advance = async (): Promise<AiPreset | undefined> => {
286
+ let preset: AiPreset | undefined
287
+ const current = !shuttingDown && !await scheduler.cancelled(run.id) ? (await control.status()).owner : undefined
288
+ if (current && ownsRun(current, run) && await scheduler.current(run, current)) {
289
+ const next = nextSetup(candidates, attempts)
290
+ attempts = [...attempts, ...next.skipped]
291
+ if (!next.preset) {
292
+ await runs.patch(run.id, { attempts })
293
+ throw new SetupsUnavailableError(unavailableSummary(attempts))
294
+ }
295
+ preset = next.preset
293
296
  }
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
- })
297
+ await runs.patch(run.id, { attempts })
298
+ return preset
299
+ }
300
+ // Each attempt is a fresh native session; a rejected Claude attempt already claimed its ID.
301
+ const launchSetup = async (preset: AiPreset): Promise<{ child: ChildProcess; cleanup: () => Promise<void>; preset: AiPreset }> => {
302
+ for (;;) {
303
+ const sessionId = attempts.length ? randomUUID() : session.nativeSessionId || session.sessionId
304
+ if (ordered) {
305
+ attempts = [...attempts, { preset, quotaScope: quotaScope(preset), outcome: 'running', startedAt: new Date().toISOString() }]
306
+ await runs.patch(run.id, { attempts })
307
+ }
308
+ try {
309
+ return { ...await launch(texts, script ? {
310
+ workspace: config.workspace, timeoutMs: 0, repairEnabled: config.repairEnabled, runId: started.id,
311
+ controlDir: config.controlDir, binDir,
312
+ script: { ...script.ref, scheduleId: script.scheduled.id, scheduleRevision: script.scheduled.revision, dueAt: script.scheduled.dueAt },
313
+ } : {
314
+ workspace: config.workspace,
315
+ timeoutMs: config.executorTimeoutMs,
316
+ repairEnabled: config.repairEnabled,
317
+ runId: started.id,
318
+ controlDir: config.controlDir,
319
+ binDir,
320
+ cli: preset.cli,
321
+ model: preset.model,
322
+ effort: preset.effort,
323
+ provider: preset.provider,
324
+ authProfile: preset.authProfile,
325
+ codexAutoCompactTokens: config.codexAutoCompactTokens,
326
+ codexSandbox: !run.taskId && preset.cli === 'codex' ? config.codexSandbox : undefined,
327
+ sessionId,
328
+ isResume: session.hasStarted,
329
+ promptSuffix: runPromptSuffix(started),
330
+ taskRun: Boolean(run.taskId),
331
+ nativeSession: Boolean(run.scheduled),
332
+ onSession: run.external || run.taskId ? undefined : async (id) => { await runs.patch(run.id,{nativeSessionId:id}); if (!run.scheduled) await control.saveNativeSession(session.sessionId,id) },
333
+ }), preset }
334
+ } catch (error) {
335
+ if (ordered && !(error instanceof CliUnavailableError)) {
336
+ settle({ outcome: 'failed', category: 'uncertain' })
337
+ await runs.patch(run.id, { attempts })
338
+ }
339
+ if (!ordered || !(error instanceof CliUnavailableError)) throw error
340
+ settle({ outcome: 'failed', category: 'cli-unavailable' })
341
+ const next = await advance()
342
+ if (!next) throw error
343
+ preset = next
344
+ }
345
+ }
346
+ }
347
+ const watch = async ({ child, cleanup, preset }: Awaited<ReturnType<typeof launchSetup>>): Promise<void> => {
348
+ const executionStarted = performance.now()
349
+ // Attach before disk writes: a fast child can close while PID persistence
350
+ // is pending, and Node drains its remaining pipes during process close.
351
+ let failureReason = 'executor-exit', errorTail = '', interrupted = false, outputTail = '', timedOut = false
352
+ // Script output is bounded diagnostics for run inspection, never chat.
353
+ if (script) child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { outputTail = (outputTail + chunk).slice(-16384) })
354
+ // Structured startup events decide whether a failed setup may advance.
355
+ const startup = ordered ? startupObserver() : undefined
356
+ if (startup) child.stdout?.setEncoding('utf8').on('data', startup.write)
357
+ const scriptTimer = script ? setTimeout(() => { timedOut = true; terminateJob(child) }, script.registration.timeoutSeconds * 1000) : undefined
358
+ child.once('close', () => clearTimeout(scriptTimer))
359
+ child.stderr?.setEncoding('utf8').on('data', (chunk: string) => {
360
+ errorTail = (errorTail + chunk).slice(-16384)
361
+ if (script) outputTail = (outputTail + chunk).slice(-16384)
362
+ if (chunk.includes('Host CLI executor is offline')) failureReason = 'host-executor-offline'
363
+ if (chunk.includes('Host executor client interrupted by')) {
364
+ failureReason = 'host-executor-transport-interrupted'
365
+ interrupted = true
366
+ }
367
+ if (chunk.trim()) console.error('executor stderr', started.id, chunk.trim())
368
+ })
369
+ console.info('run timing', { run_id: run.id, phase: 'launch',
370
+ queue_ms: Math.max(0, Date.parse(started.startedAt!) - Date.parse(run.createdAt)),
371
+ startup_ms: Math.round(executionStarted - launchStarted), resumed: session.hasStarted })
372
+ if (run.scheduled) background.set(run.id,child)
373
+ else activeChild = child
374
+ const finished = new Promise<number | null>((resolve) => {
375
+ if (child.exitCode !== null || child.signalCode !== null) resolve(child.exitCode)
376
+ else child.once('close', resolve)
377
+ })
378
+ await runs.patch(started.id, { pid: child.pid })
379
+ console.info('run started', {
380
+ run_id: started.id,
381
+ pid: child.pid,
382
+ cli: script ? 'script' : preset.cli,
383
+ ...(script ? { script: script.ref.id, script_revision: script.ref.revision } : {}),
384
+ ...(ordered ? { setup: attempts.length } : {}),
385
+ session: session.sessionId,
386
+ isResume: session.hasStarted,
387
+ })
314
388
 
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)
389
+ if (!run.scheduled && activeTypingTimer) clearInterval(activeTypingTimer)
390
+ if (bot && run.chatId !== undefined && !run.application && !run.external && !run.taskId && !run.scheduled) activeTypingTimer = setInterval(() => {
391
+ if (performance.now() - executionStarted < 30000) void bot.api.sendChatAction(run.chatId!, 'typing').catch(() => {})
392
+ }, 4000)
319
393
 
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
394
+ const free = () => {
395
+ if (run.scheduled) background.delete(run.id)
396
+ else {
397
+ activeChild = null
398
+ if (activeTypingTimer) clearInterval(activeTypingTimer)
399
+ activeTypingTimer = null
400
+ }
401
+ }
402
+ const finalize = async (code: number | null, cancelled: boolean, failed: boolean) => {
403
+ await runs.patch(started.id, { status: cancelled ? 'cancelled' : failed ? 'failed' : 'completed', endedAt: new Date().toISOString(), exitCode: code,
404
+ ...(script ? { output: redactFailure(safeError(outputTail)), ...(timedOut ? { timedOut } : {}) } : {}),
405
+ ...(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}`}`)) } : {}) })
406
+ await releaseExternal(run)
407
+ }
408
+ const completion = finished.then((code) => {
409
+ console.info('run timing', { run_id: run.id, phase: 'execution',
410
+ execution_ms: Math.round(performance.now() - executionStarted), exit_code: code })
411
+ return (async () => {
412
+ let eligible = false
413
+ await withStartLock(async () => {
414
+ try {
415
+ await cleanup()
416
+ if (code === 0 && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(session.sessionId)
417
+ const cancelled = !timedOut && (ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id)))
418
+ if (timedOut) failureReason = 'script-timeout'
419
+ const observed = startup?.result()
420
+ const rejected = observed?.rejection && !observed.workBegan ? observed.rejection : undefined
421
+ // A rejection before work is a failure even when the CLI exits 0.
422
+ const failed = !cancelled && (code !== 0 || timedOut || Boolean(rejected))
423
+ if (startup) {
424
+ settle({ outcome: cancelled ? 'cancelled' : failed ? 'failed' : 'completed', exitCode: code,
425
+ ...(failed ? { category: rejected?.category ?? (observed!.workBegan ? 'after-work-began' : 'uncertain') } : {}),
426
+ ...(failed && rejected?.resetAt ? { resetAt: rejected.resetAt } : {}) })
427
+ await runs.patch(run.id, { attempts })
428
+ eligible = failed && !interrupted && advances(rejected)
429
+ }
430
+ if (!eligible) await finalize(code, cancelled, failed)
431
+ } catch (error) {
432
+ eligible = false
433
+ 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() })
434
+ console.error('Session completion failed', safeError(error))
435
+ } finally {
436
+ // An advancing occurrence keeps its concurrency slot for the next setup.
437
+ if (!eligible) free()
344
438
  }
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
439
+ })
440
+ console.info('run ended', { run_id: started.id, code, ...(eligible ? { advancing: true } : {}) })
441
+ // Re-check cancellation and shutdown under the start lock, so a stop
442
+ // that already snapshotted children never misses the next setup.
443
+ if (eligible) return withStartLock(async () => {
444
+ try {
445
+ const next = await advance()
446
+ if (next) return await watch(await launchSetup(next))
447
+ const cancelled = await scheduler.cancelled(run.id)
448
+ await finalize(code, cancelled, !cancelled)
449
+ free()
450
+ setImmediate(() => { void runs.nextQueued().then(queued => { if (queued && !shuttingDown) return startJob(queued) }).catch(console.error) })
451
+ } catch (error) { await startFailed(error) }
452
+ })
453
+ const next = await runs.nextQueued()
454
+ if (next && !shuttingDown) await startJob(next)
455
+ })().catch((error) => console.error('Run completion failed', error.message))
456
+ })
457
+ completions.add(completion)
458
+ void completion.finally(() => completions.delete(completion))
358
459
  }
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
- })
460
+ await watch(await launchSetup(selected))
461
+ } catch (error) {
462
+ await startFailed(error)
369
463
  }
370
464
  })
371
465
  }
package/src/menu.ts CHANGED
@@ -4,7 +4,7 @@ import path from 'node:path'
4
4
  import { randomBytes } from 'node:crypto'
5
5
  import { InlineKeyboard, type Context } from 'grammy'
6
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'
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,7 +25,7 @@ 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.
@@ -66,7 +66,7 @@ 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
71
  return { preset, fresh }
72
72
  }
@@ -127,9 +127,9 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
127
127
  const state = (await control.status()).ai
128
128
  if (!state) throw new Error('AI settings not initialized')
129
129
  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 }
130
+ name: `${model.name}${effort ? ` · ${effort}` : ''}`.slice(0, 80), cli: model.cli, provider:model.provider, ...(model.authProfile ? {authProfile:model.authProfile} : {}), model: model.model, effort }
131
131
  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)
132
+ const existing = state.presets.find((preset) => sameChoice(preset, stored))
133
133
  const preset = existing ?? candidate
134
134
  await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : isInstalled)
135
135
  // Private scopes persist their selection on the session, not the owner menu.
@@ -6,7 +6,7 @@ import { createServer } from 'node:net';
6
6
  import { randomBytes, randomUUID, createHash } from 'node:crypto';
7
7
  import { spawn } from 'node:child_process';
8
8
  import { fileURLToPath } from 'node:url';
9
- import { prepareCommand, registry, run as dockerRun, stewardOwned } from './plugins/manager.mjs';
9
+ import { prepareCommand, registry, run as dockerRun, stewardOwned, publishBrokerPlugins } from './plugins/manager.mjs';
10
10
 
11
11
  const MAX_FRAME = 4 * 1024 * 1024;
12
12
  const MAX_ARGS = 100;
@@ -165,15 +165,7 @@ const writeReceipt = async (binding, receipt) => {
165
165
  // Installed plugin versions for relay status. Rewritten on broker start and
166
166
  // after every management call; the relay reads it without Docker access.
167
167
  // Failures stay local so a status snapshot never breaks plugin operations.
168
- const refreshBrokerPlugins = async binding => {
169
- const r = await registry(binding.home);
170
- const plugins = Object.values(r.plugins).map(record => ({ id: record.manifest.id, version: record.manifest.version }));
171
- const file = path.join(binding.controlDir, 'plugin-broker-plugins.json');
172
- const temporary = `${file}.${randomUUID()}.tmp`;
173
- await fs.writeFile(temporary, JSON.stringify({ version: 1, at: new Date().toISOString(), plugins }) + '\n', { mode: 0o600, flag: 'wx' });
174
- await fs.rename(temporary, file);
175
- await stewardOwned(file);
176
- };
168
+ const refreshBrokerPlugins = binding => publishBrokerPlugins(binding.home,binding.controlDir);
177
169
 
178
170
  const response = (socket, value) => {
179
171
  if (socket.destroyed || !socket.writable) return;
@@ -255,6 +247,7 @@ const invokePlugin = async (binding, request, signal, scheduledPreflight = false
255
247
  const command = await prepareCommand(binding.home, request.alias, request.args, {
256
248
  revision: request.revision,
257
249
  invocation: true,
250
+ signal,
258
251
  environment: { EZ_CONTROL_DIR: binding.controlDir, ...(scheduledPreflight ? {} : {EZ_RUN_ID: request.runId}), EZ_DELIVERY_SOCKET: process.env.EZ_DELIVERY_SOCKET ?? deliverySocketPath(binding.controlDir) },
259
252
  });
260
253
  try {
@@ -360,7 +353,7 @@ export async function servePluginBroker(binding, signal = new AbortController().
360
353
  const selected=recordForAlias(await registry(binding.home),check.alias);
361
354
  const result=await invokePlugin(binding,{alias:check.alias,args:check.args,revision:selected.record.revision,runId:`preflight_${request.scheduleId}`,id:randomUUID()},abort.signal,true);
362
355
  if(!result.ok || result.code!==0) throw Error('Scheduled plugin preflight unavailable');
363
- values.push(JSON.parse(result.stdout));
356
+ try { values.push(JSON.parse(result.stdout)); } catch { throw Error('Scheduled plugin preflight returned invalid output'); }
364
357
  }
365
358
  response(socket,{version:1,id:request.id,ok:true,result:combinePreflight(values)});
366
359
  return;
@@ -78,7 +78,8 @@ async function activeInvocations(home) {
78
78
  const directory=invocationDirectory(home),files=await fs.readdir(directory).catch(error=>{if(error.code==='ENOENT')return [];throw error;});
79
79
  for(const file of files) {
80
80
  if(!/^[0-9a-f-]{36}\.json$/.test(file))throw Error('Invalid command invocation lease; inspect before recovery');
81
- const lease=await json(path.join(directory,file));
81
+ const lease=await json(path.join(directory,file)).catch(error=>{if(error.code==='ENOENT')return null;throw error;});
82
+ if(!lease)continue;
82
83
  if(!Number.isSafeInteger(lease.pid)||lease.pid<1||typeof lease.container!=='string'||!lease.container)throw Error('Invalid command invocation lease; inspect before recovery');
83
84
  try {process.kill(lease.pid,0);return true;} catch(error) {if(error.code!=='ESRCH'&&error.code!=='EPERM')throw error;}
84
85
  const state=await run(['container','inspect',lease.container],{capture:true});
@@ -94,21 +95,31 @@ async function invocationLease(home,container) {
94
95
  await stewardOwned(file);
95
96
  return async()=>{await fs.rm(file,{force:true});};
96
97
  }
97
- export async function locked(home, fn, {allowInvocations=false, waitMs=0}={}) {
98
+ export async function locked(home, fn, {allowInvocations=false, waitMs=0, upgradeWaitMs=0, drainInvocations=false, signal}={}) {
98
99
  const lock = path.join(home,'registry.lock');
99
100
  let handle;
100
- const deadline=Date.now()+waitMs;
101
+ const started=Date.now();
101
102
  while(!handle) {
103
+ signal?.throwIfAborted();
102
104
  try { handle=await fs.open(lock,'wx',0o600); }
103
105
  catch(error) {
104
106
  if(error.code!=='EEXIST')throw error;
105
- if(Date.now()>=deadline)throw Error('Registry busy; inspect registry.lock before recovering an interrupted manager');
107
+ const owner=await json(lock).catch(()=>null);
108
+ const limit=owner?.kind==='plugin-upgrade'&&upgradeWaitMs?upgradeWaitMs:waitMs;
109
+ if(Date.now()-started>=limit)throw Error('Registry busy; inspect registry.lock before recovering an interrupted manager');
106
110
  await new Promise(resolve=>setTimeout(resolve,25));
107
111
  }
108
112
  }
109
113
  try {
110
- await handle.writeFile(JSON.stringify({pid:process.pid}));
111
- if(!allowInvocations&&await activeInvocations(home))throw Error('Plugin commands are active; retry the registry or lifecycle change after they finish');
114
+ await handle.writeFile(JSON.stringify({pid:process.pid,...(drainInvocations?{kind:'plugin-upgrade'}:{})}));
115
+ // Keep admission closed while already-started calls release their leases.
116
+ // This is a lifecycle barrier, not an agent-turn drain or a command retry.
117
+ while(!allowInvocations&&await activeInvocations(home)) {
118
+ if(!drainInvocations)throw Error('Plugin commands are active; retry the registry or lifecycle change after they finish');
119
+ signal?.throwIfAborted();
120
+ await new Promise(resolve=>setTimeout(resolve,25));
121
+ }
122
+ signal?.throwIfAborted();
112
123
  return await fn();
113
124
  }
114
125
  finally { await handle.close(); await fs.rm(lock); }
@@ -474,8 +485,16 @@ export async function registry(home) {
474
485
  for(const [alias,plugin] of Object.entries(r.commands)) if(!r.plugins[plugin]?.deployment?.commands?.[alias]) throw Error('Corrupt command registry');
475
486
  return r;
476
487
  }
488
+ // Relay status reads this generated inventory without Docker/registry access.
489
+ export async function publishBrokerPlugins(home,controlDir) {
490
+ const r=await registry(home);
491
+ const plugins=Object.values(r.plugins).map(record=>({id:record.manifest.id,version:record.manifest.version}));
492
+ const file=path.join(controlDir,'plugin-broker-plugins.json');
493
+ await atomic(file,{version:1,at:new Date().toISOString(),plugins});
494
+ await stewardOwned(file);
495
+ }
477
496
  // The lock protects admission and compose refresh, never a persistent connection.
478
- export async function prepareCommand(home,alias,args,{revision,exclude,publish,invocation=false,environment=process.env}={}) {
497
+ export async function prepareCommand(home,alias,args,{revision,exclude,publish,invocation=false,environment=process.env,signal}={}) {
479
498
  strings(args);
480
499
  return locked(home,async()=>{
481
500
  const config=await json(path.join(home,'config.json')),r=await registry(home);
@@ -505,7 +524,7 @@ export async function prepareCommand(home,alias,args,{revision,exclude,publish,i
505
524
  const invocationRelease=invocation?await invocationLease(home,container):undefined;
506
525
  const release=(invocationRelease||contextFile)?(async()=>{try{if(contextFile)await fs.rm(contextFile,{force:true});}finally{await invocationRelease?.();}}):undefined;
507
526
  return {container,plugin:record.manifest.id,revision:record.revision,contextFile,argv:[...composeArgs(record),...(contextFile?['--file',contextFile]:[]),'run','--rm','--no-deps','-T','--name',container,...(publish?['--publish',publish]:[]),'--entrypoint',binding.argv[0],binding.service,...binding.argv.slice(1),...record.manifest.commands[alias].args,...args,...(binding.suffix||[])],release};
508
- },{allowInvocations:true,waitMs:5000});
527
+ },{allowInvocations:true,waitMs:5000,upgradeWaitMs:invocation?300000:0,signal});
509
528
  }
510
529
  export async function init(home,workspace,catalogFile,hostConfig,standalone=false) {
511
530
  if(standalone && hostConfig) throw Error('Standalone setup cannot bind a relay host config');
package/src/runs.ts CHANGED
@@ -6,7 +6,8 @@ import { normalizeReactionEmoji } from './reaction.js'
6
6
  import { validScheduledOrigin, type ScheduledOrigin } from './scheduler.js'
7
7
  import type { IncomingItem } from './inbox.js'
8
8
  import { assertId } from './identity.js'
9
- import { isExecutionChoice, type ExecutionChoice } from './ai.js'
9
+ import { isExecutionChoice, type AiPreset, type ExecutionChoice } from './ai.js'
10
+ import type { SetupAttempt } from './setup-fallback.js'
10
11
  import type { ScriptRunRef } from './scripts.js'
11
12
  import {authorizeDeliveryContext,currentDeliveryOwner,type DeliveryContext} from './delivery-context.mjs'
12
13
 
@@ -32,6 +33,8 @@ export type RunRecord = {
32
33
  pid?: number
33
34
  blockReason?: string
34
35
  execution?: ExecutionChoice
36
+ // Ordered task setups tried for this occurrence; the last one is active.
37
+ attempts?: SetupAttempt[]
35
38
  replyOnly?: boolean
36
39
  exitCode?: number | null
37
40
  failureReason?: string
@@ -129,6 +132,14 @@ const pruneTerminals = (controlDir: string): void => {
129
132
  }
130
133
  }
131
134
 
135
+ // The setup the current attempt runs with: the latest recorded attempt, else the
136
+ // saved choice. Only a setup saved on the task itself is ever admitted.
137
+ export const activeSetup = (run: Pick<RunRecord, 'attempts' | 'execution'>): AiPreset | undefined => {
138
+ if (!run.execution) return undefined
139
+ const preset = [...run.attempts ?? []].reverse().find(attempt => attempt.outcome !== 'skipped')?.preset ?? run.execution.preset
140
+ return [run.execution.preset, ...run.execution.fallbacks ?? []].find(candidate => JSON.stringify(candidate) === JSON.stringify(preset))
141
+ }
142
+
132
143
  export const newRunId = (): string => `r_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`
133
144
 
134
145
  export class RunStore {
@@ -198,7 +209,7 @@ export class RunStore {
198
209
 
199
210
  async patch(
200
211
  id: string,
201
- change: Partial<Pick<RunRecord, 'status' | 'startedAt' | 'endedAt' | 'pid' | 'nativeSessionId' | 'interrupted' | 'blockReason' | 'backendSubmitted' | 'replyOnly' | 'exitCode' | 'failureReason' | 'failure' | 'failureReview' | 'externalReleased' | 'output' | 'timedOut'>>,
212
+ change: Partial<Pick<RunRecord, 'status' | 'startedAt' | 'endedAt' | 'pid' | 'nativeSessionId' | 'interrupted' | 'blockReason' | 'backendSubmitted' | 'replyOnly' | 'exitCode' | 'failureReason' | 'failure' | 'failureReview' | 'externalReleased' | 'output' | 'timedOut' | 'attempts'>>,
202
213
  ): Promise<RunRecord> {
203
214
  const store = runsFor(this.controlDir)
204
215
  const run = store.get(id)