@jc_stack/ez-agents 0.1.0-beta.51 → 0.1.0-beta.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@ import { ControlStore } from '../src/control-state.js'
10
10
  import { RunStore, activeSetup } from '../src/runs.js'
11
11
  import { Scheduler } from '../src/scheduler.js'
12
12
  import { CliUnavailableError, EXECUTOR_REGISTRY, type ExecutorOptions } from '../src/executor.js'
13
- import { attemptFailure, codexRejection, nextSetup, quotaScope, startupObserver, startupLine } from '../src/setup-fallback.js'
13
+ import { attemptFailure, codexRejection, nextSetup, quotaScope, setupCandidates, startupObserver, startupLine } from '../src/setup-fallback.js'
14
14
  import type { AiPreset } from '../src/ai.js'
15
15
 
16
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')}
@@ -95,7 +95,22 @@ test('a quota rejection skips models in the same login scope; named accounts hav
95
95
  assert.equal(nextSetup([claudeA,claudeB],[{...first,category:'provider-rejected'}]).preset,claudeB)
96
96
  })
97
97
 
98
- type Script={lines?:unknown[];code?:number;unavailable?:boolean;hold?:boolean}
98
+ test('same-settings login choices rotate from the last success, preserving explicit model fallback priority',()=>{
99
+ const a={...claudeA,effort:'xhigh'},b={...a,id:'b',authProfile:'claude2'},c={...a,id:'c',authProfile:'claude3'}
100
+ const execution={sessionId:'00000000-0000-4000-8000-000000000000',preset:a,fallbacks:[b,c,codexA]}
101
+ const health={'claude@claude2':{lastSuccessAt:'2026-01-01T00:00:00.000Z'}}
102
+ assert.deepEqual(setupCandidates(execution,health).map(p=>p.id),['b','c','claude-a','codex-a'])
103
+ assert.deepEqual(setupCandidates({...execution,fallbacks:[codexA,b]},health).map(p=>p.id),['claude-a','codex-a','b'])
104
+ const now=Date.parse('2026-01-01T01:00:00.000Z'), resetAt='2026-01-01T02:00:00.000Z'
105
+ const cooling={claude:{resetAt},'claude@claude2':{...health['claude@claude2'],resetAt}}
106
+ const next=nextSetup([a,b,c],[],cooling,now)
107
+ assert.equal(next.preset,c)
108
+ assert.deepEqual(next.skipped.map(s=>[s.quotaScope,s.category,s.resetAt]),[['claude','same-quota-scope',resetAt],['claude@claude2','same-quota-scope',resetAt]])
109
+ assert.equal(nextSetup([a,b,c],[],cooling,Date.parse(resetAt)).preset,a,'the native reset expires the cooldown')
110
+ assert.equal(nextSetup([b,c,a],[{preset:b,quotaScope:'claude@claude2',outcome:'failed',category:'quota'}]).preset,c,'rotation never retries an attempted login')
111
+ })
112
+
113
+ type Script={lines?:unknown[];code?:number;unavailable?:boolean;hold?:boolean;session?:string;stage?:string}
99
114
  const fixture=async(name:string,scripts:Script[],fallbacks=[claudeB,codexA,codexB],primary=claudeA)=>{
100
115
  const dir=await mkdtemp(join(tmpdir(),`ez-fallback-${name}-`)),control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
101
116
  const launches:ExecutorOptions[]=[],prompts:string[][]=[],admitted:(AiPreset|undefined)[]=[],sent:string[]=[]
@@ -103,6 +118,8 @@ const fixture=async(name:string,scripts:Script[],fallbacks=[claudeB,codexA,codex
103
118
  const script=scripts[launches.length] ?? {code:0}
104
119
  launches.push(options);prompts.push(texts);admitted.push(activeSetup((await runs.get(options.runId))!))
105
120
  if(script.unavailable)throw new CliUnavailableError(`Native CLI ${options.cli} is not executable on the host PATH`)
121
+ if(script.stage){const s=await scheduler.get('daily');await scheduler.edit({...s,text:script.stage},s.revision,()=>runs.list(),true)}
122
+ if (script.session) await options.onSession?.(script.session)
106
123
  const out=(script.lines ?? []).map(l=>typeof l==='string'?l:JSON.stringify(l)).join('\n')
107
124
  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
125
  await once(child,'spawn');return {child,cleanup:async()=>{},stdout:''}
@@ -110,10 +127,10 @@ const fixture=async(name:string,scripts:Script[],fallbacks=[claudeB,codexA,codex
110
127
  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
128
  await control.requestPairing(101,101);await control.approveOwner(101)
112
129
  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)
130
+ const saved=await scheduler.save({id:'daily',name:'Daily',text:'Saved work',owner,execution:{sessionId:'00000000-0000-4000-8000-000000000000',preset:primary,...(fallbacks.length ? {fallbacks} : {})},enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}},true)
114
131
  const run=await scheduler.trigger(saved.id,saved.revision,'fallback-'+name,owner,runs)
115
132
  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))!},
133
+ return {dir,owner,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
134
  close:async()=>{await relay.stop();await rm(dir,{recursive:true,force:true})}}
118
135
  }
119
136
  const quota={type:'assistant',error:'rate_limit'},work={type:'assistant',message:{content:[{type:'text',text:'working'}]}}
@@ -209,6 +226,29 @@ test('four Claude accounts advance in order on quota rejection before work',asyn
209
226
  }finally{await f.close()}
