@jc_stack/ez-agents 0.1.0-beta.48 → 0.1.0-beta.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/docker/broker-update-smoke.mjs +16 -2
- package/docker/upgrade-smoke.mjs +1 -1
- package/docs/architecture/ai-selection.md +5 -1
- package/docs/docker-runtime.md +28 -0
- package/docs/scheduling.md +41 -0
- package/docs/upgrades.md +4 -10
- package/package.json +1 -1
- package/src/ai-cli.ts +28 -15
- package/src/ai.ts +76 -12
- package/src/application-channel.ts +9 -9
- package/src/auth-profile.ts +53 -0
- package/src/codex-session.ts +14 -3
- package/src/control-state.ts +28 -21
- package/src/delivery-socket.ts +2 -0
- package/src/executor.ts +29 -26
- package/src/host-executor.ts +35 -10
- package/src/index.ts +206 -112
- package/src/menu.ts +5 -5
- package/src/plugin-broker.mjs +4 -11
- package/src/plugins/manager.mjs +27 -8
- package/src/runs.ts +13 -2
- package/src/schedule-cli.ts +33 -7
- package/src/scheduler.ts +9 -4
- package/src/setup-fallback.ts +122 -0
- package/src/task-executor.ts +3 -2
- package/src/updates/runtime.mjs +79 -41
- package/src/updates/supervisor.mjs +27 -6
- package/test/auth-profiles.test.ts +187 -0
- package/test/plugin-broker.test.mjs +3 -0
- package/test/plugin-manager.test.mjs +24 -0
- package/test/setup-fallback.test.ts +245 -0
- package/test/updates.test.mjs +58 -5
package/src/schedule-cli.ts
CHANGED
|
@@ -8,18 +8,35 @@ import { ControlStore, sameOwner } from './control-state.js'
|
|
|
8
8
|
import { ApplicationBindings } from './application-channel.js'
|
|
9
9
|
import type { RunRecord } from './runs.js'
|
|
10
10
|
import { callDeliverySocket, socketPathFor } from './delivery-socket.js'
|
|
11
|
-
import { initialPreset, isPreset } from './ai.js'
|
|
11
|
+
import { initialPreset, isPreset, presetLabel, MAX_FALLBACKS, type AiPreset } from './ai.js'
|
|
12
12
|
import { executionOverrides } from './model-policy.js'
|
|
13
13
|
import { holdsSchedule, Scheduler, executionType } from './scheduler.js'
|
|
14
14
|
import { Scripts, fileSha256, workspaceEntry, DEFAULT_SCRIPT_TIMEOUT_SECONDS } from './scripts.js'
|
|
15
15
|
import { executorEnvironment } from './executor.js'
|
|
16
|
+
import { provisionedHome } from './auth-profile.js'
|
|
16
17
|
import { ownsRun } from './identity.js'
|
|
17
18
|
import { type Trigger } from './schedule-time.js'
|
|
18
19
|
|
|
20
|
+
// `cli=claude,model=sonnet,effort=medium` names one fallback setup.
|
|
21
|
+
const fallbackSetup = (spec: string, index: number): AiPreset => {
|
|
22
|
+
const fields = Object.fromEntries(spec.split(',').map(part => {
|
|
23
|
+
const at = part.indexOf('=')
|
|
24
|
+
if (at < 1) throw new Error('Fallback uses cli=EXECUTOR,model=MODEL[,effort=EFFORT][,provider=ID]')
|
|
25
|
+
return [part.slice(0, at), part.slice(at + 1)]
|
|
26
|
+
}))
|
|
27
|
+
if (Object.keys(fields).some(key => !['cli','model','effort','provider'].includes(key)) || !fields.cli || !fields.model)
|
|
28
|
+
throw new Error('Fallback uses cli=EXECUTOR,model=MODEL[,effort=EFFORT][,provider=ID]')
|
|
29
|
+
const base = initialPreset(fields.cli)
|
|
30
|
+
const preset = executionOverrides(base.cli, {...base, ...(fields.provider ? {provider: fields.provider} : {})}, fields.model, fields.effort)
|
|
31
|
+
const named = {...preset, id: `fallback-${index + 1}`, name: presetLabel(preset).slice(0, 80)}
|
|
32
|
+
if (!isPreset(named)) throw new Error('Invalid fallback setup')
|
|
33
|
+
return named
|
|
34
|
+
}
|
|
35
|
+
|
|
19
36
|
async function main() {
|
|
20
37
|
const { values:v, positionals:[action='list',id,scriptId] } = parseArgs({allowPositionals:true,options:{
|
|
21
38
|
script:{type:'string'}, arg:{type:'string',multiple:true}, file:{type:'string'}, interpreter:{type:'string'}, 'timeout-seconds':{type:'string'},
|
|
22
|
-
cli:{type:'string'}, model:{type:'string'}, effort:{type:'string'},
|
|
39
|
+
cli:{type:'string'}, 'auth-profile':{type:'string'}, model:{type:'string'}, effort:{type:'string'}, fallback:{type:'string',multiple:true}, 'clear-fallbacks':{type:'boolean'},
|
|
23
40
|
all:{type:'boolean'}, limit:{type:'string'}, offset:{type:'string'}, expected:{type:'string'}, key:{type:'string'}, when:{type:'string'}, status:{type:'string'}, diagnosis:{type:'string'}, recovery:{type:'string'}, outcome:{type:'string'}, 'failed-at':{type:'string'},
|
|
24
41
|
'preflight-file':{type:'string'},'clear-preflight':{type:'boolean'},name:{type:'string'}, text:{type:'string'}, 'text-file':{type:'string'}, at:{type:'string'}, now:{type:'boolean'},
|
|
25
42
|
cron:{type:'string'}, timezone:{type:'string'}, 'every-seconds':{type:'string'}, start:{type:'string'}, until:{type:'string'}, help:{type:'boolean'},
|
|
@@ -31,12 +48,14 @@ async function main() {
|
|
|
31
48
|
review RUN_ID --failed-at ISO --status resolved|attention --diagnosis TEXT --recovery TEXT --outcome TEXT
|
|
32
49
|
create [ID] | edit ID --name NAME (--text TEXT | --text-file FILE | --script SCRIPT_ID [--arg=VALUE ...])
|
|
33
50
|
--now | --at ISO_WITH_OFFSET | --every-seconds N | --cron 'MIN HOUR DAY MONTH WEEKDAY' --timezone IANA
|
|
34
|
-
[--cli EXECUTOR] [--model MODEL] [--effort <native-effort>]
|
|
51
|
+
[--cli EXECUTOR] [--auth-profile NAME] [--model MODEL] [--effort <native-effort>]
|
|
52
|
+
[--fallback cli=EXECUTOR,model=MODEL[,effort=EFFORT][,provider=ID] ... | --clear-fallbacks]
|
|
35
53
|
[--preflight-file FILE | --clear-preflight]
|
|
36
54
|
[--start ISO_WITH_OFFSET] [--until ISO_WITH_OFFSET] [--when unreviewed-failures]
|
|
37
55
|
script list | show SCRIPT_ID | remove SCRIPT_ID
|
|
38
56
|
script register|update SCRIPT_ID --file WORKSPACE_PATH --interpreter COMMAND [--arg=VALUE ...] [--timeout-seconds N]
|
|
39
|
-
Execution types: agent (prompt, engine, model, effort) or script (registered script ID and saved arguments).
|
|
57
|
+
Execution types: agent (prompt, engine, auth profile, model, effort) or script (registered script ID and saved arguments).
|
|
58
|
+
--auth-profile runs a Codex/Claude task under that provisioned login (ezenciel-agents-ai profiles); --cli alone uses the default login.
|
|
40
59
|
A script registration references an entry point inside the agent workspace and records its SHA-256. Core invokes
|
|
41
60
|
the installed interpreter with the entry point and arguments directly, without a shell or a model. Changed entry-point
|
|
42
61
|
bytes refuse to run until an explicit script update. The hash covers that file only, not imported dependencies.
|
|
@@ -52,6 +71,10 @@ Failures default to unreviewed owner runs. Review records a diagnosis; it never
|
|
|
52
71
|
A conditional review schedule consumes no model run when there are no unreviewed failures.
|
|
53
72
|
New tasks capture the selected engine settings; edit preserves existing settings unless overridden.
|
|
54
73
|
Every task requires a concrete saved model. Supply --model if the selected settings have none; existing tasks never fall back to chat or client defaults.
|
|
74
|
+
Fallbacks (at most ${MAX_FALLBACKS}, in order) run only when the previous setup's CLI is unavailable or its provider
|
|
75
|
+
rejected the turn before any work began. Access denial, unknown errors, cancellation and failure after work began stop
|
|
76
|
+
the chain; nothing is replayed. A quota rejection skips later setups of the same client or provider (quota scope),
|
|
77
|
+
even with different credentials. The run records each attempt, setup, failure category and any reported reset time.
|
|
55
78
|
Trigger runs an existing task once with its saved instructions, AI and delivery binding in a fresh session. Reuse the request key after an uncertain result; the regular schedule is unchanged.
|
|
56
79
|
Creates a scheduled task. Instructions are text, never shell commands.
|
|
57
80
|
Use --now to run once. Run completion is not delivery proof.
|
|
@@ -159,7 +182,8 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
|
|
|
159
182
|
if(action==='create' && id && (await scheduler.list()).some(s=>s.id===id))throw new Error('Schedule exists; use edit')
|
|
160
183
|
if([v.now,v.at,v.cron,v['every-seconds']].filter(Boolean).length!==1)throw new Error('Choose exactly one trigger')
|
|
161
184
|
if([v.text,v['text-file'],v.script].filter(Boolean).length!==1)throw new Error('Choose --text, --text-file or --script')
|
|
162
|
-
if(v.script && (v.cli || v.model || v.effort))throw new Error('Script schedules run no model; --cli, --model and --
|
|
185
|
+
if(v.script && (v.cli || v['auth-profile'] || v.model || v.effort || v.fallback || v['clear-fallbacks']))throw new Error('Script schedules run no model; --cli, --auth-profile, --model, --effort and --fallback do not apply')
|
|
186
|
+
if(v.fallback && v['clear-fallbacks'])throw new Error('Choose --fallback or --clear-fallbacks')
|
|
163
187
|
if(v.arg && !v.script)throw new Error('--arg applies to script schedules only')
|
|
164
188
|
const start=v.start || new Date(Date.now()+1000).toISOString()
|
|
165
189
|
const trigger:Trigger=v.now ? {at:new Date(Date.now()+1000).toISOString()} : v.at ? {at:v.at} :
|
|
@@ -180,12 +204,14 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
|
|
|
180
204
|
const state = await control.status()
|
|
181
205
|
const selected = state.ai?.presets.find(p => p.id === state.ai!.selectedId)
|
|
182
206
|
const base = v.cli ? initialPreset(v.cli) : previous?.preset || selected || initialPreset(process.env.EZ_EXECUTOR_CLI || 'codex')
|
|
183
|
-
const preset = executionOverrides(base.cli, base, v.model, v.effort)
|
|
207
|
+
const preset = executionOverrides(base.cli, v['auth-profile'] === undefined ? base : {...base, authProfile:v['auth-profile']}, v.model, v.effort)
|
|
184
208
|
if (!isPreset(preset)) throw new Error('Invalid task AI selection')
|
|
209
|
+
const fallbacks = v['clear-fallbacks'] ? undefined : v.fallback ? v.fallback.map(fallbackSetup) : previous?.fallbacks
|
|
210
|
+
if (preset.authProfile !== undefined) await provisionedHome(config.controlDir, preset.cli, preset.authProfile)
|
|
185
211
|
result=await show(await scheduler.save({id:id || 's_'+randomUUID(),name:v.name || 'Task',
|
|
186
212
|
preflight,
|
|
187
213
|
originRunId:previousSchedule?.originRunId ?? caller?.scheduled?.originRunId ?? caller?.id,delivery,text:v.text || await readFile(v['text-file']!,'utf8'),when:v.when as 'unreviewed-failures' | undefined,trigger,enabled:true,owner,
|
|
188
|
-
execution:{sessionId:previous?.sessionId || randomUUID(),preset}},action==='create'))
|
|
214
|
+
execution:{sessionId:previous?.sessionId || randomUUID(),preset,...(fallbacks ? {fallbacks} : {})}},action==='create'))
|
|
189
215
|
}else{
|
|
190
216
|
if(!id)throw new Error('ID required')
|
|
191
217
|
if(action==='cancel'){
|
package/src/scheduler.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { type Owner, sameOwner, validOwner, ownerId, ownerEpoch } from './contro
|
|
|
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
|
-
import { type ExecutionChoice, isExecutionChoice, persistedPreset } from './ai.js'
|
|
11
|
+
import { type AiPreset, type ExecutionChoice, isExecutionChoice, persistedPreset } from './ai.js'
|
|
12
12
|
import { type Trigger, validateTrigger, nextOccurrence } from './schedule-time.js'
|
|
13
13
|
import { RunStore, type RunRecord } from './runs.js'
|
|
14
14
|
import { Scripts, assertScriptArgs, assertScriptId, type ScriptRunRef } from './scripts.js'
|
|
@@ -139,8 +139,13 @@ export class Scheduler {
|
|
|
139
139
|
if (!input.execution) throw new Error('Schedule needs name, text and an AI selection')
|
|
140
140
|
const execution: ExecutionChoice = {...input.execution, preset: persistedPreset(input.execution.preset)}
|
|
141
141
|
if (!input.name || !input.text?.trim() || !isExecutionChoice(execution)) throw new Error('Schedule needs name, text and an AI selection')
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
const setups = [execution.preset, ...execution.fallbacks ?? []]
|
|
143
|
+
for (const preset of setups) {
|
|
144
|
+
assertScheduledModel(preset.model)
|
|
145
|
+
assertEffort(preset.effort, preset.model, preset.cli)
|
|
146
|
+
}
|
|
147
|
+
const key = (p: AiPreset) => JSON.stringify([p.cli, p.provider, p.model, p.effort])
|
|
148
|
+
if (new Set(setups.map(key)).size !== setups.length) throw new Error('Task setups must be distinct')
|
|
144
149
|
s = {...input, execution, trigger:validateTrigger(input.trigger),version:1,revision:randomUUID()}
|
|
145
150
|
}
|
|
146
151
|
const now = Date.now()
|
|
@@ -272,7 +277,7 @@ export class Scheduler {
|
|
|
272
277
|
preflight=checked.receipt
|
|
273
278
|
if(!checked.admitted) {await atomic(cursor,{next:future,preflight});continue}
|
|
274
279
|
const current=await this.get(s.id);if(!current.enabled || current.revision!==s.revision)continue
|
|
275
|
-
} catch {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
|
+
} 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}
|
|
276
281
|
}
|
|
277
282
|
const dueAt = new Date(next).toISOString()
|
|
278
283
|
await runs.create({id:scheduledRunId(s,next),
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { presetLabel, presetProvider, type AiPreset, type ExecutionChoice } from './ai.js'
|
|
2
|
+
|
|
3
|
+
// Ordered task setups for one scheduled occurrence. Core advances only on typed
|
|
4
|
+
// evidence that the engine never started work: a missing CLI or a provider
|
|
5
|
+
// rejection before any model output or tool item. Anything else stops the chain.
|
|
6
|
+
export type StartupCategory = 'cli-unavailable' | 'quota' | 'provider-rejected' | 'access-denied'
|
|
7
|
+
export type StartupRejection = { category: StartupCategory; resetAt?: string }
|
|
8
|
+
export type AttemptCategory = StartupCategory | 'after-work-began' | 'uncertain' | 'same-quota-scope'
|
|
9
|
+
export type SetupAttempt = {
|
|
10
|
+
preset: AiPreset
|
|
11
|
+
quotaScope: string
|
|
12
|
+
outcome: 'running' | 'completed' | 'failed' | 'cancelled' | 'skipped'
|
|
13
|
+
category?: AttemptCategory
|
|
14
|
+
resetAt?: string
|
|
15
|
+
exitCode?: number | null
|
|
16
|
+
startedAt?: string
|
|
17
|
+
endedAt?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const categories: StartupCategory[] = ['cli-unavailable', 'quota', 'provider-rejected', 'access-denied']
|
|
21
|
+
export const advances = (rejection?: StartupRejection): boolean => Boolean(rejection && rejection.category !== 'access-denied')
|
|
22
|
+
|
|
23
|
+
export const setupCandidates = (execution: ExecutionChoice): AiPreset[] => [execution.preset, ...(execution.fallbacks ?? [])]
|
|
24
|
+
|
|
25
|
+
// Allowance is metered per provider account, not per credential file. Setups on
|
|
26
|
+
// the same native client share a scope; OpenCode/Pi share their upstream
|
|
27
|
+
// provider. A different credential alone never proves independent allowance.
|
|
28
|
+
export const quotaScope = (preset: AiPreset): string => {
|
|
29
|
+
if (['opencode', 'pi'].includes(preset.cli)) {
|
|
30
|
+
const provider = preset.cli === 'pi' ? preset.model?.split('/')[0] : presetProvider(preset)
|
|
31
|
+
return provider ? `provider:${provider}` : preset.cli
|
|
32
|
+
}
|
|
33
|
+
return preset.cli === 'codex-gui' ? 'codex' : preset.cli
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The next untried setup, skipping those whose quota scope already rejected
|
|
37
|
+
// this occurrence. Each candidate is tried at most once.
|
|
38
|
+
export const nextSetup = (candidates: AiPreset[], attempts: SetupAttempt[]): { skipped: SetupAttempt[]; preset?: AiPreset } => {
|
|
39
|
+
const exhausted = new Set(attempts.filter(a => a.category === 'quota').map(a => a.quotaScope))
|
|
40
|
+
const skipped: SetupAttempt[] = []
|
|
41
|
+
for (const preset of candidates.slice(attempts.length)) {
|
|
42
|
+
const scope = quotaScope(preset)
|
|
43
|
+
if (!exhausted.has(scope)) return { skipped, preset }
|
|
44
|
+
const at = new Date().toISOString()
|
|
45
|
+
skipped.push({ preset, quotaScope: scope, outcome: 'skipped', category: 'same-quota-scope', startedAt: at, endedAt: at })
|
|
46
|
+
}
|
|
47
|
+
return { skipped }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class SetupsUnavailableError extends Error {}
|
|
51
|
+
|
|
52
|
+
export const unavailableSummary = (attempts: SetupAttempt[]): string =>
|
|
53
|
+
`Every task setup was unavailable: ${attempts.map(a => `${presetLabel(a.preset)} (${a.category}${a.resetAt ? `, resets ${a.resetAt}` : ''})`).join('; ')}`
|
|
54
|
+
|
|
55
|
+
const epochSeconds = (value: unknown): string | undefined =>
|
|
56
|
+
typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? new Date(value * 1000).toISOString() : undefined
|
|
57
|
+
|
|
58
|
+
const claudeErrors: Record<string, StartupCategory> = {
|
|
59
|
+
rate_limit: 'quota', billing_error: 'quota',
|
|
60
|
+
authentication_failed: 'access-denied', oauth_org_not_allowed: 'access-denied', account_on_hold: 'access-denied',
|
|
61
|
+
verification_required: 'access-denied', cloud_credential_error: 'access-denied',
|
|
62
|
+
overloaded: 'provider-rejected', server_error: 'provider-rejected', model_not_found: 'provider-rejected', invalid_request: 'provider-rejected',
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Codex app-server `codexErrorInfo` of a failed turn.
|
|
66
|
+
export const codexRejection = (info: unknown): StartupCategory | undefined => {
|
|
67
|
+
if (info === 'usageLimitExceeded' || info === 'rateLimitExceeded') return 'quota'
|
|
68
|
+
if (info === 'unauthorized') return 'access-denied'
|
|
69
|
+
if (['serverOverloaded', 'internalServerError', 'badRequest', 'flexUnavailable'].includes(info as string)) return 'provider-rejected'
|
|
70
|
+
if (!info || typeof info !== 'object') return undefined
|
|
71
|
+
const status = (Object.values(info)[0] as { httpStatusCode?: unknown } | undefined)?.httpStatusCode
|
|
72
|
+
if (typeof status !== 'number') return undefined
|
|
73
|
+
return status === 429 ? 'quota' : [401, 403].includes(status) ? 'access-denied' : [400, 404].includes(status) || status >= 500 ? 'provider-rejected' : undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The latest reset among exhausted Codex rate-limit windows.
|
|
77
|
+
export const codexResetAt = (limits: any): string | undefined => {
|
|
78
|
+
const resets = [limits?.primary, limits?.secondary].filter(w => w && w.usedPercent >= 100 && typeof w.resetsAt === 'number').map(w => w.resetsAt as number)
|
|
79
|
+
return resets.length ? epochSeconds(Math.max(...resets)) : undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const startupLine = (state: { workBegan: boolean; rejection?: StartupRejection }): string =>
|
|
83
|
+
JSON.stringify({ type: 'ez.startup', workBegan: state.workBegan, ...(state.rejection ? { rejection: state.rejection } : {}) })
|
|
84
|
+
|
|
85
|
+
// Reads structured stdout only: Ez transport summaries (`ez.startup`) and
|
|
86
|
+
// Claude stream-json envelopes. Model text is never inspected. Any event that
|
|
87
|
+
// is not known pre-work metadata counts as work, so ambiguity never advances.
|
|
88
|
+
export const startupObserver = () => {
|
|
89
|
+
let buffer = '', workBegan = false, rejection: StartupRejection | undefined
|
|
90
|
+
const line = (text: string) => {
|
|
91
|
+
if (!text.trim()) return
|
|
92
|
+
let event: any
|
|
93
|
+
try { event = JSON.parse(text) } catch { workBegan = true; return }
|
|
94
|
+
if (event?.type === 'ez.startup') {
|
|
95
|
+
if (event.workBegan === true) workBegan = true
|
|
96
|
+
const r = event.rejection
|
|
97
|
+
if (r && categories.includes(r.category) && (r.resetAt === undefined || Number.isFinite(Date.parse(r.resetAt))))
|
|
98
|
+
rejection = { category: r.category, ...(r.resetAt ? { resetAt: r.resetAt } : {}) }
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
if (event?.type === 'thread.started' || event?.type === 'result' ||
|
|
102
|
+
(event?.type === 'system' && event.subtype === 'init')) return
|
|
103
|
+
if (event?.type === 'rate_limit_event') {
|
|
104
|
+
const info = event.rate_limit_info
|
|
105
|
+
if (info?.status === 'rejected') rejection = { category: 'quota', ...(epochSeconds(info.resetsAt) ? { resetAt: epochSeconds(info.resetsAt) } : {}) }
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
const category = event?.type === 'assistant' && typeof event.error === 'string' ? claudeErrors[event.error] : undefined
|
|
109
|
+
if (category) rejection = { category, ...(category === 'quota' && rejection?.resetAt ? { resetAt: rejection.resetAt } : {}) }
|
|
110
|
+
else workBegan = true
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
write: (chunk: string) => {
|
|
114
|
+
buffer += chunk
|
|
115
|
+
const lines = buffer.split('\n')
|
|
116
|
+
buffer = lines.pop() ?? ''
|
|
117
|
+
for (const text of lines) line(text)
|
|
118
|
+
},
|
|
119
|
+
result: () => { line(buffer); buffer = ''; return { workBegan, rejection } },
|
|
120
|
+
summary: () => startupLine({ workBegan, rejection }),
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/task-executor.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { promisify } from 'node:util'
|
|
|
8
8
|
import { readRun } from './delivery-socket.js'
|
|
9
9
|
import {ApplicationBindings} from './application-channel.js'
|
|
10
10
|
import { Tasks } from './tasks.js'
|
|
11
|
+
import { provisionedHome } from './auth-profile.js'
|
|
11
12
|
import { executorEnvironment, executorInvocation, terminateJob, type ExecutorOptions } from './executor.js'
|
|
12
13
|
|
|
13
14
|
// This adapter is deliberately version-pinned: a new native tool default needs
|
|
@@ -58,8 +59,8 @@ export async function startTaskExecutor(options: ExecutorOptions) {
|
|
|
58
59
|
const catalogInvocation = executorInvocation('codex', ['debug', 'models', '--bundled'])
|
|
59
60
|
const catalog = await promisify(execFile)(catalogInvocation.command, catalogInvocation.args, { env: environment, maxBuffer: 4 * 1024 * 1024 })
|
|
60
61
|
await writeFile(join(temporary, 'models.json'), JSON.stringify(taskModelCatalog(JSON.parse(catalog.stdout))), { mode: 0o600 })
|
|
61
|
-
const auth = process.env.EZ_ISOLATION === 'isolated'
|
|
62
|
-
? join(options.controlDir, '
|
|
62
|
+
const auth = process.env.EZ_ISOLATION === 'isolated' || options.authProfile !== undefined
|
|
63
|
+
? join(await provisionedHome(options.controlDir, 'codex', options.authProfile), 'auth.json')
|
|
63
64
|
: join(homedir(), '.codex', 'auth.json')
|
|
64
65
|
await symlink(auth, join(home, 'auth.json'))
|
|
65
66
|
const broker = [process.execPath, '--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)),
|
package/src/updates/runtime.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
8
|
import { parseEnv } from 'node:util';
|
|
9
9
|
import { tmpdir } from 'node:os';
|
|
10
|
-
import { atomic, compose, snapshot, checkFolders } from '../plugins/manager.mjs';
|
|
10
|
+
import { atomic, compose, snapshot, checkFolders, locked, publishBrokerPlugins } from '../plugins/manager.mjs';
|
|
11
11
|
import { read, state, eligibility, jobPath, cleanupStaleBackups } from './control.mjs';
|
|
12
12
|
import { bindUpdates } from './binding.mjs';
|
|
13
13
|
import { extract, digest } from './artifact.mjs';
|
|
@@ -82,24 +82,44 @@ export async function refreshBroker(config, run=execute, image) {
|
|
|
82
82
|
const current=(await run('docker',[...args,'ps','-q','plugin-broker'],{stdoutOnly:true,timeout:15000})).trim();
|
|
83
83
|
if(!/^[a-f0-9]{12,64}$/i.test(current)||(await run('docker',['inspect','--format','{{.Image}}',current],{stdoutOnly:true,timeout:15000})).trim()!==image)
|
|
84
84
|
throw Error('Recreated broker image does not match the intended installed image');
|
|
85
|
-
|
|
86
|
-
const home=path.dirname(hostConfig)===config.deploymentDir
|
|
87
|
-
? (await read(hostConfig)).agents?.find(agent=>agent.workspace===config.workspace)?.toolsHome : undefined;
|
|
88
|
-
if(typeof home!=='string'||!path.isAbsolute(home))throw Error('Missing broker registry binding');
|
|
89
|
-
const files=[hostConfig,path.join(home,'registry.json'),path.join(home,'config.json')];
|
|
90
|
-
const hashes=await Promise.all(files.map(async file=>digest(await fs.readFile(file))));
|
|
91
|
-
// Supply trusted read-only code so rollback can check an older image that
|
|
92
|
-
// predates this diagnostic. Its validators still come from that image.
|
|
93
|
-
const diagnostic=await fs.readFile(new URL('../../docker/broker-readiness.mjs',import.meta.url),'utf8');
|
|
94
|
-
const probe=JSON.parse(await run('docker',[...args,'exec','-T','plugin-broker','node','--import','/app/node_modules/tsx/dist/loader.mjs',
|
|
95
|
-
'--input-type=module','-e',diagnostic,...hashes],{stdoutOnly:true,timeout:60000}));
|
|
96
|
-
if(probe.version!==1||probe.ready!==true||probe.hostSha256!==hashes[0]||probe.registrySha256!==hashes[1]||probe.configSha256!==hashes[2])
|
|
97
|
-
throw Error('Invalid isolated broker readiness readback');
|
|
98
|
-
// A concurrent writer invalidates the readback; never silently accept it.
|
|
99
|
-
for(let i=0;i<files.length;i++)if(digest(await fs.readFile(files[i]))!==hashes[i])throw Error('Broker binding changed during verification');
|
|
100
|
-
return probe;
|
|
85
|
+
return verifyBroker(config,run);
|
|
101
86
|
}finally {await fs.rm(temporary,{recursive:true,force:true});}
|
|
102
87
|
}
|
|
88
|
+
// A plugin update normally leaves this single-file binding unchanged. If an
|
|
89
|
+
// operator replaced it (for example to approve new network pins), recreation
|
|
90
|
+
// remains necessary and must retain the existing full-turn drain.
|
|
91
|
+
export async function brokerNeedsRefresh(config,run=execute) {
|
|
92
|
+
if (!(await relayServices(config)).includes('plugin-broker')) return false;
|
|
93
|
+
const hostConfig=config.hostConfig||path.join(config.deploymentDir,'host-executor.json');
|
|
94
|
+
const expected=digest(await fs.readFile(hostConfig));
|
|
95
|
+
const script="const fs=require('fs'),crypto=require('crypto');console.log(crypto.createHash('sha256').update(fs.readFileSync(process.env.EZ_PLUGIN_BROKER_HOST_CONFIG)).digest('hex'));";
|
|
96
|
+
const mounted=(await run('docker',[...relayArgs(config),'exec','-T','plugin-broker','node','-e',script],{stdoutOnly:true,timeout:15000})).trim();
|
|
97
|
+
if(!/^[a-f0-9]{64}$/.test(mounted))throw Error('Invalid broker host binding fingerprint');
|
|
98
|
+
return mounted!==expected;
|
|
99
|
+
}
|
|
100
|
+
// Plugin upgrades change the directory-mounted registry, not the broker's
|
|
101
|
+
// single-file host binding. Verify the live broker without replacing its socket.
|
|
102
|
+
export async function verifyBroker(config,run=execute) {
|
|
103
|
+
if (!(await relayServices(config)).includes('plugin-broker')) return null;
|
|
104
|
+
const args=relayArgs(config);
|
|
105
|
+
const hostConfig=config.hostConfig||path.join(config.deploymentDir,'host-executor.json');
|
|
106
|
+
const home=path.dirname(hostConfig)===config.deploymentDir
|
|
107
|
+
? (await read(hostConfig)).agents?.find(agent=>agent.workspace===config.workspace)?.toolsHome : undefined;
|
|
108
|
+
if(typeof home!=='string'||!path.isAbsolute(home))throw Error('Missing broker registry binding');
|
|
109
|
+
const files=[hostConfig,path.join(home,'registry.json'),path.join(home,'config.json')];
|
|
110
|
+
const hashes=await Promise.all(files.map(async file=>digest(await fs.readFile(file))));
|
|
111
|
+
// Supply trusted read-only code so rollback can check an older image that
|
|
112
|
+
// predates this diagnostic. Its validators still come from that image.
|
|
113
|
+
const diagnostic=await fs.readFile(new URL('../../docker/broker-readiness.mjs',import.meta.url),'utf8');
|
|
114
|
+
const probe=JSON.parse(await run('docker',[...args,'exec','-T','plugin-broker','node','--import','/app/node_modules/tsx/dist/loader.mjs',
|
|
115
|
+
'--input-type=module','-e',diagnostic,...hashes],{stdoutOnly:true,timeout:60000}));
|
|
116
|
+
if(probe.version!==1||probe.ready!==true||probe.hostSha256!==hashes[0]||probe.registrySha256!==hashes[1]||probe.configSha256!==hashes[2])
|
|
117
|
+
throw Error('Invalid isolated broker readiness readback');
|
|
118
|
+
// A concurrent writer invalidates the readback; never silently accept it.
|
|
119
|
+
for(let i=0;i<files.length;i++)if(digest(await fs.readFile(files[i]))!==hashes[i])throw Error('Broker binding changed during verification');
|
|
120
|
+
return probe;
|
|
121
|
+
}
|
|
122
|
+
|
|
103
123
|
const healthCodes = new Set(['RELAY_UNREADABLE','RELAY_NOT_POLLING','RELAY_STALE','HOST_UNREADABLE','HOST_STALE','PLUGIN_BROKER_UNREADABLE']);
|
|
104
124
|
async function healthEvidence(config,run) {
|
|
105
125
|
const id=(await run('docker',[...relayArgs(config),'ps','-q','relay'])).trim();
|
|
@@ -140,6 +160,17 @@ export async function perform(home,job,hooks) {
|
|
|
140
160
|
const next=await eligibility(home,job.target,root,job.automatic);
|
|
141
161
|
if(next.old.root!==job.previousRoot||next.old.pkg.version!==job.previousVersion)throw Error('Stale upgrade job');
|
|
142
162
|
job.status='applying';job.root=root;job.startedAt=new Date().toISOString();await save();
|
|
163
|
+
const complete=async()=>{
|
|
164
|
+
job.status='completed';job.endedAt=new Date().toISOString();await save();
|
|
165
|
+
try { job.cleanup=await cleanupStaleBackups(home);await save(); }
|
|
166
|
+
catch(error) { job.cleanupError=error.message;await save().catch(()=>{}); }
|
|
167
|
+
return job;
|
|
168
|
+
};
|
|
169
|
+
const fail=async(error,recoveryHooks)=>{
|
|
170
|
+
let message=error.message;
|
|
171
|
+
if(job.target==='main'&&job.rollback&&/is unhealthy/.test(message))message+=await healthEvidence(config,run).catch(()=> '');
|
|
172
|
+
job.error=message;await save();return recover(home,job,recoveryHooks);
|
|
173
|
+
};
|
|
143
174
|
try {
|
|
144
175
|
if(job.target==='main') {
|
|
145
176
|
job.packageManager=await packageManager(root,run);await save();
|
|
@@ -183,38 +214,44 @@ export async function perform(home,job,hooks) {
|
|
|
183
214
|
job.brokerReadiness=await refreshBroker(config,run,brokerImage);
|
|
184
215
|
}
|
|
185
216
|
} else {
|
|
186
|
-
const old=next.old.record
|
|
217
|
+
const old=next.old.record;
|
|
187
218
|
const secrets=await read(path.join(home,'packages',job.target,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
|
|
188
219
|
const s=await snapshot(root),candidate={...old,source:root,revision:s.revision,manifest:s.manifest,deployment:s.deployment,sharedRevisions:s.sharedRevisions};
|
|
189
220
|
for (const key of old.sharedEnabled || []) if (sharedIdentity(old, key).fingerprint !== sharedIdentity(candidate, key).fingerprint) throw Error('Shared worker changed; disable this client and coordinate an explicit shared worker upgrade before updating');
|
|
190
221
|
await checkFolders(config,candidate,home);
|
|
191
222
|
const stage={...candidate,compose:path.join(dir,'compose.json')};await atomic(stage.compose,await compose(config,stage,secrets,home));
|
|
192
223
|
for(const [service,spec] of Object.entries(stage.deployment.services))await run('docker',[...pluginArgs(stage),spec.image?'pull':'build',service]);
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
224
|
+
return await locked(home,async()=>{
|
|
225
|
+
try {
|
|
226
|
+
const current=await eligibility(home,job.target,root,job.automatic);
|
|
227
|
+
if(current.old.root!==job.previousRoot||current.old.pkg.version!==job.previousVersion||JSON.stringify(current.old.record)!==JSON.stringify(old)||JSON.stringify((await state(home)).config)!==JSON.stringify(config))throw Error('Stale upgrade job');
|
|
228
|
+
if(await brokerNeedsRefresh(config,run)&&!hooks.refreshPluginBroker)throw Error('Broker host binding changed; retry the plugin update after native work drains');
|
|
229
|
+
const r=await read(path.join(home,'registry.json'));
|
|
230
|
+
const running=Boolean((await run('docker',[...pluginArgs(old),'ps','--status','running','-q'])).trim());
|
|
231
|
+
job.rollback={record:old,registry:structuredClone(r),compose:await read(old.compose),running};await save();
|
|
232
|
+
await run('docker',[...pluginArgs(old),'stop']);
|
|
233
|
+
const backup=path.join(dir,'backup');await fs.mkdir(backup,{mode:0o700});
|
|
234
|
+
const volumes=new Set(Object.values(old.deployment.services).flatMap(s=>Object.keys(s.volumes||{})));
|
|
235
|
+
for(const name of volumes)await backupVolume(run,old,name,backup);
|
|
236
|
+
await atomic(old.compose,await compose(config,candidate,secrets,home));
|
|
237
|
+
if(running)await run('docker',[...pluginArgs(candidate),'up','-d','--wait','--wait-timeout','90','--no-build']);
|
|
238
|
+
r.plugins[job.target]=candidate;
|
|
239
|
+
for(const alias of Object.keys(candidate.manifest.commands))r.commands[alias]=job.target;
|
|
240
|
+
await atomic(path.join(home,'registry.json'),r);
|
|
241
|
+
job.brokerReadiness=hooks.refreshPluginBroker?await refreshBroker(config,run):await verifyBroker(config,run);
|
|
242
|
+
await publishBrokerPlugins(home,agent.controlDir);
|
|
243
|
+
job.runtimeVerified=running;
|
|
244
|
+
return await complete();
|
|
245
|
+
} catch(error) { return fail(error,{...hooks,registryLocked:true}); }
|
|
246
|
+
},{drainInvocations:true,signal:hooks.signal});
|
|
206
247
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
return job;
|
|
211
|
-
}catch(error){
|
|
212
|
-
let message=error.message;
|
|
213
|
-
if(job.target==='main'&&job.rollback&&/is unhealthy/.test(message))message+=await healthEvidence(config,run).catch(()=> '');
|
|
214
|
-
job.error=message;await save();return recover(home,job,hooks);
|
|
215
|
-
}
|
|
248
|
+
return await complete();
|
|
249
|
+
}catch(error){return fail(error,hooks);}
|
|
250
|
+
|
|
216
251
|
}
|
|
217
252
|
export async function recover(home,job,hooks) {
|
|
253
|
+
if(job.target!=='main'&&job.rollback&&!hooks.registryLocked)
|
|
254
|
+
return locked(home,()=>recover(home,job,{...hooks,registryLocked:true}),{drainInvocations:true,signal:hooks.signal});
|
|
218
255
|
const run=hooks.execute||execute,{config}=await state(home);
|
|
219
256
|
try {
|
|
220
257
|
if(job.rollback) {
|
|
@@ -239,7 +276,8 @@ export async function recover(home,job,hooks) {
|
|
|
239
276
|
const b=job.rollback;
|
|
240
277
|
await run('docker',[...pluginArgs(b.record),'stop']);
|
|
241
278
|
await atomic(b.record.compose,b.compose);await atomic(path.join(home,'registry.json'),b.registry);
|
|
242
|
-
job.rollbackBrokerReadiness=await refreshBroker(config,run);
|
|
279
|
+
job.rollbackBrokerReadiness=hooks.refreshPluginBroker?await refreshBroker(config,run):await verifyBroker(config,run);
|
|
280
|
+
await publishBrokerPlugins(home,(await state(home)).agent.controlDir);
|
|
243
281
|
if(b.running)await run('docker',[...pluginArgs(b.record),'up','-d','--wait','--wait-timeout','90','--no-build']);
|
|
244
282
|
}
|
|
245
283
|
job.status='rolled-back';
|
|
@@ -5,7 +5,7 @@ import { spawn } from 'node:child_process';
|
|
|
5
5
|
import { atomic, locked } from '../plugins/manager.mjs';
|
|
6
6
|
import { callDeliverySocket } from '../delivery-socket-client.mjs';
|
|
7
7
|
import { state, read, jobs, jobPath, check, prepare, submit, missing, cleanupStaleBackups } from './control.mjs';
|
|
8
|
-
import { perform, environment } from './runtime.mjs';
|
|
8
|
+
import { perform, environment, brokerNeedsRefresh } from './runtime.mjs';
|
|
9
9
|
|
|
10
10
|
const reservedProviderKeys=new Set(['HOME','LANG','LC_ALL','LOGNAME','PATH','SHELL','TERM','TMPDIR','USER','CODEX_HOME','NODE_OPTIONS']);
|
|
11
11
|
const providerEnvironmentKey=value=>{
|
|
@@ -52,6 +52,11 @@ export async function withIdleUpgrade(control,job,signal,apply,{isIdle=idle,wait
|
|
|
52
52
|
if(!signal.aborted)return await apply();
|
|
53
53
|
} finally {await fs.rm(pause,{force:true});}
|
|
54
54
|
}
|
|
55
|
+
// Plugin replacement drains command leases at activation, not native turns.
|
|
56
|
+
export async function withUpgrade(control,job,signal,apply,options) {
|
|
57
|
+
if(job.target==='main'||options?.requiresIdle)return withIdleUpgrade(control,job,signal,apply,options);
|
|
58
|
+
if(!signal.aborted)return apply();
|
|
59
|
+
}
|
|
55
60
|
export async function queueAutomatic(home,available) {
|
|
56
61
|
const existing=await jobs(home);
|
|
57
62
|
if(existing.some(job=>['queued','applying','recovery-required'].includes(job.status)))return null;
|
|
@@ -94,6 +99,15 @@ export async function supervise(deployment,signal,{discover=check}={}) {
|
|
|
94
99
|
await waitForHostHeartbeat(agent.controlDir,child,()=>error);
|
|
95
100
|
};
|
|
96
101
|
const pause=path.join(agent.controlDir,'upgrade-pause.json');
|
|
102
|
+
const refreshRequirement=async job=>{
|
|
103
|
+
if(job.target==='main')return false;
|
|
104
|
+
try {return await brokerNeedsRefresh((await state(home)).config);}
|
|
105
|
+
catch(error) {
|
|
106
|
+
job.status=job.status==='applying'?'recovery-required':'failed';job.error=error.message;
|
|
107
|
+
await atomic(path.join(jobPath(home,job.id),'job.json'),job);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
97
111
|
const beat=setInterval(()=>{void atomic(path.join(directory,'supervisor.json'),{pid:process.pid,at:Date.now()}).catch(()=>{});},1000);
|
|
98
112
|
try {
|
|
99
113
|
await atomic(path.join(directory,'supervisor.json'),{pid:process.pid,at:Date.now()});
|
|
@@ -107,8 +121,11 @@ export async function supervise(deployment,signal,{discover=check}={}) {
|
|
|
107
121
|
await sleep(1200);
|
|
108
122
|
const interrupted=(await jobs(home)).find(j=>j.status==='applying');
|
|
109
123
|
if(interrupted){
|
|
110
|
-
|
|
111
|
-
|
|
124
|
+
const refreshPluginBroker=await refreshRequirement(interrupted);
|
|
125
|
+
const result=refreshPluginBroker===null?undefined:interrupted.target==='main'
|
|
126
|
+
? await (async()=>{await atomic(pause,{id:interrupted.id});try{return await locked(home,()=>perform(home,interrupted,{stopHost,startHost}));}finally{await fs.rm(pause,{force:true});}})()
|
|
127
|
+
: await withUpgrade(agent.controlDir,interrupted,signal,()=>perform(home,interrupted,{stopHost,startHost,signal,refreshPluginBroker}),{requiresIdle:refreshPluginBroker});
|
|
128
|
+
if(result?.status==='completed'&&interrupted.target==='main')return;
|
|
112
129
|
}
|
|
113
130
|
const isolated=host.isolation==='isolated'
|
|
114
131
|
if(!isolated && !child)await startHost((await state(home)).config.packageRoot);
|
|
@@ -117,15 +134,19 @@ export async function supervise(deployment,signal,{discover=check}={}) {
|
|
|
117
134
|
if(!isolated && child?.exitCode!==null&&child?.exitCode!==undefined)throw Error('Host transport exited; supervisor service should restart');
|
|
118
135
|
const pending=(await jobs(home)).find(j=>j.status==='queued');
|
|
119
136
|
if(pending) {
|
|
120
|
-
const
|
|
137
|
+
const refreshPluginBroker=await refreshRequirement(pending);
|
|
138
|
+
if(refreshPluginBroker===null)continue;
|
|
139
|
+
const apply=async()=>{
|
|
121
140
|
try {
|
|
122
|
-
|
|
141
|
+
const activate=async()=>{const latest=await read(path.join(jobPath(home,pending.id),'job.json'));return perform(home,latest,{stopHost,startHost,signal,refreshPluginBroker});};
|
|
142
|
+
return pending.target==='main'?await locked(home,activate):await activate();
|
|
123
143
|
} catch(error) {
|
|
124
144
|
// A pre-switch rejection is terminal. Applying jobs keep their journal for recovery.
|
|
125
145
|
const latest=await read(path.join(jobPath(home,pending.id),'job.json'));
|
|
126
146
|
if(latest.status==='queued'){latest.status='failed';latest.error=error.message;await atomic(path.join(jobPath(home,pending.id),'job.json'),latest);}else throw error;
|
|
127
147
|
}
|
|
128
|
-
}
|
|
148
|
+
};
|
|
149
|
+
const result=await withUpgrade(agent.controlDir,pending,signal,apply,{requiresIdle:refreshPluginBroker});
|
|
129
150
|
if(result?.status==='completed') {
|
|
130
151
|
if(pending.target==='main')return;
|
|
131
152
|
nextCheck=0;
|