@jc_stack/ez-agents 0.1.0-beta.51 → 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/scheduler.ts CHANGED
@@ -1,15 +1,16 @@
1
1
  import { validPreflight, installedPreflight, type Preflight, type PreflightReceipt } from './schedule-preflight.js'
2
2
  import { assertEffort, assertScheduledModel } from './model-policy.js'
3
3
  import { needsFailureReview } from './failure.js'
4
- import { mkdir, readFile, readdir, writeFile, rename, link, rm } from 'node:fs/promises'
4
+ import { mkdir, readFile, readdir, writeFile, rename, link, rm, stat } from 'node:fs/promises'
5
5
  import { randomUUID, createHash } from 'node:crypto'
6
6
  import { join } from 'node:path'
7
- import { type Owner, sameOwner, validOwner, ownerId, ownerEpoch } from './control-state.js'
7
+ import { ControlStore, type Owner, sameOwner, validOwner, ownerId, ownerEpoch } from './control-state.js'
8
8
  import { validApplicationOrigin } from './application-origin.js'
9
9
  import { ApplicationBindings } from './application-channel.js'
10
10
  import { assertId, ownsRun } from './identity.js'
11
11
  import { type AiPreset, type ExecutionChoice, isExecutionChoice, persistedPreset } from './ai.js'
12
12
  import { type Trigger, validateTrigger, nextOccurrence } from './schedule-time.js'
13
+ import { quotaCategory, quotaScope } from './setup-fallback.js'
13
14
  import { RunStore, type RunRecord } from './runs.js'
14
15
  import { Scripts, assertScriptArgs, assertScriptId, type ScriptRunRef } from './scripts.js'
15
16
 
@@ -23,6 +24,9 @@ export type Schedule = {
23
24
  execution?: ExecutionChoice
24
25
  script?: { id: string; args: string[] }
25
26
  delivery?: { bindingId: string; scope: string }
27
+ // Occurrences queued or running when a staged edit replaced their revision;
28
+ // they finish with the instructions and settings they captured.
29
+ continuingRunIds?: string[]
26
30
  }