210
227
  })
211
228
 
229
+ test('later tasks reuse the last working login and retain quota resets in owner control state',async()=>{
230
+ const primary={...claudeA,effort:'xhigh'},profiles=['claude2','claude3'].map((authProfile,i)=>({...primary,id:`fallback-${i}`,authProfile}))
231
+ const resetAt=new Date(Date.now()+3600_000).toISOString(),limit={type:'ez.startup',workBegan:false,rejection:{category:'quota',resetAt}}
232
+ const f=await fixture('sticky-login',[{lines:[limit],code:1},{lines:[work],code:0},{lines:[limit],code:1},{lines:[work],code:0},{lines:[work],code:0}],profiles,primary)
233
+ try{
234
+ await f.done()
235
+ const control=new ControlStore(f.dir,1000),owner=(await control.status()).owner!
236
+ assert.equal((await control.scheduledSetupHealth(owner)).claude.resetAt,resetAt)
237
+ for(const id of ['second','third']){
238
+ const saved=await f.scheduler.save({id,name:id,text:'Another saved task',owner,execution:{sessionId:'00000000-0000-4000-8000-000000000000',preset:primary,fallbacks:profiles},enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}},true)
239
+ const run=await f.scheduler.trigger(id,saved.revision,id,owner,f.runs)
240
+ await f.relay.drainSources()
241
+ await until(async()=>(await f.runs.get(run.id))?.status==='completed')
242
+ }
243
+ assert.deepEqual(f.launches.map(l=>l.authProfile),[undefined,'claude2','claude2','claude3','claude3'])
244
+ assert.ok(f.launches.every(l=>l.model==='opus'&&l.effort==='xhigh'))
245
+ const persisted=await new ControlStore(f.dir,1000).scheduledSetupHealth(owner)
246
+ assert.equal(persisted.claude.resetAt,resetAt)
247
+ assert.equal(persisted['claude@claude2'].resetAt,resetAt)
248
+ assert.ok(persisted['claude@claude3'].lastSuccessAt)
249
+ }finally{await f.close()}
250
+ })
251
+
212
252
  test('arbitrary mixed setup lists preserve CLI, provider, model and effort per attempt',async()=>{
213
253
  const candidates:AiPreset[]=[claudeA,
214
254
  {...codexA,authProfile:'work',effort:'xhigh'},
@@ -251,13 +291,19 @@ test('a usage limit after work began continues the same run on the next login, t
251
291
  }
252
292
  })
253
293
 
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'}])
294
+ test('a run that reaches the limit on every login waits and manually continues the same occurrence',async()=>{
295
+ const f=await fixture('limit-everywhere',[{lines:[work,limitEvent,limitMessage],code:0},{lines:[limitEvent,limitMessage],code:1}],[{...claudeA,authProfile:'claude2'}])
256
296
  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)
297
+ await until(async()=>Boolean((await f.runs.get(f.run.id))?.quotaWait))
298
+ const run=(await f.runs.get(f.run.id))!
299
+ assert.equal(run.status,'queued');assert.equal(run.failure,undefined);assert.equal(f.launches.length,2)
259
300
  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/)
301
+ await f.relay.drainSources();assert.equal(f.launches.length,2)
302
+ await f.scheduler.retryQuota(f.owner,f.runs,run.id)
303
+ await f.relay.drainSources()
304
+ assert.equal((await f.done()).status,'completed')
305
+ assert.equal(f.launches[2].runId,run.id)
306
+ assert.ok(f.prompts[2].some(p=>p.includes(f.launches[0].sessionId!) && p.includes('partly applied')))
261
307
  }finally{await f.close()}
262
308
  })
263
309
 
@@ -273,6 +319,18 @@ test('cancellation stops the chain',async()=>{
273
319
  }finally{await f.close()}
274
320
  })
275
321
 
