@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/CHANGELOG.md +23 -0
- package/README.md +4 -1
- package/docker/isolated-executor-smoke.mjs +35 -0
- package/docs/architecture/ai-selection.md +6 -2
- package/docs/docker-runtime.md +48 -0
- package/docs/releasing.md +1 -0
- package/docs/scheduling.md +124 -16
- package/docs/upgrades.md +19 -1
- package/package.json +1 -1
- package/src/ai-cli.ts +31 -5
- package/src/ai.ts +17 -1
- package/src/codex-session.ts +23 -2
- package/src/control-state.ts +173 -8
- package/src/delivery-socket.ts +18 -1
- package/src/executor.ts +11 -4
- package/src/host-executor.ts +17 -6
- package/src/index.ts +358 -269
- package/src/run-telemetry.ts +163 -0
- package/src/running-work.ts +123 -0
- package/src/runs.ts +7 -2
- package/src/schedule-cli.ts +55 -16
- package/src/scheduler.ts +126 -29
- package/src/setup-fallback.ts +57 -18
- package/src/updates/runtime.mjs +2 -1
- package/src/updates/supervisor.mjs +64 -19
- package/test/chat-fallback.test.ts +358 -0
- package/test/codex-session.test.ts +1 -1
- package/test/control-state.test.ts +62 -0
- package/test/executor.test.ts +2 -1
- package/test/host-executor.test.ts +41 -0
- package/test/run-handoff.test.ts +54 -0
- package/test/running-work.test.ts +175 -0
- package/test/schedule-cli.test.ts +55 -6
- package/test/scheduler.test.ts +128 -1
- package/test/script-schedules.test.ts +3 -3
- package/test/setup-fallback.test.ts +211 -14
- package/test/updates.test.mjs +53 -5
- package/test/upgrade-pause.test.ts +65 -2
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.
|
|
54
|
-
|
|
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 =>
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
282
|
-
await
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
}
|
package/src/setup-fallback.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 =>
|
|
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
|
|
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 =>
|
|
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
|
|
95
|
+
for (const preset of candidates) {
|
|
96
|
+
if (attempts.some(attempt => sameChoice(attempt.preset, preset))) continue
|
|
61
97
|
const scope = quotaScope(preset)
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
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: '
|
|
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
|
}
|
package/src/updates/runtime.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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});
|
|
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
|
|
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:
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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(!
|
|
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)) {
|