@jc_stack/ez-agents 0.1.0-beta.52 → 0.1.0-beta.58

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
@@ -11,7 +11,7 @@ import { dispatchChannel } from './channel-backend.js'
11
11
  import { randomUUID } from 'node:crypto'
12
12
  import { mkdir, stat, writeFile } from 'node:fs/promises'
13
13
  import { Scheduler } from './scheduler.js'
14
- import { Scripts, DEFAULT_SCRIPT_TIMEOUT_SECONDS } from './scripts.js'
14
+ import { Scripts } 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,12 +25,13 @@ 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, type SessionState } from './control-state.js'
28
+ import { ControlStore, telegramOwner, ownerId, ownerEpoch, type Owner, type SessionState } from './control-state.js'
29
29
  import { ApprovalStore } from './approval.js'
30
30
  import { CliUnavailableError, startExecutorJob, attachHostJob, terminateJob, opencodeDataHome, claudeConfigHome, usesFileTransport } from './executor.js'
31
- import { attemptFailure, continuationLine, nextSetup, quotaScope, setupCandidates, startupObserver, unavailableSummary, SetupsUnavailableError, type SetupAttempt } from './setup-fallback.js'
31
+ import { attemptFailure, continuationLine, nextSetup, quotaScope, quotaWait, quotaCategory, setupCandidates, startupObserver, unavailableSummary, SetupsUnavailableError, type SetupAttempt } from './setup-fallback.js'
32
+ import { telemetryObserver } from './run-telemetry.js'
32
33
  import { assertScheduledModel } from './model-policy.js'
33
- import { RunStore, saveRunHandoff, takeRunHandoff, type RunRecord } from './runs.js'
34
+ import { RunStore, activeSetup, saveRunHandoff, takeRunHandoff, type RunRecord } from './runs.js'
34
35
  import { splitTelegramText } from './reply.js'
35
36
  import { markdownToTelegramHtml, escapeHtml } from './format.js'
36
37
  import { sanitizeFileName, stageChatAttachment, workspaceFile } from './files.js'
@@ -95,6 +96,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
95
96
  let activeTypingTimer: ReturnType<typeof setInterval> | null = null
96
97
  let activeBackend = false
97
98
  const ownerStopped = new WeakSet<ChildProcess>()
99
+ // Runs the owner stopped; an ordered run never advances to its next setup.
100
+ const stoppedRuns = new Set<string>()
98
101
  // Host-transport executors outlive this relay; a graceful stop hands them off.
99
102
  const hostTransport = async () => !config.channelBackendUrl && await usesFileTransport(config.controlDir)
100
103
  const childRuns = new WeakMap<ChildProcess, string>()
@@ -111,7 +114,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
111
114
  nextSendAt = Date.now() + 1000
112
115
  }
113
116
  let startLock: Promise<void> = Promise.resolve()
114
- const withStartLock = (work: () => Promise<void>): Promise<void> => {
117
+ const withStartLock = <T>(work: () => Promise<T>): Promise<T> => {
115
118
  const next = startLock.then(work, work)
116
119
  startLock = next.then(
117
120
  () => undefined,
@@ -150,8 +153,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
150
153
  const child = background.get(id)
151
154
  if (child) { await scheduler.cancel(id); terminateJob(child); return }
152
155
  if (!activeChild || (await runs.running(false))?.id !== id) throw new Error('Application cancellation unavailable')
156
+ stoppedRuns.add(id)
153
157
  ownerStopped.add(activeChild)
154
- terminateJob(activeChild)
158
+ void terminateJob(activeChild).then(stopped => { if (!stopped) stoppedRuns.delete(id) })
155
159
  }
156
160
  }),
157
161
  })
@@ -188,6 +192,17 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
188
192
  const telegramSource = telegramEnabled ? new TelegramSource(config.controlDir, config.telegramBotToken.split(':')[0], sendChat,
189
193
  async(chatId,text,bytes,filename)=>[(await bot!.api.sendDocument(chatId,new InputFile(bytes,filename),{caption:text})).message_id]) : null
190
194
 