27
31
  export type ScheduledRunSummary = {
28
32
  status: 'completed' | 'failed' | 'cancelled'
@@ -49,9 +53,11 @@ const scriptRef = async (controlDir: string, s: Schedule, owner: Owner): Promise
49
53
  const runTexts = (s: Schedule, dueAt: string) => s.script ? [`[schedule ${s.id} due ${dueAt}] script ${s.script.id}`] : [`[schedule ${s.id} due ${dueAt}]`, s.text]
50
54
  export const scheduledRunId = (s: Schedule, due: number) => 'r_schedule_' + createHash('sha256')
51
55
  .update(JSON.stringify([s.id,s.revision,due])).digest('hex')
56
+ export const occurrenceOf = (s: Schedule, r: RunRecord): boolean =>
57
+ r.scheduled?.id === s.id && (r.scheduled.revision === s.revision || Boolean(s.continuingRunIds?.includes(r.id)))
52
58
  export const holdsSchedule = (s: Schedule, r: RunRecord): boolean =>
53
- r.scheduled?.id === s.id && r.scheduled.revision === s.revision &&
54
- Boolean(r.interrupted || (s.when === 'unreviewed-failures' && r.status === 'failed'))
59
+ occurrenceOf(s, r) && Boolean(r.interrupted || (s.when === 'unreviewed-failures' && r.status === 'failed'))
60
+ const inFlight = (r: RunRecord) => r.status === 'queued' || r.status === 'running'
55
61
  const atomic = async (file: string, value: unknown, exclusive = false) => {
56
62
  const tmp = `${file}.${randomUUID()}.tmp`
57
63
  try {
@@ -60,14 +66,42 @@ const atomic = async (file: string, value: unknown, exclusive = false) => {
60
66
  else await rename(tmp,file)
61
67
  } finally { await rm(tmp,{force:true}) }
62
68
  }
69
+ const validContinuing = (ids: unknown) => ids === undefined ||
70
+ (Array.isArray(ids) && ids.length > 0 && ids.length <= 100 && ids.every(id => typeof id === 'string' && /^[a-zA-Z0-9_-]+$/.test(id)))
63
71
  export class Scheduler {
64
72
  private dir: string
65
73
  constructor(private controlDir: string, private checkPreflight = installedPreflight) { this.dir = join(controlDir,'schedules') }
66
74
  private async ensure() { await mkdir(this.dir,{recursive:true,mode:0o700}) }
75
+ // Definition writes and dispatch commits for one task exclude each other
76
+ // across the relay and CLI processes. Holders need milliseconds; an older
77
+ // lock was left by a crashed process.
78
+ private async lock(id: string, wait = true): Promise<(() => Promise<void>) | undefined> {
79
+ await this.ensure()
80
+ const file = join(this.dir,assertId(id)+'.lock'), token = randomUUID()
81
+ for (let attempt = 0; ; attempt++) {
82
+ try {
83
+ await writeFile(file,token,{mode:0o600,flag:'wx'})
84
+ // Release only this holder's lock, never one taken after it was broken as stale.
85
+ return async () => { if (await readFile(file,'utf8').catch(() => '') === token) await rm(file,{force:true}) }
86
+ }
87
+ catch (error) {
88
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
89
+ if (Date.now() - await stat(file).then(lock => lock.mtimeMs, () => Date.now()) > 30000) { await rm(file,{force:true}); continue }
90
+ if (!wait) return undefined
91
+ if (attempt >= 200) throw new Error('Schedule is busy with another update or dispatch; retry')
92
+ await new Promise(resolve => setTimeout(resolve,25))
93
+ }
94
+ }
95
+ }
96
+ private async locked<T>(id: string, work: () => Promise<T>): Promise<T> {
97
+ const release = (await this.lock(id))!
98
+ try { return await work() } finally { await release() }
99
+ }
67
100
  async get(id: string): Promise<Schedule> {
68
101
  const s = JSON.parse(await readFile(join(this.dir,assertId(id)+'.json'),'utf8')) as Schedule
69
102
  if (s.version !== 1 || s.id !== id || !validScheduledOrigin({id:s.id,revision:s.revision,dueAt:new Date().toISOString(),pairedAt:s.owner?.pairedAt,originRunId:s.originRunId}) ||
70
103
  (s.when !== undefined && s.when !== 'unreviewed-failures') || (s.preflight !== undefined && !validPreflight(s.preflight)) || typeof s.enabled !== 'boolean' || !s.name || typeof s.text !== 'string' ||
104
+ !validContinuing(s.continuingRunIds) ||
71
105
  !validOwner(s.owner) || !validExecution(s) ||
72
106
  (s.delivery !== undefined && !validApplicationOrigin({...s.delivery, requestId: s.id})))
73
107
  throw new Error('Invalid schedule record')
@@ -105,7 +139,7 @@ export class Scheduler {
105
139
  async listTasksReadOnly(runs: RunRecord[]): Promise<ScheduledTaskView[]> {
106
140
  const active: ScheduledTaskView[] = []
107
141
  for (const s of await this.listReadOnly()) {
108
- const current = runs.filter(r => r.scheduled?.id === s.id && r.scheduled.revision === s.revision && ownsRun(s.owner,r))
142
+ const current = runs.filter(r => occurrenceOf(s,r) && ownsRun(s.owner,r))
109
143
  const history = runs.filter(r => r.scheduled?.id === s.id && r.scheduled.pairedAt === s.owner.pairedAt && ownsRun(s.owner,r))
110
144
  .filter((r): r is RunRecord & { status: ScheduledRunSummary['status'] } =>
111
145
  r.status === 'completed' || r.status === 'failed' || r.status === 'cancelled')
@@ -128,6 +162,7 @@ export class Scheduler {
128
162
  await this.ensure(); assertId(input.id)
129
163
  if (input.when !== undefined && input.when !== 'unreviewed-failures') throw new Error('Unknown schedule condition')
130
164
  if(input.preflight !== undefined && !validPreflight(input.preflight)) throw Error('Invalid schedule preflight')
165
+ if (!validContinuing(input.continuingRunIds)) throw new Error('Invalid continuing occurrences')
131
166
  let s: Schedule
132
167
  if (input.script) {
133
168
  if (input.execution !== undefined || !input.name) throw new Error('Script schedules need a name and no AI selection')
@@ -181,21 +216,75 @@ export class Scheduler {
181
216
  try {return JSON.parse(await readFile(join(this.dir,`${s.id}.${s.revision}.cursor`),'utf8')).preflight}
182
217
  catch(e) {if((e as NodeJS.ErrnoException).code==='ENOENT')return undefined;throw e}
183
218
  }
219
+ // Replaces a definition only from the revision the caller read. An edit never
220
+ // invalidates an occurrence that is already queued or running: it applies to an
221
+ // idle task, or `stage` lets those occurrences finish on their captured settings.
222
+ // It keeps the paused/enabled state; only pause/resume toggle it.
223
+ async edit(input: Omit<Schedule,'version'|'revision'|'continuingRunIds'>, expectedRevision: string,
224
+ runs: () => Promise<RunRecord[]>, stage = false): Promise<Schedule> {
225
+ return this.locked(input.id, async () => {
226
+ const current = await this.get(input.id)
227
+ if (current.revision !== expectedRevision) throw new Error(`Schedule changed since revision ${expectedRevision}; read it again with show and reapply the edit`)
228
+ if (!sameOwner(current.owner,input.owner)) throw new Error('Schedule ownership mismatch')
229
+ const active = (await runs()).filter(r => occurrenceOf(current, r) && inFlight(r)).map(r => r.id)
230
+ if (active.length && !stage) throw new Error(`Schedule has queued or running occurrences (${active.join(', ')}); edit again when idle, cancel them, or pass --stage to apply the edit to future occurrences only`)
231
+ return this.save({...input,enabled:current.enabled,continuingRunIds:active.length ? active : undefined})
232
+ })
233
+ }
184
234
  async enable(id: string, enabled: boolean): Promise<Schedule> {
185
- const s = await this.get(id)
186
- // Pausing preserves the cursor. Resuming coalesces missed recurrences like restart.
187
- await atomic(join(this.dir,assertId(id)+'.json'),{...s,enabled})
188
- return {...s,enabled}
235
+ return this.locked(id, async () => {
236
+ const s = await this.get(id)
237
+ // Pausing preserves the cursor. Resuming coalesces missed recurrences like restart.
238
+ await atomic(join(this.dir,assertId(id)+'.json'),{...s,enabled})
239
+ return {...s,enabled}
240
+ })
189
241
  }
190
- async remove(id: string) { await rm(join(this.dir,assertId(id)+'.json')) }
242
+ async remove(id: string) { await this.locked(id, () => rm(join(this.dir,assertId(id)+'.json'))) }
191
243
  async current(run: RunRecord, owner: Schedule['owner']): Promise<boolean> {
192
244
  if (!run.scheduled) return false
193
245
  try {
194
246
  const s = await this.get(run.scheduled.id)
195
- return s.revision === run.scheduled.revision && sameOwner(s.owner, owner) &&
247
+ return occurrenceOf(s, run) && sameOwner(s.owner, owner) &&
196
248
  (!!s.delivery || ((s.owner.telegramLinkedAt ?? s.owner.pairedAt) === (owner.telegramLinkedAt ?? owner.pairedAt) && s.owner.telegramChatId === owner.telegramChatId))
197
249
  } catch { return false }
198
250
  }
251
+ // Explicit retry changes quota admission only; it never repeats another failure.
252
+ async retryQuota(owner: Owner, runs: RunStore, runId?: string): Promise<{ runIds: string[]; scopes: string[] }> {
253
+ const all = await runs.list()
254
+ const requested = runId ? [await runs.get(runId)] : all.filter(r => r.status === 'queued' && r.quotaWait && ownsRun(owner, r))
255
+ const ready: RunRecord[] = []
256
+ for (const run of requested) {
257
+ const quotaFailure = run?.status === 'failed' && run.failureReason === 'setups-unavailable' &&
258
+ Boolean(run.attempts?.length) && run.attempts!.every(a => quotaCategory(a.category))
259
+ const eligible = run && ownsRun(owner, run) && run.scheduled && !run.script && !run.interrupted &&
260
+ ((run.status === 'queued' && run.quotaWait) || quotaFailure) &&
261
+ await this.current(run, owner) && (await this.get(run.scheduled.id)).enabled && !await this.cancelled(run.id) &&
262
+ !all.some(other => other.id !== run.id && other.scheduled?.id === run.scheduled!.id && ['queued', 'running'].includes(other.status))
263
+ if (!eligible) {
264
+ if (runId) throw new Error('Run is not a current, uncancelled quota-blocked occurrence; inspect it before retrying')
265
+ continue
266
+ }
267
+ if (run.delivery) await new ApplicationBindings(this.controlDir).authorize(run)
268
+ ready.push(run)
269
+ }
270
+ const retried: string[] = [], cleared = new Set<string>()
271
+ // An edit that replaced the occurrence's revision meanwhile wins over the requeue;
272
+ // only a requeued occurrence clears its setups' quota backoff.
273
+ for (const run of ready) await this.locked(run.scheduled!.id, async () => {
274
+ if (!await this.current(run, owner) || (await runs.list()).some(other => other.id !== run.id && other.scheduled?.id === run.scheduled!.id && inFlight(other))) {
275
+ if (runId) throw new Error('Schedule changed or has another occurrence in flight; inspect the run before retrying')
276
+ return
277
+ }
278
+ const scopes = [run.execution!.preset, ...run.execution!.fallbacks ?? []].map(quotaScope)
279
+ await new ControlStore(this.controlDir, 1000).clearQuotaWait(owner, scopes)
280
+ for (const scope of scopes) cleared.add(scope)
281
+ await runs.patch(run.id, { status: 'queued', quotaWait: {}, pid: undefined, endedAt: undefined,
282
+ exitCode: undefined, failure: undefined, failureReason: undefined, failureReview: undefined })
283
+ retried.push(run.id)
284
+ })
285
+ return { runIds: retried, scopes: [...cleared] }
286
+ }
287
+
199
288
  async cancel(runId: string, run?: RunRecord | null) {
200
289
  assertId(runId); await this.ensure()
201
290
  const record = run ?? await new RunStore(this.controlDir).get(runId)
@@ -228,16 +317,18 @@ export class Scheduler {
228
317
  if(s.preflight) {
229
318
  checked=await this.preflight(s.id,s.revision)
230
319
  if(!checked.receipt.eligible) throw Error('Schedule preflight has no eligible work')
231
- const current=await this.get(s.id)
232
- if(!current.enabled || current.revision!==s.revision) throw Error('Schedule changed during preflight')
233
320
  }
234
- const dueAt=new Date().toISOString()
235
- const run=await runs.create({id:runId,ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner),
236
- ...(s.delivery ? {delivery:s.delivery} : {chatId:s.owner.telegramChatId,telegramUserId:s.owner.telegramUserId,telegramEpoch:s.owner.telegramLinkedAt ?? s.owner.pairedAt}),
237
- texts:runTexts(s,dueAt),...(s.script ? {script:await scriptRef(this.controlDir,s,owner)} : {execution:{...s.execution!,sessionId:randomUUID()}}),
238
- scheduled:{id:s.id,revision:s.revision,dueAt,pairedAt:s.owner.pairedAt,...(s.originRunId ? {originRunId:s.originRunId} : {})}},true)
239
- if(checked) await atomic(checked.cursor,{...checked.previous,preflight:checked.receipt})
240
- return run
321
+ return this.locked(s.id, async () => {
322
+ const current=await this.get(s.id)
323
+ if(!current.enabled || current.revision!==s.revision) throw Error('Schedule changed before the trigger was queued')
324
+ const dueAt=new Date().toISOString()
325
+ const run=await runs.create({id:runId,ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner),
326
+ ...(s.delivery ? {delivery:s.delivery} : {chatId:s.owner.telegramChatId,telegramUserId:s.owner.telegramUserId,telegramEpoch:s.owner.telegramLinkedAt ?? s.owner.pairedAt}),
327
+ texts:runTexts(s,dueAt),...(s.script ? {script:await scriptRef(this.controlDir,s,owner)} : {execution:{...s.execution!,sessionId:randomUUID()}}),
328
+ scheduled:{id:s.id,revision:s.revision,dueAt,pairedAt:s.owner.pairedAt,...(s.originRunId ? {originRunId:s.originRunId} : {})}},true)
329
+ if(checked) await atomic(checked.cursor,{...checked.previous,preflight:checked.receipt})
330
+ return run
331
+ })
241
332
  }
242
333
  async recover(runs: RunStore) {
243
334
  for (const run of await runs.list()) {
@@ -275,17 +366,23 @@ export class Scheduler {
275
366
  const checked=await this.preflight(s.id,s.revision)
276
367
  preflight=checked.receipt
277
368
  if(!checked.admitted) {await atomic(cursor,{next:future,preflight});continue}
278
- const current=await this.get(s.id);if(!current.enabled || current.revision!==s.revision)continue
279
369
  } catch (error) {console.error('Schedule preflight unavailable',s.id,error instanceof Error ? error.message : 'Unknown error');const prior=await readFile(cursor,'utf8').then(v=>JSON.parse(v)).catch(e=>{if(e.code==='ENOENT')return {};throw e});await atomic(cursor,{next:future,preflight:{...prior.preflight,state:'unavailable',observedAt:new Date().toISOString()}});continue}
280
370
  }
281
- const dueAt = new Date(next).toISOString()
282
- await runs.create({id:scheduledRunId(s,next),
283
- ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner),
284
- ...(s.delivery ? {delivery:s.delivery} : {chatId:s.owner.telegramChatId,telegramUserId:s.owner.telegramUserId,telegramEpoch:s.owner.telegramLinkedAt ?? s.owner.pairedAt}),
285
- texts:runTexts(s,dueAt),...(s.script ? {script:await scriptRef(this.controlDir,s,owner)} : {execution:s.execution}),
286
- scheduled:{id:s.id,revision:s.revision,dueAt,pairedAt:s.owner.pairedAt,...(s.originRunId?{originRunId:s.originRunId}:{})}},true)
287
- // A restart between run creation and this cursor write sees the same occurrence ID.
288
- await atomic(cursor,{next:future,...(preflight ? {preflight} : {})})
371
+ // An edit or another dispatcher holds this task; a later tick retries.
372
+ const release = await this.lock(s.id, false)
373
+ if (!release) continue
374
+ try {
375
+ const latest = await this.get(s.id)
376
+ if (latest.revision !== s.revision || !latest.enabled) continue
377
+ const dueAt = new Date(next).toISOString()
378
+ await runs.create({id:scheduledRunId(s,next),
379
+ ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner),
380
+ ...(s.delivery ? {delivery:s.delivery} : {chatId:s.owner.telegramChatId,telegramUserId:s.owner.telegramUserId,telegramEpoch:s.owner.telegramLinkedAt ?? s.owner.pairedAt}),
381
+ texts:runTexts(s,dueAt),...(s.script ? {script:await scriptRef(this.controlDir,s,owner)} : {execution:s.execution}),
382
+ scheduled:{id:s.id,revision:s.revision,dueAt,pairedAt:s.owner.pairedAt,...(s.originRunId?{originRunId:s.originRunId}:{})}},true)
383
+ // A restart between run creation and this cursor write sees the same occurrence ID.
384
+ await atomic(cursor,{next:future,...(preflight ? {preflight} : {})})
385
+ } finally { await release() }
289
386
  } catch (error) { console.error('Schedule dispatch failed',s.id,error instanceof Error ? error.message : 'Unknown error') }
290
387
  }
291
388
  }
@@ -1,12 +1,14 @@
1
- import { presetLabel, presetProvider, type AiPreset, type ExecutionChoice } from './ai.js'
1
+ import { presetLabel, presetProvider, sameChoice, type AiPreset, type ExecutionChoice } from './ai.js'
2
+ import type { AttemptTelemetry } from './run-telemetry.js'
2
3
 
3
- // Ordered task setups for one scheduled occurrence. Core advances only on typed
4
+ // Ordered setups for one scheduled occurrence or captured chat/application
5
+ // turn. Core advances only on typed
4
6
  // evidence: a missing CLI or a provider rejection before any model output or
5
7
  // tool item, or a usage limit the run ended on after work began (continued in a
6
8
  // fresh session that is told so). Anything else stops the chain.
7
- export type StartupCategory = 'cli-unavailable' | 'login-unavailable' | 'quota' | 'provider-rejected' | 'access-denied'
9
+ export type StartupCategory = 'cli-unavailable' | 'login-unavailable' | 'quota' | 'credits-required' | 'provider-rejected' | 'access-denied'
8
10
  export type StartupRejection = { category: StartupCategory; resetAt?: string }
9
- export type AttemptCategory = StartupCategory | 'quota-after-work-began' | 'after-work-began' | 'uncertain' | 'same-quota-scope' | 'same-login-scope'
11
+ export type AttemptCategory = StartupCategory | 'quota-after-work-began' | 'credits-required-after-work-began' | 'after-work-began' | 'uncertain' | 'same-quota-scope' | 'same-login-scope'
10
12
  export type SetupAttempt = {
11
13
  preset: AiPreset
12
14
  quotaScope: string
@@ -17,9 +19,11 @@ export type SetupAttempt = {
17
19
  sessionId?: string
18
20
  startedAt?: string
19
21
  endedAt?: string
22
+ // Present only when the owner enabled run telemetry for this run.
23
+ telemetry?: AttemptTelemetry
20
24
  }
21
25
 
22
- const categories: StartupCategory[] = ['cli-unavailable', 'login-unavailable', 'quota', 'provider-rejected', 'access-denied']
26
+ const categories: StartupCategory[] = ['cli-unavailable', 'login-unavailable', 'quota', 'credits-required', 'provider-rejected', 'access-denied']
23
27
 
24
28
  // A failed attempt's category and reset time, and whether the occurrence
25
29
  // continues on the next setup. Once work began only a quota limit the run ended
@@ -27,17 +31,48 @@ const categories: StartupCategory[] = ['cli-unavailable', 'login-unavailable', '
27
31
  export const attemptFailure = ({ workBegan, rejection }: { workBegan: boolean; rejection?: StartupRejection }): { category: AttemptCategory; resetAt?: string; continues: boolean } => {
28
32
  const resetAt = rejection?.resetAt ? { resetAt: rejection.resetAt } : {}
29
33
  if (!workBegan) return rejection ? { category: rejection.category, ...resetAt, continues: rejection.category !== 'access-denied' } : { category: 'uncertain', continues: false }
30
- return rejection?.category === 'quota' ? { category: 'quota-after-work-began', ...resetAt, continues: true } : { category: 'after-work-began', continues: false }
34
+ return rejection && ['quota', 'credits-required'].includes(rejection.category) ? { category: rejection.category === 'quota' ? 'quota-after-work-began' : 'credits-required-after-work-began', ...resetAt, continues: true } : { category: 'after-work-began', continues: false }
31
35
  }
32
36
 
33
37
  // Transport fact for a setup that continues an occurrence after an earlier setup
34
38
  // stopped at its usage limit once work had begun. Facts only, no guidance.
35
39
  export const continuationLine = (runId: string, attempts: SetupAttempt[]): string | undefined => {
36
- const limited = attempts.filter(a => a.category === 'quota-after-work-began')
40
+ const limited = attempts.filter(a => ['quota-after-work-began', 'credits-required-after-work-began'].includes(a.category ?? ''))
37
41
  return limited.length ? `[run ${runId} continues: ${limited.map(a => `${presetLabel(a.preset)} began work in native session ${a.sessionId} and stopped at its usage limit${a.resetAt ? ` (resets ${a.resetAt})` : ''}`).join('; ')}. That work may be partly applied.]` : undefined
38
42
  }
39
43
 
40
- export const setupCandidates = (execution: ExecutionChoice): AiPreset[] => [execution.preset, ...(execution.fallbacks ?? [])]
44
+ export type SetupHealth = Record<string, { lastSuccessAt?: string; resetAt?: string; retryAt?: string; quotaFailures?: number; manualRetry?: true }>
45
+ export type QuotaWait = { retryAt?: string }
46
+ export const quotaBlockedUntil = (health: SetupHealth[string] = {}): number => health.manualRetry ? Infinity :
47
+ Math.max(Date.parse(health.resetAt ?? '') || 0, Date.parse(health.retryAt ?? '') || 0)
48
+ // Any available saved login may run; otherwise wait for the earliest one.
49
+ export const quotaWait = (candidates: AiPreset[], health: SetupHealth, now = Date.now()): QuotaWait | undefined => {
50
+ const times = candidates.map(p => quotaBlockedUntil(health[quotaScope(p)]))
51
+ if (!times.length || times.some(at => at <= now)) return undefined
52
+ const next = Math.min(...times)
53
+ return Number.isFinite(next) ? { retryAt: new Date(next).toISOString() } : {}
54
+ }
55
+ export const quotaCategory = (category?: string): boolean =>
56
+ ['quota', 'quota-after-work-began', 'credits-required', 'credits-required-after-work-began', 'same-quota-scope'].includes(category ?? '')
57
+ export const validSetupHealth = (value: unknown): value is SetupHealth => Boolean(value && typeof value === 'object' && !Array.isArray(value) &&
58
+ Object.entries(value).every(([scope, health]) => /^[a-z][a-zA-Z0-9@_./:-]{0,199}$/.test(scope) && health && typeof health === 'object' && !Array.isArray(health) &&
59
+ Object.entries(health).every(([key, at]) => key === 'quotaFailures' ? Number.isInteger(at) && Number(at) >= 0 && Number(at) <= 4 :
60
+ key === 'manualRetry' ? at === true : ['lastSuccessAt', 'resetAt', 'retryAt'].includes(key) && typeof at === 'string' &&
61
+ Number.isFinite(Date.parse(at)) && new Date(at).toISOString() === at)))
62
+
63
+ // Rotate only the primary's contiguous same-model/effort login choices. Other
64
+ // explicitly saved engines/models keep their fallback priority.
65
+ export const setupCandidates = (execution: ExecutionChoice, health: SetupHealth = {}, now = Date.now()): AiPreset[] => {
66
+ const saved = [execution.preset, ...(execution.fallbacks ?? [])], primary = saved[0]
67
+ const end = saved.findIndex(p => p.cli !== primary.cli || p.provider !== primary.provider || p.model !== primary.model || p.effort !== primary.effort)
68
+ const group = saved.slice(0, end < 0 ? saved.length : end)
69
+ let best = 0, latest = -Infinity
70
+ for (let i = 0; i < group.length; i++) {
71
+ const state = health[quotaScope(group[i])], at = Date.parse(state?.lastSuccessAt ?? '')
72
+ if (!(quotaBlockedUntil(state) > now) && at > latest) { best = i; latest = at }
73
+ }
74
+ return [...group.slice(best), ...group.slice(0, best), ...saved.slice(group.length)]
75
+ }
41
76
 
42
77
  // The owner provisions native profiles for separate provider accounts. Models
43
78
  // in one login share its quota scope; another named login may be tried once.
@@ -53,15 +88,18 @@ export const quotaScope = (preset: AiPreset): string => {
53
88
 
54
89
  // The next untried setup, skipping a login that already rejected authentication
55
90
  // or reached its quota in this occurrence. Each candidate is tried at most once.
56
- export const nextSetup = (candidates: AiPreset[], attempts: SetupAttempt[]): { skipped: SetupAttempt[]; preset?: AiPreset } => {
57
- const exhausted = new Map(attempts.filter(a => ['quota', 'quota-after-work-began', 'login-unavailable'].includes(a.category!))
91
+ export const nextSetup = (candidates: AiPreset[], attempts: SetupAttempt[], health: SetupHealth = {}, now = Date.now()): { skipped: SetupAttempt[]; preset?: AiPreset } => {
92
+ const exhausted = new Map(attempts.filter(a => quotaCategory(a.category) || a.category === 'login-unavailable')
58
93
  .map(a => [a.quotaScope, a.category === 'login-unavailable' ? 'same-login-scope' as const : 'same-quota-scope' as const]))
59
94
  const skipped: SetupAttempt[] = []
60
- for (const preset of candidates.slice(attempts.length)) {
95
+ for (const preset of candidates) {
96
+ if (attempts.some(attempt => sameChoice(attempt.preset, preset))) continue
61
97
  const scope = quotaScope(preset)
62
- if (!exhausted.has(scope)) return { skipped, preset }
63
- const at = new Date().toISOString()
64
- skipped.push({ preset, quotaScope: scope, outcome: 'skipped', category: exhausted.get(scope), startedAt: at, endedAt: at })
98
+ const resetAt = health[scope]?.resetAt
99
+ const category = exhausted.get(scope) ?? (quotaBlockedUntil(health[scope]) > now ? 'same-quota-scope' : undefined)
100
+ if (!category) return { skipped, preset }
101
+ const at = new Date(now).toISOString()
102
+ skipped.push({ preset, quotaScope: scope, outcome: 'skipped', category, ...(resetAt ? { resetAt } : {}), startedAt: at, endedAt: at })
65
103
  }
66
104
  return { skipped }
67
105
  }
@@ -75,7 +113,7 @@ const epochSeconds = (value: unknown): string | undefined =>
75
113
  typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? new Date(value * 1000).toISOString() : undefined
76
114
 
77
115
  const claudeErrors: Record<string, StartupCategory> = {
78
- rate_limit: 'quota', billing_error: 'quota',
116
+ rate_limit: 'quota', billing_error: 'credits-required',
79
117
  authentication_failed: 'login-unavailable', oauth_org_not_allowed: 'access-denied', account_on_hold: 'access-denied',
80
118
  verification_required: 'access-denied', cloud_credential_error: 'access-denied',
81
119
  overloaded: 'provider-rejected', server_error: 'provider-rejected', model_not_found: 'provider-rejected', invalid_request: 'provider-rejected',
@@ -129,11 +167,11 @@ export const startupObserver = () => {
129
167
  else if (rejection?.category !== 'access-denied') rejection = undefined
130
168
  return
131
169
  }
132
- if (event?.type === 'thread.started' || event?.type === 'result' ||
170
+ if (event?.type === 'thread.started' || event?.type === 'result' || event?.type === 'ez.telemetry' ||
133
171
  (event?.type === 'system' && event.subtype === 'init')) return
134
172
  if (event?.type === 'rate_limit_event') {
135
173
  const info = event.rate_limit_info
136
- if (info?.status === 'rejected') reject({ category: 'quota', ...(epochSeconds(info.resetsAt) ? { resetAt: epochSeconds(info.resetsAt) } : {}) })
174
+ if (info?.status === 'rejected') reject({ category: info.errorCode === 'credits_required' ? 'credits-required' : 'quota', ...(epochSeconds(info.resetsAt) ? { resetAt: epochSeconds(info.resetsAt) } : {}) })
137
175
  return
138
176
  }
139
177
  // Native API retry/error metadata is not model output. Do not inspect error
@@ -143,7 +181,7 @@ export const startupObserver = () => {
143
181
  if (category) { reject({ category }); return }
144
182
  }
145
183
  const category = event?.type === 'assistant' && typeof event.error === 'string' ? claudeErrors[event.error] : undefined
146
- if (category) reject({ category, ...(category === 'quota' && rejection?.resetAt ? { resetAt: rejection.resetAt } : {}) })
184
+ if (category) reject({ category: category === 'quota' && rejection?.category === 'credits-required' ? 'credits-required' : category, ...(category === 'quota' && rejection?.resetAt ? { resetAt: rejection.resetAt } : {}) })
147
185
  else work()
148
186
  }
149
187
  return {
@@ -154,6 +192,7 @@ export const startupObserver = () => {
154
192
  for (const text of lines) line(text)
155
193
  },
156
194
  result: () => { line(buffer); buffer = ''; return { workBegan, rejection } },
195
+ accepted: () => workBegan && !rejection,
157
196
  summary: () => startupLine({ workBegan, rejection }),
158
197
  }
159
198
  }
@@ -19,13 +19,14 @@ export function execute(command,args,options={}) {
19
19
  return new Promise((resolve,reject)=>{
20
20
  const invocation=process.env.EZ_DOCKER_COMPOSE==='standalone'&&command==='docker'&&args[0]==='compose'
21
21
  ? {command:'docker-compose',args:args.slice(1)} : {command,args};
22
- const child=spawn(invocation.command,invocation.args,{cwd:options.cwd,env:environment(),stdio:['ignore','pipe','pipe']});let output='',stdout='';
22
+ const child=spawn(invocation.command,invocation.args,{cwd:options.cwd,env:environment(),stdio:[options.input===undefined?'ignore':'pipe','pipe','pipe']});let output='',stdout='';
23
23
  const timer=setTimeout(()=>child.kill('SIGKILL'),options.timeout||600000);
24
24
  const file=options.outputFile?createWriteStream(options.outputFile,{flags:'wx',mode:0o600}):null;
25
25
  const written=file?new Promise((resolve,reject)=>{file.once('finish',resolve);file.once('error',reject);}):Promise.resolve();
26
26
  if(file)child.stdout.pipe(file);else child.stdout.on('data',b=>{output=(output+b).slice(-16000);if(options.stdoutOnly)stdout=(stdout+b).slice(-16000);});
27
27
  child.stderr.on('data',b=>{output=(output+b).slice(-16000);});
28
28
  void written.catch(()=>child.kill('SIGKILL'));
29
+ if(options.input!==undefined){child.stdin.on('error',error=>{child.kill('SIGKILL');reject(error);});child.stdin.end(options.input);}
29
30
  child.once('error',error=>{clearTimeout(timer);reject(error);});
30
31
  child.once('close',async code=>{clearTimeout(timer);try{await written;if(code!==0)throw Error(`${invocation.command} failed (${code}): ${output}`);resolve(options.stdoutOnly?stdout:output);}catch(error){reject(error);}});
31
32
  });
@@ -30,13 +30,14 @@ export async function waitForHostHeartbeat(controlDir,child,failure,{attempts=30
30
30
  }
31
31
  throw Error('Host transport heartbeat timeout');
32
32
  }
33
- export async function idle(control) {
33
+ export async function idle(control,{pendingRequests=true,relayRequired=false}={}) {
34
34
  const host=await fs.readdir(path.join(control,'host-executor')).catch(e=>{if(e.code==='ENOENT')return [];throw e;});
35
- if(host.some(f=>f.endsWith('.running.json')||f.endsWith('.request.json')))return false;
35
+ if(host.some(f=>f.endsWith('.running.json')||(pendingRequests&&f.endsWith('.request.json'))))return false;
36
36
  // Run records live in relay memory: the live socket status is the only
37
- // cross-process view of active work. A down relay cannot own a run.
37
+ // cross-process view of active work. Reload additionally requires its
38
+ // admission barrier; an unavailable response cannot prove a safe shutdown.
38
39
  const status=await callDeliverySocket(path.join(control,'delivery.sock'),{op:'status'},2000).catch(()=>null);
39
- return !status||status.running===0;
40
+ return status ? status.running===0 : !relayRequired;
40
41
  }
41
42
  // Pause admission only after work is idle, and release it if a turn raced the
42
43
  // check. A queued upgrade must never hold the owner's chat behind a long turn.
@@ -73,6 +74,26 @@ export async function queueAutomatic(home,available) {
73
74
  }
74
75
  return null;
75
76
  }
77
+ // Docker archive copy keeps the binding private and readonly without changing
78
+ // host ownership, which may differ from the unprivileged container identity.
79
+ export function executorBindingArchive(binding,gid) {
80
+ if(!Number.isSafeInteger(gid)||gid<0||gid>0o7777777)throw Error('Invalid executor group');
81
+ const data=Buffer.from(JSON.stringify(binding)),header=Buffer.alloc(512);
82
+ const oct=(offset,width,value)=>header.write(value.toString(8).padStart(width-1,'0')+'\0',offset);
83
+ header.write('executor.json');oct(100,8,0o440);oct(108,8,0);oct(116,8,gid);
84
+ oct(124,12,data.length);oct(136,12,0);header.fill(32,148,156);
85
+ header.write('0',156);header.write('ustar\0',257);header.write('00',263);
86
+ header.write(header.reduce((sum,byte)=>sum+byte,0).toString(8).padStart(6,'0')+'\0 ',148);
87
+ return Buffer.concat([header,data,Buffer.alloc((512-data.length%512)%512+1024)]);
88
+ }
89
+ export function executorVolumes(deployment,worker) {
90
+ const names=[...new Set(worker.volumes.filter(m=>m.type==='volume').map(m=>m.source))];
91
+ return Object.fromEntries(names.map(name=>{
92
+ const actual=deployment.volumes?.[name]?.name;
93
+ if(typeof actual!=='string'||!actual)throw Error('Missing executor volume binding');
94
+ return [name,{name:actual,external:true}];
95
+ }));
96
+ }
76
97
  export async function supervise(deployment,signal,{discover=check}={}) {
77
98
  const host=await read(path.join(deployment,'host-executor.json'));
78
99
  if(host.agents.length!==1||!host.agents[0].toolsHome)throw Error('Initialize a single deployment plugin registry before starting the supervisor');
@@ -86,11 +107,13 @@ export async function supervise(deployment,signal,{discover=check}={}) {
86
107
  await fs.writeFile(lock,JSON.stringify({pid:process.pid}),{flag:'wx',mode:0o600});
87
108
  let child,container;
88
109
  let retirements=[];
89
- let reloadPending=false;
110
+ let reloadPending;
111
+ let reloadReady=false;
112
+ let admissionReady=false;
90
113
  const generations=new Map();
91
114
  const removeContainer=async id=>{
92
115
  const generation=generations.get(id);
93
- if(generation){await execute('docker',['compose','--project-name',generation.project,'--file',generation.file,'down']);await fs.rm(generation.file,{force:true});await fs.rm(generation.configFile,{force:true});generations.delete(id);}
116
+ if(generation){await execute('docker',['compose','--project-name',generation.project,'--file',generation.file,'down']);await fs.rm(generation.file,{force:true});generations.delete(id);}
94
117
  else await execute('docker',['rm',id]);
95
118
  };
96
119
  const stopHost=async()=>{
@@ -121,20 +144,28 @@ export async function supervise(deployment,signal,{discover=check}={}) {
121
144
  const generation=path.basename(root).replace(/[^a-zA-Z0-9_.-]/g,'-')+'-'+Date.now();
122
145
  const project='ez-executor-'+generation.toLowerCase();
123
146
  const file=path.join(directory,project+'.json');
124
- const configFile=path.join(directory,project+'-binding.json');
125
- await atomic(configFile,{...host,agents:host.agents.map(a=>({...a,binDir:'/app/bin'}))});
147
+ const binding={...host,agents:host.agents.map(a=>({...a,binDir:'/app/bin'}))};
126
148
  const worker={image:relay.image,init:true,restart:'no',user:`${relay.environment.EZ_RUNTIME_UID||1000}:${relay.environment.EZ_RUNTIME_GID||1000}`,
127
149
  cap_drop:['ALL'],security_opt:['no-new-privileges:true'],entrypoint:['node','--import','/app/node_modules/tsx/dist/loader.mjs','/app/src/host-executor.ts','/run/executor.json'],
128
150
  environment:{...relay.environment,EZ_CONTAINER_EXECUTOR:'1',EZ_EXECUTOR_TRANSPORT:'local'},
129
- volumes:[...relay.volumes,{type:'bind',source:configFile,target:'/run/executor.json',read_only:true}],tmpfs:['/tmp'],
151
+ volumes:relay.volumes,tmpfs:['/tmp'],
130
152
  ...Object.fromEntries(['networks','network_mode','extra_hosts','dns','dns_search'].filter(key=>relay[key]!==undefined).map(key=>[key,relay[key]]))};
131
153
  if(worker.networks&&!Array.isArray(worker.networks))worker.networks=Object.fromEntries(Object.keys(worker.networks).map(name=>[name,{}]));
132
154
  const networks=worker.networks?Object.fromEntries(Object.entries(deployment.networks||{}).map(([name,binding])=>[name,{name:binding.name,external:true}])):undefined;
133
- await atomic(file,{services:{executor:worker},...(networks?{networks}:{})});
134
- await execute('docker',['compose','--project-name',project,'--file',file,'up','-d','--no-build','executor']);
135
- container=(await execute('docker',['compose','--project-name',project,'--file',file,'ps','-q','executor'],{stdoutOnly:true})).trim();
136
- if(!/^[a-f0-9]{12,64}$/.test(container))throw Error('Missing isolated executor');
137
- generations.set(container,{project,file,configFile});
155
+ const volumes=executorVolumes(deployment,worker);
156
+ const archive=executorBindingArchive(binding,Number(worker.user.split(':')[1]));
157
+ await atomic(file,{services:{executor:worker},...(networks?{networks}:{}),...(Object.keys(volumes).length?{volumes}:{})});
158
+ try {
159
+ await execute('docker',['compose','--project-name',project,'--file',file,'create','--no-build','executor']);
160
+ container=(await execute('docker',['compose','--project-name',project,'--file',file,'ps','-aq','executor'],{stdoutOnly:true})).trim();
161
+ if(!/^[a-f0-9]{12,64}$/.test(container))throw Error('Missing isolated executor');
162
+ } catch(error) {
163
+ await execute('docker',['compose','--project-name',project,'--file',file,'down']);
164
+ await fs.rm(file,{force:true});container=undefined;throw error;
165
+ }
166
+ generations.set(container,{project,file});
167
+ await execute('docker',['cp','-',`${container}:/run`],{input:archive});
168
+ await execute('docker',['start',container]);
138
169
  for(let n=0;n<300;n++){
139
170
  const running=await execute('docker',['inspect','--format','{{.State.Running}}',container],{stdoutOnly:true});
140
171
  if(running.trim()!=='true')throw Error('Isolated executor exited during startup');
@@ -167,6 +198,7 @@ export async function supervise(deployment,signal,{discover=check}={}) {
167
198
  (host.isolation!=='isolated'||(container&&old.ezRelease?.containerExecutor===1&&candidate.ezRelease?.containerExecutor===1));
168
199
  };
169
200
  const pause=path.join(agent.controlDir,'upgrade-pause.json');
201
+ const inheritedPause=await read(pause).catch(missing);
170
202
  const refreshRequirement=async job=>{
171
203
  if(job.target==='main')return false;
172
204
  try {return await brokerNeedsRefresh((await state(home)).config);}
@@ -198,7 +230,7 @@ export async function supervise(deployment,signal,{discover=check}={}) {
198
230
  const result=refreshPluginBroker===null?undefined:interrupted.target==='main'
199
231
  ? await (async()=>{await atomic(pause,{id:interrupted.id});try{return await locked(home,()=>perform(home,interrupted,{stopHost,startHost,retireHost,preserveRuns:interrupted.preserveRuns===true}));}finally{await fs.rm(pause,{force:true});}})()
200
232
  : await withUpgrade(agent.controlDir,interrupted,signal,()=>perform(home,interrupted,{stopHost,startHost,retireHost,signal,refreshPluginBroker}),{requiresIdle:refreshPluginBroker});
201
- if(['completed','rolled-back'].includes(result?.status)&&interrupted.target==='main')reloadPending=true;
233
+ if(['completed','rolled-back'].includes(result?.status)&&interrupted.target==='main')reloadPending=interrupted;
202
234
  }
203
235
  const isolated=host.isolation==='isolated'
204
236
  if(!isolated&&!child)await startHost((await state(home)).config.packageRoot);
@@ -206,7 +238,9 @@ export async function supervise(deployment,signal,{discover=check}={}) {
206
238
  if(!isolated||container)return;
207
239
  const root=(await state(home)).config.packageRoot;
208
240
  if((await read(path.join(root,'package.json'))).ezRelease?.containerExecutor!==1)return;
209
- await withIdleUpgrade(agent.controlDir,{id:'executor-migration'},signal,()=>startHost(root));
241
+ // Queued requests need this worker to drain. Existing native runs still
242
+ // block migration; the worker lock continues to enforce single ownership.
243
+ await withIdleUpgrade(agent.controlDir,{id:'executor-migration'},signal,()=>startHost(root),{isIdle:()=>idle(agent.controlDir,{pendingRequests:false})});
210
244
  };
211
245
  await initializeIsolated();
212
246
  let nextCheck=0;
@@ -214,7 +248,15 @@ export async function supervise(deployment,signal,{discover=check}={}) {
214
248
  if(!isolated && child?.exitCode!==null&&child?.exitCode!==undefined)throw Error('Host transport exited; supervisor service should restart');
215
249
  await initializeIsolated();
216
250
  if(container&&(await execute('docker',['inspect','--format','{{.State.Running}}',container],{stdoutOnly:true})).trim()!=='true')throw Error('Isolated executor exited; supervisor service should restart');
217
- if(reloadPending&&await idle(agent.controlDir))return;
251
+ // Reopen only after the successor worker is ready. The relay and its
252
+ // unclaimed requests remain running throughout this supervisor restart.
253
+ if(!admissionReady&&!reloadPending&&(!isolated||container)){await fs.rm(pause,{force:true});admissionReady=true;}
254
+ if(reloadPending) {
255
+ // Hold new turns while existing generations finish. Queued requests
256
+ // cannot block a restart that leaves the relay and its ledger running.
257
+ await atomic(pause,{id:reloadPending.id});
258
+ if(await idle(agent.controlDir,{pendingRequests:false,relayRequired:true})){reloadReady=true;return;}
259
+ }
218
260
  // Reap retired containers only after their worker exits.
219
261
  for(const item of retirements.filter(r=>r.container)){
220
262
  if((await execute('docker',['inspect','--format','{{.State.Running}}',item.container],{stdoutOnly:true})).trim()==='false'){await removeContainer(item.container);retirements=retirements.filter(r=>r!==item);}
@@ -236,7 +278,7 @@ export async function supervise(deployment,signal,{discover=check}={}) {
236
278
  };
237
279
  const result=await withUpgrade(agent.controlDir,pending,signal,apply,{requiresIdle:refreshPluginBroker,preserveRuns:preserve});
238
280
  if(['completed','rolled-back'].includes(result?.status)) {
239
- if(pending.target==='main')reloadPending=true;
281
+ if(pending.target==='main')reloadPending=pending;
240
282
  nextCheck=0;
241
283
  }
242
284
  }
@@ -256,7 +298,10 @@ export async function supervise(deployment,signal,{discover=check}={}) {
256
298
  }finally {
257
299
  clearInterval(beat);await stopHost();
258
300
  for(const item of retirements){if(item.container){await execute('docker',['stop','--time','10',item.container]);await removeContainer(item.container);}else if(item.child.exitCode===null)item.child.kill('SIGTERM');}await fs.rm(lock,{force:true});
259
- if(!(await jobs(home)).some(j=>j.status==='applying'))await fs.rm(pause,{force:true});
301
+ if(!reloadReady) {
302
+ if(!admissionReady&&inheritedPause)await atomic(pause,inheritedPause);
303
+ else if(!(await jobs(home)).some(j=>j.status==='applying'))await fs.rm(pause,{force:true});
304
+ }
260
305
  }
261
306
  }
262
307
  if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)) {