322
+ test('a staged edit lets the running occurrence continue to its saved fallback on captured settings',async()=>{
323
+ const f=await fixture('staged',[{stage:'Edited work',lines:[quota],code:1},{lines:[startupLine({workBegan:true})],code:0}])
324
+ try{
325
+ const run=await f.done()
326
+ assert.equal(run.status,'completed')
327
+ assert.deepEqual(run.attempts!.map(a=>[a.preset.id,a.outcome]),[['claude-a','failed'],['claude-b','skipped'],['codex-a','completed']])
328
+ assert.ok(f.prompts.every(texts=>texts.includes('Saved work')),'every attempt runs the captured instructions')
329
+ const saved=await f.scheduler.get('daily')
330
+ assert.equal(saved.text,'Edited work');assert.deepEqual(saved.continuingRunIds,[f.run.id])
331
+ }finally{await f.close()}
332
+ })
333
+
276
334
  test('when every setup is unavailable the run reports each setup clearly',async()=>{
277
335
  const f=await fixture('none',[{unavailable:true},{unavailable:true}],[codexA])
278
336
  try{
@@ -300,6 +358,7 @@ test('schedules reject duplicate or invalid setups and the socket cannot rewrite
300
358
  try{
301
359
  const run=await new RunStore(dir).create({chatId:101,telegramUserId:101,texts:['x'],execution:execution([claudeB])})
302
360
  await assert.rejects(callDeliverySocket(socketPathFor(dir),{op:'patch',payload:{runId:run.id,change:{attempts:forged}}}),/relay-owned/)
361
+ for (const key of ['quotaWait','attemptRound']) await assert.rejects(callDeliverySocket(socketPathFor(dir),{op:'patch',payload:{runId:run.id,change:{[key]:{}}}}),/relay-owned/)
303
362
  }finally{await ledger.stop()}
304
363
  }finally{await rm(dir,{recursive:true,force:true})}
305
364
  })
@@ -357,7 +416,7 @@ test('Codex native session reports typed pre-work rejection and work start',asyn
357
416
  const program=`const rl=require('readline').createInterface({input:process.stdin});const send=x=>process.stdout.write(JSON.stringify(x)+'\\n');
358
417
  const event=(method,params)=>send({method,params:{threadId:'th',...params}});
359
418
  rl.on('line',line=>{const q=JSON.parse(line);if(!q.id)return;
360
- if(q.method==='initialize')return send({id:q.id,result:{}});
419
+ if(q.method==='initialize' || q.method==='account/rateLimits/read')return send({id:q.id,result:{}});
361
420
  if(q.method==='thread/start')return send({id:q.id,result:{thread:{id:'th'}}});
362
421
  if(q.method==='turn/start'){send({id:q.id,result:{turn:{id:'one'}}});event('turn/started',{turn:{id:'one'}});
363
422
  event('item/started',{item:{type:'userMessage'}});${worked ? "event('item/started',{item:{type:'agentMessage'}});" : ''}
@@ -367,7 +426,7 @@ if(q.method==='turn/start'){send({id:q.id,result:{turn:{id:'one'}}});event('turn
367
426
  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
427
  assert.equal(code,1)
369
428
  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'}})
429
+ assert.deepEqual(o.result(),{workBegan:worked,rejection:{category:'quota',resetAt:'2026-10-08T12:00:00.000Z'}})
371
430
  }
372
431
  })
373
432
 
@@ -382,8 +441,9 @@ test('schedule CLI saves ordered fallbacks, preserves them on edit and clears th
382
441
  const when=['--at','2027-09-09T09:00:00+04:00','--text','Work']
383
442
  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
443
  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)
444
+ const kept=await cli('edit','chain','--expected-revision',saved.revision,'--cli','claude','--model','opus',...when)
445
+ assert.deepEqual(kept.execution.fallbacks,saved.execution.fallbacks)
446
+ assert.equal((await cli('edit','chain','--expected-revision',kept.revision,'--cli','claude','--model','opus',...when,'--clear-fallbacks')).execution.fallbacks,undefined)
387
447
  await assert.rejects(exec(process.execPath,[bin,'create','bad','--cli','claude','--model','opus',...when,'--fallback','cli=codex'],{env}),/cli=EXECUTOR,model=MODEL/)
388
448
  await assert.rejects(exec(process.execPath,[bin,'create','dup','--cli','claude','--model','opus',...when,'--fallback','cli=claude,model=opus'],{env}),/distinct/)
389
449
  })
@@ -409,7 +469,7 @@ test('schedule CLI saves arbitrary profile/model/effort combinations and validat
409
469
  `cli=${cli},model=${model},effort=${effort}${profile?`,auth-profile=${profile}`:''}${provider?`,provider=${provider}`:''}`])
410
470
  const saved=await cli('create','chain','--cli','claude','--model','opus','--effort','high',...when,...flags)
411
471
  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)
472
+ assert.deepEqual((await cli('edit','chain','--expected-revision',saved.revision,...when)).execution,saved.execution)
413
473
  const base=[bin,'create','bad','--cli','claude','--model','opus',...when,'--fallback']
414
474
  for(const [spec,error] of [
415
475
  ['cli=claude,model=opus,auth-profile=missing',/not provisioned/],
@@ -421,3 +481,140 @@ test('schedule CLI saves arbitrary profile/model/effort combinations and validat
421
481
  ] as const)await assert.rejects(exec(process.execPath,[...base,spec],{env}),error)
422
482
  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
483
  })
484
+
485
+
486
+ test('a single saved Codex setup waits for its reported reset and completes without duplicating the occurrence',async()=>{
487
+ const resetAt=new Date(Date.now()+900).toISOString()
488
+ const f=await fixture('timed-wake',[{lines:[startupLine({workBegan:false,rejection:{category:'quota',resetAt}})],code:1},{lines:[startupLine({workBegan:true})],code:0}],[],codexA)
489
+ try {
490
+ await until(async()=>Boolean((await f.runs.get(f.run.id))?.quotaWait))
491
+ assert.equal((await f.runs.get(f.run.id))!.quotaWait!.retryAt,resetAt)
492
+ await f.relay.drainSources();assert.equal(f.launches.length,1)
493
+ await new Promise(resolve=>setTimeout(resolve,Math.max(0,Date.parse(resetAt)-Date.now()+10)))
494
+ await f.relay.drainSources()
495
+ assert.equal((await f.done()).status,'completed')
496
+ assert.equal(f.launches.length,2)
497
+ assert.equal((await f.runs.list()).filter(r=>r.scheduled?.id==='daily').length,1)
498
+ assert.equal((await f.runs.get(f.run.id))!.attempts!.length,2)
499
+ } finally { await f.close() }
500
+ })
501
+
502
+ test('credits-required waits for explicit retry; the owner Telegram command wakes it without inference',async()=>{
503
+ const event={type:'rate_limit_event',rate_limit_info:{status:'rejected',errorCode:'credits_required'}}
504
+ assert.equal(observe(event,quota).rejection!.category,'credits-required')
505
+ const f=await fixture('credits',[{lines:[event,quota],code:1},{lines:[work],code:0}],[])
506
+ try {
507
+ await until(async()=>Boolean((await f.runs.get(f.run.id))?.quotaWait))
508
+ assert.deepEqual((await f.runs.get(f.run.id))!.quotaWait,{})
509
+ assert.equal((await new ControlStore(f.dir,1000).scheduledSetupHealth(f.owner)).claude.manualRetry,true)
510
+ await f.relay.drainSources();assert.equal(f.launches.length,1)
511
+ f.relay.bot.botInfo={id:9,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as typeof f.relay.bot.botInfo
512
+ await f.relay.bot.handleUpdate({update_id:700,message:{message_id:700,date:0,text:'/retry_quota',from:{id:101,is_bot:false,first_name:'Owner'},chat:{id:101,type:'private',first_name:'Owner'}}})
513
+ await f.relay.drainSources()
514
+ assert.equal((await f.done()).status,'completed')
515
+ assert.ok(f.sent.some(text=>text.includes('Quota retry queued')))
516
+ } finally { await f.close() }
517
+ })
518
+
519
+ test('waiting tasks share one login probe and leave the execution slot available',async()=>{
520
+ const f=await fixture('shared-probe',[{lines:[quota],code:1},{hold:true}],[])
521
+ try {
522
+ await until(async()=>Boolean((await f.runs.get(f.run.id))?.quotaWait))
523
+ const first=await f.scheduler.get('daily')
524
+ const second=await f.scheduler.save({...first,id:'second'})
525
+ const run=await f.scheduler.trigger(second.id,second.revision,'second',f.owner,f.runs)
526
+ await f.relay.drainSources()
527
+ assert.equal((await f.runs.get(run.id))!.status,'queued')
528
+ assert.equal(f.launches.length,1)
529
+ await f.scheduler.retryQuota(f.owner,f.runs)
530
+ await f.relay.drainSources()
531
+ assert.equal(f.launches.length,2,'one probe for both queued tasks')
532
+ assert.equal((await f.runs.get(run.id))!.status,'queued')
533
+ const third=await f.scheduler.save({...first,id:'third',execution:{...first.execution!,preset:codexA}})
534
+ await f.scheduler.trigger(third.id,third.revision,'third',f.owner,f.runs)
535
+ await f.relay.drainSources()
536
+ assert.equal(f.launches.length,3,'another login can use the released slot')
537
+ } finally { await f.close() }
538
+ })
539
+
540
+ test('manual quota retry rejects cancelled, edited, foreign and non-quota runs',async()=>{
541
+ const f=await fixture('retry-authority',[{lines:[quota],code:1}],[])
542
+ try {
543
+ await until(async()=>Boolean((await f.runs.get(f.run.id))?.quotaWait))
544
+ await assert.rejects(f.scheduler.retryQuota({...f.owner,generation:'00000000-0000-4000-8000-000000000000'},f.runs,f.run.id),/not a current/)
545
+ await f.runs.patch(f.run.id,{status:'failed',quotaWait:undefined,failureReason:'executor-exit'})
546
+ await assert.rejects(f.scheduler.retryQuota(f.owner,f.runs,f.run.id),/not a current/)
547
+ await f.runs.patch(f.run.id,{status:'queued',quotaWait:{}})
548
+ await f.scheduler.cancel(f.run.id)
549
+ await assert.rejects(f.scheduler.retryQuota(f.owner,f.runs,f.run.id),/not a current/)
550
+ const saved=await f.scheduler.get('daily')
551
+ await f.scheduler.save({...saved,text:'Edited work'})
552
+ await assert.rejects(f.scheduler.retryQuota(f.owner,f.runs,f.run.id),/not a current/)
553
+ assert.ok((await new ControlStore(f.dir,1000).scheduledSetupHealth(f.owner)).claude.retryAt)
554
+ } finally { await f.close() }
555
+ })
556
+
557
+
558
+ test('installed schedule retry command revives only a typed prior quota failure, retaining run identity',async()=>{
559
+ const f=await fixture('cli-retry',[{lines:[quota],code:1},{lines:[work],code:0}],[])
560
+ const {serveTestLedger}=await import('./helpers/ledger.js')
561
+ const {execFile}=await import('node:child_process'),{promisify}=await import('node:util'),{fileURLToPath}=await import('node:url')
562
+ const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
563
+ let ledger:Awaited<ReturnType<typeof serveTestLedger>>|undefined
564
+ try {
565
+ await until(async()=>Boolean((await f.runs.get(f.run.id))?.quotaWait))
566
+ await f.runs.patch(f.run.id,{status:'failed',quotaWait:undefined,failureReason:'setups-unavailable',endedAt:new Date().toISOString()})
567
+ ledger=await serveTestLedger(f.dir)
568
+ const result=JSON.parse((await exec(process.execPath,[bin,'retry',f.run.id],{env:{...process.env,EZ_CONTROL_DIR:f.dir,EZ_RUN_ID:''}})).stdout)
569
+ assert.deepEqual(result.runIds,[f.run.id]);assert.deepEqual(result.scopes,['claude'])
570
+ await f.relay.drainSources()
571
+ const run=await f.done();assert.equal(run.status,'completed');assert.equal(run.failure,undefined)
572
+ assert.equal(run.attempts!.length,2)
573
+ assert.equal((await f.runs.list()).filter(r=>r.scheduled?.id==='daily').length,1)
574
+ } finally { await ledger?.stop();await f.close() }
575
+ })
576
+
577
+
578
+ test('Codex quota continuation retains the actual stopped native thread, not the assigned transport ID',async()=>{
579
+ const native='019a0000-0000-7000-8000-000000000001'
580
+ const f=await fixture('native-reference',[{session:native,lines:[startupLine({workBegan:true,rejection:{category:'quota'}})],code:1},{session:'019a0000-0000-7000-8000-000000000002',lines:[startupLine({workBegan:true})]}],[],codexA)
581
+ try {
582
+ await until(async()=>Boolean((await f.runs.get(f.run.id))?.quotaWait))
583
+ assert.notEqual(f.launches[0].sessionId,native)
584
+ assert.equal((await f.runs.get(f.run.id))!.attempts![0].sessionId,native)
585
+ await f.scheduler.retryQuota(f.owner,f.runs,f.run.id);await f.relay.drainSources()
586
+ const ended=await f.done()
587
+ assert.equal(ended.attempts![0].sessionId,native)
588
+ assert.ok(f.prompts[1].some(line=>line.includes(native) && line.includes('partly applied')))
589
+ assert.ok(!f.prompts[1].some(line=>line.includes(f.launches[0].sessionId!)))
590
+ } finally { await f.close() }
591
+ })
592
+
593
+ test('socket quota retry shares the admission lock with timed scheduler starts',async t=>{
594
+ const f=await fixture('retry-start-race',[{lines:[quota],code:1},{hold:true}],[])
595
+ const {callDeliverySocket,socketPathFor,deliverySocketAlive}=await import('../src/delivery-socket.js')
596
+ let running:Promise<void>|undefined
597
+ let release!:()=>void,entered!:()=>void
598
+ const blocked=new Promise<void>(resolve=>{release=resolve}),atClear=new Promise<void>(resolve=>{entered=resolve})
599
+ const clear=ControlStore.prototype.clearQuotaWait
600
+ try {
601
+ await until(async()=>Boolean((await f.runs.get(f.run.id))?.quotaWait))
602
+ f.relay.bot.botInfo={id:9,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as typeof f.relay.bot.botInfo
603
+ t.mock.method(f.relay.bot,'start',async()=>{})
604
+ running=f.relay.start()
605
+ await until(async()=>deliverySocketAlive(socketPathFor(f.dir)))
606
+ t.mock.method(ControlStore.prototype,'clearQuotaWait',async function(this:ControlStore,owner:Parameters<typeof clear>[0],scopes:string[]) {
607
+ await clear.call(this,owner,scopes)
608
+ entered();await blocked
609
+ })
610
+ const retry=callDeliverySocket(socketPathFor(f.dir),{op:'retryScheduleQuota',payload:{runId:f.run.id}})
611
+ await atClear
612
+ const dispatch=f.relay.drainSources()
613
+ await new Promise(resolve=>setTimeout(resolve,30))
614
+ assert.equal(f.launches.length,1,'the timed start waits while retry changes admission')
615
+ release();await retry;await dispatch
616
+ assert.equal(f.launches.length,2)
617
+ assert.equal((await f.runs.get(f.run.id))!.status,'running')
618
+ await f.relay.drainSources();assert.equal(f.launches.length,2,'no duplicate native child')
619
+ } finally { release();await f.close();await running }
620
+ })
@@ -14,7 +14,7 @@ import { atomic, snapshot, compose, prepareCommand } from '../src/plugins/manage
14
14
  import { bindUpdates } from '../src/updates/binding.mjs';
15
15
  import { TOOLS_LINE } from '../src/tools-line.mjs';
16
16
  import { status as runtimeStatus } from '../src/updates/status.mjs';
17
- import { providerEnvironment, queueAutomatic, waitForHostHeartbeat, withIdleUpgrade, withUpgrade } from '../src/updates/supervisor.mjs';
17
+ import { idle, executorBindingArchive, executorVolumes, providerEnvironment, queueAutomatic, waitForHostHeartbeat, withIdleUpgrade, withUpgrade } from '../src/updates/supervisor.mjs';
18
18
  const exec=promisify(execFile);
19
19
 
20
20
  test('structured runtime output excludes warnings but failures retain diagnostics',async()=>{
@@ -531,6 +531,8 @@ test('missing or broken managers fail with repair guidance before installing or
531
531
 
532
532
  for(const provider of ['pnpm','corepack']) test(`supervisor with only ${provider} drains work, replaces host PID and recovers after restart`,async t=>{
533
533
  const f=await fixture(t),fake=path.join(f.root,'fake');await fs.mkdir(fake);
534
+ const server=createServer(socket=>socket.on('data',chunk=>{const req=JSON.parse(chunk.toString());socket.end(JSON.stringify({id:req.id,ok:true,result:{running:0}})+'\n');}));
535
+ await new Promise(resolve=>server.listen(path.join(f.agent.controlDir,'delivery.sock'),resolve));t.after(()=>new Promise(resolve=>server.close(resolve)));
534
536
  // A process may die after creating a job directory but before atomically
535
537
  // committing its receipt. That directory must not block transport startup.
536
538
  await fs.mkdir(path.join(f.home,'updates','d18e847a-bb59-49c6-96f3-27fdc43ca44f'));
@@ -543,9 +545,9 @@ for(const provider of ['pnpm','corepack']) test(`supervisor with only ${provider
543
545
  }
544
546
  // Package archives never contain node_modules; the fake pnpm below provisions the fixture loader.
545
547
  await fs.rm(path.join(f.source,'node_modules'),{recursive:true});
546
- const log=path.join(f.root,'commands.jsonl');
548
+ const log=path.join(f.root,'commands.jsonl'),held=path.join(f.agent.controlDir,'host-executor/held.request.json'),retiring=path.join(f.agent.controlDir,'host-executor/retiring.running.json');
547
549
  await fs.writeFile(path.join(fake,provider),`#!${process.execPath}\nif(${JSON.stringify(provider)}==='corepack'&&process.argv[2]!=='pnpm@10.30.3')throw Error('Unpinned manager');if(process.argv.includes('view')){console.log(JSON.stringify('0.160.0'));process.exit(0)}if(process.argv.includes('--version')){console.log('10.30.3');process.exit(0)}const fs=require('fs');fs.mkdirSync('node_modules/tsx/dist',{recursive:true});fs.writeFileSync('node_modules/tsx/dist/loader.mjs','');`,{mode:0o755});
548
- await fs.writeFile(path.join(fake,'docker'),`#!${process.execPath}\nconst fs=require('fs');const a=process.argv.slice(2);fs.appendFileSync(${JSON.stringify(log)},JSON.stringify(a)+'\\n');if(a.includes('config'))console.log(JSON.stringify({services:{relay:{build:{args:{}}}}}));if(a.includes('ps'))console.log('cid');if(a[0]==='inspect')console.log('sha256:'+'a'.repeat(64));`,{mode:0o755});
550
+ await fs.writeFile(path.join(fake,'docker'),`#!${process.execPath}\nconst fs=require('fs');const a=process.argv.slice(2);fs.appendFileSync(${JSON.stringify(log)},JSON.stringify(a)+'\\n');if(a.includes('config'))console.log(JSON.stringify({services:{relay:{build:{args:{}}}}}));if(a.includes('ps'))console.log('cid');if(a[0]==='inspect')console.log('sha256:'+'a'.repeat(64));if(a.includes('up')&&!fs.existsSync(${JSON.stringify(held)})){fs.writeFileSync(${JSON.stringify(held)},'queued request');fs.writeFileSync(${JSON.stringify(retiring)},'active retired generation');}`,{mode:0o755});
549
551
  const wrapper=path.join(f.root,'supervisor.mjs'),module=new URL('../src/updates/supervisor.mjs',import.meta.url).href;
550
552
  await fs.writeFile(wrapper,`import {supervise} from ${JSON.stringify(module)};const a=new AbortController();process.on('SIGTERM',()=>a.abort());await supervise(${JSON.stringify(f.config.deploymentDir)},a.signal,{discover:async()=>{${provider==='pnpm' ? "throw Error('Synthetic discovery failure')" : 'return []'}}});`);
551
553
  const start=()=>{const p=spawn(process.execPath,[wrapper],{env:{...process.env,PATH:fake},stdio:['ignore','pipe','pipe']});let output='';p.stdout.on('data',b=>output+=b);p.stderr.on('data',b=>output+=b);return {p,output:()=>output};};
@@ -562,21 +564,38 @@ for(const provider of ['pnpm','corepack']) test(`supervisor with only ${provider
562
564
  await assert.rejects(fs.readFile(path.join(f.agent.controlDir,'upgrade-pause.json')),e=>e.code==='ENOENT');
563
565
  await fs.rm(running);
564
566
  await wait(async()=>{const j=await read(path.join(jobPath(f.home,job.id),'job.json'));if(j.status==='failed'||j.status==='rolled-back')throw Error(JSON.stringify(j)+first.output());return j.status==='completed';});
565
- const newBeat=await heartbeat();assert.notEqual(newBeat.pid,oldBeat.pid);assert.equal(await firstClosed,0,first.output());
567
+ const newBeat=await heartbeat();assert.notEqual(newBeat.pid,oldBeat.pid);
568
+ await wait(async()=>await fs.access(path.join(f.agent.controlDir,'upgrade-pause.json')).then(()=>true,()=>false));
569
+ assert.equal(first.p.exitCode,null,'active retiring work must finish before service reload');
570
+ assert.equal(await fs.readFile(retiring,'utf8'),'active retired generation');
571
+ assert.equal(await fs.readFile(held,'utf8'),'queued request');
572
+ await fs.rm(retiring);
573
+ assert.equal(await firstClosed,0,first.output());
574
+ assert.equal((await read(path.join(f.agent.controlDir,'upgrade-pause.json'))).id,job.id,'admission stays closed across supervisor exit');
566
575
  if(provider==='pnpm'){assert.match(first.output(),/Update discovery failed; host remains running/);assert.doesNotMatch(first.output(),/Synthetic discovery failure/);}
567
576
  // Adversarial: a completed update must not publish an agent wakeup notice.
568
577
  await assert.rejects(fs.readFile(path.join(f.agent.controlDir,'update-attention.json')),error=>error.code==='ENOENT');
569
578
  const active=(await read(path.join(f.home,'config.json'))).packageRoot;assert(active.endsWith('/runtime'));
570
579
  assert.equal((await read(path.join(jobPath(f.home,job.id),'job.json'))).packageManager.command,provider);
580
+ // Failed successor startup must not reopen admission or consume pending work.
581
+ const activeHost=path.join(active,'src/host-executor.ts');await fs.writeFile(activeHost,"throw Error('Synthetic worker startup failure')");
582
+ const broken=start(),brokenClosed=new Promise(resolve=>broken.p.once('close',resolve));t.after(()=>broken.p.kill('SIGTERM'));
583
+ assert.equal(await brokenClosed,1,broken.output());
584
+ assert.equal((await read(path.join(f.agent.controlDir,'upgrade-pause.json'))).id,job.id);
585
+ assert.equal(await fs.readFile(held,'utf8'),'queued request');
586
+ await fs.writeFile(activeHost,hostCode);
571
587
  const second=start();t.after(()=>second.p.kill('SIGTERM'));
572
588
  await wait(async()=>{const h=await heartbeat();return h?.pid!==newBeat.pid&&h?.at>newBeat.at;});
589
+ await wait(async()=>await fs.access(path.join(f.agent.controlDir,'upgrade-pause.json')).then(()=>false,()=>true));
590
+ assert.equal(await fs.readFile(held,'utf8'),'queued request','successor resumes without removing queued requests');
573
591
  const interrupted=await read(path.join(jobPath(f.home,job.id),'job.json'));interrupted.status='applying';await atomic(path.join(jobPath(f.home,job.id),'job.json'),interrupted);
574
592
  await atomic(path.join(f.home,'registry.lock'),{pid:second.p.pid});
575
593
  const closed2=new Promise(r=>second.p.once('close',r));second.p.kill('SIGKILL');await closed2;
576
594
  const third=start(),closed3=new Promise(r=>third.p.once('close',r));t.after(()=>third.p.kill('SIGTERM'));
577
595
  await wait(async()=>{const j=await read(path.join(jobPath(f.home,job.id),'job.json'));return j.status==='rolled-back';});
578
596
  assert.equal((await read(path.join(f.home,'config.json'))).packageRoot,f.old);
579
- // A rolled-back main update reloads automatically once native work is idle.
597
+ // The exact post-rollback regression: an unclaimed request cannot starve reload.
598
+ assert.equal(await fs.readFile(held,'utf8'),'queued request');
580
599
  await wait(()=>third.p.exitCode!==null||third.p.signalCode!==null);
581
600
  assert.equal(await closed3,0,third.output());
582
601
  });
@@ -794,3 +813,32 @@ test('failed handoff-compatible activation retires the candidate and rolls back
794
813
  assert.equal(r.calls.filter(c=>c[0]==='retireHost').length,2)
795
814
  assert.ok(!r.calls.some(c=>c[0]==='stopHost'))
796
815
  })
816
+
817
+
818
+ test('missing executor startup ignores queued requests but never live native work',async t=>{
819
+ const root=await fs.mkdtemp(path.join(tmpdir(),'ez-executor-admission-'));t.after(()=>fs.rm(root,{recursive:true,force:true}));
820
+ const dir=path.join(root,'host-executor');await fs.mkdir(dir);
821
+ await fs.writeFile(path.join(dir,'queued.request.json'),'{}');
822
+ assert.equal(await idle(root),false);assert.equal(await idle(root,{pendingRequests:false}),true);
823
+ assert.equal(await idle(root,{pendingRequests:false,relayRequired:true}),false,'reload requires the live admission barrier');
824
+ await fs.writeFile(path.join(dir,'live.running.json'),'{}');assert.equal(await idle(root,{pendingRequests:false}),false);
825
+ assert.equal(await fs.readFile(path.join(dir,'queued.request.json'),'utf8'),'{}');
826
+ await fs.rm(path.join(dir,'live.running.json'));
827
+ const server=createServer(socket=>socket.on('data',chunk=>{const req=JSON.parse(chunk.toString());socket.end(JSON.stringify({id:req.id,ok:true,result:{running:1}})+'\n');}));
828
+ await new Promise(resolve=>server.listen(path.join(root,'delivery.sock'),resolve));t.after(()=>new Promise(resolve=>server.close(resolve)));
829
+ assert.equal(await idle(root,{pendingRequests:false}),false);
830
+ assert.equal(await idle(root,{pendingRequests:false,relayRequired:true}),false);
831
+ });
832
+ test('executor preserves exact named volume identities and rejects missing declarations',()=>{
833
+ const worker={volumes:[{type:'volume',source:'registered-whatsapp-ipc'},{type:'bind',source:'/private/workspace'}]};
834
+ assert.deepEqual(executorVolumes({volumes:{'registered-whatsapp-ipc':{name:'existing-private-ipc'}}},worker),{'registered-whatsapp-ipc':{name:'existing-private-ipc',external:true}});
835
+ assert.throws(()=>executorVolumes({},worker),/Missing executor volume/);
836
+ });
837
+ test('executor archive extracts its exact private binding without host ownership changes',async t=>{
838
+ const root=await fs.mkdtemp(path.join(tmpdir(),'ez-executor-binding-'));t.after(()=>fs.rm(root,{recursive:true,force:true}));
839
+ const binding={agents:[{workspace:'/private/work',binDir:'/app/bin'}]},file=path.join(root,'binding.tar');
840
+ await fs.writeFile(file,executorBindingArchive(binding,1000),{mode:0o600});
841
+ await exec('tar',['-xf',file,'-C',root]);
842
+ const extracted=path.join(root,'executor.json');assert.deepEqual(await read(extracted),binding);assert.equal((await fs.stat(extracted)).mode&0o777,0o440);
843
+ for(const invalid of [-1,NaN,1.5,'1000',0o10000000])assert.throws(()=>executorBindingArchive(binding,invalid),/Invalid executor group/);
844
+ });
@@ -3,13 +3,76 @@ import test from 'node:test'
3
3
  import { access, chmod, mkdir, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
- import { spawnSync } from 'node:child_process'
6
+ import { spawn, spawnSync } from 'node:child_process'
7
7
  import { fileURLToPath } from 'node:url'
8
8
  import { createRelay } from '../src/index.js'
9
9
  import { ControlStore } from '../src/control-state.js'
10
10
  import { RunStore } from '../src/runs.js'
11
11
  import { initialPreset } from '../src/ai.js'
12
12
  import { opencodeDataHome, piAgentDir } from '../src/executor.js'
13
+ // @ts-expect-error The supervisor is a standalone Node module.
14
+ import { idle } from '../src/updates/supervisor.mjs'
15
+ import { initializeWorkspace } from '../src/workspace.js'
16
+
17
+ test('reload idle status waits for an admission already past the pause check', { timeout: 15000 }, async () => {
18
+ const dir = await mkdtemp(join(tmpdir(), 'ez-upgrade-barrier-'))
19
+ const purpose = join(dir, 'purpose.md')
20
+ await writeFile(purpose, 'Fixture agent\n')
21
+ await initializeWorkspace(dir, purpose)
22
+ const control = new ControlStore(dir, 1000), runs = new RunStore(dir)
23
+ await control.requestPairing(101, 101)
24
+ await control.approveOwner(101)
25
+ let launched = 0
26
+ const relay = createRelay({ controlDir: dir, workspace: dir, pairingTtlMs: 1000,
27
+ executorTimeoutMs: 0, executorCli: 'grok', telegramBotToken: 'fixture' }, async () => {
28
+ launched++
29
+ return { child: spawn(process.execPath, ['-e', 'setTimeout(()=>{},10000)'], { stdio: 'ignore' }), cleanup: async () => {}, stdout: '' }
30
+ })
31
+ relay.bot.botInfo = { id: 999, is_bot: true, first_name: 'Fixture', username: 'fixture_bot' } as typeof relay.bot.botInfo
32
+ let polling!: () => void
33
+ const ready = new Promise<void>(resolve => { polling = resolve })
34
+ relay.bot.api.config.use(async (_prev, method, _payload, signal) => {
35
+ if (method === 'getUpdates') {
36
+ polling()
37
+ if (signal && !signal.aborted) await new Promise<void>(resolve => signal.addEventListener('abort', () => resolve(), { once: true }))
38
+ return { ok: true, result: [] } as never
39
+ }
40
+ return { ok: true, result: { message_id: 42 } } as never
41
+ })
42
+ const started = relay.start()
43
+ const originalStatus = ControlStore.prototype.status
44
+ let release!: () => void, blocked!: () => void
45
+ const gate = new Promise<void>(resolve => { release = resolve })
46
+ const entering = new Promise<void>(resolve => { blocked = resolve })
47
+ try {
48
+ await ready
49
+ const run = await runs.create({ chatId: 101, telegramUserId: 101, texts: ['fixture'],
50
+ execution: await control.captureChoice({ ...initialPreset('grok'), model: 'fixture-model' }) })
51
+ let reads = 0
52
+ ControlStore.prototype.status = async function () {
53
+ if (++reads === 2) { blocked(); await gate }
54
+ return originalStatus.call(this)
55
+ }
56
+ const admission = relay.drainSources()
57
+ await entering
58
+ await writeFile(join(dir, 'upgrade-pause.json'), '{}', { mode: 0o600 })
59
+ let settled = false
60
+ const snapshot = idle(dir, { pendingRequests: false, relayRequired: true }).then((value: boolean) => { settled = true; return value })
61
+ await new Promise(resolve => setTimeout(resolve, 50))
62
+ assert.equal(settled, false, 'status must wait behind the held admission')
63
+ release()
64
+ await admission
65
+ assert.equal(await snapshot, false)
66
+ assert.equal(launched, 1)
67
+ assert.equal((await runs.get(run.id))?.status, 'running')
68
+ } finally {
69
+ release()
70
+ ControlStore.prototype.status = originalStatus
71
+ await relay.stop()
72
+ await started
73
+ await rm(dir, { recursive: true, force: true })
74
+ }
75
+ })
13
76
 
14
77
  test('private control directory preserves pause, fails closed on errors, and resumes queued work', async () => {
15
78
  const dir = await mkdtemp(join(tmpdir(), 'ez-upgrade-pause-'))
@@ -74,5 +137,5 @@ test('Linux relay real/effective UID regression (Docker test target)', {
74
137
  '--test', fileURLToPath(import.meta.url),
75
138
  ], { encoding: 'utf8', timeout: 15000, env })
76
139
  assert.equal(result.status, 0, result.stdout + result.stderr)
77
- assert.match(result.stdout, /ok 1 - private control directory/)
140
+ assert.match(result.stdout, /ok \d+ - private control directory/)
78
141
  })