195
+ const setupAvailability = async (owner: Owner, runId?: string) => {
196
+ const health = await control.scheduledSetupHealth(owner)
197
+ // Only one waiting occurrence probes a login until structured work is accepted.
198
+ for (const active of await runs.list()) {
199
+ if (active.id === runId || active.status !== 'running' || !active.quotaWait) continue
200
+ const preset = activeSetup(active)
201
+ if (preset) health[quotaScope(preset)] = { ...health[quotaScope(preset)], manualRetry: true }
202
+ }
203
+ return health
204
+ }
205
+
191
206
  const startJob = async (run: RunRecord): Promise<void> => {
192
207
  await withStartLock(async () => {
193
208
  if (shuttingDown || activeBackend) return
@@ -253,235 +268,326 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
253
268
  await releaseExternal(run)
254
269
  return
255
270
  }
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
- })
271
+ if (run.scheduled && !run.script) {
272
+ const waiting = quotaWait(setupCandidates(run.execution!), await setupAvailability(owner, run.id))
273
+ if (waiting) { await runs.patch(run.id, { quotaWait: waiting }); return }
275
274
  }
276
- try {
277
- const launchStarted = performance.now()
278
- const started = await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
279
- // A registered script occurrence: no model, prompt or native session.
280
- const script = run.script && run.scheduled
281
- ? { registration: await new Scripts(config.controlDir).owned(run.script.id, owner), ref: run.script, scheduled: run.scheduled }
282
- : undefined
283
- if (run.script && !script) throw new Error('Script runs require a scheduled occurrence')
284
- if (!started.execution && !run.taskId && !script) throw new Error('Legacy queued work has no pinned AI. Resend the request after /new.')
285
- const startsOwnSession = Boolean(run.external || run.taskId || run.scheduled)
286
- const session = startsOwnSession
287
- ? { sessionId: randomUUID(), hasStarted: false, nativeSessionId: undefined }
288
- : await control.executionSession(started.execution!)
289
- const selected = script ? initialPreset('codex') : run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : started.execution!.preset
290
- if (run.scheduled && !script) assertScheduledModel(selected.model)
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() }]
275
+ await watchRun(run, texts, owner)
276
+ })
277
+ }
278
+
279
+ const cancelUnobservedHostRun = async (id: string): Promise<void> => {
280
+ const base = join(config.controlDir, 'host-executor', id)
281
+ const exists = (suffix: string) => stat(base + suffix).then(() => true, e => { if (e.code === 'ENOENT') return false; throw e })
282
+ if (await exists('.request.json') || await exists('.running.json')) await writeFile(base + '.cancel', '', { mode: 0o600 })
283
+ }
284
+
285
+ // First launches, adopted host sessions and fallback launches share one watcher.
286
+ const watchRun = async (run: RunRecord, texts: string[], owner: Owner, adopted = false): Promise<void> => {
287
+ // Shared by a first launch and a fallback launch that cannot start.
288
+ const startFailed = async (error: unknown) => {
289
+ if (!run.scheduled && activeTypingTimer) {
290
+ clearInterval(activeTypingTimer)
291
+ activeTypingTimer = null
292
+ }
293
+ if (run.scheduled) background.delete(run.id)
294
+ // A later ordered setup can fail after an earlier attempt held the slot.
295
+ else if (activeChild && childRuns.get(activeChild) === run.id) activeChild = null
296
+ stoppedRuns.delete(run.id)
297
+ const unavailable = error instanceof SetupsUnavailableError
298
+ if (unavailable && run.scheduled && run.execution) {
299
+ const owner = (await control.status()).owner
300
+ if (owner && ownsRun(owner, run) && await scheduler.current(run, owner) && !await scheduler.cancelled(run.id)) {
301
+ const waiting = quotaWait(setupCandidates(run.execution), await setupAvailability(owner, run.id))
302
+ if (waiting) {
303
+ await runs.patch(run.id, { status: 'queued', quotaWait: waiting, pid: undefined })
304
+ return
305
+ }
298
306
  }
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
307
+ }
308
+ await runs.patch(run.id, { status: 'failed', failureReason: unavailable ? 'setups-unavailable' : 'executor-start', failure: await failureEvidence(config.controlDir, safeError(error)), endedAt: new Date().toISOString() })
309
+ await releaseExternal(run)
310
+ console.error('run start failed', run.id, safeError(error))
311
+ if (bot && !run.application && !run.delivery && run.chatId !== undefined)
312
+ await sendChat(run.chatId, unavailable ? `Run ${run.id} could not start. ${safeError(error)}` : `Run ${run.id} failed to start. Check the local relay log.`)
313
+ setImmediate(() => {
314
+ void runs
315
+ .nextQueued()
316
+ .then((next) => next && startJob(next))
317
+ .catch(console.error)
318
+ })
319
+ }
320
+ try {
321
+ const launchStarted = performance.now()
322
+ const started = adopted ? run : await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
323
+ // A registered script occurrence: no model, prompt or native session.
324
+ const script = run.script && run.scheduled
325
+ ? { registration: await new Scripts(config.controlDir).owned(run.script.id, owner), ref: run.script, scheduled: run.scheduled }
326
+ : undefined
327
+ if (run.script && !script) throw new Error('Script runs require a scheduled occurrence')
328
+ if (!started.execution && !run.taskId && !script) throw new Error('Legacy queued work has no pinned AI. Resend the request after /new.')
329
+ const startsOwnSession = Boolean(run.external || run.taskId || run.scheduled)
330
+ const session = startsOwnSession
331
+ ? { sessionId: randomUUID(), hasStarted: false, nativeSessionId: undefined }
332
+ : await control.executionSession(started.execution!)
333
+ const selected = script ? initialPreset('codex') : run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : (adopted ? activeSetup(started) : started.execution!.preset)
334
+ if (!selected) throw new Error('Handed-off run has no authorized saved setup')
335
+ if (run.scheduled && !script) assertScheduledModel(selected.model)
336
+ // Owner-enabled telemetry is fixed for the run once it starts.
337
+ const telemetryOn = !script && !run.taskId && (adopted ? Boolean(run.telemetry) : Boolean((await control.status()).runTelemetry))
338
+ if (telemetryOn && !adopted) await runs.patch(run.id, { telemetry: true })
339
+ // A scheduled occurrence, or chat/application work with a captured chain,
340
+ // may name ordered fallback setups. They share this run, workspace and
341
+ // execution slot; each is tried at most once. Telemetry also records attempts.
342
+ const chat = !startsOwnSession
343
+ const ordered = Boolean(!script && !run.taskId && (run.scheduled || started.execution?.fallbacks?.length || telemetryOn))
344
+ const candidates = run.scheduled && !script ? setupCandidates(started.execution!, await control.scheduledSetupHealth(owner))
345
+ : ordered ? [started.execution!.preset, ...started.execution!.fallbacks ?? []] : [selected]
346
+ const runCancelled = async () => stoppedRuns.has(run.id) || Boolean(run.scheduled && await scheduler.cancelled(run.id))
347
+ // Authority is rechecked before every later setup.
348
+ const authorized = async (current: Owner) => ownsRun(current, run) && (run.scheduled ? await scheduler.current(run, current)
349
+ : !executionBlockReason(run, current) && (!(run.application || run.delivery) || await applicationChannel.bindings.authorize(run).then(() => true, () => false)))
350
+ let attempts: SetupAttempt[] = [...run.attempts ?? []]
351
+ // Keep audit history and partial-work facts; a wake starts a new attempt round.
352
+ const roundStart = adopted ? run.attemptRound ?? 0 : attempts.length
353
+ if (ordered && !adopted) await runs.patch(run.id, { attemptRound: roundStart })
354
+ const settle = (change: Partial<SetupAttempt>) => {
355
+ attempts = [...attempts.slice(0, -1), { ...attempts[attempts.length - 1], ...change, endedAt: new Date().toISOString() }]
356
+ }
357
+ // Advance only after an eligible failure (see attemptFailure) of a current,
358
+ // uncancelled occurrence. Setups in an exhausted scope are skipped and recorded.
359
+ // Owner-present work (chat, application, event sources) never refuses on
360
+ // recorded quota health alone: when every saved login is cooling down, it
361
+ // tries them in order instead.
362
+ let ignoreHealth = false
363
+ const advance = async (): Promise<AiPreset | undefined> => {
364
+ let preset: AiPreset | undefined
365
+ const current = !shuttingDown && !await runCancelled() ? (await control.status()).owner : undefined
366
+ if (current && await authorized(current)) {
367
+ const round = attempts.slice(roundStart)
368
+ let next = nextSetup(candidates, round, ignoreHealth ? {} : await setupAvailability(current, run.id))
369
+ if (!next.preset && !run.scheduled && !round.length && !ignoreHealth) { ignoreHealth = true; next = nextSetup(candidates, round) }
370
+ attempts = [...attempts, ...next.skipped]
371
+ if (!next.preset) {
372
+ await runs.patch(run.id, { attempts })
373
+ // Owner-present work whose setup already began work ends as that
374
+ // run's failure; it did start.
375
+ if (!run.scheduled && round.some(a => a.category?.endsWith('after-work-began'))) return undefined
376
+ throw new SetupsUnavailableError(unavailableSummary(attempts))
312
377
  }
313
- await runs.patch(run.id, { attempts })
314
- return preset
378
+ preset = next.preset
315
379
  }
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() }]
380
+ await runs.patch(run.id, { attempts })
381
+ return preset
382
+ }
383
+ // A scheduled attempt is a fresh native session: a rejected Claude attempt
384
+ // already claimed its ID, and a session cannot resume under another login's
385
+ // home. An ordered chat attempt resumes its own login's conversation session.
386
+ type Launched = { child: ChildProcess; cleanup: () => Promise<void>; preset: AiPreset; resumed?: boolean }
387
+ const launchSetup = async (preset: AiPreset): Promise<Launched> => {
388
+ for (;;) {
389
+ const setup = preset
390
+ const engine = chat && ordered ? await control.engineSession(session.sessionId, setup) : undefined
391
+ const sessionId = engine ? engine.nativeSessionId || engine.sessionId : attempts.length ? randomUUID() : session.nativeSessionId || session.sessionId
392
+ const resumed = engine ? engine.hasStarted : session.hasStarted
393
+ const continuation = continuationLine(started.id, attempts)
394
+ if (ordered) {
395
+ attempts = [...attempts, { preset, quotaScope: quotaScope(preset), outcome: 'running', sessionId, startedAt: new Date().toISOString() }]
396
+ await runs.patch(run.id, { attempts })
397
+ }
398
+ try {
399
+ return { ...await launch(continuation ? [...texts, continuation] : texts, script ? {
400
+ workspace: config.workspace, timeoutMs: 0, repairEnabled: config.repairEnabled, runId: started.id,
401
+ controlDir: config.controlDir, binDir,
402
+ script: { ...script.ref, scheduleId: script.scheduled.id, scheduleRevision: script.scheduled.revision, dueAt: script.scheduled.dueAt },
403
+ } : {
404
+ workspace: config.workspace,
405
+ timeoutMs: config.executorTimeoutMs,
406
+ repairEnabled: config.repairEnabled,
407
+ runId: started.id,
408
+ controlDir: config.controlDir,
409
+ binDir,
410
+ cli: preset.cli,
411
+ model: preset.model,
412
+ effort: preset.effort,
413
+ provider: preset.provider,
414
+ authProfile: preset.authProfile,
415
+ codexAutoCompactTokens: config.codexAutoCompactTokens,
416
+ codexSandbox: !run.taskId && preset.cli === 'codex' ? config.codexSandbox : undefined,
417
+ sessionId,
418
+ isResume: resumed,
419
+ promptSuffix: runPromptSuffix(started),
420
+ taskRun: Boolean(run.taskId),
421
+ nativeSession: Boolean(run.scheduled),
422
+ structuredOutput: ordered,
423
+ telemetry: telemetryOn,
424
+ onSession: run.external || run.taskId ? undefined : async (id) => {
425
+ if (ordered) attempts = attempts.map(a => a.sessionId === sessionId ? { ...a, sessionId: id } : a)
426
+ await runs.patch(run.id, { nativeSessionId: id, ...(ordered ? { attempts } : {}) })
427
+ if (engine) await control.saveEngineSession(session.sessionId, setup, id)
428
+ else if (!run.scheduled) await control.saveNativeSession(session.sessionId, id)
429
+ },
430
+ }), preset, resumed }
431
+ } catch (error) {
432
+ if (ordered && !(error instanceof CliUnavailableError)) {
433
+ settle({ outcome: 'failed', category: 'uncertain' })
324
434
  await runs.patch(run.id, { attempts })
325
435
  }
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
- }
436
+ if (!ordered || !(error instanceof CliUnavailableError)) throw error
437
+ settle({ outcome: 'failed', category: 'cli-unavailable' })
438
+ const next = await advance()
439
+ if (!next) throw error
440
+ preset = next
363
441
  }
364
442
  }
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
- })
443
+ }
444
+ const watch = async ({ child, cleanup, preset, resumed }: Launched): Promise<void> => {
445
+ const executionStarted = performance.now()
446
+ // Attach before disk writes: a fast child can close while PID persistence
447
+ // is pending, and Node drains its remaining pipes during process close.
448
+ let failureReason = 'executor-exit', errorTail = '', interrupted = false, outputTail = '', timedOut = false
449
+ // Script output is bounded diagnostics for run inspection, never chat.
450
+ if (script) child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { outputTail = (outputTail + chunk).slice(-16384) })
451
+ // Structured startup events decide whether a failed setup may advance.
452
+ const startup = ordered ? startupObserver() : undefined
453
+ let probing = Boolean(run.quotaWait)
454
+ // A resumed Codex session reports its session-wide total.
455
+ const telemetry = telemetryOn ? telemetryObserver({ cumulativeSession: Boolean(resumed) && ['codex', 'codex-gui'].includes(preset.cli) }) : undefined
456
+ if (telemetry) child.stdout?.setEncoding('utf8').on('data', (chunk: string) => telemetry.write(chunk))
457
+ if (startup) child.stdout?.setEncoding('utf8').on('data', (chunk: string) => {
458
+ startup.write(chunk)
459
+ if (probing && startup.accepted()) {
460
+ probing = false
461
+ void runs.patch(run.id, { quotaWait: undefined }).catch(console.error)
462
+ }
463
+ })
464
+ const scriptTimer = script ? setTimeout(() => { timedOut = true; terminateJob(child) }, Math.max(0,
465
+ script.registration.timeoutSeconds * 1000 - (adopted ? Date.now() - Date.parse(started.startedAt ?? started.createdAt) : 0))) : undefined
466
+ child.once('close', () => clearTimeout(scriptTimer))
467
+ child.stderr?.setEncoding('utf8').on('data', (chunk: string) => {
468
+ errorTail = (errorTail + chunk).slice(-16384)
469
+ if (script) outputTail = (outputTail + chunk).slice(-16384)
470
+ if (chunk.includes('Host CLI executor is offline')) failureReason = 'host-executor-offline'
471
+ if (chunk.includes('Host executor client interrupted by')) {
472
+ failureReason = 'host-executor-transport-interrupted'
473
+ interrupted = true
474
+ }
475
+ if (chunk.includes('outcome is unknown')) {
476
+ failureReason = 'handoff-outcome-unknown'
477
+ interrupted = true
478
+ }
479
+ if (chunk.trim()) console.error('executor stderr', started.id, chunk.trim())
480
+ })
481
+ console.info('run timing', { run_id: run.id, phase: 'launch',
482
+ queue_ms: Math.max(0, Date.parse(started.startedAt!) - Date.parse(run.createdAt)),
483
+ startup_ms: Math.round(executionStarted - launchStarted), resumed: session.hasStarted })
484
+ if (run.scheduled) background.set(run.id,child)
485
+ else activeChild = child
486
+ childRuns.set(child, run.id)
487
+ // A stop that arrived while this setup was launching reached only the
488
+ // previous attempt; stop the new one before it does any work.
489
+ if (await runCancelled()) { ownerStopped.add(child); void terminateJob(child) }
490
+ const finished = new Promise<number | null>((resolve) => {
491
+ if (child.exitCode !== null || child.signalCode !== null) resolve(child.exitCode)
492
+ else child.once('close', resolve)
493
+ })
494
+ await runs.patch(started.id, { pid: child.pid })
495
+ console.info('run started', {
496
+ run_id: started.id,
497
+ pid: child.pid,
498
+ cli: script ? 'script' : preset.cli,
499
+ ...(script ? { script: script.ref.id, script_revision: script.ref.revision } : {}),
500
+ ...(ordered ? { setup: attempts.length } : {}),
501
+ session: session.sessionId,
502
+ isResume: session.hasStarted,
503
+ })
407
504
 
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)
505
+ if (!run.scheduled && activeTypingTimer) clearInterval(activeTypingTimer)
506
+ if (bot && run.chatId !== undefined && !run.application && !run.external && !run.taskId && !run.scheduled) activeTypingTimer = setInterval(() => {
507
+ if (performance.now() - executionStarted < 30000) void bot.api.sendChatAction(run.chatId!, 'typing').catch(() => {})
508
+ }, 4000)
412
509
 
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
- }
510
+ const free = () => {
511
+ if (run.scheduled) background.delete(run.id)
512
+ else {
513
+ activeChild = null
514
+ if (activeTypingTimer) clearInterval(activeTypingTimer)
515
+ activeTypingTimer = null
420
516
  }
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()
458
- }
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))
479
517
  }
