@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.
@@ -0,0 +1,187 @@
1
+ import { ownerRun } from './helpers/owner-run.js'
2
+ import assert from 'node:assert/strict'
3
+ import test from 'node:test'
4
+ import { lstat, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
5
+ import { spawn, spawnSync } from 'node:child_process'
6
+ import { tmpdir } from 'node:os'
7
+ import path from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { startExecutorJob } from '../src/executor.js'
10
+ import { ControlStore } from '../src/control-state.js'
11
+ import { RunStore } from '../src/runs.js'
12
+ import { serveHostExecutor } from '../src/host-executor.js'
13
+ import { authProfileStatus, initialPreset, readModels, validateSelection, type AiPreset } from '../src/ai.js'
14
+
15
+ const restoreEnv = (keys: string[]) => {
16
+ const prior = Object.fromEntries(keys.map(key => [key, process.env[key]]))
17
+ return () => { for (const [key, value] of Object.entries(prior)) value === undefined ? delete process.env[key] : process.env[key] = value }
18
+ }
19
+
20
+ test('each Claude and Codex profile runs only with its own home and credentials', {skip:process.platform==='win32'}, async t => {
21
+ const root = await mkdtemp(path.join(tmpdir(), 'ez-profiles-'))
22
+ t.after(() => rm(root, { recursive: true, force: true }))
23
+ t.after(restoreEnv(['PATH', 'HOME', 'EZ_ISOLATION', 'EZ_EXECUTOR_TRANSPORT']))
24
+ const bin = path.join(root, 'bin'), workspace = path.join(root, 'mind'), controlDir = path.join(root, 'control')
25
+ await mkdir(bin); await mkdir(workspace)
26
+ const observed = path.join(root, 'observed.json')
27
+ for (const cli of ['claude', 'codex']) await writeFile(path.join(bin, cli), `#!${process.execPath}
28
+ const fs=require('fs'),p=require('path');let auth=null
29
+ try{auth=fs.lstatSync(p.join(process.env.CODEX_HOME,'auth.json')).isSymbolicLink()?'link:'+fs.readlinkSync(p.join(process.env.CODEX_HOME,'auth.json')):'file'}catch{}
30
+ fs.writeFileSync(${JSON.stringify(observed)},JSON.stringify({config:process.env.CLAUDE_CONFIG_DIR,storage:process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR,token:process.env.CLAUDE_CODE_OAUTH_TOKEN,codexHome:process.env.CODEX_HOME,auth}))`, { mode: 0o700 })
31
+ process.env.PATH = bin + path.delimiter + process.env.PATH
32
+ process.env.HOME = root
33
+ delete process.env.EZ_ISOLATION; delete process.env.EZ_EXECUTOR_TRANSPORT
34
+ const tokens = { work: 'sk-ant-oat01-work_profile_token', personal: 'sk-ant-oat01-personal_profile_token' }
35
+ for (const [name, token] of Object.entries(tokens)) {
36
+ await mkdir(path.join(controlDir, 'cli', 'profiles', name, 'claude'), { recursive: true })
37
+ await writeFile(path.join(controlDir, 'cli', 'profiles', name, 'claude', 'oauth-token'), token, { mode: 0o600 })
38
+ await mkdir(path.join(controlDir, 'cli', 'profiles', name, 'codex'), { recursive: true })
39
+ await writeFile(path.join(controlDir, 'cli', 'profiles', name, 'codex', 'auth.json'), '{}', { mode: 0o600 })
40
+ }
41
+ let n = 0
42
+ const run = async (cli: string, authProfile?: string) => {
43
+ const runId = `r_profile_${n++}`
44
+ await ownerRun(controlDir, runId)
45
+ await rm(observed, { force: true })
46
+ const job = await startExecutorJob(['hello'], { workspace, controlDir, binDir: bin, cli, authProfile, runId, timeoutMs: 5000, repairEnabled: false })
47
+ assert.equal(await new Promise(resolve => job.child.once('close', resolve)), 0); await job.cleanup()
48
+ return JSON.parse(await readFile(observed, 'utf8'))
49
+ }
50
+ // Default bindings are unchanged: shared host login.
51
+ const claudeDefault = await run('claude')
52
+ assert.equal(claudeDefault.config, path.join(controlDir, 'cli', 'claude')); assert.equal(claudeDefault.storage, ''); assert.equal(claudeDefault.token, undefined)
53
+ const codexDefault = await run('codex')
54
+ assert.equal(codexDefault.codexHome, path.join(controlDir, 'cli', 'codex')); assert.equal(codexDefault.auth, `link:${path.join(root, '.codex', 'auth.json')}`)
55
+ // Named profiles use only their own store: no shared secure storage, no host link.
56
+ for (const [name, token] of Object.entries(tokens)) {
57
+ const claude = await run('claude', name)
58
+ assert.deepEqual(claude, { config: path.join(controlDir, 'cli', 'profiles', name, 'claude'), token, auth: null })
59
+ const codex = await run('codex', name)
60
+ assert.deepEqual(codex, { codexHome: path.join(controlDir, 'cli', 'profiles', name, 'codex'), auth: 'file' })
61
+ }
62
+ // Unprovisioned or unsupported profiles fail closed before any process starts.
63
+ for (const [cli, authProfile, error] of [['claude', 'missing', /not provisioned/], ['codex', 'missing', /not provisioned/], ['opencode', 'work', /Invalid auth profile/], ['claude', '../work', /Invalid auth profile/]] as const) {
64
+ await ownerRun(controlDir, `r_reject_${n}`); await rm(observed, { force: true })
65
+ await assert.rejects(startExecutorJob(['hello'], { workspace, controlDir, binDir: bin, cli, authProfile, runId: `r_reject_${n++}`, timeoutMs: 5000, repairEnabled: false }), error)
66
+ await assert.rejects(stat(observed))
67
+ }
68
+ assert.equal((await lstat(path.join(controlDir, 'cli', 'profiles'))).isDirectory(), true)
69
+ await assert.rejects(stat(path.join(controlDir, 'cli', 'profiles', 'missing')))
70
+ })
71
+
72
+ test('a conversation stays bound to its auth profile and never resumes under another', async () => {
73
+ const root = await mkdtemp(path.join(tmpdir(), 'ez-profile-sessions-'))
74
+ try {
75
+ const store = new ControlStore(root, 900000)
76
+ await store.aiState(initialPreset('claude'))
77
+ const work: AiPreset = { id: 'work', name: 'Claude work', cli: 'claude', authProfile: 'work', model: 'opus' }
78
+ const personal: AiPreset = { id: 'personal', name: 'Claude personal', cli: 'claude', authProfile: 'personal', model: 'opus' }
79
+ for (const preset of [work, personal]) await store.savePreset(preset)
80
+ const first = await store.captureChoice(initialPreset('claude'))
81
+ await store.markSessionStarted(first.sessionId)
82
+ // A profile change is a new conversation, never an in-place settings change.
83
+ assert.equal(await store.selectPreset('work', first.sessionId), false)
84
+ assert.equal(await store.selectPreset('work', first.sessionId, true), true)
85
+ const working = await store.captureChoice(initialPreset('claude'))
86
+ assert.notEqual(working.sessionId, first.sessionId)
87
+ assert.equal((await store.executionSession(working)).authProfile, 'work')
88
+ await store.markSessionStarted(working.sessionId)
89
+ // A choice that names another account for this session is rejected at launch.
90
+ await assert.rejects(store.executionSession({ sessionId: working.sessionId, preset: personal }), /matching CLI binding/)
91
+ await assert.rejects(store.executionSession({ sessionId: first.sessionId, preset: work }), /matching CLI binding/)
92
+ // Switching back restores that conversation's own account.
93
+ assert.equal(await store.selectPreset('personal', working.sessionId, true), true)
94
+ const restored = await store.switchSession(working.sessionId)
95
+ assert.equal(restored.authProfile, 'work')
96
+ assert.equal((await store.captureChoice(initialPreset('claude'))).preset.authProfile, 'work')
97
+ } finally { await rm(root, { recursive: true, force: true }) }
98
+ })
99
+
100
+ test('catalog offers provisioned profiles only; host refuses a request that changes the captured profile', {skip:process.platform==='win32'}, async t => {
101
+ const root = await mkdtemp(path.join(tmpdir(), 'ez-profile-host-'))
102
+ t.after(() => rm(root, { recursive: true, force: true }))
103
+ t.after(restoreEnv(['PATH']))
104
+ const workspace = path.join(root, 'mind'), controlDir = path.join(root, 'control'), bin = path.join(root, 'bin'), directory = path.join(controlDir, 'host-executor')
105
+ await mkdir(workspace, { recursive: true }); await mkdir(bin); await mkdir(directory, { recursive: true })
106
+ for (const cli of ['codex', 'claude']) await writeFile(path.join(bin, cli), '#!/bin/sh\nexit 0\n', { mode: 0o700 })
107
+ await mkdir(path.join(controlDir, 'cli', 'profiles', 'work', 'claude'), { recursive: true })
108
+ process.env.PATH = bin + path.delimiter + process.env.PATH
109
+ const catalog = await readModels(root, undefined, path.join(controlDir, 'cli', 'codex'), undefined, undefined, undefined, controlDir)
110
+ assert.deepEqual(catalog.filter(model => model.authProfile), [{ cli: 'claude', name: 'work · claude · client default', efforts: [], authProfile: 'work' }])
111
+ await validateSelection({ id: 'w', name: 'w', cli: 'claude', authProfile: 'work' }, catalog)
112
+ await assert.rejects(validateSelection({ id: 'p', name: 'p', cli: 'claude', authProfile: 'personal' }, catalog), /not provisioned/)
113
+
114
+ const runs = new RunStore(controlDir)
115
+ await ownerRun(controlDir, 'r_seed')
116
+ const execution = { sessionId: '00000000-0000-4000-8000-000000000001', preset: { id: 'work', name: 'work', cli: 'claude', authProfile: 'work' } }
117
+ for (const id of ['r_wrong', 'r_right', 'r_dropped']) {
118
+ await runs.create({ id, chatId: 101, telegramUserId: 101, texts: ['hello'], execution })
119
+ await runs.patch(id, { status: 'running' })
120
+ }
121
+ const abort = new AbortController(), launched: string[] = []
122
+ const server = serveHostExecutor({ cli: 'codex', agents: [{ name: 'test', workspace, controlDir, binDir: bin }] }, abort.signal, async (_texts, options) => {
123
+ launched.push(`${options.runId}:${options.authProfile}`)
124
+ return { child: spawn(process.execPath, ['-e', 'process.exit(0)']), cleanup: async () => {}, stdout: '' }
125
+ }, async () => catalog)
126
+ try {
127
+ const exit = async (id: string, options: Record<string, unknown>) => {
128
+ await writeFile(path.join(directory, `${id}.request.json`), JSON.stringify({ texts: ['hello'], options }))
129
+ for (let n = 0; n < 200; n++) {
130
+ const events = await readFile(path.join(directory, `${id}.events`), 'utf8').catch(() => '')
131
+ const code = events.match(/"stream":"exit","code":(\d+)/)?.[1]
132
+ if (code !== undefined) return { code: Number(code), events }
133
+ await new Promise(resolve => setTimeout(resolve, 20))
134
+ }
135
+ throw new Error('host request did not finish')
136
+ }
137
+ for (const [id, options] of [['r_wrong', { authProfile: 'personal' }], ['r_dropped', {}]] as const) {
138
+ const result = await exit(id, { cli: 'claude', sessionId: execution.sessionId, ...options })
139
+ assert.equal(result.code, 1); assert.match(result.events, /does not match its captured AI selection/)
140
+ }
141
+ assert.equal((await exit('r_right', { cli: 'claude', authProfile: 'work', sessionId: execution.sessionId })).code, 0)
142
+ assert.deepEqual(launched, ['r_right:work'])
143
+ } finally { abort.abort(); await server }
144
+ })
145
+
146
+ test('profile setup and diagnostics never print credentials or account identity', {skip:process.platform==='win32'}, async () => {
147
+ const root = await mkdtemp(path.join(tmpdir(), 'ez-profile-cli-'))
148
+ try {
149
+ const controlDir = path.join(root, 'control'), bin = path.join(root, 'bin')
150
+ await mkdir(bin)
151
+ await writeFile(path.join(bin, 'claude'), `#!/bin/sh
152
+ if [ "$1" = auth ]; then
153
+ if [ -n "$CLAUDE_CODE_OAUTH_TOKEN" ]; then echo '{"loggedIn":true,"authMethod":"oauth_token","email":"owner@example.com","orgId":"org-secret"}'; exit 0; fi
154
+ echo '{"loggedIn":false,"authMethod":"none"}'; exit 1
155
+ fi
156
+ exit 0
157
+ `, { mode: 0o700 })
158
+ await writeFile(path.join(bin, 'codex'), '#!/bin/sh\n[ -f "$CODEX_HOME/auth.json" ] && { echo "Logged in using ChatGPT (owner@example.com)" >&2; exit 0; }\necho "Not logged in"; exit 1\n', { mode: 0o700 })
159
+ const store = new ControlStore(controlDir, 900000); await store.aiState(initialPreset('claude'))
160
+ const env = { ...process.env, HOME: root, PATH: bin + path.delimiter + process.env.PATH, EZ_CONTROL_DIR: controlDir }
161
+ const ai = (...args: string[]) => spawnSync(process.execPath, [fileURLToPath(new URL('../bin/ezenciel-agents-ai.mjs', import.meta.url)), ...args], { env, encoding: 'utf8' })
162
+ for (const [cli, name] of [['claude', 'work'], ['claude', 'personal'], ['codex', 'work']]) {
163
+ const added = ai('profile', 'add', '--cli', cli, '--auth-profile', name)
164
+ assert.equal(added.status, 0, added.stderr)
165
+ const home = path.join(controlDir, 'cli', 'profiles', name, cli)
166
+ assert.equal(JSON.parse(added.stdout).home, home)
167
+ assert.equal((await stat(home)).mode & 0o777, 0o700)
168
+ }
169
+ assert.notEqual(ai('profile', 'add', '--cli', 'opencode', '--auth-profile', 'work').status, 0)
170
+ await writeFile(path.join(controlDir, 'cli', 'profiles', 'work', 'claude', 'oauth-token'), 'sk-ant-oat01-never_printed_token', { mode: 0o600 })
171
+ await writeFile(path.join(controlDir, 'cli', 'profiles', 'work', 'codex', 'auth.json'), '{"tokens":"never-printed"}', { mode: 0o600 })
172
+ const profiles = ai('profiles')
173
+ assert.equal(profiles.status, 0, profiles.stderr)
174
+ for (const secret of ['never_printed', 'never-printed', 'owner@example.com', 'org-secret']) assert.equal(profiles.stdout.includes(secret), false)
175
+ const byKey = Object.fromEntries((JSON.parse(profiles.stdout) as Awaited<ReturnType<typeof authProfileStatus>>).map(item => [`${item.cli}@${item.authProfile}`, item]))
176
+ assert.deepEqual(byKey['claude@work'], { cli: 'claude', authProfile: 'work', provisioned: true, sharesHostLogin: false, credentialFiles: ['oauth-token'], loggedIn: true, method: 'oauth_token' })
177
+ assert.equal(byKey['claude@personal'].loggedIn, false)
178
+ assert.deepEqual(byKey['codex@work'], { cli: 'codex', authProfile: 'work', provisioned: true, sharesHostLogin: false, credentialFiles: ['auth.json'], loggedIn: true, method: 'chatgpt' })
179
+ assert.equal(byKey['claude@null'].sharesHostLogin, true)
180
+ // Selecting a provisioned profile binds a fresh conversation to it.
181
+ const selected = ai('select', '--cli', 'claude', '--auth-profile', 'work')
182
+ assert.equal(selected.status, 0, selected.stderr)
183
+ assert.equal((await store.status()).activeSession?.authProfile, 'work')
184
+ assert.notEqual(ai('select', '--cli', 'claude', '--auth-profile', 'unknown').status, 0)
185
+ assert.equal((await store.status()).activeSession?.authProfile, 'work')
186
+ } finally { await rm(root, { recursive: true, force: true }) }
187
+ })
@@ -209,6 +209,9 @@ test('scheduled preflight uses authoritative configuration and rejects writes or
209
209
  registry.plugins.sample.manifest.commands.sample.exposure={receivesExternalContent:true,changesRecords:false,sendsExternally:false,requiresReview:false};
210
210
  await fs.writeFile(file,JSON.stringify(registry));
211
211
  const accepted=await request(f.socket,payload);assert.equal(accepted.ok,true);assert.equal(accepted.result.eligible,true);
212
+ await fs.writeFile(path.join(f.fake,'docker'), `#!${process.execPath}\nprocess.stdout.write('SECRET-token-xyz');\n`, {mode:0o700});
213
+ const invalid=await request(f.socket,payload);assert.equal(invalid.ok,false);
214
+ assert.equal(invalid.error,'Scheduled plugin preflight returned invalid output');assert(!JSON.stringify(invalid).includes('SECRET-token-xyz'));
212
215
  await scheduler.save({...schedule,enabled:false});
213
216
  assert.equal((await request(f.socket,payload)).ok,false);
214
217
  } finally {abort.abort();await serving;process.env.PATH=previousPath;}
@@ -672,3 +672,27 @@ test('bounded lock admission times out without stealing an interrupted lock',asy
672
672
  await assert.rejects(locked(f.home,async()=>assert.fail('must not enter'),{waitMs:50}),/Registry busy/);
673
673
  assert.equal(await fs.readFile(file,'utf8'),'preserved');
674
674
  });
