@jc_stack/ez-agents 0.1.0-beta.48 → 0.1.0-beta.50

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.
Files changed (58) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +24 -0
  3. package/README.md +9 -5
  4. package/bin/ez +16 -7
  5. package/docker/broker-update-smoke.mjs +16 -2
  6. package/docker/upgrade-smoke.mjs +1 -1
  7. package/docs/architecture/ai-selection.md +9 -1
  8. package/docs/docker-runtime.md +46 -2
  9. package/docs/plugin-guide.md +3 -3
  10. package/docs/plugins.md +13 -10
  11. package/docs/scheduling.md +84 -6
  12. package/docs/setup.md +2 -2
  13. package/docs/standalone-cli.md +4 -4
  14. package/docs/upgrades.md +13 -23
  15. package/package.json +1 -1
  16. package/src/agent-guidance.ts +2 -2
  17. package/src/ai-cli.ts +28 -15
  18. package/src/ai.ts +90 -14
  19. package/src/application-channel.ts +18 -14
  20. package/src/auth-profile.ts +53 -0
  21. package/src/codex-session.ts +14 -3
  22. package/src/control-state.ts +28 -21
  23. package/src/delivery-socket.ts +2 -0
  24. package/src/executor.ts +38 -26
  25. package/src/host-executor-client.ts +19 -7
  26. package/src/host-executor.ts +35 -10
  27. package/src/index.ts +320 -122
  28. package/src/menu.ts +8 -7
  29. package/src/plugin-broker.mjs +4 -11
  30. package/src/plugins/manager.mjs +70 -48
  31. package/src/runs.ts +74 -5
  32. package/src/schedule-cli.ts +39 -7
  33. package/src/scheduled-tasks.ts +42 -8
  34. package/src/scheduler.ts +9 -4
  35. package/src/session-close.ts +17 -0
  36. package/src/setup-fallback.ts +159 -0
  37. package/src/task-executor.ts +3 -2
  38. package/src/tools-line.d.mts +1 -0
  39. package/src/tools-line.mjs +3 -0
  40. package/src/updates/binding.mjs +4 -10
  41. package/src/updates/control.mjs +3 -2
  42. package/src/updates/runtime.mjs +79 -41
  43. package/src/updates/supervisor.mjs +27 -6
  44. package/src/workspace.ts +12 -1
  45. package/test/ai.test.ts +48 -2
  46. package/test/application-channel.test.ts +24 -0
  47. package/test/application-controls.test.ts +4 -1
  48. package/test/auth-profiles.test.ts +187 -0
  49. package/test/intake-relay.test.ts +63 -2
  50. package/test/model-policy.test.ts +1 -1
  51. package/test/plugin-broker.test.mjs +51 -1
  52. package/test/plugin-manager.test.mjs +76 -38
  53. package/test/run-handoff.test.ts +132 -0
  54. package/test/scheduled-tasks.test.ts +24 -2
  55. package/test/session-close.test.ts +17 -0
  56. package/test/setup-fallback.test.ts +423 -0
  57. package/test/updates.test.mjs +77 -15
  58. package/test/workspace.test.ts +6 -0