480
- await watch(await launchSetup(selected))
481
- } catch (error) {
482
- await startFailed(error)
518
+ const finalize = async (code: number | null, cancelled: boolean, failed: boolean) => {
519
+ stoppedRuns.delete(run.id)
520
+ await runs.patch(started.id, { status: cancelled ? 'cancelled' : failed ? 'failed' : 'completed', quotaWait: undefined, endedAt: new Date().toISOString(), exitCode: code,
521
+ ...(script ? { output: redactFailure(safeError(outputTail)), ...(timedOut ? { timedOut } : {}) } : {}),
522
+ ...(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}`}`)) } : {}) })
523
+ await releaseExternal(run)
524
+ }
525
+ const completion = finished.then((code) => {
526
+ console.info('run timing', { run_id: run.id, phase: 'execution',
527
+ execution_ms: Math.round(performance.now() - executionStarted), exit_code: code })
528
+ return (async () => {
529
+ let eligible = false
530
+ await withStartLock(async () => {
531
+ try {
532
+ await cleanup()
533
+ // The next relay records a handed-off run's result.
534
+ if (handedOff.has(child)) return
535
+ const observed = startup?.result()
536
+ if (chat && ordered) await control.settleEngineSession(session.sessionId, preset, code === 0 || Boolean(observed?.workBegan))
537
+ else if (code === 0 && chat) await control.markSessionStarted(session.sessionId)
538
+ const cancelled = !timedOut && (ownerStopped.has(child) || await runCancelled())
539
+ if (timedOut) failureReason = 'script-timeout'
540
+ const rejected = observed?.rejection && !observed.workBegan ? observed.rejection : undefined
541
+ // A rejection before work is a failure even when the CLI exits 0.
542
+ const failed = !cancelled && (code !== 0 || timedOut || Boolean(rejected) || quotaCategory(observed?.rejection?.category))
543
+ if (startup) {
544
+ const { continues, ...failure } = failed ? attemptFailure(observed!) : { continues: false }
545
+ settle({ outcome: cancelled ? 'cancelled' : failed ? 'failed' : 'completed', exitCode: code, ...failure, ...(telemetry ? { telemetry: telemetry.result() } : {}) })
546
+ if (!cancelled) await control.recordSetupOutcome(owner, attempts.at(-1)!)
547
+ await runs.patch(run.id, { attempts })
548
+ eligible = continues && !interrupted
549
+ }
550
+ if (!eligible) await finalize(code, cancelled, failed)
551
+ } catch (error) {
552
+ eligible = false
553
+ 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() })
554
+ console.error('Session completion failed', safeError(error))
555
+ } finally {
556
+ // An advancing occurrence keeps its concurrency slot for the next setup.
557
+ if (!eligible) free()
558
+ }
559
+ })
560
+ console.info('run ended', { run_id: started.id, code, ...(eligible ? { advancing: true } : {}) })
561
+ // Re-check cancellation and shutdown under the start lock, so a stop
562
+ // that already snapshotted children never misses the next setup.
563
+ if (eligible) return withStartLock(async () => {
564
+ try {
565
+ const next = await advance()
566
+ if (next) return await watch(await launchSetup(next))
567
+ const cancelled = await runCancelled()
568
+ await finalize(code, cancelled, !cancelled)
569
+ free()
570
+ setImmediate(() => { void runs.nextQueued().then(queued => { if (queued && !shuttingDown) return startJob(queued) }).catch(console.error) })
571
+ } catch (error) { await startFailed(error) }
572
+ })
573
+ const next = await runs.nextQueued()
574
+ if (next && !shuttingDown) await startJob(next)
575
+ })().catch((error) => console.error('Run completion failed', error.message))
576
+ })
577
+ completions.add(completion)
578
+ void completion.finally(() => completions.delete(completion))
483
579
  }