675
+
676
+
677
+ test('cancelled plugin upgrade drain preserves live invocation and releases admission lock',async t=>{
678
+ const f=await fixture(t),controller=new AbortController();
679
+ const directory=path.join(f.home,'command-invocations');await fs.mkdir(directory,{recursive:true});
680
+ const lease=path.join(directory,'00000000-0000-4000-8000-000000000000.json');
681
+ await fs.writeFile(lease,JSON.stringify({pid:process.pid,container:'active-call'}));
682
+ let activated=false;
683
+ const drain=locked(f.home,async()=>{activated=true;},{drainInvocations:true,signal:controller.signal});
684
+ for(let n=0;n<100&&!await fs.access(path.join(f.home,'registry.lock')).then(()=>true,()=>false);n++)await new Promise(resolve=>setTimeout(resolve,10));
685
+ controller.abort();await assert.rejects(drain,{name:'AbortError'});assert.equal(activated,false);
686
+ await fs.access(lease);await assert.rejects(fs.access(path.join(f.home,'registry.lock')),{code:'ENOENT'});
687
+ });
688
+
689
+
690
+ test('native call cancelled while admission is closed never acquires an invocation lease',async t=>{
691
+ const f=await fixture(t),controller=new AbortController();await fs.mkdir(f.home,{recursive:true});
692
+ let entered,release;const ready=new Promise(resolve=>entered=resolve),hold=new Promise(resolve=>release=resolve);
693
+ const activation=locked(f.home,async()=>{entered();await hold;});await ready;
694
+ const call=prepareCommand(f.home,'sample',[],{invocation:true,signal:controller.signal});
695
+ controller.abort();await assert.rejects(call,{name:'AbortError'});
696
+ await assert.rejects(fs.access(path.join(f.home,'command-invocations')),{code:'ENOENT'});
697
+ release();await activation;
698
+ });
@@ -0,0 +1,245 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, rm } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import { tmpdir } from 'node:os'
6
+ import { spawn } from 'node:child_process'
7
+ import { once } from 'node:events'
8
+ import { createRelay } from '../src/index.js'
9
+ import { ControlStore } from '../src/control-state.js'
10
+ import { RunStore, activeSetup } from '../src/runs.js'
11
+ import { Scheduler } from '../src/scheduler.js'
12
+ import { CliUnavailableError, EXECUTOR_REGISTRY, type ExecutorOptions } from '../src/executor.js'
13
+ import { codexRejection, nextSetup, quotaScope, startupObserver, startupLine } from '../src/setup-fallback.js'
14
+ import type { AiPreset } from '../src/ai.js'
15
+
16
+ const until=async(check:()=>Promise<boolean>)=>{for(let i=0;i<250;i++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Timed out')}
17
+ const setup=(id:string,cli:string,model:string):AiPreset=>({id,name:id,cli,model})
18
+ const claudeA=setup('claude-a','claude','opus'),claudeB=setup('claude-b','claude','sonnet')
19
+ const codexA=setup('codex-a','codex','gpt-6-sol'),codexB=setup('codex-b','codex','gpt-6-astra')
20
+ const observe=(...lines:unknown[])=>{const o=startupObserver();o.write(lines.map(l=>typeof l==='string'?l:JSON.stringify(l)).join('\n')+'\n');return o.result()}
21
+
22
+ test('startup observer advances only on typed rejection before any work',()=>{
23
+ assert.deepEqual(observe({type:'system',subtype:'init'},{type:'rate_limit_event',rate_limit_info:{status:'rejected',resetsAt:1791460800}},{type:'assistant',error:'rate_limit',message:{content:[]}},{type:'result',is_error:true}),
24
+ {workBegan:false,rejection:{category:'quota',resetAt:'2026-10-08T12:00:00.000Z'}})
25
+ assert.equal(observe({type:'assistant',error:'authentication_failed'}).rejection?.category,'access-denied')
26
+ assert.equal(observe({type:'assistant',error:'model_not_found'}).rejection?.category,'provider-rejected')
27
+ // Real output, unknown errors and unparseable lines all count as work.
28
+ assert.equal(observe({type:'assistant',message:{content:[{type:'tool_use'}]}},{type:'assistant',error:'rate_limit'}).workBegan,true)
29
+ assert.equal(observe({type:'assistant',error:'unknown'}).workBegan,true)
30
+ assert.equal(observe('plain text').workBegan,true)
31
+ for (const subtype of ['hook_started','hook_progress','hook_response','unknown'])
32
+ assert.equal(observe({type:'system',subtype},{type:'assistant',error:'rate_limit'}).workBegan,true)
33
+ // Ez transport summaries (Codex native session, host-forwarded Claude) and session metadata.
34
+ assert.deepEqual(observe({type:'thread.started',thread_id:'t'},startupLine({workBegan:false,rejection:{category:'cli-unavailable'}})),{workBegan:false,rejection:{category:'cli-unavailable'}})
35
+ assert.equal(observe(startupLine({workBegan:true}),startupLine({workBegan:false,rejection:{category:'quota'}})).workBegan,true)
36
+ })
37
+
38
+ test('only scheduled Claude runs stream structured events',()=>{
39
+ assert.deepEqual(EXECUTOR_REGISTRY.claude.buildArgs({workspace:'/w',nativeSession:true},'','').slice(-3),['--output-format','stream-json','--verbose'])
40
+ assert.ok(!EXECUTOR_REGISTRY.claude.buildArgs({workspace:'/w'},'','').includes('stream-json'))
41
+ })
42
+
43
+ test('codex errors map to categories; policy and context errors never advance',()=>{
44
+ assert.equal(codexRejection('usageLimitExceeded'),'quota')
45
+ assert.equal(codexRejection('unauthorized'),'access-denied')
46
+ assert.equal(codexRejection('serverOverloaded'),'provider-rejected')
47
+ assert.equal(codexRejection({responseTooManyFailedAttempts:{httpStatusCode:429}}),'quota')
48
+ assert.equal(codexRejection({httpConnectionFailed:{httpStatusCode:403}}),'access-denied')
49
+ for(const info of ['cyberPolicy','contextWindowExceeded','other',{httpConnectionFailed:{httpStatusCode:null}},undefined]) assert.equal(codexRejection(info),undefined)
50
+ })
51
+
52
+ test('a quota rejection skips later setups in the same scope, even with different credentials',()=>{
53
+ assert.equal(quotaScope(claudeA),quotaScope(claudeB))
54
+ assert.equal(quotaScope({...codexA,provider:'bridge'}),quotaScope(codexB))
55
+ assert.equal(quotaScope({id:'p',name:'p',cli:'pi',model:'opencode-go/m'}),quotaScope({id:'o',name:'o',cli:'opencode',model:'opencode-go/x'}))
56
+ const first={preset:claudeA,quotaScope:'claude',outcome:'failed' as const,category:'quota' as const}
57
+ const next=nextSetup([claudeA,claudeB,codexA,codexB],[first])
58
+ assert.equal(next.preset,codexA);assert.deepEqual(next.skipped.map(s=>[s.preset.id,s.outcome,s.category]),[['claude-b','skipped','same-quota-scope']])
59
+ // A non-quota rejection (model unavailable) leaves the scope eligible.
60
+ assert.equal(nextSetup([claudeA,claudeB],[{...first,category:'provider-rejected'}]).preset,claudeB)
61
+ })
62
+
63
+ type Script={lines?:unknown[];code?:number;unavailable?:boolean;hold?:boolean}
64
+ const fixture=async(name:string,scripts:Script[],fallbacks=[claudeB,codexA,codexB],primary=claudeA)=>{
65
+ const dir=await mkdtemp(join(tmpdir(),`ez-fallback-${name}-`)),control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
66
+ const launches:ExecutorOptions[]=[],admitted:(AiPreset|undefined)[]=[],sent:string[]=[]
67
+ const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'codex',telegramBotToken:'fixture'},async(_texts,options)=>{
68
+ const script=scripts[launches.length] ?? {code:0}
69
+ launches.push(options);admitted.push(activeSetup((await runs.get(options.runId))!))
70
+ if(script.unavailable)throw new CliUnavailableError(`Native CLI ${options.cli} is not executable on the host PATH`)
71
+ const out=(script.lines ?? []).map(l=>typeof l==='string'?l:JSON.stringify(l)).join('\n')
72
+ const child=spawn(process.execPath,['-e',`process.stdout.write(${JSON.stringify(out ? out+'\n' : '')});setTimeout(()=>process.exit(${script.code ?? 0}),${script.hold ? 60000 : 10})`],{detached:process.platform!=='win32'})
73
+ await once(child,'spawn');return {child,cleanup:async()=>{},stdout:''}
74
+ })
75
+ relay.bot.api.config.use(async(_prev,method,payload)=>{if(method==='sendMessage')sent.push((payload as {text:string}).text);return {ok:true,result:{message_id:42}} as never})
76
+ await control.requestPairing(101,101);await control.approveOwner(101)
77
+ const owner=(await control.status()).owner!
78
+ const saved=await scheduler.save({id:'daily',name:'Daily',text:'Saved work',owner,execution:{sessionId:'00000000-0000-4000-8000-000000000000',preset:primary,fallbacks},enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}},true)
79
+ const run=await scheduler.trigger(saved.id,saved.revision,'fallback-'+name,owner,runs)
80
+ await relay.drainSources()
81
+ return {dir,runs,scheduler,relay,run,launches,admitted,sent,done:async()=>{await until(async()=>['completed','failed','cancelled'].includes((await runs.get(run.id))!.status));return (await runs.get(run.id))!},
82
+ close:async()=>{await relay.stop();await rm(dir,{recursive:true,force:true})}}
83
+ }
84
+ const quota={type:'assistant',error:'rate_limit'},work={type:'assistant',message:{content:[{type:'text',text:'working'}]}}
85
+
86
+ test('a quota rejection before work advances within the same occurrence, skipping the exhausted scope',async()=>{
87
+ const f=await fixture('advance',[{lines:[{type:'rate_limit_event',rate_limit_info:{status:'rejected',resetsAt:1791460800}},quota],code:1},{lines:[startupLine({workBegan:true})],code:0}])
88
+ try{
89
+ const run=await f.done()
90
+ assert.equal(run.status,'completed');assert.equal(run.id,f.run.id)
91
+ assert.deepEqual(f.launches.map(l=>[l.cli,l.model,l.runId,l.nativeSession]),[['claude','opus',f.run.id,true],['codex','gpt-6-sol',f.run.id,true]])
92
+ assert.notEqual(f.launches[0].sessionId,f.launches[1].sessionId)
93
+ assert.deepEqual(f.admitted.map(p=>p?.id),['claude-a','codex-a'])
94
+ assert.deepEqual(run.attempts!.map(a=>[a.preset.id,a.outcome,a.category,a.resetAt]),[
95
+ ['claude-a','failed','quota','2026-10-08T12:00:00.000Z'],['claude-b','skipped','same-quota-scope',undefined],['codex-a','completed',undefined,undefined]])
96
+ assert.equal((await f.runs.list()).filter(r=>r.scheduled?.id==='daily').length,1)
97
+ }finally{await f.close()}
98
+ })
99
+
100
+ test('failure after work began, access denial and unknown failures stop without replay',async()=>{
101
+ for(const [name,lines,category] of [['partial',[work,quota],'after-work-began'],['hook',[{type:'system',subtype:'hook_started'},quota],'after-work-began'],['denied',[{type:'assistant',error:'authentication_failed'}],'access-denied'],['silent',[],'uncertain']] as const){
102
+ const f=await fixture(name,[{lines:[...lines],code:1}])
103
+ try{
104
+ const run=await f.done()
105
+ assert.equal(run.status,'failed',name);assert.equal(f.launches.length,1,name)
106
+ assert.deepEqual(run.attempts!.map(a=>[a.preset.id,a.outcome,a.category]),[['claude-a','failed',category]],name)
107
+ }finally{await f.close()}
108
+ }
109
+ })
110
+
111
+ test('each scheduled attempt launches with its own captured auth profile',async()=>{
112
+ const primary={...claudeA,authProfile:'work'}
113
+ const f=await fixture('profile',[{lines:[{type:'assistant',error:'model_not_found'}],code:1},{lines:[work],code:0}],[claudeB],primary)
114
+ try{
115
+ assert.equal((await f.done()).status,'completed')
116
+ assert.deepEqual(f.launches.map(l=>[l.cli,l.model,l.authProfile]),[['claude','opus','work'],['claude','sonnet',undefined]])
117
+ }finally{await f.close()}
118
+ })
119
+
120
+ test('cancellation stops the chain',async()=>{
121
+ const f=await fixture('cancel',[{lines:[quota],hold:true}])
122
+ try{
123
+ await until(async()=>Boolean((await f.runs.get(f.run.id))?.pid))
124
+ await f.scheduler.cancel(f.run.id)
125
+ await f.relay.drainSources()
126
+ const run=await f.done()
127
+ assert.equal(run.status,'cancelled');assert.equal(f.launches.length,1)
128
+ assert.deepEqual(run.attempts!.map(a=>a.outcome),['cancelled'])
129
+ }finally{await f.close()}
130
+ })
131
+
132
+ test('when every setup is unavailable the run reports each setup clearly',async()=>{
133
+ const f=await fixture('none',[{unavailable:true},{unavailable:true}],[codexA])
134
+ try{
135
+ const run=await f.done()
136
+ assert.equal(run.status,'failed');assert.equal(run.failureReason,'setups-unavailable')
137
+ assert.deepEqual(run.attempts!.map(a=>[a.preset.id,a.category]),[['claude-a','cli-unavailable'],['codex-a','cli-unavailable']])
138
+ assert.match(run.failure!.error,/Every task setup was unavailable: claude · opus .*\(cli-unavailable\); codex · gpt-6-sol/)
139
+ assert.match(f.sent.join('\n'),/could not start\. Every task setup was unavailable/)
140
+ }finally{await f.close()}
141
+ })
142
+
143
+ test('schedules reject duplicate or excess setups and the socket cannot rewrite attempts',async()=>{
144
+ const dir=await mkdtemp(join(tmpdir(),'ez-fallback-validate-')),control=new ControlStore(dir,1000),scheduler=new Scheduler(dir)
145
+ try{
146
+ await control.requestPairing(101,101);await control.approveOwner(101)
147
+ const owner=(await control.status()).owner!,base={id:'t',name:'T',text:'Work',owner,enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}}
148
+ const execution=(fallbacks:AiPreset[])=>({sessionId:'00000000-0000-4000-8000-000000000000',preset:claudeA,fallbacks})
149
+ await assert.rejects(scheduler.save({...base,execution:execution([{...claudeA,id:'copy'}])}),/distinct/)
150
+ await assert.rejects(scheduler.save({...base,execution:execution([claudeB,codexA,codexB,setup('x','codex','other')])}),/AI selection/)
151
+ await assert.rejects(scheduler.save({...base,execution:execution([{...codexA,model:undefined}])}),/explicit model/)
152
+ const forged=[{preset:setup('forged','claude','opus-max'),quotaScope:'claude',outcome:'running' as const}]
153
+ assert.equal(activeSetup({execution:execution([claudeB]),attempts:forged}),undefined)
154
+ const {serveTestLedger}=await import('./helpers/ledger.js'),{callDeliverySocket,socketPathFor}=await import('../src/delivery-socket.js')
155
+ const ledger=await serveTestLedger(dir)
156
+ try{
157
+ const run=await new RunStore(dir).create({chatId:101,telegramUserId:101,texts:['x'],execution:execution([claudeB])})
158
+ await assert.rejects(callDeliverySocket(socketPathFor(dir),{op:'patch',payload:{runId:run.id,change:{attempts:forged}}}),/relay-owned/)
159
+ }finally{await ledger.stop()}
160
+ }finally{await rm(dir,{recursive:true,force:true})}
161
+ })
162
+
163
+ test('host transport admits only the active task setup and profile, forwards only Claude startup summaries',async()=>{
164
+ const root=await mkdtemp(join(tmpdir(),'ez-fallback-host-')),workspace=join(root,'mind'),controlDir=join(root,'control'),directory=join(controlDir,'host-executor')
165
+ const {mkdir,writeFile,readFile}=await import('node:fs/promises'),{serveHostExecutor}=await import('../src/host-executor.js')
166
+ const abort=new AbortController(),launched:{cli?:string;model?:string;authProfile?:string}[]=[],oldPath=process.env.PATH
167
+ const primary={...claudeA,authProfile:'work'}
168
+ let server:Promise<void>|undefined
169
+ try{
170
+ await mkdir(workspace,{recursive:true});await mkdir(directory,{recursive:true});await mkdir(join(root,'bin'))
171
+ await writeFile(join(root,'bin','claude'),'#!/bin/sh\nexit 0\n',{mode:0o700})
172
+ process.env.PATH=join(root,'bin')+':'+oldPath
173
+ const control=new ControlStore(controlDir,900_000);await control.requestPairing(101,101);await control.approveOwner(101)
174
+ const owner=(await control.status()).owner!,runs=new RunStore(controlDir),id='r_schedule_fallback'
175
+ await runs.create({id,chatId:101,telegramUserId:101,texts:['work'],execution:{sessionId:'00000000-0000-4000-8000-000000000000',preset:primary,fallbacks:[claudeB]},
176
+ scheduled:{id:'daily',revision:'rev',dueAt:new Date().toISOString(),pairedAt:owner.pairedAt}})
177
+ await runs.patch(id,{status:'running',attempts:[{preset:primary,quotaScope:'claude',outcome:'running'}]})
178
+ server=serveHostExecutor({cli:'claude',agents:[{name:'t',workspace,controlDir,binDir:join(root,'bin')}]},abort.signal,async(_texts,options)=>{
179
+ launched.push({cli:options.cli,model:options.model,authProfile:options.authProfile})
180
+ const lines=options.model==='opus'?[JSON.stringify({type:'system',subtype:'init'}),JSON.stringify({type:'assistant',error:'model_not_found',message:{content:[{type:'text',text:'secret tool output'}]}})].join('\n')+'\n':''
181
+ const child=spawn(process.execPath,['-e',`process.stdout.write(${JSON.stringify(lines)});process.exit(1)`])
182
+ return {child,cleanup:async()=>{},stdout:''}
183
+ },async()=>[{cli:'claude',authProfile:'work',model:'opus',name:'Work',efforts:[]}])
184
+ const events=async()=>{for(let n=0;n<150;n++){try{const text=await readFile(join(directory,id+'.events'),'utf8');if(text.includes('"stream":"exit"'))return text}catch{};await new Promise(r=>setTimeout(r,20))}throw new Error('No exit event')}
185
+ const request=(options:object)=>writeFile(join(directory,id+'.request.json'),JSON.stringify({texts:['work'],options}))
186
+ for(let n=0;n<100 && !await readFile(join(directory,'heartbeat.json')).then(()=>true,()=>false);n++)await new Promise(r=>setTimeout(r,20))
187
+ await request({cli:'claude',model:'opus',authProfile:'work'})
188
+ const first=await events()
189
+ const summaries=first.trim().split('\n').map(l=>JSON.parse(l)).filter(e=>e.stream==='stdout').flatMap(e=>e.text.trim().split('\n').map((l:string)=>JSON.parse(l)))
190
+ assert.ok(summaries.every((e:{type:string})=>e.type==='ez.startup'))
191
+ assert.deepEqual(summaries.at(-1),{type:'ez.startup',workBegan:false,rejection:{category:'provider-rejected'}})
192
+ assert.doesNotMatch(first,/secret tool output/)
193
+ const {rm:remove}=await import('node:fs/promises');await remove(join(directory,id+'.events'))
194
+ // The relay advances: the next attempt reuses the run ID.
195
+ await runs.patch(id,{attempts:[{preset:primary,quotaScope:'claude',outcome:'failed',category:'provider-rejected'},{preset:claudeB,quotaScope:'claude',outcome:'running'}]})
196
+ await request({cli:'claude',model:'sonnet'})
197
+ await events();await remove(join(directory,id+'.events'))
198
+ assert.deepEqual(launched.map(l=>[l.cli,l.model,l.authProfile]),[['claude','opus','work'],['claude','sonnet',undefined]])
199
+ await request({cli:'claude',model:'sonnet',authProfile:'work'})
200
+ assert.match(await events(),/auth profile does not match its captured AI selection/)
201
+ await remove(join(directory,id+'.events'))
202
+ // A request for a setup other than the active attempt is refused before launch.
203
+ await request({cli:'claude',model:'opus'})
204
+ assert.match(await events(),/does not match its saved task model/)
205
+ assert.equal(launched.length,2)
206
+ }finally{abort.abort();await server;process.env.PATH=oldPath;await rm(root,{recursive:true,force:true})}
207
+ })
208
+
209
+ test('Codex native session reports typed pre-work rejection and work start',async(t)=>{
210
+ t.mock.method(console,'error',()=>{})
211
+ const {runCodexSession}=await import('../src/codex-session.js')
212
+ for(const worked of [false,true]){
213
+ const program=`const rl=require('readline').createInterface({input:process.stdin});const send=x=>process.stdout.write(JSON.stringify(x)+'\\n');
214
+ const event=(method,params)=>send({method,params:{threadId:'th',...params}});
215
+ rl.on('line',line=>{const q=JSON.parse(line);if(!q.id)return;
216
+ if(q.method==='initialize')return send({id:q.id,result:{}});
217
+ if(q.method==='thread/start')return send({id:q.id,result:{thread:{id:'th'}}});
218
+ if(q.method==='turn/start'){send({id:q.id,result:{turn:{id:'one'}}});event('turn/started',{turn:{id:'one'}});
219
+ event('item/started',{item:{type:'userMessage'}});${worked ? "event('item/started',{item:{type:'agentMessage'}});" : ''}
220
+ send({method:'account/rateLimits/updated',params:{rateLimits:{primary:{usedPercent:100,resetsAt:1791460800}}}});
221
+ event('turn/completed',{turn:{id:'one',status:'failed',error:{message:'limit',codexErrorInfo:'usageLimitExceeded'}}})}});setInterval(()=>{},1000);`
222
+ const output:string[]=[]
223
+ const code=await runCodexSession({workspace:'/tmp',controlDir:'/tmp/control',model:'gpt-6-sol',prompt:'work'},{launch:()=>spawn(process.execPath,['-e',program],{stdio:['pipe','pipe','pipe'],detached:process.platform!=='win32'}),emit:line=>output.push(line)})
224
+ assert.equal(code,1)
225
+ const o=startupObserver();o.write(output.join('\n')+'\n')
226
+ assert.deepEqual(o.result(),worked ? {workBegan:true,rejection:undefined} : {workBegan:false,rejection:{category:'quota',resetAt:'2026-10-08T12:00:00.000Z'}})
227
+ }
228
+ })
229
+
230
+ test('schedule CLI saves ordered fallbacks, preserves them on edit and clears them explicitly',async t=>{
231
+ const {execFile}=await import('node:child_process'),{promisify}=await import('node:util'),{fileURLToPath}=await import('node:url'),{serveTestLedger}=await import('./helpers/ledger.js')
232
+ const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
233
+ const dir=await mkdtemp(join(tmpdir(),'ez-fallback-cli-'));t.after(()=>rm(dir,{recursive:true,force:true}))
234
+ const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
235
+ const control=new ControlStore(dir,1000);await control.requestPairing(101,101);await control.approveOwner(101)
236
+ const env={...process.env,EZ_CONTROL_DIR:dir,EZ_RUN_ID:''}
237
+ const cli=async(...args:string[])=>JSON.parse((await exec(process.execPath,[bin,...args],{env})).stdout)
238
+ const when=['--at','2027-09-09T09:00:00+04:00','--text','Work']
239
+ const saved=await cli('create','chain','--cli','claude','--model','opus',...when,'--fallback','cli=claude,model=sonnet,effort=medium','--fallback','cli=codex,model=gpt-6-sol')
240
+ assert.deepEqual(saved.execution.fallbacks.map((p:AiPreset)=>[p.cli,p.model,p.effort]),[['claude','sonnet','medium'],['codex','gpt-6-sol',undefined]])
241
+ assert.deepEqual((await cli('edit','chain','--cli','claude','--model','opus',...when)).execution.fallbacks,saved.execution.fallbacks)
242
+ assert.equal((await cli('edit','chain','--cli','claude','--model','opus',...when,'--clear-fallbacks')).execution.fallbacks,undefined)
243
+ await assert.rejects(exec(process.execPath,[bin,'create','bad','--cli','claude','--model','opus',...when,'--fallback','cli=codex'],{env}),/cli=EXECUTOR,model=MODEL/)
244
+ await assert.rejects(exec(process.execPath,[bin,'create','dup','--cli','claude','--model','opus',...when,'--fallback','cli=claude,model=opus'],{env}),/distinct/)
245
+ })
@@ -9,11 +9,11 @@ import { createServer } from 'node:net';
9
9
  import { promisify } from 'node:util';