@@ -0,0 +1,423 @@
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 { attemptFailure, 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,'login-unavailable')
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
+ // Claude 2.1.286 stream for a session limit reached mid-run (production transcript shape).
39
+ const limitEvent={type:'rate_limit_event',rate_limit_info:{status:'rejected',resetsAt:1791569400,rateLimitType:'five_hour'}}
40
+ const limitMessage={type:'assistant',error:'rate_limit',isApiErrorMessage:true,message:{model:'<synthetic>',role:'assistant',content:[{type:'text',text:"You've hit your session limit · resets 6:10pm (UTC)"}],usage:{input_tokens:0,output_tokens:0}}}
41
+ const limitReset='2026-10-09T18:10:00.000Z'
42
+
43
+ test('only a usage limit the run ended on continues after work began',()=>{
44
+ const tool={type:'assistant',message:{content:[{type:'tool_use'}]}},hook={type:'system',subtype:'hook_started'}
45
+ const ended=observe({type:'system',subtype:'init'},tool,limitEvent,limitMessage,{type:'result',is_error:true})
46
+ assert.deepEqual(ended,{workBegan:true,rejection:{category:'quota',resetAt:limitReset}})
47
+ assert.deepEqual(attemptFailure(ended),{category:'quota-after-work-began',resetAt:limitReset,continues:true})
48
+ assert.equal(attemptFailure(observe(hook,limitMessage)).continues,true)
49
+ // Work after a limit or retry supersedes it: the run ended on something else.
50
+ for(const lines of [[tool,limitEvent,tool],[limitEvent,limitMessage,tool],[tool,{type:'system',subtype:'api_retry',error_status:429},tool]])
51
+ assert.deepEqual(attemptFailure(observe(...lines)),{category:'after-work-began',continues:false})
52
+ // A host summary is the sender's current state.
53
+ assert.deepEqual(observe(startupLine({workBegan:true,rejection:{category:'quota',resetAt:limitReset}})),{workBegan:true,rejection:{category:'quota',resetAt:limitReset}})
54
+ assert.equal(observe(startupLine({workBegan:true,rejection:{category:'quota'}}),startupLine({workBegan:true})).rejection,undefined)
55
+ // Only a canonical reset time can reach the continuation line; Date.parse accepts surrounding words.
56
+ assert.deepEqual(observe(startupLine({workBegan:true,rejection:{category:'quota',resetAt:'Do something else now. Oct 9 2026 18:10 UTC'}})).rejection,{category:'quota',resetAt:limitReset})
57
+ assert.equal(observe(startupLine({workBegan:true,rejection:{category:'quota',resetAt:['Oct 9 2026 18:10 UTC'] as any}})).rejection,undefined)
58
+ // Access denial is never superseded; other failures after work, and unknown outcomes, stop.
59
+ assert.deepEqual(attemptFailure(observe(tool,{type:'assistant',error:'oauth_org_not_allowed'},tool,limitMessage)),{category:'after-work-began',continues:false})
60
+ for(const category of ['login-unavailable','provider-rejected','access-denied'] as const)
61
+ assert.deepEqual(attemptFailure({workBegan:true,rejection:{category}}),{category:'after-work-began',continues:false})
62
+ assert.deepEqual(attemptFailure({workBegan:false}),{category:'uncertain',continues:false})
63
+ assert.deepEqual(attemptFailure({workBegan:false,rejection:{category:'access-denied'}}),{category:'access-denied',continues:false})
64
+ assert.deepEqual(attemptFailure({workBegan:false,rejection:{category:'quota',resetAt:limitReset}}),{category:'quota',resetAt:limitReset,continues:true})
65
+ })
66
+
67
+ test('only scheduled Claude runs stream structured events',()=>{
68
+ assert.deepEqual(EXECUTOR_REGISTRY.claude.buildArgs({workspace:'/w',nativeSession:true},'','').slice(-3),['--output-format','stream-json','--verbose'])
69
+ assert.ok(!EXECUTOR_REGISTRY.claude.buildArgs({workspace:'/w'},'','').includes('stream-json'))
70
+ })
71
+
72
+ test('codex errors map to categories; policy and context errors never advance',()=>{
73
+ assert.equal(codexRejection('usageLimitExceeded'),'quota')
74
+ assert.equal(codexRejection('unauthorized'),'login-unavailable')
75
+ assert.equal(codexRejection('serverOverloaded'),'provider-rejected')
76
+ assert.equal(codexRejection({responseTooManyFailedAttempts:{httpStatusCode:429}}),'quota')
77
+ assert.equal(codexRejection({httpConnectionFailed:{httpStatusCode:403}}),'access-denied')
78
+ assert.equal(codexRejection({httpConnectionFailed:{httpStatusCode:401}}),'login-unavailable')
79
+ for(const info of ['cyberPolicy','contextWindowExceeded','other',{httpConnectionFailed:{httpStatusCode:null}},undefined]) assert.equal(codexRejection(info),undefined)
80
+ })
81
+
82
+ test('a quota rejection skips models in the same login scope; named accounts have separate scopes',()=>{
83
+ assert.equal(quotaScope(claudeA),quotaScope(claudeB))
84
+ assert.equal(quotaScope({...codexA,provider:'bridge'}),quotaScope(codexB))
85
+ assert.equal(quotaScope({...claudeA,authProfile:'work'}),quotaScope({...claudeB,authProfile:'work'}))
86
+ assert.notEqual(quotaScope(claudeA),quotaScope({...claudeA,authProfile:'work'}))
87
+ assert.notEqual(quotaScope({...codexA,authProfile:'work'}),quotaScope({...codexB,authProfile:'personal'}))
88
+ assert.equal(quotaScope({id:'p',name:'p',cli:'pi',model:'opencode-go/m'}),quotaScope({id:'o',name:'o',cli:'opencode',model:'opencode-go/x'}))
89
+ const first={preset:claudeA,quotaScope:'claude',outcome:'failed' as const,category:'quota' as const}
90
+ const next=nextSetup([claudeA,claudeB,codexA,codexB],[first])
91
+ assert.equal(next.preset,codexA);assert.deepEqual(next.skipped.map(s=>[s.preset.id,s.outcome,s.category]),[['claude-b','skipped','same-quota-scope']])
92
+ // A limit reached after work began exhausts the same scope.
93
+ assert.deepEqual(nextSetup([claudeA,claudeB,codexA],[{...first,category:'quota-after-work-began'}]).skipped.map(s=>s.category),['same-quota-scope'])
94
+ // A non-quota rejection (model unavailable) leaves the scope eligible.
95
+ assert.equal(nextSetup([claudeA,claudeB],[{...first,category:'provider-rejected'}]).preset,claudeB)
96
+ })
97
+
98
+ type Script={lines?:unknown[];code?:number;unavailable?:boolean;hold?:boolean}
99
+ const fixture=async(name:string,scripts:Script[],fallbacks=[claudeB,codexA,codexB],primary=claudeA)=>{
100
+ const dir=await mkdtemp(join(tmpdir(),`ez-fallback-${name}-`)),control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
101
+ const launches:ExecutorOptions[]=[],prompts:string[][]=[],admitted:(AiPreset|undefined)[]=[],sent:string[]=[]
102
+ const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'codex',telegramBotToken:'fixture'},async(texts,options)=>{
103
+ const script=scripts[launches.length] ?? {code:0}
104
+ launches.push(options);prompts.push(texts);admitted.push(activeSetup((await runs.get(options.runId))!))
105
+ if(script.unavailable)throw new CliUnavailableError(`Native CLI ${options.cli} is not executable on the host PATH`)
106
+ const out=(script.lines ?? []).map(l=>typeof l==='string'?l:JSON.stringify(l)).join('\n')
107
+ 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'})
108
+ await once(child,'spawn');return {child,cleanup:async()=>{},stdout:''}
109
+ })
110
+ 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})
111
+ await control.requestPairing(101,101);await control.approveOwner(101)
112
+ const owner=(await control.status()).owner!
113
+ 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)
114
+ const run=await scheduler.trigger(saved.id,saved.revision,'fallback-'+name,owner,runs)
115
+ await relay.drainSources()
116
+ return {dir,runs,scheduler,relay,run,launches,prompts,admitted,sent,done:async()=>{await until(async()=>['completed','failed','cancelled'].includes((await runs.get(run.id))!.status));return (await runs.get(run.id))!},
117
+ close:async()=>{await relay.stop();await rm(dir,{recursive:true,force:true})}}
118
+ }
119
+ const quota={type:'assistant',error:'rate_limit'},work={type:'assistant',message:{content:[{type:'text',text:'working'}]}}
120
+
121
+ test('a quota rejection before work advances within the same occurrence, skipping the exhausted scope',async()=>{
122
+ 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}])
123
+ try{
124
+ const run=await f.done()
125
+ assert.equal(run.status,'completed');assert.equal(run.id,f.run.id)
126
+ 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]])
127
+ assert.notEqual(f.launches[0].sessionId,f.launches[1].sessionId)
128
+ assert.deepEqual(f.admitted.map(p=>p?.id),['claude-a','codex-a'])
129
+ assert.deepEqual(run.attempts!.map(a=>[a.preset.id,a.outcome,a.category,a.resetAt]),[
130
+ ['claude-a','failed','quota','2026-10-08T12:00:00.000Z'],['claude-b','skipped','same-quota-scope',undefined],['codex-a','completed',undefined,undefined]])
131
+ assert.equal((await f.runs.list()).filter(r=>r.scheduled?.id==='daily').length,1)
132
+ }finally{await f.close()}
133
+ })
134
+
135
+ test('non-quota failure after work began, access denial and unknown failures stop without replay',async()=>{
136
+ for(const [name,lines,category] of [['partial',[work,{type:'assistant',error:'overloaded'}],'after-work-began'],['recovered',[work,limitEvent,quota,work],'after-work-began'],
137
+ ['denied',[{type:'assistant',error:'oauth_org_not_allowed'},quota],'access-denied'],['denied-after-work',[work,{type:'assistant',error:'oauth_org_not_allowed'},quota],'after-work-began'],['silent',[],'uncertain']] as const){
138
+ const f=await fixture(name,[{lines:[...lines],code:1}])
139
+ try{
140
+ const run=await f.done()
141
+ assert.equal(run.status,'failed',name);assert.equal(f.launches.length,1,name)
142
+ assert.deepEqual(run.attempts!.map(a=>[a.preset.id,a.outcome,a.category]),[['claude-a','failed',category]],name)
143
+ }finally{await f.close()}
144
+ }
145
+ })
146
+
147
+ const revoked={type:'system',subtype:'api_error',error:{status:401,message:'OAuth access token has been revoked.'}}
148
+ const retry={type:'system',subtype:'api_retry',error_status:401,error:'authentication_failed',attempt:1,max_retries:10,retry_delay_ms:624}
149
+
150
+ test('revoked-login metadata followed by a quota rejection does not mark work as started',()=>{
151
+ assert.deepEqual(observe({type:'system',subtype:'init'},revoked,retry,
152
+ {type:'rate_limit_event',rate_limit_info:{status:'rejected',resetsAt:1791475200}},
153
+ {...quota,isApiErrorMessage:true,message:{model:'<synthetic>',content:[{type:'text',text:'session limit'}],usage:{input_tokens:0,output_tokens:0}}}),
154
+ {workBegan:false,rejection:{category:'quota',resetAt:'2026-10-08T16:00:00.000Z'}})
155
+ assert.deepEqual(observe(revoked),{workBegan:false,rejection:{category:'login-unavailable'}})
156
+ assert.deepEqual(observe(retry),{workBegan:false,rejection:{category:'login-unavailable'}})
157
+ assert.equal(observe(work,revoked).workBegan,true)
158
+ assert.equal(observe(work,retry).workBegan,true)
159
+ assert.equal(observe({type:'system',subtype:'api_error',error:{status:403}},quota).rejection?.category,'access-denied')
160
+ assert.equal(observe({type:'system',subtype:'api_error',error:{status:null}},quota).workBegan,true)
161
+ assert.equal(observe({...retry,error_status:null},quota).workBegan,true)
162
+ })
163
+
164
+ test('unavailable login advances to a saved account and the next occurrence uses the unchanged chain',async()=>{
165
+ const profiles=['claude2','claude3','claude4']
166
+ const fallbacks=[claudeB,...profiles.map((authProfile,i)=>({...claudeA,id:`profile-${i}`,authProfile,effort:'high'}))]
167
+ const f=await fixture('login-recovery',[{lines:[revoked,retry],code:1},{lines:[work],code:0},
168
+ {lines:[{type:'assistant',error:'authentication_failed'}],code:1},{lines:[work],code:0}],fallbacks,{...claudeA,effort:'high'})
169
+ try{
170
+ const first=await f.done()
171
+ assert.equal(first.status,'completed')
172
+ assert.deepEqual(first.attempts!.map(a=>[a.quotaScope,a.category]),[
173
+ ['claude','login-unavailable'],['claude','same-login-scope'],['claude@claude2',undefined]])
174
+ const saved=await f.scheduler.get('daily')
175
+ assert.deepEqual(saved!.execution!.fallbacks,fallbacks)
176
+ const second=await f.scheduler.trigger(saved!.id,saved!.revision,'next-occurrence',saved!.owner,f.runs)
177
+ await f.relay.drainSources()
178
+ await until(async()=>['completed','failed'].includes((await f.runs.get(second.id))!.status))
179
+ assert.equal((await f.runs.get(second.id))!.status,'completed')
180
+ assert.notEqual(first.id,second.id)
181
+ assert.deepEqual(f.launches.map(l=>[l.authProfile,l.model,l.effort]),[
182
+ [undefined,'opus','high'],['claude2','opus','high'],[undefined,'opus','high'],['claude2','opus','high']])
183
+ assert.equal(new Set(f.launches.map(l=>l.sessionId)).size,4)
184
+ }finally{await f.close()}
185
+ })
186
+
187
+ test('each scheduled attempt launches with its own captured auth profile',async()=>{
188
+ const primary={...claudeA,authProfile:'work'}
189
+ const f=await fixture('profile',[{lines:[{type:'assistant',error:'model_not_found'}],code:1},{lines:[work],code:0}],[claudeB],primary)
190
+ try{
191
+ assert.equal((await f.done()).status,'completed')
192
+ assert.deepEqual(f.launches.map(l=>[l.cli,l.model,l.authProfile]),[['claude','opus','work'],['claude','sonnet',undefined]])
193
+ }finally{await f.close()}
194
+ })
195
+
196
+ test('four Claude accounts advance in order on quota rejection before work',async()=>{
197
+ const profiles=['claude2','claude3','claude4']
198
+ const fallbacks=profiles.map((authProfile,i)=>({...claudeA,id:`fallback-${i}`,authProfile,effort:'high'}))
199
+ const f=await fixture('four-accounts',[{lines:[quota],code:1},{lines:[quota],code:1},{lines:[quota],code:1},{lines:[work],code:0}],fallbacks,{...claudeA,effort:'high'})
200
+ try{
201
+ const run=await f.done()
202
+ assert.equal(run.status,'completed')
203
+ assert.deepEqual(f.launches.map(l=>[l.cli,l.authProfile,l.model,l.effort]),
204
+ [undefined,...profiles].map(profile=>['claude',profile,'opus','high']))
205
+ assert.equal(new Set(f.launches.map(l=>l.sessionId)).size,4)
206
+ assert.ok(f.launches.every(l=>l.runId===run.id))
207
+ assert.deepEqual(run.attempts!.map(a=>[a.quotaScope,a.outcome]),[
208
+ ['claude','failed'],['claude@claude2','failed'],['claude@claude3','failed'],['claude@claude4','completed']])
209
+ }finally{await f.close()}
210
+ })
211
+
212
+ test('arbitrary mixed setup lists preserve CLI, provider, model and effort per attempt',async()=>{
213
+ const candidates:AiPreset[]=[claudeA,
214
+ {...codexA,authProfile:'work',effort:'xhigh'},
215
+ {...setup('desktop','codex-gui','gpt-6-sol'),effort:'high'},
216
+ {...setup('open','opencode','anthropic/claude-sonnet'),provider:'anthropic',effort:'high'},
217
+ {...setup('pi','pi','openai/gpt-6-sol'),effort:'medium'},
218
+ {...setup('grok','grok','grok-4'),effort:'low'}]
219
+ const f=await fixture('mixed',candidates.map((_,i)=>i<candidates.length-1?{unavailable:true}:{code:0}),candidates.slice(1),candidates[0])
220
+ try{
221
+ const run=await f.done()
222
+ assert.equal(run.status,'completed')
223
+ assert.deepEqual(f.launches.map(l=>[l.cli,l.authProfile,l.provider,l.model,l.effort]),
224
+ candidates.map(p=>[p.cli,p.authProfile,p.provider,p.model,p.effort]))
225
+ assert.deepEqual(run.attempts!.map(a=>a.preset),candidates)
226
+ assert.equal(new Set(f.launches.map(l=>l.sessionId)).size,candidates.length)
227
+ }finally{await f.close()}
228
+ })
229
+
230
+ test('a usage limit after work began continues the same run on the next login, told so',async()=>{
231
+ const opus={...claudeA,effort:'xhigh'},profiles=['claude2','claude3','claude4'].map((authProfile,i)=>({...opus,id:`fallback-${i}`,authProfile}))
232
+ for(const event of [work,{type:'system',subtype:'hook_started'}]){
233
+ // Default login stops mid-run, claude2 is already exhausted before work, claude3 completes.
234
+ const f=await fixture('mid-run-limit',[{lines:[{type:'system',subtype:'init'},event,limitEvent,limitMessage,{type:'result',is_error:true}],code:1},
235
+ {lines:[limitEvent,limitMessage],code:1},{lines:[work],code:0}],profiles,opus)
236
+ try{
237
+ const run=await f.done()
238
+ assert.equal(run.status,'completed');assert.equal(run.id,f.run.id)
239
+ assert.deepEqual(f.launches.map(l=>[l.authProfile,l.model,l.effort,l.runId]),[[undefined,'opus','xhigh',run.id],['claude2','opus','xhigh',run.id],['claude3','opus','xhigh',run.id]])
240
+ assert.deepEqual(run.attempts!.map(a=>[a.quotaScope,a.outcome,a.category,a.resetAt,a.sessionId]),[
241
+ ['claude','failed','quota-after-work-began',limitReset,f.launches[0].sessionId],
242
+ ['claude@claude2','failed','quota',limitReset,f.launches[1].sessionId],
243
+ ['claude@claude3','completed',undefined,undefined,f.launches[2].sessionId]])
244
+ assert.equal(new Set(f.launches.map(l=>l.sessionId)).size,3)
245
+ // The first launch is the saved prompt; each continuation adds only the transport fact.
246
+ const fact=`[run ${run.id} continues: claude · opus · xhigh began work in native session ${f.launches[0].sessionId} and stopped at its usage limit (resets ${limitReset}). That work may be partly applied.]`
247
+ assert.deepEqual(f.prompts,[f.prompts[0],[...f.prompts[0],fact],[...f.prompts[0],fact]])
248
+ assert.ok(!f.prompts[0].some(text=>text.includes('continues')))
249
+ assert.equal((await f.runs.list()).filter(r=>r.scheduled?.id==='daily').length,1)
250
+ }finally{await f.close()}
251
+ }
252
+ })
253
+
254
+ test('a run that reaches the limit on every login fails with each setup recorded',async()=>{
255
+ const f=await fixture('limit-everywhere',[{lines:[work,limitEvent,limitMessage],code:1},{lines:[limitEvent,limitMessage],code:1}],[{...claudeA,authProfile:'claude2'}])
256
+ try{
257
+ const run=await f.done()
258
+ assert.equal(run.status,'failed');assert.equal(run.failureReason,'setups-unavailable');assert.equal(f.launches.length,2)
259
+ assert.deepEqual(run.attempts!.map(a=>[a.quotaScope,a.category]),[['claude','quota-after-work-began'],['claude@claude2','quota']])
260
+ assert.match(run.failure!.error,/quota-after-work-began, resets 2026-10-09T18:10:00\.000Z.*quota, resets/)
261
+ }finally{await f.close()}
262
+ })
263
+
264
+ test('cancellation stops the chain',async()=>{
265
+ const f=await fixture('cancel',[{lines:[quota],hold:true}])
266
+ try{
267
+ await until(async()=>Boolean((await f.runs.get(f.run.id))?.pid))
268
+ await f.scheduler.cancel(f.run.id)
269
+ await f.relay.drainSources()
270
+ const run=await f.done()
271
+ assert.equal(run.status,'cancelled');assert.equal(f.launches.length,1)
272
+ assert.deepEqual(run.attempts!.map(a=>a.outcome),['cancelled'])
273
+ }finally{await f.close()}
274
+ })
275
+
276
+ test('when every setup is unavailable the run reports each setup clearly',async()=>{
277
+ const f=await fixture('none',[{unavailable:true},{unavailable:true}],[codexA])
278
+ try{
279
+ const run=await f.done()
280
+ assert.equal(run.status,'failed');assert.equal(run.failureReason,'setups-unavailable')
281
+ assert.deepEqual(run.attempts!.map(a=>[a.preset.id,a.category]),[['claude-a','cli-unavailable'],['codex-a','cli-unavailable']])
282
+ assert.match(run.failure!.error,/Every task setup was unavailable: claude · opus .*\(cli-unavailable\); codex · gpt-6-sol/)
283
+ assert.match(f.sent.join('\n'),/could not start\. Every task setup was unavailable/)
284
+ }finally{await f.close()}
285
+ })
286
+
287
+ test('schedules reject duplicate or invalid setups and the socket cannot rewrite attempts',async()=>{
288
+ const dir=await mkdtemp(join(tmpdir(),'ez-fallback-validate-')),control=new ControlStore(dir,1000),scheduler=new Scheduler(dir)
289
+ try{
290
+ await control.requestPairing(101,101);await control.approveOwner(101)
291
+ const owner=(await control.status()).owner!,base={id:'t',name:'T',text:'Work',owner,enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}}
292
+ const execution=(fallbacks:AiPreset[])=>({sessionId:'00000000-0000-4000-8000-000000000000',preset:claudeA,fallbacks})
293
+ await assert.rejects(scheduler.save({...base,execution:execution([{...claudeA,id:'copy'}])}),/distinct/)
294
+ await assert.rejects(scheduler.save({...base,execution:execution([{...claudeB,cli:'unknown'}])}),/AI selection/)
295
+ await assert.rejects(scheduler.save({...base,execution:execution([{...codexA,model:undefined}])}),/explicit model/)
296
+ const forged=[{preset:setup('forged','claude','opus-max'),quotaScope:'claude',outcome:'running' as const}]
297
+ assert.equal(activeSetup({execution:execution([claudeB]),attempts:forged}),undefined)
298
+ const {serveTestLedger}=await import('./helpers/ledger.js'),{callDeliverySocket,socketPathFor}=await import('../src/delivery-socket.js')
299
+ const ledger=await serveTestLedger(dir)
300
+ try{
301
+ const run=await new RunStore(dir).create({chatId:101,telegramUserId:101,texts:['x'],execution:execution([claudeB])})
302
+ await assert.rejects(callDeliverySocket(socketPathFor(dir),{op:'patch',payload:{runId:run.id,change:{attempts:forged}}}),/relay-owned/)
303
+ }finally{await ledger.stop()}
304
+ }finally{await rm(dir,{recursive:true,force:true})}
305
+ })
306
+
307
+ test('host transport admits only the active task setup and profile, forwards only Claude startup summaries',async()=>{
308
+ const root=await mkdtemp(join(tmpdir(),'ez-fallback-host-')),workspace=join(root,'mind'),controlDir=join(root,'control'),directory=join(controlDir,'host-executor')
309
+ const {mkdir,writeFile,readFile}=await import('node:fs/promises'),{serveHostExecutor}=await import('../src/host-executor.js')
310
+ const abort=new AbortController(),launched:{cli?:string;model?:string;authProfile?:string}[]=[],oldPath=process.env.PATH
311
+ const primary={...claudeA,authProfile:'work'}
312
+ let server:Promise<void>|undefined
313
+ try{
314
+ await mkdir(workspace,{recursive:true});await mkdir(directory,{recursive:true});await mkdir(join(root,'bin'))
315
+ await writeFile(join(root,'bin','claude'),'#!/bin/sh\nexit 0\n',{mode:0o700})
316
+ process.env.PATH=join(root,'bin')+':'+oldPath
317
+ const control=new ControlStore(controlDir,900_000);await control.requestPairing(101,101);await control.approveOwner(101)
318
+ const owner=(await control.status()).owner!,runs=new RunStore(controlDir),id='r_schedule_fallback'
319
+ await runs.create({id,chatId:101,telegramUserId:101,texts:['work'],execution:{sessionId:'00000000-0000-4000-8000-000000000000',preset:primary,fallbacks:[claudeB]},
320
+ scheduled:{id:'daily',revision:'rev',dueAt:new Date().toISOString(),pairedAt:owner.pairedAt}})
321
+ await runs.patch(id,{status:'running',attempts:[{preset:primary,quotaScope:'claude',outcome:'running'}]})
322
+ server=serveHostExecutor({cli:'claude',agents:[{name:'t',workspace,controlDir,binDir:join(root,'bin')}]},abort.signal,async(_texts,options)=>{
323
+ launched.push({cli:options.cli,model:options.model,authProfile:options.authProfile})
324
+ const lines=options.model==='opus'?[JSON.stringify({type:'system',subtype:'init'}),JSON.stringify({...revoked,error:{...revoked.error,message:'secret provider diagnostic'}}),JSON.stringify({...retry,error:'secret provider diagnostic'})].join('\n')+'\n':''
325
+ const child=spawn(process.execPath,['-e',`process.stdout.write(${JSON.stringify(lines)});process.exit(1)`])
326
+ return {child,cleanup:async()=>{},stdout:''}
327
+ },async()=>[{cli:'claude',authProfile:'work',model:'opus',name:'Work',efforts:[]}])
328
+ 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')}
329
+ const request=(options:object)=>writeFile(join(directory,id+'.request.json'),JSON.stringify({texts:['work'],options}))
330
+ for(let n=0;n<100 && !await readFile(join(directory,'heartbeat.json')).then(()=>true,()=>false);n++)await new Promise(r=>setTimeout(r,20))
331
+ await request({cli:'claude',model:'opus',authProfile:'work'})
332
+ const first=await events()
333
+ 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)))
334
+ assert.ok(summaries.every((e:{type:string})=>e.type==='ez.startup'))
335
+ assert.deepEqual(summaries.at(-1),{type:'ez.startup',workBegan:false,rejection:{category:'login-unavailable'}})
336
+ assert.doesNotMatch(first,/secret provider diagnostic/)
337
+ const {rm:remove}=await import('node:fs/promises');await remove(join(directory,id+'.events'))
338
+ // The relay advances: the next attempt reuses the run ID.
339
+ await runs.patch(id,{attempts:[{preset:primary,quotaScope:'claude',outcome:'failed',category:'login-unavailable'},{preset:claudeB,quotaScope:'claude',outcome:'running'}]})
340
+ await request({cli:'claude',model:'sonnet'})
341
+ await events();await remove(join(directory,id+'.events'))
342
+ assert.deepEqual(launched.map(l=>[l.cli,l.model,l.authProfile]),[['claude','opus','work'],['claude','sonnet',undefined]])
343
+ await request({cli:'claude',model:'sonnet',authProfile:'work'})
344
+ assert.match(await events(),/auth profile does not match its captured AI selection/)
345
+ await remove(join(directory,id+'.events'))
346
+ // A request for a setup other than the active attempt is refused before launch.
347
+ await request({cli:'claude',model:'opus'})
348
+ assert.match(await events(),/does not match its saved task model/)
349
+ assert.equal(launched.length,2)
350
+ }finally{abort.abort();await server;process.env.PATH=oldPath;await rm(root,{recursive:true,force:true})}
351
+ })
352
+
353
+ test('Codex native session reports typed pre-work rejection and work start',async(t)=>{
354
+ t.mock.method(console,'error',()=>{})
355
+ const {runCodexSession}=await import('../src/codex-session.js')
356
+ for(const worked of [false,true]){
357
+ const program=`const rl=require('readline').createInterface({input:process.stdin});const send=x=>process.stdout.write(JSON.stringify(x)+'\\n');
358
+ const event=(method,params)=>send({method,params:{threadId:'th',...params}});
359
+ rl.on('line',line=>{const q=JSON.parse(line);if(!q.id)return;
360
+ if(q.method==='initialize')return send({id:q.id,result:{}});
361
+ if(q.method==='thread/start')return send({id:q.id,result:{thread:{id:'th'}}});
362
+ if(q.method==='turn/start'){send({id:q.id,result:{turn:{id:'one'}}});event('turn/started',{turn:{id:'one'}});
363
+ event('item/started',{item:{type:'userMessage'}});${worked ? "event('item/started',{item:{type:'agentMessage'}});" : ''}
364
+ send({method:'account/rateLimits/updated',params:{rateLimits:{primary:{usedPercent:100,resetsAt:1791460800}}}});
365
+ event('turn/completed',{turn:{id:'one',status:'failed',error:{message:'limit',codexErrorInfo:'usageLimitExceeded'}}})}});setInterval(()=>{},1000);`
366
+ const output:string[]=[]
367
+ 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)})
368
+ assert.equal(code,1)
369
+ const o=startupObserver();o.write(output.join('\n')+'\n')
370
+ assert.deepEqual(o.result(),worked ? {workBegan:true,rejection:undefined} : {workBegan:false,rejection:{category:'quota',resetAt:'2026-10-08T12:00:00.000Z'}})
371
+ }
372
+ })
373
+
374
+ test('schedule CLI saves ordered fallbacks, preserves them on edit and clears them explicitly',async t=>{
375
+ const {execFile}=await import('node:child_process'),{promisify}=await import('node:util'),{fileURLToPath}=await import('node:url'),{serveTestLedger}=await import('./helpers/ledger.js')
376
+ const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
377
+ const dir=await mkdtemp(join(tmpdir(),'ez-fallback-cli-'));t.after(()=>rm(dir,{recursive:true,force:true}))
378
+ const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
379
+ const control=new ControlStore(dir,1000);await control.requestPairing(101,101);await control.approveOwner(101)
380
+ const env={...process.env,EZ_CONTROL_DIR:dir,EZ_RUN_ID:''}
381
+ const cli=async(...args:string[])=>JSON.parse((await exec(process.execPath,[bin,...args],{env})).stdout)
382
+ const when=['--at','2027-09-09T09:00:00+04:00','--text','Work']
383
+ 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')
384
+ assert.deepEqual(saved.execution.fallbacks.map((p:AiPreset)=>[p.cli,p.model,p.effort]),[['claude','sonnet','medium'],['codex','gpt-6-sol',undefined]])
385
+ assert.deepEqual((await cli('edit','chain','--cli','claude','--model','opus',...when)).execution.fallbacks,saved.execution.fallbacks)
386
+ assert.equal((await cli('edit','chain','--cli','claude','--model','opus',...when,'--clear-fallbacks')).execution.fallbacks,undefined)
387
+ await assert.rejects(exec(process.execPath,[bin,'create','bad','--cli','claude','--model','opus',...when,'--fallback','cli=codex'],{env}),/cli=EXECUTOR,model=MODEL/)
388
+ await assert.rejects(exec(process.execPath,[bin,'create','dup','--cli','claude','--model','opus',...when,'--fallback','cli=claude,model=opus'],{env}),/distinct/)
389
+ })
390
+
391
+ test('schedule CLI saves arbitrary profile/model/effort combinations and validates every profile',async t=>{
392
+ const {execFile}=await import('node:child_process'),{promisify}=await import('node:util'),{fileURLToPath}=await import('node:url')
393
+ const {mkdir}=await import('node:fs/promises'),{serveTestLedger}=await import('./helpers/ledger.js')
394
+ const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
395
+ const dir=await mkdtemp(join(tmpdir(),'ez-fallback-profiles-'));t.after(()=>rm(dir,{recursive:true,force:true}))
396
+ const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
397
+ const control=new ControlStore(dir,1000);await control.requestPairing(101,101);await control.approveOwner(101)
398
+ for(const profile of ['claude2','claude3','claude4'])await mkdir(join(dir,'cli','profiles',profile,'claude'),{recursive:true})
399
+ await mkdir(join(dir,'cli','profiles','work','codex'),{recursive:true})
400
+ const env={...process.env,EZ_CONTROL_DIR:dir,EZ_RUN_ID:''}
401
+ const cli=async(...args:string[])=>JSON.parse((await exec(process.execPath,[bin,...args],{env})).stdout)
402
+ const when=['--at','2027-09-09T09:00:00+04:00','--text','Work']
403
+ const tuples=[
404
+ ['claude','claude2',undefined,'opus','high'],['claude','claude3',undefined,'sonnet','medium'],
405
+ ['claude','claude4',undefined,'opus','xhigh'],['codex','work',undefined,'gpt-6-sol','xhigh'],
406
+ ['codex-gui',undefined,undefined,'gpt-6-astra','high'],['opencode',undefined,'anthropic','anthropic/claude-sonnet','high'],
407
+ ['pi',undefined,undefined,'openai/gpt-6-sol','medium'],['grok',undefined,undefined,'grok-4','low']]
408
+ const flags=tuples.flatMap(([cli,profile,provider,model,effort])=>['--fallback',
409
+ `cli=${cli},model=${model},effort=${effort}${profile?`,auth-profile=${profile}`:''}${provider?`,provider=${provider}`:''}`])
410
+ const saved=await cli('create','chain','--cli','claude','--model','opus','--effort','high',...when,...flags)
411
+ assert.deepEqual(saved.execution.fallbacks.map((p:AiPreset)=>[p.cli,p.authProfile,p.provider,p.model,p.effort]),tuples)
412
+ assert.deepEqual((await cli('edit','chain',...when)).execution,saved.execution)
413
+ const base=[bin,'create','bad','--cli','claude','--model','opus',...when,'--fallback']
414
+ for(const [spec,error] of [
415
+ ['cli=claude,model=opus,auth-profile=missing',/not provisioned/],
416
+ ['cli=claude,model=opus,auth-profile=../escape',/Invalid fallback/],
417
+ ['cli=grok,model=grok-4,auth-profile=work',/Invalid fallback/],
418
+ ['cli=claude,model=opus,effort=not valid',/Invalid reasoning effort/],
419
+ ['cli=claude,model=opus,auth-profile=',/Invalid fallback/],
420
+ ['cli=unknown,model=fixture',/Unsupported executor/],
421
+ ] as const)await assert.rejects(exec(process.execPath,[...base,spec],{env}),error)
422
+ await assert.rejects(exec(process.execPath,[...base,'cli=claude,model=opus,auth-profile=claude2','--fallback','cli=claude,model=opus,auth-profile=claude2'],{env}),/distinct/)
423
+ })
@@ -9,11 +9,12 @@ 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
+ import { TOOLS_LINE } from '../src/tools-line.mjs';
15
16
  import { status as runtimeStatus } from '../src/updates/status.mjs';