484
- })
580
+ const first = ordered && !adopted ? await advance() : selected
581
+ if (!first) throw new Error('Scheduled execution authority revoked')
582
+ await watch(adopted ? { child: await attachHostJob(config.controlDir, run.id), cleanup: async () => {}, preset: selected } : await launchSetup(first))
583
+ } catch (error) {
584
+ if (adopted) {
585
+ await cancelUnobservedHostRun(run.id).catch(() => {})
586
+ await runs.patch(run.id, { status: 'failed', endedAt: new Date().toISOString(), interrupted: true, failureReason: 'handoff-outcome-unknown', failure: await failureEvidence(config.controlDir, `Handed-off run outcome is unknown: ${safeError(error)}`) })
587
+ return
588
+ }
589
+ await startFailed(error)
590
+ }
485
591
  }
486
592
 
487
593
  const unavailableSources = new Set<string>()
@@ -775,7 +881,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
775
881
  // the single delivery executor; the socket only admits work into it.
776
882
  let drainingHandoff=false
777
883
  let deliveryServer: { socketPath: string; stop: () => Promise<void> } | null = null
778
- const handleDeliveryOp = createLedgerHandler(config.controlDir, {
884
+ const ledgerHandler = createLedgerHandler(config.controlDir, {
779
885
  wake: () => { void drainOutbox() },
780
886
  status: () => ({
781
887
  polling: Boolean(bot?.isRunning()),
@@ -784,6 +890,14 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
784
890
  version: packageVersion,
785
891
  }),
786
892
  })
893
+ // Read idle status only after admissions already inside startJob finish.
894
+ // The updater closes admission before asking for this barrier.
895
+ const handleDeliveryOp: typeof ledgerHandler = op => ['status', 'retryScheduleQuota'].includes(op.op)
896
+ ? withStartLock(() => ledgerHandler(op)).then(result => {
897
+ if (op.op === 'retryScheduleQuota') void drainSources().catch(console.error)
898
+ return result
899
+ })
900
+ : ledgerHandler(op)
787
901
 
788
902
  const checkOwner = async (ctx: Context): Promise<boolean> => {
789
903
  if (!ctx.from || ctx.from.is_bot || ctx.message?.sender_chat) return false
@@ -815,6 +929,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
815
929
  { command: 'status', description: 'Work, queue and delivery health' },
816
930
  { command: 'stop', description: 'Stop active work; keep queued messages' },
817
931
  { command: 'cancel', description: 'Cancel pending messages; keep active work' },
932
+ { command: 'retry_quota', description: 'Retry quota-blocked work after a reset or added credits' },
818
933
  { command: 'retry', description: 'Retry the latest failed incoming batch' },
819
934
  { command: 'new', description: 'New conversation; keep workspace files' },
820
935
  ]
@@ -882,6 +997,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
882
997
  `Current: ${running ? (waitingForHost ? 'waiting for the workspace' : 'running') : 'idle'}`,
883
998
  `Scheduled limit: ${config.scheduledConcurrency ?? 6}`,
884
999
  `Background: ${scheduled ? `${scheduled} scheduled task${scheduled === 1 ? '' : 's'} running` : 'none'}`,
1000
+ ...all.filter(r => r.status === 'queued' && r.quotaWait).map(r => `Waiting for quota: ${r.scheduled?.id ?? r.id}${r.quotaWait!.retryAt ? ` · next check ${r.quotaWait!.retryAt}` : ' · awaiting availability or /retry_quota'}`),
885
1001
  `Queue: ${queued || incoming.pending ? `${queued} run${queued === 1 ? '' : 's'}; ${incoming.pending} incoming message${incoming.pending === 1 ? '' : 's'}` : 'empty'}`,
886
1002
  ...(attention.length ? ['', 'Needs attention', ...attention] : []),
887
1003
  '',
@@ -898,7 +1014,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
898
1014
  if (activeChild) children.unshift([undefined, activeChild])
899
1015
  let requested = true
900
1016
  for (const [id, child] of children) {
901
- if (!await terminateJob(child)) { requested = false; continue }
1017
+ const runId = childRuns.get(child)
1018
+ if (runId) stoppedRuns.add(runId)
1019
+ if (!await terminateJob(child)) { requested = false; if (runId) stoppedRuns.delete(runId); continue }
902
1020
  ownerStopped.add(child)
903
1021
  if (id) await scheduler.cancel(id)
904
1022
  }
@@ -1024,6 +1142,16 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1024
1142
  .map((c) => `/${c.command} — ${c.description}`).join('\n'))
1025
1143
  return
1026
1144
  }
