@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.
- package/CHANGELOG.md +17 -0
- package/docker/isolated-executor-smoke.mjs +35 -0
- package/docs/docker-runtime.md +12 -2
- package/docs/releasing.md +1 -0
- package/docs/scheduling.md +7 -4
- package/docs/upgrades.md +20 -2
- package/package.json +4 -2
- package/src/ai-cli.ts +4 -2
- package/src/control-state.ts +10 -4
- package/src/delivery-socket-client.mjs +15 -1
- package/src/delivery-socket.ts +18 -4
- package/src/executor.ts +54 -10
- package/src/host-executor-client.ts +4 -1
- package/src/host-executor.ts +63 -32
- package/src/index.ts +37 -19
- package/src/plugins/workspace-lease.mjs +1 -1
- package/src/runs.ts +8 -2
- package/src/schedule-cli.ts +6 -3
- package/src/scheduled-tasks.ts +12 -10
- package/src/scheduler.ts +6 -7
- package/src/updates/runtime.mjs +27 -10
- package/src/updates/supervisor.mjs +137 -18
- package/test/ai-cli.test.ts +23 -1
- package/test/ai.test.ts +24 -2
- package/test/delivery-transport.test.ts +41 -2
- package/test/executor.test.ts +32 -4
- package/test/host-executor.test.ts +70 -3
- package/test/intake-relay.test.ts +8 -1
- package/test/plugin-manager.test.mjs +1 -1
- package/test/run-handoff.test.ts +4 -0
- package/test/schedule-cli.test.ts +24 -1
- package/test/scheduled-tasks.test.ts +23 -15
- package/test/updates.test.mjs +64 -3
package/src/updates/runtime.mjs
CHANGED
|
@@ -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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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||'')&¤t.Image===pinned&¤t.State?.Running===true&¤t.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')
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
151
|
-
if(pending.target==='main')
|
|
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();
|
|
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
|
}
|
package/test/ai-cli.test.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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++;
|
|
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
|
+
})
|
package/test/executor.test.ts
CHANGED
|
@@ -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
|
-
|
|
143
|
-
await
|
|
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'))
|
|
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
|
-
|
|
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
|
+
})
|