@jc_stack/ez-agents 0.1.0-beta.46 → 0.1.0-beta.47

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 (44) hide show
  1. package/.dockerignore +2 -0
  2. package/CHANGELOG.md +17 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/Dockerfile +13 -9
  5. package/docker/engine-pnpm-workspace.yaml +4 -0
  6. package/docker/smoke.mjs +1 -1
  7. package/docs/architecture/ai-selection.md +8 -2
  8. package/docs/architecture/authority-boundaries.md +2 -2
  9. package/docs/development-and-testing.md +1 -1
  10. package/docs/docker-runtime.md +10 -0
  11. package/docs/plugin-guide.md +1 -1
  12. package/docs/plugins.md +1 -1
  13. package/docs/releasing.md +1 -1
  14. package/docs/scheduling.md +17 -3
  15. package/docs/upgrades.md +3 -1
  16. package/package.json +4 -3
  17. package/pnpm-workspace.yaml +7 -0
  18. package/scripts/smoke-scheduler.ts +4 -1
  19. package/src/ai.ts +20 -2
  20. package/src/delivery-socket.ts +15 -1
  21. package/src/host-executor.ts +5 -0
  22. package/src/index.ts +2 -0
  23. package/src/install-tools.mjs +11 -5
  24. package/src/model-policy.ts +4 -0
  25. package/src/runs.ts +4 -1
  26. package/src/schedule-cli.ts +12 -3
  27. package/src/scheduler.ts +28 -3
  28. package/src/task-executor.ts +2 -2
  29. package/src/updates/runtime.mjs +7 -4
  30. package/src/updates/supervisor.mjs +23 -13
  31. package/test/ai.test.ts +45 -5
  32. package/test/channel-owner.test.ts +2 -2
  33. package/test/desktop-bridge.test.ts +2 -1
  34. package/test/failure.test.ts +4 -4
  35. package/test/host-executor.test.ts +4 -4
  36. package/test/install-tools.test.mjs +6 -5
  37. package/test/native-tasks.test.ts +1 -1
  38. package/test/schedule-cli.test.ts +47 -3
  39. package/test/scheduled-tasks.test.ts +2 -2
  40. package/test/scheduler-host.test.ts +14 -3
  41. package/test/scheduler-relay.test.ts +47 -2
  42. package/test/scheduler.test.ts +21 -1
  43. package/test/tasks.test.ts +1 -1
  44. package/test/updates.test.mjs +32 -9
@@ -13,7 +13,7 @@ import { perform, environment, packageManager, backupStateDirectory, execute } f
13
13
  import { atomic, snapshot, compose, prepareCommand } from '../src/plugins/manager.mjs';
14
14
  import { bindUpdates } from '../src/updates/binding.mjs';
15
15
  import { status as runtimeStatus } from '../src/updates/status.mjs';
16
- import { providerEnvironment, queueAutomatic, waitForHostHeartbeat } from '../src/updates/supervisor.mjs';
16
+ import { providerEnvironment, queueAutomatic, waitForHostHeartbeat, withIdleUpgrade } from '../src/updates/supervisor.mjs';
17
17
  const exec=promisify(execFile);
18
18
 