1145
+ if (typeof text === 'string' && /^\/retry[_-]quota(?:\s|$)/.test(text)) {
1146
+ const id = text.split(/\s+/)[1]
1147
+ try {
1148
+ if (text.split(/\s+/).length > 2) throw new Error('Usage: /retry_quota [RUN_ID]')
1149
+ const result = await withStartLock(async () => scheduler.retryQuota((await control.status()).owner!, runs, id))
1150
+ void drainSources().catch(console.error)
1151
+ await ctx.reply(result.runIds.length ? `Quota retry queued for ${result.runIds.length} run(s).` : 'No queued quota-blocked work. Use /retry_quota RUN_ID for an earlier quota failure.')
1152
+ } catch (error) { await ctx.reply(safeError(error)) }
1153
+ return
1154
+ }
1027
1155
  if (text === '/retry') {
1028
1156
  const id = await inbox.retryLatest(ctx.from.id, ctx.chat.id, (await control.status()).owner)
1029
1157
  if (id) scheduleIntake()
@@ -1305,59 +1433,20 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1305
1433
  // Admit every record before the first attach, so no trigger races a hold.
1306
1434
  const admitted = (await Promise.all(handed.map(run => runs.adopt(run).catch(() => null)))).filter((run): run is RunRecord => run !== null)
1307
1435
  for (const run of admitted) await withStartLock(async () => {
1308
- let child: ChildProcess
1436
+ let owner: Owner
1309
1437
  try {
1438
+ const current = (await control.status()).owner
1439
+ if (!current || !ownsRun(current, run)) throw new Error('Handed-off run authority revoked')
1440
+ owner = current
1310
1441
  // The foreground lane is serial; a second foreground handoff is corrupt.
1311
1442
  if (!run.scheduled && activeChild) throw new Error('A second handed-off foreground run was cancelled; its outcome is unknown')
1312
- child = await attachHostJob(config.controlDir, run.id)
1313
1443
  } catch (error) {
1314
1444
  // Never leave an unobserved executor: ask the host to stop it.
1315
- await mkdir(join(config.controlDir, 'host-executor'), { recursive: true, mode: 0o700 })
1316
- .then(() => writeFile(join(config.controlDir, 'host-executor', run.id + '.cancel'), '', { mode: 0o600 })).catch(() => {})
1445
+ await cancelUnobservedHostRun(run.id).catch(() => {})
1317
1446
  await runs.patch(run.id, { status: 'failed', endedAt: new Date().toISOString(), interrupted: true, failureReason: 'handoff-outcome-unknown', failure: await failureEvidence(config.controlDir, safeError(error)) })
1318
1447
  return
1319
1448
  }
1320
- // Listen before any await so a fast exit cannot be missed.
1321
- const closed = new Promise<number | null>(resolve => child.once('close', resolve))
1322
- let errorTail = '', outputTail = '', timedOut = false
1323
- child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { if (run.script) outputTail = (outputTail + chunk).slice(-16384) })
1324
- child.stderr?.setEncoding('utf8').on('data', (chunk: string) => {
1325
- errorTail = (errorTail + chunk).slice(-16384)
1326
- if (run.script) outputTail = (outputTail + chunk).slice(-16384)
1327
- })
1328
- if (run.scheduled) background.set(run.id, child)
1329
- else activeChild = child
1330
- childRuns.set(child, run.id)
1331
- await runs.patch(run.id, { pid: child.pid })
1332
- console.info('run adopted', { run_id: run.id, pid: child.pid })
1333
- // A script keeps its registered bound from its original start.
1334
- const limit = run.script ? await new Scripts(config.controlDir).get(run.script.id).then(script => script.timeoutSeconds, () => DEFAULT_SCRIPT_TIMEOUT_SECONDS) : 0
1335
- const timer = limit ? setTimeout(() => { timedOut = true; terminateJob(child) }, Math.max(0, Date.parse(run.startedAt ?? run.createdAt) + limit * 1000 - Date.now())) : undefined
1336
- const completion = closed.then(code => withStartLock(async () => {
1337
- clearTimeout(timer)
1338
- if (run.scheduled) background.delete(run.id)
1339
- else if (activeChild === child) activeChild = null
1340
- if (handedOff.has(child)) return
1341
- const cancelled = !timedOut && (ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id)))
1342
- const failed = !cancelled && code !== 0
1343
- const status = cancelled ? 'cancelled' : failed ? 'failed' : 'completed'
1344
- const unknown = errorTail.includes('outcome is unknown'), transport = errorTail.includes('Host executor client interrupted by')
1345
- const failureReason = timedOut ? 'script-timeout' : unknown ? 'handoff-outcome-unknown' : transport ? 'host-executor-transport-interrupted'
1346
- : errorTail.includes('Host CLI executor is offline') ? 'host-executor-offline' : 'executor-exit'
1347
- if (code === 0 && run.execution && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(run.execution.sessionId)
1348
- const at = new Date().toISOString(), last = run.attempts?.at(-1)
1349
- await runs.patch(run.id, { status, endedAt: at, exitCode: code,
1350
- ...(last?.outcome === 'running' ? { attempts: [...run.attempts!.slice(0, -1), { ...last, outcome: status, exitCode: code, endedAt: at }] } : {}),
1351
- ...(run.script ? { output: redactFailure(safeError(outputTail)), ...(timedOut ? { timedOut } : {}) } : {}),
1352
- ...(failed ? { failureReason, interrupted: unknown || transport, failure: await failureEvidence(config.controlDir, safeError(errorTail || `Executor exited with ${code === null ? 'a signal' : `code ${code}`}`)) } : {}) })
1353
- await releaseExternal(run)
1354
- console.info('run ended', { run_id: run.id, code, adopted: true })
1355
- })).then(async () => {
1356
- const next = await runs.nextQueued()
1357
- if (next && !shuttingDown) await startJob(next)
1358
- }).catch(error => console.error('Adopted run completion failed', safeError(error)))
1359
- completions.add(completion)
1360
- void completion.finally(() => completions.delete(completion))
1449
+ await watchRun(run, run.texts, owner, true)
1361
1450
  }).catch(error => console.error('Run adoption failed', run.id, safeError(error)))
1362
1451
  }
1363
1452