@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.
- package/CHANGELOG.md +23 -0
- package/README.md +4 -1
- package/docker/isolated-executor-smoke.mjs +35 -0
- package/docs/architecture/ai-selection.md +6 -2
- package/docs/docker-runtime.md +48 -0
- package/docs/releasing.md +1 -0
- package/docs/scheduling.md +124 -16
- package/docs/upgrades.md +19 -1
- package/package.json +1 -1
- package/src/ai-cli.ts +31 -5
- package/src/ai.ts +17 -1
- package/src/codex-session.ts +23 -2
- package/src/control-state.ts +173 -8
- package/src/delivery-socket.ts +18 -1
- package/src/executor.ts +11 -4
- package/src/host-executor.ts +17 -6
- package/src/index.ts +358 -269
- package/src/run-telemetry.ts +163 -0
- package/src/running-work.ts +123 -0
- package/src/runs.ts +7 -2
- package/src/schedule-cli.ts +55 -16
- package/src/scheduler.ts +126 -29
- package/src/setup-fallback.ts +57 -18
- package/src/updates/runtime.mjs +2 -1
- package/src/updates/supervisor.mjs +64 -19
- package/test/chat-fallback.test.ts +358 -0
- package/test/codex-session.test.ts +1 -1
- package/test/control-state.test.ts +62 -0
- package/test/executor.test.ts +2 -1
- package/test/host-executor.test.ts +41 -0
- package/test/run-handoff.test.ts +54 -0
- package/test/running-work.test.ts +175 -0
- package/test/schedule-cli.test.ts +55 -6
- package/test/scheduler.test.ts +128 -1
- package/test/script-schedules.test.ts +3 -3
- package/test/setup-fallback.test.ts +211 -14
- package/test/updates.test.mjs +53 -5
- package/test/upgrade-pause.test.ts +65 -2
|
@@ -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
|
-
|
|
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
|
|
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
|
|
255
|
-
const f=await fixture('limit-everywhere',[{lines:[work,limitEvent,limitMessage],code:
|
|
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
|
-
|
|
258
|
-
|
|
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.
|
|
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(),
|
|
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
|
-
|
|
386
|
-
assert.
|
|
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
|
+
})
|
package/test/updates.test.mjs
CHANGED
|
@@ -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))
|
|
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);
|
|
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
|
-
//
|
|
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
|
|
140
|
+
assert.match(result.stdout, /ok \d+ - private control directory/)
|
|
78
141
|
})
|