@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.
@@ -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 --effort do not apply')
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
- assertScheduledModel(execution.preset.model)
143
- assertEffort(execution.preset.effort, execution.preset.model, execution.preset.cli)
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
+ }
@@ -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, 'cli', 'codex', 'auth.json')
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)),
@@ -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
- const hostConfig=config.hostConfig||path.join(config.deploymentDir,'host-executor.json');
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,r=await read(path.join(home,'registry.json'));
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
- const running=Boolean((await run('docker',[...pluginArgs(old),'ps','--status','running','-q'])).trim());
194
- job.rollback={record:old,registry:structuredClone(r),compose:await read(old.compose),running};await save();
195
- await run('docker',[...pluginArgs(old),'stop']);
196
- const backup=path.join(dir,'backup');await fs.mkdir(backup,{mode:0o700});
197
- const volumes=new Set(Object.values(old.deployment.services).flatMap(s=>Object.keys(s.volumes||{})));
198
- for(const name of volumes)await backupVolume(run,old,name,backup);
199
- await atomic(old.compose,await compose(config,candidate,secrets,home));
200
- if(running)await run('docker',[...pluginArgs(candidate),'up','-d','--wait','--wait-timeout','90','--no-build']);
201
- r.plugins[job.target]=candidate;
202
- for(const alias of Object.keys(candidate.manifest.commands))r.commands[alias]=job.target;
203
- await atomic(path.join(home,'registry.json'),r);
204
- job.brokerReadiness=await refreshBroker(config,run);
205
- job.runtimeVerified=running;
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
- job.status='completed';job.endedAt=new Date().toISOString();await save();
208
- try { job.cleanup=await cleanupStaleBackups(home);await save(); }
209
- catch(error) { job.cleanupError=error.message;await save().catch(()=>{}); }
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
- await atomic(pause,{id:interrupted.id});const result=await locked(home,()=>perform(home,interrupted,{stopHost,startHost}));await fs.rm(pause,{force:true});
111
- if(result.status==='completed'&&interrupted.target==='main')return;
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 result=await withIdleUpgrade(agent.controlDir,pending,signal,async()=>{
137
+ const refreshPluginBroker=await refreshRequirement(pending);
138
+ if(refreshPluginBroker===null)continue;
139
+ const apply=async()=>{
121
140
  try {
122
- return await locked(home,async()=>{const latest=await read(path.join(jobPath(home,pending.id),'job.json'));return perform(home,latest,{stopHost,startHost});});
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;