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

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.
@@ -19,13 +19,14 @@ export function execute(command,args,options={}) {
19
19
  return new Promise((resolve,reject)=>{
20
20
  const invocation=process.env.EZ_DOCKER_COMPOSE==='standalone'&&command==='docker'&&args[0]==='compose'
21
21
  ? {command:'docker-compose',args:args.slice(1)} : {command,args};
22
- const child=spawn(invocation.command,invocation.args,{cwd:options.cwd,env:environment(),stdio:['ignore','pipe','pipe']});let output='',stdout='';
22
+ const child=spawn(invocation.command,invocation.args,{cwd:options.cwd,env:environment(),stdio:[options.input===undefined?'ignore':'pipe','pipe','pipe']});let output='',stdout='';
23
23
  const timer=setTimeout(()=>child.kill('SIGKILL'),options.timeout||600000);
24
24
  const file=options.outputFile?createWriteStream(options.outputFile,{flags:'wx',mode:0o600}):null;
25
25
  const written=file?new Promise((resolve,reject)=>{file.once('finish',resolve);file.once('error',reject);}):Promise.resolve();
26
26
  if(file)child.stdout.pipe(file);else child.stdout.on('data',b=>{output=(output+b).slice(-16000);if(options.stdoutOnly)stdout=(stdout+b).slice(-16000);});
27
27
  child.stderr.on('data',b=>{output=(output+b).slice(-16000);});
28
28
  void written.catch(()=>child.kill('SIGKILL'));
29
+ if(options.input!==undefined){child.stdin.on('error',error=>{child.kill('SIGKILL');reject(error);});child.stdin.end(options.input);}
29
30
  child.once('error',error=>{clearTimeout(timer);reject(error);});
30
31
  child.once('close',async code=>{clearTimeout(timer);try{await written;if(code!==0)throw Error(`${invocation.command} failed (${code}): ${output}`);resolve(options.stdoutOnly?stdout:output);}catch(error){reject(error);}});
31
32
  });
@@ -33,8 +34,8 @@ export function execute(command,args,options={}) {
33
34
  export async function textAtomic(file,text) {
34
35
  const tmp=file+'.update.tmp';await fs.writeFile(tmp,text,{mode:0o600});await fs.rename(tmp,file);
35
36
  }
36
- export async function backupStateDirectory(source,destination) {
37
- await fs.cp(source,destination,{recursive:true,filter:async entry=>!(await fs.lstat(entry)).isSocket()});
37
+ export async function backupStateDirectory(source,destination,omit=[]) {
38
+ await fs.cp(source,destination,{recursive:true,filter:async entry=>!omit.includes(path.relative(source,entry).split(path.sep)[0])&&!(await fs.lstat(entry)).isSocket()});
38
39
  }
39
40
  export const pluginArgs = record => ['compose','--project-name',record.project,'--file',record.compose];
40
41
  export async function packageManager(root,run=execute) {
@@ -159,7 +160,7 @@ export async function perform(home,job,hooks) {
159
160
  const root=path.join(dir,'runtime');await fs.rm(root,{recursive:true,force:true});await extract(archive,root);
160
161
  const next=await eligibility(home,job.target,root,job.automatic);
161
162
  if(next.old.root!==job.previousRoot||next.old.pkg.version!==job.previousVersion)throw Error('Stale upgrade job');
162
- job.status='applying';job.root=root;job.startedAt=new Date().toISOString();await save();
163
+ job.status='applying';job.preserveRuns=hooks.preserveRuns===true;job.root=root;job.startedAt=new Date().toISOString();await save();
163
164
  const complete=async()=>{
164
165
  job.status='completed';job.endedAt=new Date().toISOString();await save();
165
166
  try { job.cleanup=await cleanupStaleBackups(home);await save(); }
@@ -193,17 +194,30 @@ export async function perform(home,job,hooks) {
193
194
  });
194
195
  const image=`ez-upgrade-${digest(JSON.stringify([job.sha256,buildArgs])).slice(0,24)}`;
195
196
  await run('docker',['build','--target','runtime',...buildArgs,'-t',image,root]);
197
+ const backup=path.join(dir,'backup');
198
+ const backupState=async()=>{
199
+ await fs.mkdir(backup,{mode:0o700});
200
+ await backupStateDirectory(config.workspace,path.join(backup,'workspace'));
201
+ await backupStateDirectory(agent.controlDir,path.join(backup,'control'),hooks.preserveRuns ? ['host-executor'] : []);
202
+ for(const name of ['docker.env','host-executor.json','agent.json','purpose.md','relay.env'])await fs.copyFile(path.join(config.deploymentDir,name),path.join(backup,name));
203
+ };
204
+ // Native client state can be many gigabytes. This advisory copy never
205
+ // rewinds live state, so compatible generations keep serving throughout it.
206
+ if(hooks.preserveRuns)await backupState();
196
207
  const envFile=path.join(config.deploymentDir,'docker.env'),oldEnv=await fs.readFile(envFile,'utf8');
197
208
  const cid=(await run('docker',[...relayArgs(config),'ps','-q','relay'])).trim();
198
209
  if(!cid||/\s/.test(cid))throw Error('Expected one running relay');
199
210
  const oldImage=(await run('docker',['inspect','--format','{{.Image}}',cid])).trim();
200
211
  if(!/^sha256:[a-f0-9]{64}$/.test(oldImage))throw Error('Cannot pin rollback image');
201
212
  job.rollback={env:envValue(oldEnv,'EZ_RELAY_IMAGE',oldImage),packageRoot:config.packageRoot};await save();
202
- await run('docker',[...relayArgs(config),'stop','relay']);await hooks.stopHost();
203
- const backup=path.join(dir,'backup');await fs.mkdir(backup,{mode:0o700});
204
- await backupStateDirectory(config.workspace,path.join(backup,'workspace'));
205
- await backupStateDirectory(agent.controlDir,path.join(backup,'control'));
206
- for(const name of ['docker.env','host-executor.json','agent.json','purpose.md','relay.env'])await fs.copyFile(path.join(config.deploymentDir,name),path.join(backup,name));
213
+ const activate=async()=>{
214
+ const current=await eligibility(home,job.target,root,job.automatic);
215
+ if(current.old.root!==job.previousRoot||current.old.pkg.version!==job.previousVersion)throw Error('Stale upgrade job');
216
+ const pause=path.join(agent.controlDir,'upgrade-pause.json');
217
+ if(hooks.preserveRuns)await atomic(pause,{id:job.id});
218
+ try {
219
+ await run('docker',[...relayArgs(config),'stop','relay']);await (hooks.preserveRuns ? hooks.retireHost() : hooks.stopHost());
220
+ if(!hooks.preserveRuns)await backupState();
207
221
  let env=oldEnv.split(job.previousRoot+path.sep).join(root+path.sep);env=envValue(env,'EZ_RELAY_IMAGE',image);
208
222
  await textAtomic(envFile,env);
209
223
  await bindUpdates(home,path.join(config.deploymentDir,'host-executor.json'),root);
@@ -213,6 +227,9 @@ export async function perform(home,job,hooks) {
213
227
  const brokerImage=(await run('docker',['image','inspect','--format','{{.Id}}',image],{stdoutOnly:true})).trim();
214
228
  job.brokerReadiness=await refreshBroker(config,run,brokerImage);
215
229
  }
230
+ }finally{if(hooks.preserveRuns)await fs.rm(pause,{force:true});}
231
+ };
232
+ if(hooks.preserveRuns)await locked(home,activate,{drainInvocations:true,signal:hooks.signal});else await activate();
216
233
  } else {
217
234
  const old=next.old.record;
218
235
  const secrets=await read(path.join(home,'packages',job.target,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
@@ -266,7 +283,7 @@ export async function recover(home,job,hooks) {
266
283
  return /^sha256:[a-f0-9]{64}$/.test(pinned||'')&&current.Image===pinned&&current.State?.Running===true&&current.State?.Health?.Status==='healthy';
267
284
  })().catch(()=>false);
268
285
  if(!retained)await run('docker',[...relayArgs(config),'stop','relay']);
269
- await hooks.stopHost();
286
+ await (hooks.preserveRuns ? hooks.retireHost() : hooks.stopHost());
270
287
  await textAtomic(path.join(config.deploymentDir,'docker.env'),job.rollback.env);
271
288
  await bindUpdates(home,path.join(config.deploymentDir,'host-executor.json'),job.rollback.packageRoot);
272
289
  await hooks.startHost(job.rollback.packageRoot);
@@ -5,7 +5,7 @@ import { spawn } from 'node:child_process';
5
5
  import { atomic, locked } from '../plugins/manager.mjs';
6
6
  import { callDeliverySocket } from '../delivery-socket-client.mjs';
7
7
  import { state, read, jobs, jobPath, check, prepare, submit, missing, cleanupStaleBackups } from './control.mjs';
8
- import { perform, environment, brokerNeedsRefresh } from './runtime.mjs';
8
+ import { perform, environment, brokerNeedsRefresh, execute, relayArgs } from './runtime.mjs';
9
9
 
10
10
  const reservedProviderKeys=new Set(['HOME','LANG','LC_ALL','LOGNAME','PATH','SHELL','TERM','TMPDIR','USER','CODEX_HOME','NODE_OPTIONS']);
11
11
  const providerEnvironmentKey=value=>{
@@ -30,9 +30,9 @@ export async function waitForHostHeartbeat(controlDir,child,failure,{attempts=30
30
30
  }
31
31
  throw Error('Host transport heartbeat timeout');
32
32
  }
33
- export async function idle(control) {
33
+ export async function idle(control,{pendingRequests=true}={}) {
34
34
  const host=await fs.readdir(path.join(control,'host-executor')).catch(e=>{if(e.code==='ENOENT')return [];throw e;});
35
- if(host.some(f=>f.endsWith('.running.json')||f.endsWith('.request.json')))return false;
35
+ if(host.some(f=>f.endsWith('.running.json')||(pendingRequests&&f.endsWith('.request.json'))))return false;
36
36
  // Run records live in relay memory: the live socket status is the only
37
37
  // cross-process view of active work. A down relay cannot own a run.
38
38
  const status=await callDeliverySocket(path.join(control,'delivery.sock'),{op:'status'},2000).catch(()=>null);
@@ -54,7 +54,7 @@ export async function withIdleUpgrade(control,job,signal,apply,{isIdle=idle,wait
54
54
  }
55
55
  // Plugin replacement drains command leases at activation, not native turns.
56
56
  export async function withUpgrade(control,job,signal,apply,options) {
57
- if(job.target==='main'||options?.requiresIdle)return withIdleUpgrade(control,job,signal,apply,options);
57
+ if((job.target==='main'&&!options?.preserveRuns)||options?.requiresIdle)return withIdleUpgrade(control,job,signal,apply,options);
58
58
  if(!signal.aborted)return apply();
59
59
  }
60
60
  export async function queueAutomatic(home,available) {
@@ -73,6 +73,26 @@ export async function queueAutomatic(home,available) {
73
73
  }
74
74
  return null;
75
75
  }
76
+ // Docker archive copy keeps the binding private and readonly without changing
77
+ // host ownership, which may differ from the unprivileged container identity.
78
+ export function executorBindingArchive(binding,gid) {
79
+ if(!Number.isSafeInteger(gid)||gid<0||gid>0o7777777)throw Error('Invalid executor group');
80
+ const data=Buffer.from(JSON.stringify(binding)),header=Buffer.alloc(512);
81
+ const oct=(offset,width,value)=>header.write(value.toString(8).padStart(width-1,'0')+'\0',offset);
82
+ header.write('executor.json');oct(100,8,0o440);oct(108,8,0);oct(116,8,gid);
83
+ oct(124,12,data.length);oct(136,12,0);header.fill(32,148,156);
84
+ header.write('0',156);header.write('ustar\0',257);header.write('00',263);
85
+ header.write(header.reduce((sum,byte)=>sum+byte,0).toString(8).padStart(6,'0')+'\0 ',148);
86
+ return Buffer.concat([header,data,Buffer.alloc((512-data.length%512)%512+1024)]);
87
+ }
88
+ export function executorVolumes(deployment,worker) {
89
+ const names=[...new Set(worker.volumes.filter(m=>m.type==='volume').map(m=>m.source))];
90
+ return Object.fromEntries(names.map(name=>{
91
+ const actual=deployment.volumes?.[name]?.name;
92
+ if(typeof actual!=='string'||!actual)throw Error('Missing executor volume binding');
93
+ return [name,{name:actual,external:true}];
94
+ }));
95
+ }
76
96
  export async function supervise(deployment,signal,{discover=check}={}) {
77
97
  const host=await read(path.join(deployment,'host-executor.json'));
78
98
  if(host.agents.length!==1||!host.agents[0].toolsHome)throw Error('Initialize a single deployment plugin registry before starting the supervisor');
@@ -84,20 +104,96 @@ export async function supervise(deployment,signal,{discover=check}={}) {
84
104
  if(prior&&(!Number.isSafeInteger(prior.pid)||prior.pid<1))throw Error('Corrupt supervisor lock');
85
105
  if(prior){try{process.kill(prior.pid,0);throw Error('Supervisor already running');}catch(e){if(e.code!=='ESRCH')throw e;}await fs.rm(lock);}
86
106
  await fs.writeFile(lock,JSON.stringify({pid:process.pid}),{flag:'wx',mode:0o600});
87
- let child;
107
+ let child,container;
108
+ let retirements=[];
109
+ let reloadPending=false;
110
+ const generations=new Map();
111
+ const removeContainer=async id=>{
112
+ const generation=generations.get(id);
113
+ if(generation){await execute('docker',['compose','--project-name',generation.project,'--file',generation.file,'down']);await fs.rm(generation.file,{force:true});generations.delete(id);}
114
+ else await execute('docker',['rm',id]);
115
+ };
88
116
  const stopHost=async()=>{
117
+ if(container){await execute('docker',['stop','--time','10',container]);await removeContainer(container);container=undefined;}
89
118
  if(!child)return;const running=child;child=undefined;
90
119
  if(running.exitCode!==null)return;
91
120
  const closed=new Promise(resolve=>running.once('close',resolve));running.kill('SIGTERM');
92
121
  const timer=setTimeout(()=>running.kill('SIGKILL'),10000);await closed;clearTimeout(timer);
93
122
  };
94
123
  const startHost=async root=>{
95
- if(host.isolation==='isolated')return
124
+ if(host.isolation==='isolated') {
125
+ const pkg=await read(path.join(root,'package.json'));
126
+ if(pkg.ezRelease?.containerExecutor!==1)return;
127
+ const lock=path.join(agent.controlDir,'host-executor/worker.lock');
128
+ const prior=await read(lock).catch(missing);
129
+ if(prior?.containerId){
130
+ if(!/^[a-f0-9]{12,64}$/.test(prior.containerId))throw Error('Invalid previous isolated worker identity');
131
+ const running=await execute('docker',['inspect','--format','{{.State.Running}}',prior.containerId],{stdoutOnly:true}).catch(()=>null);
132
+ if(running?.trim()==='true')throw Error('Previous isolated worker is still running');
133
+ // Only the supervisor can prove a foreign container stopped.
134
+ if(JSON.stringify(await read(lock))!==JSON.stringify(prior))throw Error('Worker lock changed during recovery');
135
+ await fs.rm(lock);
136
+ }
137
+ const config=(await state(home)).config;
138
+ const deployment=JSON.parse(await execute('docker',[...relayArgs(config),'config','--format','json'],{stdoutOnly:true}));
139
+ const relay=deployment.services.relay;
140
+ if(!relay?.image||!Array.isArray(relay.volumes))throw Error('Invalid isolated relay binding');
141
+ const generation=path.basename(root).replace(/[^a-zA-Z0-9_.-]/g,'-')+'-'+Date.now();
142
+ const project='ez-executor-'+generation.toLowerCase();
143
+ const file=path.join(directory,project+'.json');
144
+ const binding={...host,agents:host.agents.map(a=>({...a,binDir:'/app/bin'}))};
145
+ const worker={image:relay.image,init:true,restart:'no',user:`${relay.environment.EZ_RUNTIME_UID||1000}:${relay.environment.EZ_RUNTIME_GID||1000}`,
146
+ cap_drop:['ALL'],security_opt:['no-new-privileges:true'],entrypoint:['node','--import','/app/node_modules/tsx/dist/loader.mjs','/app/src/host-executor.ts','/run/executor.json'],
147
+ environment:{...relay.environment,EZ_CONTAINER_EXECUTOR:'1',EZ_EXECUTOR_TRANSPORT:'local'},
148
+ volumes:relay.volumes,tmpfs:['/tmp'],
149
+ ...Object.fromEntries(['networks','network_mode','extra_hosts','dns','dns_search'].filter(key=>relay[key]!==undefined).map(key=>[key,relay[key]]))};
150
+ if(worker.networks&&!Array.isArray(worker.networks))worker.networks=Object.fromEntries(Object.keys(worker.networks).map(name=>[name,{}]));
151
+ const networks=worker.networks?Object.fromEntries(Object.entries(deployment.networks||{}).map(([name,binding])=>[name,{name:binding.name,external:true}])):undefined;
152
+ const volumes=executorVolumes(deployment,worker);
153
+ const archive=executorBindingArchive(binding,Number(worker.user.split(':')[1]));
154
+ await atomic(file,{services:{executor:worker},...(networks?{networks}:{}),...(Object.keys(volumes).length?{volumes}:{})});
155
+ try {
156
+ await execute('docker',['compose','--project-name',project,'--file',file,'create','--no-build','executor']);
157
+ container=(await execute('docker',['compose','--project-name',project,'--file',file,'ps','-aq','executor'],{stdoutOnly:true})).trim();
158
+ if(!/^[a-f0-9]{12,64}$/.test(container))throw Error('Missing isolated executor');
159
+ } catch(error) {
160
+ await execute('docker',['compose','--project-name',project,'--file',file,'down']);
161
+ await fs.rm(file,{force:true});container=undefined;throw error;
162
+ }
163
+ generations.set(container,{project,file});
164
+ await execute('docker',['cp','-',`${container}:/run`],{input:archive});
165
+ await execute('docker',['start',container]);
166
+ for(let n=0;n<300;n++){
167
+ const running=await execute('docker',['inspect','--format','{{.State.Running}}',container],{stdoutOnly:true});
168
+ if(running.trim()!=='true')throw Error('Isolated executor exited during startup');
169
+ const heartbeat=await read(path.join(agent.controlDir,'host-executor/heartbeat.json')).catch(missing);
170
+ if(heartbeat?.containerExecutor===true&&container.startsWith(heartbeat.containerId||'missing')&&Date.now()-heartbeat.at<1000)return;
171
+ await sleep(100);
172
+ }
173
+ throw Error('Isolated executor heartbeat timeout');
174
+ }
96
175
  child=spawn(process.execPath,['--import',path.join(root,'node_modules/tsx/dist/loader.mjs'),path.join(root,'src/host-executor.ts'),path.join(deployment,'host-executor.json')],
97
- {env:{...environment(),...providerEnvironment(host),EZ_HOST_SUPERVISOR_PID:String(process.pid)},stdio:['ignore','inherit','inherit']});
176
+ {detached:true,env:{...environment(),...providerEnvironment(host),EZ_HOST_SUPERVISOR_PID:String(process.pid)},stdio:['ignore','inherit','inherit']});
98
177
  let error;child.once('error',e=>{error=e;});
99
178
  await waitForHostHeartbeat(agent.controlDir,child,()=>error);
100
179
  };
180
+ const retireHost=async()=>{
181
+ if(container){await execute('docker',['kill','--signal','SIGUSR2',container]);retirements.push({container});container=undefined;}
182
+ if(child){const running=child;child=undefined;if(running.exitCode===null){running.kill('SIGUSR2');retirements.push({child:running});}}
183
+ // A retiring generation releases its single claim lock before replacement.
184
+ for(let n=0;n<300;n++){
185
+ if(!await fs.stat(path.join(agent.controlDir,'host-executor/worker.lock')).then(()=>true,e=>{if(e.code==='ENOENT')return false;throw e;}))return;
186
+ await sleep(100);
187
+ }
188
+ throw Error('Native executor retirement timeout');
189
+ };
190
+ const preserveRuns=async job=>{
191
+ if(job.target!=='main')return false;
192
+ const old=await read(path.join((await state(home)).config.packageRoot,'package.json'));
193
+ const candidate=await read(path.join(jobPath(home,job.id),'package/package.json'));
194
+ return old.ezRelease?.runHandoff===1&&candidate.ezRelease?.runHandoff===1&&
195
+ (host.isolation!=='isolated'||(container&&old.ezRelease?.containerExecutor===1&&candidate.ezRelease?.containerExecutor===1));
196
+ };
101
197
  const pause=path.join(agent.controlDir,'upgrade-pause.json');
102
198
  const refreshRequirement=async job=>{
103
199
  if(job.target==='main')return false;
@@ -119,36 +215,58 @@ export async function supervise(deployment,signal,{discover=check}={}) {
119
215
  if(owner&&prior&&owner.pid===prior.pid)await fs.rm(registryLock);
120
216
  // Wait for an orphaned transport's parent-watch to terminate it first.
121
217
  await sleep(1200);
218
+ const workerOwner=await read(path.join(agent.controlDir,'host-executor/worker.lock')).catch(missing);
219
+ if(host.isolation==='isolated'&&workerOwner?.containerId){
220
+ if(!/^[a-f0-9]{12,64}$/.test(workerOwner.containerId))throw Error('Invalid isolated worker identity');
221
+ if((await execute('docker',['inspect','--format','{{.State.Running}}',workerOwner.containerId],{stdoutOnly:true}).catch(()=>''))?.trim()==='true')container=workerOwner.containerId;
222
+ }
122
223
  const interrupted=(await jobs(home)).find(j=>j.status==='applying');
123
224
  if(interrupted){
124
225
  const refreshPluginBroker=await refreshRequirement(interrupted);
125
226
  const result=refreshPluginBroker===null?undefined:interrupted.target==='main'
126
- ? await (async()=>{await atomic(pause,{id:interrupted.id});try{return await locked(home,()=>perform(home,interrupted,{stopHost,startHost}));}finally{await fs.rm(pause,{force:true});}})()
127
- : await withUpgrade(agent.controlDir,interrupted,signal,()=>perform(home,interrupted,{stopHost,startHost,signal,refreshPluginBroker}),{requiresIdle:refreshPluginBroker});
128
- if(result?.status==='completed'&&interrupted.target==='main')return;
227
+ ? await (async()=>{await atomic(pause,{id:interrupted.id});try{return await locked(home,()=>perform(home,interrupted,{stopHost,startHost,retireHost,preserveRuns:interrupted.preserveRuns===true}));}finally{await fs.rm(pause,{force:true});}})()
228
+ : await withUpgrade(agent.controlDir,interrupted,signal,()=>perform(home,interrupted,{stopHost,startHost,retireHost,signal,refreshPluginBroker}),{requiresIdle:refreshPluginBroker});
229
+ if(['completed','rolled-back'].includes(result?.status)&&interrupted.target==='main')reloadPending=true;
129
230
  }
130
231
  const isolated=host.isolation==='isolated'
131
- if(!isolated && !child)await startHost((await state(home)).config.packageRoot);
232
+ if(!isolated&&!child)await startHost((await state(home)).config.packageRoot);
233
+ const initializeIsolated=async()=>{
234
+ if(!isolated||container)return;
235
+ const root=(await state(home)).config.packageRoot;
236
+ if((await read(path.join(root,'package.json'))).ezRelease?.containerExecutor!==1)return;
237
+ // Queued requests need this worker to drain. Existing native runs still
238
+ // block migration; the worker lock continues to enforce single ownership.
239
+ await withIdleUpgrade(agent.controlDir,{id:'executor-migration'},signal,()=>startHost(root),{isIdle:()=>idle(agent.controlDir,{pendingRequests:false})});
240
+ };
241
+ await initializeIsolated();
132
242
  let nextCheck=0;
133
243
  while(!signal.aborted) {
134
244
  if(!isolated && child?.exitCode!==null&&child?.exitCode!==undefined)throw Error('Host transport exited; supervisor service should restart');
135
- const pending=(await jobs(home)).find(j=>j.status==='queued');
245
+ await initializeIsolated();
246
+ if(container&&(await execute('docker',['inspect','--format','{{.State.Running}}',container],{stdoutOnly:true})).trim()!=='true')throw Error('Isolated executor exited; supervisor service should restart');
247
+ if(reloadPending&&await idle(agent.controlDir))return;
248
+ // Reap retired containers only after their worker exits.
249
+ for(const item of retirements.filter(r=>r.container)){
250
+ if((await execute('docker',['inspect','--format','{{.State.Running}}',item.container],{stdoutOnly:true})).trim()==='false'){await removeContainer(item.container);retirements=retirements.filter(r=>r!==item);}
251
+ }
252
+ const pending=!reloadPending&&(await jobs(home)).find(j=>j.status==='queued');
136
253
  if(pending) {
137
254
  const refreshPluginBroker=await refreshRequirement(pending);
138
255
  if(refreshPluginBroker===null)continue;
256
+ const preserve=await preserveRuns(pending);
139
257
  const apply=async()=>{
140
258
  try {
141
- const activate=async()=>{const latest=await read(path.join(jobPath(home,pending.id),'job.json'));return perform(home,latest,{stopHost,startHost,signal,refreshPluginBroker});};
142
- return pending.target==='main'?await locked(home,activate,{drainInvocations:true,signal}):await activate();
259
+ const activate=async()=>{const latest=await read(path.join(jobPath(home,pending.id),'job.json'));return perform(home,latest,{stopHost,startHost,retireHost,signal,refreshPluginBroker,preserveRuns:preserve});};
260
+ return pending.target==='main'&&!preserve?await locked(home,activate,{drainInvocations:true,signal}):await activate();
143
261
  } catch(error) {
144
262
  // A pre-switch rejection is terminal. Applying jobs keep their journal for recovery.
145
263
  const latest=await read(path.join(jobPath(home,pending.id),'job.json'));
146
264
  if(latest.status==='queued'){latest.status='failed';latest.error=error.message;await atomic(path.join(jobPath(home,pending.id),'job.json'),latest);}else throw error;
147
265
  }
148
266
  };
149
- const result=await withUpgrade(agent.controlDir,pending,signal,apply,{requiresIdle:refreshPluginBroker});
150
- if(result?.status==='completed') {
151
- if(pending.target==='main')return;
267
+ const result=await withUpgrade(agent.controlDir,pending,signal,apply,{requiresIdle:refreshPluginBroker,preserveRuns:preserve});
268
+ if(['completed','rolled-back'].includes(result?.status)) {
269
+ if(pending.target==='main')reloadPending=true;
152
270
  nextCheck=0;
153
271
  }
154
272
  }
@@ -166,7 +284,8 @@ export async function supervise(deployment,signal,{discover=check}={}) {
166
284
  await sleep(500);
167
285
  }
168
286
  }finally {
169
- clearInterval(beat);await stopHost();await fs.rm(lock,{force:true});
287
+ clearInterval(beat);await stopHost();
288
+ for(const item of retirements){if(item.container){await execute('docker',['stop','--time','10',item.container]);await removeContainer(item.container);}else if(item.child.exitCode===null)item.child.kill('SIGTERM');}await fs.rm(lock,{force:true});
170
289
  if(!(await jobs(home)).some(j=>j.status==='applying'))await fs.rm(pause,{force:true});
171
290
  }
172
291
  }
@@ -6,6 +6,7 @@ import path from 'node:path'
6
6
  import {spawnSync} from 'node:child_process'
7
7
  import {fileURLToPath} from 'node:url'
8
8
  import {ControlStore} from '../src/control-state.js'
9
+ import {executorJobEnv} from '../src/executor.js'
9
10
  import {initialPreset} from '../src/ai.js'
10
11
 
11
12
  test('explicit CLI/model selection preserves installation default and rejects unavailable choices',async()=>{
@@ -50,12 +51,33 @@ test('an agent can select a declared provider and model without putting the key
50
51
  await writeFile(path.join(binDir,'codex'),'#!/bin/sh\nexit 0\n',{mode:0o700})
51
52
  await writeFile(path.join(controlDir,'host-executor','models.json'),JSON.stringify([{cli:'codex',provider:'openrouter',model:'google/gemini-3.8-flash',name:'Gemini',efforts:[]}]))
52
53
  const store=new ControlStore(controlDir,900000);await store.aiState(initialPreset('codex'))
53
- const env={...process.env,HOME:root,PATH:binDir+path.delimiter+process.env.PATH,EZ_CONTROL_DIR:controlDir,OPENROUTER_API_KEY:'must-not-persist'}
54
+ const env=executorJobEnv({runId:'fixture',controlDir,binDir,catalogTransport:'host'},{...process.env,HOME:root,PATH:binDir+path.delimiter+process.env.PATH,OPENROUTER_API_KEY:'must-not-persist'})
54
55
  const bin=fileURLToPath(new URL('../bin/ezenciel-agents-ai.mjs',import.meta.url))
55
56
  const result=spawnSync(process.execPath,[bin,'select','--cli','codex','--provider','openrouter','--model','google/gemini-3.8-flash'],{env,encoding:'utf8'})
56
57
  assert.equal(result.status,0,result.stderr)
57
58
  const state=await store.status(),selected=state.ai?.presets.find(p=>p.id===state.ai?.selectedId)
58
59
  assert.equal(selected?.provider,'openrouter');assert.equal(selected?.model,'google/gemini-3.8-flash')
59
60
  assert.equal(JSON.stringify(state).includes('must-not-persist'),false)
61
+ assert.equal(env.EZ_EXECUTOR_TRANSPORT,undefined)
62
+ assert.equal(env.EZ_ISOLATION,undefined)
63
+ }finally{await rm(root,{recursive:true,force:true})}
64
+ })
65
+
66
+
67
+ test('isolated AI CLI ignores a legacy host catalog and discovers its own Sonnet',async()=>{
68
+ const root=await mkdtemp(path.join(tmpdir(),'ez-ai-local-catalog-'))
69
+ try{
70
+ const controlDir=path.join(root,'control'),binDir=path.join(root,'bin')
71
+ await mkdir(path.join(controlDir,'host-executor'),{recursive:true});await mkdir(binDir)
72
+ const stale=JSON.stringify([{cli:'codex',model:'host-only',name:'Host only',efforts:[]}])
73
+ await writeFile(path.join(controlDir,'host-executor','models.json'),stale)
74
+ await writeFile(path.join(binDir,'claude'),"#!/bin/sh\nprintf '%s\n' \" --model <model> Model (e.g. 'sonnet', 'opus')\" \" --effort <level> Effort (low, high)\"\n",{mode:0o700})
75
+ const env={...process.env,HOME:root,PATH:binDir,EZ_CONTROL_DIR:controlDir,EZ_ISOLATION:'isolated',EZ_EXECUTOR_TRANSPORT:'local'}
76
+ const bin=fileURLToPath(new URL('../bin/ezenciel-agents-ai.mjs',import.meta.url))
77
+ const result=spawnSync(process.execPath,[bin,'list'],{env,encoding:'utf8'})
78
+ assert.equal(result.status,0,result.stderr)
79
+ const catalog=JSON.parse(result.stdout)
80
+ assert.ok(catalog.some((m:{cli:string;model:string})=>m.cli==='claude'&&m.model==='sonnet'))
81
+ assert.ok(!catalog.some((m:{model:string})=>m.model==='host-only'))
60
82
  }finally{await rm(root,{recursive:true,force:true})}
61
83
  })
package/test/ai.test.ts CHANGED
@@ -631,7 +631,7 @@ test('private channel model choices do not consume owner preset slots', async ()
631
631
  assert.equal(readback.preset?.model, model.model)
632
632
  assert.equal(readback.preset?.effort, 'low')
633
633
  assert.deepEqual((await store.status()).ai, before)
634
- await assert.rejects(menu.saveSelection(model, 'max'), /at most 12/)
634
+ assert.equal((await menu.saveSelection(model, 'max')).effort, 'max')
635
635
  await assert.rejects(store.changeApplicationSession(scope, {...guard, expectedSession:null}, preset), /Conversation changed/)
636
636
  assert.deepEqual((await store.applicationSession(scope))?.preset, readback.preset)
637
637
  } finally { await rm(dir, {recursive:true, force:true}) }
@@ -648,7 +648,29 @@ test('detected presets do not count against the saved AI cap', async () => {
648
648
  assert.equal((await store.status()).ai!.presets.length, 13)
649
649
  const next = {id:'one-more', name:'One more', cli:'claude', model:'sonnet', effort:'medium'}
650
650
  await store.savePreset(next)
651
- await assert.rejects(store.savePreset({...next, id:'too-many', name:'Too many'}), /at most 12/)
651
+ await store.savePreset({...next, id:'one-past-cap', name:'One past cap'})
652
+ const ids = (await store.status()).ai!.presets.map(p => p.id)
653
+ assert.equal(ids.filter(id => !id.startsWith('detected_')).length, 12)
654
+ assert.ok(!ids.includes('saved-1') && ids.includes('one-past-cap') && ids.includes(initial.id))
655
+ } finally { await rm(dir, {recursive:true, force:true}) }
656
+ })
657
+
658
+ test('a new model choice at the saved AI cap replaces the oldest unused choice', async () => {
659
+ const dir = await mkdtemp(join(tmpdir(), 'ez-ai-cap-select-'))
660
+ try {
661
+ const store = new ControlStore(dir, 1000)
662
+ const initial = initialPreset('claude')
663
+ await store.captureChoice(initial)
664
+ for (let n = 1; n < 12; n++) await store.savePreset({...initial, id:`saved-${n}`, name:`Saved ${n}`, model:'opus', effort:n % 2 ? 'high' : 'xhigh'})
665
+ await store.selectPreset('saved-1', (await store.getActiveSession())?.sessionId ?? null)
666
+ const model = {cli:'claude', model:'sonnet', name:'sonnet', efforts:['low', 'high']}
667
+ const menu = createAiMenu(store, 'claude', async () => [model], dir, undefined, async () => true)
668
+ const preset = await menu.saveSelection(model, 'high')
669
+ await menu.select(preset, (await store.getActiveSession())?.sessionId ?? null)
670
+ const ai = (await store.status()).ai!
671
+ assert.equal(ai.presets.find(p => p.id === ai.selectedId)?.model, 'sonnet')
672
+ assert.equal(ai.presets.length, 12)
673
+ assert.ok(ai.presets.some(p => p.id === 'saved-1') && !ai.presets.some(p => p.id === 'saved-2'))
652
674
  } finally { await rm(dir, {recursive:true, force:true}) }
653
675
  })
654
676
 
@@ -1,6 +1,6 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import { mkdtemp, rm, unlink } from 'node:fs/promises'
3
+ import { mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
  import { createConnection } from 'node:net'
@@ -64,9 +64,48 @@ test('a response timeout is not replayed over the loopback endpoint', async t =>
64
64
  const dir = await mkdtemp(join(tmpdir(), 'ez-delivery-timeout-'))
65
65
  t.after(() => rm(dir, { recursive: true, force: true }))
66
66
  let calls = 0
67
- const server = await serveDeliverySocket(dir, async () => { calls++; return new Promise(() => {}) }, { tcpPort: 0 })
67
+ const server = await serveDeliverySocket(dir, async () => { calls++; await new Promise(resolve=>setTimeout(resolve,300)); return {accepted:true} }, { tcpPort: 0 })
68
68
  t.after(() => server.stop())
69
69
  assert.ok(server.endpoint)
70
70
  await assert.rejects(callDeliverySocket(deliverySocketPath(dir), { op: 'enqueue' }, 200), /Delivery relay unavailable/)
71
71
  assert.equal(calls, 1)
72
72
  })
73
+
74
+ test('shutdown waits for an admitted enqueue before the final delivery drain',async t=>{
75
+ const dir=await mkdtemp(join(tmpdir(),'ez-delivery-admission-'))
76
+ t.after(()=>rm(dir,{recursive:true,force:true}))
77
+ let release!:()=>void,entered!:()=>void,accepted=0,drained=0
78
+ const gate=new Promise<void>(resolve=>{release=resolve}),admitted=new Promise<void>(resolve=>{entered=resolve})
79
+ const server=await serveDeliverySocket(dir,async()=>{entered();await gate;accepted++;return {accepted:true}},{beforeClose:async()=>{drained=accepted}})
80
+ const request=callDeliverySocket(deliverySocketPath(dir),{op:'enqueue'})
81
+ await admitted
82
+ let stopped=false
83
+ const stop=server.stop().then(()=>{stopped=true})
84
+ await new Promise(resolve=>setTimeout(resolve,50))
85
+ assert.equal(stopped,false)
86
+ assert.equal(drained,0)
87
+ release()
88
+ assert.deepEqual(await request,{accepted:true})
89
+ await stop
90
+ assert.equal(drained,1)
91
+ })
92
+
93
+
94
+ test('a swap connection failure waits for the relay but an accepted timeout is never retried',async t=>{
95
+ const dir=await mkdtemp(join(tmpdir(),'ez-delivery-swap-'))
96
+ t.after(()=>rm(dir,{recursive:true,force:true}))
97
+ await writeFile(join(dir,'upgrade-pause.json'),'{}')
98
+ const waiting=callDeliverySocket(deliverySocketPath(dir),{op:'enqueue'},1000)
99
+ await new Promise(r=>setTimeout(r,300))
100
+ let accepted=0
101
+ const server=await serveDeliverySocket(dir,async()=>({accepted:++accepted}))
102
+ t.after(()=>server.stop())
103
+ assert.deepEqual(await waiting,{accepted:1})
104
+ assert.equal(accepted,1)
105
+ await server.stop()
106
+ let attempted=0
107
+ const hanging=await serveDeliverySocket(dir,async()=>{attempted++;await new Promise(r=>setTimeout(r,200));return {accepted:true}})
108
+ t.after(()=>hanging.stop())
109
+ await assert.rejects(callDeliverySocket(deliverySocketPath(dir),{op:'enqueue'},40),/unavailable/)
110
+ assert.equal(attempted,1)
111
+ })
@@ -2,11 +2,11 @@ import { ownerRun } from './helpers/owner-run.js'
2
2
  import { RunStore } from '../src/runs.js'
3
3
  import assert from 'node:assert/strict'
4
4
  import test from 'node:test'
5
- import { mkdtemp, mkdir, rm, writeFile, readFile } from 'node:fs/promises'
5
+ import { mkdtemp, mkdir, rm, writeFile, readFile, rename, stat } from 'node:fs/promises'
6
6
  import { spawn } from 'node:child_process'
7
7
  import { tmpdir } from 'node:os'
8
8
  import path from 'node:path'
9
- import { EXECUTOR_REGISTRY, executorEnvironment, grokInvocation, grokJobEnv, opencodeDataHome, opencodeInvocation, piAgentDir, resolveExecutor, resolveHostCommand, startExecutorJob, terminateJob, validateCodexProvider } from '../src/executor.js'
9
+ import { EXECUTOR_REGISTRY, attachHostJob, executorEnvironment, grokInvocation, grokJobEnv, opencodeDataHome, opencodeInvocation, piAgentDir, resolveExecutor, resolveHostCommand, startExecutorJob, terminateJob, validateCodexProvider } from '../src/executor.js'
10
10
  import { requireOwnerExecution } from '../src/execution-authority.js'
11
11
  import { splitTelegramText } from '../src/reply.js'
12
12
  import { matchingProcessIds, processSnapshot } from '../src/process-tree.js'
@@ -139,8 +139,10 @@ test('host transport does not throw when selecting the desktop adapter', async (
139
139
  workspace: root, controlDir, binDir: path.join(root, 'bin'), cli: 'codex-gui', runId: 'r_hostgui', timeoutMs: 1500,
140
140
  })
141
141
  assert.ok(job.child.pid)
142
- terminateJob(job.child)
143
- await new Promise((resolve) => job.child.once('close', resolve))
142
+ const closed = new Promise((resolve) => job.child.once('close', resolve))
143
+ await terminateJob(job.child)
144
+ await closed
145
+ assert.equal(await readFile(path.join(controlDir,'host-executor/r_hostgui.cancel'),'utf8'),'')
144
146
  await job.cleanup()
145
147
  } finally {
146
148
  if (prior === undefined) delete process.env.EZ_EXECUTOR_TRANSPORT
@@ -149,6 +151,32 @@ test('host transport does not throw when selecting the desktop adapter', async (
149
151
  }
150
152
  })
151
153
 
154
+ test('host cancellation persists before a stalled observer receives a signal', {skip:process.platform==='win32'}, async () => {
155
+ const root=await mkdtemp(path.join(tmpdir(),'ez-cancel-observer-'))
156
+ const directory=path.join(root,'host-executor'),id='r_stalled'
157
+ await mkdir(directory)
158
+ await assert.rejects(attachHostJob(root,'../escape'),/Invalid host executor run ID/)
159
+ await writeFile(path.join(directory,id+'.running.json'),'{}')
160
+ const child=await attachHostJob(root,id)
161
+ const closed=new Promise(resolve=>child.once('close',resolve))
162
+ child.kill('SIGSTOP')
163
+ try {
164
+ await rename(directory,directory+'.saved')
165
+ await writeFile(directory,'Blocked transport directory')
166
+ assert.equal(await terminateJob(child),false)
167
+ assert.equal(child.exitCode,null)
168
+ assert.equal(child.signalCode,null)
169
+ await assert.rejects(readFile(path.join(directory+'.saved',id+'.cancel')),{code:'ENOENT'})
170
+ await rm(directory);await rename(directory+'.saved',directory)
171
+ assert.equal(await terminateJob(child),true)
172
+ assert.equal(await readFile(path.join(directory,id+'.cancel'),'utf8'),'')
173
+ assert.equal((await stat(path.join(directory,id+'.cancel'))).mode&0o777,0o600)
174
+ } finally {
175
+ child.kill('SIGCONT');child.kill('SIGKILL')
176
+ await closed;await rm(root,{recursive:true,force:true})
177
+ }
178
+ })
179
+
152
180
  test('Codex jobs disable global memory and host skill discovery', () => {
153
181
  const args = EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent'},'/prompt','hello')
154
182
  assert.ok(args.includes('memories'))
@@ -8,7 +8,7 @@ import { serveHostExecutor, refreshAgentCatalog } from '../src/host-executor.js'
8
8
  import { spawn } from 'node:child_process'
9
9
  import { fileURLToPath } from 'node:url'
10
10
  import { isHostRunId } from '../src/host-executor-protocol.js'
11
- import { EXECUTOR_REGISTRY } from '../src/executor.js'
11
+ import { EXECUTOR_REGISTRY, attachHostJob, terminateJob } from '../src/executor.js'
12
12
  import { RunStore } from '../src/runs.js'
13
13
  import { packageVersion } from '../src/version.js'
14
14
  import { executionDefaults } from '../src/model-policy.js'
@@ -294,11 +294,17 @@ test('one installed CLI executes two agent bindings with separate minds and sani
294
294
  assert.equal(await workspaceLease(agents[0].toolsHome),undefined)
295
295
  await ownerRun(agents[0].controlDir,'tg_43')
296
296
  await writeFile(path.join(directory,'tg_43.request.json'),JSON.stringify({texts:['Hold chat'],options:{cli:'grok'}}))
297
- for(let n=0;n<100;n++){try{await readFile(path.join(directory,'tg_43.process.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
297
+ for(let n=0;n<100;n++){try{if(JSON.parse(await readFile(path.join(directory,'tg_43.process.json'),'utf8')).pid)break}catch{await new Promise(r=>setTimeout(r,20))}}
298
298
  await readFile(path.join(directory,'tg_43.running.json'))
299
299
  assert.doesNotMatch(await readFile(path.join(directory,'r_hold.events'),'utf8'),/"stream":"exit"/)
300
300
  await readFile(path.join(directory,'r_hold.running.json'))
301
- await writeFile(path.join(directory,'r_hold.cancel'),'')
301
+ // An observer that cannot handle signals must still stop the native run.
302
+ const observer=await attachHostJob(agents[0].controlDir,'r_hold')
303
+ const observerClosed=new Promise(resolve=>observer.once('close',resolve))
304
+ observer.stdout?.resume();observer.stderr?.resume()
305
+ observer.kill('SIGSTOP')
306
+ try { assert.equal(await terminateJob(observer),true) }
307
+ finally { observer.kill('SIGCONT');observer.kill('SIGKILL');await observerClosed }
302
308
  let heldOutput=''
303
309
  for(let n=0;n<200;n++){heldOutput=await readFile(path.join(directory,'r_hold.events'),'utf8');if(heldOutput.includes('"stream":"exit"'))break;await new Promise(r=>setTimeout(r,20))}
304
310
  assert.match(heldOutput, /"stream":"exit","code":1/)
@@ -568,3 +574,64 @@ test('host transport runs a registered script from the run record and refuses ch
568
574
  await rm(root,{recursive:true,force:true})
569
575
  }
570
576
  })
577
+
578
+ test('a native process that settles during launch records completion without hanging the worker',async t=>{
579
+ const root=await mkdtemp(path.join(tmpdir(),'ez-fast-native-')),workspace=path.join(root,'mind'),controlDir=path.join(root,'control')
580
+ await mkdir(workspace);await mkdir(controlDir)
581
+ const abort=new AbortController(),directory=path.join(controlDir,'host-executor')
582
+ const launch=async()=>{
583
+ const child=spawn(process.execPath,['-e','process.exit(0)'],{stdio:['pipe','pipe','pipe']})
584
+ await new Promise(resolve=>child.once('close',resolve))
585
+ return {child,cleanup:async()=>{},stdout:''}
586
+ }
587
+ const server=serveHostExecutor({cli:'grok',agents:[{name:'test',workspace,controlDir,binDir:root}]},abort.signal,launch,async()=>[])
588
+ t.after(async()=>{abort.abort();await server;await rm(root,{recursive:true,force:true})})
589
+ for(let n=0;n<300;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
590
+ await ownerRun(controlDir,'r_fast_native')
591
+ await writeFile(path.join(directory,'r_fast_native.request.json'),JSON.stringify({texts:['fast'],options:{cli:'grok'}}))
592
+ let events=''
593
+ for(let n=0;n<300;n++){try{events=await readFile(path.join(directory,'r_fast_native.events'),'utf8');if(events.includes('"stream":"exit"'))break}catch{}await new Promise(r=>setTimeout(r,20))}
594
+ assert.match(events,/"stream":"exit","code":0/)
595
+ })
596
+
597
+ test('retiring native generation preserves its run while the replacement claims new work once',async t=>{
598
+ const root=await mkdtemp(path.join(tmpdir(),'ez-host-generation-'))
599
+ const workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),toolsHome=path.join(root,'tools')
600
+ await mkdir(workspace);await mkdir(toolsHome);await mkdir(controlDir)
601
+ await writeFile(path.join(toolsHome,'config.json'),JSON.stringify({schemaVersion:1,workspace:await realpath(workspace)}))
602
+ const agent={name:'qa',workspace,controlDir,toolsHome,binDir:root},installation={cli:'grok',agents:[agent]}
603
+ const stopA=new AbortController(),stopB=new AbortController(),retireA=new AbortController()
604
+ const children=new Map<string,ReturnType<typeof spawn>>(),launched:string[]=[]
605
+ const launch=async(_texts:string[],options:any)=>{
606
+ launched.push(options.runId)
607
+ const child=spawn(process.execPath,['-e',"process.stdin.resume();process.stdin.once('data',()=>process.exit(0));"],{stdio:['pipe','pipe','pipe']})
608
+ children.set(options.runId,child)
609
+ return {child,cleanup:async()=>{},stdout:''}
610
+ }
611
+ let a:Promise<void>|undefined,b:Promise<void>|undefined
612
+ t.after(async()=>{stopA.abort();stopB.abort();for(const child of children.values())child.stdin?.end('done');await Promise.all([a,b]);await rm(root,{recursive:true,force:true})})
613
+ const directory=path.join(controlDir,'host-executor')
614
+ const wait=async(check:()=>Promise<boolean>)=>{for(let n=0;n<300;n++){if(await check())return;await new Promise(r=>setTimeout(r,20))}assert.fail('generation did not reach expected state')}
615
+ const exists=(file:string)=>readFile(file).then(()=>true,()=>false)
616
+ a=serveHostExecutor(installation,stopA.signal,launch,async()=>[],retireA.signal)
617
+ await wait(()=>exists(path.join(directory,'heartbeat.json')))
618
+ await ownerRun(controlDir,'r_old_generation')
619
+ await writeFile(path.join(directory,'r_old_generation.request.json'),JSON.stringify({texts:['old'],options:{cli:'grok'}}))
620
+ await wait(async()=>children.has('r_old_generation'))
621
+ retireA.abort()
622
+ await wait(async()=>!await exists(path.join(directory,'worker.lock')))
623
+ assert.equal(children.get('r_old_generation')!.exitCode,null)
624
+ b=serveHostExecutor(installation,stopB.signal,launch,async()=>[])
625
+ await wait(()=>exists(path.join(directory,'worker.lock')))
626
+ await ownerRun(controlDir,'r_new_generation')
627
+ await writeFile(path.join(directory,'r_new_generation.request.json'),JSON.stringify({texts:['new'],options:{cli:'grok'}}))
628
+ await wait(async()=>children.has('r_new_generation'))
629
+ assert.deepEqual(launched,['r_old_generation','r_new_generation'])
630
+ children.get('r_old_generation')!.stdin!.end('done')
631
+ await a
632
+ assert.ok(await exists(path.join(directory,'worker.lock')),'retiring host must not delete replacement lock')
633
+ assert.match(await readFile(path.join(directory,'r_old_generation.events'),'utf8'),/"stream":"exit","code":0/)
634
+ children.get('r_new_generation')!.stdin!.end('done')
635
+ await wait(async()=>(await readFile(path.join(directory,'r_new_generation.events'),'utf8')).includes('"stream":"exit"'))
636
+ assert.match(await readFile(path.join(directory,'r_new_generation.events'),'utf8'),/"stream":"exit","code":0/)
637
+ })