10
10
  import { extract, digest, version, newer, compatible } from '../src/updates/artifact.mjs';
11
11
  import { prepare, submit, command, read, jobPath, eligibility, jobs, cleanupStaleBackups, reviewedPluginDeploymentMigration } from '../src/updates/control.mjs';
12
- import { perform, environment, packageManager, backupStateDirectory, execute } from '../src/updates/runtime.mjs';
12
+ import { perform, environment, packageManager, backupStateDirectory, execute, brokerNeedsRefresh } from '../src/updates/runtime.mjs';
13
13
  import { atomic, snapshot, compose, prepareCommand } from '../src/plugins/manager.mjs';
14
14
  import { bindUpdates } from '../src/updates/binding.mjs';
15
15
  import { status as runtimeStatus } from '../src/updates/status.mjs';
16
- import { providerEnvironment, queueAutomatic, waitForHostHeartbeat, withIdleUpgrade } from '../src/updates/supervisor.mjs';
16
+ import { providerEnvironment, queueAutomatic, waitForHostHeartbeat, withIdleUpgrade, withUpgrade } from '../src/updates/supervisor.mjs';
17
17
  const exec=promisify(execFile);
18
18
 
19
19
  test('structured runtime output excludes warnings but failures retain diagnostics',async()=>{
@@ -120,6 +120,7 @@ function runtime(f,{fail,stopped=false,buildArgs={CODEX_CLI_VERSION:'0.156.1'},l
120
120
  if(args.at(-1)==='--version')return '10.30.3';
121
121
  if(args.includes('config'))return JSON.stringify({services:{relay:{build:{args:buildArgs}}}});
122
122
  if(args.includes('ps'))return stopped?'':'a'.repeat(64);
123
+ if(args.some(arg=>arg.includes("const fs=require('fs'),crypto=require('crypto')")))return digest(await fs.readFile(path.join(f.config.deploymentDir,'host-executor.json')));
123
124
  if(args.some(arg=>arg.includes('EZ_BROKER_STRUCTURAL_READINESS_FAILED'))) {const [hostSha256,registrySha256,configSha256]=args.slice(-3);return JSON.stringify({version:1,ready:true,hostSha256,registrySha256,configSha256,plugins:[]});}
124
125
  if(args[0]==='image'&&args[1]==='inspect')return 'sha256:'+'b'.repeat(64);
125
126
  if(args.includes('--force-recreate'))brokerImage=(await fs.readFile(args[args.lastIndexOf('--env-file')+1],'utf8')).trim().split('=')[1];
@@ -387,7 +388,7 @@ test('plugin updates register additive command routes on an unchanged deployment
387
388
  const dispatch=await prepareCommand(f.home,'query',[]);assert.deepEqual(dispatch.argv.slice(-4),['node','sample','/app/bin/example.mjs','query']);
388
389
  assert(r.calls.some(call=>call.includes('up')));
389
390
  });
390
- test('isolated plugin replacement recreates only the pinned broker and verifies activation and rollback',async t=>{
391
+ test('isolated plugin replacement preserves the live broker and verifies activation and rollback',async t=>{
391
392
  const f=await fixture(t,'plugin');
392
393
  await fs.appendFile(path.join(f.config.deploymentDir,'docker.env'),'EZ_EXECUTOR_TRANSPORT=local\n');
393
394
  const job=await queued(f),r=runtime(f),execute=r.execute;
@@ -407,10 +408,11 @@ test('isolated plugin replacement recreates only the pinned broker and verifies
407
408
  const interrupted=await read(path.join(jobPath(f.home,job.id),'job.json'));interrupted.status='applying';
408
409
  assert.equal((await perform(f.home,interrupted,r)).status,'rolled-back');
409
410
  assert.equal(observed,'0.1.0');assert.equal(interrupted.rollbackBrokerReadiness.ready,true);
410
- assert.equal(r.calls.filter(call=>call.includes('--force-recreate')).length,2);
411
+ assert.equal(r.calls.filter(call=>call.includes('--force-recreate')).length,0);
411
412
  assert(!r.calls.some(call=>call.includes('restart')||call[0]==='stopHost'));
413
+ assert.equal((await read(path.join(f.agent.controlDir,'plugin-broker-plugins.json'))).plugins[0].version,'0.1.0');
412
414
  });
413
- for(const boundary of ['recreate','probe','readback'])test(`isolated broker ${boundary} failure cannot complete an update`,async t=>{
415
+ for(const boundary of ['probe','readback'])test(`isolated broker ${boundary} failure cannot complete an update`,async t=>{
414
416
  const f=await fixture(t,'plugin');await fs.appendFile(path.join(f.config.deploymentDir,'docker.env'),'EZ_EXECUTOR_TRANSPORT=local\n');
415
417
  const job=await queued(f),r=runtime(f),base=r.execute;
416
418
  r.execute=async(c,a,o)=>{
@@ -698,3 +700,54 @@ for(const mode of ['exact','wrong-image','unhealthy']) test(`recovery reuses onl
698
700
  assert.equal(calls.some(c=>c.includes('stop')),mode!=='exact');
699
701
  assert.equal((await read(path.join(f.home,'config.json'))).packageRoot,f.old);
700
702
  });
703
+
704
+
705
+ test('plugin upgrade builds during a native turn, then gates and drains calls for activation',async t=>{
706
+ const f=await fixture(t,'plugin'),job=await queued(f),r=runtime(f),execute=r.execute;
707
+ await fs.mkdir(path.join(f.agent.controlDir,'host-executor'));
708
+ await atomic(path.join(f.agent.controlDir,'host-executor/task.running.json'),{pid:process.pid});
709
+ let built,continueBuild;
710
+ const building=new Promise(resolve=>built=resolve),hold=new Promise(resolve=>continueBuild=resolve);
711
+ r.execute=async(command,args,options)=>{
712
+ if(args.includes('build')) {
713
+ await assert.rejects(fs.access(path.join(f.home,'registry.lock')),{code:'ENOENT'});
714
+ built();await hold;
715
+ }
716
+ return execute(command,args,options);
717
+ };
718
+ const upgrade=withUpgrade(f.agent.controlDir,job,new AbortController().signal,()=>perform(f.home,job,r),{isIdle:async()=>false});
719
+ await building;
720
+ const oldCall=await prepareCommand(f.home,'sample',[],{invocation:true});
721
+ continueBuild();
722
+ for(let n=0;n<100&&!await fs.access(path.join(f.home,'registry.lock')).then(()=>true,()=>false);n++)await new Promise(resolve=>setTimeout(resolve,10));
723
+ await fs.access(path.join(f.home,'registry.lock'));
724
+ assert(!r.calls.some(call=>call.includes('stop')));
725
+ let admitted=false;
726
+ const nextCall=prepareCommand(f.home,'sample',[],{invocation:true}).then(command=>{admitted=true;return command;});
727
+ await new Promise(resolve=>setTimeout(resolve,50));assert.equal(admitted,false);
728
+ await oldCall.release();
729
+ assert.equal((await upgrade).status,'completed');
730
+ const command=await nextCall;assert.equal(command.revision,(await read(path.join(f.home,'registry.json'))).plugins.sample.revision);await command.release();
731
+ await fs.access(path.join(f.agent.controlDir,'host-executor/task.running.json'));
732
+ await assert.rejects(fs.access(path.join(f.agent.controlDir,'upgrade-pause.json')),{code:'ENOENT'});
733
+ assert(!r.calls.some(call=>call[0]==='stopHost'||call.includes('--force-recreate')));
734
+ });
735
+
736
+ test('core upgrades still defer for active work; cancelled plugin admission never applies',async t=>{
737
+ const root=await fs.mkdtemp(path.join(tmpdir(),'ez-plugin-admission-'));t.after(()=>fs.rm(root,{recursive:true,force:true}));
738
+ const controller=new AbortController();let applied=0;
739
+ await withUpgrade(root,{id:'core',target:'main'},controller.signal,async()=>applied++,{isIdle:async()=>false});assert.equal(applied,0);
740
+ controller.abort();await withUpgrade(root,{id:'plugin',target:'sample'},controller.signal,async()=>applied++);assert.equal(applied,0);
741
+ });
742
+
743
+
744
+ test('changed single-file broker binding retains full native drain and broker recreation',async t=>{
745
+ const f=await fixture(t,'plugin'),job=await queued(f),r=runtime(f);
746
+ await fs.appendFile(path.join(f.config.deploymentDir,'docker.env'),'EZ_EXECUTOR_TRANSPORT=local\n');
747
+ const execute=r.execute;r.execute=async(c,a,o)=>a.some(arg=>arg.includes("const fs=require('fs'),crypto=require('crypto')"))?'0'.repeat(64):execute(c,a,o);
748
+ assert.equal(await brokerNeedsRefresh(f.config,r.execute),true);
749
+ let applied=false;
750
+ await withUpgrade(f.agent.controlDir,job,new AbortController().signal,async()=>{applied=true;},{requiresIdle:true,isIdle:async()=>false});assert.equal(applied,false);
751
+ assert.equal((await perform(f.home,job,{...r,refreshPluginBroker:true})).status,'completed');
752
+ assert.equal(r.calls.filter(call=>call.includes('--force-recreate')).length,1);
753
+ });