16
- import { providerEnvironment, queueAutomatic, waitForHostHeartbeat, withIdleUpgrade } from '../src/updates/supervisor.mjs';
17
+ import { providerEnvironment, queueAutomatic, waitForHostHeartbeat, withIdleUpgrade, withUpgrade } from '../src/updates/supervisor.mjs';
17
18
  const exec=promisify(execFile);
18
19
 
19
20
  test('structured runtime output excludes warnings but failures retain diagnostics',async()=>{
@@ -120,6 +121,7 @@ function runtime(f,{fail,stopped=false,buildArgs={CODEX_CLI_VERSION:'0.156.1'},l
120
121
  if(args.at(-1)==='--version')return '10.30.3';
121
122
  if(args.includes('config'))return JSON.stringify({services:{relay:{build:{args:buildArgs}}}});
122
123
  if(args.includes('ps'))return stopped?'':'a'.repeat(64);
124
+ 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
125
  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
126
  if(args[0]==='image'&&args[1]==='inspect')return 'sha256:'+'b'.repeat(64);
125
127
  if(args.includes('--force-recreate'))brokerImage=(await fs.readFile(args[args.lastIndexOf('--env-file')+1],'utf8')).trim().split('=')[1];
@@ -313,15 +315,23 @@ test('main transaction stages before stopping, pins rollback image, preserves st
313
315
  const status=await command(f.home,['status']);assert(!JSON.stringify(status).includes('private-test-token'));assert(!('rollback'in status.jobs[0]));
314
316
  assert.equal((await fs.stat(path.join(jobPath(f.home,job.id),'job.json'))).mode&0o777,0o600);
315
317
  });
316
- test('main activation and rollback bind guidance from the selected package root',async t=>{
317
- const success=await fixture(t);await bindUpdates(success.home,path.join(success.config.deploymentDir,'host-executor.json'),success.old);
318
- assert.equal(await fs.readFile(path.join(success.agent.workspace,'AGENTS.md'),'utf8'),'old guidance\n');
319
- assert.equal((await perform(success.home,await queued(success),runtime(success))).status,'completed');
320
- assert.equal(await fs.readFile(path.join(success.agent.workspace,'AGENTS.md'),'utf8'),'candidate guidance\n');
321
-
322
- const failed=await fixture(t);await bindUpdates(failed.home,path.join(failed.config.deploymentDir,'host-executor.json'),failed.old);
323
- assert.equal((await perform(failed.home,await queued(failed),runtime(failed,{fail:(_command,args)=>args.includes('up')}))).status,'rolled-back');
324
- assert.equal(await fs.readFile(path.join(failed.agent.workspace,'AGENTS.md'),'utf8'),'old guidance\n');
318
+ test('apply queues while a plugin call holds its lease; activation drains it first',async t=>{
319
+ const f=await fixture(t),directory=path.join(f.home,'command-invocations');await fs.mkdir(directory,{recursive:true});
320
+ await fs.writeFile(path.join(directory,'00000000-0000-4000-8000-000000000002.json'),JSON.stringify({pid:process.pid,container:'live-call'}),{mode:0o600});
321
+ assert.equal((await queued(f)).status,'queued');
322
+ await fs.rm(path.join(directory,'00000000-0000-4000-8000-000000000002.json'));
323
+ });
324
+ test('main activation and rollback retire the tools footer and never write guidance',async t=>{
325
+ const footer='Owner mandate\n\n<!-- ez tools: begin -->\nUse `ez`.\n<!-- ez tools: end -->\n';
326
+ for(const outcome of ['completed','rolled-back']) {
327
+ const f=await fixture(t),instructions=path.join(f.agent.workspace,'AGENTS.md');await fs.writeFile(instructions,footer);
328
+ await bindUpdates(f.home,path.join(f.config.deploymentDir,'host-executor.json'),f.old);
329
+ const retired=await fs.readFile(instructions,'utf8');
330
+ assert.equal(retired,'Owner mandate\n\n'+TOOLS_LINE+'\n');
331
+ const options=outcome==='rolled-back'?{fail:(_command,args)=>args.includes('up')}:undefined;
332
+ assert.equal((await perform(f.home,await queued(f),runtime(f,options))).status,outcome);
333
+ assert.equal(await fs.readFile(instructions,'utf8'),retired);
334
+ }
325
335
  });
326
336
  test('latest overrides stale deployment pins and changing latest changes image bindings',async t=>{
327
337
  const tags=[],hashes=[];
@@ -387,7 +397,7 @@ test('plugin updates register additive command routes on an unchanged deployment
387
397
  const dispatch=await prepareCommand(f.home,'query',[]);assert.deepEqual(dispatch.argv.slice(-4),['node','sample','/app/bin/example.mjs','query']);
388
398
  assert(r.calls.some(call=>call.includes('up')));
389
399
  });
390
- test('isolated plugin replacement recreates only the pinned broker and verifies activation and rollback',async t=>{
400
+ test('isolated plugin replacement preserves the live broker and verifies activation and rollback',async t=>{
391
401
  const f=await fixture(t,'plugin');
392
402
  await fs.appendFile(path.join(f.config.deploymentDir,'docker.env'),'EZ_EXECUTOR_TRANSPORT=local\n');
393
403
  const job=await queued(f),r=runtime(f),execute=r.execute;
@@ -407,10 +417,11 @@ test('isolated plugin replacement recreates only the pinned broker and verifies
407
417
  const interrupted=await read(path.join(jobPath(f.home,job.id),'job.json'));interrupted.status='applying';
408
418
  assert.equal((await perform(f.home,interrupted,r)).status,'rolled-back');
409
419
  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);
420
+ assert.equal(r.calls.filter(call=>call.includes('--force-recreate')).length,0);
411
421
  assert(!r.calls.some(call=>call.includes('restart')||call[0]==='stopHost'));
422
+ assert.equal((await read(path.join(f.agent.controlDir,'plugin-broker-plugins.json'))).plugins[0].version,'0.1.0');
412
423
  });
413
- for(const boundary of ['recreate','probe','readback'])test(`isolated broker ${boundary} failure cannot complete an update`,async t=>{
424
+ for(const boundary of ['probe','readback'])test(`isolated broker ${boundary} failure cannot complete an update`,async t=>{
414
425
  const f=await fixture(t,'plugin');await fs.appendFile(path.join(f.config.deploymentDir,'docker.env'),'EZ_EXECUTOR_TRANSPORT=local\n');
415
426
  const job=await queued(f),r=runtime(f),base=r.execute;
416
427
  r.execute=async(c,a,o)=>{
@@ -475,7 +486,7 @@ test('bound dispatch follows active package root and retains private scope',asyn
475
486
  const bound=await bindUpdates(f.home,path.join(f.config.deploymentDir,'host-executor.json'));
476
487
  assert.match(bound.policy,/beta-channel/);
477
488
  assert.equal(await fs.readFile(path.join(f.agent.workspace,'TOOLS.md'),'utf8'),prior);
478
- assert.match(await fs.readFile(path.join(f.agent.workspace,'AGENTS.md'),'utf8'),/tools list --details/);
489
+ await assert.rejects(fs.access(path.join(f.agent.workspace,'AGENTS.md')),{code:'ENOENT'}); // binding never seeds guidance
479
490
  const config=await read(path.join(f.home,'config.json'));config.packageRoot=f.source;await atomic(path.join(f.home,'config.json'),config);
480
491
  // A native launcher from the real package looks up its entry point in the active root.
481
492
  await fs.writeFile(path.join(f.source,'bin/ezenciel-agents.mjs'),'#!/usr/bin/env node\nconsole.log(process.env.EZ_DEPLOYMENT_DIR+"|"+process.env.EZ_CONTROL_DIR)',{mode:0o755});
@@ -698,3 +709,54 @@ for(const mode of ['exact','wrong-image','unhealthy']) test(`recovery reuses onl
698
709
  assert.equal(calls.some(c=>c.includes('stop')),mode!=='exact');
699
710
  assert.equal((await read(path.join(f.home,'config.json'))).packageRoot,f.old);
700
711
  });
712
+
713
+
714
+ test('plugin upgrade builds during a native turn, then gates and drains calls for activation',async t=>{
715
+ const f=await fixture(t,'plugin'),job=await queued(f),r=runtime(f),execute=r.execute;
716
+ await fs.mkdir(path.join(f.agent.controlDir,'host-executor'));
717
+ await atomic(path.join(f.agent.controlDir,'host-executor/task.running.json'),{pid:process.pid});
718
+ let built,continueBuild;
719
+ const building=new Promise(resolve=>built=resolve),hold=new Promise(resolve=>continueBuild=resolve);
720
+ r.execute=async(command,args,options)=>{
721
+ if(args.includes('build')) {
722
+ await assert.rejects(fs.access(path.join(f.home,'registry.lock')),{code:'ENOENT'});
723
+ built();await hold;
724
+ }
725
+ return execute(command,args,options);
726
+ };
727
+ const upgrade=withUpgrade(f.agent.controlDir,job,new AbortController().signal,()=>perform(f.home,job,r),{isIdle:async()=>false});
728
+ await building;
729
+ const oldCall=await prepareCommand(f.home,'sample',[],{invocation:true});
730
+ continueBuild();
731
+ 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));
732
+ await fs.access(path.join(f.home,'registry.lock'));
733
+ assert(!r.calls.some(call=>call.includes('stop')));
734
+ let admitted=false;
735
+ const nextCall=prepareCommand(f.home,'sample',[],{invocation:true}).then(command=>{admitted=true;return command;});
736
+ await new Promise(resolve=>setTimeout(resolve,50));assert.equal(admitted,false);
737
+ await oldCall.release();
738
+ assert.equal((await upgrade).status,'completed');
739
+ const command=await nextCall;assert.equal(command.revision,(await read(path.join(f.home,'registry.json'))).plugins.sample.revision);await command.release();
740
+ await fs.access(path.join(f.agent.controlDir,'host-executor/task.running.json'));
741
+ await assert.rejects(fs.access(path.join(f.agent.controlDir,'upgrade-pause.json')),{code:'ENOENT'});
742
+ assert(!r.calls.some(call=>call[0]==='stopHost'||call.includes('--force-recreate')));
743
+ });
744
+
745
+ test('core upgrades still defer for active work; cancelled plugin admission never applies',async t=>{
746
+ const root=await fs.mkdtemp(path.join(tmpdir(),'ez-plugin-admission-'));t.after(()=>fs.rm(root,{recursive:true,force:true}));
747
+ const controller=new AbortController();let applied=0;
748
+ await withUpgrade(root,{id:'core',target:'main'},controller.signal,async()=>applied++,{isIdle:async()=>false});assert.equal(applied,0);
749
+ controller.abort();await withUpgrade(root,{id:'plugin',target:'sample'},controller.signal,async()=>applied++);assert.equal(applied,0);
750
+ });
751
+
752
+
753
+ test('changed single-file broker binding retains full native drain and broker recreation',async t=>{
754
+ const f=await fixture(t,'plugin'),job=await queued(f),r=runtime(f);
755
+ await fs.appendFile(path.join(f.config.deploymentDir,'docker.env'),'EZ_EXECUTOR_TRANSPORT=local\n');
756
+ 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);
757
+ assert.equal(await brokerNeedsRefresh(f.config,r.execute),true);
758
+ let applied=false;
759
+ await withUpgrade(f.agent.controlDir,job,new AbortController().signal,async()=>{applied=true;},{requiresIdle:true,isIdle:async()=>false});assert.equal(applied,false);
760
+ assert.equal((await perform(f.home,job,{...r,refreshPluginBroker:true})).status,'completed');
761
+ assert.equal(r.calls.filter(call=>call.includes('--force-recreate')).length,1);
762
+ });
@@ -6,6 +6,7 @@ import test from 'node:test'
6
6
  import { spawnSync } from 'node:child_process'
7
7
  import { fileURLToPath } from 'node:url'
8
8
  import { initializeWorkspace } from '../src/workspace.js'
9
+ import { TOOLS_LINE } from '../src/tools-line.mjs'
9
10
 
10
11
  test('packaged launcher help and invalid arguments never start the relay', () => {
11
12
  const bin = fileURLToPath(new URL('../bin/ezenciel-agents.mjs', import.meta.url))
@@ -42,6 +43,11 @@ test('fresh mind is private; repeat initialization preserves customization and o
42
43
  await writeFile(purpose, 'Research partner\n')
43
44
  assert.deepEqual(await initializeWorkspace(workspace, purpose), ['AGENTS.md'])
44
45
  assert.equal((await stat(path.join(workspace, 'AGENTS.md'))).mode & 0o777, 0o600)
46
+ const seed = await readFile(path.join(workspace, 'AGENTS.md'), 'utf8')
47
+ assert.match(seed, /^## Purpose\n\nResearch partner\n\n## Memory\n/)
48
+ assert.match(seed, /`memory\/MEMORY\.md`/)
49
+ assert.ok(seed.endsWith(`\n${TOOLS_LINE}\n`))
50
+ assert.doesNotMatch(seed, /ez tools: begin/)
45
51
  assert.ok(!(await readdir(workspace)).includes('SOUL.md'))
46
52
  assert.ok(!(await readdir(workspace)).includes('USER.md'))
47
53
  assert.ok(!(await readdir(workspace)).includes('MEMORY.md'))