19
19
  test('structured runtime output excludes warnings but failures retain diagnostics',async()=>{
@@ -113,10 +113,10 @@ test('root preparation leaves private plugin updates readable by the installer',
113
113
  const script=`const fs=require('fs');fs.readFileSync(${JSON.stringify(path.join(dir,'job.json'))});fs.readFileSync(${JSON.stringify(path.join(dir,'candidate.tgz'))});`;
114
114
  await exec('setpriv',['--reuid=20001','--regid=20002','--clear-groups','--no-new-privs',process.execPath,'-e',script]);
115
115
  });
116
- function runtime(f,{fail,stopped=false,buildArgs={CODEX_CLI_VERSION:'0.156.1'}}={}) {
116
+ function runtime(f,{fail,stopped=false,buildArgs={CODEX_CLI_VERSION:'0.156.1'},latest='0.160.0'}={}) {
117
117
  const calls=[];let failed=false;
118
118
  const execute=async(command,args,opts)=>{
119
- calls.push([command,...args]);if(fail&&!failed&&fail(command,args)){failed=true;throw Error('Synthetic failure');}
119
+ calls.push([command,...args]);if(args.includes('view'))return JSON.stringify(latest);if(fail&&!failed&&fail(command,args)){failed=true;throw Error('Synthetic failure');}
120
120
  if(args.at(-1)==='--version')return '10.30.3';
121
121
  if(args.includes('config'))return JSON.stringify({services:{relay:{build:{args:buildArgs}}}});
122
122
  if(args.includes('ps'))return stopped?'':'container-id';
@@ -306,7 +306,7 @@ test('main transaction stages before stopping, pins rollback image, preserves st
306
306
  assert.notEqual(await fs.readFile(path.join(active,'bin/example.mjs'),'utf8'),'tampered');
307
307
  assert(r.calls.findIndex(c=>c.includes('build'))<r.calls.findIndex(c=>c[0]==='stopHost'));
308
308
  const build=r.calls.find(c=>c[0]==='docker'&&c[1]==='build');
309
- assert.equal(build[build.indexOf('--build-arg')+1],'CODEX_CLI_VERSION=0.156.1');
309
+ assert.equal(build[build.indexOf('--build-arg')+1],'CODEX_CLI_VERSION=0.160.0');
310
310
  const status=await command(f.home,['status']);assert(!JSON.stringify(status).includes('private-test-token'));assert(!('rollback'in status.jobs[0]));
311
311
  assert.equal((await fs.stat(path.join(jobPath(f.home,job.id),'job.json'))).mode&0o777,0o600);
312
312
  });
@@ -320,12 +320,14 @@ test('main activation and rollback bind guidance from the selected package root'
320
320
  assert.equal((await perform(failed.home,await queued(failed),runtime(failed,{fail:(_command,args)=>args.includes('up')}))).status,'rolled-back');
321
321
  assert.equal(await fs.readFile(path.join(failed.agent.workspace,'AGENTS.md'),'utf8'),'old guidance\n');
322
322
  });
323
- test('the same archive with different deployment CLI pins gets distinct image bindings',async t=>{
323
+ test('latest overrides stale deployment pins and changing latest changes image bindings',async t=>{
324
324
  const tags=[],hashes=[];
325
325
  for(const pin of ['0.153.4','0.156.1']) {
326
- const f=await fixture(t),job=await queued(f),r=runtime(f,{buildArgs:{CODEX_CLI_VERSION:pin}});
326
+ const f=await fixture(t),job=await queued(f),r=runtime(f,{buildArgs:{CODEX_CLI_VERSION:'0.153.4'},latest:pin});
327
327
  assert.equal((await perform(f.home,job,r)).status,'completed');
328
328
  const build=r.calls.find(c=>c[0]==='docker'&&c[1]==='build'),tag=build[build.indexOf('-t')+1];
329
+ assert.ok(build.includes(`CODEX_CLI_VERSION=${pin}`));
330
+ assert.equal((await read(path.join(jobPath(f.home,job.id),'job.json'))).codexVersion,pin);
329
331
  assert.match(await fs.readFile(path.join(f.config.deploymentDir,'docker.env'),'utf8'),new RegExp(tag));
330
332
  tags.push(tag);hashes.push(job.sha256);
331
333
  }
@@ -402,7 +404,7 @@ test('isolated plugin replacement refreshes broker version inventory after regis
402
404
  test('plugin update preserves a created but never running service as stopped',async t=>{
403
405
  const f=await fixture(t,'plugin'),job=await queued(f),r=runtime(f),execute=r.execute;
404
406
  r.execute=async(command,args,options)=>{
405
- if(args.includes('ps')) {r.calls.push([command,...args]);return args.includes('--status')?'':'created-container';}
407
+ if(args.includes('ps')) {r.calls.push([command,...args]);if(args.includes('view'))return JSON.stringify(latest);return args.includes('--status')?'':'created-container';}
406
408
  return execute(command,args,options);
407
409
  };
408
410
  assert.equal((await perform(f.home,job,r)).status,'completed');
@@ -495,7 +497,7 @@ for(const provider of ['pnpm','corepack']) test(`supervisor with only ${provider
495
497
  // Package archives never contain node_modules; the fake pnpm below provisions the fixture loader.
496
498
  await fs.rm(path.join(f.source,'node_modules'),{recursive:true});
497
499
  const log=path.join(f.root,'commands.jsonl');
498
- 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('--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});
500
+ 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});
499
501
  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});
500
502
  const wrapper=path.join(f.root,'supervisor.mjs'),module=new URL('../src/updates/supervisor.mjs',import.meta.url).href;
501
503
  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 []'}}});`);
@@ -510,6 +512,7 @@ for(const provider of ['pnpm','corepack']) test(`supervisor with only ${provider
510
512
  const requester=await exec(process.execPath,['--input-type=module','-e',`import {submit} from ${JSON.stringify(new URL('../src/updates/control.mjs',import.meta.url).href)};console.log(JSON.stringify(await submit(${JSON.stringify(f.home)},${JSON.stringify(job.id)},false)));`]);
511
513
  assert.equal(JSON.parse(requester.stdout).status,'queued');
512
514
  await new Promise(r=>setTimeout(r,1800));assert.equal((await read(path.join(jobPath(f.home,job.id),'job.json'))).status,'queued');
515
+ await assert.rejects(fs.readFile(path.join(f.agent.controlDir,'upgrade-pause.json')),e=>e.code==='ENOENT');
513
516
  await fs.rm(running);
514
517
  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';});
515
518
  const newBeat=await heartbeat();assert.notEqual(newBeat.pid,oldBeat.pid);assert.equal(await firstClosed,0,first.output());
@@ -529,6 +532,26 @@ for(const provider of ['pnpm','corepack']) test(`supervisor with only ${provider
529
532
  const closed3=new Promise(r=>third.p.once('close',r));third.p.kill('SIGTERM');assert.equal(await closed3,0,third.output());
530
533
  });
531
534
 
535
+ test('queued upgrades keep admission open while busy and release a raced or aborted pause',async t=>{
536
+ const root=await fs.mkdtemp(path.join(tmpdir(),'ez-upgrade-admission-'));
537
+ t.after(()=>fs.rm(root,{recursive:true,force:true}));
538
+ const pause=path.join(root,'upgrade-pause.json'),job={id:'test'},abort=new AbortController();
539
+ const absent=()=>assert.rejects(fs.readFile(pause),e=>e.code==='ENOENT');
540
+ let applied=0;
541
+ const apply=async()=>{assert.equal((await read(pause)).id,job.id);applied++;return 'applied';};
542
+ await withIdleUpgrade(root,job,abort.signal,apply,{isIdle:async()=>false});
543
+ await absent();assert.equal(applied,0);
544
+ let checks=0;
545
+ await withIdleUpgrade(root,job,abort.signal,apply,{isIdle:async()=>++checks===1,wait:async()=>{}});
546
+ await absent();assert.equal(applied,0);
547
+ assert.equal(await withIdleUpgrade(root,job,abort.signal,apply,{isIdle:async()=>true,wait:async()=>{}}),'applied');
548
+ await absent();assert.equal(applied,1);
549
+ await assert.rejects(withIdleUpgrade(root,job,abort.signal,async()=>{throw Error('apply failed');},{isIdle:async()=>true,wait:async()=>{}}),/apply failed/);
550
+ await absent();
551
+ await withIdleUpgrade(root,job,abort.signal,apply,{isIdle:async()=>true,wait:async()=>abort.abort()});
552
+ await absent();assert.equal(applied,1);
553
+ });
554
+
532
555
  test('npm candidates verify exact version and integrity; automatic policy is enforced again at execution',async t=>{
533
556
  const f=await fixture(t),data=await fs.readFile(await f.pack()),original=globalThis.fetch;
534
557
  const {createHash}=await import('node:crypto');
@@ -627,7 +650,7 @@ for(const mode of ['exact','wrong-image','unhealthy']) test(`recovery reuses onl
627
650
  const f=await fixture(t),job=await queued(f),r=runtime(f);await perform(f.home,job,r);
628
651
  const interrupted=await read(path.join(jobPath(f.home,job.id),'job.json'));interrupted.status='applying';
629
652
  const calls=[],execute=async(command,args,options)=>{
630
- calls.push([command,...args]);
653
+ calls.push([command,...args]);if(args.includes('view'))return JSON.stringify(latest);
631
654
  if(args.includes('ps'))return 'b'.repeat(64);
632
655
  if(args[0]==='inspect'&&args.includes('{{json .}}'))return JSON.stringify({Image:'sha256:'+(mode==='wrong-image'?'c':'a').repeat(64),State:{Running:true,Health:{Status:mode==='unhealthy'?'unhealthy':'healthy'}}});
633
656
  if(mode==='exact'&&args.includes('up'))throw Error('Pinned image index no longer exists');