@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/CHANGELOG.md +9 -0
- package/docker/broker-update-smoke.mjs +16 -2
- package/docker/upgrade-smoke.mjs +1 -1
- package/docs/architecture/ai-selection.md +5 -1
- package/docs/docker-runtime.md +28 -0
- package/docs/scheduling.md +41 -0
- package/docs/upgrades.md +4 -10
- package/package.json +1 -1
- package/src/ai-cli.ts +28 -15
- package/src/ai.ts +76 -12
- package/src/application-channel.ts +9 -9
- 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 +29 -26
- package/src/host-executor.ts +35 -10
- package/src/index.ts +206 -112
- package/src/menu.ts +5 -5
- package/src/plugin-broker.mjs +4 -11
- package/src/plugins/manager.mjs +27 -8
- package/src/runs.ts +13 -2
- package/src/schedule-cli.ts +33 -7
- package/src/scheduler.ts +9 -4
- package/src/setup-fallback.ts +122 -0
- package/src/task-executor.ts +3 -2
- package/src/updates/runtime.mjs +79 -41
- package/src/updates/supervisor.mjs +27 -6
- package/test/auth-profiles.test.ts +187 -0
- package/test/plugin-broker.test.mjs +3 -0
- package/test/plugin-manager.test.mjs +24 -0
- package/test/setup-fallback.test.ts +245 -0
- package/test/updates.test.mjs +58 -5
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
|
-
|
|
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
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
if (
|
|
343
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
|
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
|
-
})
|
|
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
|
|
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
|
|
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
|
|
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.
|
package/src/plugin-broker.mjs
CHANGED
|
@@ -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 =
|
|
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;
|
package/src/plugins/manager.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|